diff --git a/.golangci.yml b/.golangci.yml index 9441e7a4..aff2ea69 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/Makefile b/Makefile index 50655ebf..912d1bbf 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/commands.go b/commands/commands.go index 93949abe..d02c804f 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 88a7711f..26a487d9 100644 --- a/commands/platformify.go +++ b/commands/platformify.go @@ -5,6 +5,9 @@ import ( "errors" "fmt" "io" + "io/fs" + "os" + "path" "github.com/spf13/cobra" @@ -19,8 +22,11 @@ import ( 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"}, @@ -28,25 +34,36 @@ 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(), + 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) - 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.FilesOverwrite{}, &question.Welcome{}, &question.Stack{}, &question.Type{}, @@ -65,23 +82,58 @@ 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) - err = pfier.Platformify(ctx) + 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) } + 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(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(stderr, "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 c00667ec..5fe4812a 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/discovery/application_root.go b/discovery/application_root.go new file mode 100644 index 00000000..2a38c394 --- /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 00000000..b582a32f --- /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 00000000..4ff140ec --- /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 00000000..ffc6c3cb --- /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 new file mode 100644 index 00000000..a520a533 --- /dev/null +++ b/discovery/dependency_managers.go @@ -0,0 +1,105 @@ +package discovery + +import ( + "slices" + + "github.com/platformsh/platformify/internal/utils" +) + +var ( + dependencyManagersMap = []struct { + lang string + lockFile string + name string + }{ + {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"}, + } +) + +// 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 +} + +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/dependency_managers_test.go b/discovery/dependency_managers_test.go new file mode 100644 index 00000000..3b8ceb36 --- /dev/null +++ b/discovery/dependency_managers_test.go @@ -0,0 +1,73 @@ +package discovery + +import ( + "io/fs" + "reflect" + "slices" + "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() + slices.Sort(got) + 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/discovery.go b/discovery/discovery.go new file mode 100644 index 00000000..a5574b21 --- /dev/null +++ b/discovery/discovery.go @@ -0,0 +1,43 @@ +package discovery + +import ( + "io/fs" + + "github.com/platformsh/platformify/platformifier" +) + +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)} +} + +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) { + // TODO: Add back dependencies + // 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 00000000..b59f966e --- /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 00000000..0d19cc35 --- /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/runtime.go b/discovery/runtime.go new file mode 100644 index 00000000..db2bccb1 --- /dev/null +++ b/discovery/runtime.go @@ -0,0 +1,83 @@ +package discovery + +import ( + "github.com/platformsh/platformify/internal/utils" + "github.com/platformsh/platformify/platformifier" +) + +var ( + languageMap = map[string]string{ + ".py": "python", + ".js": "nodejs", + ".go": "golang", + ".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 typ, ok := d.memory["type"]; ok { + return typ.(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 := utils.CountFiles(d.fileSystem) + 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 + } + } + + maxCount := 0 + selectedLang := "" + for lang, count := range langCount { + if count > maxCount { + maxCount = count + selectedLang = lang + } + } + + return selectedLang, nil +} diff --git a/discovery/runtime_test.go b/discovery/runtime_test.go new file mode 100644 index 00000000..fc478cc8 --- /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 00000000..ee68586a --- /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/discovery/web_command.go b/discovery/web_command.go new file mode 100644 index 00000000..be020211 --- /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/go.mod b/go.mod index d0194ab6..f9192b3f 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/colors/context.go b/internal/colors/context.go index 01fea1ee..68a47c99 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/almost_done.go b/internal/question/almost_done.go index 252a8a4a..6b4d8bed 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/application_root.go b/internal/question/application_root.go index 7a13c084..f1c86514 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 00000000..5d8d8738 --- /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 5a3529dc..a341e6fc 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, @@ -137,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/build_steps_test.go b/internal/question/build_steps_test.go index 302970dc..a2854f8d 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,8 @@ func TestBuildSteps_Ask(t *testing.T) { type args struct { answers models.Answers } + nodeJS, _ := models.Runtimes.RuntimeByType("nodejs") + ruby, _ := models.Runtimes.RuntimeByType("ruby") tests := []struct { name string q *BuildSteps @@ -24,10 +27,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 +41,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, @@ -50,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{}, @@ -63,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/dependency_manager.go b/internal/question/dependency_manager.go index 22a4526d..c0a99072 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 new file mode 100644 index 00000000..4019c0d6 --- /dev/null +++ b/internal/question/dependency_manager_test.go @@ -0,0 +1,73 @@ +package question + +import ( + "context" + "io/fs" + "slices" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/discovery" + "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 + a.Discoverer = discovery.New(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 b7c6fe36..ac0e8760 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 " @@ -46,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/done.go b/internal/question/done.go index a2b6007c..3593a018 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/environment.go b/internal/question/environment.go index b11d8e3d..791a4bf1 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/files_overwrite.go b/internal/question/files_overwrite.go index 0de6f16f..95dc71a7 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,11 +13,13 @@ 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) - if !ok { + if !ok || answers.NoInteraction { return nil } @@ -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 2546150f..feb340e8 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{}{ @@ -23,16 +26,20 @@ 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 == 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 17700eec..d728c785 100644 --- a/internal/question/models/answer.go +++ b/internal/question/models/answer.go @@ -2,34 +2,39 @@ package models import ( "encoding/json" + "io/fs" "os" "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"` - WorkingDirectory string `json:"working_directory"` - HasGit bool `json:"has_git"` - FilesCreated []string `json:"files_created"` - Locations map[string]map[string]interface{} `json:"locations"` + NoInteraction bool + 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 + Locations map[string]map[string]interface{} + Discoverer *discovery.Discoverer } type Service struct { @@ -45,14 +50,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()) } @@ -74,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), } } @@ -112,21 +118,18 @@ 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(), - Runtime: a.Type.Runtime.String(), + Runtime: strings.Split(a.Type.Runtime.String(), ":")[0], + SocketFamily: a.SocketFamily.String(), + Disk: a.Disk, 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), @@ -151,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/registry.go b/internal/question/models/registry.go new file mode 100644 index 00000000..aef19872 --- /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 00000000..2252e8ca --- /dev/null +++ b/internal/question/models/registry.json @@ -0,0 +1,1264 @@ +{ + "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" + ] + } + } +} \ No newline at end of file diff --git a/internal/question/models/runtime.go b/internal/question/models/runtime.go index 599cba53..27e95012 100644 --- a/internal/question/models/runtime.go +++ b/internal/question/models/runtime.go @@ -4,64 +4,43 @@ import ( "fmt" ) -const ( - DotNet Runtime = "dotnet" - Elixir Runtime = "elixir" - Golang Runtime = "golang" - Java Runtime = "java" - NodeJS Runtime = "nodejs" - PHP Runtime = "php" - Python Runtime = "python" - Ruby Runtime = "ruby" - Rust Runtime = "rust" -) - -var ( - Runtimes = RuntimeList{ - DotNet, - Elixir, - Golang, - Java, - 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 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)) @@ -71,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 f42c8212..38bf24fd 100644 --- a/internal/question/models/service_name.go +++ b/internal/question/models/service_name.go @@ -6,102 +6,44 @@ import ( "github.com/AlecAivazis/survey/v2" ) -const ( - ChromeHeadless ServiceName = "chrome-headless" - ClickHouse ServiceName = "clickhouse" - 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) { @@ -127,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 06783f49..34b9858c 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 "" } @@ -88,17 +92,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 - case Laravel: - return PHP + if r, err := Runtimes.RuntimeByType("ruby"); err == nil { + return r + } + return nil + case Laravel, Symfony: + if r, err := Runtimes.RuntimeByType("php"); err == nil { + return r + } + return nil case NextJS, Strapi, Express: - return NodeJS - default: - return "" + if r, err := Runtimes.RuntimeByType("nodejs"); err == nil { + return r + } + return nil } + + return nil } diff --git a/internal/question/models/stack_test.go b/internal/question/models/stack_test.go new file mode 100644 index 00000000..cb3ca558 --- /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 963030de..00000000 --- a/internal/question/models/version.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:generate go run generate_versions.go - -package models - -var ( - LanguageTypeVersions = map[Runtime][]string{ - DotNet: {"8.0", "7.0", "6.0"}, - Elixir: {"1.18", "1.15", "1.14"}, - Golang: {"1.25", "1.24", "1.23", "1.22", "1.21", "1.20"}, - Java: {"21", "19", "18", "17", "11", "8"}, - NodeJS: {"24", "22", "20"}, - PHP: {"8.4", "8.3", "8.2", "8.1"}, - Python: {"3.13", "3.12", "3.11", "3.10", "3.9", "3.8"}, - Ruby: {"3.4", "3.3", "3.2", "3.1", "3.0"}, - Rust: {"1"}, - } - - ServiceTypeVersions = map[ServiceName][]string{ - ChromeHeadless: {"120", "113", "95", "91"}, - ClickHouse: {"25.3", "24.3", "23.8"}, - InfluxDB: {"2.7", "2.3"}, - Kafka: {"3.7", "3.6", "3.4", "3.2"}, - MariaDB: {"11.8", "11.4", "10.11", "10.6"}, - Memcached: {"1.6", "1.5", "1.4"}, - MySQL: {"11.8", "11.4", "10.11", "10.6"}, - NetworkStorage: {"1.0"}, - OpenSearch: {"3", "2"}, - OracleMySQL: {"8.0", "5.7"}, - PostgreSQL: {"18", "17", "16", "15", "14", "13", "12"}, - RabbitMQ: {"4.1", "4.0", "3.13", "3.12"}, - Redis: {"8.0", "7.2"}, - RedisPersistent: {"8.0", "7.2"}, - Solr: {"9.9", "9.6", "9.4", "9.2", "9.1", "8.11"}, - Varnish: {"7.6", "7.3", "7.2", "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/mounts.go b/internal/question/mounts.go index 8b672874..883a2ec1 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/name.go b/internal/question/name.go index 78f0131e..354f03ea 100644 --- a/internal/question/name.go +++ b/internal/question/name.go @@ -24,13 +24,20 @@ func (q *Name) Ask(ctx context.Context) error { if !ok { return nil } + defaultName := slugify(path.Base(answers.Cwd)) + if defaultName == "" { + defaultName = "app" + } + 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.WorkingDirectory)), + 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 01cc73fb..a6cbf849 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 } @@ -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 51f3134f..19fdaea6 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 6d94a10c..b1466168 100644 --- a/internal/question/stack.go +++ b/internal/question/stack.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "os" - "slices" - "strings" "github.com/AlecAivazis/survey/v2" @@ -13,6 +11,7 @@ import ( "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" ) @@ -34,8 +33,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 { @@ -52,167 +51,95 @@ 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: + if answers.NoInteraction { + answers.Stack = models.Symfony + return nil + } + // Interactive: offer Symfony CLI below. + default: + answers.Stack = models.GenericStack + return nil } - rackPath := utils.FindFile(answers.WorkingDirectory, rackFile) + rackPath := utils.FindFile(answers.WorkingDirectory, "", rackFile) if rackPath != "" { 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 } } } - 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 { + if ok, _ = utils.ContainsStringInFile(f, "flask", true); ok { answers.Stack = models.Flask return nil } } } - pyProjectPath := utils.FindFile(answers.WorkingDirectory, "pyproject.toml") - if pyProjectPath != "" { - if _, ok := utils.GetTOMLValue([]string{"tool", "poetry", "dependencies", "flask"}, pyProjectPath, true); ok { - answers.Stack = models.Flask - 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 } - pipfilePath := utils.FindFile(answers.WorkingDirectory, "Pipfile") - if pipfilePath != "" { - if _, ok := utils.GetTOMLValue([]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([]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([]string{"dependencies", "next"}, packageJSONPath, true); ok { - answers.Stack = models.NextJS - return nil - } - - if _, ok := utils.GetJSONValue([]string{"dependencies", "@strapi/strapi"}, packageJSONPath, true); ok { - answers.Stack = models.Strapi - return nil - } - - if _, ok := utils.GetJSONValue([]string{"dependencies", "strapi"}, packageJSONPath, true); ok { - answers.Stack = models.Strapi - return nil - } - - if _, ok := utils.GetJSONValue([]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([]string{"autoload", "psr-0", "shopware"}, composerJSONPath, true); ok { - hasShopwareDependencies = true - break - } - if _, ok := utils.GetJSONValue([]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 { - hasShopwareDependencies = true - break - } - - if keywords, ok := utils.GetJSONValue([]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 requirementsVal, requirementsOK := requirements.(map[string]interface{}); requirementsOK { - if _, hasSymfonyFrameworkBundle := requirementsVal["symfony/framework-bundle"]; hasSymfonyFrameworkBundle { - 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.Symfony return nil } diff --git a/internal/question/stack_test.go b/internal/question/stack_test.go new file mode 100644 index 00000000..7eb557f9 --- /dev/null +++ b/internal/question/stack_test.go @@ -0,0 +1,89 @@ +package question + +import ( + "context" + "io/fs" + "testing" + "testing/fstest" + + "github.com/platformsh/platformify/discovery" + "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 + a.Discoverer = discovery.New(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 02fc0128..1576327a 100644 --- a/internal/question/type.go +++ b/internal/question/type.go @@ -28,7 +28,7 @@ func (q *Type) Ask(ctx context.Context) error { return } - if answers.Stack != models.GenericStack { + if answers.Type.Runtime.Title() != "" { fmt.Fprintf( stderr, "%s %s\n", @@ -41,12 +41,29 @@ func (q *Type) Ask(ctx context.Context) error { } }() - runtime := models.RuntimeForStack(answers.Stack) - if runtime == "" { + 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:", Options: models.Runtimes.AllTitles(), } + if runtime != nil { + question.Default = runtime.Title() + } var title string err := survey.AskOne(question, &title, survey.WithPageSize(len(question.Options))) @@ -59,8 +76,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 137bbbbb..9036aece 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/welcome.go b/internal/question/welcome.go index 959626e5..a6c063e8 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 db12e874..0ed6e16e 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" @@ -18,27 +19,30 @@ import ( type WorkingDirectory struct{} func (q *WorkingDirectory) Ask(ctx context.Context) error { - _, stderr, ok := colors.FromContext(ctx) + _, 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, ok := models.FromContext(ctx) - if !ok { + answers.Discoverer = discovery.New(answers.WorkingDirectory) + if answers.NoInteraction { return nil } - answers.WorkingDirectory = cwd - answers.HasGit = false var outBuf, errBuf bytes.Buffer 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, @@ -84,8 +88,10 @@ func (q *WorkingDirectory) Ask(ctx context.Context) error { } if proceed { - answers.WorkingDirectory = gitRepoAbsPath + answers.WorkingDirectory = os.DirFS(gitRepoAbsPath) + answers.Cwd = gitRepoAbsPath answers.HasGit = true + answers.Discoverer = discovery.New(answers.WorkingDirectory) } } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index d83b378e..c7425585 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -5,7 +5,9 @@ import ( "bytes" "cmp" "encoding/json" + "fmt" "io" + "io/fs" "os" "path/filepath" "slices" @@ -22,15 +24,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 +44,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 +95,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 +148,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 } @@ -171,3 +174,35 @@ func GetTOMLValue(keyPath []string, filePath string, caseInsensitive bool) (valu 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 err != nil { + fmt.Println(err) + return err + } + + 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/django.go b/platformifier/django.go index 08d5cae1..15a95ead 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 2962b6da..00000000 --- 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 9d606272..00000000 --- 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 af7f785e..00000000 --- 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 f38fbc2e..5280b9a1 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 86db6f5d..00000000 --- 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 7ffd5c67..e4fd0a9c 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/models.go b/platformifier/models.go index 68162612..5353784d 100644 --- a/platformifier/models.go +++ b/platformifier/models.go @@ -1,6 +1,7 @@ package platformifier import ( + "io/fs" "strings" ) @@ -27,6 +28,9 @@ const ( Flask Express Rails + Symfony + Ibexa + Shopware ) type Stack int @@ -49,6 +53,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 "" } @@ -62,26 +72,24 @@ type Relationship struct { // UserInput contains the configuration from user input. type UserInput struct { Stack Stack - Root string ApplicationRoot string Name string Type string Runtime string + SocketFamily string Environment map[string]string + Disk 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. diff --git a/platformifier/nextjs.go b/platformifier/nextjs.go deleted file mode 100644 index 5fcc3752..00000000 --- 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 e886771c..1a370f78 100644 --- a/platformifier/platformifier.go +++ b/platformifier/platformifier.go @@ -26,11 +26,9 @@ 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 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 +37,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 +48,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 +73,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 82a4a678..00000000 --- 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 4651e4d4..00000000 --- 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.13", - 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{"17", "16", "15", "14", "13", "12"}, - Disk: "1024", - DiskSizes: []string{"1024", "2048"}, - }, - { - Name: "mysql", - Type: "mysql", - TypeVersions: []string{"11.0", "10.11", "10.6", "10.5", "10.4", "10.3"}, - 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.4", - 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/platformifier/templates/generic/.platform.app.yaml b/platformifier/templates/generic/.platform.app.yaml index 0c21624a..1bdab730 100644 --- a/platformifier/templates/generic/.platform.app.yaml +++ b/platformifier/templates/generic/.platform.app.yaml @@ -152,14 +152,9 @@ variables: # firewall: # 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 -}} +# More information: {{ .Assets.Docs.AppReference }}#build # 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 7ec361e4..a685eb0c 100644 --- a/platformifier/templates/upsun/.upsun/config.yaml +++ b/platformifier/templates/upsun/.upsun/config.yaml @@ -147,14 +147,9 @@ applications: # firewall: # 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 -}} + # More information: {{ .Assets.Docs.AppReference }}#build # 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/validator/validator.go b/validator/validator.go index 3cd4d507..b7f0a626 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)) } }