-
Notifications
You must be signed in to change notification settings - Fork 0
feat: re-implement railpack plan generation orchestration so we can inject custom providers. #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tjholm
wants to merge
1
commit into
main
Choose a base branch
from
custom-providers
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+365
−1
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "strings" | ||
|
|
||
| "github.com/railwayapp/railpack/core" | ||
| "github.com/railwayapp/railpack/core/app" | ||
| c "github.com/railwayapp/railpack/core/config" | ||
| "github.com/railwayapp/railpack/core/generate" | ||
| "github.com/railwayapp/railpack/core/logger" | ||
| "github.com/railwayapp/railpack/core/providers" | ||
| "github.com/railwayapp/railpack/core/providers/cpp" | ||
| "github.com/railwayapp/railpack/core/providers/deno" | ||
| "github.com/railwayapp/railpack/core/providers/dotnet" | ||
| "github.com/railwayapp/railpack/core/providers/elixir" | ||
| "github.com/railwayapp/railpack/core/providers/gleam" | ||
| "github.com/railwayapp/railpack/core/providers/golang" | ||
| "github.com/railwayapp/railpack/core/providers/java" | ||
| "github.com/railwayapp/railpack/core/providers/php" | ||
| "github.com/railwayapp/railpack/core/providers/procfile" | ||
| "github.com/railwayapp/railpack/core/providers/python" | ||
| "github.com/railwayapp/railpack/core/providers/ruby" | ||
| "github.com/railwayapp/railpack/core/providers/rust" | ||
| "github.com/railwayapp/railpack/core/providers/shell" | ||
| "github.com/railwayapp/railpack/core/providers/staticfile" | ||
| ) | ||
|
|
||
| // generateBuildPlan mirrors railpack's core.GenerateBuildPlan but accepts an | ||
| // explicit provider list, letting sugapack inject or extend providers without | ||
| // patching railpack itself. Every railpack symbol it touches is exported, so | ||
| // this compiles against an unmodified railpack dependency. | ||
| // | ||
| // Adapted from github.com/railwayapp/railpack v0.23.0 core.GenerateBuildPlan. | ||
| // Keep in sync when bumping the railpack version. | ||
| func generateBuildPlan(a *app.App, env *app.Environment, options *core.GenerateBuildPlanOptions, allProviders []providers.Provider) *core.BuildResult { | ||
| log := logger.NewLogger() | ||
|
|
||
| config, err := core.GetConfig(a, env, options, log) | ||
| if err != nil { | ||
| log.LogError("%s", err.Error()) | ||
| return &core.BuildResult{Success: false, Logs: log.Logs} | ||
| } | ||
|
|
||
| ctx, err := generate.NewGenerateContext(a, env, config, log) | ||
| if err != nil { | ||
| log.LogError("%s", err.Error()) | ||
| return &core.BuildResult{Success: false, Logs: log.Logs} | ||
| } | ||
|
|
||
| if options.PreviousVersions != nil { | ||
| for name, version := range options.PreviousVersions { | ||
| ctx.Resolver.SetPreviousVersion(name, version) | ||
| } | ||
| } | ||
|
|
||
| providerToUse, detectedProviderName := selectProvider(ctx, config, allProviders) | ||
| ctx.Metadata.Set("providers", detectedProviderName) | ||
|
|
||
| if providerToUse != nil { | ||
| if err := providerToUse.Plan(ctx); err != nil { | ||
| log.LogError("%s", err.Error()) | ||
| return &core.BuildResult{Success: false, Logs: log.Logs} | ||
| } | ||
| } | ||
|
|
||
| // Support apps that declare a start command in a Procfile. | ||
| procfileProvider := &procfile.ProcfileProvider{} | ||
| if _, err := procfileProvider.Plan(ctx); err != nil { | ||
| log.LogError("%s", err.Error()) | ||
| return &core.BuildResult{Success: false, Logs: log.Logs} | ||
| } | ||
|
|
||
| buildPlan, resolvedPackages, err := ctx.Generate() | ||
| if err != nil { | ||
| log.LogError("%s", err.Error()) | ||
| return &core.BuildResult{Success: false, Logs: log.Logs} | ||
| } | ||
|
|
||
| if providerToUse != nil { | ||
| providerToUse.CleansePlan(buildPlan) | ||
| } | ||
|
|
||
| if !core.ValidatePlan(buildPlan, a, log, &core.ValidatePlanOptions{ | ||
| ErrorMissingStartCommand: options.ErrorMissingStartCommand, | ||
| ProviderToUse: providerToUse, | ||
| }) { | ||
| return &core.BuildResult{Success: false, Logs: log.Logs} | ||
| } | ||
|
|
||
| return &core.BuildResult{ | ||
| RailpackVersion: options.RailpackVersion, | ||
| Plan: buildPlan, | ||
| ResolvedPackages: resolvedPackages, | ||
| Metadata: ctx.Metadata.Properties, | ||
| DetectedProviders: []string{detectedProviderName}, | ||
| Logs: log.Logs, | ||
| Success: true, | ||
| } | ||
| } | ||
|
|
||
| // selectProvider mirrors railpack's unexported core.getProviders, but takes the | ||
| // provider list as a parameter instead of hardcoding GetLanguageProviders(). | ||
| // This is the single hook the PR was trying to add upstream. | ||
| // | ||
| // Adapted from github.com/railwayapp/railpack v0.23.0 core.getProviders. | ||
| func selectProvider(ctx *generate.GenerateContext, config *c.Config, allProviders []providers.Provider) (providers.Provider, string) { | ||
| var providerToUse providers.Provider | ||
| var detectedProvider string | ||
|
|
||
| // Detect regardless of an explicit config.Provider so we can still report | ||
| // what kind of app this is. | ||
| for _, provider := range allProviders { | ||
| matched, err := provider.Detect(ctx) | ||
| if err != nil { | ||
| ctx.Logger.LogWarn("Failed to detect provider `%s`: %s", provider.Name(), err.Error()) | ||
| continue | ||
| } | ||
|
|
||
| if matched { | ||
| detectedProvider = provider.Name() | ||
|
|
||
| if config.Provider == nil { | ||
| if err := provider.Initialize(ctx); err != nil { | ||
| ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", provider.Name(), err.Error()) | ||
| continue | ||
| } | ||
| ctx.Logger.LogInfo("Detected %s", capitalizeFirst(provider.Name())) | ||
| providerToUse = provider | ||
| } | ||
| break | ||
| } | ||
| } | ||
|
|
||
| if config.Provider != nil { | ||
| provider := providerByName(*config.Provider, allProviders) | ||
| if provider == nil { | ||
| ctx.Logger.LogWarn("Provider `%s` not found", *config.Provider) | ||
| return providerToUse, detectedProvider | ||
| } | ||
| if err := provider.Initialize(ctx); err != nil { | ||
| ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", *config.Provider, err.Error()) | ||
| return providerToUse, detectedProvider | ||
| } | ||
| ctx.Logger.LogInfo("Using provider %s from config", capitalizeFirst(*config.Provider)) | ||
| providerToUse = provider | ||
| } | ||
|
|
||
| return providerToUse, detectedProvider | ||
| } | ||
|
|
||
| // providerByName looks up a provider by name (case-insensitive). Replaces | ||
| // providers.GetProvider, which only searches railpack's built-in list. | ||
| func providerByName(name string, list []providers.Provider) providers.Provider { | ||
| for _, p := range list { | ||
| if strings.EqualFold(p.Name(), name) { | ||
| return p | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // capitalizeFirst reimplements railpack's internal/utils.CapitalizeFirst, which | ||
| // lives under an internal/ path and cannot be imported from here. | ||
| func capitalizeFirst(s string) string { | ||
| if s == "" { | ||
| return s | ||
| } | ||
| return strings.ToUpper(s[:1]) + s[1:] | ||
| } | ||
|
|
||
| // sugapackProviders returns the provider list used for plan generation. It | ||
| // mirrors railpack's providers.GetLanguageProviders() with the Node provider | ||
| // swapped for sugapack's provenance-emitting wrapper. The list is reproduced | ||
| // explicitly (rather than mutating the built-in slice) so the ordering — which | ||
| // determines detection priority — is visible and any upstream drift shows up as | ||
| // a compile error. Keep in sync with GetLanguageProviders when bumping railpack. | ||
| func sugapackProviders() []providers.Provider { | ||
| return []providers.Provider{ | ||
| &php.PhpProvider{}, | ||
| &golang.GoProvider{}, | ||
| &java.JavaProvider{}, | ||
| &rust.RustProvider{}, | ||
| &ruby.RubyProvider{}, | ||
| &elixir.ElixirProvider{}, | ||
| &python.PythonProvider{}, | ||
| &deno.DenoProvider{}, | ||
| &dotnet.DotnetProvider{}, | ||
| &nodeProvenanceProvider{}, // wraps node.NodeProvider | ||
| &gleam.GleamProvider{}, | ||
| &cpp.CppProvider{}, | ||
| &staticfile.StaticfileProvider{}, | ||
| &shell.ShellProvider{}, | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/railwayapp/railpack/core/generate" | ||
| "github.com/railwayapp/railpack/core/providers/node" | ||
| ) | ||
|
|
||
| // nodeProvenanceProvider wraps railpack's node.NodeProvider to add "decision | ||
| // provenance": after the wrapped provider builds its plan, it explains *why* the | ||
| // app was classified the way it was and warns when a project that likely wants a | ||
| // server was deployed as a static SPA (e.g. a TanStack Start app with no `start` | ||
| // script — the motivating case from railpack PR #588). | ||
| // | ||
| // This is a prototype demonstrating that providers can be extended entirely from | ||
| // sugapack: it embeds the built-in provider (so Detect/Initialize/CleansePlan/ | ||
| // etc. are inherited unchanged) and only reads *exported* railpack surface | ||
| // (Metadata, Config.Deploy, GetPackageJson). No railpack patch required. | ||
| type nodeProvenanceProvider struct { | ||
| node.NodeProvider | ||
| } | ||
|
|
||
| // Plan runs the wrapped provider's planning, then annotates provenance. | ||
| func (p *nodeProvenanceProvider) Plan(ctx *generate.GenerateContext) error { | ||
| if err := p.NodeProvider.Plan(ctx); err != nil { | ||
| return err | ||
| } | ||
| p.recordProvenance(ctx) | ||
| return nil | ||
| } | ||
|
|
||
| // recordProvenance inspects the metadata the wrapped provider already set | ||
| // (nodeIsSPA, nodeSPAFramework — see node.SetNodeMetadata) plus the user's | ||
| // configured start command and package.json, and surfaces an explanation. | ||
| func (p *nodeProvenanceProvider) recordProvenance(ctx *generate.GenerateContext) { | ||
| meta := ctx.Metadata | ||
|
|
||
| if meta.Properties["nodeIsSPA"] != "true" { | ||
| meta.Set("provenance.node.classification", "server") | ||
| return | ||
| } | ||
|
|
||
| framework := meta.Properties["nodeSPAFramework"] | ||
| meta.Set("provenance.node.classification", "spa") | ||
| if framework != "" { | ||
| meta.Set("provenance.node.spaFramework", framework) | ||
| } | ||
|
|
||
| // A custom start command disables SPA classification (node.isSPA), so if we | ||
| // reached the SPA branch, either no start command was found or an output dir | ||
| // was forced via RAILPACK_SPA_OUTPUT_DIR. | ||
| forcedOutputDir, _ := ctx.Env.GetConfigVariable(node.OUTPUT_DIR_VAR) | ||
|
|
||
| var reason string | ||
| switch { | ||
| case forcedOutputDir != "": | ||
| reason = fmt.Sprintf("RAILPACK_%s is set (%q), forcing a static SPA deploy.", node.OUTPUT_DIR_VAR, forcedOutputDir) | ||
| case framework != "": | ||
| reason = fmt.Sprintf("Detected the %s static-site framework and found no start command, so the app is served as a static SPA via Caddy.", framework) | ||
| default: | ||
| reason = "No start command was found, so the app is served as a static SPA via Caddy." | ||
| } | ||
| meta.Set("provenance.node.reason", reason) | ||
| ctx.Logger.LogInfo("Build decision: %s", reason) | ||
|
|
||
| // The motivating case: a framework that can run a server was built as a SPA | ||
| // only because it had no start command. Nudge the user toward the fix. | ||
| if forcedOutputDir == "" && serverCapableFramework(ctx, p) { | ||
| hint := "This project uses a framework that can run as a server (e.g. TanStack Start), " + | ||
| "but was built as a static SPA because no `start` script was found. " + | ||
| "Add a \"start\" script to package.json (or set RAILPACK_NO_SPA=1) to deploy it as a server." | ||
| meta.Set("provenance.node.hint", hint) | ||
| ctx.Logger.LogWarn("%s", hint) | ||
| } | ||
| } | ||
|
|
||
| // serverCapableFramework reports whether the project depends on a framework that | ||
| // commonly runs its own server but can also be built as a static SPA. Reads | ||
| // package.json via the wrapped provider's exported GetPackageJson. | ||
| func serverCapableFramework(ctx *generate.GenerateContext, p *nodeProvenanceProvider) bool { | ||
| pkg, err := p.NodeProvider.GetPackageJson(ctx.App) | ||
| if err != nil || pkg == nil { | ||
| return false | ||
| } | ||
| for dep := range pkg.Dependencies { | ||
| switch dep { | ||
| case "@tanstack/react-start", "@tanstack/start": | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/railwayapp/railpack/core" | ||
| "github.com/railwayapp/railpack/core/app" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // writeFixture writes a minimal app source tree and returns its directory. | ||
| func writeFixture(t *testing.T, files map[string]string) string { | ||
| t.Helper() | ||
| dir := t.TempDir() | ||
| for name, content := range files { | ||
| path := filepath.Join(dir, name) | ||
| require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) | ||
| require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) | ||
| } | ||
| return dir | ||
| } | ||
|
|
||
| func planFor(t *testing.T, dir string) *core.BuildResult { | ||
| t.Helper() | ||
| a, err := app.NewApp(dir) | ||
| require.NoError(t, err) | ||
| env := app.NewEnvironment(nil) | ||
| result := generateBuildPlan(a, env, &core.GenerateBuildPlanOptions{}, sugapackProviders()) | ||
| printRailpackLogs(os.Stderr, result.Logs) | ||
| return result | ||
| } | ||
|
|
||
| // A TanStack Start app (Vite-based) with no `start` script is classified as a | ||
| // static SPA. The provenance wrapper should explain why and warn that a | ||
| // server-capable framework was built as a SPA. | ||
| func TestNodeProvenance_TanStackStartTreatedAsSPA(t *testing.T) { | ||
| dir := writeFixture(t, map[string]string{ | ||
| "package.json": `{ | ||
| "name": "tanstack-app", | ||
| "scripts": { "build": "vite build" }, | ||
| "dependencies": { "vite": "^5.0.0", "@tanstack/react-start": "^1.0.0" } | ||
| }`, | ||
| "vite.config.ts": `export default {}`, | ||
| }) | ||
|
|
||
| result := planFor(t, dir) | ||
| require.True(t, result.Success, "plan generation should succeed") | ||
|
|
||
| require.Equal(t, "spa", result.Metadata["provenance.node.classification"]) | ||
| require.Equal(t, "vite", result.Metadata["provenance.node.spaFramework"]) | ||
| require.Contains(t, result.Metadata["provenance.node.reason"], "static SPA") | ||
| require.Contains(t, result.Metadata["provenance.node.hint"], "start") | ||
| } | ||
|
|
||
| // A plain Node server app (start script, no static framework) should be | ||
| // classified as a server with no SPA hint. | ||
| func TestNodeProvenance_ServerApp(t *testing.T) { | ||
| dir := writeFixture(t, map[string]string{ | ||
| "package.json": `{ | ||
| "name": "server-app", | ||
| "scripts": { "start": "node index.js" }, | ||
| "dependencies": { "express": "^4.0.0" } | ||
| }`, | ||
| "index.js": `require("express")()`, | ||
| }) | ||
|
|
||
| result := planFor(t, dir) | ||
| require.True(t, result.Success, "plan generation should succeed") | ||
|
|
||
| require.Equal(t, "server", result.Metadata["provenance.node.classification"]) | ||
| require.Empty(t, result.Metadata["provenance.node.hint"]) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did we have the same issue with astro sites?