From e9c0ee8ed44870591bca638f6c6d92f85cd7a144 Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Mon, 4 Dec 2023 16:26:46 +0200 Subject: [PATCH 1/9] Completely abstract the filesystem --- Makefile | 5 + commands/platformify.go | 32 +- commands/validate.go | 2 +- internal/question/application_root.go | 21 +- internal/question/application_root_test.go | 92 ++ internal/question/build_steps.go | 11 +- internal/question/build_steps_test.go | 8 +- internal/question/dependency_manager.go | 12 +- internal/question/dependency_manager_test.go | 71 + internal/question/deploy_command.go | 5 +- internal/question/files_overwrite.go | 13 +- internal/question/locations.go | 14 +- internal/question/models/answer.go | 8 +- internal/question/models/registry.go | 36 + internal/question/models/registry.json | 1266 ++++++++++++++++++ internal/question/models/runtime.go | 98 +- internal/question/models/service_name.go | 119 +- internal/question/models/stack.go | 23 +- internal/question/models/stack_test.go | 32 + internal/question/models/version.go | 43 - internal/question/name.go | 2 +- internal/question/services.go | 9 +- internal/question/socket_family.go | 6 +- internal/question/stack.go | 106 +- internal/question/stack_test.go | 87 ++ internal/question/type.go | 6 +- internal/question/web_command.go | 22 +- internal/question/working_directory.go | 6 +- internal/utils/utils.go | 46 +- platformifier/django.go | 65 +- platformifier/django_test.go | 123 -- platformifier/fs.go | 90 -- platformifier/fs_mock_test.go | 80 -- platformifier/generic.go | 24 +- platformifier/generic_test.go | 173 --- platformifier/laravel.go | 21 +- platformifier/nextjs.go | 20 - platformifier/platformifier.go | 37 +- platformifier/platformifier_mock_test.go | 49 - platformifier/platformifier_test.go | 578 -------- validator/validator.go | 24 +- 41 files changed, 1954 insertions(+), 1531 deletions(-) create mode 100644 internal/question/application_root_test.go create mode 100644 internal/question/dependency_manager_test.go create mode 100644 internal/question/models/registry.go create mode 100644 internal/question/models/registry.json create mode 100644 internal/question/models/stack_test.go delete mode 100644 internal/question/models/version.go create mode 100644 internal/question/stack_test.go delete mode 100644 platformifier/django_test.go delete mode 100644 platformifier/fs.go delete mode 100644 platformifier/fs_mock_test.go delete mode 100644 platformifier/generic_test.go delete mode 100644 platformifier/nextjs.go delete mode 100644 platformifier/platformifier_mock_test.go delete mode 100644 platformifier/platformifier_test.go diff --git a/Makefile b/Makefile index 50655eb..912d1bb 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,11 @@ lint: ## Run linter command -v golangci-lint >/dev/null || go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.59 golangci-lint run --timeout=10m --verbose +.PHONY: lint-fix +lint-fix: ## Run linter fixes + command -v golangci-lint >/dev/null || go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52 + golangci-lint run --fix + .PHONY: generate generate: ## Generate mock data command -v mockgen >/dev/null || go install github.com/golang/mock/mockgen@latest diff --git a/commands/platformify.go b/commands/platformify.go index 88a7711..dc0c27a 100644 --- a/commands/platformify.go +++ b/commands/platformify.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io" + "os" + "path" "github.com/spf13/cobra" @@ -46,7 +48,6 @@ func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendoriz ) q := questionnaire.New( &question.WorkingDirectory{}, - &question.FilesOverwrite{}, &question.Welcome{}, &question.Stack{}, &question.Type{}, @@ -69,19 +70,42 @@ func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendoriz } if err != nil { - fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error())) + fmt.Fprintln(cmd.ErrOrStderr(), colors.Colorize(colors.ErrorCode, err.Error())) return err } input := answers.ToUserInput() pfier := platformifier.New(input, assets.ConfigFlavor) - err = pfier.Platformify(ctx) + configFiles, err := pfier.Platformify(ctx) if err != nil { - fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error())) + fmt.Fprintln(cmd.ErrOrStderr(), colors.Colorize(colors.ErrorCode, err.Error())) return fmt.Errorf("could not configure project: %w", err) } + filesToCreateUpdate := make([]string, 0, len(configFiles)) + for file := range configFiles { + filesToCreateUpdate = append(filesToCreateUpdate, file) + } + + filesOverwrite := question.FilesOverwrite{FilesToCreateUpdate: filesToCreateUpdate} + if err := filesOverwrite.Ask(ctx); err != nil { + return err + } + + for file, contents := range configFiles { + filePath := path.Join(answers.Cwd, file) + if err := os.MkdirAll(path.Dir(filePath), os.ModeDir|os.ModePerm); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Could not create parent directories of file %s: %s\n", file, err) + continue + } + + if err := os.WriteFile(filePath, contents, 0o664); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Could not write file %s: %s\n", file, err) + continue + } + } + done := question.Done{} return done.Ask(ctx) } diff --git a/commands/validate.go b/commands/validate.go index c00667e..5fe4812 100644 --- a/commands/validate.go +++ b/commands/validate.go @@ -36,7 +36,7 @@ func NewValidateCommand(assets *vendorization.VendorAssets) *cobra.Command { return err } - if err = validator.ValidateConfig(cwd, assets.ConfigFlavor); err != nil { + if err = validator.ValidateConfig(os.DirFS(cwd), assets.ConfigFlavor); err != nil { fmt.Fprintf( cmd.ErrOrStderr(), colors.Colorize( diff --git a/internal/question/application_root.go b/internal/question/application_root.go index 7a13c08..f1c8651 100644 --- a/internal/question/application_root.go +++ b/internal/question/application_root.go @@ -3,7 +3,6 @@ package question import ( "context" "path" - "path/filepath" "github.com/platformsh/platformify/internal/question/models" "github.com/platformsh/platformify/internal/utils" @@ -20,28 +19,28 @@ func (q *ApplicationRoot) Ask(ctx context.Context) error { for _, dm := range answers.DependencyManagers { switch dm { case models.Composer: - if composerPath := utils.FindFile(answers.WorkingDirectory, "composer.json"); composerPath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(composerPath)) + if composerPath := utils.FindFile(answers.WorkingDirectory, "", "composer.json"); composerPath != "" { + answers.ApplicationRoot = path.Dir(composerPath) return nil } case models.Npm, models.Yarn: - if packagePath := utils.FindFile(answers.WorkingDirectory, "package.json"); packagePath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(packagePath)) + if packagePath := utils.FindFile(answers.WorkingDirectory, "", "package.json"); packagePath != "" { + answers.ApplicationRoot = path.Dir(packagePath) return nil } case models.Poetry: - if pyProjectPath := utils.FindFile(answers.WorkingDirectory, "pyproject.toml"); pyProjectPath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(pyProjectPath)) + if pyProjectPath := utils.FindFile(answers.WorkingDirectory, "", "pyproject.toml"); pyProjectPath != "" { + answers.ApplicationRoot = path.Dir(pyProjectPath) return nil } case models.Pipenv: - if pipfilePath := utils.FindFile(answers.WorkingDirectory, "Pipfile"); pipfilePath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(pipfilePath)) + if pipfilePath := utils.FindFile(answers.WorkingDirectory, "", "Pipfile"); pipfilePath != "" { + answers.ApplicationRoot = path.Dir(pipfilePath) return nil } case models.Pip: - if requirementsPath := utils.FindFile(answers.WorkingDirectory, "requirements.txt"); requirementsPath != "" { - answers.ApplicationRoot, _ = filepath.Rel(answers.WorkingDirectory, path.Dir(requirementsPath)) + if requirementsPath := utils.FindFile(answers.WorkingDirectory, "", "requirements.txt"); requirementsPath != "" { + answers.ApplicationRoot = path.Dir(requirementsPath) return nil } } diff --git a/internal/question/application_root_test.go b/internal/question/application_root_test.go new file mode 100644 index 0000000..5d8d873 --- /dev/null +++ b/internal/question/application_root_test.go @@ -0,0 +1,92 @@ +package question + +import ( + "context" + "io/fs" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/internal/question/models" +) + +func TestApplicationRoot_Ask(t *testing.T) { + tests := []struct { + name string + fileSystem fs.FS + dependencyManagers []models.DepManager + want string + wantErr bool + }{ + { + name: "Requirements.txt root", + fileSystem: fstest.MapFS{ + "requirements.txt": &fstest.MapFile{}, + }, + dependencyManagers: []models.DepManager{models.Pip}, + want: ".", + wantErr: false, + }, + { + name: "Requirements.txt subdir", + fileSystem: fstest.MapFS{ + "sub/requirements.txt": &fstest.MapFile{}, + }, + dependencyManagers: []models.DepManager{models.Pip}, + want: "sub", + wantErr: false, + }, + { + name: "Package.json root", + fileSystem: fstest.MapFS{ + "package.json": &fstest.MapFile{}, + }, + dependencyManagers: []models.DepManager{models.Yarn}, + want: ".", + wantErr: false, + }, + { + name: "Package.json subdir", + fileSystem: fstest.MapFS{ + "sub/package.json": &fstest.MapFile{}, + }, + dependencyManagers: []models.DepManager{models.Npm}, + want: "sub", + wantErr: false, + }, + { + name: "pyproject.toml subdir", + fileSystem: fstest.MapFS{ + "sub/pyproject.toml": &fstest.MapFile{}, + }, + dependencyManagers: []models.DepManager{models.Poetry}, + want: "sub", + wantErr: false, + }, + { + name: "Pipfile subdir", + fileSystem: fstest.MapFS{ + "sub/Pipfile": &fstest.MapFile{}, + }, + dependencyManagers: []models.DepManager{models.Pipenv}, + want: "sub", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := &ApplicationRoot{} + a := models.NewAnswers() + a.WorkingDirectory = tt.fileSystem + a.DependencyManagers = tt.dependencyManagers + ctx := models.ToContext(context.Background(), a) + + if err := q.Ask(ctx); (err != nil) != tt.wantErr { + t.Errorf("ApplicationRoot.Ask() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && a.ApplicationRoot != tt.want { + t.Errorf("ApplicationRoot.Ask().ApplicationRoot = %s, want %s", a.ApplicationRoot, tt.want) + } + }) + } +} diff --git a/internal/question/build_steps.go b/internal/question/build_steps.go index 5a3529d..c78e828 100644 --- a/internal/question/build_steps.go +++ b/internal/question/build_steps.go @@ -3,8 +3,6 @@ package question import ( "context" "fmt" - "path" - "path/filepath" "slices" "github.com/platformsh/platformify/internal/question/models" @@ -48,7 +46,7 @@ func (q *BuildSteps) Ask(ctx context.Context) error { "pip install -r requirements.txt", ) case models.Yarn, models.Npm: - if answers.Type.Runtime != models.NodeJS { + if answers.Type.Runtime.Type != "nodejs" { if _, ok := answers.Dependencies["nodejs"]; !ok { answers.Dependencies["nodejs"] = map[string]string{} } @@ -74,8 +72,9 @@ func (q *BuildSteps) Ask(ctx context.Context) error { ) } if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, []string{"scripts", "build"}, - path.Join(answers.WorkingDirectory, "package.json"), + "package.json", true, ); ok { if dm == models.Yarn { @@ -100,7 +99,8 @@ func (q *BuildSteps) Ask(ctx context.Context) error { switch answers.Stack { case models.Django: if managePyPath := utils.FindFile( - path.Join(answers.WorkingDirectory, answers.ApplicationRoot), + answers.WorkingDirectory, + answers.ApplicationRoot, managePyFile, ); managePyPath != "" { prefix := "" @@ -110,7 +110,6 @@ func (q *BuildSteps) Ask(ctx context.Context) error { prefix = "poetry run " } - managePyPath, _ = filepath.Rel(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyPath) assets, _ := vendorization.FromContext(ctx) answers.BuildSteps = append( answers.BuildSteps, diff --git a/internal/question/build_steps_test.go b/internal/question/build_steps_test.go index 302970d..674e193 100644 --- a/internal/question/build_steps_test.go +++ b/internal/question/build_steps_test.go @@ -4,6 +4,7 @@ import ( "context" "reflect" "testing" + "testing/fstest" "github.com/platformsh/platformify/internal/question/models" ) @@ -12,6 +13,7 @@ func TestBuildSteps_Ask(t *testing.T) { type args struct { answers models.Answers } + nodeJS, _ := models.Runtimes.RuntimeByType("nodejs") tests := []struct { name string q *BuildSteps @@ -24,10 +26,11 @@ func TestBuildSteps_Ask(t *testing.T) { q: &BuildSteps{}, args: args{models.Answers{ Stack: models.NextJS, - Type: models.RuntimeType{Runtime: models.NodeJS, Version: "20.0"}, + Type: models.RuntimeType{Runtime: *nodeJS, Version: "20.0"}, Dependencies: map[string]map[string]string{}, DependencyManagers: []models.DepManager{models.Yarn}, Environment: map[string]string{}, + WorkingDirectory: fstest.MapFS{}, }}, buildSteps: []string{"yarn", "yarn exec next build"}, wantErr: false, @@ -37,10 +40,11 @@ func TestBuildSteps_Ask(t *testing.T) { q: &BuildSteps{}, args: args{models.Answers{ Stack: models.NextJS, - Type: models.RuntimeType{Runtime: models.NodeJS, Version: "20.0"}, + Type: models.RuntimeType{Runtime: *nodeJS, Version: "20.0"}, Dependencies: map[string]map[string]string{}, DependencyManagers: []models.DepManager{models.Npm}, Environment: map[string]string{}, + WorkingDirectory: fstest.MapFS{}, }}, buildSteps: []string{"npm i", "npm exec next build"}, wantErr: false, diff --git a/internal/question/dependency_manager.go b/internal/question/dependency_manager.go index 22a4526..4d166e5 100644 --- a/internal/question/dependency_manager.go +++ b/internal/question/dependency_manager.go @@ -58,23 +58,23 @@ func (q *DependencyManager) Ask(ctx context.Context) error { } }() - if exists := utils.FileExists(answers.WorkingDirectory, poetryLockFile); exists { + if exists := utils.FileExists(answers.WorkingDirectory, "", poetryLockFile); exists { answers.DependencyManagers = append(answers.DependencyManagers, models.Poetry) - } else if exists := utils.FileExists(answers.WorkingDirectory, pipenvLockFile); exists { + } else if exists := utils.FileExists(answers.WorkingDirectory, "", pipenvLockFile); exists { answers.DependencyManagers = append(answers.DependencyManagers, models.Pipenv) - } else if exists := utils.FileExists(answers.WorkingDirectory, pipLockFile); exists { + } else if exists := utils.FileExists(answers.WorkingDirectory, "", pipLockFile); exists { answers.DependencyManagers = append(answers.DependencyManagers, models.Pip) } - if exists := utils.FileExists(answers.WorkingDirectory, composerLockFile); exists { + if exists := utils.FileExists(answers.WorkingDirectory, "", composerLockFile); exists { answers.DependencyManagers = append(answers.DependencyManagers, models.Composer) answers.Dependencies["php"] = map[string]string{"composer/composer": "^2"} } - if exists := utils.FileExists(answers.WorkingDirectory, yarnLockFileName); exists { + if exists := utils.FileExists(answers.WorkingDirectory, "", yarnLockFileName); exists { answers.DependencyManagers = append(answers.DependencyManagers, models.Yarn) answers.Dependencies["nodejs"] = map[string]string{"yarn": "^1.22.0"} - } else if exists := utils.FileExists(answers.WorkingDirectory, npmLockFileName); exists { + } else if exists := utils.FileExists(answers.WorkingDirectory, "", npmLockFileName); exists { answers.DependencyManagers = append(answers.DependencyManagers, models.Npm) } diff --git a/internal/question/dependency_manager_test.go b/internal/question/dependency_manager_test.go new file mode 100644 index 0000000..57446bc --- /dev/null +++ b/internal/question/dependency_manager_test.go @@ -0,0 +1,71 @@ +package question + +import ( + "context" + "io/fs" + "slices" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/internal/question/models" +) + +func TestDependencyManager_Ask(t *testing.T) { + tests := []struct { + fileSystem fs.FS + want models.DepManager + }{ + {fileSystem: fstest.MapFS{"package-lock.json": &fstest.MapFile{}}, want: models.Npm}, + {fileSystem: fstest.MapFS{"yarn.lock": &fstest.MapFile{}}, want: models.Yarn}, + {fileSystem: fstest.MapFS{"poetry.lock": &fstest.MapFile{}}, want: models.Poetry}, + {fileSystem: fstest.MapFS{"Pipfile.lock": &fstest.MapFile{}}, want: models.Pipenv}, + {fileSystem: fstest.MapFS{"composer.lock": &fstest.MapFile{}}, want: models.Composer}, + {fileSystem: fstest.MapFS{"requirements.txt": &fstest.MapFile{}}, want: models.Pip}, + + // Verify sub-directories + {fileSystem: fstest.MapFS{"sub/package-lock.json": &fstest.MapFile{}}, want: models.Npm}, + {fileSystem: fstest.MapFS{"sub/yarn.lock": &fstest.MapFile{}}, want: models.Yarn}, + {fileSystem: fstest.MapFS{"sub/poetry.lock": &fstest.MapFile{}}, want: models.Poetry}, + {fileSystem: fstest.MapFS{"sub/Pipfile.lock": &fstest.MapFile{}}, want: models.Pipenv}, + {fileSystem: fstest.MapFS{"sub/composer.lock": &fstest.MapFile{}}, want: models.Composer}, + {fileSystem: fstest.MapFS{"sub/requirements.txt": &fstest.MapFile{}}, want: models.Pip}, + + // Verify multiple examples + {fileSystem: fstest.MapFS{ + "sub/package-lock.json": &fstest.MapFile{}, + "sub/yarn.lock": &fstest.MapFile{}, + "sub/poetry.lock": &fstest.MapFile{}, + "sub/Pipfile.lock": &fstest.MapFile{}, + "sub/composer.lock": &fstest.MapFile{}, + "sub/requirements.txt": &fstest.MapFile{}, + }, want: models.Composer}, + {fileSystem: fstest.MapFS{ + "sub/package-lock.json": &fstest.MapFile{}, + "sub/yarn.lock": &fstest.MapFile{}, + "sub/poetry.lock": &fstest.MapFile{}, + "sub/Pipfile.lock": &fstest.MapFile{}, + "sub/composer.lock": &fstest.MapFile{}, + "sub/requirements.txt": &fstest.MapFile{}, + }, want: models.Yarn}, + } + for _, tt := range tests { + t.Run(tt.want.Title(), func(t *testing.T) { + q := &DependencyManager{} + a := models.NewAnswers() + a.WorkingDirectory = tt.fileSystem + ctx := models.ToContext(context.Background(), a) + + if err := q.Ask(ctx); err != nil { + t.Errorf("DependencyManager.Ask() error = %v", err) + } + + if !slices.Contains(a.DependencyManagers, tt.want) { + t.Errorf( + "DependencyManager.Ask().DependencyManagers = %v, want %s", + a.DependencyManagers, + tt.want.Title(), + ) + } + }) + } +} diff --git a/internal/question/deploy_command.go b/internal/question/deploy_command.go index b7c6fe3..fcb4c00 100644 --- a/internal/question/deploy_command.go +++ b/internal/question/deploy_command.go @@ -3,7 +3,6 @@ package question import ( "context" "fmt" - "path" "path/filepath" "slices" @@ -21,9 +20,9 @@ func (q *DeployCommand) Ask(ctx context.Context) error { switch answers.Stack { case models.Django: - managePyPath := utils.FindFile(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyFile) + managePyPath := utils.FindFile(answers.WorkingDirectory, answers.ApplicationRoot, managePyFile) if managePyPath != "" { - managePyPath, _ = filepath.Rel(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyPath) + managePyPath, _ = filepath.Rel(answers.ApplicationRoot, managePyPath) prefix := "" if slices.Contains(answers.DependencyManagers, models.Pipenv) { prefix = "pipenv run " diff --git a/internal/question/files_overwrite.go b/internal/question/files_overwrite.go index 0de6f16..00e58d0 100644 --- a/internal/question/files_overwrite.go +++ b/internal/question/files_overwrite.go @@ -3,8 +3,7 @@ package question import ( "context" "fmt" - "os" - "path/filepath" + "io/fs" "github.com/AlecAivazis/survey/v2" @@ -14,7 +13,9 @@ import ( "github.com/platformsh/platformify/vendorization" ) -type FilesOverwrite struct{} +type FilesOverwrite struct { + FilesToCreateUpdate []string +} func (q *FilesOverwrite) Ask(ctx context.Context) error { answers, ok := models.FromContext(ctx) @@ -28,9 +29,9 @@ func (q *FilesOverwrite) Ask(ctx context.Context) error { } assets, _ := vendorization.FromContext(ctx) - existingFiles := make([]string, 0, len(assets.ProprietaryFiles())) - for _, p := range assets.ProprietaryFiles() { - if st, err := os.Stat(filepath.Join(answers.WorkingDirectory, p)); err == nil && !st.IsDir() { + existingFiles := make([]string, 0, len(q.FilesToCreateUpdate)) + for _, p := range q.FilesToCreateUpdate { + if st, err := fs.Stat(answers.WorkingDirectory, p); err == nil && !st.IsDir() { existingFiles = append(existingFiles, p) } } diff --git a/internal/question/locations.go b/internal/question/locations.go index 2546150..ec25110 100644 --- a/internal/question/locations.go +++ b/internal/question/locations.go @@ -15,7 +15,10 @@ func (q *Locations) Ask(ctx context.Context) error { if !ok { return nil } - answers.Locations = make(map[string]map[string]interface{}) + answers.Locations = answers.Type.Runtime.Docs.Locations + if answers.Locations == nil { + answers.Locations = make(map[string]map[string]interface{}) + } switch answers.Stack { case models.Django: answers.Locations["/static"] = map[string]interface{}{ @@ -24,15 +27,14 @@ func (q *Locations) Ask(ctx context.Context) error { "allow": true, } default: - if answers.Type.Runtime == models.PHP { + if answers.Type.Runtime.Type == "php" { locations := map[string]interface{}{ "passthru": "/index.php", "root": "", } - if indexPath := utils.FindFile(answers.WorkingDirectory, "index.php"); indexPath != "" { - indexRelPath, _ := filepath.Rel(answers.WorkingDirectory, indexPath) - if filepath.Dir(indexRelPath) != "." { - locations["root"] = filepath.Dir(indexRelPath) + if indexPath := utils.FindFile(answers.WorkingDirectory, "", "index.php"); indexPath != "" { + if filepath.Dir(indexPath) != "." { + locations["root"] = filepath.Dir(indexPath) } } answers.Locations["/"] = locations diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index 17700ee..08b6417 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -2,6 +2,7 @@ package models import ( "encoding/json" + "io/fs" "os" "path/filepath" "strings" @@ -26,7 +27,8 @@ type Answers struct { Disk string `json:"disk"` Mounts map[string]map[string]string `json:"mounts"` Services []Service `json:"services"` - WorkingDirectory string `json:"working_directory"` + Cwd string `json:"cwd"` + WorkingDirectory fs.FS `json:"working_directory"` HasGit bool `json:"has_git"` FilesCreated []string `json:"files_created"` Locations map[string]map[string]interface{} `json:"locations"` @@ -45,14 +47,14 @@ type RuntimeType struct { Version string } -func (t RuntimeType) String() string { +func (t *RuntimeType) String() string { if t.Version != "" { return t.Runtime.String() + ":" + t.Version } return t.Runtime.String() } -func (t RuntimeType) MarshalJSON() ([]byte, error) { +func (t *RuntimeType) MarshalJSON() ([]byte, error) { return json.Marshal(t.String()) } diff --git a/internal/question/models/registry.go b/internal/question/models/registry.go new file mode 100644 index 0000000..aef1987 --- /dev/null +++ b/internal/question/models/registry.go @@ -0,0 +1,36 @@ +package models + +import ( + _ "embed" + "encoding/json" + "log" +) + +//go:embed registry.json +var registry []byte + +var Runtimes RuntimeList + +var ServiceNames ServiceNameList + +func init() { + allRuntimes := map[string]*Runtime{} + if err := json.Unmarshal(registry, &allRuntimes); err != nil { + log.Fatal(err) + } + for _, r := range allRuntimes { + if r.Runtime { + Runtimes = append(Runtimes, r) + } + } + + allServices := map[string]*ServiceName{} + if err := json.Unmarshal(registry, &allServices); err != nil { + log.Fatal(err) + } + for _, s := range allServices { + if !s.Runtime { + ServiceNames = append(ServiceNames, s) + } + } +} diff --git a/internal/question/models/registry.json b/internal/question/models/registry.json new file mode 100644 index 0000000..3e926e1 --- /dev/null +++ b/internal/question/models/registry.json @@ -0,0 +1,1266 @@ +{ + "chrome-headless": { + "description": "", + "disk": false, + "docs": { + "relationship_name": "chromeheadlessbrowser", + "service_name": "headlessbrowser", + "url": "/add-services/headless-chrome.html" + }, + "endpoint": "http", + "min_disk_size": null, + "name": "Headless Chrome", + "repo_name": "chrome-headless", + "runtime": false, + "type": "chrome-headless", + "versions": { + "deprecated": [], + "supported": [ + "120", + "113", + "95", + "91", + "86", + "84", + "83", + "81", + "80", + "73" + ], + "legacy": [ + "86", + "84", + "83", + "81", + "80", + "73" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "95" + ] + } + }, + "dotnet": { + "description": "ASP.NET 5 application container.", + "repo_name": "dotnet", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/dotnet.html", + "web": { + "commands": { + "start": "dotnet application.dll" + }, + "locations": { + "/": { + "root": "wwwroot", + "allow": true, + "passthru": true + } + } + }, + "hooks": { + "build": [ + "|", + "set -e", + "dotnet publish --output \"$PLATFORM_OUTPUT_DIR\" -p:UseRazorBuildServer=false -p:UseSharedCompilation=false" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "C#/.Net Core", + "runtime": true, + "type": "dotnet", + "versions": { + "deprecated": [ + "5.0", + "3.1", + "2.2", + "2.1", + "2.0" + ], + "supported": [ + "7.0", + "6.0" + ], + "legacy": [ + "3.1", + "2.2", + "2.1", + "2.0" + ] + } + }, + "elasticsearch": { + "description": "A manufacture service for Elasticsearch", + "disk": true, + "docs": { + "relationship_name": "essearch", + "service_name": "searchelastic", + "url": "/add-services/elasticsearch.html" + }, + "endpoint": "elasticsearch", + "min_disk_size": 256, + "name": "Elasticsearch", + "repo_name": "elasticsearch", + "runtime": false, + "type": "elasticsearch", + "versions": { + "deprecated": [ + "7.10", + "7.9", + "7.7", + "7.5", + "7.2", + "6.8", + "6.5", + "5.4", + "5.2", + "2.4", + "1.7", + "1.4" + ], + "supported": [ + "8.5", + "7.17" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "8.5", + "7.17" + ], + "deprecated" : [ + "7.10", + "7.9", + "7.7", + "7.6", + "7.5", + "7.2", + "6.8", + "6.5", + "5.6", + "5.2", + "2.4", + "1.7" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "7.10", + "7.9", + "7.7", + "7.5", + "7.2", + "6.8", + "6.5" + ], + "supported": [] + } + }, + "elixir": { + "description": "", + "repo_name": "elixir", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/elixir.html", + "web": { + "commands": { + "start": "mix run --no-halt" + }, + "locations": { + "/": { + "allow": false, + "root": "web", + "passthru": true + } + } + }, + "hooks": { + "build": [ + "|", + "mix local.hex --force", + "mix local.rebar --force", + "mix do deps.get --only prod, deps.compile, compile" + ] + } + + }, + "endpoint": null, + "min_disk_size": null, + "name": "Elixir", + "runtime": true, + "type": "elixir", + "versions": { + "deprecated": [ + "1.13", + "1.12", + "1.11", + "1.10", + "1.9" + ], + "supported": [ + "1.15", + "1.14" + ], + "legacy": [ + "1.10", + "1.9" + ] + } + }, + "golang": { + "description": "", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/go.html", + "web": { + "upstream": { + "socket_family": "tcp", + "protocol": "http" + }, + "commands": { + "start": "./bin/app" + }, + "locations": { + "/": { + "allow": false, + "passthru": true + } + } + }, + "hooks": { + "build": [ + "go build -o bin/app" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Go", + "repo_name": "golang", + "runtime": true, + "type": "golang", + "versions": { + "deprecated": [ + "1.19", + "1.18", + "1.17", + "1.16", + "1.15", + "1.14", + "1.13", + "1.12", + "1.11", + "1.10", + "1.9", + "1.8" + ], + "supported": [ + "1.21", + "1.20" + ] + } + }, + "influxdb": { + "description": "", + "disk": true, + "docs": { + "relationship_name": "influxtimedb", + "service_name": "timedb", + "url": "/add-services/influxdb.html" + }, + "endpoint": "influxdb", + "min_disk_size": null, + "name": "InfluxDB", + "repo_name": "influxdb", + "runtime": false, + "type": "influxdb", + "versions": { + "deprecated": [ + "2.2", + "1.8", + "1.7", + "1.3", + "1.2" + ], + "supported": [ + "2.7", + "2.3" + ] + } + }, + "java": { + "description": "", + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/java.html", + "web": { + "commands": { + "start": "java -jar target/application.jar --server.port=$PORT" + } + }, + "hooks": { + "build": [ + "mvn clean install" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Java", + "repo_name": "java", + "runtime": true, + "type": "java", + "versions": { + "deprecated": [ + "14", + "13", + "12" + ], + "supported": [ + "21", + "19", + "18", + "17", + "11", + "8" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "17", + "11", + "8" + ], + "deprecated": [ + "15", + "13", + "7" + ] + } + }, + "kafka": { + "description": "", + "disk": true, + "docs": { + "relationship_name": "kafkaqueue", + "service_name": "queuekafka", + "url": "/add-services/kafka.html" + }, + "endpoint": "kafka", + "min_disk_size": 512, + "name": "Kafka", + "repo_name": "kafka", + "runtime": false, + "type": "kafka", + "versions": { + "deprecated": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3", + "2.2", + "2.1" + ], + "supported": [ + "3.2", + "3.4" + ], + "legacy": [ + "2.6", + "2.5", + "2.4", + "2.3", + "2.2", + "2.1" + ] + } + }, + "lisp": { + "description": "", + "id": 1102, + "repo_name": "lisp", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/lisp.html", + "web": { + "commands": { + "start": "./example" + }, + "locations": { + "/": { + "allow": false, + "passthru": true + } + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Lisp", + "runtime": true, + "type": "lisp", + "versions": { + "deprecated": [], + "supported": [ + "2.1", + "2.0", + "1.5" + ] + } + }, + "mariadb": { + "description": "A manufacture-based container for MariaDB", + "repo_name": "mariadb", + "disk": true, + "docs": { + "relationship_name": "database", + "service_name": "db", + "url": "/add-services/mysql.html" + }, + "endpoint": "mysql", + "min_disk_size": 256, + "name": "MariaDB/MySQL", + "runtime": false, + "type": "mariadb", + "versions": { + "deprecated": [ + "10.2", + "10.1", + "10.0", + "5.5" + ], + "supported": [ + "11.0", + "10.11", + "10.6", + "10.5", + "10.4", + "10.3" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "10.8 Galera", + "10.7 Galera", + "10.6 Galera", + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera", + "10.0 Galera" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "10.11 Galera", + "10.6 Galera", + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera" + ] + } + }, + "mysql": { + "description": "A manufacture-based container for MariaDB", + "repo_name": "mariadb", + "disk": true, + "docs": { + "relationship_name": "database", + "service_name": "db", + "url": "/add-services/mysql.html" + }, + "endpoint": "mysql", + "min_disk_size": 256, + "name": "MariaDB/MySQL", + "runtime": false, + "type": "mysql", + "versions": { + "deprecated": [ + "10.2", + "10.1", + "10.0", + "5.5" + ], + "supported": [ + "11.0", + "10.11", + "10.6", + "10.5", + "10.4", + "10.3" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera", + "10.0 Galera" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "10.11 Galera", + "10.6 Galera", + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera" + ] + } + }, + "memcached": { + "description": "Memcached service.", + "repo_name": "memcached", + "disk": false, + "docs": { + "relationship_name": "memcachedcache", + "service_name": "cachemc", + "url": "/add-services/memcached.html" + }, + "endpoint": "memcached", + "min_disk_size": null, + "name": "Memcached", + "runtime": false, + "type": "memcached", + "versions": { + "deprecated": [], + "supported": [ + "1.6", + "1.5", + "1.4" + ] + }, + "versions-dedicated-gen-2": { + "supported": ["1.4"] + } + }, + "mongodb": { + "description": "Experimental MongoDB support on Platform.sh", + "repo_name": "mongodb", + "disk": true, + "docs": { + "relationship_name": "mongodatabase", + "service_name": "dbmongo", + "url": "/add-services/mongodb.html" + }, + "endpoint": "mongodb", + "min_disk_size": 512, + "name": "MongoDB", + "runtime": false, + "type": "mongodb", + "versions": { + "deprecated": [ + "4.0.3", + "3.6", + "3.4", + "3.2", + "3.0" + ], + "supported": [] + } + }, + "mongodb-enterprise": { + "description": "Support for the enterprise edition of MongoDB", + "repo_name": "mongodb", + "disk": true, + "docs": { + "relationship_name": "mongodatabase", + "service_name": "dbmongo", + "url": "/add-services/mongodb.html" + }, + "endpoint": "mongodb", + "min_disk_size": 512, + "name": "MongoDB", + "runtime": false, + "type": "mongodb-enterprise", + "premium": true, + "versions": { + "supported": [ + "6.0", + "5.0", + "4.4", + "4.2" + ], + "deprecated": [ + "4.0" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "6.0", + "5.0", + "4.4", + "4.2" + ], + "deprecated": [ + "4.0" + ] + } + }, + "network-storage": { + "description": "", + "repo_name": "network-storage", + "disk": true, + "docs": { + "relationship_name": "null", + "service_name": "files", + "url": "/add-services/network-storage.html" + }, + "endpoint": "something", + "min_disk_size": null, + "name": "Network Storage", + "runtime": false, + "type": "network-storage", + "versions": { + "deprecated": [ + "1.0" + ], + "supported": [ + "2.0" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "2.0" + ] + } + }, + "nodejs": { + "description": "NodeJS service for Platform", + "repo_name": "nodejs", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/nodejs.html", + "web": { + "commands": { + "start": "node index.js" + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "JavaScript/Node.js", + "runtime": true, + "type": "nodejs", + "versions": { + "deprecated": [ + "14", + "12", + "10", + "8", + "6", + "4.8", + "4.7", + "0.12" + ], + "supported": [ + "20", + "18", + "16" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + + ], + "deprecated": [ + "14", + "12", + "10", + "9.8" + ] + } + }, + "opensearch": { + "description": "A manufacture service for OpenSearch", + "disk": true, + "docs": { + "relationship_name": "ossearch", + "service_name": "searchopen", + "url": "/add-services/opensearch.html" + }, + "endpoint": "opensearch", + "min_disk_size": 256, + "name": "OpenSearch", + "repo_name": "opensearch", + "runtime": false, + "type": "opensearch", + "versions": { + "deprecated": [ + "1.2", + "1.1" + ], + "supported": [ + "2", + "1" + ], + "legacy": [ + "1.1" + ] + }, + "versions-dedicated-gen-2": { + "deprecated": [], + "supported": [ + "2.5", + "1.2" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "2" + ] + } + }, + "oracle-mysql": { + "description": "Images using MySQL from Oracle instead of MariaDB still providing mysql endpoints", + "repo_name": "oracle-mysql", + "disk": true, + "docs": { + "relationship_name": "mysqldatabase", + "service_name": "dbmysql", + "url": "/add-services/mysql.html" + }, + "endpoint": "mysql", + "min_disk_size": 256, + "name": "Oracle MySQL", + "runtime": false, + "type": "oracle-mysql", + "versions": { + "deprecated": [], + "supported": [ + "8.0", + "5.7" + ] + } + }, + "php": { + "description": "PHP service for Platform.sh.", + "repo_name": "php", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/php.html", + "web": { + "locations": { + "/": { + "root": "web", + "passthru": "/index.php" + } + } + }, + "hooks": { + "build": [ + "|", + "set -e" + ], + "deploy": [ + "|", + "set -e" + ] + }, + "build": { + "flavor": "composer" + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "PHP", + "runtime": true, + "type": "php", + "versions-dedicated-gen-2": { + "supported": [ + "8.2", + "8.1", + "8.0" + ], + "deprecated": [ + "7.4", + "7.3", + "7.2", + "7.1", + "7.0" + ] + }, + "versions": { + "deprecated": [ + "7.4", + "7.3", + "7.2", + "7.1", + "7.0", + "5.6", + "5.5", + "5.4" + ], + "supported": [ + "8.2", + "8.1", + "8.0" + ] + } + }, + "postgresql": { + "description": "PostgreSQL service for Platform.sh.", + "repo_name": "postgresql", + "disk": true, + "docs": { + "relationship_name": "postgresdatabase", + "service_name": "dbpostgres", + "url": "/add-services/postgresql.html" + }, + "endpoint": "postgresql", + "min_disk_size": null, + "name": "PostgreSQL", + "runtime": false, + "type": "postgresql", + "versions": { + "deprecated": [ + "10", + "9.6", + "9.5", + "9.4", + "9.3" + ], + "supported": [ + "15", + "14", + "13", + "12", + "11" + ] + }, + "versions-dedicated-gen-2": { + "deprecated": [ + "9.6*", + "9.5", + "9.4", + "9.3" + ], + "supported": [ + "11*" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "10" + ], + "supported": [ + "15", + "14", + "13", + "12", + "11" + ] + } + }, + "python": { + "description": "", + "repo_name": "python", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/python.html", + "web": { + "commands": { + "start": "python server.py" + } + }, + "hooks": { + "build": [ + "|", + "pipenv install --system --deploy" + ] + }, + "dependencies": { + "python3": { + "pipenv": "2018.10.13" + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Python", + "runtime": true, + "type": "python", + "versions": { + "deprecated": [ + "3.7", + "3.6", + "3.5", + "2.7*" + ], + "supported": [ + "3.12", + "3.11", + "3.10", + "3.9", + "3.8" + ] + } + }, + "rabbitmq": { + "description": "A manufacture-based container for RabbitMQ", + "repo_name": "rabbitmq", + "disk": true, + "docs": { + "relationship_name": "rabbitmqqueue", + "service_name": "queuerabbit", + "url": "/add-services/rabbitmq.html" + }, + "endpoint": "rabbitmq", + "min_disk_size": 512, + "name": "RabbitMQ", + "runtime": false, + "type": "rabbitmq", + "versions": { + "deprecated": [ + "3.8", + "3.7", + "3.6", + "3.5" + ], + "supported": [ + "3.12", + "3.11", + "3.10", + "3.9" + ], + "legacy": [ + "3.8", + "3.7", + "3.6", + "3.5" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "3.11", + "3.10", + "3.9" + ], + "deprecated": [ + "3.8", + "3.7" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "3.12", + "3.11", + "3.10", + "3.9" + ] + } + }, + "redis": { + "description": "A manufacture-based Redis container ", + "repo_name": "redis", + "disk": false, + "docs": { + "relationship_name": "rediscache", + "service_name": "cacheredis", + "url": "/add-services/redis.html" + }, + "endpoint": "redis", + "min_disk_size": null, + "name": "Redis", + "runtime": false, + "type": "redis", + "versions": { + "deprecated": [ + "6.0", + "5.0", + "4.0", + "3.2", + "3.0", + "2.8" + ], + "supported": [ + "7.2", + "7.0", + "6.2" + ], + "legacy": [ + "6.0" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "7.0", + "6.2" + ], + "deprecated": [ + "6.0", + "5.0", + "3.2" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "6.0", + "5.0", + "4.0", + "3.2", + "3.0", + "2.8" + ], + "supported": [ + "7.2", + "7.0", + "6.2" + ] + } + }, + "ruby": { + "description": "", + "repo_name": "ruby", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/ruby.html", + "web": { + "upstream": { + "socket_family": "unix" + }, + "commands": { + "start": "unicorn -l $SOCKET -E production config.ru" + }, + "locations": { + "/": { + "root": "public", + "passthru": true, + "expires": "1h", + "allow": true + } + } + }, + "hooks": { + "build": [ + "|", + "bundle install --without development test" + ], + "deploy": [ + "|", + "RACK_ENV=production bundle exec rake db:migrate" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Ruby", + "runtime": true, + "type": "ruby", + "versions": { + "deprecated": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ], + "supported": [ + "3.2", + "3.1", + "3.0" + ], + "legacy": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ] + } + }, + "rust": { + "description": "", + "repo_name": "rust", + "disk": true, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/rust.html", + "web": { + "commands": { + "start": "./target/debug/hello" + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Rust", + "runtime": true, + "type": "rust", + "versions": { + "deprecated": [], + "supported": [ + "1" + ] + } + }, + "solr": { + "description": "", + "repo_name": "solr", + "disk": true, + "docs": { + "relationship_name": "solrsearch", + "service_name": "searchsolr", + "url": "/add-services/solr.html" + }, + "endpoint": "solr", + "min_disk_size": 256, + "name": "Solr", + "runtime": false, + "type": "solr", + "versions": { + "deprecated": [ + "8.6", + "8.4", + "8.0", + "7.7", + "7.6", + "6.6", + "6.3", + "4.10", + "3.6" + ], + "supported": [ + "9.2", + "9.1", + "8.11" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "8.11" + ], + "deprecated":[ + "8.6", + "8.0", + "7.7", + "6.6", + "6.3", + "4.10" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "9.2", + "9.1", + "8.11" + ], + "deprecated": [ + ] + } + }, + "varnish": { + "description": "", + "repo_name": "varnish", + "disk": false, + "docs": { + "relationship_name": "varnishstats", + "service_name": "varnish", + "url": "/add-services/varnish.html" + }, + "endpoint": "http+stats", + "min_disk_size": null, + "configuration": " configuration:\n vcl: !include\n type: string\n path: config.vcl", + "service_relationships": "application: 'app:http'", + "name": "Varnish", + "runtime": false, + "type": "varnish", + "versions": { + "deprecated": [ + "5.2", + "5.1", + "7.1", + "6.0" + ], + "supported": [ + "7.3", + "7.2", + "6.3" + ] + } + }, + "vault-kms": { + "description": "", + "disk": true, + "docs": { + "relationship_name": "vault_service", + "service_name": "vault-kms", + "url": "/add-services/vault.html" + }, + "endpoint": "manage_keys", + "min_disk_size": 512, + "configuration": " configuration:\n endpoints:\n :\n - policy: \n key: \n type: ", + "name": "Vault KMS", + "repo_name": "vault-kms", + "runtime": false, + "type": "vault-kms", + "versions": { + "supported": [ + "1.12" + ], + "deprecated": [ + "1.8", + "1.6" + ], + "legacy": [ + "1.6" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "1.6" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "1.12" + ], + "deprecated": [ + "1.8", + "1.6" + ] + } + } +} diff --git a/internal/question/models/runtime.go b/internal/question/models/runtime.go index 8c5697f..27e9501 100644 --- a/internal/question/models/runtime.go +++ b/internal/question/models/runtime.go @@ -4,68 +4,43 @@ import ( "fmt" ) -const ( - DotNet Runtime = "dotnet" - Elixir Runtime = "elixir" - Golang Runtime = "golang" - Java Runtime = "java" - Lisp Runtime = "lisp" - NodeJS Runtime = "nodejs" - PHP Runtime = "php" - Python Runtime = "python" - Ruby Runtime = "ruby" - Rust Runtime = "rust" -) - -var ( - Runtimes = RuntimeList{ - DotNet, - Elixir, - Golang, - Java, - Lisp, - NodeJS, - PHP, - Python, - Ruby, - Rust, +type Runtime struct { + Name string + Description string + Disk bool + Docs struct { + URL string + Web struct { + Commands struct { + Start string + } + } + Locations map[string]map[string]interface{} } -) + Type string + Versions struct { + Supported []string + } + Runtime bool +} -type Runtime string +func (r *Runtime) String() string { + return r.Type +} -func (r Runtime) String() string { - return string(r) +func (r *Runtime) Title() string { + return r.Name } -func (r Runtime) Title() string { - switch r { - case DotNet: - return "C#/.Net Core" - case Elixir: - return "Elixir" - case Golang: - return "Go" - case Java: - return "Java" - case Lisp: - return "Lisp" - case NodeJS: - return "JavaScript/Node.js" - case PHP: - return "PHP" - case Python: - return "Python" - case Ruby: - return "Ruby" - case Rust: - return "Rust" - default: - return "" +func (r *Runtime) DefaultVersion() string { + if len(r.Versions.Supported) > 0 { + return r.Versions.Supported[0] } + + return "" } -type RuntimeList []Runtime +type RuntimeList []*Runtime func (r RuntimeList) AllTitles() []string { titles := make([]string, 0, len(r)) @@ -75,11 +50,22 @@ func (r RuntimeList) AllTitles() []string { return titles } -func (r RuntimeList) RuntimeByTitle(title string) (Runtime, error) { +func (r RuntimeList) RuntimeByTitle(title string) (*Runtime, error) { for _, runtime := range r { if runtime.Title() == title { return runtime, nil } } - return "", fmt.Errorf("runtime by title is not found") + + return nil, fmt.Errorf("runtime by title is not found") +} + +func (r RuntimeList) RuntimeByType(typ string) (*Runtime, error) { + for _, runtime := range r { + if runtime.Type == typ { + return runtime, nil + } + } + + return nil, fmt.Errorf("runtime by type is not found") } diff --git a/internal/question/models/service_name.go b/internal/question/models/service_name.go index 923fe6e..38bf24f 100644 --- a/internal/question/models/service_name.go +++ b/internal/question/models/service_name.go @@ -6,101 +6,44 @@ import ( "github.com/AlecAivazis/survey/v2" ) -const ( - ChromeHeadless ServiceName = "chrome-headless" - InfluxDB ServiceName = "influxdb" - Kafka ServiceName = "kafka" - MariaDB ServiceName = "mariadb" - Memcached ServiceName = "memcached" - MySQL ServiceName = "mysql" - NetworkStorage ServiceName = "network-storage" - OpenSearch ServiceName = "opensearch" - OracleMySQL ServiceName = "oracle-mysql" - PostgreSQL ServiceName = "postgresql" - RabbitMQ ServiceName = "rabbitmq" - Redis ServiceName = "redis" - RedisPersistent ServiceName = "redis-persistent" - Solr ServiceName = "solr" - Varnish ServiceName = "varnish" - VaultKMS ServiceName = "vault-kms" -) - -var ( - ServiceNames = ServiceNameList{ - MariaDB, - MySQL, - PostgreSQL, - Redis, - RedisPersistent, - Memcached, - OpenSearch, - Solr, - Varnish, - Kafka, - VaultKMS, - RabbitMQ, - InfluxDB, - ChromeHeadless, - NetworkStorage, - OracleMySQL, +type ServiceName struct { + Name string + Type string + Description string + Disk bool + Docs struct { + Relationship string + URL string } -) + Endpoint string + MinDiskSize *int + Versions struct { + Supported []string + } + Runtime bool +} -type ServiceName string +func (s *ServiceName) String() string { + return s.Type +} -func (s ServiceName) String() string { - return string(s) +func (s *ServiceName) Title() string { + return s.Name } -func (s ServiceName) Title() string { - switch s { - case ChromeHeadless: - return "Chrome Headless" - case InfluxDB: - return "InfluxDB" - case Kafka: - return "Kafka" - case MariaDB: - return "MariaDB" - case Memcached: - return "Memcached" - case MySQL: - return "MySQL" - case NetworkStorage: - return "Network Storage" - case OpenSearch: - return "OpenSearch" - case OracleMySQL: - return "Oracle MySQL" - case PostgreSQL: - return "PostgreSQL" - case RabbitMQ: - return "RabbitMQ" - case Redis: - return "Redis" - case RedisPersistent: - return "Redis Persistent" - case Solr: - return "Solr" - case Varnish: - return "Varnish" - case VaultKMS: - return "Vault KMS" - default: - return "" - } +func (s *ServiceName) IsPersistent() bool { + return s.Disk } -func (s ServiceName) IsPersistent() bool { - switch s { - case ChromeHeadless, Memcached, Redis: - return false - default: - return true +func (s *ServiceName) DefaultVersion() string { + if len(s.Versions.Supported) > 0 { + return s.Versions.Supported[0] } + + return "" } -type ServiceNameList []ServiceName +type ServiceNameList []*ServiceName func (s *ServiceNameList) WriteAnswer(_ string, value interface{}) error { switch answer := value.(type) { @@ -126,11 +69,11 @@ func (s *ServiceNameList) AllTitles() []string { return titles } -func (s *ServiceNameList) ServiceByTitle(title string) (ServiceName, error) { - for _, service := range *s { +func (s ServiceNameList) ServiceByTitle(title string) (*ServiceName, error) { + for _, service := range s { if service.Title() == title { return service, nil } } - return "", fmt.Errorf("service name by title is not found") + return nil, fmt.Errorf("service name by title is not found") } diff --git a/internal/question/models/stack.go b/internal/question/models/stack.go index 06783f4..79d0bf1 100644 --- a/internal/question/models/stack.go +++ b/internal/question/models/stack.go @@ -88,17 +88,28 @@ func (s StackList) StackByTitle(title string) (Stack, error) { return GenericStack, fmt.Errorf("stack by title is not found") } -func RuntimeForStack(stack Stack) Runtime { +func RuntimeForStack(stack Stack) *Runtime { switch stack { case Django, Flask: - return Python + if r, err := Runtimes.RuntimeByType("python"); err == nil { + return r + } case Rails: - return Ruby + if r, err := Runtimes.RuntimeByType("ruby"); err == nil { + return r + } + return nil case Laravel: - return PHP + if r, err := Runtimes.RuntimeByType("php"); err == nil { + return r + } + return nil case NextJS, Strapi, Express: - return NodeJS + if r, err := Runtimes.RuntimeByType("nodejs"); err == nil { + return r + } + return nil default: - return "" + return nil } } diff --git a/internal/question/models/stack_test.go b/internal/question/models/stack_test.go new file mode 100644 index 0000000..cb3ca55 --- /dev/null +++ b/internal/question/models/stack_test.go @@ -0,0 +1,32 @@ +package models + +import ( + "reflect" + "testing" +) + +func TestRuntimeForStack(t *testing.T) { + type args struct { + stack Stack + } + + tests := []struct { + args args + want string + }{ + {args: args{stack: Django}, want: "python"}, + {args: args{stack: Flask}, want: "python"}, + {args: args{stack: Express}, want: "nodejs"}, + {args: args{stack: Strapi}, want: "nodejs"}, + {args: args{stack: NextJS}, want: "nodejs"}, + {args: args{stack: Laravel}, want: "php"}, + } + for _, tt := range tests { + t.Run(tt.args.stack.Title(), func(t *testing.T) { + got := RuntimeForStack(tt.args.stack) + if !reflect.DeepEqual(got.Type, tt.want) { + t.Errorf("RuntimeForStack() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/question/models/version.go b/internal/question/models/version.go deleted file mode 100644 index e4c4c4a..0000000 --- a/internal/question/models/version.go +++ /dev/null @@ -1,43 +0,0 @@ -package models - -var ( - LanguageTypeVersions = map[Runtime][]string{ - DotNet: {"6.0", "3.1"}, - Elixir: {"1.13", "1.12", "1.11"}, - Golang: {"1.22", "1.21", "1.20"}, - Java: {"19", "18", "17", "11", "8"}, - Lisp: {"2.1", "2.0", "1.5"}, - NodeJS: {"20", "18", "16"}, - PHP: {"8.2", "8.1", "8.0"}, - Python: {"3.11", "3.10", "3.9", "3.8", "3.7"}, - Ruby: {"3.3", "3.2", "3.1", "3.0", "2.7", "2.6", "2.5", "2.4", "2.3"}, - Rust: {"1"}, - } - - ServiceTypeVersions = map[ServiceName][]string{ - ChromeHeadless: {"95", "91", "86", "84", "83", "81", "80", "73"}, - InfluxDB: {"2.3"}, - Kafka: {"3.2"}, - MariaDB: {"11.0", "10.11", "10.6", "10.5", "10.4", "10.3"}, - Memcached: {"1.6", "1.5", "1.4"}, - MySQL: {"10.6", "10.5", "10.4", "10.3"}, - NetworkStorage: {"2.0"}, - OpenSearch: {"2", "1.2", "1.1"}, - OracleMySQL: {"8.0", "5.7"}, - PostgreSQL: {"15", "14", "13", "12", "11"}, - RabbitMQ: {"3.11", "3.10", "3.9"}, - Redis: {"7.0", "6.2"}, - RedisPersistent: {"7.0", "6.2"}, - Solr: {"9.1", "8.11"}, - Varnish: {"7.2", "7.1", "6.3", "6.0"}, - VaultKMS: {"1.12"}, - } -) - -func DefaultVersionForRuntime(r Runtime) string { - versions := LanguageTypeVersions[r] - if len(versions) == 0 { - return "" - } - return versions[0] -} diff --git a/internal/question/name.go b/internal/question/name.go index 78f0131..f8409f3 100644 --- a/internal/question/name.go +++ b/internal/question/name.go @@ -30,7 +30,7 @@ func (q *Name) Ask(ctx context.Context) error { } question := &survey.Input{ - Message: "Tell us your project's application name:", Default: slugify(path.Base(answers.WorkingDirectory)), + Message: "Tell us your project's application name:", Default: slugify(path.Base(answers.Cwd)), } var name string diff --git a/internal/question/services.go b/internal/question/services.go index 01cc73f..63b1a72 100644 --- a/internal/question/services.go +++ b/internal/question/services.go @@ -66,18 +66,13 @@ func (q *Services) Ask(ctx context.Context) error { } for _, serviceName := range services { - versions, ok := models.ServiceTypeVersions[serviceName] - if !ok || len(versions) == 0 { - return nil - } - service := models.Service{ Name: strings.ReplaceAll(serviceName.String(), "_", "-"), Type: models.ServiceType{ Name: serviceName.String(), - Version: versions[0], + Version: serviceName.DefaultVersion(), }, - TypeVersions: versions, + TypeVersions: serviceName.Versions.Supported, } if serviceName.IsPersistent() { service.Disk = models.ServiceDisks[0] diff --git a/internal/question/socket_family.go b/internal/question/socket_family.go index 51f3134..19fdaea 100644 --- a/internal/question/socket_family.go +++ b/internal/question/socket_family.go @@ -14,10 +14,10 @@ func (q *SocketFamily) Ask(ctx context.Context) error { return nil } - switch answers.Type.Runtime { - case models.PHP: + switch answers.Type.Runtime.Type { + case "php": return nil - case models.Ruby, models.Python: + case "ruby", "python": answers.SocketFamily = models.UnixSocket return nil default: diff --git a/internal/question/stack.go b/internal/question/stack.go index 6d94a10..0c7d8ae 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -53,14 +53,14 @@ func (q *Stack) Ask(ctx context.Context) error { answers.Stack = models.GenericStack - hasSettingsPy := utils.FileExists(answers.WorkingDirectory, settingsPyFile) - hasManagePy := utils.FileExists(answers.WorkingDirectory, managePyFile) + hasSettingsPy := utils.FileExists(answers.WorkingDirectory, "", settingsPyFile) + hasManagePy := utils.FileExists(answers.WorkingDirectory, "", managePyFile) if hasSettingsPy && hasManagePy { answers.Stack = models.Django return nil } - rackPath := utils.FindFile(answers.WorkingDirectory, rackFile) + rackPath := utils.FindFile(answers.WorkingDirectory, "", rackFile) if rackPath != "" { f, err := os.Open(rackPath) if err == nil { @@ -72,9 +72,9 @@ func (q *Stack) Ask(ctx context.Context) error { } } - requirementsPath := utils.FindFile(answers.WorkingDirectory, "requirements.txt") + requirementsPath := utils.FindFile(answers.WorkingDirectory, "", "requirements.txt") if requirementsPath != "" { - f, err := os.Open(requirementsPath) + f, err := answers.WorkingDirectory.Open(requirementsPath) if err == nil { defer f.Close() if ok, _ := utils.ContainsStringInFile(f, "flask", true); ok { @@ -84,80 +84,140 @@ func (q *Stack) Ask(ctx context.Context) error { } } - pyProjectPath := utils.FindFile(answers.WorkingDirectory, "pyproject.toml") + pyProjectPath := utils.FindFile(answers.WorkingDirectory, "", "pyproject.toml") if pyProjectPath != "" { - if _, ok := utils.GetTOMLValue([]string{"tool", "poetry", "dependencies", "flask"}, pyProjectPath, true); ok { + if _, ok := utils.GetTOMLValue( + answers.WorkingDirectory, + []string{"tool", "poetry", "dependencies", "flask"}, + pyProjectPath, + true, + ); ok { answers.Stack = models.Flask return nil } } - pipfilePath := utils.FindFile(answers.WorkingDirectory, "Pipfile") + pipfilePath := utils.FindFile(answers.WorkingDirectory, "", "Pipfile") if pipfilePath != "" { - if _, ok := utils.GetTOMLValue([]string{"packages", "flask"}, pipfilePath, true); ok { + if _, ok := utils.GetTOMLValue( + answers.WorkingDirectory, + []string{"packages", "flask"}, + pipfilePath, + true, + ); ok { answers.Stack = models.Flask return nil } } - composerJSONPaths := utils.FindAllFiles(answers.WorkingDirectory, composerJSONFile) + composerJSONPaths := utils.FindAllFiles(answers.WorkingDirectory, "", composerJSONFile) for _, composerJSONPath := range composerJSONPaths { - if _, ok := utils.GetJSONValue([]string{"require", "laravel/framework"}, composerJSONPath, true); ok { + if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"require", "laravel/framework"}, + composerJSONPath, + true, + ); ok { answers.Stack = models.Laravel return nil } } - packageJSONPaths := utils.FindAllFiles(answers.WorkingDirectory, packageJSONFile) + packageJSONPaths := utils.FindAllFiles(answers.WorkingDirectory, "", packageJSONFile) for _, packageJSONPath := range packageJSONPaths { - if _, ok := utils.GetJSONValue([]string{"dependencies", "next"}, packageJSONPath, true); ok { + if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"dependencies", "next"}, + packageJSONPath, + true, + ); ok { answers.Stack = models.NextJS return nil } - if _, ok := utils.GetJSONValue([]string{"dependencies", "@strapi/strapi"}, packageJSONPath, true); ok { + if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"dependencies", "@strapi/strapi"}, + packageJSONPath, + true, + ); ok { answers.Stack = models.Strapi return nil } - if _, ok := utils.GetJSONValue([]string{"dependencies", "strapi"}, packageJSONPath, true); ok { + if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"dependencies", "strapi"}, + packageJSONPath, + true, + ); ok { answers.Stack = models.Strapi return nil } - if _, ok := utils.GetJSONValue([]string{"dependencies", "express"}, packageJSONPath, true); ok { + if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"dependencies", "express"}, + packageJSONPath, + true, + ); ok { answers.Stack = models.Express return nil } } - hasSymfonyLock := utils.FileExists(answers.WorkingDirectory, symfonyLockFile) + hasSymfonyLock := utils.FileExists(answers.WorkingDirectory, "", symfonyLockFile) hasSymfonyBundle := false hasIbexaDependencies := false hasShopwareDependencies := false for _, composerJSONPath := range composerJSONPaths { - if _, ok := utils.GetJSONValue([]string{"autoload", "psr-0", "shopware"}, composerJSONPath, true); ok { + if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"autoload", "psr-0", "shopware"}, + composerJSONPath, + true, + ); ok { hasShopwareDependencies = true break } - if _, ok := utils.GetJSONValue([]string{"autoload", "psr-4", "shopware\\core\\"}, composerJSONPath, true); ok { + if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"autoload", "psr-4", "shopware\\core\\"}, + composerJSONPath, + true, + ); ok { hasShopwareDependencies = true break } - if _, ok := utils.GetJSONValue([]string{"autoload", "psr-4", "shopware\\appbundle\\"}, composerJSONPath, true); ok { + if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"autoload", "psr-4", "shopware\\appbundle\\"}, + composerJSONPath, + true, + ); ok { hasShopwareDependencies = true break } - if keywords, ok := utils.GetJSONValue([]string{"keywords"}, composerJSONPath, true); ok { + if keywords, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"keywords"}, + composerJSONPath, + true, + ); ok { if keywordsVal, ok := keywords.([]string); ok && slices.Contains(keywordsVal, "shopware") { hasShopwareDependencies = true break } } - if requirements, ok := utils.GetJSONValue([]string{"require"}, composerJSONPath, true); ok { + if requirements, ok := utils.GetJSONValue( + answers.WorkingDirectory, + []string{"require"}, + composerJSONPath, + true, + ); ok { if requirementsVal, requirementsOK := requirements.(map[string]interface{}); requirementsOK { - if _, hasSymfonyFrameworkBundle := requirementsVal["symfony/framework-bundle"]; hasSymfonyFrameworkBundle { + if _, requiresSymfony := requirementsVal["symfony/framework-bundle"]; requiresSymfony { hasSymfonyBundle = true } diff --git a/internal/question/stack_test.go b/internal/question/stack_test.go new file mode 100644 index 0000000..9f42f0e --- /dev/null +++ b/internal/question/stack_test.go @@ -0,0 +1,87 @@ +package question + +import ( + "context" + "io/fs" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/internal/question/models" +) + +func TestStack_Ask(t *testing.T) { + tests := []struct { + name string + fileSystem fs.FS + want string + wantErr bool + }{ + { + name: "Django", + fileSystem: fstest.MapFS{ + "demo/settings.py": &fstest.MapFile{}, + "manage.py": &fstest.MapFile{}, + }, + want: "Django", + wantErr: false, + }, + { + name: "Django subdir", + fileSystem: fstest.MapFS{ + "sub/settings.py": &fstest.MapFile{}, + "sub/demo/manage.py": &fstest.MapFile{}, + }, + want: "Django", + wantErr: false, + }, + { + name: "Flask requirements.txt", + fileSystem: fstest.MapFS{ + "requirements.txt": &fstest.MapFile{Data: []byte("FlAsK==1.2.3#hash-here")}, + }, + want: "Flask", + wantErr: false, + }, + { + name: "Flask Poetry", + fileSystem: fstest.MapFS{ + "pyproject.toml": &fstest.MapFile{Data: []byte(` +[tool.poetry.dependencies] +# Get the latest revision on the branch named "next" +requests = { git = "https://github.com/kennethreitz/requests.git", branch = "next" } +# Get a revision by its commit hash +FlAsK = { git = "https://github.com/pallets/flask.git", rev = "38eb5d3b" } + `)}, + }, + want: "Flask", + wantErr: false, + }, + { + name: "Flask Pipenv", + fileSystem: fstest.MapFS{ + "Pipfile": &fstest.MapFile{Data: []byte(` +[packages] +fLaSk = "^1.2.3" + `)}, + }, + want: "Flask", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := &Stack{} + a := models.NewAnswers() + a.WorkingDirectory = tt.fileSystem + ctx := models.ToContext(context.Background(), a) + + if err := q.Ask(ctx); (err != nil) != tt.wantErr { + t.Errorf("Stack.Ask() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && a.Stack.Title() != tt.want { + t.Errorf("Stack.Ask().Stack = %s, want %s", a.Stack.Title(), tt.want) + } + }) + } +} diff --git a/internal/question/type.go b/internal/question/type.go index 02fc012..d432949 100644 --- a/internal/question/type.go +++ b/internal/question/type.go @@ -42,7 +42,7 @@ func (q *Type) Ask(ctx context.Context) error { }() runtime := models.RuntimeForStack(answers.Stack) - if runtime == "" { + if runtime == nil { question := &survey.Select{ Message: "What language is your project using? We support the following:", Options: models.Runtimes.AllTitles(), @@ -59,8 +59,8 @@ func (q *Type) Ask(ctx context.Context) error { return err } } - answers.Type.Runtime = runtime - answers.Type.Version = models.DefaultVersionForRuntime(runtime) + answers.Type.Runtime = *runtime + answers.Type.Version = runtime.DefaultVersion() return nil } diff --git a/internal/question/web_command.go b/internal/question/web_command.go index 137bbbb..9036aec 100644 --- a/internal/question/web_command.go +++ b/internal/question/web_command.go @@ -3,7 +3,7 @@ package question import ( "context" "fmt" - "os" + "io/fs" "path" "path/filepath" "slices" @@ -27,7 +27,7 @@ func (q *WebCommand) Ask(ctx context.Context) error { } // Do not ask the command for PHP applications - if answers.Type.Runtime == models.PHP { + if answers.Type.Runtime.Type == "php" { return nil } @@ -54,14 +54,14 @@ func (q *WebCommand) Ask(ctx context.Context) error { pythonPath := "" wsgi := "app.wsgi" // try to find the wsgi.py file to change the default command - wsgiPath := utils.FindFile(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), "wsgi.py") + wsgiPath := utils.FindFile(answers.WorkingDirectory, answers.ApplicationRoot, "wsgi.py") if wsgiPath != "" { wsgiParentDir := path.Base(path.Dir(wsgiPath)) wsgi = fmt.Sprintf("%s.wsgi", wsgiParentDir) // add the pythonpath if the wsgi.py file is not in the root of the app wsgiRel, _ := filepath.Rel( - path.Join(answers.WorkingDirectory, answers.ApplicationRoot), + answers.ApplicationRoot, path.Dir(path.Dir(wsgiPath)), ) if wsgiRel != "." { @@ -94,8 +94,9 @@ func (q *WebCommand) Ask(ctx context.Context) error { return nil case models.Strapi: if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, []string{"scripts", "start"}, - path.Join(answers.WorkingDirectory, "package.json"), + "package.json", true, ); ok { if slices.Contains(answers.DependencyManagers, models.Yarn) { @@ -106,8 +107,9 @@ func (q *WebCommand) Ask(ctx context.Context) error { } case models.Express: if _, ok := utils.GetJSONValue( + answers.WorkingDirectory, []string{"scripts", "start"}, - path.Join(answers.WorkingDirectory, "package.json"), + "package.json", true, ); ok { if slices.Contains(answers.DependencyManagers, models.Yarn) { @@ -119,16 +121,16 @@ func (q *WebCommand) Ask(ctx context.Context) error { } if mainPath, ok := utils.GetJSONValue( + answers.WorkingDirectory, []string{"main"}, - path.Join(answers.WorkingDirectory, "package.json"), + "package.json", true, ); ok { answers.WebCommand = fmt.Sprintf("node %s", mainPath.(string)) return nil } - if indexFile := utils.FindFile(answers.WorkingDirectory, "index.js"); indexFile != "" { - indexFile, _ = filepath.Rel(answers.WorkingDirectory, indexFile) + if indexFile := utils.FindFile(answers.WorkingDirectory, "", "index.js"); indexFile != "" { answers.WebCommand = fmt.Sprintf("node %s", indexFile) return nil } @@ -136,7 +138,7 @@ func (q *WebCommand) Ask(ctx context.Context) error { appPath := "" // try to find the app.py, api.py or server.py files for _, name := range []string{"app.py", "server.py", "api.py"} { - if _, err := os.Stat(path.Join(answers.WorkingDirectory, name)); err == nil { + if _, err := fs.Stat(answers.WorkingDirectory, name); err == nil { appPath = fmt.Sprintf("'%s:app'", strings.TrimSuffix(name, ".py")) break } diff --git a/internal/question/working_directory.go b/internal/question/working_directory.go index db12e87..dfd89af 100644 --- a/internal/question/working_directory.go +++ b/internal/question/working_directory.go @@ -31,7 +31,8 @@ func (q *WorkingDirectory) Ask(ctx context.Context) error { if !ok { return nil } - answers.WorkingDirectory = cwd + answers.WorkingDirectory = os.DirFS(cwd) + answers.Cwd = cwd answers.HasGit = false var outBuf, errBuf bytes.Buffer @@ -84,7 +85,8 @@ func (q *WorkingDirectory) Ask(ctx context.Context) error { } if proceed { - answers.WorkingDirectory = gitRepoAbsPath + answers.WorkingDirectory = os.DirFS(gitRepoAbsPath) + answers.Cwd = gitRepoAbsPath answers.HasGit = true } } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index d83b378..7997c54 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -6,6 +6,7 @@ import ( "cmp" "encoding/json" "io" + "io/fs" "os" "path/filepath" "slices" @@ -22,15 +23,15 @@ var skipDirs = []string{ } // FileExists checks if the file exists -func FileExists(searchPath, name string) bool { - return FindFile(searchPath, name) != "" +func FileExists(fileSystem fs.FS, searchPath, name string) bool { + return FindFile(fileSystem, searchPath, name) != "" } // FindFile searches for the file inside the path recursively // and returns the full path of the file if found // If multiple files exist, tries to return the one closest to root -func FindFile(searchPath, name string) string { - files := FindAllFiles(searchPath, name) +func FindFile(fileSystem fs.FS, searchPath, name string) string { + files := FindAllFiles(fileSystem, searchPath, name) if len(files) == 0 { return "" } @@ -42,9 +43,12 @@ func FindFile(searchPath, name string) string { } // FindAllFiles searches for the file inside the path recursively and returns all matches -func FindAllFiles(searchPath, name string) []string { +func FindAllFiles(fileSystem fs.FS, searchPath, name string) []string { found := make([]string, 0) - _ = filepath.WalkDir(searchPath, func(p string, d os.DirEntry, err error) error { + if searchPath == "" { + searchPath = "." + } + _ = fs.WalkDir(fileSystem, searchPath, func(p string, d os.DirEntry, err error) error { if err != nil { return err } @@ -90,14 +94,13 @@ func GetMapValue(keyPath []string, data map[string]interface{}) (value interface } // GetJSONValue gets a value from a JSON file, by traversing the path given -func GetJSONValue(keyPath []string, filePath string, caseInsensitive bool) (value interface{}, ok bool) { - fin, err := os.Open(filePath) - if err != nil { - return nil, false - } - defer fin.Close() - - rawData, err := io.ReadAll(fin) +func GetJSONValue( + fileSystem fs.FS, + keyPath []string, + filePath string, + caseInsensitive bool, +) (value interface{}, ok bool) { + rawData, err := fs.ReadFile(fileSystem, filePath) if err != nil { return nil, false } @@ -144,14 +147,13 @@ func ContainsStringInFile(file io.Reader, target string, caseInsensitive bool) ( } // GetTOMLValue gets a value from a TOML file, by traversing the path given -func GetTOMLValue(keyPath []string, filePath string, caseInsensitive bool) (value interface{}, ok bool) { - fin, err := os.Open(filePath) - if err != nil { - return nil, false - } - defer fin.Close() - - rawData, err := io.ReadAll(fin) +func GetTOMLValue( + fileSystem fs.FS, + keyPath []string, + filePath string, + caseInsensitive bool, +) (value interface{}, ok bool) { + rawData, err := fs.ReadFile(fileSystem, filePath) if err != nil { return nil, false } diff --git a/platformifier/django.go b/platformifier/django.go index 08d5cae..15a95ea 100644 --- a/platformifier/django.go +++ b/platformifier/django.go @@ -1,16 +1,15 @@ package platformifier import ( + "bytes" "context" "fmt" "io/fs" - "os" "path/filepath" "text/template" "github.com/Masterminds/sprig/v3" - "github.com/platformsh/platformify/internal/colors" "github.com/platformsh/platformify/internal/utils" "github.com/platformsh/platformify/vendorization" ) @@ -21,7 +20,7 @@ const ( importSettingsPshLine = "from .settings_psh import *" ) -func newDjangoPlatformifier(templates fs.FS, fileSystem FS) *djangoPlatformifier { +func newDjangoPlatformifier(templates, fileSystem fs.FS) *djangoPlatformifier { return &djangoPlatformifier{ templates: templates, fileSystem: fileSystem, @@ -30,64 +29,38 @@ func newDjangoPlatformifier(templates fs.FS, fileSystem FS) *djangoPlatformifier type djangoPlatformifier struct { templates fs.FS - fileSystem FS + fileSystem fs.FS } -func (p *djangoPlatformifier) Platformify(ctx context.Context, input *UserInput) error { - appRoot := filepath.Join(input.Root, input.ApplicationRoot) - if settingsPath := p.fileSystem.Find(appRoot, settingsPyFile, true); len(settingsPath) > 0 { - pshSettingsPath := filepath.Join(filepath.Dir(settingsPath[0]), settingsPshPyFile) +func (p *djangoPlatformifier) Platformify(ctx context.Context, input *UserInput) (map[string][]byte, error) { + files := make(map[string][]byte) + if settingsPath := utils.FindFile(p.fileSystem, input.ApplicationRoot, settingsPyFile); len(settingsPath) > 0 { + pshSettingsPath := filepath.Join(filepath.Dir(settingsPath), settingsPshPyFile) tpl, parseErr := template.New(settingsPshPyFile).Funcs(sprig.FuncMap()). ParseFS(p.templates, settingsPshPyFile) if parseErr != nil { - return fmt.Errorf("could not parse template: %w", parseErr) + return nil, fmt.Errorf("could not parse template: %w", parseErr) } - pshSettingsFile, err := p.fileSystem.Create(pshSettingsPath) - if err != nil { - return err - } - defer pshSettingsFile.Close() + pshSettingsBuffer := &bytes.Buffer{} assets, _ := vendorization.FromContext(ctx) - err = tpl.Execute(pshSettingsFile, templateData{input, assets}) - if err != nil { - return err + if err := tpl.Execute(pshSettingsBuffer, templateData{input, assets}); err != nil { + return nil, fmt.Errorf("could not execute template: %w", parseErr) } + files[pshSettingsPath] = pshSettingsBuffer.Bytes() - // append from .settings_psh import * to the bottom of settings.py - settingsFile, err := p.fileSystem.Open(settingsPath[0], os.O_APPEND|os.O_RDWR, 0o644) + settingsFile, err := fs.ReadFile(p.fileSystem, settingsPath) if err != nil { - return nil + return files, nil } - defer settingsFile.Close() - - // Check if there is an import line in the file - found, err := utils.ContainsStringInFile(settingsFile, importSettingsPshLine, false) - if err != nil { - return err - } - - if !found { - if _, err = settingsFile.Write([]byte("\n\n" + importSettingsPshLine + "\n")); err != nil { - out, _, ok := colors.FromContext(ctx) - if !ok { - return nil - } - fmt.Fprintf( - out, - colors.Colorize( - colors.WarningCode, - "We have created a %s file for you. Please add the following line to your %s file:\n", - ), - settingsPshPyFile, - settingsPyFile, - ) - fmt.Fprint(out, colors.Colorize(colors.WarningCode, " "+importSettingsPshLine+"\n")) - return nil + if !bytes.Contains(settingsFile, []byte(importSettingsPshLine)) { + b := bytes.NewBuffer(settingsFile) + if _, err := b.WriteString("\n\n" + importSettingsPshLine + "\n"); err == nil { + files[settingsPath] = b.Bytes() } } } - return nil + return files, nil } diff --git a/platformifier/django_test.go b/platformifier/django_test.go deleted file mode 100644 index 2962b6d..0000000 --- a/platformifier/django_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package platformifier - -import ( - "context" - "embed" - "errors" - "io/fs" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - - "github.com/platformsh/platformify/internal/utils" -) - -const ( - djangoTemplatesDir = "templates/django" -) - -var ( - //go:embed templates/django/* - testDjangoTemplatesFS embed.FS -) - -type PlatformifyDjangoSuiteTester struct { - suite.Suite - - cwd string - templates fs.FS - fileSystem *MockFS -} - -func (s *PlatformifyDjangoSuiteTester) SetupTest() { - ctrl := gomock.NewController(s.T()) - - s.fileSystem = NewMockFS(ctrl) - - cwd, err := os.Getwd() - require.NoError(s.T(), err) - s.cwd = cwd - - templates, err := fs.Sub(testDjangoTemplatesFS, djangoTemplatesDir) - require.NoError(s.T(), err) - s.templates = templates -} - -func (s *PlatformifyDjangoSuiteTester) TestSuccessfulFileCreation() { - // GIVEN mock buffers to store settings and PSH settings files - settingsBuff, settingsPSHBuff := &MockBuffer{}, &MockBuffer{} - // AND working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND the settings.py file exists - s.fileSystem.EXPECT(). - Find("", settingsPyFile, true). - Return([]string{settingsPyFile}).Times(1) - s.fileSystem.EXPECT(). - Open(gomock.Eq(settingsPyFile), gomock.Any(), gomock.Any()). - Return(settingsBuff, nil).Times(1) - // AND creation of the PSH settings file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(settingsPshPyFile)). - Return(settingsPSHBuff, nil).Times(1) - - // WHEN run config files creation - p := newDjangoPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it doesn't return any errors - assert.NoError(s.T(), err) - // AND the buffer contains settings file - assert.NotEmpty(s.T(), settingsPSHBuff) - - // WHEN check if settings file contains the line that imported psh settings file - found, err := utils.ContainsStringInFile(settingsBuff, importSettingsPshLine, false) - // THEN it doesn't return any errors - assert.NoError(s.T(), err) - // AND the line is found - assert.True(s.T(), found) -} - -func (s *PlatformifyDjangoSuiteTester) TestSettingsFileNotFound() { - // GIVEN mock buffer to store PSH settings file - buff := &MockBuffer{} - // AND working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND the settings.py file doesn't exist - s.fileSystem.EXPECT(). - Find("", settingsPyFile, true). - Return([]string{}).Times(1) - - // WHEN run config files creation - p := newDjangoPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it doesn't return any errors - assert.NoError(s.T(), err) - // AND the buffer is empty - assert.Empty(s.T(), buff) -} - -func (s *PlatformifyDjangoSuiteTester) TestPSHSettingsFileCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND the settings.py file exists - s.fileSystem.EXPECT(). - Find("", settingsPyFile, true). - Return([]string{settingsPyFile}).Times(1) - // AND creating PSH settings file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(settingsPshPyFile)). - Return(nil, errors.New("")).Times(1) - - // WHEN run config files creation - p := newDjangoPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func TestPlatformifyDjangoSuite(t *testing.T) { - suite.Run(t, new(PlatformifyDjangoSuiteTester)) -} diff --git a/platformifier/fs.go b/platformifier/fs.go deleted file mode 100644 index 9d60627..0000000 --- a/platformifier/fs.go +++ /dev/null @@ -1,90 +0,0 @@ -package platformifier - -import ( - "errors" - "io" - "io/fs" - "os" - "path" - "path/filepath" - "slices" - "strings" -) - -var skipDirs = []string{ - "vendor", - "node_modules", - ".next", - ".git", -} - -//go:generate mockgen -destination=fs_mock_test.go -package=platformifier -source=fs.go -type FS interface { - Create(name string) (io.WriteCloser, error) - Find(root, name string, firstMatch bool) []string - Open(name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) -} - -func NewOSFileSystem(root string) *OSFileSystem { - return &OSFileSystem{ - root: root, - } -} - -type OSFileSystem struct { - root string -} - -func (f *OSFileSystem) Open(name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { - return os.OpenFile(f.fullPath(name), flag, perm) -} - -func (f *OSFileSystem) Create(name string) (io.WriteCloser, error) { - filePath := f.fullPath(name) - if err := os.MkdirAll(path.Dir(filePath), os.ModeDir|os.ModePerm); err != nil { - return nil, err - } - - return os.Create(filePath) -} - -// Find searches for the file inside the path recursively and returns all matches -func (f *OSFileSystem) Find(root, name string, firstMatch bool) []string { - root = strings.TrimPrefix(root, "/") - if root == "" { - root = "." - } - found := make([]string, 0) - _ = fs.WalkDir(f.readonly(), root, func(p string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - // Skip vendor directories - if slices.Contains(skipDirs, d.Name()) { - return filepath.SkipDir - } - return nil - } - - if d.Name() == name { - found = append(found, p) - if firstMatch { - return errors.New("found") - } - } - - return nil - }) - - return found -} - -func (f *OSFileSystem) readonly() fs.FS { - return os.DirFS(f.root) -} - -func (f *OSFileSystem) fullPath(name string) string { - return filepath.Join(f.root, name) -} diff --git a/platformifier/fs_mock_test.go b/platformifier/fs_mock_test.go deleted file mode 100644 index af7f785..0000000 --- a/platformifier/fs_mock_test.go +++ /dev/null @@ -1,80 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: fs.go - -// Package platformifier is a generated GoMock package. -package platformifier - -import ( - io "io" - os "os" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockFS is a mock of FS interface. -type MockFS struct { - ctrl *gomock.Controller - recorder *MockFSMockRecorder -} - -// MockFSMockRecorder is the mock recorder for MockFS. -type MockFSMockRecorder struct { - mock *MockFS -} - -// NewMockFS creates a new mock instance. -func NewMockFS(ctrl *gomock.Controller) *MockFS { - mock := &MockFS{ctrl: ctrl} - mock.recorder = &MockFSMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockFS) EXPECT() *MockFSMockRecorder { - return m.recorder -} - -// Create mocks base method. -func (m *MockFS) Create(name string) (io.WriteCloser, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", name) - ret0, _ := ret[0].(io.WriteCloser) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Create indicates an expected call of Create. -func (mr *MockFSMockRecorder) Create(name interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFS)(nil).Create), name) -} - -// Find mocks base method. -func (m *MockFS) Find(root, name string, firstMatch bool) []string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Find", root, name, firstMatch) - ret0, _ := ret[0].([]string) - return ret0 -} - -// Find indicates an expected call of Find. -func (mr *MockFSMockRecorder) Find(root, name, firstMatch interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockFS)(nil).Find), root, name, firstMatch) -} - -// Open mocks base method. -func (m *MockFS) Open(name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Open", name, flag, perm) - ret0, _ := ret[0].(io.ReadWriteCloser) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Open indicates an expected call of Open. -func (mr *MockFSMockRecorder) Open(name, flag, perm interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFS)(nil).Open), name, flag, perm) -} diff --git a/platformifier/generic.go b/platformifier/generic.go index f38fbc2..5280b9a 100644 --- a/platformifier/generic.go +++ b/platformifier/generic.go @@ -3,8 +3,6 @@ package platformifier import ( "bytes" "context" - "fmt" - "io" "io/fs" "strings" "text/template" @@ -14,7 +12,7 @@ import ( "github.com/Masterminds/sprig/v3" ) -func newGenericPlatformifier(templates fs.FS, fileSystem FS) *genericPlatformifier { +func newGenericPlatformifier(templates, fileSystem fs.FS) *genericPlatformifier { return &genericPlatformifier{ templates: templates, fileSystem: fileSystem, @@ -24,12 +22,13 @@ func newGenericPlatformifier(templates fs.FS, fileSystem FS) *genericPlatformifi // genericPlatformifier contains the configuration for the application to Platformify type genericPlatformifier struct { templates fs.FS - fileSystem FS + fileSystem fs.FS } // Platformify will generate the needed configuration files in the current directory. -func (p *genericPlatformifier) Platformify(ctx context.Context, input *UserInput) error { +func (p *genericPlatformifier) Platformify(ctx context.Context, input *UserInput) (map[string][]byte, error) { assets, _ := vendorization.FromContext(ctx) + files := make(map[string][]byte) err := fs.WalkDir(p.templates, ".", func(name string, d fs.DirEntry, _ error) error { if d.IsDir() { return nil @@ -45,21 +44,12 @@ func (p *genericPlatformifier) Platformify(ctx context.Context, input *UserInput return nil } - f, writeErr := p.fileSystem.Create(name) - if writeErr != nil { - return fmt.Errorf("could not write template: %w", writeErr) - } - defer f.Close() - - if _, err := io.Copy(f, contents); err != nil { - return err - } - + files[name] = contents.Bytes() return nil }) if err != nil { - return err + return nil, err } - return nil + return files, nil } diff --git a/platformifier/generic_test.go b/platformifier/generic_test.go deleted file mode 100644 index 86db6f5..0000000 --- a/platformifier/generic_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package platformifier - -import ( - "bytes" - "context" - "embed" - "errors" - "io/fs" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" -) - -const ( - genericTemplatesDir = "templates/generic" - environmentFile = ".environment" - appConfigFile = ".platform.app.yaml" - routesConfigFile = ".platform/routes.yaml" - servicesConfigFile = ".platform/services.yaml" -) - -var ( - //go:embed templates/generic/* - testGenericTemplatesFS embed.FS -) - -type MockBuffer struct { - bytes.Buffer -} - -func (b *MockBuffer) Close() error { - return nil -} - -type PlatformifyGenericSuiteTester struct { - suite.Suite - - cwd string - templates fs.FS - fileSystem *MockFS -} - -func (s *PlatformifyGenericSuiteTester) SetupTest() { - ctrl := gomock.NewController(s.T()) - - cwd, err := os.Getwd() - require.NoError(s.T(), err) - s.cwd = cwd - - templates, err := fs.Sub(testGenericTemplatesFS, genericTemplatesDir) - require.NoError(s.T(), err) - s.templates = templates - - s.fileSystem = NewMockFS(ctrl) -} - -func (s *PlatformifyGenericSuiteTester) TestSuccessfulConfigsCreation() { - // GIVEN mock buffers to store config files - envBuff, appBuff, routesBuff, servicesBuff := &MockBuffer{}, &MockBuffer{}, &MockBuffer{}, &MockBuffer{} - // AND working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creation of the environment file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(environmentFile)). - Return(envBuff, nil).Times(1) - // AND creation of the app config file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(appConfigFile)). - Return(appBuff, nil).Times(1) - // AND creation of the routes config file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(routesConfigFile)). - Return(routesBuff, nil).Times(1) - // AND creation of the services config file returns no errors - s.fileSystem.EXPECT(). - Create(gomock.Eq(servicesConfigFile)). - Return(servicesBuff, nil).Times(1) - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it doesn't return any errors - assert.NoError(s.T(), err) - // AND the buffers contain configs - assert.NotEmpty(s.T(), envBuff) - assert.NotEmpty(s.T(), appBuff) - assert.NotEmpty(s.T(), routesBuff) - assert.NotEmpty(s.T(), servicesBuff) -} - -func (s *PlatformifyGenericSuiteTester) TestEnvironmentCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creating environment file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(environmentFile)). - Return(nil, errors.New("")).Times(1) - // AND creating other config files work fine - s.fileSystem.EXPECT(). - Create(gomock.Any()). - Return(&MockBuffer{}, nil).AnyTimes() - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func (s *PlatformifyGenericSuiteTester) TestAppConfigCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creating app config file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(appConfigFile)). - Return(nil, errors.New("")).Times(1) - // AND creating other config files work fine - s.fileSystem.EXPECT(). - Create(gomock.Any()). - Return(&MockBuffer{}, nil).AnyTimes() - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func (s *PlatformifyGenericSuiteTester) TestRoutesConfigCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creating routes config file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(routesConfigFile)). - Return(nil, errors.New("")).Times(1) - // AND creating other config files work fine - s.fileSystem.EXPECT(). - Create(gomock.Any()). - Return(&MockBuffer{}, nil).AnyTimes() - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func (s *PlatformifyGenericSuiteTester) TestServicesConfigCreationError() { - // GIVEN working directory is a current directory - input := &UserInput{WorkingDirectory: s.cwd} - // AND creating services config file fails - s.fileSystem.EXPECT(). - Create(gomock.Eq(servicesConfigFile)). - Return(nil, errors.New("")).Times(1) - // AND creating other config files work fine - s.fileSystem.EXPECT(). - Create(gomock.Any()). - Return(&MockBuffer{}, nil).AnyTimes() - - // WHEN run config files creation - p := newGenericPlatformifier(s.templates, s.fileSystem) - err := p.Platformify(context.Background(), input) - // THEN it fails - assert.Error(s.T(), err) -} - -func TestPlatformifyGenericSuite(t *testing.T) { - suite.Run(t, new(PlatformifyGenericSuiteTester)) -} diff --git a/platformifier/laravel.go b/platformifier/laravel.go index 7ffd5c6..e4fd0a9 100644 --- a/platformifier/laravel.go +++ b/platformifier/laravel.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io/fs" - "path/filepath" "github.com/platformsh/platformify/internal/colors" "github.com/platformsh/platformify/internal/utils" @@ -14,7 +13,7 @@ const ( composerJSONFile = "composer.json" ) -func newLaravelPlatformifier(templates fs.FS, fileSystem FS) *laravelPlatformifier { +func newLaravelPlatformifier(templates, fileSystem fs.FS) *laravelPlatformifier { return &laravelPlatformifier{ templates: templates, fileSystem: fileSystem, @@ -23,19 +22,23 @@ func newLaravelPlatformifier(templates fs.FS, fileSystem FS) *laravelPlatformifi type laravelPlatformifier struct { templates fs.FS - fileSystem FS + fileSystem fs.FS } -func (p *laravelPlatformifier) Platformify(ctx context.Context, input *UserInput) error { +func (p *laravelPlatformifier) Platformify(ctx context.Context, input *UserInput) (map[string][]byte, error) { // Check for the Laravel Bridge. - appRoot := filepath.Join(input.Root, input.ApplicationRoot) - composerJSONPaths := p.fileSystem.Find(appRoot, composerJSONFile, false) + composerJSONPaths := utils.FindAllFiles(p.fileSystem, input.ApplicationRoot, composerJSONFile) for _, composerJSONPath := range composerJSONPaths { - _, required := utils.GetJSONValue([]string{"require", "platformsh/laravel-bridge"}, composerJSONPath, true) + _, required := utils.GetJSONValue( + p.fileSystem, + []string{"require", "platformsh/laravel-bridge"}, + composerJSONPath, + true, + ) if !required { out, _, ok := colors.FromContext(ctx) if !ok { - return fmt.Errorf("output context failed") + return nil, fmt.Errorf("output context failed") } var suggest = "\nPlease use composer to add the Laravel Bridge to your project:\n" @@ -44,5 +47,5 @@ func (p *laravelPlatformifier) Platformify(ctx context.Context, input *UserInput } } - return nil + return nil, nil } diff --git a/platformifier/nextjs.go b/platformifier/nextjs.go deleted file mode 100644 index 5fcc375..0000000 --- a/platformifier/nextjs.go +++ /dev/null @@ -1,20 +0,0 @@ -package platformifier - -import ( - "context" - "io/fs" -) - -func newNextJSPlatformifier(templates fs.FS) *nextJSPlatformifier { - return &nextJSPlatformifier{ - templates: templates, - } -} - -type nextJSPlatformifier struct { - templates fs.FS -} - -func (p *nextJSPlatformifier) Platformify(_ context.Context, _ *UserInput) error { - return nil -} diff --git a/platformifier/platformifier.go b/platformifier/platformifier.go index e886771..3afc9a8 100644 --- a/platformifier/platformifier.go +++ b/platformifier/platformifier.go @@ -29,8 +29,8 @@ const ( // //go:generate mockgen -destination=platformifier_mock_test.go -package=platformifier -source=platformifier.go type platformifier interface { - // Platformify loads and writes the templates to the user's system. - Platformify(ctx context.Context, input *UserInput) error + // Platformify loads and returns the rendered templates that should be written to the user's system. + Platformify(ctx context.Context, input *UserInput) (map[string][]byte, error) } type templateData struct { @@ -39,14 +39,7 @@ type templateData struct { } // New creates Platformifier with the appropriate platformifier stack based on UserInput. -func New(input *UserInput, flavor string, fileSystems ...FS) *Platformifier { - var fileSystem FS - if len(fileSystems) > 0 { - fileSystem = fileSystems[0] - } else { - fileSystem = NewOSFileSystem(input.WorkingDirectory) - } - +func New(input *UserInput, flavor string) *Platformifier { // fs.Sub(...) returns an error only if the given path name is invalid. // Since we determine the path name ourselves in advance, // there is no need to check for errors in this path name. @@ -57,21 +50,17 @@ func New(input *UserInput, flavor string, fileSystems ...FS) *Platformifier { } templates, _ := fs.Sub(templatesFS, templatesDir) - stacks = append(stacks, newGenericPlatformifier(templates, fileSystem)) + stacks = append(stacks, newGenericPlatformifier(templates, input.WorkingDirectory)) switch input.Stack { case Django: // No need to check for errors (see the comment above) templates, _ := fs.Sub(templatesFS, djangoDir) - stacks = append(stacks, newDjangoPlatformifier(templates, fileSystem)) + stacks = append(stacks, newDjangoPlatformifier(templates, input.WorkingDirectory)) case Laravel: // No need to check for errors (see the comment above) templates, _ := fs.Sub(templatesFS, laravelDir) - stacks = append(stacks, newLaravelPlatformifier(templates, fileSystem)) - case NextJS: - // No need to check for errors (see the comment above) - templates, _ := fs.Sub(templatesFS, nextjsDir) - stacks = append(stacks, newNextJSPlatformifier(templates)) + stacks = append(stacks, newLaravelPlatformifier(templates, input.WorkingDirectory)) } return &Platformifier{ @@ -86,12 +75,18 @@ type Platformifier struct { stacks []platformifier } -func (p *Platformifier) Platformify(ctx context.Context) error { +func (p *Platformifier) Platformify(ctx context.Context) (map[string][]byte, error) { + files := make(map[string][]byte) for _, stack := range p.stacks { - err := stack.Platformify(ctx, p.input) + newFiles, err := stack.Platformify(ctx, p.input) if err != nil { - return err + return nil, err + } + + for p, contents := range newFiles { + files[p] = contents } } - return nil + + return files, nil } diff --git a/platformifier/platformifier_mock_test.go b/platformifier/platformifier_mock_test.go deleted file mode 100644 index 82a4a67..0000000 --- a/platformifier/platformifier_mock_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: platformifier.go - -// Package platformifier is a generated GoMock package. -package platformifier - -import ( - context "context" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// Mockplatformifier is a mock of platformifier interface. -type Mockplatformifier struct { - ctrl *gomock.Controller - recorder *MockplatformifierMockRecorder -} - -// MockplatformifierMockRecorder is the mock recorder for Mockplatformifier. -type MockplatformifierMockRecorder struct { - mock *Mockplatformifier -} - -// NewMockplatformifier creates a new mock instance. -func NewMockplatformifier(ctrl *gomock.Controller) *Mockplatformifier { - mock := &Mockplatformifier{ctrl: ctrl} - mock.recorder = &MockplatformifierMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *Mockplatformifier) EXPECT() *MockplatformifierMockRecorder { - return m.recorder -} - -// Platformify mocks base method. -func (m *Mockplatformifier) Platformify(ctx context.Context, input *UserInput) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Platformify", ctx, input) - ret0, _ := ret[0].(error) - return ret0 -} - -// Platformify indicates an expected call of Platformify. -func (mr *MockplatformifierMockRecorder) Platformify(ctx, input interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Platformify", reflect.TypeOf((*Mockplatformifier)(nil).Platformify), ctx, input) -} diff --git a/platformifier/platformifier_test.go b/platformifier/platformifier_test.go deleted file mode 100644 index 282ba84..0000000 --- a/platformifier/platformifier_test.go +++ /dev/null @@ -1,578 +0,0 @@ -package platformifier - -import ( - "context" - "errors" - "io/fs" - "os" - "reflect" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - - "github.com/platformsh/platformify/validator" -) - -var ( - djangoStack = &UserInput{ - Name: "django", - Type: "python", - Stack: Django, - Runtime: "python-3.9", - ApplicationRoot: "app", - Environment: map[string]string{ - "DJANGO_SETTINGS_MODULE": "app.settings", - "PYTHONUNBUFFERED": "1", - }, - Root: "app", - BuildFlavor: "none", - BuildSteps: []string{ - "pip install -r requirements.txt", - "# comment here", - "python manage.py collectstatic --noinput", - }, - WebCommand: "gunicorn app.wsgi", - SocketFamily: "unix", - DeployCommand: []string{ - "python manage.py migrate", - "# comment here", - }, - DependencyManagers: []string{"pip", "yarn"}, - Locations: map[string]map[string]interface{}{ - "/": { - "passthru": true, - }, - "/static": { - "root": "static", - "expires": "1h", - "allow": true, - }, - }, - Dependencies: map[string]map[string]string{ - "python": { - "poetry": "*", - "pip": ">=20.0.0", - }, - "node": { - "yarn": "*", - "npm": ">=6.0.0", - }, - }, - Disk: "1024", - Mounts: map[string]map[string]string{ - "/.npm": { - "source": "local", - "source_path": "npm", - }, - "/.pip": { - "source": "local", - "source_path": "pip", - }, - }, - Relationships: map[string]Relationship{ - "db": {Service: "db", Endpoint: "postgresql"}, - "mysql": {Service: "mysql", Endpoint: "mysql"}, - }, - HasGit: true, - Services: []Service{ - { - Name: "db", - Type: "postgres", - TypeVersions: []string{"13", "14", "15"}, - Disk: "1024", - DiskSizes: []string{"1024", "2048"}, - }, - { - Name: "mysql", - Type: "mysql", - TypeVersions: []string{"13", "14", "15"}, - Disk: "1024", - DiskSizes: []string{"1024", "2034"}, - }, - }, - } - genericStack = &UserInput{ - Name: "Generic", - Type: "java", - Stack: Generic, - Environment: map[string]string{ - "JAVA": "19", - }, - Root: "app", - BuildFlavor: "", - BuildSteps: []string{ - "mvn install", - }, - WebCommand: "tomcat", - SocketFamily: "tcp", - DeployCommand: []string{}, - DependencyManagers: []string{"mvn"}, - Locations: map[string]map[string]interface{}{ - "/": { - "passthru": true, - }, - }, - Dependencies: map[string]map[string]string{}, - Disk: "1024", - Mounts: map[string]map[string]string{ - "/.mvn": { - "source": "local", - "source_path": "maven", - }, - }, - Relationships: map[string]Relationship{ - "mysql": {Service: "mysql", Endpoint: "mysql"}, - }, - HasGit: true, - Services: []Service{ - { - Name: "mysql", - Type: "mysql", - TypeVersions: []string{"13", "14", "15"}, - Disk: "1024", - DiskSizes: []string{"1024", "2048"}, - }, - }, - } - laravelStack = &UserInput{ - Name: "Laravel", - Type: "php", - Stack: Laravel, - Runtime: "php-8.2", - ApplicationRoot: "app", - Environment: map[string]string{}, - Root: "app", - BuildFlavor: "php", - BuildSteps: []string{}, - DeployCommand: []string{}, - DependencyManagers: []string{"composer"}, - Locations: map[string]map[string]interface{}{ - "/": { - "root": "index.php", - }, - }, - Dependencies: map[string]map[string]string{}, - Disk: "", - Mounts: map[string]map[string]string{}, - Relationships: map[string]Relationship{}, - HasGit: false, - Services: []Service{}, - } - nextJSStack = &UserInput{ - Name: "Next.js", - Type: "node", - Stack: NextJS, - } - strapiStack = &UserInput{ - Name: "Strapi", - Type: "node", - Stack: Strapi, - } - flaskStack = &UserInput{ - Name: "Flask", - Type: "python", - Stack: Flask, - } - expressStack = &UserInput{ - Name: "Express", - Type: "node", - Stack: Express, - } -) - -func TestNewPlatformifier(t *testing.T) { - genericTemplates, err := fs.Sub(templatesFS, genericDir) - require.NoError(t, err) - djangoTemplates, err := fs.Sub(templatesFS, djangoDir) - require.NoError(t, err) - laravelTemplates, err := fs.Sub(templatesFS, laravelDir) - require.NoError(t, err) - nextjsTemplates, err := fs.Sub(templatesFS, nextjsDir) - require.NoError(t, err) - fileSystem := NewOSFileSystem("") - tests := []struct { - name string - stack Stack - platformifiers []platformifier - }{ - { - name: "generic", - stack: Generic, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "django", - stack: Django, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - &djangoPlatformifier{templates: djangoTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "laravel", - stack: Laravel, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - &laravelPlatformifier{templates: laravelTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "nextjs", - stack: NextJS, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - &nextJSPlatformifier{templates: nextjsTemplates}, - }, - }, - { - name: "strapi", - stack: Strapi, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "flask", - stack: Flask, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - }, - }, - { - name: "express", - stack: Express, - platformifiers: []platformifier{ - &genericPlatformifier{templates: genericTemplates, fileSystem: fileSystem}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // GIVEN user input with given stack - input := &UserInput{Stack: tt.stack} - - // WHEN create new platformifier - pfier := New(input, "platform") - // THEN user input inside platformifier should be the same as given - assert.Equal(t, input, pfier.input) - // AND length of the platformifier's stack must be equal to the length of expected stacks - require.Len(t, pfier.stacks, len(tt.platformifiers)) - for i := range pfier.stacks { - // AND the type of each stack should be the same as expected - assert.IsType(t, tt.platformifiers[i], pfier.stacks[i]) - assert.True(t, reflect.DeepEqual(tt.platformifiers[i], pfier.stacks[i])) - } - }) - } -} - -type PlatformifySuiteTester struct { - suite.Suite - - generic *Mockplatformifier - django *Mockplatformifier - laravel *Mockplatformifier - nextjs *Mockplatformifier -} - -func (s *PlatformifySuiteTester) SetupTest() { - ctrl := gomock.NewController(s.T()) - - s.generic = NewMockplatformifier(ctrl) - s.django = NewMockplatformifier(ctrl) - s.laravel = NewMockplatformifier(ctrl) - s.nextjs = NewMockplatformifier(ctrl) -} - -func (s *PlatformifySuiteTester) TestSuccessfulPlatformifying() { - // GIVEN empty context - ctx := context.Background() - // AND user input is empty (because it doesn't matter if it's empty or not) - input := &UserInput{} - // AND platformifying generic stack returns no errors - s.generic.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(nil).AnyTimes() - // AND platformifying django stack returns no errors - s.django.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(nil).AnyTimes() - // AND platformifying laravel stack returns no errors - s.laravel.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(nil).AnyTimes() - // AND platformifying nextjs stack returns no errors - s.nextjs.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(nil).AnyTimes() - - tests := []struct { - name string - stacks []platformifier - }{ - { - name: "empty", - stacks: []platformifier{}, - }, - { - name: "generic", - stacks: []platformifier{s.generic}, - }, - { - name: "django", - stacks: []platformifier{s.django}, - }, - { - name: "laravel", - stacks: []platformifier{s.laravel}, - }, - { - name: "nextjs", - stacks: []platformifier{s.nextjs}, - }, - { - name: "generic+django", - stacks: []platformifier{s.generic, s.django}, - }, - { - name: "generic+laravel", - stacks: []platformifier{s.generic, s.laravel}, - }, - { - name: "generic+nextjs", - stacks: []platformifier{s.generic, s.nextjs}, - }, - { - name: "all", - stacks: []platformifier{s.generic, s.django, s.laravel, s.nextjs}, - }, - } - - for _, tt := range tests { - s.T().Run(tt.name, func(t *testing.T) { - // WHEN run platformifying of the given stack - p := Platformifier{ - input: input, - stacks: tt.stacks, - } - err := p.Platformify(ctx) - // THEN it doesn't return any errors - assert.NoError(t, err) - }) - } -} - -func (s *PlatformifySuiteTester) TestPlatformifyingError() { - // GIVEN empty context - ctx := context.Background() - // AND user input is empty (because it doesn't matter if it's empty or not) - input := &UserInput{} - // AND platformifying generic stack fails - s.generic.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(errors.New("")).AnyTimes() - // AND platformifying django stack fails - s.django.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(errors.New("")).AnyTimes() - // AND platformifying laravel stack fails - s.laravel.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(errors.New("")).AnyTimes() - // AND platformifying nextjs stack fails - s.nextjs.EXPECT(). - Platformify(gomock.Eq(ctx), gomock.Eq(input)). - Return(errors.New("")).AnyTimes() - - tests := []struct { - name string - stacks []platformifier - }{ - { - name: "generic", - stacks: []platformifier{s.generic}, - }, - { - name: "django", - stacks: []platformifier{s.django}, - }, - { - name: "laravel", - stacks: []platformifier{s.laravel}, - }, - { - name: "nextjs", - stacks: []platformifier{s.nextjs}, - }, - { - name: "generic+django", - stacks: []platformifier{s.generic, s.django}, - }, - { - name: "generic+laravel", - stacks: []platformifier{s.generic, s.laravel}, - }, - { - name: "generic+nextjs", - stacks: []platformifier{s.generic, s.nextjs}, - }, - { - name: "all", - stacks: []platformifier{s.generic, s.django, s.laravel, s.nextjs}, - }, - } - - for _, tt := range tests { - s.T().Run(tt.name, func(t *testing.T) { - // WHEN run platformifying of the given stack - p := Platformifier{ - input: input, - stacks: tt.stacks, - } - err := p.Platformify(ctx) - // THEN it fails - assert.Error(t, err) - }) - } -} - -func TestPlatformifySuite(t *testing.T) { - suite.Run(t, new(PlatformifySuiteTester)) -} - -func TestPlatformifier_Platformify(t *testing.T) { - type fields struct { - ui *UserInput - } - tests := []struct { - name string - fields fields - wantErr bool - }{ - { - name: "Django", - fields: fields{ui: djangoStack}, - }, - { - name: "Generic", - fields: fields{ui: genericStack}, - }, - { - name: "Laravel", - fields: fields{ui: laravelStack}, - }, - { - name: "Next.js", - fields: fields{ui: nextJSStack}, - }, - { - name: "Strapi", - fields: fields{ui: strapiStack}, - }, - { - name: "Flask", - fields: fields{ui: flaskStack}, - }, - { - name: "Express", - fields: fields{ui: expressStack}, - }, - } - - // Create a temporary directory to use as the output directory. - tempDir, err := os.MkdirTemp("", "yaml_tests") - if err != nil { - t.Fatalf("Failed to create temporary directory: %v", err) - } - defer os.RemoveAll(tempDir) - - ctx := context.Background() - for _, tt := range tests { - dir, err := os.MkdirTemp(tempDir, tt.name) - if err != nil { - t.Fatalf("Failed to create temporary %v directory: %v", tt.name, err) - } - tt.fields.ui.WorkingDirectory = dir - t.Run(tt.name, func(t *testing.T) { - if err := New(tt.fields.ui, "platform").Platformify(ctx); (err != nil) != tt.wantErr { - t.Errorf("Platformifier.Platformify() error = %v, wantErr %v", err, tt.wantErr) - } - - // Validate the config. - if err := validator.ValidateConfig(dir, "platform"); (err != nil) != tt.wantErr { - t.Errorf("Platformifier.Platformify() validation error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestPlatformifier_Upsunify(t *testing.T) { - type fields struct { - ui *UserInput - } - tests := []struct { - name string - fields fields - wantErr bool - }{ - { - name: "Django", - fields: fields{ui: djangoStack}, - }, - { - name: "Generic", - fields: fields{ui: genericStack}, - }, - { - name: "Laravel", - fields: fields{ui: laravelStack}, - }, - { - name: "Next.js", - fields: fields{ui: nextJSStack}, - }, - { - name: "Strapi", - fields: fields{ui: strapiStack}, - }, - { - name: "Flask", - fields: fields{ui: flaskStack}, - }, - { - name: "Express", - fields: fields{ui: expressStack}, - }, - } - - // Create a temporary directory to use as the output directory. - tempDir, err := os.MkdirTemp("", "yaml_tests") - if err != nil { - t.Fatalf("Failed to create temporary directory: %v", err) - } - defer os.RemoveAll(tempDir) - - ctx := context.Background() - for _, tt := range tests { - dir, err := os.MkdirTemp(tempDir, tt.name) - if err != nil { - t.Fatalf("Failed to create temporary %v directory: %v", tt.name, err) - } - tt.fields.ui.WorkingDirectory = dir - t.Run(tt.name, func(t *testing.T) { - if err := New(tt.fields.ui, "upsun").Platformify(ctx); (err != nil) != tt.wantErr { - t.Errorf("Platformifier.Platformify() error = %v, wantErr %v", err, tt.wantErr) - } - - // Validate the config. - if err := validator.ValidateConfig(dir, "upsun"); (err != nil) != tt.wantErr { - t.Errorf("Platformifier.Platformify() validation error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/validator/validator.go b/validator/validator.go index 3cd4d50..b7f0a62 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -16,8 +16,8 @@ import ( ) // ValidateFile checks the file exists and is valid yaml, then returns the unmarshalled data. -func ValidateFile(path string, schema *gojsonschema.Schema) (map[string]interface{}, error) { - rawData, err := os.ReadFile(path) +func ValidateFile(fileSystem fs.FS, path string, schema *gojsonschema.Schema) (map[string]interface{}, error) { + rawData, err := fs.ReadFile(fileSystem, path) if err != nil { return nil, err } @@ -51,18 +51,18 @@ func ValidateFile(path string, schema *gojsonschema.Schema) (map[string]interfac } // ValidateConfig uses ValidateFile and to check config for a given directory is valid config. -func ValidateConfig(path, flavor string) error { +func ValidateConfig(fileSystem fs.FS, flavor string) error { switch flavor { case "platform": - return validatePlatformConfig(path) + return validatePlatformConfig(fileSystem) case "upsun": - return validateUpsunConfig(os.DirFS(path)) + return validateUpsunConfig(fileSystem) default: return fmt.Errorf("unknown flavor: %s", flavor) } } -func validatePlatformConfig(path string) error { +func validatePlatformConfig(fileSystem fs.FS) error { var errs error files := map[string]*gojsonschema.Schema{ ".platform/routes.yaml": routesSchema, @@ -70,8 +70,7 @@ func validatePlatformConfig(path string) error { } for file, schema := range files { - absPath := filepath.Join(path, file) - if _, err := os.Stat(absPath); err != nil { + if _, err := fs.Stat(fileSystem, file); err != nil { if os.IsNotExist(err) { continue } @@ -80,17 +79,16 @@ func validatePlatformConfig(path string) error { continue } - if _, err := ValidateFile(absPath, schema); err != nil { + if _, err := ValidateFile(fileSystem, file, schema); err != nil { errs = errors.Join(errs, fmt.Errorf("validation failed for %s: %w", file, err)) } } foundApp := false - for _, file := range utils.FindAllFiles(path, ".platform.app.yaml") { + for _, file := range utils.FindAllFiles(fileSystem, "", ".platform.app.yaml") { foundApp = true - if _, err := ValidateFile(file, applicationSchema); err != nil { - relPath, _ := filepath.Rel(path, file) - errs = errors.Join(errs, fmt.Errorf("validation failed for %s: %w", relPath, err)) + if _, err := ValidateFile(fileSystem, file, applicationSchema); err != nil { + errs = errors.Join(errs, fmt.Errorf("validation failed for %s: %w", file, err)) } } From 0b64545cdc631e07f17f25f8c141f037d1010af9 Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Tue, 5 Dec 2023 09:16:06 +0200 Subject: [PATCH 2/9] Add discovery for runtime and stack --- discovery/discovery.go | 22 +++ discovery/fs.go | 41 +++++ discovery/runtime.go | 80 ++++++++ discovery/runtime_test.go | 80 ++++++++ discovery/stack.go | 191 +++++++++++++++++++ internal/question/models/answer.go | 43 ++--- internal/question/models/registry.json | 18 +- internal/question/stack.go | 242 ++++++------------------- internal/question/working_directory.go | 3 + platformifier/models.go | 9 + 10 files changed, 508 insertions(+), 221 deletions(-) create mode 100644 discovery/discovery.go create mode 100644 discovery/fs.go create mode 100644 discovery/runtime.go create mode 100644 discovery/runtime_test.go create mode 100644 discovery/stack.go diff --git a/discovery/discovery.go b/discovery/discovery.go new file mode 100644 index 0000000..7eb1790 --- /dev/null +++ b/discovery/discovery.go @@ -0,0 +1,22 @@ +package discovery + +import ( + "io/fs" +) + +const ( + settingsPyFile = "settings.py" + managePyFile = "manage.py" + composerJSONFile = "composer.json" + packageJSONFile = "package.json" + symfonyLockFile = "symfony.lock" +) + +type Discoverer struct { + fileSystem fs.FS + memory map[string]any +} + +func New(fileSystem fs.FS) *Discoverer { + return &Discoverer{fileSystem: fileSystem, memory: make(map[string]any)} +} diff --git a/discovery/fs.go b/discovery/fs.go new file mode 100644 index 0000000..c8bed68 --- /dev/null +++ b/discovery/fs.go @@ -0,0 +1,41 @@ +package discovery + +import ( + "io/fs" + "path/filepath" + "slices" +) + +var skipDirs = []string{ + "vendor", + "node_modules", + ".next", + ".git", +} + +func (d *Discoverer) CountFiles() (map[string]int, error) { + fileCounter := make(map[string]int) + err := fs.WalkDir(d.fileSystem, ".", func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + if slices.Contains(skipDirs, d.Name()) { + return filepath.SkipDir + } + + return nil + } + + ext := filepath.Ext(path) + _, ok := fileCounter[ext] + if !ok { + fileCounter[ext] = 0 + } + + fileCounter[ext] += 1 + return nil + }) + if err != nil { + return nil, err + } + + return fileCounter, nil +} diff --git a/discovery/runtime.go b/discovery/runtime.go new file mode 100644 index 0000000..b580e73 --- /dev/null +++ b/discovery/runtime.go @@ -0,0 +1,80 @@ +package discovery + +import "github.com/platformsh/platformify/platformifier" + +var ( + languageMap = map[string]string{ + ".py": "python", + ".js": "nodejs", + ".go": "go", + ".php": "php", + ".rb": "ruby", + ".exs": "elixir", + ".ex": "elixir", + ".cs": "dotnet", + ".rs": "rust", + ".lisp": "lisp", + ".lsp": "lisp", + ".l": "lisp", + ".cl": "lisp", + ".fasl": "lisp", + ".java": "java", + } +) + +// Returns the Runtime, either from memory or by discovering it on the spot +func (d *Discoverer) Type() (string, error) { + if stack, ok := d.memory["type"]; ok { + return stack.(string), nil + } + + typ, err := d.discoverType() + if err != nil { + return "", err + } + + d.memory["type"] = typ + return typ, nil +} + +func (d *Discoverer) discoverType() (string, error) { + stack, err := d.Stack() + if err != nil { + return "", err + } + + switch stack { + case platformifier.Laravel, platformifier.Symfony: + return "php", nil + case platformifier.Django, platformifier.Flask: + return "python", nil + case platformifier.Express, platformifier.NextJS, platformifier.Strapi: + return "nodejs", nil + } + + extCount, err := d.CountFiles() + if err != nil { + return "", err + } + + langCount := make(map[string]int) + for ext, count := range extCount { + if lang, ok := languageMap[ext]; ok { + if _, _ok := langCount[lang]; !_ok { + langCount[lang] = 0 + } + langCount[lang] += count + } + } + + max := 0 + selectedLang := "" + for lang, count := range langCount { + if count > max { + max = count + selectedLang = lang + } + } + + return selectedLang, nil +} diff --git a/discovery/runtime_test.go b/discovery/runtime_test.go new file mode 100644 index 0000000..fc478cc --- /dev/null +++ b/discovery/runtime_test.go @@ -0,0 +1,80 @@ +package discovery + +import ( + "io/fs" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/platformifier" +) + +func TestDiscoverer_discoverType(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want string + wantErr bool + }{ + { + name: "Python", + fields: fields{ + fileSystem: fstest.MapFS{ + "hey.py": &fstest.MapFile{}, + "hey.js": &fstest.MapFile{}, + "another.py": &fstest.MapFile{}, + }, + }, + want: "python", + wantErr: false, + }, + { + name: "Express stack override", + fields: fields{ + fileSystem: fstest.MapFS{ + "hey.py": &fstest.MapFile{}, + "hey.js": &fstest.MapFile{}, + "another.py": &fstest.MapFile{}, + }, + memory: map[string]any{"stack": platformifier.Express}, + }, + want: "nodejs", + wantErr: false, + }, + { + name: "Node.js skip vendor", + fields: fields{ + fileSystem: fstest.MapFS{ + "vendor/a.py": &fstest.MapFile{}, + "vendor/b.py": &fstest.MapFile{}, + "vendor/c.py": &fstest.MapFile{}, + "hey.js": &fstest.MapFile{}, + }, + }, + want: "nodejs", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.fields.memory == nil { + tt.fields.memory = make(map[string]any) + } + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + got, err := d.discoverType() + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverType() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Discoverer.discoverType() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/stack.go b/discovery/stack.go new file mode 100644 index 0000000..ee68586 --- /dev/null +++ b/discovery/stack.go @@ -0,0 +1,191 @@ +package discovery + +import ( + "slices" + "strings" + + "github.com/platformsh/platformify/internal/utils" + "github.com/platformsh/platformify/platformifier" +) + +// Returns the stack, either from memory or by discovering it on the spot +func (d *Discoverer) Stack() (platformifier.Stack, error) { + if stack, ok := d.memory["stack"]; ok { + return stack.(platformifier.Stack), nil + } + + stack, err := d.discoverStack() + if err != nil { + return platformifier.Generic, err + } + + d.memory["stack"] = stack + return stack, nil +} + +func (d *Discoverer) discoverStack() (platformifier.Stack, error) { + hasSettingsPy := utils.FileExists(d.fileSystem, "", settingsPyFile) + hasManagePy := utils.FileExists(d.fileSystem, "", managePyFile) + if hasSettingsPy && hasManagePy { + return platformifier.Django, nil + } + + requirementsPath := utils.FindFile(d.fileSystem, "", "requirements.txt") + if requirementsPath != "" { + f, err := d.fileSystem.Open(requirementsPath) + if err == nil { + defer f.Close() + if ok, _ := utils.ContainsStringInFile(f, "flask", true); ok { + return platformifier.Flask, nil + } + } + } + + pyProjectPath := utils.FindFile(d.fileSystem, "", "pyproject.toml") + if pyProjectPath != "" { + if _, ok := utils.GetTOMLValue( + d.fileSystem, + []string{"tool", "poetry", "dependencies", "flask"}, + pyProjectPath, + true, + ); ok { + return platformifier.Flask, nil + } + } + + pipfilePath := utils.FindFile(d.fileSystem, "", "Pipfile") + if pipfilePath != "" { + if _, ok := utils.GetTOMLValue( + d.fileSystem, + []string{"packages", "flask"}, + pipfilePath, + true, + ); ok { + return platformifier.Flask, nil + } + } + + composerJSONPaths := utils.FindAllFiles(d.fileSystem, "", composerJSONFile) + for _, composerJSONPath := range composerJSONPaths { + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"require", "laravel/framework"}, + composerJSONPath, + true, + ); ok { + return platformifier.Laravel, nil + } + } + + packageJSONPaths := utils.FindAllFiles(d.fileSystem, "", packageJSONFile) + for _, packageJSONPath := range packageJSONPaths { + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"dependencies", "next"}, + packageJSONPath, + true, + ); ok { + return platformifier.NextJS, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"dependencies", "@strapi/strapi"}, + packageJSONPath, + true, + ); ok { + return platformifier.Strapi, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"dependencies", "strapi"}, + packageJSONPath, + true, + ); ok { + return platformifier.Strapi, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"dependencies", "express"}, + packageJSONPath, + true, + ); ok { + return platformifier.Express, nil + } + } + + hasSymfonyLock := utils.FileExists(d.fileSystem, "", symfonyLockFile) + hasSymfonyBundle := false + for _, composerJSONPath := range composerJSONPaths { + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"autoload", "psr-0", "shopware"}, + composerJSONPath, + true, + ); ok { + return platformifier.Shopware, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"autoload", "psr-4", "shopware\\core\\"}, + composerJSONPath, + true, + ); ok { + return platformifier.Shopware, nil + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"autoload", "psr-4", "shopware\\appbundle\\"}, + composerJSONPath, + true, + ); ok { + return platformifier.Shopware, nil + } + + if keywords, ok := utils.GetJSONValue( + d.fileSystem, + []string{"keywords"}, + composerJSONPath, + true, + ); ok { + if keywordsVal, ok := keywords.([]string); ok && slices.Contains(keywordsVal, "shopware") { + return platformifier.Shopware, nil + } + } + if requirements, ok := utils.GetJSONValue( + d.fileSystem, + []string{"require"}, + composerJSONPath, + true, + ); ok { + if requirementsVal, requirementsOK := requirements.(map[string]interface{}); requirementsOK { + if _, requiresSymfony := requirementsVal["symfony/framework-bundle"]; requiresSymfony { + hasSymfonyBundle = true + } + + for requirement := range requirementsVal { + if strings.HasPrefix(requirement, "shopware/") { + return platformifier.Shopware, nil + } + if strings.HasPrefix(requirement, "ibexa/") { + return platformifier.Ibexa, nil + } + if strings.HasPrefix(requirement, "ezsystems/") { + return platformifier.Ibexa, nil + } + } + } + } + } + + isSymfony := hasSymfonyBundle || hasSymfonyLock + if isSymfony { + return platformifier.Symfony, nil + } + + return platformifier.Generic, nil +} diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index 08b6417..3379be8 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -7,31 +7,32 @@ import ( "path/filepath" "strings" + "github.com/platformsh/platformify/discovery" "github.com/platformsh/platformify/platformifier" ) type Answers struct { - Stack Stack `json:"stack"` - Flavor string `json:"flavor"` - Type RuntimeType `json:"type"` - Name string `json:"name"` - ApplicationRoot string `json:"application_root"` - Environment map[string]string `json:"environment"` - BuildSteps []string `json:"build_steps"` - WebCommand string `json:"web_command"` - SocketFamily SocketFamily `json:"socket_family"` - DeployCommand []string `json:"deploy_command"` - DependencyManagers []DepManager `json:"dependency_managers"` - Dependencies map[string]map[string]string `json:"dependencies"` - BuildFlavor string `json:"build_flavor"` - Disk string `json:"disk"` - Mounts map[string]map[string]string `json:"mounts"` - Services []Service `json:"services"` - Cwd string `json:"cwd"` - WorkingDirectory fs.FS `json:"working_directory"` - HasGit bool `json:"has_git"` - FilesCreated []string `json:"files_created"` - Locations map[string]map[string]interface{} `json:"locations"` + Stack Stack + Flavor string + Type RuntimeType + Name string + ApplicationRoot string + Environment map[string]string + BuildSteps []string + WebCommand string + SocketFamily SocketFamily + DeployCommand []string + DependencyManagers []DepManager + Dependencies map[string]map[string]string + BuildFlavor string + Disk string + Mounts map[string]map[string]string + Services []Service + Cwd string + WorkingDirectory fs.FS + HasGit bool + FilesCreated []string + Discoverer *discovery.Discoverer } type Service struct { diff --git a/internal/question/models/registry.json b/internal/question/models/registry.json index 3e926e1..2252e8c 100644 --- a/internal/question/models/registry.json +++ b/internal/question/models/registry.json @@ -135,7 +135,7 @@ "8.5", "7.17" ], - "deprecated" : [ + "deprecated": [ "7.10", "7.9", "7.7", @@ -191,7 +191,6 @@ "mix do deps.get --only prod, deps.compile, compile" ] } - }, "endpoint": null, "min_disk_size": null, @@ -561,7 +560,9 @@ ] }, "versions-dedicated-gen-2": { - "supported": ["1.4"] + "supported": [ + "1.4" + ] } }, "mongodb": { @@ -693,9 +694,7 @@ ] }, "versions-dedicated-gen-2": { - "supported": [ - - ], + "supported": [], "deprecated": [ "14", "12", @@ -1172,7 +1171,7 @@ "supported": [ "8.11" ], - "deprecated":[ + "deprecated": [ "8.6", "8.0", "7.7", @@ -1187,8 +1186,7 @@ "9.1", "8.11" ], - "deprecated": [ - ] + "deprecated": [] } }, "varnish": { @@ -1263,4 +1261,4 @@ ] } } -} +} \ No newline at end of file diff --git a/internal/question/stack.go b/internal/question/stack.go index 0c7d8ae..a177bdb 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -12,7 +12,7 @@ import ( "github.com/platformsh/platformify/internal/colors" "github.com/platformsh/platformify/internal/question/models" "github.com/platformsh/platformify/internal/questionnaire" - "github.com/platformsh/platformify/internal/utils" + "github.com/platformsh/platformify/platformifier" "github.com/platformsh/platformify/vendorization" ) @@ -52,12 +52,35 @@ func (q *Stack) Ask(ctx context.Context) error { }() answers.Stack = models.GenericStack + stack, err := answers.Discoverer.Stack() + if err != nil { + return err + } - hasSettingsPy := utils.FileExists(answers.WorkingDirectory, "", settingsPyFile) - hasManagePy := utils.FileExists(answers.WorkingDirectory, "", managePyFile) - if hasSettingsPy && hasManagePy { + switch stack { + case platformifier.Django: answers.Stack = models.Django return nil + case platformifier.Express: + answers.Stack = models.Express + return nil + case platformifier.Flask: + answers.Stack = models.Flask + return nil + case platformifier.Laravel: + answers.Stack = models.Laravel + return nil + case platformifier.NextJS: + answers.Stack = models.NextJS + return nil + case platformifier.Strapi: + answers.Stack = models.Strapi + return nil + case platformifier.Symfony: + // Pass to handle below + default: + answers.Stack = models.GenericStack + return nil } rackPath := utils.FindFile(answers.WorkingDirectory, "", rackFile) @@ -82,197 +105,36 @@ func (q *Stack) Ask(ctx context.Context) error { return nil } } + confirm := true + if err := survey.AskOne( + &survey.Confirm{ + Message: "It seems like this project uses Symfony full-stack. For a better experience, you should use Symfony CLI. Would you like to use it to deploy your project instead?", //nolint:lll + Default: confirm, + }, + &confirm, + ); err != nil { + return err } - pyProjectPath := utils.FindFile(answers.WorkingDirectory, "", "pyproject.toml") - if pyProjectPath != "" { - if _, ok := utils.GetTOMLValue( - answers.WorkingDirectory, - []string{"tool", "poetry", "dependencies", "flask"}, - pyProjectPath, - true, - ); ok { - answers.Stack = models.Flask - return nil - } - } - - pipfilePath := utils.FindFile(answers.WorkingDirectory, "", "Pipfile") - if pipfilePath != "" { - if _, ok := utils.GetTOMLValue( - answers.WorkingDirectory, - []string{"packages", "flask"}, - pipfilePath, - true, - ); ok { - answers.Stack = models.Flask - return nil - } - } - - composerJSONPaths := utils.FindAllFiles(answers.WorkingDirectory, "", composerJSONFile) - for _, composerJSONPath := range composerJSONPaths { - if _, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"require", "laravel/framework"}, - composerJSONPath, - true, - ); ok { - answers.Stack = models.Laravel - return nil - } - } - - packageJSONPaths := utils.FindAllFiles(answers.WorkingDirectory, "", packageJSONFile) - for _, packageJSONPath := range packageJSONPaths { - if _, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"dependencies", "next"}, - packageJSONPath, - true, - ); ok { - answers.Stack = models.NextJS - return nil - } - - if _, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"dependencies", "@strapi/strapi"}, - packageJSONPath, - true, - ); ok { - answers.Stack = models.Strapi - return nil - } - - if _, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"dependencies", "strapi"}, - packageJSONPath, - true, - ); ok { - answers.Stack = models.Strapi - return nil - } - - if _, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"dependencies", "express"}, - packageJSONPath, - true, - ); ok { - answers.Stack = models.Express - return nil - } - } - - hasSymfonyLock := utils.FileExists(answers.WorkingDirectory, "", symfonyLockFile) - hasSymfonyBundle := false - hasIbexaDependencies := false - hasShopwareDependencies := false - for _, composerJSONPath := range composerJSONPaths { - if _, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"autoload", "psr-0", "shopware"}, - composerJSONPath, - true, - ); ok { - hasShopwareDependencies = true - break - } - if _, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"autoload", "psr-4", "shopware\\core\\"}, - composerJSONPath, - true, - ); ok { - hasShopwareDependencies = true - break - } - if _, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"autoload", "psr-4", "shopware\\appbundle\\"}, - composerJSONPath, - true, - ); ok { - hasShopwareDependencies = true - break - } - - if keywords, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"keywords"}, - composerJSONPath, - true, - ); ok { - if keywordsVal, ok := keywords.([]string); ok && slices.Contains(keywordsVal, "shopware") { - hasShopwareDependencies = true - break - } - } - if requirements, ok := utils.GetJSONValue( - answers.WorkingDirectory, - []string{"require"}, - composerJSONPath, - true, - ); ok { - if requirementsVal, requirementsOK := requirements.(map[string]interface{}); requirementsOK { - if _, requiresSymfony := requirementsVal["symfony/framework-bundle"]; requiresSymfony { - hasSymfonyBundle = true - } - - for requirement := range requirementsVal { - if strings.HasPrefix(requirement, "shopware/") { - hasShopwareDependencies = true - break - } - if strings.HasPrefix(requirement, "ibexa/") { - hasIbexaDependencies = true - break - } - if strings.HasPrefix(requirement, "ezsystems/") { - hasIbexaDependencies = true - break - } - } - } - } + assets, _ := vendorization.FromContext(ctx) + _, stderr, ok := colors.FromContext(ctx) + if !ok { + return questionnaire.ErrSilent } - - isSymfony := hasSymfonyBundle || hasSymfonyLock - if isSymfony && !hasIbexaDependencies && !hasShopwareDependencies { - _, stderr, ok := colors.FromContext(ctx) - if !ok { - return questionnaire.ErrSilent - } - - confirm := true - err := survey.AskOne( - &survey.Confirm{ - Message: "It seems like this project uses Symfony full-stack. For a better experience, you should use Symfony CLI. Would you like to use it to deploy your project instead?", //nolint:lll - Default: confirm, - }, - &confirm, - ) - if err != nil { - return err - } - - assets, _ := vendorization.FromContext(ctx) - if confirm { - fmt.Fprintln( - stderr, - colors.Colorize( - colors.WarningCode, - fmt.Sprintf( - "Check out the Symfony CLI documentation here: %s", - assets.Docs().SymfonyCLI, - ), + if confirm { + fmt.Fprintln( + stderr, + colors.Colorize( + colors.WarningCode, + fmt.Sprintf( + "Check out the Symfony CLI documentation here: %s", + assets.Docs().SymfonyCLI, ), - ) - return questionnaire.ErrSilent - } + ), + ) + return questionnaire.ErrSilent } + answers.Stack = models.GenericStack return nil } diff --git a/internal/question/working_directory.go b/internal/question/working_directory.go index dfd89af..6bccbb5 100644 --- a/internal/question/working_directory.go +++ b/internal/question/working_directory.go @@ -10,6 +10,7 @@ import ( "github.com/AlecAivazis/survey/v2" + "github.com/platformsh/platformify/discovery" "github.com/platformsh/platformify/internal/colors" "github.com/platformsh/platformify/internal/question/models" "github.com/platformsh/platformify/vendorization" @@ -34,6 +35,7 @@ func (q *WorkingDirectory) Ask(ctx context.Context) error { answers.WorkingDirectory = os.DirFS(cwd) answers.Cwd = cwd answers.HasGit = false + answers.Discoverer = discovery.New(answers.WorkingDirectory) var outBuf, errBuf bytes.Buffer cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir") @@ -88,6 +90,7 @@ func (q *WorkingDirectory) Ask(ctx context.Context) error { answers.WorkingDirectory = os.DirFS(gitRepoAbsPath) answers.Cwd = gitRepoAbsPath answers.HasGit = true + answers.Discoverer = discovery.New(answers.WorkingDirectory) } } diff --git a/platformifier/models.go b/platformifier/models.go index 6816261..1be83be 100644 --- a/platformifier/models.go +++ b/platformifier/models.go @@ -27,6 +27,9 @@ const ( Flask Express Rails + Symfony + Ibexa + Shopware ) type Stack int @@ -49,6 +52,12 @@ func (s Stack) Name() string { return "flask" case Express: return "express" + case Symfony: + return "symfony" + case Ibexa: + return "ibexa" + case Shopware: + return "shopware" default: return "" } From d22173c8a41dc5d3e3607031aa3dd936a910e86b Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Tue, 5 Dec 2023 09:23:33 +0200 Subject: [PATCH 3/9] fixup! Add discovery for runtime and stack --- discovery/fs.go | 2 +- discovery/runtime.go | 2 +- internal/question/models/answer.go | 1 + internal/question/type.go | 12 ++++++++++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/discovery/fs.go b/discovery/fs.go index c8bed68..e868a38 100644 --- a/discovery/fs.go +++ b/discovery/fs.go @@ -30,7 +30,7 @@ func (d *Discoverer) CountFiles() (map[string]int, error) { fileCounter[ext] = 0 } - fileCounter[ext] += 1 + fileCounter[ext]++ return nil }) if err != nil { diff --git a/discovery/runtime.go b/discovery/runtime.go index b580e73..d886e00 100644 --- a/discovery/runtime.go +++ b/discovery/runtime.go @@ -6,7 +6,7 @@ var ( languageMap = map[string]string{ ".py": "python", ".js": "nodejs", - ".go": "go", + ".go": "golang", ".php": "php", ".rb": "ruby", ".exs": "elixir", diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index 3379be8..cc68f6f 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -32,6 +32,7 @@ type Answers struct { WorkingDirectory fs.FS HasGit bool FilesCreated []string + Locations map[string]map[string]interface{} Discoverer *discovery.Discoverer } diff --git a/internal/question/type.go b/internal/question/type.go index d432949..216a159 100644 --- a/internal/question/type.go +++ b/internal/question/type.go @@ -41,12 +41,20 @@ func (q *Type) Ask(ctx context.Context) error { } }() - runtime := models.RuntimeForStack(answers.Stack) - if runtime == nil { + typ, err := answers.Discoverer.Type() + if err != nil { + return err + } + + runtime, _ := models.Runtimes.RuntimeByType(typ) + if runtime == nil || answers.Stack == models.GenericStack { question := &survey.Select{ Message: "What language is your project using? We support the following:", Options: models.Runtimes.AllTitles(), } + if runtime != nil { + question.Default = runtime.Title() + } var title string err := survey.AskOne(question, &title, survey.WithPageSize(len(question.Options))) From eab113720c845d5786c4f52798fc64970d447672 Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Tue, 5 Dec 2023 10:44:16 +0200 Subject: [PATCH 4/9] Discover dependency managers --- discovery/dependency_managers.go | 54 +++++++++++++++ discovery/dependency_managers_test.go | 71 ++++++++++++++++++++ discovery/fs.go | 41 ----------- discovery/runtime.go | 7 +- go.mod | 3 - internal/question/dependency_manager.go | 25 ++----- internal/question/dependency_manager_test.go | 2 + internal/question/models/answer.go | 1 - internal/question/stack.go | 4 +- internal/question/stack_test.go | 2 + internal/utils/utils.go | 27 ++++++++ platformifier/models.go | 1 - platformifier/platformifier.go | 2 - 13 files changed, 170 insertions(+), 70 deletions(-) create mode 100644 discovery/dependency_managers.go create mode 100644 discovery/dependency_managers_test.go delete mode 100644 discovery/fs.go diff --git a/discovery/dependency_managers.go b/discovery/dependency_managers.go new file mode 100644 index 0000000..9097dab --- /dev/null +++ b/discovery/dependency_managers.go @@ -0,0 +1,54 @@ +package discovery + +import ( + "slices" + + "github.com/platformsh/platformify/internal/utils" +) + +var ( + dependencyManagersMap = []struct { + lang string + lockFile string + name string + }{ + {lockFile: "yarn.lock", name: "yarn", lang: "nodejs"}, + {lockFile: "package-lock.json", name: "npm", lang: "nodejs"}, + {lockFile: "poetry.lock", name: "poetry", lang: "python"}, + {lockFile: "Pipfile.lock", name: "pipenv", lang: "python"}, + {lockFile: "requirements.txt", name: "pip", lang: "python"}, + {lockFile: "composer.lock", name: "composer", lang: "php"}, + } +) + +// Returns the dependency managers, either from memory or by discovering it on the spot +func (d *Discoverer) DependencyManagers() ([]string, error) { + if dependencyManagers, ok := d.memory["dependency_managers"]; ok { + return dependencyManagers.([]string), nil + } + + dependencyManagers, err := d.discoverDependencyManagers() + if err != nil { + return nil, err + } + + d.memory["dependency_managers"] = dependencyManagers + return dependencyManagers, nil +} + +func (d *Discoverer) discoverDependencyManagers() ([]string, error) { + dependencyManagers := make([]string, 0) + matchedLanguages := make([]string, 0) + for _, dependencyManager := range dependencyManagersMap { + if slices.Contains(matchedLanguages, dependencyManager.lang) { + continue + } + + if utils.FileExists(d.fileSystem, "", dependencyManager.lockFile) { + dependencyManagers = append(dependencyManagers, dependencyManager.name) + matchedLanguages = append(matchedLanguages, dependencyManager.lang) + } + } + + return dependencyManagers, nil +} diff --git a/discovery/dependency_managers_test.go b/discovery/dependency_managers_test.go new file mode 100644 index 0000000..2a47f17 --- /dev/null +++ b/discovery/dependency_managers_test.go @@ -0,0 +1,71 @@ +package discovery + +import ( + "io/fs" + "reflect" + "testing" + "testing/fstest" +) + +func TestDiscoverer_discoverDependencyManagers(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want []string + wantErr bool + }{ + { + name: "Simple", + fields: fields{ + fileSystem: fstest.MapFS{ + "package-lock.json": &fstest.MapFile{}, + }, + }, + want: []string{"npm"}, + wantErr: false, + }, + { + name: "Multiple", + fields: fields{ + fileSystem: fstest.MapFS{ + "package-lock.json": &fstest.MapFile{}, + "poetry.lock": &fstest.MapFile{}, + }, + }, + want: []string{"npm", "poetry"}, + wantErr: false, + }, + { + name: "Priority", + fields: fields{ + fileSystem: fstest.MapFS{ + "package-lock.json": &fstest.MapFile{}, + "poetry.lock": &fstest.MapFile{}, + "requirements.txt": &fstest.MapFile{}, + }, + }, + want: []string{"npm", "poetry"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + got, err := d.discoverDependencyManagers() + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverDependencyManagers() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Discoverer.discoverDependencyManagers() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/fs.go b/discovery/fs.go deleted file mode 100644 index e868a38..0000000 --- a/discovery/fs.go +++ /dev/null @@ -1,41 +0,0 @@ -package discovery - -import ( - "io/fs" - "path/filepath" - "slices" -) - -var skipDirs = []string{ - "vendor", - "node_modules", - ".next", - ".git", -} - -func (d *Discoverer) CountFiles() (map[string]int, error) { - fileCounter := make(map[string]int) - err := fs.WalkDir(d.fileSystem, ".", func(path string, d fs.DirEntry, err error) error { - if d.IsDir() { - if slices.Contains(skipDirs, d.Name()) { - return filepath.SkipDir - } - - return nil - } - - ext := filepath.Ext(path) - _, ok := fileCounter[ext] - if !ok { - fileCounter[ext] = 0 - } - - fileCounter[ext]++ - return nil - }) - if err != nil { - return nil, err - } - - return fileCounter, nil -} diff --git a/discovery/runtime.go b/discovery/runtime.go index d886e00..7e0bd7b 100644 --- a/discovery/runtime.go +++ b/discovery/runtime.go @@ -1,6 +1,9 @@ package discovery -import "github.com/platformsh/platformify/platformifier" +import ( + "github.com/platformsh/platformify/internal/utils" + "github.com/platformsh/platformify/platformifier" +) var ( languageMap = map[string]string{ @@ -52,7 +55,7 @@ func (d *Discoverer) discoverType() (string, error) { return "nodejs", nil } - extCount, err := d.CountFiles() + extCount, err := utils.CountFiles(d.fileSystem) if err != nil { return "", err } diff --git a/go.mod b/go.mod index d0194ab..f9192b3 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/pelletier/go-toml/v2 v2.2.2 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.9.0 github.com/xeipuuv/gojsonschema v1.2.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -18,7 +17,6 @@ require ( require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -32,7 +30,6 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.6.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect diff --git a/internal/question/dependency_manager.go b/internal/question/dependency_manager.go index 4d166e5..c0a9907 100644 --- a/internal/question/dependency_manager.go +++ b/internal/question/dependency_manager.go @@ -58,27 +58,16 @@ func (q *DependencyManager) Ask(ctx context.Context) error { } }() - if exists := utils.FileExists(answers.WorkingDirectory, "", poetryLockFile); exists { - answers.DependencyManagers = append(answers.DependencyManagers, models.Poetry) - } else if exists := utils.FileExists(answers.WorkingDirectory, "", pipenvLockFile); exists { - answers.DependencyManagers = append(answers.DependencyManagers, models.Pipenv) - } else if exists := utils.FileExists(answers.WorkingDirectory, "", pipLockFile); exists { - answers.DependencyManagers = append(answers.DependencyManagers, models.Pip) + dependencyManagers, err := answers.Discoverer.DependencyManagers() + if err != nil { + return err } - - if exists := utils.FileExists(answers.WorkingDirectory, "", composerLockFile); exists { - answers.DependencyManagers = append(answers.DependencyManagers, models.Composer) - answers.Dependencies["php"] = map[string]string{"composer/composer": "^2"} - } - - if exists := utils.FileExists(answers.WorkingDirectory, "", yarnLockFileName); exists { - answers.DependencyManagers = append(answers.DependencyManagers, models.Yarn) - answers.Dependencies["nodejs"] = map[string]string{"yarn": "^1.22.0"} - } else if exists := utils.FileExists(answers.WorkingDirectory, "", npmLockFileName); exists { - answers.DependencyManagers = append(answers.DependencyManagers, models.Npm) + answers.DependencyManagers = make([]models.DepManager, 0, len(dependencyManagers)) + for _, dm := range dependencyManagers { + answers.DependencyManagers = append(answers.DependencyManagers, models.DepManager(dm)) } - if exists := utils.FileExists(answers.WorkingDirectory, bundlerLockFile); exists { + if exists := utils.FileExists(answers.WorkingDirectory, "", bundlerLockFile); exists { answers.DependencyManagers = append(answers.DependencyManagers, models.Bundler) } diff --git a/internal/question/dependency_manager_test.go b/internal/question/dependency_manager_test.go index 57446bc..4019c0d 100644 --- a/internal/question/dependency_manager_test.go +++ b/internal/question/dependency_manager_test.go @@ -7,6 +7,7 @@ import ( "testing" "testing/fstest" + "github.com/platformsh/platformify/discovery" "github.com/platformsh/platformify/internal/question/models" ) @@ -53,6 +54,7 @@ func TestDependencyManager_Ask(t *testing.T) { q := &DependencyManager{} a := models.NewAnswers() a.WorkingDirectory = tt.fileSystem + a.Discoverer = discovery.New(tt.fileSystem) ctx := models.ToContext(context.Background(), a) if err := q.Ask(ctx); err != nil { diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index cc68f6f..a8995ed 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -120,7 +120,6 @@ func (a *Answers) ToUserInput() *platformifier.UserInput { ApplicationRoot: filepath.Join(string(os.PathSeparator), a.ApplicationRoot), Name: a.Name, Type: a.Type.String(), - Runtime: a.Type.Runtime.String(), Environment: a.Environment, BuildSteps: a.BuildSteps, WebCommand: a.WebCommand, diff --git a/internal/question/stack.go b/internal/question/stack.go index a177bdb..27d800f 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -34,8 +34,8 @@ func (q *Stack) Ask(ctx context.Context) error { } defer func() { - _, stderr, ok := colors.FromContext(ctx) - if !ok { + _, stderr, stderrOK := colors.FromContext(ctx) + if !stderrOK { return } if answers.Stack != models.GenericStack { diff --git a/internal/question/stack_test.go b/internal/question/stack_test.go index 9f42f0e..7eb557f 100644 --- a/internal/question/stack_test.go +++ b/internal/question/stack_test.go @@ -6,6 +6,7 @@ import ( "testing" "testing/fstest" + "github.com/platformsh/platformify/discovery" "github.com/platformsh/platformify/internal/question/models" ) @@ -73,6 +74,7 @@ fLaSk = "^1.2.3" q := &Stack{} a := models.NewAnswers() a.WorkingDirectory = tt.fileSystem + a.Discoverer = discovery.New(tt.fileSystem) ctx := models.ToContext(context.Background(), a) if err := q.Ask(ctx); (err != nil) != tt.wantErr { diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 7997c54..88bbc4b 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -173,3 +173,30 @@ func GetTOMLValue( return GetMapValue(keyPath, data) } + +func CountFiles(fileSystem fs.FS) (map[string]int, error) { + fileCounter := make(map[string]int) + err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + if slices.Contains(skipDirs, d.Name()) { + return filepath.SkipDir + } + + return nil + } + + ext := filepath.Ext(path) + _, ok := fileCounter[ext] + if !ok { + fileCounter[ext] = 0 + } + + fileCounter[ext]++ + return nil + }) + if err != nil { + return nil, err + } + + return fileCounter, nil +} diff --git a/platformifier/models.go b/platformifier/models.go index 1be83be..0155108 100644 --- a/platformifier/models.go +++ b/platformifier/models.go @@ -75,7 +75,6 @@ type UserInput struct { ApplicationRoot string Name string Type string - Runtime string Environment map[string]string BuildSteps []string WebCommand string diff --git a/platformifier/platformifier.go b/platformifier/platformifier.go index 3afc9a8..1a370f7 100644 --- a/platformifier/platformifier.go +++ b/platformifier/platformifier.go @@ -26,8 +26,6 @@ const ( ) // A platformifier handles the business logic of a given runtime to platformify. -// -//go:generate mockgen -destination=platformifier_mock_test.go -package=platformifier -source=platformifier.go type platformifier interface { // Platformify loads and returns the rendered templates that should be written to the user's system. Platformify(ctx context.Context, input *UserInput) (map[string][]byte, error) From b7a77e89fa33f135ace5ca368c1c310068e4cc52 Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Wed, 13 Dec 2023 22:57:31 +0200 Subject: [PATCH 5/9] WIP --- discovery/application_root.go | 45 ++++++++++ discovery/application_root_test.go | 67 ++++++++++++++ discovery/build_steps.go | 137 +++++++++++++++++++++++++++++ discovery/build_steps_test.go | 109 +++++++++++++++++++++++ discovery/dependency_managers.go | 55 +++++++++++- discovery/discovery.go | 20 +++++ discovery/environment.go | 47 ++++++++++ discovery/environment_test.go | 83 +++++++++++++++++ discovery/web_command.go | 5 ++ internal/question/environment.go | 14 +-- internal/question/models/answer.go | 1 - platformifier/models.go | 11 +-- 12 files changed, 574 insertions(+), 20 deletions(-) create mode 100644 discovery/application_root.go create mode 100644 discovery/application_root_test.go create mode 100644 discovery/build_steps.go create mode 100644 discovery/build_steps_test.go create mode 100644 discovery/environment.go create mode 100644 discovery/environment_test.go create mode 100644 discovery/web_command.go diff --git a/discovery/application_root.go b/discovery/application_root.go new file mode 100644 index 0000000..2a38c39 --- /dev/null +++ b/discovery/application_root.go @@ -0,0 +1,45 @@ +package discovery + +import ( + "path" + "slices" + + "github.com/platformsh/platformify/internal/utils" +) + +// Returns the application root, either from memory or by discovering it on the spot +func (d *Discoverer) ApplicationRoot() (string, error) { + if applicationRoot, ok := d.memory["application_root"]; ok { + return applicationRoot.(string), nil + } + + appRoot, err := d.discoverApplicationRoot() + if err != nil { + return "", err + } + + d.memory["application_root"] = appRoot + return appRoot, nil +} + +func (d *Discoverer) discoverApplicationRoot() (string, error) { + depManagers, err := d.DependencyManagers() + if err != nil { + return "", err + } + + for _, dependencyManager := range dependencyManagersMap { + if !slices.Contains(depManagers, dependencyManager.name) { + continue + } + + lockPath := utils.FindFile(d.fileSystem, "", dependencyManager.lockFile) + if lockPath == "" { + continue + } + + return path.Dir(lockPath), nil + } + + return "", nil +} diff --git a/discovery/application_root_test.go b/discovery/application_root_test.go new file mode 100644 index 0000000..b582a32 --- /dev/null +++ b/discovery/application_root_test.go @@ -0,0 +1,67 @@ +package discovery + +import ( + "io/fs" + "testing" + "testing/fstest" +) + +func TestDiscoverer_discoverApplicationRoot(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want string + wantErr bool + }{ + { + name: "Simple", + fields: fields{ + fileSystem: fstest.MapFS{ + "package-lock.json": &fstest.MapFile{}, + }, + }, + want: ".", + }, + { + name: "No root", + fields: fields{ + fileSystem: fstest.MapFS{}, + }, + want: "", + }, + { + name: "Priority", + fields: fields{ + fileSystem: fstest.MapFS{ + "yarn/yarn.lock": &fstest.MapFile{}, + "poetry/poetry.lock": &fstest.MapFile{}, + "composer/composer-lock.json": &fstest.MapFile{}, + }, + }, + want: "poetry", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + if d.memory == nil { + d.memory = make(map[string]any) + } + got, err := d.discoverApplicationRoot() + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverApplicationRoot() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Discoverer.discoverApplicationRoot() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/build_steps.go b/discovery/build_steps.go new file mode 100644 index 0000000..4ff140e --- /dev/null +++ b/discovery/build_steps.go @@ -0,0 +1,137 @@ +package discovery + +import ( + "fmt" + "slices" + + "github.com/platformsh/platformify/internal/utils" + "github.com/platformsh/platformify/platformifier" +) + +// Returns the application build steps, either from memory or by discovering it on the spot +func (d *Discoverer) BuildSteps() ([]string, error) { + if buildSteps, ok := d.memory["build_steps"]; ok { + return buildSteps.([]string), nil + } + + buildSteps, err := d.discoverBuildSteps() + if err != nil { + return nil, err + } + + d.memory["build_steps"] = buildSteps + return buildSteps, nil +} + +func (d *Discoverer) discoverBuildSteps() ([]string, error) { + dependencyManagers, err := d.DependencyManagers() + if err != nil { + return nil, err + } + + typ, err := d.Type() + if err != nil { + return nil, err + } + + stack, err := d.Stack() + if err != nil { + return nil, err + } + + appRoot, err := d.ApplicationRoot() + if err != nil { + return nil, err + } + + buildSteps := make([]string, 0) + + // Start with lower priority dependency managers first + slices.Reverse(dependencyManagers) + for _, dm := range dependencyManagers { + switch dm { + case "poetry": + buildSteps = append( + buildSteps, + "# Set PIP_USER to 0 so that Poetry does not complain", + "export PIP_USER=0", + "# Install poetry as a global tool", + "python -m venv /app/.global", + "pip install poetry==$POETRY_VERSION", + "poetry install", + ) + case "pipenv": + buildSteps = append( + buildSteps, + "# Set PIP_USER to 0 so that Pipenv does not complain", + "export PIP_USER=0", + "# Install Pipenv as a global tool", + "python -m venv /app/.global", + "pip install pipenv==$PIPENV_TOOL_VERSION", + "pipenv install", + ) + case "pip": + buildSteps = append( + buildSteps, + "pip install -r requirements.txt", + ) + case "yarn", "npm": + // Install n, if on different runtime + if typ != "nodejs" { + buildSteps = append( + buildSteps, + "n auto || n lts", + "hash -r", + ) + } + + if dm == "yarn" { + buildSteps = append( + buildSteps, + "yarn", + ) + } else { + buildSteps = append( + buildSteps, + "npm i", + ) + } + + if _, ok := utils.GetJSONValue( + d.fileSystem, + []string{"scripts", "build"}, + "package.json", + true, + ); ok { + buildSteps = append(buildSteps, d.nodeScriptPrefix()+"build") + } + case "composer": + buildSteps = append( + buildSteps, + "composer --no-ansi --no-interaction install --no-progress --prefer-dist --optimize-autoloader --no-dev", + ) + } + } + + switch stack { + case platformifier.Django: + if managePyPath := utils.FindFile( + d.fileSystem, + appRoot, + managePyFile, + ); managePyPath != "" { + buildSteps = append( + buildSteps, + "# Collect static files", + fmt.Sprintf("%spython %s collectstatic --noinput", d.pythonPrefix(), managePyPath), + ) + } + case platformifier.NextJS: + // If there is no custom build script, fallback to next build for Next.js projects + if !slices.Contains(buildSteps, "yarn build") && !slices.Contains(buildSteps, "npm run build") { + buildSteps = append(buildSteps, d.nodeExecPrefix()+"next build") + } + } + + return buildSteps, nil +} diff --git a/discovery/build_steps_test.go b/discovery/build_steps_test.go new file mode 100644 index 0000000..ffc6c3c --- /dev/null +++ b/discovery/build_steps_test.go @@ -0,0 +1,109 @@ +package discovery + +import ( + "io/fs" + "reflect" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/platformifier" +) + +func TestDiscoverer_discoverBuildSteps(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want []string + wantErr bool + }{ + { + name: "Poetry Django", + fields: fields{ + fileSystem: fstest.MapFS{ + "project/manage.py": &fstest.MapFile{}, + }, + memory: map[string]any{ + "stack": platformifier.Django, + "type": "python", + "dependency_managers": []string{"poetry"}, + "application_root": ".", + }, + }, + want: []string{ + "# Set PIP_USER to 0 so that Poetry does not complain", + "export PIP_USER=0", + "# Install poetry as a global tool", + "python -m venv /app/.global", + "pip install poetry==$POETRY_VERSION", + "poetry install", + "# Collect static files", + "poetry run python project/manage.py collectstatic --noinput", + }, + }, + { + name: "Pipenv Django with Yarn build", + fields: fields{ + fileSystem: fstest.MapFS{ + "project/manage.py": &fstest.MapFile{}, + "package.json": &fstest.MapFile{Data: []byte(`{"scripts": {"build": "nuxt build"}}`)}, + }, + memory: map[string]any{ + "stack": platformifier.Django, + "type": "python", + "dependency_managers": []string{"poetry", "yarn"}, + "application_root": ".", + }, + }, + want: []string{ + "n auto || n lts", + "hash -r", + "yarn", + "yarn build", + "# Set PIP_USER to 0 so that Poetry does not complain", + "export PIP_USER=0", + "# Install poetry as a global tool", + "python -m venv /app/.global", + "pip install poetry==$POETRY_VERSION", + "poetry install", + "# Collect static files", + "poetry run python project/manage.py collectstatic --noinput", + }, + }, + { + name: "Next.js without build script", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{ + "stack": platformifier.NextJS, + "type": "nodejs", + "dependency_managers": []string{"npm"}, + "application_root": ".", + }, + }, + want: []string{ + "npm i", + "npm exec next build", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + got, err := d.discoverBuildSteps() + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverBuildSteps() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Discoverer.discoverBuildSteps() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/dependency_managers.go b/discovery/dependency_managers.go index 9097dab..a520a53 100644 --- a/discovery/dependency_managers.go +++ b/discovery/dependency_managers.go @@ -12,12 +12,12 @@ var ( lockFile string name string }{ - {lockFile: "yarn.lock", name: "yarn", lang: "nodejs"}, - {lockFile: "package-lock.json", name: "npm", lang: "nodejs"}, {lockFile: "poetry.lock", name: "poetry", lang: "python"}, {lockFile: "Pipfile.lock", name: "pipenv", lang: "python"}, {lockFile: "requirements.txt", name: "pip", lang: "python"}, {lockFile: "composer.lock", name: "composer", lang: "php"}, + {lockFile: "yarn.lock", name: "yarn", lang: "nodejs"}, + {lockFile: "package-lock.json", name: "npm", lang: "nodejs"}, } ) @@ -52,3 +52,54 @@ func (d *Discoverer) discoverDependencyManagers() ([]string, error) { return dependencyManagers, nil } + +func (d *Discoverer) pythonPrefix() string { + dependencyManagers, err := d.DependencyManagers() + if err != nil { + return "" + } + + if slices.Contains(dependencyManagers, "pipenv") { + return "pipenv run " + } + + if slices.Contains(dependencyManagers, "poetry") { + return "poetry run " + } + + return "" +} + +func (d *Discoverer) nodeScriptPrefix() string { + dependencyManagers, err := d.DependencyManagers() + if err != nil { + return "" + } + + if slices.Contains(dependencyManagers, "yarn") { + return "yarn " + } + + if slices.Contains(dependencyManagers, "npm") { + return "npm run " + } + + return "" +} + +func (d *Discoverer) nodeExecPrefix() string { + dependencyManagers, err := d.DependencyManagers() + if err != nil { + return "" + } + + if slices.Contains(dependencyManagers, "yarn") { + return "yarn exec" + } + + if slices.Contains(dependencyManagers, "npm") { + return "npm exec " + } + + return "" +} diff --git a/discovery/discovery.go b/discovery/discovery.go index 7eb1790..7156342 100644 --- a/discovery/discovery.go +++ b/discovery/discovery.go @@ -2,6 +2,8 @@ package discovery import ( "io/fs" + + "github.com/platformsh/platformify/platformifier" ) const ( @@ -20,3 +22,21 @@ type Discoverer struct { func New(fileSystem fs.FS) *Discoverer { return &Discoverer{fileSystem: fileSystem, memory: make(map[string]any)} } + +func (d *Discoverer) DeployCommand() ([]string, error) { + return nil, nil +} +func (d *Discoverer) Locations() (map[string]map[string]any, error) { + return nil, nil +} +func (d *Discoverer) Dependencies() (map[string]map[string]string, error) { + // answers.Dependencies["nodejs"]["n"] = "*" + // answers.Dependencies["nodejs"]["npx"] = "*" + return nil, nil +} +func (d *Discoverer) Mounts() (map[string]map[string]string, error) { + return nil, nil +} +func (d *Discoverer) Services() ([]platformifier.Service, error) { + return nil, nil +} diff --git a/discovery/environment.go b/discovery/environment.go new file mode 100644 index 0000000..b59f966 --- /dev/null +++ b/discovery/environment.go @@ -0,0 +1,47 @@ +package discovery + +// Returns the application environment, either from memory or by discovering it on the spot +func (d *Discoverer) Environment() (map[string]string, error) { + if environment, ok := d.memory["environment"]; ok { + return environment.(map[string]string), nil + } + + environment, err := d.discoverEnvironment() + if err != nil { + return nil, err + } + + d.memory["environment"] = environment + return environment, nil +} + +func (d *Discoverer) discoverEnvironment() (map[string]string, error) { + depManagers, err := d.DependencyManagers() + if err != nil { + return nil, err + } + + typ, err := d.Type() + if err != nil { + return nil, err + } + + environment := make(map[string]string) + + for _, dm := range depManagers { + switch dm { + case "poetry": + environment["POETRY_VERSION"] = "1.4.0" + environment["POETRY_VIRTUALENVS_IN_PROJECT"] = "true" + case "pipenv": + environment["PIPENV_TOOL_VERSION"] = "2023.2.18" + environment["PIPENV_VENV_IN_PROJECT"] = "1" + case "npm", "yarn": + if typ != "nodejs" { + environment["N_PREFIX"] = "/app/.global" + } + } + } + + return environment, nil +} diff --git a/discovery/environment_test.go b/discovery/environment_test.go new file mode 100644 index 0000000..0d19cc3 --- /dev/null +++ b/discovery/environment_test.go @@ -0,0 +1,83 @@ +package discovery + +import ( + "io/fs" + "reflect" + "testing" + "testing/fstest" +) + +func TestDiscoverer_discoverEnvironment(t *testing.T) { + type fields struct { + fileSystem fs.FS + memory map[string]any + } + tests := []struct { + name string + fields fields + want map[string]string + wantErr bool + }{ + { + name: "No env", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{}, + }, + want: make(map[string]string), + }, + { + name: "Simple poetry", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{ + "dependency_managers": []string{"poetry"}, + }, + }, + want: map[string]string{ + "POETRY_VERSION": "1.4.0", + "POETRY_VIRTUALENVS_IN_PROJECT": "true", + }, + }, + { + name: "Node.js", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{ + "dependency_managers": []string{"yarn"}, + "type": "nodejs", + }, + }, + want: map[string]string{}, + }, + { + name: "Node.js on different runtime", + fields: fields{ + fileSystem: fstest.MapFS{}, + memory: map[string]any{ + "dependency_managers": []string{"yarn"}, + "type": "python", + }, + }, + want: map[string]string{ + "N_PREFIX": "/app/.global", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Discoverer{ + fileSystem: tt.fields.fileSystem, + memory: tt.fields.memory, + } + got, err := d.discoverEnvironment() + if (err != nil) != tt.wantErr { + t.Errorf("Discoverer.discoverEnvironment() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Discoverer.discoverEnvironment() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/discovery/web_command.go b/discovery/web_command.go new file mode 100644 index 0000000..be02021 --- /dev/null +++ b/discovery/web_command.go @@ -0,0 +1,5 @@ +package discovery + +func (d *Discoverer) WebCommand() ([]string, error) { + return nil, nil +} diff --git a/internal/question/environment.go b/internal/question/environment.go index b11d8e3..791a4bf 100644 --- a/internal/question/environment.go +++ b/internal/question/environment.go @@ -14,17 +14,11 @@ func (q *Environment) Ask(ctx context.Context) error { return nil } - answers.Environment = make(map[string]string) - for _, dm := range answers.DependencyManagers { - switch dm { - case models.Poetry: - answers.Environment["POETRY_VERSION"] = "1.8.4" - answers.Environment["POETRY_VIRTUALENVS_IN_PROJECT"] = "true" - case models.Pipenv: - answers.Environment["PIPENV_TOOL_VERSION"] = "2024.2.0" - answers.Environment["PIPENV_VENV_IN_PROJECT"] = "1" - } + environment, err := answers.Discoverer.Environment() + if err != nil { + return err } + answers.Environment = environment return nil } diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index a8995ed..7899b6f 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -116,7 +116,6 @@ func (a *Answers) ToUserInput() *platformifier.UserInput { return &platformifier.UserInput{ Stack: getStack(a.Stack), - Root: "", ApplicationRoot: filepath.Join(string(os.PathSeparator), a.ApplicationRoot), Name: a.Name, Type: a.Type.String(), diff --git a/platformifier/models.go b/platformifier/models.go index 0155108..7d75a71 100644 --- a/platformifier/models.go +++ b/platformifier/models.go @@ -1,6 +1,7 @@ package platformifier import ( + "io/fs" "strings" ) @@ -71,25 +72,21 @@ type Relationship struct { // UserInput contains the configuration from user input. type UserInput struct { Stack Stack - Root string ApplicationRoot string Name string Type string Environment map[string]string BuildSteps []string - WebCommand string - SocketFamily string + WebCommand []string DeployCommand []string DependencyManagers []string - Locations map[string]map[string]interface{} + Locations map[string]map[string]any Dependencies map[string]map[string]string - BuildFlavor string - Disk string Mounts map[string]map[string]string Services []Service Relationships map[string]Relationship - WorkingDirectory string HasGit bool + WorkingDirectory fs.FS } // Service contains the configuration for a service needed by the application. From bf79f89e7dbdabf886bc1a0fbcef97d1e16c17e9 Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Tue, 11 Feb 2025 12:21:50 +0200 Subject: [PATCH 6/9] Fix rebase conflicts and issues --- commands/platformify.go | 8 ++++---- discovery/dependency_managers_test.go | 2 ++ internal/question/build_steps_test.go | 5 +++-- internal/question/models/answer.go | 4 ---- internal/question/models/stack.go | 4 ++-- internal/question/stack.go | 5 +++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/commands/platformify.go b/commands/platformify.go index dc0c27a..5b82bd3 100644 --- a/commands/platformify.go +++ b/commands/platformify.go @@ -70,7 +70,7 @@ func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendoriz } if err != nil { - fmt.Fprintln(cmd.ErrOrStderr(), colors.Colorize(colors.ErrorCode, err.Error())) + fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error())) return err } @@ -79,7 +79,7 @@ func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendoriz pfier := platformifier.New(input, assets.ConfigFlavor) configFiles, err := pfier.Platformify(ctx) if err != nil { - fmt.Fprintln(cmd.ErrOrStderr(), colors.Colorize(colors.ErrorCode, err.Error())) + fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error())) return fmt.Errorf("could not configure project: %w", err) } @@ -96,12 +96,12 @@ func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendoriz for file, contents := range configFiles { filePath := path.Join(answers.Cwd, file) if err := os.MkdirAll(path.Dir(filePath), os.ModeDir|os.ModePerm); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Could not create parent directories of file %s: %s\n", file, err) + fmt.Fprintf(stderr, "Could not create parent directories of file %s: %s\n", file, err) continue } if err := os.WriteFile(filePath, contents, 0o664); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Could not write file %s: %s\n", file, err) + fmt.Fprintf(stderr, "Could not write file %s: %s\n", file, err) continue } } diff --git a/discovery/dependency_managers_test.go b/discovery/dependency_managers_test.go index 2a47f17..3b8ceb3 100644 --- a/discovery/dependency_managers_test.go +++ b/discovery/dependency_managers_test.go @@ -3,6 +3,7 @@ package discovery import ( "io/fs" "reflect" + "slices" "testing" "testing/fstest" ) @@ -59,6 +60,7 @@ func TestDiscoverer_discoverDependencyManagers(t *testing.T) { memory: tt.fields.memory, } got, err := d.discoverDependencyManagers() + slices.Sort(got) if (err != nil) != tt.wantErr { t.Errorf("Discoverer.discoverDependencyManagers() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/internal/question/build_steps_test.go b/internal/question/build_steps_test.go index 674e193..a2854f8 100644 --- a/internal/question/build_steps_test.go +++ b/internal/question/build_steps_test.go @@ -14,6 +14,7 @@ func TestBuildSteps_Ask(t *testing.T) { answers models.Answers } nodeJS, _ := models.Runtimes.RuntimeByType("nodejs") + ruby, _ := models.Runtimes.RuntimeByType("ruby") tests := []struct { name string q *BuildSteps @@ -54,7 +55,7 @@ func TestBuildSteps_Ask(t *testing.T) { q: &BuildSteps{}, args: args{models.Answers{ Stack: models.GenericStack, - Type: models.RuntimeType{Runtime: models.Ruby, Version: "3.3"}, + Type: models.RuntimeType{Runtime: *ruby, Version: "3.3"}, Dependencies: map[string]map[string]string{}, DependencyManagers: []models.DepManager{models.Bundler}, Environment: map[string]string{}, @@ -67,7 +68,7 @@ func TestBuildSteps_Ask(t *testing.T) { q: &BuildSteps{}, args: args{models.Answers{ Stack: models.Rails, - Type: models.RuntimeType{Runtime: models.Ruby, Version: "3.3"}, + Type: models.RuntimeType{Runtime: *ruby, Version: "3.3"}, Dependencies: map[string]map[string]string{}, DependencyManagers: []models.DepManager{models.Bundler}, Environment: map[string]string{}, diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index 7899b6f..ae64109 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -121,14 +121,10 @@ func (a *Answers) ToUserInput() *platformifier.UserInput { Type: a.Type.String(), Environment: a.Environment, BuildSteps: a.BuildSteps, - WebCommand: a.WebCommand, - SocketFamily: a.SocketFamily.String(), DependencyManagers: dependencyManagers, DeployCommand: a.DeployCommand, Locations: locations, Dependencies: a.Dependencies, - BuildFlavor: a.BuildFlavor, - Disk: a.Disk, Mounts: a.Mounts, Services: services, Relationships: getRelationships(a.Services), diff --git a/internal/question/models/stack.go b/internal/question/models/stack.go index 79d0bf1..108b9f0 100644 --- a/internal/question/models/stack.go +++ b/internal/question/models/stack.go @@ -109,7 +109,7 @@ func RuntimeForStack(stack Stack) *Runtime { return r } return nil - default: - return nil } + + return nil } diff --git a/internal/question/stack.go b/internal/question/stack.go index 27d800f..be8499f 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -4,14 +4,13 @@ import ( "context" "fmt" "os" - "slices" - "strings" "github.com/AlecAivazis/survey/v2" "github.com/platformsh/platformify/internal/colors" "github.com/platformsh/platformify/internal/question/models" "github.com/platformsh/platformify/internal/questionnaire" + "github.com/platformsh/platformify/internal/utils" "github.com/platformsh/platformify/platformifier" "github.com/platformsh/platformify/vendorization" ) @@ -105,6 +104,8 @@ func (q *Stack) Ask(ctx context.Context) error { return nil } } + } + confirm := true if err := survey.AskOne( &survey.Confirm{ From 980c13803747bfcca7e63f7e61c19a0801431de2 Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Tue, 11 Feb 2025 16:26:07 +0200 Subject: [PATCH 7/9] Enhance non-interactive mode support across question prompts and models --- .golangci.yml | 2 +- commands/commands.go | 18 +++++++++-- commands/platformify.go | 9 +++++- discovery/discovery.go | 1 + discovery/runtime.go | 10 +++--- internal/question/almost_done.go | 5 +++ internal/question/done.go | 2 +- internal/question/files_overwrite.go | 2 +- internal/question/models/answer.go | 11 +++++-- internal/question/name.go | 6 +++- internal/question/services.go | 2 +- internal/question/stack.go | 10 ++++-- internal/question/type.go | 31 ++++++++++++------- internal/question/welcome.go | 5 +++ internal/question/working_directory.go | 11 ++++--- internal/utils/utils.go | 2 +- platformifier/models.go | 3 ++ .../templates/generic/.platform.app.yaml | 5 --- .../templates/upsun/.upsun/config.yaml | 5 --- 19 files changed, 93 insertions(+), 47 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 9441e7a..aff2ea6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -65,7 +65,7 @@ linters: # Tool for code clone detection # - dupl # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases - - errcheck + # - errcheck # Tool for detection of long functions # - funlen # Checks that no globals are present in Go code diff --git a/commands/commands.go b/commands/commands.go index 93949ab..d02c804 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -3,13 +3,25 @@ package commands import ( "context" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/platformsh/platformify/vendorization" ) // Execute executes the ify command and sets flags appropriately. func Execute(assets *vendorization.VendorAssets) error { - cmd := NewPlatformifyCmd(assets) + rootCmd := NewPlatformifyCmd(assets) validateCmd := NewValidateCommand(assets) - cmd.AddCommand(validateCmd) - return cmd.ExecuteContext(vendorization.WithVendorAssets(context.Background(), assets)) + rootCmd.AddCommand(validateCmd) + + rootCmd.PersistentFlags().Bool("no-interaction", false, "Disable interactive prompts") + viper.BindPFlag("no-interaction", rootCmd.PersistentFlags().Lookup("no-interaction")) + + rootCmd.PreRun = func(cmd *cobra.Command, _ []string) { + ctx := context.WithValue(cmd.Context(), NoInteractionKey, viper.GetBool("no-interaction")) + cmd.SetContext(ctx) + } + + return rootCmd.ExecuteContext(vendorization.WithVendorAssets(context.Background(), assets)) } diff --git a/commands/platformify.go b/commands/platformify.go index 5b82bd3..67c60bd 100644 --- a/commands/platformify.go +++ b/commands/platformify.go @@ -21,6 +21,7 @@ import ( type contextKey string var FlavorKey contextKey = "flavor" +var NoInteractionKey contextKey = "no-interaction" func NewPlatformifyCmd(assets *vendorization.VendorAssets) *cobra.Command { cmd := &cobra.Command{ @@ -30,7 +31,12 @@ func NewPlatformifyCmd(assets *vendorization.VendorAssets) *cobra.Command { SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, _ []string) error { - return Platformify(cmd.Context(), cmd.OutOrStderr(), cmd.ErrOrStderr(), assets) + return Platformify( + cmd.Context(), + cmd.OutOrStderr(), + cmd.ErrOrStderr(), + assets, + ) }, } @@ -40,6 +46,7 @@ func NewPlatformifyCmd(assets *vendorization.VendorAssets) *cobra.Command { func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendorization.VendorAssets) error { answers := models.NewAnswers() answers.Flavor, _ = ctx.Value(FlavorKey).(string) + answers.NoInteraction, _ = ctx.Value(NoInteractionKey).(bool) ctx = models.ToContext(ctx, answers) ctx = colors.ToContext( ctx, diff --git a/discovery/discovery.go b/discovery/discovery.go index 7156342..a5574b2 100644 --- a/discovery/discovery.go +++ b/discovery/discovery.go @@ -30,6 +30,7 @@ func (d *Discoverer) Locations() (map[string]map[string]any, error) { return nil, nil } func (d *Discoverer) Dependencies() (map[string]map[string]string, error) { + // TODO: Add back dependencies // answers.Dependencies["nodejs"]["n"] = "*" // answers.Dependencies["nodejs"]["npx"] = "*" return nil, nil diff --git a/discovery/runtime.go b/discovery/runtime.go index 7e0bd7b..db2bccb 100644 --- a/discovery/runtime.go +++ b/discovery/runtime.go @@ -27,8 +27,8 @@ var ( // Returns the Runtime, either from memory or by discovering it on the spot func (d *Discoverer) Type() (string, error) { - if stack, ok := d.memory["type"]; ok { - return stack.(string), nil + if typ, ok := d.memory["type"]; ok { + return typ.(string), nil } typ, err := d.discoverType() @@ -70,11 +70,11 @@ func (d *Discoverer) discoverType() (string, error) { } } - max := 0 + maxCount := 0 selectedLang := "" for lang, count := range langCount { - if count > max { - max = count + if count > maxCount { + maxCount = count selectedLang = lang } } diff --git a/internal/question/almost_done.go b/internal/question/almost_done.go index 252a8a4..6b4d8be 100644 --- a/internal/question/almost_done.go +++ b/internal/question/almost_done.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/platformsh/platformify/internal/colors" + "github.com/platformsh/platformify/internal/question/models" ) type AlmostDone struct{} @@ -14,6 +15,10 @@ func (q *AlmostDone) Ask(ctx context.Context) error { if !ok { return nil } + answers, ok := models.FromContext(ctx) + if !ok || answers.NoInteraction { + return nil + } fmt.Fprintln(out) fmt.Fprintln(out, colors.Colorize(colors.AccentCode, " (\\_/)")) diff --git a/internal/question/done.go b/internal/question/done.go index a2b6007..3593a01 100644 --- a/internal/question/done.go +++ b/internal/question/done.go @@ -18,7 +18,7 @@ func (q *Done) Ask(ctx context.Context) error { } answers, ok := models.FromContext(ctx) - if !ok { + if !ok || answers.NoInteraction { return nil } diff --git a/internal/question/files_overwrite.go b/internal/question/files_overwrite.go index 00e58d0..95dc71a 100644 --- a/internal/question/files_overwrite.go +++ b/internal/question/files_overwrite.go @@ -19,7 +19,7 @@ type FilesOverwrite struct { func (q *FilesOverwrite) Ask(ctx context.Context) error { answers, ok := models.FromContext(ctx) - if !ok { + if !ok || answers.NoInteraction { return nil } diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index ae64109..0cfcc70 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -12,6 +12,7 @@ import ( ) type Answers struct { + NoInteraction bool Stack Stack Flavor string Type RuntimeType @@ -78,9 +79,10 @@ func (t ServiceType) MarshalJSON() ([]byte, error) { func NewAnswers() *Answers { return &Answers{ - Environment: make(map[string]string), - BuildSteps: make([]string, 0), - Services: make([]Service, 0), + NoInteraction: true, + Environment: make(map[string]string), + BuildSteps: make([]string, 0), + Services: make([]Service, 0), } } @@ -119,6 +121,9 @@ func (a *Answers) ToUserInput() *platformifier.UserInput { ApplicationRoot: filepath.Join(string(os.PathSeparator), a.ApplicationRoot), Name: a.Name, Type: a.Type.String(), + Runtime: strings.Split(a.Type.Runtime.String(), ":")[0], + SocketFamily: a.SocketFamily.String(), + Disk: a.Disk, Environment: a.Environment, BuildSteps: a.BuildSteps, DependencyManagers: dependencyManagers, diff --git a/internal/question/name.go b/internal/question/name.go index f8409f3..9d2ea7c 100644 --- a/internal/question/name.go +++ b/internal/question/name.go @@ -24,13 +24,17 @@ func (q *Name) Ask(ctx context.Context) error { if !ok { return nil } + defaultName := slugify(path.Base(answers.Cwd)) + if answers.NoInteraction { + answers.Name = defaultName + } if answers.Name != "" { // Skip the step return nil } question := &survey.Input{ - Message: "Tell us your project's application name:", Default: slugify(path.Base(answers.Cwd)), + Message: "Tell us your project's application name:", Default: defaultName, } var name string diff --git a/internal/question/services.go b/internal/question/services.go index 63b1a72..a6cbf84 100644 --- a/internal/question/services.go +++ b/internal/question/services.go @@ -18,7 +18,7 @@ func (q *Services) Ask(ctx context.Context) error { if !ok { return nil } - if len(answers.Services) != 0 { + if len(answers.Services) != 0 || answers.NoInteraction { // Skip the step return nil } diff --git a/internal/question/stack.go b/internal/question/stack.go index be8499f..d264ecd 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -76,7 +76,11 @@ func (q *Stack) Ask(ctx context.Context) error { answers.Stack = models.Strapi return nil case platformifier.Symfony: - // Pass to handle below + // Pass to handle below if no interaction + if answers.NoInteraction { + answers.Stack = models.GenericStack + return nil + } default: answers.Stack = models.GenericStack return nil @@ -87,7 +91,7 @@ func (q *Stack) Ask(ctx context.Context) error { f, err := os.Open(rackPath) if err == nil { defer f.Close() - if ok, _ := utils.ContainsStringInFile(f, "Rails.application.load_server", true); ok { + if ok, _ = utils.ContainsStringInFile(f, "Rails.application.load_server", true); ok { answers.Stack = models.Rails return nil } @@ -99,7 +103,7 @@ func (q *Stack) Ask(ctx context.Context) error { f, err := answers.WorkingDirectory.Open(requirementsPath) if err == nil { defer f.Close() - if ok, _ := utils.ContainsStringInFile(f, "flask", true); ok { + if ok, _ = utils.ContainsStringInFile(f, "flask", true); ok { answers.Stack = models.Flask return nil } diff --git a/internal/question/type.go b/internal/question/type.go index 216a159..200cdcd 100644 --- a/internal/question/type.go +++ b/internal/question/type.go @@ -28,25 +28,32 @@ func (q *Type) Ask(ctx context.Context) error { return } - if answers.Stack != models.GenericStack { - fmt.Fprintf( - stderr, - "%s %s\n", - colors.Colorize(colors.GreenCode, "✓"), - colors.Colorize( - colors.BrandCode, - fmt.Sprintf("Detected runtime: %s", answers.Type.Runtime.Title()), - ), - ) - } + fmt.Fprintf( + stderr, + "%s %s\n", + colors.Colorize(colors.GreenCode, "✓"), + colors.Colorize( + colors.BrandCode, + fmt.Sprintf("Detected runtime: %s", answers.Type.Runtime.Title()), + ), + ) }() typ, err := answers.Discoverer.Type() if err != nil { return err } - runtime, _ := models.Runtimes.RuntimeByType(typ) + + if answers.NoInteraction { + if runtime == nil { + return fmt.Errorf("no runtime detected") + } + answers.Type.Runtime = *runtime + answers.Type.Version = runtime.DefaultVersion() + return nil + } + if runtime == nil || answers.Stack == models.GenericStack { question := &survey.Select{ Message: "What language is your project using? We support the following:", diff --git a/internal/question/welcome.go b/internal/question/welcome.go index 959626e..a6c063e 100644 --- a/internal/question/welcome.go +++ b/internal/question/welcome.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/platformsh/platformify/internal/colors" + "github.com/platformsh/platformify/internal/question/models" "github.com/platformsh/platformify/vendorization" ) @@ -15,6 +16,10 @@ func (q *Welcome) Ask(ctx context.Context) error { if !ok { return nil } + answers, ok := models.FromContext(ctx) + if !ok || answers.NoInteraction { + return nil + } assets, _ := vendorization.FromContext(ctx) fmt.Fprintln( diff --git a/internal/question/working_directory.go b/internal/question/working_directory.go index 6bccbb5..dcf6cec 100644 --- a/internal/question/working_directory.go +++ b/internal/question/working_directory.go @@ -24,18 +24,21 @@ func (q *WorkingDirectory) Ask(ctx context.Context) error { return nil } - cwd, err := os.Getwd() - if err != nil { - return err - } answers, ok := models.FromContext(ctx) if !ok { return nil } + cwd, err := os.Getwd() + if err != nil { + return err + } answers.WorkingDirectory = os.DirFS(cwd) answers.Cwd = cwd answers.HasGit = false answers.Discoverer = discovery.New(answers.WorkingDirectory) + if answers.NoInteraction { + return nil + } var outBuf, errBuf bytes.Buffer cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir") diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 88bbc4b..f47a82f 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -176,7 +176,7 @@ func GetTOMLValue( func CountFiles(fileSystem fs.FS) (map[string]int, error) { fileCounter := make(map[string]int) - err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error { + err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, _ error) error { if d.IsDir() { if slices.Contains(skipDirs, d.Name()) { return filepath.SkipDir diff --git a/platformifier/models.go b/platformifier/models.go index 7d75a71..5353784 100644 --- a/platformifier/models.go +++ b/platformifier/models.go @@ -75,7 +75,10 @@ type UserInput struct { ApplicationRoot string Name string Type string + Runtime string + SocketFamily string Environment map[string]string + Disk string BuildSteps []string WebCommand []string DeployCommand []string diff --git a/platformifier/templates/generic/.platform.app.yaml b/platformifier/templates/generic/.platform.app.yaml index 86efb62..4f9d9b0 100644 --- a/platformifier/templates/generic/.platform.app.yaml +++ b/platformifier/templates/generic/.platform.app.yaml @@ -153,13 +153,8 @@ variables: # Specifies a default set of build tasks to run. Flavors are language-specific. # More information: {{ .Assets.Docs.AppReference }}#build -{{ if .BuildFlavor -}} -build: - flavor: {{ .BuildFlavor }} -{{- else -}} # build: # flavor: none -{{- end }} # Installs global dependencies as part of the build process. They’re independent of your app’s dependencies and # are available in the PATH during the build process and in the runtime environment. They’re installed before diff --git a/platformifier/templates/upsun/.upsun/config.yaml b/platformifier/templates/upsun/.upsun/config.yaml index c5aca1e..4826459 100644 --- a/platformifier/templates/upsun/.upsun/config.yaml +++ b/platformifier/templates/upsun/.upsun/config.yaml @@ -148,13 +148,8 @@ applications: # Specifies a default set of build tasks to run. Flavors are language-specific. # More information: {{ .Assets.Docs.AppReference }}#build - {{ if .BuildFlavor -}} - build: - flavor: {{ .BuildFlavor }} - {{- else -}} # build: # flavor: none - {{- end }} # Installs global dependencies as part of the build process. They’re independent of your app’s dependencies and # are available in the PATH during the build process and in the runtime environment. They’re installed before From 96e1a235088b2c4267cd65dc86c0e5c97192e77e Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Thu, 13 Feb 2025 13:34:32 +0200 Subject: [PATCH 8/9] Refactor platformify command to support non-interactive mode and improve context handling --- commands/platformify.go | 51 ++++++++++++++++++-------- internal/colors/context.go | 4 +- internal/question/name.go | 3 ++ internal/question/type.go | 20 +++++----- internal/question/working_directory.go | 22 +++++------ internal/utils/utils.go | 8 +++- 6 files changed, 69 insertions(+), 39 deletions(-) diff --git a/commands/platformify.go b/commands/platformify.go index 67c60bd..26a487d 100644 --- a/commands/platformify.go +++ b/commands/platformify.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "path" @@ -22,8 +23,10 @@ type contextKey string var FlavorKey contextKey = "flavor" var NoInteractionKey contextKey = "no-interaction" +var FSKey contextKey = "fs" func NewPlatformifyCmd(assets *vendorization.VendorAssets) *cobra.Command { + var noInteraction bool cmd := &cobra.Command{ Use: assets.Use, Aliases: []string{"ify"}, @@ -35,24 +38,30 @@ func NewPlatformifyCmd(assets *vendorization.VendorAssets) *cobra.Command { cmd.Context(), cmd.OutOrStderr(), cmd.ErrOrStderr(), + noInteraction, assets, ) }, } + cmd.Flags().BoolVar(&noInteraction, "no-interaction", false, "Disable interactive prompts") return cmd } -func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendorization.VendorAssets) error { - answers := models.NewAnswers() - answers.Flavor, _ = ctx.Value(FlavorKey).(string) - answers.NoInteraction, _ = ctx.Value(NoInteractionKey).(bool) - ctx = models.ToContext(ctx, answers) - ctx = colors.ToContext( - ctx, - stdout, - stderr, - ) +func Discover( + ctx context.Context, + flavor string, + noInteraction bool, + fileSystem fs.FS, +) (*platformifier.UserInput, error) { + answers, _ := models.FromContext(ctx) + if answers == nil { + answers = models.NewAnswers() + ctx = models.ToContext(ctx, answers) + } + answers.Flavor = flavor + answers.NoInteraction = noInteraction + answers.WorkingDirectory = fileSystem q := questionnaire.New( &question.WorkingDirectory{}, &question.Welcome{}, @@ -73,20 +82,32 @@ func Platformify(ctx context.Context, stdout, stderr io.Writer, assets *vendoriz ) err := q.AskQuestions(ctx) if errors.Is(err, questionnaire.ErrSilent) { - return nil + return nil, nil } if err != nil { - fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error())) - return err + return nil, err } - input := answers.ToUserInput() + return answers.ToUserInput(), nil +} +func Platformify( + ctx context.Context, + stdout, stderr io.Writer, + noInteraction bool, + assets *vendorization.VendorAssets, +) error { + ctx = colors.ToContext(ctx, stdout, stderr) + ctx = models.ToContext(ctx, models.NewAnswers()) + input, err := Discover(ctx, assets.ConfigFlavor, noInteraction, nil) + if err != nil { + return err + } + answers, _ := models.FromContext(ctx) pfier := platformifier.New(input, assets.ConfigFlavor) configFiles, err := pfier.Platformify(ctx) if err != nil { - fmt.Fprintln(stderr, colors.Colorize(colors.ErrorCode, err.Error())) return fmt.Errorf("could not configure project: %w", err) } diff --git a/internal/colors/context.go b/internal/colors/context.go index 01fea1e..68a47c9 100644 --- a/internal/colors/context.go +++ b/internal/colors/context.go @@ -23,11 +23,11 @@ func ToContext(ctx context.Context, out, err io.Writer) context.Context { func FromContext(ctx context.Context) (out, err io.Writer, ok bool) { out, ok = ctx.Value(outKey).(io.Writer) if !ok { - return nil, nil, false + return io.Discard, io.Discard, false } err, ok = ctx.Value(errKey).(io.Writer) if !ok { - return nil, nil, false + return out, io.Discard, false } return out, err, true } diff --git a/internal/question/name.go b/internal/question/name.go index 9d2ea7c..354f03e 100644 --- a/internal/question/name.go +++ b/internal/question/name.go @@ -25,6 +25,9 @@ func (q *Name) Ask(ctx context.Context) error { return nil } defaultName := slugify(path.Base(answers.Cwd)) + if defaultName == "" { + defaultName = "app" + } if answers.NoInteraction { answers.Name = defaultName } diff --git a/internal/question/type.go b/internal/question/type.go index 200cdcd..1576327 100644 --- a/internal/question/type.go +++ b/internal/question/type.go @@ -28,15 +28,17 @@ func (q *Type) Ask(ctx context.Context) error { return } - fmt.Fprintf( - stderr, - "%s %s\n", - colors.Colorize(colors.GreenCode, "✓"), - colors.Colorize( - colors.BrandCode, - fmt.Sprintf("Detected runtime: %s", answers.Type.Runtime.Title()), - ), - ) + if answers.Type.Runtime.Title() != "" { + fmt.Fprintf( + stderr, + "%s %s\n", + colors.Colorize(colors.GreenCode, "✓"), + colors.Colorize( + colors.BrandCode, + fmt.Sprintf("Detected runtime: %s", answers.Type.Runtime.Title()), + ), + ) + } }() typ, err := answers.Discoverer.Type() diff --git a/internal/question/working_directory.go b/internal/question/working_directory.go index dcf6cec..0ed6e16 100644 --- a/internal/question/working_directory.go +++ b/internal/question/working_directory.go @@ -19,22 +19,20 @@ import ( type WorkingDirectory struct{} func (q *WorkingDirectory) Ask(ctx context.Context) error { - _, stderr, ok := colors.FromContext(ctx) - if !ok { - return nil - } - + _, stderr, _ := colors.FromContext(ctx) answers, ok := models.FromContext(ctx) if !ok { return nil } - cwd, err := os.Getwd() - if err != nil { - return err + if answers.WorkingDirectory == nil { + cwd, err := os.Getwd() + if err != nil { + return err + } + answers.WorkingDirectory = os.DirFS(cwd) + answers.Cwd = cwd + answers.HasGit = false } - answers.WorkingDirectory = os.DirFS(cwd) - answers.Cwd = cwd - answers.HasGit = false answers.Discoverer = discovery.New(answers.WorkingDirectory) if answers.NoInteraction { return nil @@ -44,7 +42,7 @@ func (q *WorkingDirectory) Ask(ctx context.Context) error { cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir") cmd.Stdout = &outBuf cmd.Stderr = &errBuf - err = cmd.Run() + err := cmd.Run() if err != nil { fmt.Fprintln( stderr, diff --git a/internal/utils/utils.go b/internal/utils/utils.go index f47a82f..c742558 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -5,6 +5,7 @@ import ( "bytes" "cmp" "encoding/json" + "fmt" "io" "io/fs" "os" @@ -176,7 +177,12 @@ func GetTOMLValue( func CountFiles(fileSystem fs.FS) (map[string]int, error) { fileCounter := make(map[string]int) - err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, _ error) error { + err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + fmt.Println(err) + return err + } + if d.IsDir() { if slices.Contains(skipDirs, d.Name()) { return filepath.SkipDir From 502c97d139d532c89cba4bbe9e0906a7c7ea5bbf Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 23 Mar 2026 10:53:13 +0000 Subject: [PATCH 9/9] feat(stack): add Symfony as a first-class stack type Add Symfony to the question models and handlers so that detected Symfony projects get proper configuration instead of falling back to GenericStack or redirecting to Symfony CLI. In non-interactive mode (as used by source-integration-apps), Symfony projects now produce a config with symfony-build/symfony-deploy hooks, /var/cache and /var/log tmp mounts, and public/ web root. In interactive mode, the Symfony CLI prompt is preserved. If declined, the stack is now set to Symfony instead of Generic. Changes: - models/stack.go: add Symfony constant, Title, RuntimeForStack (PHP) - models/answer.go: add Symfony case in getStack() - stack.go: set Symfony stack in both non-interactive and interactive decline paths - build_steps.go: use Symfony Cloud configurator (symfony-build) - deploy_command.go: use symfony-deploy - mounts.go: /var/cache and /var/log as tmp mounts - locations.go: root: public, passthru: /index.php Closes [SIA-20: Implement Symfony configuration in platformify](https://linear.app/platformsh/issue/SIA-20/implement-symfony-configuration-in-platformify) Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/question/build_steps.go | 7 +++++++ internal/question/deploy_command.go | 4 ++++ internal/question/locations.go | 5 +++++ internal/question/models/answer.go | 2 ++ internal/question/models/stack.go | 6 +++++- internal/question/mounts.go | 10 ++++++++++ internal/question/stack.go | 6 +++--- 7 files changed, 36 insertions(+), 4 deletions(-) diff --git a/internal/question/build_steps.go b/internal/question/build_steps.go index c78e828..a341e6f 100644 --- a/internal/question/build_steps.go +++ b/internal/question/build_steps.go @@ -136,6 +136,13 @@ func (q *BuildSteps) Ask(ctx context.Context) error { answers.BuildSteps, "bundle exec rails assets:precompile", ) + case models.Symfony: + // Replace all build steps with the Symfony Cloud configurator. + // symfony-build handles composer install and asset building internally. + answers.BuildSteps = []string{ + "curl -s https://get.symfony.com/cloud/configurator | bash", + "symfony-build", + } } return nil diff --git a/internal/question/deploy_command.go b/internal/question/deploy_command.go index fcb4c00..ac0e876 100644 --- a/internal/question/deploy_command.go +++ b/internal/question/deploy_command.go @@ -45,6 +45,10 @@ func (q *DeployCommand) Ask(ctx context.Context) error { answers.DeployCommand = append(answers.DeployCommand, "bundle exec rake db:migrate", ) + case models.Symfony: + answers.DeployCommand = append(answers.DeployCommand, + "symfony-deploy", + ) } return nil diff --git a/internal/question/locations.go b/internal/question/locations.go index ec25110..feb340e 100644 --- a/internal/question/locations.go +++ b/internal/question/locations.go @@ -26,6 +26,11 @@ func (q *Locations) Ask(ctx context.Context) error { "expires": "1h", "allow": true, } + case models.Symfony: + answers.Locations["/"] = map[string]interface{}{ + "root": "public", + "passthru": "/index.php", + } default: if answers.Type.Runtime.Type == "php" { locations := map[string]interface{}{ diff --git a/internal/question/models/answer.go b/internal/question/models/answer.go index 0cfcc70..d728c78 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -154,6 +154,8 @@ func getStack(answersStack Stack) platformifier.Stack { return platformifier.Flask case Express: return platformifier.Express + case Symfony: + return platformifier.Symfony default: return platformifier.Generic } diff --git a/internal/question/models/stack.go b/internal/question/models/stack.go index 108b9f0..34b9858 100644 --- a/internal/question/models/stack.go +++ b/internal/question/models/stack.go @@ -15,6 +15,7 @@ const ( Flask Express Rails + Symfony ) var ( @@ -27,6 +28,7 @@ var ( Flask, Express, Rails, + Symfony, } ) @@ -50,6 +52,8 @@ func (s Stack) Title() string { return "Flask" case Express: return "Express" + case Symfony: + return "Symfony" default: return "" } @@ -99,7 +103,7 @@ func RuntimeForStack(stack Stack) *Runtime { return r } return nil - case Laravel: + case Laravel, Symfony: if r, err := Runtimes.RuntimeByType("php"); err == nil { return r } diff --git a/internal/question/mounts.go b/internal/question/mounts.go index 8b67287..883a2ec 100644 --- a/internal/question/mounts.go +++ b/internal/question/mounts.go @@ -79,6 +79,16 @@ func (q *Mounts) Ask(ctx context.Context) error { "source_path": "uploads", }, } + case models.Symfony: + answers.Disk = "2048" // in MB + answers.Mounts = map[string]map[string]string{ + "/var/cache": { + "source": "tmp", + }, + "/var/log": { + "source": "tmp", + }, + } } return nil diff --git a/internal/question/stack.go b/internal/question/stack.go index d264ecd..b146616 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -76,11 +76,11 @@ func (q *Stack) Ask(ctx context.Context) error { answers.Stack = models.Strapi return nil case platformifier.Symfony: - // Pass to handle below if no interaction if answers.NoInteraction { - answers.Stack = models.GenericStack + answers.Stack = models.Symfony return nil } + // Interactive: offer Symfony CLI below. default: answers.Stack = models.GenericStack return nil @@ -140,6 +140,6 @@ func (q *Stack) Ask(ctx context.Context) error { return questionnaire.ErrSilent } - answers.Stack = models.GenericStack + answers.Stack = models.Symfony return nil }