diff --git a/CLAUDE.md b/CLAUDE.md index f29fbf47..50b91b45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,9 +114,9 @@ The CLI operates as a wrapper around a legacy PHP CLI: - The `csv`, `tsv` and `plain` formats match the legacy output, with ANSI escape sequences removed - The `table` format wraps cells to the terminal width (a port of the legacy `AdaptiveTable`), and supports ANSI-styled cells -**Project Initialization**: `internal/init/` -- AI-powered project configuration generation -- Integrates with whatsun library for codebase analysis +**Project Initialization**: `internal/init/` and `internal/platformify/` +- `init` generates a starter configuration with AI (`internal/init`, using the whatsun library for codebase analysis), or with a guided wizard (`internal/platformify`, formerly the Platformify project), which also supports Fixed configuration +- The wizard detects the stack, asks a few questions, and renders `internal/platformify/templates` (`flex` or `fixed`, by `project_config_flavor`). Runtime and service versions come from `pkg/registry`, and tests check the output with `pkg/lint` ### Build System diff --git a/commands/auth_test.go b/commands/auth_test.go index a5fa9b70..7da9123d 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -7,7 +7,6 @@ import ( "strings" "testing" - "github.com/platformsh/platformify/vendorization" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -26,7 +25,7 @@ func TestAuthCommands_Disabled(t *testing.T) { func TestAuthCommands_LegacyGlobalFlags(t *testing.T) { t.Setenv("TEST_GO_AUTH", "1") - root := newRootCommand(testConfig(), &vendorization.VendorAssets{}) + root := newRootCommand(testConfig()) for _, args := range [][]string{ {"logout", "-n"}, {"auth:token", "-W", "--no-ansi"}, @@ -41,7 +40,7 @@ func TestAuthCommands_LegacyGlobalFlags(t *testing.T) { func TestAuthCommands_GoAuthDisabled(t *testing.T) { t.Setenv("TEST_GO_AUTH", "") assert.Empty(t, authCommands(testConfig())) - root := newRootCommand(testConfig(), &vendorization.VendorAssets{}) + root := newRootCommand(testConfig()) c, _, err := root.Find([]string{"auth:internal"}) require.NoError(t, err) assert.Equal(t, root, c, "auth:internal must not be registered") diff --git a/commands/completion_test.go b/commands/completion_test.go index 1f4d73be..950a4861 100644 --- a/commands/completion_test.go +++ b/commands/completion_test.go @@ -7,7 +7,6 @@ import ( "strings" "testing" - "github.com/platformsh/platformify/vendorization" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/stretchr/testify/assert" @@ -77,7 +76,7 @@ func TestCompleteCommandPassesArgsThrough(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { cnf := testConfig() - root := newRootCommand(cnf, &vendorization.VendorAssets{Binary: cnf.Application.Executable}) + root := newRootCommand(cnf) root.SetOut(io.Discard) root.SetErr(io.Discard) diff --git a/commands/init.go b/commands/init.go index 1b5e2024..0eeb5d02 100644 --- a/commands/init.go +++ b/commands/init.go @@ -14,14 +14,14 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/fatih/color" - "github.com/platformsh/platformify/commands" - "github.com/platformsh/platformify/vendorization" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/upsun/whatsun/pkg/digest" "github.com/upsun/whatsun/pkg/files" "gopkg.in/yaml.v3" + "github.com/upsun/cli/internal/platformify" + "github.com/upsun/cli/internal/legacy" "github.com/upsun/cli/internal/api" @@ -30,7 +30,7 @@ import ( _init "github.com/upsun/cli/internal/init" ) -func newInitCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cobra.Command { +func newInitCommand(cnf *config.Config) *cobra.Command { var ( useAI bool initOptions = &_init.Options{} @@ -48,7 +48,7 @@ func newInitCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob " " + cnf.Application.Executable + " init --ai --context='Use PostgreSQL for the database'", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runInitCommand(cmd, args, useAI, initOptions, assets) + return runInitCommand(cmd, args, useAI, initOptions) }, } @@ -81,7 +81,7 @@ func initCommandHelp(cnf *config.Config, short bool) string { // runInitCommand handles the main logic for the init command. func runInitCommand( - cmd *cobra.Command, args []string, useAI bool, initOptions *_init.Options, assets *vendorization.VendorAssets, + cmd *cobra.Command, args []string, useAI bool, initOptions *_init.Options, ) error { var path = "." if len(args) > 0 { @@ -104,12 +104,12 @@ func runInitCommand( return yaml.NewEncoder(cmd.OutOrStdout()).Encode(dg) } + cnf := config.FromContext(cmd.Context()) + runNonAIConfig := func() error { - return commands.Platformify(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), assets) + return platformify.Run(cnf, platformify.Options{Dir: gitRoot, Stdout: cmd.OutOrStdout(), Stderr: cmd.ErrOrStderr()}) } - cnf := config.FromContext(cmd.Context()) - ia, err := newInitAuth(cmd, cnf) if err != nil { return err diff --git a/commands/lint_test.go b/commands/lint_test.go index ed856941..001ccda6 100644 --- a/commands/lint_test.go +++ b/commands/lint_test.go @@ -10,7 +10,6 @@ import ( "testing" "github.com/fatih/color" - "github.com/platformsh/platformify/vendorization" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -207,7 +206,7 @@ func TestLintCommand_Registration(t *testing.T) { cnf := testConfig() cnf.Service.ProjectConfigDir = c.configDir cnf.Service.AppConfigFile = c.appFile - root := newRootCommand(cnf, &vendorization.VendorAssets{}) + root := newRootCommand(cnf) cmd, _, err := root.Find([]string{"lint"}) require.NoError(t, err) assert.Equal(t, c.want, cmd != root) diff --git a/commands/list_models_test.go b/commands/list_models_test.go index 3cbab96f..cabcf8a7 100644 --- a/commands/list_models_test.go +++ b/commands/list_models_test.go @@ -38,7 +38,7 @@ func TestHelpPageExamples(t *testing.T) { }, { name: "init", - cmd: commandFromCobra(cnf, newInitCommand(cnf, nil)), + cmd: commandFromCobra(cnf, newInitCommand(cnf)), expected: `Examples: Create the starter YAML file(s) for your project: upsun init diff --git a/commands/root.go b/commands/root.go index 944ab40b..e9cf0954 100644 --- a/commands/root.go +++ b/commands/root.go @@ -14,7 +14,6 @@ import ( "time" "github.com/fatih/color" - "github.com/platformsh/platformify/vendorization" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -26,17 +25,8 @@ import ( // Execute is the main entrypoint to run the CLI. func Execute(cnf *config.Config) error { - assets := &vendorization.VendorAssets{ - Use: "project:init", - Binary: cnf.Application.Executable, - ConfigFlavor: cnf.Service.ProjectConfigFlavor, - EnvPrefix: strings.TrimSuffix(cnf.Service.EnvPrefix, "_"), - ServiceName: cnf.Service.Name, - DocsBaseURL: cnf.Service.DocsURL, - } - - ctx := vendorization.WithVendorAssets(config.ToContext(context.Background(), cnf), assets) - cmd := newRootCommand(cnf, assets) + ctx := config.ToContext(context.Background(), cnf) + cmd := newRootCommand(cnf) if args, ok, err := expandAbbreviation(cmd, enabledLegacyCommands(cnf, legacy.Commands), os.Args[1:]); err != nil { debugLogf("Failed to load the legacy command index: %s", err) } else if ok { @@ -71,7 +61,7 @@ func waitForUpdateCheck() { updateCheck.Wait(updateCheckWait) } -func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cobra.Command { +func newRootCommand(cnf *config.Config) *cobra.Command { versionCommand := newVersionCommand(cnf) cmd := &cobra.Command{ Use: cnf.Application.Executable, @@ -182,7 +172,7 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob newConfigInstallCommand(), newCompletionCommand(cnf), newHelpCommand(cnf), - useLegacyStyleHelp(cnf, newInitCommand(cnf, assets)), + useLegacyStyleHelp(cnf, newInitCommand(cnf)), newListCommand(cnf), versionCommand, ) diff --git a/go.mod b/go.mod index 4b758584..1403abbd 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.27.1 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Masterminds/semver/v3 v3.5.0 + github.com/Masterminds/sprig/v3 v3.3.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/x/ansi v0.11.9 @@ -14,9 +15,10 @@ require ( github.com/go-playground/validator/v10 v10.30.5 github.com/godbus/dbus/v5 v5.2.3 github.com/gofrs/flock v0.13.1 + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/oklog/ulid/v2 v2.1.2 + github.com/pelletier/go-toml/v2 v2.3.0 github.com/pkg/sftp v1.13.11 - github.com/platformsh/platformify v0.5.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 @@ -42,7 +44,6 @@ require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/IGLOU-EU/go-wildcard/v2 v2.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/STARRY-S/zip v0.2.3 // indirect @@ -95,7 +96,6 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mholt/archives v0.1.5 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.1.1 // indirect @@ -103,7 +103,6 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/nwaples/rardecode/v2 v2.2.2 // indirect - github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pjbgf/sha1cd v0.7.0 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 662cf3b6..2d7af14f 100644 --- a/go.sum +++ b/go.sum @@ -130,8 +130,6 @@ github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/google/cel-go v0.31.0 h1:H0bhpFTqOvmHrBGrWKp7ZlhBm5Hh8PYUEXnwxT1LL7A= github.com/google/cel-go v0.31.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -224,8 +222,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= -github.com/platformsh/platformify v0.5.0 h1:i9cRbM0K1f14Yr0dJ6dhYq9BcmZb8fsx/mpYSnw8ssA= -github.com/platformsh/platformify v0.5.0/go.mod h1:fgmCcfQfHbhe1oXsIdIhpnniyZu8IdIMOlcBAa/ygic= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/internal/platformify/colors.go b/internal/platformify/colors.go new file mode 100644 index 00000000..e496a2be --- /dev/null +++ b/internal/platformify/colors.go @@ -0,0 +1,35 @@ +package platformify + +import ( + "os" + + "github.com/AlecAivazis/survey/v2/core" + "github.com/mgutz/ansi" +) + +// Colors, as names for survey templates and as ANSI codes. +var ( + colorBrand = "210" + colorAccent = "33" + colorSecondary = "247" + colorError = "red" + colorDefault = "default" + + brandCode = ansi.ColorCode(colorBrand) + accentCode = ansi.ColorCode(colorAccent) + greenCode = ansi.ColorCode("green") + warningCode = ansi.ColorCode("yellow") + resetCode = ansi.ColorCode("reset") +) + +func init() { + if v := os.Getenv("NO_COLOR"); v != "" && v != "0" { + brandCode, accentCode, greenCode, warningCode, resetCode = "", "", "", "", "" + core.DisableColor = true + } +} + +// colorize wraps s in the given ANSI color code. +func colorize(code, s string) string { + return code + s + resetCode +} diff --git a/internal/platformify/detect.go b/internal/platformify/detect.go new file mode 100644 index 00000000..2d4a9748 --- /dev/null +++ b/internal/platformify/detect.go @@ -0,0 +1,463 @@ +package platformify + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +const ( + settingsPyFile = "settings.py" + managePyFile = "manage.py" + composerJSON = "composer.json" + packageJSON = "package.json" +) + +// detectStack sets the stack from the project's files. +func (w *wizard) detectStack() error { + w.answers.stack = w.findStack() + if w.answers.stack != genericStack { + w.detected("stack", w.answers.stack.Title()) + } + if w.answers.stack == genericStack && w.isSymfony() { + return w.suggestSymfonyCLI() + } + return nil +} + +func (w *wizard) findStack() stack { + dir := w.dir + if fileExists(dir, settingsPyFile) && fileExists(dir, managePyFile) { + return django + } + if p := findFile(dir, "config.ru"); p != "" && fileContains(p, "Rails.application.load_server") { + return rails + } + if p := findFile(dir, "requirements.txt"); p != "" && fileContains(p, "flask") { + return flask + } + if p := findFile(dir, "pyproject.toml"); p != "" { + if _, ok := tomlValue(p, "tool", "poetry", "dependencies", "flask"); ok { + return flask + } + } + if p := findFile(dir, "Pipfile"); p != "" { + if _, ok := tomlValue(p, "packages", "flask"); ok { + return flask + } + } + for _, p := range findAllFiles(dir, composerJSON) { + if _, ok := jsonValue(p, "require", "laravel/framework"); ok { + return laravel + } + } + for _, p := range findAllFiles(dir, packageJSON) { + if _, ok := jsonValue(p, "dependencies", "next"); ok { + return nextJS + } + if _, ok := jsonValue(p, "dependencies", "@strapi/strapi"); ok { + return strapi + } + if _, ok := jsonValue(p, "dependencies", "strapi"); ok { + return strapi + } + if _, ok := jsonValue(p, "dependencies", "express"); ok { + return express + } + } + return genericStack +} + +// isSymfony reports whether the project uses Symfony full-stack, and not Ibexa or Shopware. +func (w *wizard) isSymfony() bool { + isSymfony := fileExists(w.dir, "symfony.lock") + for _, p := range findAllFiles(w.dir, composerJSON) { + if _, ok := jsonValue(p, "autoload", "psr-0", "shopware"); ok { + return false + } + if _, ok := jsonValue(p, "autoload", "psr-4", `shopware\core\`); ok { + return false + } + if _, ok := jsonValue(p, "autoload", "psr-4", `shopware\appbundle\`); ok { + return false + } + if keywords, ok := jsonValue(p, "keywords"); ok { + if k, ok := keywords.([]any); ok && slices.ContainsFunc(k, func(v any) bool { + s, _ := v.(string) + return strings.EqualFold(s, "shopware") + }) { + return false + } + } + if require, ok := jsonValue(p, "require"); ok { + if r, ok := require.(map[string]any); ok { + if _, ok := r["symfony/framework-bundle"]; ok { + isSymfony = true + } + for name := range r { + if strings.HasPrefix(name, "shopware/") || strings.HasPrefix(name, "ibexa/") || + strings.HasPrefix(name, "ezsystems/") { + return false + } + } + } + } + } + return isSymfony +} + +// detectDependencyManagers sets the dependency managers from lock files. +func (w *wizard) detectDependencyManagers() error { + a := w.answers + a.dependencies = map[string]map[string]string{} + a.buildFlavor = "none" + + switch { + case fileExists(w.dir, "poetry.lock"): + a.dependencyManagers = append(a.dependencyManagers, poetry) + case fileExists(w.dir, "Pipfile.lock"): + a.dependencyManagers = append(a.dependencyManagers, pipenv) + case fileExists(w.dir, "requirements.txt"): + a.dependencyManagers = append(a.dependencyManagers, pip) + } + if fileExists(w.dir, "composer.lock") { + a.dependencyManagers = append(a.dependencyManagers, composer) + a.dependencies[php] = map[string]string{"composer/composer": "^2"} + } + switch { + case fileExists(w.dir, "yarn.lock"): + a.dependencyManagers = append(a.dependencyManagers, yarn) + a.dependencies[nodejs] = map[string]string{"yarn": "^1.22.0"} + case fileExists(w.dir, "package-lock.json"): + a.dependencyManagers = append(a.dependencyManagers, npm) + } + if fileExists(w.dir, "Gemfile.lock") { + a.dependencyManagers = append(a.dependencyManagers, bundler) + } + + if len(a.dependencyManagers) > 0 { + names := make([]string, len(a.dependencyManagers)) + for i, dm := range a.dependencyManagers { + names[i] = depManagerTitles[dm] + } + w.detected("dependency managers", strings.Join(names, ", ")) + } + return nil +} + +// detectApplicationRoot sets the application root to the directory of the first dependency manager's file. +func (w *wizard) detectApplicationRoot() error { + files := map[string]string{ + composer: composerJSON, + npm: packageJSON, + yarn: packageJSON, + poetry: "pyproject.toml", + pipenv: "Pipfile", + pip: "requirements.txt", + } + for _, dm := range w.answers.dependencyManagers { + name, ok := files[dm] + if !ok { + continue + } + if p := findFile(w.dir, name); p != "" { + w.answers.applicationRoot, _ = filepath.Rel(w.dir, filepath.Dir(p)) + return nil + } + } + return nil +} + +// appDir returns the absolute path of the application root. +func (w *wizard) appDir() string { + return filepath.Join(w.dir, w.answers.applicationRoot) +} + +// setEnvironment sets environment variables for the dependency managers. +func (w *wizard) setEnvironment() error { + a := w.answers + a.environment = map[string]string{} + for _, dm := range a.dependencyManagers { + switch dm { + case poetry: + a.environment["POETRY_VERSION"] = "1.8.4" + a.environment["POETRY_VIRTUALENVS_IN_PROJECT"] = "true" + case pipenv: + a.environment["PIPENV_TOOL_VERSION"] = "2024.2.0" + a.environment["PIPENV_VENV_IN_PROJECT"] = "1" + } + } + return nil +} + +// setBuildSteps sets the build hook commands. +func (w *wizard) setBuildSteps() error { + a := w.answers + for _, dm := range a.dependencyManagers { + switch dm { + case poetry: + a.buildSteps = append(a.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: + a.buildSteps = append(a.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 sync", + ) + case pip: + a.buildSteps = append(a.buildSteps, "pip install -r requirements.txt") + case yarn, npm: + if a.runtime != nodejs { + if _, ok := a.dependencies[nodejs]; !ok { + a.dependencies[nodejs] = map[string]string{} + } + a.dependencies[nodejs]["n"] = "*" + a.dependencies[nodejs]["npx"] = "*" + a.environment["N_PREFIX"] = "/app/.global" + a.buildSteps = append(a.buildSteps, "n auto || n lts", "hash -r") + } + _, hasBuild := jsonValue(filepath.Join(w.appDir(), packageJSON), "scripts", "build") + if dm == yarn { + a.buildSteps = append(a.buildSteps, "yarn") + if hasBuild { + a.buildSteps = append(a.buildSteps, "yarn build") + } + } else { + a.buildSteps = append(a.buildSteps, "npm i") + if hasBuild { + a.buildSteps = append(a.buildSteps, "npm run build") + } + } + case composer: + a.buildSteps = append(a.buildSteps, + "composer --no-ansi --no-interaction install --no-progress --prefer-dist --optimize-autoloader --no-dev", + ) + case bundler: + a.buildSteps = append(a.buildSteps, "bundle install") + } + } + + switch a.stack { + case django: + if managePy := w.managePyPath(); managePy != "" { + a.buildSteps = append(a.buildSteps, + "# Collect static files so that they can be served by "+w.assets.ServiceName, + fmt.Sprintf("%spython %s collectstatic --noinput", a.pythonRunPrefix(), managePy), + ) + } + case nextJS: + // Fall back to "next build" if there is no build script. + if !slices.Contains(a.buildSteps, "yarn build") && !slices.Contains(a.buildSteps, "npm run build") { + if slices.Contains(a.dependencyManagers, yarn) { + a.buildSteps = append(a.buildSteps, "yarn exec next build") + } else { + a.buildSteps = append(a.buildSteps, "npm exec next build") + } + } + case rails: + a.environment["RAILS_ENV"] = "production" + a.environment["PIDFILE"] = "tmp/server.pid" + a.buildSteps = append(a.buildSteps, "bundle exec rails assets:precompile") + } + return nil +} + +// managePyPath returns the path of Django's manage.py relative to the application root, or "". +func (w *wizard) managePyPath() string { + p := findFile(w.appDir(), managePyFile) + if p == "" { + return "" + } + rel, _ := filepath.Rel(w.appDir(), p) + return filepath.ToSlash(rel) +} + +// setDeployCommand sets the deploy hook commands. +func (w *wizard) setDeployCommand() error { + a := w.answers + switch a.stack { + case django: + if managePy := w.managePyPath(); managePy != "" { + a.deployCommand = append(a.deployCommand, fmt.Sprintf("%spython %s migrate", a.pythonRunPrefix(), managePy)) + } + case laravel: + a.deployCommand = append(a.deployCommand, + "mkdir -p storage/framework/sessions", + "mkdir -p storage/framework/cache", + "mkdir -p storage/framework/views", + "php artisan migrate --force", + "php artisan optimize:clear", + ) + case rails: + a.deployCommand = append(a.deployCommand, "bundle exec rake db:migrate") + } + return nil +} + +// setSocketFamily sets how the web server listens: on a Unix socket or a TCP port. +func (w *wizard) setSocketFamily() error { + switch w.answers.runtime { + case php: + case ruby, python: + w.answers.socketFamily = unix + default: + w.answers.socketFamily = tcp + } + return nil +} + +// setWebCommand sets the command to start the web server. +func (w *wizard) setWebCommand() error { + a := w.answers + if a.runtime == php { + return nil + } + listen := `listen to the "$SOCKET" Unix socket` + if a.socketFamily == tcp { + listen = `listen on the "$PORT" port` + } + a.webCommand = fmt.Sprintf( + "echo 'Put your web server command in here! You need to %s. Read more about it here: %s#web-commands'; sleep 60", + listen, w.assets.Docs().AppReference, + ) + + packageJSONPath := filepath.Join(w.appDir(), packageJSON) + npmStart := func() string { + if slices.Contains(a.dependencyManagers, yarn) { + return "NODE_ENV=production yarn start" + } + return "NODE_ENV=production npm start" + } + switch a.stack { + case django: + pythonPath := "" + wsgi := "app.wsgi" + if wsgiPath := findFile(w.appDir(), "wsgi.py"); wsgiPath != "" { + wsgiDir := filepath.Dir(wsgiPath) + wsgi = filepath.Base(wsgiDir) + ".wsgi" + // Add the Python path if the WSGI module's package is not directly in the application root. + if rel, _ := filepath.Rel(w.appDir(), filepath.Dir(wsgiDir)); rel != "." { + pythonPath = "--pythonpath=" + filepath.Base(filepath.Dir(wsgiDir)) + } + } + bind := "unix:$SOCKET" + if a.socketFamily == tcp { + bind = "0.0.0.0:$PORT" + } + a.webCommand = fmt.Sprintf("%sgunicorn %s -b %s %s --log-file -", a.pythonRunPrefix(), pythonPath, bind, wsgi) + case rails: + if a.socketFamily == tcp { + a.webCommand = "bundle exec rails server" + } else { + a.webCommand = "bundle exec puma -b unix://$SOCKET" + } + case nextJS: + a.webCommand = "npx next start -p $PORT" + case strapi: + if _, ok := jsonValue(packageJSONPath, "scripts", "start"); ok { + a.webCommand = npmStart() + } + case express: + if _, ok := jsonValue(packageJSONPath, "scripts", "start"); ok { + a.webCommand = npmStart() + } else if main, ok := jsonValue(packageJSONPath, "main"); ok { + if s, ok := main.(string); ok { + a.webCommand = "node " + s + } + } else if index := findFile(w.appDir(), "index.js"); index != "" { + rel, _ := filepath.Rel(w.appDir(), index) + a.webCommand = "node " + filepath.ToSlash(rel) + } + case flask: + for _, name := range []string{"app.py", "server.py", "api.py"} { + if _, err := os.Stat(filepath.Join(w.appDir(), name)); err != nil { + continue + } + app := fmt.Sprintf("'%s:app'", strings.TrimSuffix(name, ".py")) + bind := "unix:$SOCKET" + if a.socketFamily == tcp { + bind = "0.0.0.0:$PORT" + } + a.webCommand = fmt.Sprintf("%sgunicorn -b %s %s --log-file -", a.pythonRunPrefix(), bind, app) + break + } + } + return nil +} + +// setLocations sets the web locations. +func (w *wizard) setLocations() error { + a := w.answers + a.locations = map[string]map[string]any{} + switch { + case a.stack == django: + a.locations["/static"] = map[string]any{ + "root": "static", + "expires": "1h", + "allow": true, + } + case a.runtime == php: + location := map[string]any{ //nolint:gosec // Not a credential. + "passthru": "/index.php", + "root": "", + } + if index := findFile(w.appDir(), "index.php"); index != "" { + if rel, _ := filepath.Rel(w.appDir(), filepath.Dir(index)); rel != "." { + location["root"] = filepath.ToSlash(rel) + } + } + a.locations["/"] = location + } + return nil +} + +// setMounts sets the disk and mounts for the stack. +func (w *wizard) setMounts() error { + a := w.answers + local := func(sourcePath string) map[string]string { + return map[string]string{"source": "local", "source_path": sourcePath} + } + switch a.stack { + case rails: + a.disk = "2048" + tmp := map[string]string{"source": "tmp", "source_path": "tmp"} + a.mounts = map[string]map[string]string{ + "storage": local("storage"), + "tmp": tmp, + "log": tmp, + } + case laravel: + a.disk = "2048" + a.mounts = map[string]map[string]string{ + "storage": local("storage"), + "bootstrap/cache": local("cache"), + "/.config": local("config"), + } + case nextJS: + a.disk = "512" + a.mounts = map[string]map[string]string{ + "/.npm": local("npm"), + } + case strapi: + a.disk = "1024" + a.mounts = map[string]map[string]string{ + "/.cache": local("cache"), + "/.tmp": local("app"), + "database": local("database"), + "extensions": local("extensions"), + "public/uploads": local("uploads"), + } + } + return nil +} diff --git a/internal/platformify/detect_test.go b/internal/platformify/detect_test.go new file mode 100644 index 00000000..eaf46d74 --- /dev/null +++ b/internal/platformify/detect_test.go @@ -0,0 +1,173 @@ +package platformify + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeTree creates files in a temporary directory and returns its path. +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + p := filepath.Join(dir, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte(content), 0o600)) + } + return dir +} + +func TestFindStack(t *testing.T) { + cases := []struct { + name string + files map[string]string + want stack + }{ + {"empty", nil, genericStack}, + {"django", map[string]string{"manage.py": "", "mysite/settings.py": ""}, django}, + {"rails", map[string]string{"config.ru": "Rails.application.load_server"}, rails}, + {"flask requirements", map[string]string{"requirements.txt": "Flask==3.0"}, flask}, + {"flask poetry", map[string]string{"pyproject.toml": "[tool.poetry.dependencies]\nFlask = \"^3\"\n"}, flask}, + {"laravel", map[string]string{"composer.json": `{"require": {"laravel/framework": "^11"}}`}, laravel}, + {"nextjs", map[string]string{"package.json": `{"dependencies": {"next": "^15"}}`}, nextJS}, + {"strapi", map[string]string{"package.json": `{"dependencies": {"@strapi/strapi": "^5"}}`}, strapi}, + {"express", map[string]string{"package.json": `{"dependencies": {"express": "^5"}}`}, express}, + {"node_modules ignored", map[string]string{ + "node_modules/x/package.json": `{"dependencies": {"next": "1"}}`, + }, genericStack}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w := &wizard{dir: writeTree(t, c.files)} + assert.Equal(t, c.want, w.findStack()) + }) + } +} + +func TestIsSymfony(t *testing.T) { + cases := []struct { + name string + composer string + want bool + }{ + {"symfony", `{"require": {"symfony/framework-bundle": "^7"}}`, true}, + {"not symfony", `{"require": {"monolog/monolog": "^3"}}`, false}, + {"ibexa", `{"require": {"symfony/framework-bundle": "^7", "ibexa/core": "^4"}}`, false}, + {"shopware keyword", `{"keywords": ["Shopware"], "require": {"symfony/framework-bundle": "^7"}}`, false}, + {"shopware autoload", `{"autoload": {"psr-4": {"Shopware\\Core\\": "src/"}}, + "require": {"symfony/framework-bundle": "^7"}}`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w := &wizard{dir: writeTree(t, map[string]string{"composer.json": c.composer})} + assert.Equal(t, c.want, w.isSymfony()) + }) + } +} + +func TestSetBuildSteps(t *testing.T) { + cases := []struct { + name string + answers answers + want []string + }{ + { + name: "Next.js with Yarn", + answers: answers{stack: nextJS, runtime: nodejs, dependencyManagers: []string{yarn}}, + want: []string{"yarn", "yarn exec next build"}, + }, + { + name: "Next.js with npm", + answers: answers{stack: nextJS, runtime: nodejs, dependencyManagers: []string{npm}}, + want: []string{"npm i", "npm exec next build"}, + }, + { + name: "Bundler", + answers: answers{runtime: ruby, dependencyManagers: []string{bundler}}, + want: []string{"bundle install"}, + }, + { + name: "Rails", + answers: answers{stack: rails, runtime: ruby, dependencyManagers: []string{bundler}}, + want: []string{"bundle install", "bundle exec rails assets:precompile"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + c.answers.environment = map[string]string{} + c.answers.dependencies = map[string]map[string]string{} + w := &wizard{dir: t.TempDir(), assets: &assets{}, answers: &c.answers} + require.NoError(t, w.setBuildSteps()) + assert.Equal(t, c.want, c.answers.buildSteps) + }) + } +} + +func TestSetWebCommandExpressMain(t *testing.T) { + dir := writeTree(t, map[string]string{"package.json": `{"main": "src/Server.js"}`}) + w := &wizard{dir: dir, assets: &assets{}, answers: &answers{stack: express, runtime: nodejs, socketFamily: tcp}} + require.NoError(t, w.setWebCommand()) + assert.Equal(t, "node src/Server.js", w.answers.webCommand) +} + +func TestSubdirectoryApp(t *testing.T) { + cases := []struct { + name string + files map[string]string + check func(t *testing.T, a *answers) + }{ + { + name: "laravel", + files: map[string]string{ + "app/composer.json": `{"require": {"laravel/framework": "^11"}}`, + "app/composer.lock": "{}", + "app/public/index.php": "", + }, + check: func(t *testing.T, a *answers) { + assert.Equal(t, "app", a.applicationRoot) + assert.Equal(t, "public", a.locations["/"]["root"]) + }, + }, + { + name: "express", + files: map[string]string{ + "frontend/package.json": `{"dependencies": {"express": "^5"}, "scripts": {"build": "tsc"}}`, + "frontend/package-lock.json": "{}", + "frontend/src/index.js": "", + }, + check: func(t *testing.T, a *answers) { + assert.Equal(t, "frontend", a.applicationRoot) + assert.Equal(t, []string{"npm i", "npm run build"}, a.buildSteps) + assert.Equal(t, "node src/index.js", a.webCommand) + }, + }, + { + name: "flask", + files: map[string]string{ + "api/requirements.txt": "flask\n", + "api/app.py": "", + }, + check: func(t *testing.T, a *answers) { + assert.Equal(t, "api", a.applicationRoot) + assert.Equal(t, "gunicorn -b unix:$SOCKET 'app:app' --log-file -", a.webCommand) + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + w := &wizard{dir: writeTree(t, c.files), stdout: io.Discard, stderr: io.Discard, assets: &assets{}, + answers: &answers{}} + require.NoError(t, w.detectStack()) + w.answers.runtime = w.answers.stack.runtime() + for _, step := range w.detectionSteps() { + require.NoError(t, step()) + } + c.check(t, w.answers) + }) + } +} diff --git a/internal/platformify/files.go b/internal/platformify/files.go new file mode 100644 index 00000000..601d9387 --- /dev/null +++ b/internal/platformify/files.go @@ -0,0 +1,105 @@ +package platformify + +import ( + "bytes" + "cmp" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +// skipDirs are not searched for project files. +var skipDirs = []string{"vendor", "node_modules", ".next", ".git"} + +// findAllFiles returns the paths of files with the given name under dir. +func findAllFiles(dir, name string) []string { + var found []string + _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if slices.Contains(skipDirs, d.Name()) { + return filepath.SkipDir + } + return nil + } + if d.Name() == name { + found = append(found, p) + } + return nil + }) + return found +} + +// findFile returns the path of the file with the given name under dir that is closest to dir, or "". +func findFile(dir, name string) string { + files := findAllFiles(dir, name) + if len(files) == 0 { + return "" + } + return slices.MinFunc(files, func(a, b string) int { + return cmp.Compare(strings.Count(a, string(os.PathSeparator)), strings.Count(b, string(os.PathSeparator))) + }) +} + +// fileExists reports whether a file with the given name exists under dir. +func fileExists(dir, name string) bool { + return findFile(dir, name) != "" +} + +// fileContains reports whether the file contains s, ignoring case. +func fileContains(path, s string) bool { + b, err := os.ReadFile(path) + return err == nil && bytes.Contains(bytes.ToLower(b), []byte(strings.ToLower(s))) +} + +// jsonValue returns the value at keyPath in a JSON file, matching keys case-insensitively. +func jsonValue(path string, keyPath ...string) (any, bool) { + return decodedValue(path, json.Unmarshal, keyPath) +} + +// tomlValue returns the value at keyPath in a TOML file, matching keys case-insensitively. +func tomlValue(path string, keyPath ...string) (any, bool) { + return decodedValue(path, toml.Unmarshal, keyPath) +} + +func decodedValue(path string, unmarshal func([]byte, any) error, keyPath []string) (any, bool) { + b, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var value any + if err := unmarshal(b, &value); err != nil { + return nil, false + } + for _, key := range keyPath { + m, ok := value.(map[string]any) + if !ok { + return nil, false + } + value, ok = lookupFold(m, key) + if !ok { + return nil, false + } + } + return value, true +} + +// lookupFold returns the value of a key in m, matched case-insensitively. +func lookupFold(m map[string]any, key string) (any, bool) { + if v, ok := m[key]; ok { + return v, true + } + for k, v := range m { + if strings.EqualFold(k, key) { + return v, true + } + } + return nil, false +} diff --git a/internal/platformify/fixtures_test.go b/internal/platformify/fixtures_test.go new file mode 100644 index 00000000..b2af0983 --- /dev/null +++ b/internal/platformify/fixtures_test.go @@ -0,0 +1,106 @@ +package platformify + +// testInputs returns template inputs for each stack, without assets. +func testInputs() map[string]*templateInput { + return map[string]*templateInput{ + "django": { + Name: "django", + Type: "python:3.14", + Stack: django, + Runtime: "python", + ApplicationRoot: "app", + Environment: map[string]string{ + "DJANGO_SETTINGS_MODULE": "app.settings", + "PYTHONUNBUFFERED": "1", + }, + 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", + }, + Locations: map[string]map[string]any{ + "/": {"passthru": true}, + "/static": {"root": "static", "expires": "1h", "allow": true}, + }, + Dependencies: map[string]map[string]string{ + "python": {"poetry": "*", "pip": ">=20.0.0"}, + "nodejs": {"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"}, + }, + Services: []service{ + { + Name: "db", + Type: "postgresql:17", + TypeVersions: []string{"17", "16", "15", "14", "13", "12"}, + Disk: "1024", + DiskSizes: []string{"1024", "2048"}, + }, + { + Name: "mysql", + Type: "mysql:11.8", + TypeVersions: []string{"11.0", "10.11", "10.6", "10.5", "10.4", "10.3"}, + Disk: "1024", + DiskSizes: []string{"1024", "2034"}, + }, + }, + }, + "generic": { + Name: "generic", + Type: "java:21", + Runtime: "java", + Stack: genericStack, + Environment: map[string]string{ + "JAVA": "19", + }, + BuildSteps: []string{"mvn install"}, + WebCommand: "tomcat", + SocketFamily: "tcp", + Locations: map[string]map[string]any{ + "/": {"passthru": true}, + }, + Dependencies: map[string]map[string]string{}, + Disk: "1024", + Mounts: map[string]map[string]string{ + "/.mvn": {"source": "local", "source_path": "maven"}, + }, + Services: []service{ + { + Name: "mysql", + Type: "mysql:11.8", + TypeVersions: []string{"13", "14", "15"}, + Disk: "1024", + DiskSizes: []string{"1024", "2048"}, + }, + }, + }, + "laravel": { + Name: "laravel", + Type: "php:8.4", + Stack: laravel, + Runtime: "php", + ApplicationRoot: "app", + Environment: map[string]string{}, + BuildFlavor: "php", + Locations: map[string]map[string]any{ + "/": {"root": "index.php"}, + }, + Dependencies: map[string]map[string]string{}, + Mounts: map[string]map[string]string{}, + }, + "nextjs": {Name: "nextjs", Type: "nodejs:24", Runtime: "nodejs", Stack: nextJS}, + "strapi": {Name: "strapi", Type: "nodejs:24", Runtime: "nodejs", Stack: strapi}, + "flask": {Name: "flask", Type: "python:3.14", Runtime: "python", Stack: flask}, + "express": {Name: "express", Type: "nodejs:24", Runtime: "nodejs", Stack: express}, + } +} diff --git a/internal/platformify/generate.go b/internal/platformify/generate.go new file mode 100644 index 00000000..dd806834 --- /dev/null +++ b/internal/platformify/generate.go @@ -0,0 +1,211 @@ +package platformify + +import ( + "bytes" + "embed" + "io/fs" + "maps" + "os" + "path" + "path/filepath" + "slices" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + + "github.com/upsun/cli/internal/config" +) + +//go:embed all:templates +var templatesFS embed.FS + +// importSettingsLine imports the generated settings_psh.py into Django's settings.py. +const importSettingsLine = "from .settings_psh import *" + +func newAssets(cnf *config.Config) *assets { + return &assets{ + Binary: cnf.Application.Executable, + EnvPrefix: strings.TrimSuffix(cnf.Service.EnvPrefix, "_"), + ServiceName: cnf.Service.Name, + docsBaseURL: cnf.Service.DocsURL, + flex: cnf.Service.ProjectConfigFlavor == "upsun", + } +} + +// templateInput is the data passed to templates. +type templateInput struct { + Assets *assets + Stack stack + Name string + Type string // e.g. "python:3.14" + Runtime string // e.g. "python" + ApplicationRoot string + Environment map[string]string + BuildSteps []string + WebCommand string + SocketFamily string + DeployCommand []string + Locations map[string]map[string]any + Dependencies map[string]map[string]string + BuildFlavor string + Disk string + Mounts map[string]map[string]string + Services []service +} + +func (w *wizard) templateInput() *templateInput { + a := w.answers + typ := a.runtime + if a.version != "" { + typ += ":" + a.version + } + locations := map[string]map[string]any{"/": {"passthru": true}} + maps.Copy(locations, a.locations) + return &templateInput{ + Assets: w.assets, + Stack: a.stack, + Name: a.name, + Type: typ, + Runtime: a.runtime, + ApplicationRoot: path.Join("/", filepath.ToSlash(a.applicationRoot)), + Environment: a.environment, + BuildSteps: a.buildSteps, + WebCommand: a.webCommand, + SocketFamily: a.socketFamily, + DeployCommand: a.deployCommand, + Locations: locations, + Dependencies: a.dependencies, + BuildFlavor: a.buildFlavor, + Disk: a.disk, + Mounts: a.mounts, + Services: a.services, + } +} + +// ServiceNames returns the sorted names of the services. +func (t *templateInput) ServiceNames() []string { + names := make([]string, len(t.Services)) + for i, s := range t.Services { + names[i] = s.Name + } + slices.Sort(names) + return names +} + +// Database returns the name of the first database service. +func (t *templateInput) Database() string { + return t.firstService("mariadb", "mysql", "oracle-mysql", "postgresql") +} + +// DatabaseUpper returns Database as an environment variable name. +func (t *templateInput) DatabaseUpper() string { + return envName(t.Database()) +} + +// Cache returns the name of the first cache service. +func (t *templateInput) Cache() string { + return t.firstService("redis", "redis-persistent", "memcached") +} + +// CacheUpper returns Cache as an environment variable name. +func (t *templateInput) CacheUpper() string { + return envName(t.Cache()) +} + +func (t *templateInput) firstService(types ...string) string { + for _, s := range t.Services { + for _, typ := range types { + if strings.Contains(s.Type, typ) { + return s.Name + } + } + } + return "" +} + +func envName(s string) string { + return strings.ToUpper(strings.ReplaceAll(s, "-", "_")) +} + +// generate renders the configuration files for the project in dir. +// It returns their contents by slash-separated path relative to dir. +func generate(dir string, input *templateInput) (map[string][]byte, error) { + templatesDir := "templates/fixed" + if input.Assets.flex { + templatesDir = "templates/flex" + } + files := map[string][]byte{} + err := fs.WalkDir(templatesFS, templatesDir, func(name string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + content, err := render(name, input) + if err != nil { + return err + } + if len(bytes.TrimSpace(content)) > 0 { + files[outputPath(strings.TrimPrefix(name, templatesDir+"/"), input.ApplicationRoot)] = content + } + return nil + }) + if err != nil { + return nil, err + } + if input.Stack == django { + if err := generateDjango(dir, input, files); err != nil { + return nil, err + } + } + return files, nil +} + +// appFiles are written in the application root, and the other files in the project root. +var appFiles = []string{".environment", ".platform.app.yaml"} + +// outputPath returns the path for a template's output, relative to the project root. +func outputPath(name, appRoot string) string { + if slices.Contains(appFiles, name) { + return strings.TrimPrefix(path.Join(appRoot, name), "/") + } + return name +} + +// generateDjango adds a settings_psh.py file next to settings.py, and imports it there. +func generateDjango(dir string, input *templateInput, files map[string][]byte) error { + settingsPath := findFile(filepath.Join(dir, filepath.FromSlash(input.ApplicationRoot)), settingsPyFile) + if settingsPath == "" { + return nil + } + rel, err := filepath.Rel(dir, filepath.Dir(settingsPath)) + if err != nil { + return err + } + content, err := render("templates/django/settings_psh.py", input) + if err != nil { + return err + } + files[path.Join(filepath.ToSlash(rel), "settings_psh.py")] = content + + settings, err := os.ReadFile(settingsPath) + if err != nil { + return err + } + if !bytes.Contains(settings, []byte(importSettingsLine)) { + settingsRel, _ := filepath.Rel(dir, settingsPath) + files[filepath.ToSlash(settingsRel)] = append(settings, []byte("\n\n"+importSettingsLine+"\n")...) + } + return nil +} + +func render(name string, input *templateInput) ([]byte, error) { + tpl, err := template.New(path.Base(name)).Funcs(sprig.FuncMap()).ParseFS(templatesFS, name) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := tpl.Execute(&buf, input); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/platformify/generate_test.go b/internal/platformify/generate_test.go new file mode 100644 index 00000000..64b05f72 --- /dev/null +++ b/internal/platformify/generate_test.go @@ -0,0 +1,152 @@ +package platformify + +import ( + "maps" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/lint" +) + +func TestGenerate(t *testing.T) { + for _, flex := range []bool{true, false} { + a := &assets{Binary: "upsun", EnvPrefix: "PLATFORM", ServiceName: "Upsun", docsBaseURL: "https://docs.upsun.com", + flex: flex} + for name, in := range testInputs() { + t.Run(name, func(t *testing.T) { + in.Assets = a + dir := t.TempDir() + files, err := generate(dir, in) + require.NoError(t, err) + + assert.NotEmpty(t, files) + require.NoError(t, writeFiles(dir, files)) + result, err := lint.CheckDir(dir) + require.NoError(t, err) + assert.False(t, result.HasErrors(), result.String()) + }) + } + } +} + +func TestGenerateDjango(t *testing.T) { + dir := t.TempDir() + settings := filepath.Join(dir, "app", "mysite", "settings.py") + require.NoError(t, os.MkdirAll(filepath.Dir(settings), 0o755)) + require.NoError(t, os.WriteFile(settings, []byte("DEBUG = False\n"), 0o600)) + + in := testInputs()["django"] + in.Assets = &assets{flex: true} + in.ApplicationRoot = "/app" + files, err := generate(dir, in) + require.NoError(t, err) + assert.Contains(t, files, "app/mysite/settings_psh.py") + assert.Equal(t, "DEBUG = False\n\n\n"+importSettingsLine+"\n", string(files["app/mysite/settings.py"])) + + // The import is not added twice. + require.NoError(t, writeFiles(dir, files)) + files, err = generate(dir, in) + require.NoError(t, err) + assert.Contains(t, files, "app/mysite/settings_psh.py") + assert.NotContains(t, files, "app/mysite/settings.py") +} + +func TestVersions(t *testing.T) { + for _, c := range slices.Concat(runtimeChoices, serviceChoices) { + assert.NotEmpty(t, versions(c.typ), c.typ) + } +} + +func TestGenerateSubdirectoryApp(t *testing.T) { + cases := []struct { + flex bool + want []string + }{ + {true, []string{".upsun/config.yaml", "app/.environment"}}, + {false, []string{".platform/routes.yaml", ".platform/services.yaml", "app/.environment", "app/.platform.app.yaml"}}, + } + for _, c := range cases { + in := testInputs()["generic"] + in.Assets = &assets{flex: c.flex} + in.ApplicationRoot = "/app" + files, err := generate(t.TempDir(), in) + require.NoError(t, err) + assert.ElementsMatch(t, c.want, slices.Collect(maps.Keys(files))) + } +} + +func TestGenerateRedisPersistent(t *testing.T) { + in := testInputs()["generic"] + in.Services = []service{{Name: "redis-persistent", Type: "redis-persistent:8.0"}} + for _, flex := range []bool{true, false} { + in.Assets = &assets{flex: flex} + files, err := generate(t.TempDir(), in) + require.NoError(t, err) + assert.Contains(t, string(files[".environment"]), `export REDIS_URL="$CACHE_URL"`) + if !flex { + assert.Contains(t, string(files[".environment"]), `jq -r '.["redis-persistent"][0].host'`) + } + } +} + +func TestGenerateStrapiDatabaseScheme(t *testing.T) { + in := testInputs()["strapi"] + in.Services = []service{{Name: "postgresql", Type: "postgresql:18"}} + for _, flex := range []bool{true, false} { + in.Assets = &assets{flex: flex} + files, err := generate(t.TempDir(), in) + require.NoError(t, err) + assert.Contains(t, string(files[".environment"]), `export DATABASE_SCHEME="$DB_SCHEME"`) + } +} + +func TestExistingConfig(t *testing.T) { + cases := []struct { + name string + flex bool + files map[string]string + wantOverwrite []string + wantConflicts []string + }{ + { + name: "fixed app moved to a subdirectory", + files: map[string]string{ + "app/composer.lock": "{}", + ".platform.app.yaml": "name: app\n", + ".platform/applications.yaml": "[]\n", + }, + wantConflicts: []string{".platform.app.yaml", ".platform/applications.yaml"}, + }, + { + name: "fixed app overwritten", + files: map[string]string{"app/composer.lock": "{}", "app/.platform.app.yaml": "name: app\n"}, + wantOverwrite: []string{"app/.platform.app.yaml"}, + }, + { + name: "flex", + flex: true, + files: map[string]string{".upsun/config.yaml": "applications: {}\n", ".upsun/other.yml": "services: {}\n"}, + wantOverwrite: []string{".upsun/config.yaml"}, + wantConflicts: []string{".upsun/other.yml"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := writeTree(t, c.files) + in := testInputs()["generic"] + in.Assets = &assets{flex: c.flex} + in.ApplicationRoot = "/app" + files, err := generate(dir, in) + require.NoError(t, err) + w := &wizard{dir: dir, assets: in.Assets} + overwrite, conflicts := w.existingConfig(files) + assert.Equal(t, c.wantOverwrite, overwrite) + assert.Equal(t, c.wantConflicts, conflicts) + }) + } +} diff --git a/internal/platformify/messages.go b/internal/platformify/messages.go new file mode 100644 index 00000000..3f49521f --- /dev/null +++ b/internal/platformify/messages.go @@ -0,0 +1,108 @@ +package platformify + +import ( + "fmt" +) + +// detected prints a detected property of the project. +func (w *wizard) detected(what, value string) { + fmt.Fprintf(w.stderr, "%s %s\n", colorize(greenCode, "✓"), colorize(brandCode, "Detected "+what+": "+value)) +} + +func (w *wizard) welcome() error { + fmt.Fprintln(w.stdout, colorize(brandCode, fmt.Sprintf("Welcome to %s!", w.assets.ServiceName))) + fmt.Fprintln(w.stdout, colorize(brandCode, "Let's get started with a few questions.")) + fmt.Fprintln(w.stdout) + fmt.Fprintln(w.stdout, "We need to know a bit more about your project. This will only take a minute!") + fmt.Fprintln(w.stdout) + return nil +} + +func (w *wizard) almostDone() error { + fmt.Fprintln(w.stdout) + fmt.Fprintln(w.stdout, colorize(accentCode, " (\\_/)")) + fmt.Fprintln(w.stdout, colorize(accentCode, "We’re almost done... =(^.^)=")) + fmt.Fprintln(w.stdout) + return nil +} + +func (w *wizard) done(files []string) { + out := w.stdout + fmt.Fprintln(out) + fmt.Fprintln(out, "┌───────────────────────────────────────────────────┐") + fmt.Fprintln(out, "│ CONGRATULATIONS! │") + fmt.Fprintln(out, "│ │") + fmt.Fprintln(out, "│ We have created the following files for your: │") + for _, f := range files { + fmt.Fprintf(out, "│ - %-44s│\n", f) + } + fmt.Fprintln(out, "│ │") + fmt.Fprintln(out, "│ We’re jumping for joy! ⍢ │") + fmt.Fprintln(out, "└───────────────────────────────────────────────────┘") + fmt.Fprintln(out, " │ /") + fmt.Fprintln(out, " │/") + fmt.Fprintln(out, " │") + fmt.Fprintln(out, " (\\ /)") + fmt.Fprintln(out, " ( . .)") + fmt.Fprintln(out, " o (_(“)(“)") + fmt.Fprintln(out) + fmt.Fprintln(out, colorize(accentCode, "You can now deploy your application to "+w.assets.ServiceName+"!")) + fmt.Fprintln(out, colorize(accentCode, fmt.Sprintf( + "To do so, commit your files and deploy your application using the %s CLI:", w.assets.ServiceName))) + fmt.Fprintln(out, " $ git add .") + fmt.Fprintf(out, " $ git commit -m 'Add %s configuration files'\n", w.assets.ServiceName) + fmt.Fprintf(out, " $ %s project:set-remote\n", w.assets.Binary) + fmt.Fprintf(out, " $ %s push\n", w.assets.Binary) + fmt.Fprintln(out) +} + +// suggestLaravelBridge suggests installing the Laravel Bridge, if it is not required. +func (w *wizard) suggestLaravelBridge() { + composerFiles := findAllFiles(w.appDir(), composerJSON) + for _, p := range composerFiles { + if _, ok := jsonValue(p, "require", "platformsh/laravel-bridge"); ok { + return + } + } + if len(composerFiles) == 0 { + return + } + fmt.Fprintln(w.stdout, colorize(warningCode, + "\nPlease use composer to add the Laravel Bridge to your project:\n\n"+ + " composer require platformsh/laravel-bridge\n")) +} + +// assets are the vendor-specific values used in messages and templates. +type assets struct { + Binary string + EnvPrefix string + ServiceName string + docsBaseURL string + flex bool +} + +// docs are documentation links used in templates. +type docs struct { + AppReference string + Hooks string + PHP string + Routes string + Services string + SymfonyCLI string + TimeZone string + Variables string +} + +// Docs returns documentation links. +func (a *assets) Docs() docs { + return docs{ + AppReference: a.docsBaseURL + "/app/reference/", + Hooks: a.docsBaseURL + "/app/hooks/compare/", + PHP: a.docsBaseURL + "/languages/php/", + Routes: a.docsBaseURL + "/routes/", + Services: a.docsBaseURL + "/services/", + SymfonyCLI: a.docsBaseURL + "/stacks/symfony/cli/tips/", + TimeZone: a.docsBaseURL + "/app/timezone/", + Variables: a.docsBaseURL + "/variables/use/provided/", + } +} diff --git a/internal/platformify/models.go b/internal/platformify/models.go new file mode 100644 index 00000000..290ed33d --- /dev/null +++ b/internal/platformify/models.go @@ -0,0 +1,193 @@ +package platformify + +import ( + "github.com/upsun/cli/pkg/registry" +) + +// stack is a framework that the wizard detects and configures. +type stack int + +const ( + genericStack stack = iota + django + laravel + nextJS + strapi + flask + express + rails +) + +// Name returns the stack's identifier, used in templates. +func (s stack) Name() string { + return [...]string{"generic", "django", "laravel", "nextjs", "strapi", "flask", "express", "rails"}[s] +} + +// Title returns the stack's display name. +func (s stack) Title() string { + return [...]string{"Other", "Django", "Laravel", "Next.js", "Strapi", "Flask", "Express", "Rails"}[s] +} + +// runtime returns the runtime type for the stack, or "" if it can be any. +func (s stack) runtime() string { + switch s { + case django, flask: + return python + case rails: + return ruby + case laravel: + return php + case nextJS, strapi, express: + return nodejs + default: + return "" + } +} + +// Runtime types. +const ( + nodejs = "nodejs" + php = "php" + python = "python" + ruby = "ruby" +) + +// choice is an image type offered by the wizard, with its display title. +type choice struct { + typ, title string +} + +var runtimeChoices = []choice{ + {"dotnet", "C#/.Net Core"}, + {"elixir", "Elixir"}, + {"golang", "Go"}, + {"java", "Java"}, + {nodejs, "JavaScript/Node.js"}, + {php, "PHP"}, + {python, "Python"}, + {ruby, "Ruby"}, + {"rust", "Rust"}, +} + +var serviceChoices = []choice{ + {"mariadb", "MariaDB"}, + {"mysql", "MySQL"}, + {"postgresql", "PostgreSQL"}, + {"redis", "Redis"}, + {"redis-persistent", "Redis Persistent"}, + {"memcached", "Memcached"}, + {"opensearch", "OpenSearch"}, + {"solr", "Solr"}, + {"varnish", "Varnish"}, + {"kafka", "Kafka"}, + {"vault-kms", "Vault KMS"}, + {"rabbitmq", "RabbitMQ"}, + {"influxdb", "InfluxDB"}, + {"chrome-headless", "Chrome Headless"}, + {"network-storage", "Network Storage"}, + {"oracle-mysql", "Oracle MySQL"}, +} + +// ephemeralServices are service types that have no disk. +var ephemeralServices = []string{"chrome-headless", "memcached", "redis"} + +// diskSizes are the disk sizes offered for services, in MB. +var diskSizes = []string{"1024", "2048", "3072", "4096", "5120"} + +// titles returns the titles of the choices. +func titles(choices []choice) []string { + t := make([]string, len(choices)) + for i, c := range choices { + t[i] = c.title + } + return t +} + +// typeByTitle returns the type of the choice with the given title. +func typeByTitle(choices []choice, title string) string { + for _, c := range choices { + if c.title == title { + return c.typ + } + } + return "" +} + +// versions returns the supported versions of an image type, newest first. +func versions(typ string) []string { + reg, err := registry.Load() + if err != nil { + return nil + } + return reg[typ].Versions.Supported +} + +// Dependency managers. +const ( + pip = "pip" + poetry = "poetry" + pipenv = "pipenv" + composer = "composer" + yarn = "yarn" + npm = "npm" + bundler = "bundler" +) + +var depManagerTitles = map[string]string{ + pip: "Pip", + poetry: "Poetry", + pipenv: "Pipenv", + composer: "Composer", + yarn: "Yarn", + npm: "Npm", + bundler: "Bundler", +} + +// Socket families. +const ( + tcp = "tcp" + unix = "unix" +) + +// answers holds what the wizard detects or asks. +type answers struct { + stack stack + runtime string // e.g. "python" + version string // e.g. "3.14" + name string + applicationRoot string + environment map[string]string + buildSteps []string + webCommand string + socketFamily string + deployCommand []string + dependencyManagers []string + dependencies map[string]map[string]string + buildFlavor string + disk string + mounts map[string]map[string]string + services []service + locations map[string]map[string]any +} + +// service is a service needed by the application. +type service struct { + Name string + Type string // e.g. "postgresql:18" + TypeVersions []string + Disk string + DiskSizes []string +} + +// pythonRunPrefix returns the command prefix for the Python dependency manager, if it uses a virtualenv. +func (a *answers) pythonRunPrefix() string { + for _, dm := range a.dependencyManagers { + switch dm { + case pipenv: + return "pipenv run " + case poetry: + return "poetry run " + } + } + return "" +} diff --git a/internal/platformify/platformify.go b/internal/platformify/platformify.go new file mode 100644 index 00000000..21fdfe34 --- /dev/null +++ b/internal/platformify/platformify.go @@ -0,0 +1,300 @@ +// Package platformify generates a starter configuration for a project, by +// detecting its stack and asking a few questions (the guided mode of "init"). +package platformify + +import ( + "bytes" + "errors" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/AlecAivazis/survey/v2" + + "github.com/upsun/cli/internal/config" +) + +// Options configures Run. +type Options struct { + // Dir is the project's root directory, where files are written. + Dir string + Stdout io.Writer + Stderr io.Writer +} + +// errAborted stops the wizard without an error. +var errAborted = errors.New("aborted") + +type wizard struct { + dir string + stdout io.Writer + stderr io.Writer + assets *assets + answers *answers +} + +// Run asks questions about the project in opts.Dir, and writes its configuration files. +func Run(cnf *config.Config, opts Options) error { + w := &wizard{ + dir: opts.Dir, + stdout: opts.Stdout, + stderr: opts.Stderr, + assets: newAssets(cnf), + answers: &answers{}, + } + steps := slices.Concat( + []func() error{w.welcome, w.detectStack, w.askRuntime}, + w.detectionSteps(), + []func() error{w.confirmOverwrite, w.askName, w.almostDone, w.askServices}, + ) + for _, step := range steps { + if err := step(); err != nil { + if errors.Is(err, errAborted) { + return nil + } + return err + } + } + + files, err := generate(w.dir, w.templateInput()) + if err != nil { + return fmt.Errorf("could not configure project: %w", err) + } + if err := writeFiles(w.dir, files); err != nil { + return fmt.Errorf("could not configure project: %w", err) + } + if w.answers.stack == laravel { + w.suggestLaravelBridge() + } + w.done(slices.Sorted(maps.Keys(files))) + return nil +} + +// detectionSteps returns the steps that configure the app from its files, after the stack and runtime are known. +func (w *wizard) detectionSteps() []func() error { + return []func() error{ + w.detectDependencyManagers, + w.detectApplicationRoot, + w.setLocations, + w.setMounts, + w.setEnvironment, + w.setBuildSteps, + w.setDeployCommand, + w.setSocketFamily, + w.setWebCommand, + } +} + +// writeFiles writes files, keyed by slash-separated paths relative to dir. +func writeFiles(dir string, files map[string][]byte) error { + for name, content := range files { + p := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + if err := os.WriteFile(p, content, 0o644); err != nil { //nolint:gosec // Configuration files are not secret. + return err + } + } + return nil +} + +// confirmOverwrite asks whether to continue, if configuration files exist. +func (w *wizard) confirmOverwrite() error { + files, err := generate(w.dir, w.templateInput()) + if err != nil { + return err + } + overwrite, conflicts := w.existingConfig(files) + if len(overwrite) == 0 && len(conflicts) == 0 { + return nil + } + fmt.Fprintln(w.stderr, colorize(warningCode, "You are reconfiguring the project at "+w.dir+".")) + if len(overwrite) > 0 { + fmt.Fprintln(w.stderr, colorize(warningCode, "These files exist, and will be overwritten:")) + for _, f := range overwrite { + fmt.Fprintln(w.stderr, colorize(warningCode, " - "+f)) + } + } + if len(conflicts) > 0 { + fmt.Fprintln(w.stderr, colorize(warningCode, + fmt.Sprintf("These %s configuration files exist, and will be kept (remove them if they conflict):", + w.assets.ServiceName))) + for _, f := range conflicts { + fmt.Fprintln(w.stderr, colorize(warningCode, " - "+f)) + } + } + proceed := false + if err := survey.AskOne(&survey.Confirm{Message: "Do you want to continue?"}, &proceed); err != nil { + return err + } + if !proceed { + return errAborted + } + return nil +} + +// existingConfig returns the generated files that exist and would be overwritten, +// and other existing configuration files, which would be kept. +func (w *wizard) existingConfig(files map[string][]byte) (overwrite, conflicts []string) { + for _, f := range slices.Sorted(maps.Keys(files)) { + current, err := os.ReadFile(filepath.Join(w.dir, filepath.FromSlash(f))) + // Files that are only appended to (Django's settings.py) are not overwritten. + if err == nil && !bytes.HasPrefix(files[f], current) { + overwrite = append(overwrite, f) + } + } + + var candidates []string + if w.assets.flex { + for _, pattern := range []string{"*.yaml", "*.yml"} { + matches, _ := filepath.Glob(filepath.Join(w.dir, ".upsun", pattern)) + candidates = append(candidates, matches...) + } + } else { + candidates = findAllFiles(w.dir, ".platform.app.yaml") + candidates = append(candidates, filepath.Join(w.dir, ".platform", "applications.yaml")) + } + for _, p := range candidates { + rel, err := filepath.Rel(w.dir, p) + if err != nil { + continue + } + rel = filepath.ToSlash(rel) + if _, generated := files[rel]; generated { + continue + } + if st, err := os.Stat(p); err == nil && !st.IsDir() { + conflicts = append(conflicts, rel) + } + } + slices.Sort(conflicts) + return overwrite, conflicts +} + +// suggestSymfonyCLI offers to stop, to use the Symfony CLI instead. +func (w *wizard) suggestSymfonyCLI() error { + useSymfonyCLI := 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?", + Default: useSymfonyCLI, + }, &useSymfonyCLI); err != nil { + return err + } + if useSymfonyCLI { + fmt.Fprintln(w.stderr, colorize(warningCode, + "Check out the Symfony CLI documentation here: "+w.assets.Docs().SymfonyCLI)) + return errAborted + } + return nil +} + +// askRuntime sets the runtime from the stack, or asks for it. +func (w *wizard) askRuntime() error { + a := w.answers + a.runtime = a.stack.runtime() + if a.runtime == "" { + var title string + options := titles(runtimeChoices) + if err := survey.AskOne(&survey.Select{ + Message: "What language is your project using? We support the following:", + Options: options, + }, &title, survey.WithPageSize(len(options))); err != nil { + return err + } + a.runtime = typeByTitle(runtimeChoices, title) + } else { + w.detected("runtime", runtimeTitle(a.runtime)) + } + if v := versions(a.runtime); len(v) > 0 { + a.version = v[0] + } + return nil +} + +func runtimeTitle(typ string) string { + for _, c := range runtimeChoices { + if c.typ == typ { + return c.title + } + } + return typ +} + +var ( + invalidNameChars = regexp.MustCompile("[^a-z0-9_-]") + consecutiveDashes = regexp.MustCompile("-+") +) + +func slugify(s string) string { + s = invalidNameChars.ReplaceAllLiteralString(strings.ToLower(strings.TrimSpace(s)), "-") + return consecutiveDashes.ReplaceAllLiteralString(s, "-") +} + +// askName asks for the application name. +func (w *wizard) askName() error { + validSlug := func(val any) error { + if s, _ := val.(string); s != slugify(s) { + return fmt.Errorf("%s: the name can only contain lowercase alphanumeric characters, dashes, or underscores", s) + } + return nil + } + return survey.AskOne(&survey.Input{ + Message: "Tell us your project's application name:", + Default: slugify(filepath.Base(w.dir)), + }, &w.answers.name, survey.WithValidator(survey.Required), survey.WithValidator(validSlug)) +} + +// askServices asks which services the project uses. +func (w *wizard) askServices() error { + fmt.Fprintln(w.stdout, colorize(accentCode, + "Last but not least, unless you’re creating a static website, your project uses services. Let’s define them:")) + fmt.Fprintln(w.stdout) + + var selected []string + for { + if err := survey.AskOne(&survey.MultiSelect{ + Message: "Select all the services you are using:", + Options: titles(serviceChoices), + }, &selected, survey.WithKeepFilter(true)); err != nil { + return err + } + if len(selected) > 0 { + break + } + proceed := false + if err := survey.AskOne(&survey.Confirm{ + Message: "You have not selected any service, would you like to proceed anyway?", + }, &proceed); err != nil { + return err + } + if proceed { + break + } + } + + for _, title := range selected { + typ := typeByTitle(serviceChoices, title) + v := versions(typ) + if len(v) == 0 { + continue + } + s := service{ + Name: typ, + Type: typ + ":" + v[0], + TypeVersions: v, + } + if !slices.Contains(ephemeralServices, typ) { + s.Disk = diskSizes[0] + s.DiskSizes = diskSizes + } + w.answers.services = append(w.answers.services, s) + } + return nil +} diff --git a/internal/platformify/survey.go b/internal/platformify/survey.go new file mode 100644 index 00000000..2f8b1ad4 --- /dev/null +++ b/internal/platformify/survey.go @@ -0,0 +1,58 @@ +package platformify + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" +) + +func init() { + survey.InputQuestionTemplate = fmt.Sprintf(` +{{- color "%[1]s"}}{{ .Message }} {{color "reset"}} +{{- if .ShowAnswer}} + {{- color "%[2]s"}}[{{.Answer}}]{{color "reset"}}{{"\n"}} +{{ else }} + {{- if .Default }}{{color "%[3]s"}}[{{.Default}}] {{color "reset"}}{{ end }} +{{- end }}`, colorAccent, colorBrand, colorSecondary, colorError, colorDefault) + + //nolint:lll + survey.SelectQuestionTemplate = fmt.Sprintf(` +{{- define "option"}} + {{- if eq .SelectedIndex .CurrentIndex }}{{color "%[2]s" }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "%[5]s"}} {{end}} + {{- .CurrentOpt.Value}} + {{- color "reset"}} +{{end}} +{{- color "%[1]s"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} +{{- if .ShowAnswer}}{{color "%[2]s"}} [{{.Answer}}]{{color "reset"}}{{"\n"}}{{"\n"}} +{{- else}} +{{ color "%[5]s"}}Use arrows to move up and down, type to filter{{color "reset"}}{{"\n"}} + {{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} + {{- end}} +{{- end }}`, colorAccent, colorBrand, colorSecondary, colorError, colorDefault) + + survey.ConfirmQuestionTemplate = fmt.Sprintf(` +{{- color "%[1]s"}}{{ .Message }} {{color "reset"}} +{{- if .Answer}} + {{- color "%[2]s"}}[{{.Answer}}]{{color "reset"}}{{"\n"}} +{{- else }} + {{- color "%[3]s"}}{{if .Default}}(Y/n){{else}}(y/N){{end}}{{color "reset"}}{{"\n"}} +{{- end }}`, colorAccent, colorBrand, colorSecondary, colorError, colorDefault) + + //nolint:lll + survey.MultiSelectQuestionTemplate = fmt.Sprintf(` +{{- define "option"}} + {{- if eq .SelectedIndex .CurrentIndex }}{{color "%[5]s" }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}} + {{- if index .Checked .CurrentOpt.Index }}{{color "%[2]s" }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color "%[5]s" }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}} + {{- color "reset"}} + {{- " "}}{{- if index .Checked .CurrentOpt.Index }}{{color "%[2]s" }}{{else}}{{color "%[5]s" }}{{end}}{{- .CurrentOpt.Value}}{{color "reset"}} +{{end}} +{{- color "%[1]s"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} +{{- if .ShowAnswer}}{{color "%[2]s"}} [{{.Answer}}]{{color "reset"}}{{"\n"}}{{"\n"}} +{{- else }} +{{ color "%[5]s"}}Use arrows to move, space to select, type to filter{{color "reset"}}{{"\n"}} + {{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} + {{- end}} +{{- end }}`, colorAccent, colorBrand, colorSecondary, colorError, colorDefault) +} diff --git a/internal/platformify/templates/django/settings_psh.py b/internal/platformify/templates/django/settings_psh.py new file mode 100644 index 00000000..f865eff2 --- /dev/null +++ b/internal/platformify/templates/django/settings_psh.py @@ -0,0 +1,73 @@ +################################################################################# +# {{ .Assets.ServiceName }}-specific configuration + +import base64 +import json +import os +import sys +from urllib.parse import urlparse + +# This variable should always match the primary database relationship name +{{ .Assets.EnvPrefix }}_DB_RELATIONSHIP = "{{ .Database }}" + +# Helper function for decoding base64-encoded JSON variables. +def decode(variable): + """Decodes a {{ .Assets.ServiceName }} environment variable. + Args: + variable (string): + Base64-encoded JSON (the content of an environment variable). + Returns: + An dict (if representing a JSON object), or a scalar type. + Raises: + JSON decoding error. + """ + try: + return json.loads(base64.b64decode(variable)) + except json.decoder.JSONDecodeError: + print("Error decoding JSON, code %d", json.decoder.JSONDecodeError) + + +# Import some {{ .Assets.ServiceName }} settings from the environment. +# Read more on {{ .Assets.ServiceName }} variables at {{ .Assets.Docs.Variables }} +if os.getenv("{{ .Assets.EnvPrefix }}_APPLICATION_NAME"): + DEBUG = False + + if os.getenv("{{ .Assets.EnvPrefix }}_APP_DIR"): + STATIC_ROOT = os.path.join(os.getenv("{{ .Assets.EnvPrefix }}_APP_DIR"), "static") + + if os.getenv("{{ .Assets.EnvPrefix }}_PROJECT_ENTROPY"): + SECRET_KEY = os.getenv("{{ .Assets.EnvPrefix }}_PROJECT_ENTROPY") + + if os.getenv("{{ .Assets.EnvPrefix }}_ROUTES"): + {{ lower .Assets.EnvPrefix }}_routes = decode(os.getenv("{{ .Assets.EnvPrefix }}_ROUTES")) + ALLOWED_HOSTS = list(map( + lambda key: urlparse(key).hostname, + {{ lower .Assets.EnvPrefix }}_routes.keys(), + )) + + # Database service configuration, post-build only. + if os.getenv("{{ .Assets.EnvPrefix }}_RELATIONSHIPS") and {{ .Assets.EnvPrefix }}_DB_RELATIONSHIP: + {{ lower .Assets.EnvPrefix }}_relationships = decode(os.getenv("{{ .Assets.EnvPrefix }}_RELATIONSHIPS")) + if {{ .Assets.EnvPrefix }}_DB_RELATIONSHIP in {{ lower .Assets.EnvPrefix }}_relationships: + db_settings = {{ lower .Assets.EnvPrefix }}_relationships[{{ .Assets.EnvPrefix }}_DB_RELATIONSHIP][0] + engine = None + if ( + "mariadb" in db_settings["type"] + or "mysql" in db_settings["type"] + or "oracle-mysql" in db_settings["type"] + ): + engine = "django.db.backends.mysql" + elif "postgresql" in db_settings["type"]: + engine = "django.db.backends.postgresql" + + if engine: + DATABASES = { + "default": { + "ENGINE": engine, + "NAME": db_settings["path"], + "USER": db_settings["username"], + "PASSWORD": db_settings["password"], + "HOST": db_settings["host"], + "PORT": db_settings["port"], + }, + } diff --git a/internal/platformify/templates/fixed/.environment b/internal/platformify/templates/fixed/.environment new file mode 100644 index 00000000..91132eff --- /dev/null +++ b/internal/platformify/templates/fixed/.environment @@ -0,0 +1,59 @@ +export RELATIONSHIPS_JSON="$(echo ${{ .Assets.EnvPrefix }}_RELATIONSHIPS | base64 --decode)" +{{- if .Database }} + +# Set database environment variables +export DB_HOST="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].host')" +export DB_PORT="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].port')" +export DB_PATH="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].path')" +export DB_DATABASE="$DB_PATH" +export DB_USERNAME="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].username')" +export DB_PASSWORD="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].password')" +{{ if eq .Database "postgresql" }}export DB_SCHEME="postgresql" +{{- else if and (eq .Stack.Name "rails") (or (eq .Database "mariadb") (eq .Database "mysql")) }}export DB_SCHEME="mysql2" # mysql/mariadb rails adapter +{{- else }}export DB_SCHEME="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Database }}"][0].scheme')"{{ end }} +{{- end -}} +{{- if eq .Stack.Name "laravel" }} + +# Set Laravel-specific environment variables +export DB_CONNECTION="$DB_SCHEME" +{{- end }} +export DATABASE_URL="${DB_SCHEME}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_PATH}" +{{- if .Cache }} + +# Set Cache environment variables +export CACHE_HOST="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Cache }}"][0].host')" +export CACHE_PORT="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Cache }}"][0].port')" +export CACHE_PASSWORD="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Cache }}"][0].password')" +export CACHE_SCHEME="$(echo $RELATIONSHIPS_JSON | jq -r '.["{{ .Cache }}"][0].scheme')" +export CACHE_URL="${CACHE_SCHEME}://${CACHE_PASSWORD}@${CACHE_HOST}:${CACHE_PORT}" +{{- end -}} +{{- if or (eq .Cache "redis") (eq .Cache "redis-persistent") }} + +# Set Redis environment variables +export REDIS_URL="$CACHE_URL" +{{- end -}} +{{- if eq .Stack.Name "strapi" }} + +# Set Strapi-specific environment variables +export DATABASE_HOST="$DB_HOST" +export DATABASE_PORT="$DB_PORT" +export DATABASE_NAME="$DB_PATH" +export DATABASE_USERNAME="$DB_USERNAME" +export DATABASE_PASSWORD="$DB_PASSWORD" +export DATABASE_SCHEME="$DB_SCHEME" + +# Set secrets needed by Strapi, if they are not set +# Prefer setting these as project secret variables with {{ .Assets.Binary }} variable:create env:SECRET_NAME --sensitive=true +if [ -z "$ADMIN_JWT_SECRET" ]; then + export ADMIN_JWT_SECRET="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$JWT_SECRET" ]; then + export JWT_SECRET="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$API_TOKEN_SALT" ]; then + export API_TOKEN_SALT="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$APP_KEYS" ]; then + export APP_KEYS="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +{{- end -}} diff --git a/internal/platformify/templates/fixed/.platform.app.yaml b/internal/platformify/templates/fixed/.platform.app.yaml new file mode 100644 index 00000000..d5ec92ac --- /dev/null +++ b/internal/platformify/templates/fixed/.platform.app.yaml @@ -0,0 +1,229 @@ +# Complete list of all available properties: {{ .Assets.Docs.AppReference }} + +# A unique name for the app. Must be lowercase alphanumeric characters. Changing the name destroys data associated +# with the app. +name: "{{ .Name }}" + +# The runtime the application uses. +# Complete list of available runtimes: {{ .Assets.Docs.AppReference }}type/ +type: "{{ .Type }}" + +# How many resources to devote to the app. Defaults to AUTO in production environments. +# More information: {{ .Assets.Docs.AppReference }}sizes/ +# size: + +# The relationships of the application with services or other applications. +# The left-hand side is the name of the relationship as it will be exposed +# to the application in the {{ .Assets.EnvPrefix }}_RELATIONSHIPS variable. The right-hand +# side is in the form `:`. +# More information: {{ .Assets.Docs.AppReference }}relationships/ +{{ if .Services -}} +relationships: + {{ range .ServiceNames }} + {{- . }}: + {{ end -}} +{{ else }} +# relationships: +# db: +{{ end }} + +# The size of the persistent disk of the application (in MB). Minimum value is 128. +{{ if .Disk -}} +disk: {{ .Disk }} +{{- else -}} +# disk: 512 +{{- end }} + +# Mounts define directories that are writable after the build is complete. If set as a local source, disk property is required. +# More information: {{ .Assets.Docs.AppReference }}mounts/ +{{ if .Mounts -}} +mounts: + {{ range $key, $value := .Mounts -}} + "{{ $key }}": + {{ range $key, $value := $value }} + {{- $key }}: "{{ $value }}" + {{ end }} + {{ end -}} +{{- else -}} +# mounts: +# "/.cache": # Represents the path in the app. +# source: "local" # "local" sources are unique to the app, while "service" sources can be shared among apps. +# source_path: "cache" # The subdirectory within the mounted disk (the source) where the mount should point. +{{- end }} + +# The web key configures the web server running in front of your app. +# More information: {{ .Assets.Docs.AppReference }}web/ +web: + # Commands are run once after deployment to start the application process. + # More information: {{ .Assets.Docs.AppReference }}web/commands/ + {{- if .WebCommand }} + commands: + # The command to launch your app. If it terminates, it’s restarted immediately. + # You can use the $PORT or the $SOCKET environment variable depending on the socket family of your upstream + start: {{ quote .WebCommand }} + {{- else }} + # commands: + # The command to launch your app. If it terminates, it’s restarted immediately. + # You can use the $PORT or the $SOCKET environment variable depending on the socket family of your upstream + {{- if eq "php" .Runtime }} + # PHP applications run PHP-fpm by default + # Read about alternative commands here: {{ .Assets.Docs.PHP }}alternate-start-commands/ + {{- end }} + # start: echo 'Put your start command here' + {{- end }} + # You can listen to a UNIX socket (unix) or a TCP port (tcp, default). + {{- if eq "php" .Runtime }} + # For PHP, the defaults are configured for PHP-FPM and shouldn't need adjustment. + {{- end }} + # Whether your app should speak to the webserver via TCP or Unix socket. Defaults to tcp + # More information: {{ .Assets.Docs.AppReference }}web/upstream/socket-family/ + {{- if .SocketFamily }} + upstream: + # Whether your app should speak to the webserver via TCP or Unix socket. Defaults to tcp + # More information: {{ .Assets.Docs.AppReference }}web/upstream/socket-family/ + socket_family: {{ .SocketFamily }} + {{- else }} + # upstream: + # socket_family: unix + {{- end }} + + {{- if .Locations }} + # Each key in locations is a path on your site with a leading /. + # More information: {{ .Assets.Docs.AppReference }}web/locations/ + locations: + {{ range $key, $value := .Locations -}} + "{{ $key }}": + {{ range $key, $value := $value }} + {{- $key }}: {{ if typeIs "string" $value }}{{ quote $value }}{{ else }}{{ $value }}{{ end }} + {{ end }} + {{ end -}} + {{ else }} + # The locations of the application. Use passthru or serve static files according to your application's needs. + locations: + "/": + passthru: true + # "/static": + # The directory to serve static assets for this location relative to the app’s root directory. Must be an + # actual directory inside the root directory. + # root: "static" + # The number of seconds whitelisted (static) content should be cached. + # expires: 1h + # Whether to allow serving files which don’t match a rule. + # allow: true + {{ end }} + +# Alternate copies of the application to run as background processes. +# More information: {{ .Assets.Docs.AppReference }}workers/ +# workers: + +# The timezone for crons to run. Format: a TZ database name. Defaults to UTC, which is the timezone used for all logs +# no matter the value here. More information: {{ .Assets.Docs.TimeZone }} +# timezone: + +# Access control for roles accessing app environments. +# More information: {{ .Assets.Docs.AppReference }}access/ +# access: + +# Variables to control the environment. More information: {{ .Assets.Docs.AppReference }}variables/ +{{ if .Environment -}} +variables: + env: + {{ range $key, $value := .Environment }} + {{- $key }}: "{{ $value }}" + {{ end -}} +{{ else -}} +# variables: +# env: +# # Add environment variables here that are static. +{{- if eq "python" .Runtime }} +# PYTHONUNBUFFERED: "1" +{{- else if eq "php" .Runtime }} +# XDEBUG_MODE: off +{{- else if eq "nodejs" .Runtime }} +# NODE_ENV: development +{{- else if eq "ruby" .Runtime }} +# RACK_ENV: production +{{- else }} +# ENV_VARIABLE_NAME: "value" +{{- end }} +{{- end }} + +# Outbound firewall rules for the application. More information: {{ .Assets.Docs.AppReference }}firewall/ +# 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 -}} +# 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 +# the build hook runs using a package manager for the language. +# More information: {{ .Assets.Docs.AppReference }}dependencies/ +{{ if .Dependencies -}} +dependencies: + {{- range $depGroupKey, $depGroupValue := .Dependencies }} + {{ $depGroupKey }}: + {{ range $key, $value := $depGroupValue }} + {{ $key }}: "{{ $value }}" + {{- end }} + {{- end }} +{{ else -}} +# dependencies: + {{- if eq "python" .Runtime }} +# python3: # Specify one Python 3 package per line. +# numpy: '*' + {{- else if eq "php" .Runtime }} +# php: # Specify one Composer package per line. +# drush/drush: '8.0.0' +# composer/composer: '^2' + {{- else if eq "nodejs" .Runtime }} +# nodejs: # Specify one NPM package per line. +# pm2: '^4.5.0' + {{- else }} +# runtime: # Specify one package per line. +# package: 'version' + {{- end }} +{{- end }} + +# Hooks allow you to customize your code/environment as the project moves through the build and deploy stages +# More information: {{ .Assets.Docs.AppReference }}hooks/ +hooks: + # The build hook is run after any build flavor. + # More information: {{ .Assets.Docs.Hooks }}compare/build/ + build: | + set -eux + {{ range $step := .BuildSteps }} + {{- $step }} + {{ else }} + # Add build steps here. + # echo 'I am a build step' + {{ end }} + # The deploy hook is run after the app container has been started, but before it has started accepting requests. + # More information: {{ .Assets.Docs.Hooks }}compare/deploy/ + deploy: | + set -eux + {{ range $deploycmd := .DeployCommand }} + {{- $deploycmd }} + {{ else -}} + # echo 'Put your deploy command here' + {{ end }} + + # The post_deploy hook is run after the app container has been started and after it has started accepting requests. + # More information: {{ .Assets.Docs.Hooks }}compare/post-deploy/ + # post_deploy: | + +# Scheduled tasks for the app. +# More information: {{ .Assets.Docs.AppReference }}crons/ +# crons: + +# Customizations to your PHP or Lisp runtime. More information: {{ .Assets.Docs.AppReference }}runtime/ +# runtime: + +# More information: {{ .Assets.Docs.AppReference }}additional-hosts/ +# additional_hosts: diff --git a/internal/platformify/templates/fixed/.platform/routes.yaml b/internal/platformify/templates/fixed/.platform/routes.yaml new file mode 100644 index 00000000..5371492f --- /dev/null +++ b/internal/platformify/templates/fixed/.platform/routes.yaml @@ -0,0 +1,14 @@ +# The routes of the project. +# +# Each route describes how an incoming URL is going +# to be processed by {{ .Assets.ServiceName }}. +# More information: {{ .Assets.Docs.Routes }} +"https://{{ "{default}" }}/": + type: upstream + upstream: "{{ .Name }}:http" + +# A basic redirect definition +# More information: {{ .Assets.Docs.Routes }}examples/redirect/ +"https://www.{{ "{default}" }}": + type: redirect + to: "https://{{ "{default}" }}/" diff --git a/internal/platformify/templates/fixed/.platform/services.yaml b/internal/platformify/templates/fixed/.platform/services.yaml new file mode 100644 index 00000000..7704303d --- /dev/null +++ b/internal/platformify/templates/fixed/.platform/services.yaml @@ -0,0 +1,21 @@ +# The services of the project. +# +# Each service listed will be deployed +# to power your {{ .Assets.ServiceName }} project. +# More information: {{ .Assets.Docs.Services }} +# Full list of available services: {{ .Assets.Docs.Services }}available/ +{{- if .Services -}} +{{- range $value := .Services }} +{{ $value.Name }}: + type: {{ $value.Type }} # All available versions are: {{ $value.TypeVersions | join ", "}} + {{- if $value.Disk }} + disk: {{ $value.Disk }} # All available sizes are: {{ $value.DiskSizes | join ", "}} + {{- end }} + +{{- end -}} +{{ else }} +# relationships +# db: +# type: postgresql:17 +# disk: 1024 +{{ end -}} diff --git a/internal/platformify/templates/flex/.environment b/internal/platformify/templates/flex/.environment new file mode 100644 index 00000000..7e881342 --- /dev/null +++ b/internal/platformify/templates/flex/.environment @@ -0,0 +1,55 @@ +{{- if .Database }}# Set database environment variables +export DB_HOST="${{ .DatabaseUpper }}_HOST" +export DB_PORT="${{ .DatabaseUpper }}_PORT" +export DB_PATH="${{ .DatabaseUpper }}_PATH" +export DB_DATABASE="$DB_PATH" +export DB_USERNAME="${{ .DatabaseUpper }}_USERNAME" +export DB_PASSWORD="${{ .DatabaseUpper }}_PASSWORD" +{{ if eq .Database "postgresql" }}export DB_SCHEME="postgresql" +{{- else if and (eq .Stack.Name "rails") (or (eq .Database "mariadb") (eq .Database "mysql")) }}export DB_SCHEME="mysql2" # mysql/mariadb rails adapter +{{- else }}export DB_SCHEME="${{ .DatabaseUpper }}_SCHEME"{{ end }} +{{- end -}} +{{- if eq .Stack.Name "laravel" }} + +# Set Laravel-specific environment variables +export DB_CONNECTION="$DB_SCHEME" +{{- end }} +export DATABASE_URL="${DB_SCHEME}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_PATH}" +{{- if .Cache }} + +# Set Cache environment variables +export CACHE_HOST="${{ .CacheUpper }}_HOST" +export CACHE_PORT="${{ .CacheUpper }}_PORT" +export CACHE_SCHEME="${{ .CacheUpper }}_SCHEME" +export CACHE_URL="${CACHE_SCHEME}://${CACHE_HOST}:${CACHE_PORT}" +{{- end -}} +{{- if or (eq .Cache "redis") (eq .Cache "redis-persistent") }} + +# Set Redis environment variables +export REDIS_URL="$CACHE_URL" +{{- end -}} +{{- if eq .Stack.Name "strapi" }} + +# Set Strapi-specific environment variables +export DATABASE_HOST="$DB_HOST" +export DATABASE_PORT="$DB_PORT" +export DATABASE_NAME="$DB_PATH" +export DATABASE_USERNAME="$DB_USERNAME" +export DATABASE_PASSWORD="$DB_PASSWORD" +export DATABASE_SCHEME="$DB_SCHEME" + +# Set secrets needed by Strapi, if they are not set +# Prefer setting these as project secret variables with {{ .Assets.Binary }} variable:create env:SECRET_NAME --sensitive=true +if [ -z "$ADMIN_JWT_SECRET" ]; then + export ADMIN_JWT_SECRET="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$JWT_SECRET" ]; then + export JWT_SECRET="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$API_TOKEN_SALT" ]; then + export API_TOKEN_SALT="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +if [ -z "$APP_KEYS" ]; then + export APP_KEYS="${{ .Assets.EnvPrefix }}_PROJECT_ENTROPY" +fi +{{- end -}} diff --git a/internal/platformify/templates/flex/.upsun/config.yaml b/internal/platformify/templates/flex/.upsun/config.yaml new file mode 100644 index 00000000..2e6ccac2 --- /dev/null +++ b/internal/platformify/templates/flex/.upsun/config.yaml @@ -0,0 +1,258 @@ +# Complete list of all available properties: {{ .Assets.Docs.AppReference }} +applications: + {{ .Name }}: + # Application source code directory + source: + root: "{{ .ApplicationRoot }}" + + # The runtime the application uses. + # Complete list of available runtimes: {{ .Assets.Docs.AppReference }}type/ + type: "{{ .Type }}" + + # Choose which container profile (ratio CPU+RAM) your app will use. Default value comes from the image itself. + # More information: https://docs.upsun.com/anchors/resources/manage/configuration/profiles/adjust/ + # container_profile: + + # The relationships of the application with services or other applications. + # The left-hand side is the name of the relationship as it will be exposed + # to the application in the {{ .Assets.EnvPrefix }}_RELATIONSHIPS variable. The right-hand + # side is in the form `:`. + # More information: {{ .Assets.Docs.AppReference }}relationships/ + {{ if .Services -}} + relationships: + {{ range .ServiceNames }} + {{- . }}: + {{ end -}} + {{ else }} + # relationships: + # db: + {{ end }} + + # Mounts define directories that are writable after the build is complete. + # More information: {{ .Assets.Docs.AppReference }}mounts/ + {{ if .Mounts -}} + mounts: + {{ range $key, $value := .Mounts -}} + "{{ $key }}": + {{ range $key, $value := $value }} + {{- if eq $key "source" -}}{{- $value = $value | replace "local" "storage" -}}{{- end -}} + {{- $key }}: "{{ $value }}" + {{ end }} + {{ end -}} + {{- else -}} + # mounts: + # "/var/uploads": # Represents the path in the app. + # source: "storage" # "storage" sources are unique to the app, but shared among instances of the app. "service" sources can be shared among apps. + # source_path: "var/uploads" # The subdirectory within the mounted disk (the source) where the mount should point. + {{- end }} + + # The web key configures the web server running in front of your app. + # More information: {{ .Assets.Docs.AppReference }}web/ + web: + # Commands are run once after deployment to start the application process. + # More information: {{ .Assets.Docs.AppReference }}web/commands/ + {{- if .WebCommand }} + commands: + # The command to launch your app. If it terminates, it’s restarted immediately. + # You can use the $PORT or the $SOCKET environment variable depending on the socket family of your upstream + start: {{ quote .WebCommand }} + {{- else }} + # commands: + # The command to launch your app. If it terminates, it’s restarted immediately. + # You can use the $PORT or the $SOCKET environment variable depending on the socket family of your upstream + {{- if eq "php" .Runtime }} + # PHP applications run PHP-fpm by default + # Read about alternative commands here: {{ .Assets.Docs.PHP }}alternate-start-commands/ + {{- end }} + # start: echo 'Put your start command here' + {{- end }} + # You can listen to a UNIX socket (unix) or a TCP port (tcp, default). + {{- if eq "php" .Runtime }} + # For PHP, the defaults are configured for PHP-FPM and shouldn't need adjustment. + {{- end }} + # Whether your app should speak to the webserver via TCP or Unix socket. Defaults to tcp + # More information: {{ .Assets.Docs.AppReference }}web/upstream/socket-family/ + {{- if .SocketFamily }} + upstream: + # Whether your app should speak to the webserver via TCP or Unix socket. Defaults to tcp + # More information: {{ .Assets.Docs.AppReference }}web/upstream/socket-family/ + socket_family: {{ .SocketFamily }} + {{- else }} + # upstream: + # socket_family: unix + {{- end }} + + {{- if .Locations }} + # Each key in locations is a path on your site with a leading /. + # More information: {{ .Assets.Docs.AppReference }}web/locations/ + locations: + {{ range $key, $value := .Locations -}} + "{{ $key }}": + {{ range $key, $value := $value }} + {{- $key }}: {{ if typeIs "string" $value }}{{ quote $value }}{{ else }}{{ $value }}{{ end }} + {{ end }} + {{ end -}} + {{ else }} + # The locations of the application. Use passthru or serve static files according to your application's needs. + locations: + "/": + passthru: true + # "/static": + # The directory to serve static assets for this location relative to the app’s root directory. Must be an + # actual directory inside the root directory. + # root: "static" + # The number of seconds whitelisted (static) content should be cached. + # expires: 1h + # Whether to allow serving files which don’t match a rule. + # allow: true + {{ end }} + + # Alternate copies of the application to run as background processes. + # More information: {{ .Assets.Docs.AppReference }}workers/ + # workers: + + # The timezone for crons to run. Format: a TZ database name. Defaults to UTC, which is the timezone used for all logs + # no matter the value here. More information: {{ .Assets.Docs.TimeZone }} + # timezone: + + # Access control for roles accessing app environments. + # More information: {{ .Assets.Docs.AppReference }}access/ + # access: + + # Variables to control the environment. More information: {{ .Assets.Docs.AppReference }}variables/ + {{ if .Environment -}} + variables: + env: + {{ range $key, $value := .Environment }} + {{- $key }}: "{{ $value }}" + {{ end -}} + {{ else -}} + # variables: + # env: + # # Add environment variables here that are static. + {{- if eq "python" .Runtime }} + # PYTHONUNBUFFERED: "1" + {{- else if eq "php" .Runtime }} + # XDEBUG_MODE: off + {{- else if eq "nodejs" .Runtime }} + # NODE_ENV: development + {{- else if eq "ruby" .Runtime }} + # RACK_ENV: production + {{- else }} + # ENV_VARIABLE_NAME: "value" + {{- end }} + {{- end }} + + # Outbound firewall rules for the application. More information: {{ .Assets.Docs.AppReference }}firewall/ + # 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 -}} + # 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 + # the build hook runs using a package manager for the language. + # More information: {{ .Assets.Docs.AppReference }}dependencies/ + {{ if .Dependencies -}} + dependencies: + {{- range $key, $value := .Dependencies }} + {{ $key }}: + {{- range $key, $value := $value }} + {{ $key }}: "{{ $value }}" + {{- end }} + {{- end }} + {{- else -}} + # dependencies: + {{- if eq "python" .Runtime }} + # python3: # Specify one Python 3 package per line. + # numpy: '*' + {{- else if eq "php" .Runtime }} + # php: # Specify one Composer package per line. + # drush/drush: '8.0.0' + # composer/composer: '^2' + {{- else if eq "nodejs" .Runtime }} + # nodejs: # Specify one NPM package per line. + # pm2: '^4.5.0' + {{- else }} + # runtime: # Specify one package per line. + # package: 'version' + {{- end }} + {{- end }} + + # Hooks allow you to customize your code/environment as the project moves through the build and deploy stages + # More information: {{ .Assets.Docs.AppReference }}hooks/ + hooks: + # The build hook is run after any build flavor. + # More information: {{ .Assets.Docs.Hooks }}compare/build/ + build: | + set -eux + {{ range $step := .BuildSteps }} + {{- $step }} + {{ else }} + # Add build steps here. + # echo 'I am a build step' + {{ end }} + # The deploy hook is run after the app container has been started, but before it has started accepting requests. + # More information: {{ .Assets.Docs.Hooks }}compare/deploy/ + deploy: | + set -eux + {{ range $deploycmd := .DeployCommand }} + {{- $deploycmd }} + {{ else -}} + # echo 'Put your deploy command here' + {{ end }} + + # The post_deploy hook is run after the app container has been started and after it has started accepting requests. + # More information: {{ .Assets.Docs.Hooks }}compare/post-deploy/ + # post_deploy: | + + # Scheduled tasks for the app. + # More information: {{ .Assets.Docs.AppReference }}crons/ + # crons: + + # Customizations to your PHP or Lisp runtime. More information: {{ .Assets.Docs.AppReference }}runtime/ + # runtime: + + # More information: {{ .Assets.Docs.AppReference }}additional-hosts/ + # additional_hosts: + +# The services of the project. +# +# Each service listed will be deployed +# to power your {{ .Assets.ServiceName }} project. +# More information: {{ .Assets.Docs.Services }} +# Full list of available services: {{ .Assets.Docs.Services }}available/ +{{- if .Services }} +services: + {{ range $value := .Services }} + {{- $value.Name }}: + type: {{ $value.Type }} # All available versions are: {{ $value.TypeVersions | join ", "}} + + {{ end }} +{{- else }} +# services: +# db: +# type: postgresql:17 +{{ end }} + +# The routes of the project. +# +# Each route describes how an incoming URL is going +# to be processed by {{ .Assets.ServiceName }}. +# More information: {{ .Assets.Docs.Routes }} +routes: + "https://{{ "{default}" }}/": + type: upstream + upstream: "{{ .Name }}:http" + # A basic redirect definition + # More information: {{ .Assets.Docs.Routes }}examples/redirect/ + "https://www.{{ "{default}" }}": + type: redirect + to: "https://{{ "{default}" }}/"