From 5b8575a63d05cc57389ca8b994dc0cea8899f8a5 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Wed, 28 Oct 2026 22:07:41 +0000 Subject: [PATCH 1/7] Support source-root build contexts for monorepo apps Keep app configuration and deployment targets in each service directory while resolving build.source_root relative to that directory. Upload the selected config at the canonical path in full and delta build contexts without changing the repository or server APIs. Cover source resolution, upload projection, nested config and alias selection, local deployment targets, gating, and CLI-to-server analysis. --- appconfig/appconfig.go | 4 +- appconfig/appconfig_test.go | 130 ++++++++++++++++++++++++ appconfig/source.go | 27 +++++ cli/alias_test.go | 14 +++ cli/commands/app.go | 11 +- cli/commands/deploy.go | 30 +++--- cli/commands/deploy_targets_test.go | 28 ++++++ cli/commands/deploy_test.go | 41 ++++++++ cli/commands/localconfig.go | 4 +- cli/commands/localconfig_test.go | 18 ++++ pkg/deploygating/gate.go | 15 ++- pkg/deploygating/gate_test.go | 27 +++++ pkg/tarx/tarx.go | 151 ++++++++++++++++++---------- 13 files changed, 424 insertions(+), 76 deletions(-) create mode 100644 appconfig/source.go diff --git a/appconfig/appconfig.go b/appconfig/appconfig.go index 97b9dc49e..688feb1e8 100644 --- a/appconfig/appconfig.go +++ b/appconfig/appconfig.go @@ -42,6 +42,7 @@ type AppEnvVar struct { type BuildConfig struct { Dockerfile string `toml:"dockerfile"` + SourceRoot string `toml:"source_root,omitempty"` OnBuild []string `toml:"onbuild"` Version string `toml:"version"` @@ -385,8 +386,7 @@ func LoadAppConfig() (*AppConfig, error) { // LoadAppConfigWithPath loads the app config and returns the file path it was // loaded from. The path is also returned alongside a parse error, so a caller -// can tell which file was at fault. Returns (nil, "", nil) if no config file -// is found. +// can tell which file was at fault. Returns (nil, "", nil) if no config is found. func LoadAppConfigWithPath() (*AppConfig, string, error) { dir, err := os.Getwd() if err != nil { diff --git a/appconfig/appconfig_test.go b/appconfig/appconfig_test.go index 6a81fbc17..ddd800bde 100644 --- a/appconfig/appconfig_test.go +++ b/appconfig/appconfig_test.go @@ -1,15 +1,145 @@ package appconfig import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "io" "os" "path/filepath" "strconv" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "miren.dev/runtime/pkg/tarx" ) +func TestSourceRoot(t *testing.T) { + dir := t.TempDir() + configDir := filepath.Join(dir, "services", "api") + for _, tc := range []struct { + root string + want string + }{ + {"", configDir}, + {".", configDir}, + {"../..", dir}, + {"src", filepath.Join(configDir, "src")}, + {dir, dir}, + } { + t.Run(tc.root, func(t *testing.T) { + ac, err := Parse([]byte("[build]\nsource_root = " + strconv.Quote(tc.root))) + require.NoError(t, err) + require.Equal(t, tc.want, ac.SourceDir(configDir)) + if tc.want == configDir { + require.Empty(t, ac.UploadFiles(configDir)) + } + }) + } + var absent *AppConfig + require.Equal(t, configDir, absent.SourceDir(configDir)) + require.Empty(t, absent.UploadFiles(configDir)) +} + +func TestSourceRootUploads(t *testing.T) { + for _, layout := range []string{"with default", "without default", "gitignored", "included"} { + t.Run(layout, func(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "main.go": "package main\n", + "services/api/.miren/app.toml": "name = 'api-app'\n[build]\nsource_root = '../..'\n", + "services/worker/.miren/app.toml": "name = 'background-worker'\n[build]\nsource_root = '../..'\n", + } + if layout != "without default" { + files[".miren/app.toml"] = "name = 'default-app'\n" + } + if layout == "gitignored" { + files[".gitignore"] = ".miren/\n" + } + var include []string + if layout == "included" { + include = []string{"main.go"} + } + for name, body := range files { + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(body), 0644)) + } + for _, app := range []string{"api", "worker"} { + configDir := filepath.Join(dir, "services", app) + ac, err := LoadAppConfigUnder(configDir) + require.NoError(t, err) + want := files["services/"+app+"/.miren/app.toml"] + manifest, err := tarx.ComputeManifest(ac.SourceDir(configDir), include, ac.UploadFiles(configDir)...) + require.NoError(t, err) + count := 0 + for _, entry := range manifest { + if entry.Path == ".miren/app.toml" { + count++ + hash := sha256.Sum256([]byte(want)) + require.Equal(t, hex.EncodeToString(hash[:]), entry.Hash) + require.Equal(t, int64(len(want)), entry.Size) + require.Equal(t, int32(0644), entry.Mode) + } + } + require.Equal(t, 1, count, "manifest must replace, not duplicate, the default config") + for _, mode := range []string{"full", "delta config", "cached config"} { + var written atomic.Int64 + var archive io.ReadCloser + if mode == "full" { + archive, err = tarx.MakeTar(ac.SourceDir(configDir), include, &written, ac.UploadFiles(configDir)...) + } else { + needed := map[string]bool{".miren/app.toml": mode == "delta config", "main.go": mode == "cached config"} + archive, err = tarx.MakeFilteredTar(ac.SourceDir(configDir), include, needed, &written, ac.UploadFiles(configDir)...) + } + require.NoError(t, err) + gz, err := gzip.NewReader(archive) + require.NoError(t, err) + tr := tar.NewReader(gz) + entries := make(map[string]string) + var totalBytes int64 + for { + header, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + if header.Typeflag != tar.TypeReg { + continue + } + body, err := io.ReadAll(tr) + require.NoError(t, err) + require.NotContains(t, entries, header.Name, "archive must not contain duplicate files") + entries[header.Name] = string(body) + totalBytes += int64(len(body)) + } + require.NoError(t, gz.Close()) + require.NoError(t, archive.Close()) + require.Equal(t, totalBytes, written.Load()) + if mode == "cached config" { + require.Equal(t, map[string]string{"main.go": files["main.go"]}, entries) + } else { + require.Equal(t, want, entries[".miren/app.toml"]) + if mode == "delta config" { + require.Len(t, entries, 1) + } else { + require.Equal(t, files["main.go"], entries["main.go"]) + } + } + } + } + for name, want := range files { + body, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + require.Equal(t, want, string(body), "upload must not modify %s", name) + } + }) + } +} + func TestCloneAddonsConfig(t *testing.T) { for _, tc := range []struct { config string diff --git a/appconfig/source.go b/appconfig/source.go new file mode 100644 index 000000000..3647d8f1d --- /dev/null +++ b/appconfig/source.go @@ -0,0 +1,27 @@ +package appconfig + +import ( + "path/filepath" + + "miren.dev/runtime/pkg/tarx" +) + +// SourceDir resolves the build context relative to the directory containing .miren. +func (ac *AppConfig) SourceDir(configDir string) string { + if ac == nil || ac.Build == nil || ac.Build.SourceRoot == "" { + return configDir + } + if filepath.IsAbs(ac.Build.SourceRoot) { + return filepath.Clean(ac.Build.SourceRoot) + } + return filepath.Join(configDir, ac.Build.SourceRoot) +} + +// UploadFiles projects this app's config into the build context without changing +// the source tree or using another app's config from the source root. +func (ac *AppConfig) UploadFiles(configDir string) []tarx.FileOverride { + if filepath.Clean(ac.SourceDir(configDir)) == filepath.Clean(configDir) { + return nil + } + return []tarx.FileOverride{{Path: AppConfigPath, Source: filepath.Join(configDir, AppConfigPath)}} +} diff --git a/cli/alias_test.go b/cli/alias_test.go index d8ab53fba..29439727e 100644 --- a/cli/alias_test.go +++ b/cli/alias_test.go @@ -1,6 +1,8 @@ package cli import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -9,6 +11,18 @@ import ( "miren.dev/runtime/appconfig" ) +func TestRunUsesNestedAppAliases(t *testing.T) { + dir := t.TempDir() + appDir := filepath.Join(dir, "services", "api") + t.Setenv("HOME", t.TempDir()) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".miren"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(appDir, ".miren"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".miren", "app.toml"), []byte("[aliases]\nwho = 'unknown-command'\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(appDir, ".miren", "app.toml"), []byte("[build]\nsource_root = '../..'\n[aliases]\nwho = 'version'\n"), 0644)) + t.Chdir(appDir) + require.Equal(t, 0, Run([]string{"miren", "who"})) +} + func TestTokenizeCommand(t *testing.T) { tests := []struct { name string diff --git a/cli/commands/app.go b/cli/commands/app.go index 6cb971d28..f310dc029 100644 --- a/cli/commands/app.go +++ b/cli/commands/app.go @@ -38,7 +38,7 @@ type AppCentric struct { clusterPicked bool } -// ResolvedDir returns the directory that should be used as the app source. +// ResolvedDir returns the config root, the directory containing .miren. // When a config is found in a parent directory, this returns that parent // directory instead of the original Dir value. func (a *AppCentric) ResolvedDir() string { @@ -73,7 +73,7 @@ func (a *AppCentric) Validate(glbl *GlobalFlags) error { } if err == nil && ac != nil && configPath != "" { // Config was found — always record the resolved directory. - configDir := filepath.Dir(filepath.Dir(configPath)) // strip .miren/app.toml + configDir := filepath.Dir(filepath.Dir(configPath)) a.resolvedDir = configDir if cwd, wdErr := os.Getwd(); wdErr == nil && configDir != cwd { a.foundInParent = true @@ -120,11 +120,12 @@ func (a *AppCentric) Validate(glbl *GlobalFlags) error { return fmt.Errorf("app is required; pass -a or run from a directory with .miren/app.toml") } else { // No app name from flag or config — try to help the user. + noAppMsg := fmt.Sprintf("no app configuration found at %s — run 'miren init' to get started, or pass -a ", appconfig.AppConfigPath) workDir := a.Dir if workDir == "." || workDir == "" { wd, err := os.Getwd() if err != nil { - return fmt.Errorf("no app configuration found — run 'miren init' to get started, or pass -a ") + return fmt.Errorf("%s", noAppMsg) } workDir = wd } else { @@ -136,14 +137,12 @@ func (a *AppCentric) Validate(glbl *GlobalFlags) error { appName := inferAppName(workDir) - noAppMsg := "no app configuration found — run 'miren init' to get started, or pass -a " - if !a.isInteractive() { return fmt.Errorf("%s", noAppMsg) } confirmed, err := ui.Confirm( - ui.WithMessage(fmt.Sprintf("Looks like this directory isn't set up yet. Run 'miren init' to create app %q here?", appName)), + ui.WithMessage(fmt.Sprintf("No app configuration found at %s. Run 'miren init' to create it for app %q?", appconfig.AppConfigPath, appName)), ui.WithDefault(true), ui.WithIndent(" "), ) diff --git a/cli/commands/deploy.go b/cli/commands/deploy.go index 17d637f3f..59676663c 100644 --- a/cli/commands/deploy.go +++ b/cli/commands/deploy.go @@ -313,7 +313,7 @@ func jsonlExitError(deployErr, streamErr error) error { // app is routable) so the caller can report a partial result on failure. func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, events *deployEventStream) error { name := opts.App - dir := opts.ResolvedDir() + configDir := opts.ResolvedDir() // A nil *deployEventStream must not become a non-nil interface. var healthObs healthObserver @@ -411,7 +411,7 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even } bc := build_v1alpha.NewBuilderClient(cl) - return analyzeApp(ctx, bc, dir) + return analyzeApp(ctx, bc, configDir) } // Handle --version flag: deploy an existing version (skip build) @@ -526,6 +526,12 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even return nil } + ac, err := appconfig.LoadAppConfigUnder(configDir) + if err != nil { + return fmt.Errorf("failed to load app config: %w", err) + } + dir := ac.SourceDir(configDir) + // Set up OTel tracing via the server's telemetry proxy. // Spans are shipped through the existing RPC connection — no client-side // OTLP config needed. If the server isn't reachable, tracing is a no-op. @@ -563,7 +569,7 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even needsConfirm := opts.foundInParent || (!hasSingleCluster && !opts.clusterPicked) if !opts.Yes && !opts.Force && isInteractive && needsConfirm { if opts.foundInParent { - ctx.Printf(" ℹ App config found in parent directory %s\n", dir) + ctx.Printf(" ℹ App config found in parent directory %s\n", configDir) } message := fmt.Sprintf("Deploy app '%s' to cluster '%s'?", name, ctx.ClusterName) confirmed, err := ui.Confirm( @@ -602,7 +608,7 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even serverOwnsDeployment := ephemeralLabel == "" && cl.HasMethodParam(ctx.Context, "buildFromTar", "deployment") // Check if deployment is allowed before proceeding - remedy, err := deploygating.CheckDeployAllowed(dir) + remedy, err := deploygating.CheckDeployAllowed(configDir) if err != nil { if remedy != "" { ctx.Printf("Error: %s\n%s\n\n", err, remedy) @@ -949,11 +955,6 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even // Load AppConfig to get include patterns var includePatterns []string - ac, err := appconfig.LoadAppConfigUnder(dir) - if err != nil { - updateDeploymentOnError(fmt.Sprintf("Failed to load app config: %v", err)) - return err - } if ac != nil && ac.Include != nil { // Validate patterns before using them for _, pattern := range ac.Include { @@ -980,7 +981,8 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even neededPaths map[string]bool ) - manifest, manifestErr := tarx.ComputeManifest(dir, includePatterns) + uploadFiles := ac.UploadFiles(configDir) + manifest, manifestErr := tarx.ComputeManifest(dir, includePatterns, uploadFiles...) if manifestErr == nil { totalFiles = len(manifest) @@ -1038,11 +1040,11 @@ func runDeploy(ctx *Context, opts deployOpts, summary *deployevents.Result, even var uncompressedWritten atomic.Int64 var r io.ReadCloser if useOptimized && len(neededPaths) > 0 { - r, err = tarx.MakeFilteredTar(dir, includePatterns, neededPaths, &uncompressedWritten) + r, err = tarx.MakeFilteredTar(dir, includePatterns, neededPaths, &uncompressedWritten, uploadFiles...) } else if useOptimized { r = tarx.MakeEmptyTar() } else { - r, err = tarx.MakeTar(dir, includePatterns, &uncompressedWritten) + r, err = tarx.MakeTar(dir, includePatterns, &uncompressedWritten, uploadFiles...) } if err != nil { uploadSpan.RecordError(err) @@ -2244,7 +2246,9 @@ func analyzeApp(ctx *Context, bc *build_v1alpha.BuilderClient, dir string) error includePatterns = ac.Include } - r, err := tarx.MakeTar(dir, includePatterns, nil) + configDir := dir + dir = ac.SourceDir(configDir) + r, err := tarx.MakeTar(dir, includePatterns, nil, ac.UploadFiles(configDir)...) if err != nil { return fmt.Errorf("failed to create tar: %w", err) } diff --git a/cli/commands/deploy_targets_test.go b/cli/commands/deploy_targets_test.go index 7615eddf6..b20393191 100644 --- a/cli/commands/deploy_targets_test.go +++ b/cli/commands/deploy_targets_test.go @@ -355,6 +355,34 @@ func TestDeployTargetCommandsMaintainConfig(t *testing.T) { require.Nil(t, dc) } +func TestDeployTargetsStayAtConfigRoot(t *testing.T) { + dir := t.TempDir() + chdir(t, dir) + writeAppToml(t, dir, `name = "default-app"`) + writeDeployToml(t, dir, "[[targets]]\nname = 'default'\ncluster = 'default-cluster'\n") + configDir := filepath.Join(dir, "services", "api") + writeAppToml(t, configDir, "name = 'api-app'\n[build]\nsource_root = '../..'\n") + cfg := clientconfig.NewConfig() + cfg.SetCluster("api-cluster", &clientconfig.ClusterConfig{XID: "api-cluster-id"}) + var output bytes.Buffer + ctx := &Context{Context: t.Context(), Stdout: &output} + require.NoError(t, DeployTargetAdd(ctx, deployTargetAddOpts{ + AppCentric: AppCentric{Dir: configDir, ConfigCentric: ConfigCentric{cfg: cfg}}, + Name: "prod", ClusterName: "api-cluster", + })) + opts := deployOpts{AppCentric: AppCentric{Dir: configDir, ConfigCentric: ConfigCentric{cfg: cfg}}} + require.NoError(t, opts.Validate(nil)) + require.Equal(t, "api-app", opts.App) + require.Equal(t, "api-cluster", opts.targetCluster) + require.NoError(t, DeployTargetRemove(ctx, deployTargetNameOpts{AppCentric: opts.AppCentric, Name: "prod"})) + dc, err := appconfig.LoadDeployConfigUnder(dir) + require.NoError(t, err) + require.Equal(t, "default-cluster", dc.Targets[0].Cluster) + dc, err = appconfig.LoadDeployConfigUnder(configDir) + require.NoError(t, err) + require.Nil(t, dc) +} + func TestDeployTargetAddRequiresNameNonInteractively(t *testing.T) { t.Setenv("CI", "1") var output bytes.Buffer diff --git a/cli/commands/deploy_test.go b/cli/commands/deploy_test.go index e7348ffee..a9fec61d4 100644 --- a/cli/commands/deploy_test.go +++ b/cli/commands/deploy_test.go @@ -1,17 +1,58 @@ package commands import ( + "bytes" "context" + "log/slog" + "os" + "path/filepath" "strings" "sync/atomic" "testing" "time" + "github.com/stretchr/testify/require" + "miren.dev/runtime/api/build/build_v1alpha" "miren.dev/runtime/api/deployment/deployment_v1alpha" "miren.dev/runtime/pkg/progress/upload" "miren.dev/runtime/pkg/rpc" + "miren.dev/runtime/pkg/saga" + buildserver "miren.dev/runtime/servers/build" ) +func TestAnalyzeSourceRoot(t *testing.T) { + dir := t.TempDir() + writeAppToml(t, dir, `[[[`) + for _, app := range []string{"api", "worker"} { + configDir := filepath.Join(dir, "services", app) + writeAppToml(t, configDir, "name = '"+app+"'\n[build]\nsource_root = '../..'\ndockerfile = 'services/"+app+"/Dockerfile'\n") + require.NoError(t, os.WriteFile(filepath.Join(configDir, "Dockerfile"), []byte("FROM alpine\nCMD echo "+app+"\n"), 0644)) + } + for _, tc := range []struct { + app string + cwd string + dir string + }{ + {"api", dir, "services/api"}, + {"worker", filepath.Join(dir, "services", "worker"), "."}, + } { + t.Run(tc.app, func(t *testing.T) { + chdir(t, tc.cwd) + opts := AppCentric{Dir: tc.dir} + require.NoError(t, opts.Validate(nil)) + var output bytes.Buffer + log := slog.New(slog.DiscardHandler) + ctx := &Context{Context: t.Context(), Stdout: &output, Log: log} + server := &buildserver.Builder{Log: log, TempDir: t.TempDir()} + builder := buildserver.NewSagaBuilder(server, saga.NewMemoryStorage(), log) + client := build_v1alpha.NewBuilderClient(rpc.LocalClient(build_v1alpha.AdaptBuilder(builder))) + require.NoError(t, analyzeApp(ctx, client, opts.ResolvedDir())) + require.Contains(t, output.String(), tc.app) + require.Contains(t, output.String(), "services/"+tc.app+"/Dockerfile") + }) + } +} + func TestDeployMessageChecksNewerServerNotJustDeploymentTracking(t *testing.T) { ctx := context.Background() buildClient := rpc.LocalClient(rpc.NewInterface([]rpc.Method{{ diff --git a/cli/commands/localconfig.go b/cli/commands/localconfig.go index 02a5cab54..ec6f1a624 100644 --- a/cli/commands/localconfig.go +++ b/cli/commands/localconfig.go @@ -7,8 +7,8 @@ import ( "miren.dev/runtime/appconfig" ) -// localAppConfig is the .miren/app.toml found by walking up from the working -// directory, loaded at most once per CLI invocation. Alias expansion, per-app +// localAppConfig is the app.toml found by walking up from the working directory, +// loaded at most once per invocation. Alias expansion, per-app // cluster pinning, and app-name defaulting all want it, so the load is // memoized and a broken file is reported by whichever caller hits it first. // Package state rather than a Context field because alias expansion runs diff --git a/cli/commands/localconfig_test.go b/cli/commands/localconfig_test.go index e06bbfd09..fedbcc48c 100644 --- a/cli/commands/localconfig_test.go +++ b/cli/commands/localconfig_test.go @@ -34,6 +34,24 @@ func writeLocalAppConfig(t *testing.T, contents string) { chdir(t, dir) } +func TestSourceRootConfigSelection(t *testing.T) { + dir := t.TempDir() + chdir(t, dir) + writeAppToml(t, dir, `name = "root-app"`) + configDir := filepath.Join(dir, "services", "api") + writeAppToml(t, configDir, "name = 'api-app'\n[build]\nsource_root = '../..'\n") + child := filepath.Join(configDir, "src") + require.NoError(t, os.MkdirAll(child, 0755)) + t.Chdir(child) + for _, configPath := range []string{".", configDir} { + opts := AppCentric{Dir: configPath} + require.NoError(t, opts.Validate(nil)) + require.Equal(t, "api-app", opts.App) + require.Equal(t, configDir, opts.ResolvedDir()) + require.Equal(t, dir, opts.config.SourceDir(opts.ResolvedDir())) + } +} + func TestLocalAppConfig(t *testing.T) { t.Run("broken config warns once across callers", func(t *testing.T) { writeLocalAppConfig(t, "name = 'demo'\npost_import = ''\n") diff --git a/pkg/deploygating/gate.go b/pkg/deploygating/gate.go index 046d5c53f..a509c8d81 100644 --- a/pkg/deploygating/gate.go +++ b/pkg/deploygating/gate.go @@ -36,14 +36,25 @@ func CheckDeployAllowed(dir string) (string, error) { } // Validate appconfig syntax if present - _, err = appconfig.LoadAppConfigUnder(absDir) + ac, err := appconfig.LoadAppConfigUnder(absDir) if err != nil { return fmt.Sprintf("Fix the configuration error in %s.", appconfig.AppConfigPath), fmt.Errorf("invalid app configuration: %w", err) } + sourceDir := ac.SourceDir(absDir) + if sourceDir != absDir { + info, err := os.Stat(sourceDir) + if err != nil { + return "Check build.source_root in .miren/app.toml.", fmt.Errorf("failed to access source root %s: %w", sourceDir, err) + } + if !info.IsDir() { + return "Set build.source_root to a directory.", fmt.Errorf("source root is not a directory: %s", sourceDir) + } + } + // Validate Procfile syntax if present - procfilePath := filepath.Join(absDir, "Procfile") + procfilePath := filepath.Join(sourceDir, "Procfile") _, err = tasks.ParseFile(procfilePath) if err != nil && !errors.Is(err, os.ErrNotExist) { return "Fix the syntax error in Procfile.", diff --git a/pkg/deploygating/gate_test.go b/pkg/deploygating/gate_test.go index feb14bde9..ebe9fa5d5 100644 --- a/pkg/deploygating/gate_test.go +++ b/pkg/deploygating/gate_test.go @@ -10,6 +10,33 @@ import ( ) func TestCheckDeployAllowed(t *testing.T) { + t.Run("checks the app config and source-root Procfile", func(t *testing.T) { + dir := t.TempDir() + configDir := filepath.Join(dir, "services", "api") + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".miren"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(configDir, ".miren"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".miren", "app.toml"), []byte("[[["), 0644)) + path := filepath.Join(configDir, ".miren", "app.toml") + require.NoError(t, os.WriteFile(path, []byte("name = 'api'\n[build]\nsource_root = '../..'\n"), 0644)) + _, err := CheckDeployAllowed(configDir) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "Procfile"), []byte("invalid line without colon"), 0644)) + _, err = CheckDeployAllowed(configDir) + require.ErrorContains(t, err, "failed to parse Procfile") + require.NoError(t, os.Remove(filepath.Join(dir, "Procfile"))) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "file"), []byte("not a directory"), 0644)) + for _, root := range []string{"missing", "file"} { + require.NoError(t, os.WriteFile(path, []byte("[build]\nsource_root = '"+root+"'\n"), 0644)) + remedy, err := CheckDeployAllowed(configDir) + require.ErrorContains(t, err, "source root") + require.Contains(t, remedy, "build.source_root") + } + require.NoError(t, os.WriteFile(path, []byte("[[["), 0644)) + remedy, err := CheckDeployAllowed(configDir) + require.Error(t, err) + require.Contains(t, remedy, ".miren/app.toml") + }) + t.Run("fails when directory does not exist", func(t *testing.T) { remedy, err := CheckDeployAllowed("/non/existent/path") assert.Error(t, err) diff --git a/pkg/tarx/tarx.go b/pkg/tarx/tarx.go index 4cafc7ec2..3f1ddb375 100644 --- a/pkg/tarx/tarx.go +++ b/pkg/tarx/tarx.go @@ -26,6 +26,14 @@ type FileManifest struct { Mode int32 } +// FileOverride adds a local file at Path in the upload, replacing any existing +// entry there. Source is a local filesystem path. Overrides bypass gitignore, +// but filtered archives include them only when their destination is requested. +type FileOverride struct { + Path string + Source string +} + // vcsDirs are version-control metadata directories that never belong in a // build context. jj writes .jj/.gitignore in a main repo but not in a // secondary workspace, so relying on gitignore alone would ship jj's @@ -99,8 +107,8 @@ func ValidatePattern(pattern string) error { return nil } -func MakeTar(dir string, includePatterns []string, uncompressedBytes *atomic.Int64) (io.ReadCloser, error) { - return makeTarWithFilter(dir, includePatterns, func(string) bool { return true }, uncompressedBytes) +func MakeTar(dir string, includePatterns []string, uncompressedBytes *atomic.Int64, overrides ...FileOverride) (io.ReadCloser, error) { + return makeTarWithFilter(dir, includePatterns, func(string) bool { return true }, uncompressedBytes, overrides) } func TarToMap(r io.Reader) (map[string][]byte, error) { @@ -139,7 +147,7 @@ func TarToMap(r io.Reader) (map[string][]byte, error) { // ComputeManifest walks a directory using the same gitignore/include logic as MakeTar // and returns a manifest of all regular files with their SHA-256 hashes. -func ComputeManifest(dir string, includePatterns []string) ([]FileManifest, error) { +func ComputeManifest(dir string, includePatterns []string, overrides ...FileOverride) ([]FileManifest, error) { ignorePatterns, err := parseGitignoreFile(filepath.Join(dir, ".gitignore"), nil) if err != nil { return nil, err @@ -196,6 +204,11 @@ func ComputeManifest(dir string, includePatterns []string) ([]FileManifest, erro if !info.Mode().IsRegular() { return nil } + for _, override := range overrides { + if rp == override.Path { + return nil + } + } hash, hashErr := hashFile(path) if hashErr != nil { @@ -215,6 +228,23 @@ func ComputeManifest(dir string, includePatterns []string) ([]FileManifest, erro return nil, err } + for _, override := range overrides { + info, err := os.Stat(override.Source) + if err != nil { + return nil, err + } + hash, err := hashFile(override.Source) + if err != nil { + return nil, err + } + manifest = append(manifest, FileManifest{ + Path: override.Path, + Hash: hash, + Size: info.Size(), + Mode: int32(info.Mode().Perm()), + }) + } + return manifest, nil } @@ -246,18 +276,28 @@ func MakeEmptyTar() io.ReadCloser { // whose relative paths are in the onlyPaths set. Directory entries are included // as needed to contain the requested files. If uncompressedBytes is non-nil, // each file's uncompressed size is atomically added after it is written. -func MakeFilteredTar(dir string, includePatterns []string, onlyPaths map[string]bool, uncompressedBytes *atomic.Int64) (io.ReadCloser, error) { - return makeTarWithFilter(dir, includePatterns, func(rp string) bool { return onlyPaths[rp] }, uncompressedBytes) +func MakeFilteredTar(dir string, includePatterns []string, onlyPaths map[string]bool, uncompressedBytes *atomic.Int64, overrides ...FileOverride) (io.ReadCloser, error) { + return makeTarWithFilter(dir, includePatterns, func(rp string) bool { return onlyPaths[rp] }, uncompressedBytes, overrides) } // makeTarWithFilter creates a gzipped tar of dir, applying gitignore/include logic, // and only including regular files for which accept returns true. Directory entries // are emitted lazily as needed to contain accepted files. -func makeTarWithFilter(dir string, includePatterns []string, accept func(string) bool, uncompressedBytes *atomic.Int64) (io.ReadCloser, error) { +func makeTarWithFilter(dir string, includePatterns []string, accept func(string) bool, uncompressedBytes *atomic.Int64, overrides []FileOverride) (io.ReadCloser, error) { ignorePatterns, err := parseGitignoreFile(filepath.Join(dir, ".gitignore"), nil) if err != nil { return nil, err } + overrideInfo := make([]os.FileInfo, len(overrides)) + for i, override := range overrides { + if !accept(override.Path) { + continue + } + overrideInfo[i], err = os.Stat(override.Source) + if err != nil { + return nil, err + } + } ignorePatterns = append(ignorePatterns, vcsIgnorePatterns()...) includes := parseStringPatterns(includePatterns) @@ -273,51 +313,7 @@ func makeTarWithFilter(dir string, includePatterns []string, accept func(string) go func() { emittedDirs := make(map[string]bool) - walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - if dir == path { - return nil - } - - rp, _ := filepath.Rel(dir, path) - - if filepath.Base(rp) == ".gitignore" { - return nil - } - - segs := pathSegments(rp) - - isIncluded := len(includes) > 0 && includesMatcher.Match(segs, info.IsDir()) - - if !isIncluded { - if ignoreMatcher.Match(segs, info.IsDir()) { - if info.IsDir() { - return filepath.SkipDir - } - return nil - } - } - - if info.IsDir() { - nestedGitignore := filepath.Join(path, ".gitignore") - more, err := parseGitignoreFile(nestedGitignore, segs) - if err != nil { - return err - } - if more != nil { - ignorePatterns = append(ignorePatterns, more...) - ignoreMatcher = gitignore.NewMatcher(ignorePatterns) - } - return nil - } - - if !accept(rp) { - return nil - } - + writeFile := func(path, rp string, info os.FileInfo) error { // Emit parent directories if not yet emitted parts := strings.Split(filepath.Dir(rp), string(filepath.Separator)) for i := range parts { @@ -366,8 +362,61 @@ func makeTarWithFilter(dir string, includePatterns []string, accept func(string) } return nil + } + + walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if dir == path { + return nil + } + rp, _ := filepath.Rel(dir, path) + if filepath.Base(rp) == ".gitignore" { + return nil + } + segs := pathSegments(rp) + isIncluded := len(includes) > 0 && includesMatcher.Match(segs, info.IsDir()) + if !isIncluded && ignoreMatcher.Match(segs, info.IsDir()) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if info.IsDir() { + more, err := parseGitignoreFile(filepath.Join(path, ".gitignore"), segs) + if err != nil { + return err + } + if more != nil { + ignorePatterns = append(ignorePatterns, more...) + ignoreMatcher = gitignore.NewMatcher(ignorePatterns) + } + return nil + } + if !accept(rp) { + return nil + } + for _, override := range overrides { + if rp == override.Path { + return nil + } + } + return writeFile(path, rp, info) }) + if walkErr == nil { + for i, override := range overrides { + if !accept(override.Path) { + continue + } + if err := writeFile(override.Source, override.Path, overrideInfo[i]); err != nil { + walkErr = err + break + } + } + } + // Close the tar/gzip writers before the pipe so their trailers // flush. Order matters: tw must close before gzw, gzw before w. // On walk error, propagate it through the pipe so the reader From 93fa5835fab026b60552bb9b120e138f125e6f2a Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Wed, 28 Oct 2026 22:07:42 +0000 Subject: [PATCH 2/7] Prefer Elixir releases matching the app name Select a matching declared Mix release for independently deployed umbrella apps. Preserve the first-release fallback and ignore nested application names and keys outside the release list. --- pkg/stackbuild/elixir.go | 28 +++++++++++++++-- pkg/stackbuild/elixir_test.go | 59 ++++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/pkg/stackbuild/elixir.go b/pkg/stackbuild/elixir.go index 131fb4337..9abbfa59d 100644 --- a/pkg/stackbuild/elixir.go +++ b/pkg/stackbuild/elixir.go @@ -55,6 +55,9 @@ var ( mixAppRe = regexp.MustCompile(`\bapp:\s*:([a-z_][a-zA-Z0-9_]*)`) mixReleasesRe = regexp.MustCompile(`\breleases:\s*\[\s*([a-z_][a-zA-Z0-9_]*):`) mixElixirRe = regexp.MustCompile(`\belixir:\s*"([^"]+)"`) + // Only list depth and keyword names matter for literal release lists. + // Skip strings and comments so their brackets and names cannot interfere. + mixReleaseTokenRe = regexp.MustCompile(`"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\n]*|[\[\]]|([a-z_][a-zA-Z0-9_]*):`) ) // ElixirStack implements Stack for Mix projects. It builds a Mix release on a @@ -113,9 +116,30 @@ func (s *ElixirStack) Init(opts BuildOptions) { // A releases: block names the release explicitly, and is the only option // for an umbrella; otherwise mix release uses the OTP app name. - if m := mixReleasesRe.FindSubmatch(s.mixExs); m != nil { - s.releaseName = string(m[1]) + if m := mixReleasesRe.FindSubmatchIndex(s.mixExs); m != nil { + s.releaseName = string(s.mixExs[m[2]:m[3]]) s.namedRelease = true + if opts.Name != "" { + // Prefer the app's declared release, not an application nested in + // another release's options. Keep the first release as the fallback. + start := m[0] + bytes.IndexByte(s.mixExs[m[0]:m[1]], '[') + 1 + depth := 1 + for _, token := range mixReleaseTokenRe.FindAllSubmatch(s.mixExs[start:], -1) { + switch string(token[0]) { + case "[": + depth++ + case "]": + depth-- + default: + if depth == 1 && string(token[1]) == opts.Name { + s.releaseName = opts.Name + } + } + if depth == 0 { + break + } + } + } } else if m := mixAppRe.FindSubmatch(s.mixExs); m != nil && !s.umbrella { s.releaseName = string(m[1]) } diff --git a/pkg/stackbuild/elixir_test.go b/pkg/stackbuild/elixir_test.go index 6a1b0c630..b28a94c90 100644 --- a/pkg/stackbuild/elixir_test.go +++ b/pkg/stackbuild/elixir_test.go @@ -92,14 +92,16 @@ func TestElixirDetect(t *testing.T) { func TestElixirReleaseName(t *testing.T) { cases := []struct { - name string - mixExs string - want string + name string + mixExs string + appName string + want string }{ { - name: "app name", - mixExs: `def project, do: [app: :my_app, version: "0.1.0"]`, - want: "my_app", + name: "app name", + mixExs: `def project, do: [app: :my_app, version: "0.1.0"]`, + appName: "deployment", + want: "my_app", }, { name: "explicit release wins", @@ -118,13 +120,54 @@ end`, mixExs: `def project, do: [apps_path: "apps", deps: [{:credo, "~> 1.7", app: false}]]`, want: "", }, + { + name: "umbrella release matching app is not first", + mixExs: `def project, do: [apps_path: "apps", releases: [ + worker: [applications: [shop: :permanent], cookie: "[worker]"], + # ] shop: [] + shop: [applications: [shop: :permanent, shop_web: :permanent]] +]]`, + appName: "shop", + want: "shop", + }, + { + name: "nested application is not a release", + mixExs: `def project, do: [apps_path: "apps", releases: [ + worker: [applications: [shop: :permanent]], + web: [applications: [shop_web: :permanent]] +]]`, + appName: "shop", + want: "worker", + }, + { + name: "single release with a different app name", + mixExs: `def project, do: [apps_path: "apps", releases: [platform: []]]`, + appName: "shop", + want: "platform", + }, + { + name: "multiple releases without app name keep first", + mixExs: `def project, do: [apps_path: "apps", releases: [worker: [], shop: []]]`, + want: "worker", + }, + { + name: "matching key outside releases is ignored", + mixExs: `def project, do: [apps_path: "apps", releases: [worker: []], aliases: [shop: []]]`, + appName: "shop", + want: "worker", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "mix.exs"), []byte(tc.mixExs), 0o644)) - s := initElixir(t, dir) - assert.Equal(t, tc.want, s.releaseName) + s, err := DetectStack(dir, BuildOptions{Name: tc.appName}) + require.NoError(t, err) + if tc.want == "" { + assert.Empty(t, s.WebCommand()) + } else { + assert.Equal(t, "/app/bin/"+tc.want+" start", s.WebCommand()) + } }) } From 8f878a30ab7576dd44f3d904c30f0856f124d511 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Wed, 28 Oct 2026 22:07:42 +0000 Subject: [PATCH 3/7] Document independent monorepo app deployments Describe service-local .miren directories with shared source roots, separate build and scaling strategies, deployment targets, and CI jobs. Add build.source_root to the app configuration schema. --- docs/docs/app-configuration.md | 12 ++++ docs/docs/deployment.md | 96 ++++++++++++++++++++++++++++++++ docs/static/app-toml.schema.json | 5 +- 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/docs/docs/app-configuration.md b/docs/docs/app-configuration.md index 4036bafa5..edb41e230 100644 --- a/docs/docs/app-configuration.md +++ b/docs/docs/app-configuration.md @@ -104,6 +104,18 @@ version = "3.12" onbuild = ["npm run build"] ``` +For a monorepo app, `build.source_root` selects a shared upload root and build context. The path is relative to the config root (the directory containing `.miren`), not to `.miren` itself: + +```toml title="services/api/.miren/app.toml" +name = "my-api" + +[build] +source_root = "../.." +dockerfile = "services/api/Dockerfile" +``` + +With this configuration, `miren deploy -d services/api` reads the API's local configuration but uploads and builds from the repository root. Dockerfile and top-level `include` paths are relative to that source root. If `source_root` is omitted, the config root is also the source root, preserving the usual behavior. Deployment targets remain in the app's local `.miren/deploy.toml`. See [Monorepos: independent deployment strategies](./deployment.md#monorepos-independent-deployment-strategies) for an API and worker example. + See [Language Guides](./guides/index.md) for build details per language. ### Services diff --git a/docs/docs/deployment.md b/docs/docs/deployment.md index a6807a204..18e6d9d81 100644 --- a/docs/docs/deployment.md +++ b/docs/docs/deployment.md @@ -68,6 +68,92 @@ miren deploy -d path/to/app Miren reads the app name from `.miren/app.toml`. If you haven't set up your project yet, Miren offers to run `miren init` for you. `miren init` creates `app.toml` with the app name derived from your directory, then scans the project for required environment variables and stages whatever it can — generated secrets, read-from-file values, and sensible defaults — on the app's initial config so they're available on the first deploy. See [What `miren init` Does for You](./app-configuration.md#what-miren-init-does-for-you) for the full picture. If this is the first deploy of the app, Miren creates it automatically on the server. +### Monorepos: independent deployment strategies + +In a monorepo, give each independently deployed service its own `.miren` directory and app name. Services can share source code while choosing their own Dockerfile, commands, environment variables, scaling settings, and deployment targets. Unlike defining several services in one `app.toml`, these are separate Miren apps: deploying or rolling back one does not deploy or roll back the others. + +Set `build.source_root` when an app needs source outside its own directory. Miren resolves it relative to the **config root**, the directory containing `.miren`, and uses the resulting **source root** as the upload root and build context. Dockerfile and top-level `include` paths are relative to the source root. Without `source_root`, the config root and source root are the same, as before. + +For example, keep the API and background worker configurations alongside their shared source tree: + +```text +services/ + api/ + .miren/ + app.toml + deploy.toml + Dockerfile + worker/ + .miren/ + app.toml + deploy.toml + Dockerfile +packages/ + shared/ +``` + +The API builds with its own Dockerfile and autoscales with incoming requests: + +```toml title="services/api/.miren/app.toml" +name = "my-api" + +[build] +source_root = "../.." +dockerfile = "services/api/Dockerfile" + +[services.web] +command = "./bin/api" +port = 8080 + +[services.web.concurrency] +mode = "auto" +requests_per_instance = 20 +``` + +The worker builds a different binary and runs two fixed instances: + +```toml title="services/worker/.miren/app.toml" +name = "my-worker" + +[build] +source_root = "../.." +dockerfile = "services/worker/Dockerfile" + +[services.worker] +command = "./bin/worker" + +[services.worker.concurrency] +mode = "fixed" +num_instances = 2 +``` + +Here, `../..` is relative to `services/api/` or `services/worker/`, not to the `.miren` directory itself. Both apps upload and build from the repository root, so their Dockerfiles can copy code from `packages/shared/`. These Dockerfiles should build the corresponding `bin/api` and `bin/worker` executables. + +Deploy from the repository root by selecting the app directory, or change into that directory and run `miren deploy`: + +```miren +miren deploy -d services/api +miren deploy -d services/worker +``` + +The CLI projects the chosen `app.toml` into `.miren/app.toml` at the upload root without changing your repository. The server receives the usual layout and needs no new configuration-selection or path behavior. + +Give the apps their own [deployment targets](#deployment-targets). For example, the API can have a staging-to-production workflow while the worker deploys to a separate jobs cluster. Run these commands from the repository root, using cluster names already configured in your client: + +```miren +miren deploy target add staging api-staging-cluster -d services/api +miren deploy target add prod api-prod-cluster -d services/api +miren deploy target add prod jobs-cluster -d services/worker + +miren deploy -d services/api --target staging +miren deploy -d services/api --target prod +miren deploy -d services/worker --target prod +``` + +Targets stay alongside each app's configuration in `services/api/.miren/deploy.toml` and `services/worker/.miren/deploy.toml`, not at the shared source root. + +Each deploy command deploys only the selected app. CI jobs can use `-d services/api` or `-d services/worker` and run on independent release schedules. If CI uses path-based triggers, include shared-package changes in every dependent service's triggers; selecting an app directory does not determine which source changes affect that app. + ### Deployment targets To keep deployment destinations in the repository, add a target from the app directory: @@ -124,6 +210,16 @@ miren target add qa # add via cluster picker miren target remove qa ``` +Deployment target commands also accept `-d` to manage another app's local targets: + +```miren +miren target add prod my-prod-cluster -d services/api +miren target list -d services/api +miren deploy -d services/api --target prod +``` + +These commands use `services/api/.miren/deploy.toml`, even when `build.source_root` points to the repository root. Other apps' targets are unchanged. + When `deploy.toml` exists, `-C` and `MIREN_CLUSTER` must name one of its targets. Configured cluster names for those targets remain accepted for compatibility. `miren deploy --target prod` also remains supported, but cannot be combined with `-C` or `MIREN_CLUSTER`. Selecting a cluster affects only the current invocation. Per-app cluster pins are no longer read or written. `miren cluster switch` changes the global active cluster; cluster-wide commands ignore `deploy.toml`, even when run in an app directory. diff --git a/docs/static/app-toml.schema.json b/docs/static/app-toml.schema.json index 2f1e2e058..63ed0192e 100644 --- a/docs/static/app-toml.schema.json +++ b/docs/static/app-toml.schema.json @@ -16,7 +16,7 @@ "dir": {"type": "string", "description": "Absolute directory to export and serve.", "pattern": "^/"} } }, - "include": {"type": "array", "description": "Extra files or directories to include in the build context.", "items": {"type": "string"}, "uniqueItems": true}, + "include": {"type": "array", "description": "Extra files or directories to include in the build context, relative to the source root.", "items": {"type": "string"}, "uniqueItems": true}, "concurrency": {"type": "integer", "description": "Legacy global concurrency target. Prefer per-service concurrency.", "minimum": 0, "deprecated": true}, "workload_role": {"type": "string", "description": "App-scoped role used for in-cluster API access.", "default": "app-readonly"}, "web": {"type": "boolean", "description": "Whether Miren may synthesize a long-running web service when none is declared."}, @@ -50,8 +50,9 @@ "type": "object", "additionalProperties": false, "properties": { + "source_root": {"type": "string", "description": "Upload root and build context, relative to the config root (the directory containing .miren). Defaults to the config root when omitted."}, "version": {"type": "string", "description": "Language or runtime version."}, - "dockerfile": {"type": "string", "description": "Path to a custom Dockerfile."}, + "dockerfile": {"type": "string", "description": "Path to a custom Dockerfile, relative to the source root."}, "onbuild": {"type": "array", "description": "Commands run in /app after the main build steps.", "items": {"type": "string"}}, "alpine_image": {"type": "string", "description": "Custom Alpine base image for the runtime stage."}, "secrets": {"type": "array", "description": "Secrets exposed to a Dockerfile build via --mount=type=secret. Never enters an image layer, and BuildKit does not write it to the build log on its own.", "items": {"$ref": "#/$defs/build_secret"}} From c45b92d0f28f0b4d1c3c2e04b23f1a26900edf88 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Wed, 28 Oct 2026 22:51:45 +0000 Subject: [PATCH 4/7] Emit missing parent directories for projected build configs Synthesize directory headers for override parents absent from the source root so the unchanged server extractor can unpack service-local app configs. Round-trip full and filtered uploads through TarFS and exercise analysis without a root .miren directory. --- appconfig/appconfig_test.go | 13 ++++++++++++- cli/commands/deploy_test.go | 1 - pkg/tarx/tarx.go | 14 +++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/appconfig/appconfig_test.go b/appconfig/appconfig_test.go index ddd800bde..1bfd592bd 100644 --- a/appconfig/appconfig_test.go +++ b/appconfig/appconfig_test.go @@ -2,6 +2,7 @@ package appconfig import ( "archive/tar" + "bytes" "compress/gzip" "crypto/sha256" "encoding/hex" @@ -96,7 +97,9 @@ func TestSourceRootUploads(t *testing.T) { archive, err = tarx.MakeFilteredTar(ac.SourceDir(configDir), include, needed, &written, ac.UploadFiles(configDir)...) } require.NoError(t, err) - gz, err := gzip.NewReader(archive) + packed, err := io.ReadAll(archive) + require.NoError(t, err) + gz, err := gzip.NewReader(bytes.NewReader(packed)) require.NoError(t, err) tr := tar.NewReader(gz) entries := make(map[string]string) @@ -129,6 +132,14 @@ func TestSourceRootUploads(t *testing.T) { require.Equal(t, files["main.go"], entries["main.go"]) } } + extracted := t.TempDir() + _, err = tarx.TarFS(bytes.NewReader(packed), extracted) + require.NoError(t, err) + for path, want := range entries { + body, err := os.ReadFile(filepath.Join(extracted, path)) + require.NoError(t, err) + require.Equal(t, want, string(body)) + } } } for name, want := range files { diff --git a/cli/commands/deploy_test.go b/cli/commands/deploy_test.go index a9fec61d4..d1e4472a2 100644 --- a/cli/commands/deploy_test.go +++ b/cli/commands/deploy_test.go @@ -22,7 +22,6 @@ import ( func TestAnalyzeSourceRoot(t *testing.T) { dir := t.TempDir() - writeAppToml(t, dir, `[[[`) for _, app := range []string{"api", "worker"} { configDir := filepath.Join(dir, "services", app) writeAppToml(t, configDir, "name = '"+app+"'\n[build]\nsource_root = '../..'\ndockerfile = 'services/"+app+"/Dockerfile'\n") diff --git a/pkg/tarx/tarx.go b/pkg/tarx/tarx.go index 3f1ddb375..4e2fc6091 100644 --- a/pkg/tarx/tarx.go +++ b/pkg/tarx/tarx.go @@ -326,13 +326,17 @@ func makeTarWithFilter(dir string, includePatterns []string, accept func(string) } emittedDirs[dirPath] = true + dirHdr := &tar.Header{Typeflag: tar.TypeDir, Mode: 0755} dirInfo, dirErr := os.Stat(filepath.Join(dir, dirPath)) - if dirErr != nil { - continue + if dirErr != nil && !os.IsNotExist(dirErr) { + return dirErr } - dirHdr, hdrErr := tar.FileInfoHeader(dirInfo, "") - if hdrErr != nil { - continue + if dirErr == nil { + var hdrErr error + dirHdr, hdrErr = tar.FileInfoHeader(dirInfo, "") + if hdrErr != nil { + return hdrErr + } } dirHdr.Name = dirPath if err := tw.WriteHeader(dirHdr); err != nil { From eef0f819fb6eb9933d60ced6da31ff1584244bc7 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Wed, 28 Oct 2026 22:51:45 +0000 Subject: [PATCH 5/7] Clarify Elixir release selection and existing-app behavior --- docs/docs/guides/elixir.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/docs/guides/elixir.md b/docs/docs/guides/elixir.md index 419d43142..e1009d226 100644 --- a/docs/docs/guides/elixir.md +++ b/docs/docs/guides/elixir.md @@ -162,9 +162,18 @@ as `1.18.4-erlang-27.3.4.18-debian-bookworm-20260918-slim`. ## Umbrella apps Add a `releases:` block to your umbrella's `mix.exs` so Miren knows which release to -build. Each app under `apps/` with its own assets gets them built, with nothing extra to +build. Miren prefers a declared release whose name exactly matches the Miren app's +`name` in `.miren/app.toml`; if none matches, it builds the first declared release. +There is no hyphen-to-underscore conversion: `my-shop` does not match `my_shop`. + +:::warning[Release selection for existing apps] +This preference also applies to existing apps, so an app matching a later release +entry will switch from the first release to that matching release on its next deploy. +::: + +Each app under `apps/` with its own assets gets them built, with nothing extra to configure. A single app without a `releases:` block gets the default release, named -after the app. +after the OTP app. ## Clustering @@ -246,7 +255,7 @@ the release at `/app`, and the web service runs `/app/bin/ start`. - **Detection:** `mix.exs` in the project - **Version:** `[build] version`, else `.tool-versions` / `mise.toml`, else Elixir 1.19 / OTP 28; forms like `1.18`, `1.18.4-otp-27`, or a full bookworm hexpm tag - **Build:** `deps.get`, `deps.compile`, `compile`, `assets.deploy` (if aliased), `release`, all with `MIX_ENV=prod` -- **Release:** the `releases:` entry in `mix.exs`, else the OTP app name; umbrellas need `releases:` +- **Release:** prefer an exact Miren app-name match in `releases:`, else its first entry; no hyphen/underscore normalization. Without `releases:`, use the OTP app name; umbrellas need `releases:` - **Umbrella Phoenix:** npm dependencies and `assets.deploy` aliases under `apps/*` are handled automatically before release - **Start command:** `/app/bin/ start` (version-scoped, IP-named for DNSCluster apps); `PHX_SERVER=true` is set for Phoenix - **Secrets:** `SECRET_KEY_BASE` generated by `miren init`; the release bundles its clustering cookie; set `PHX_HOST` to the route host for LiveView From 9ce1f4c5e95f3cfa5461d6feb9aa15913e7b2810 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Tue, 3 Nov 2026 22:49:20 +0000 Subject: [PATCH 6/7] Reject absolute build source roots Validate source_root as a relative config-root path while retaining parent traversal for monorepos. Reject invalid paths in appconfig rather than adding per-command checks. --- appconfig/appconfig.go | 5 ++++- appconfig/appconfig_test.go | 3 ++- appconfig/source.go | 3 --- docs/docs/app-configuration.md | 2 +- docs/static/app-toml.schema.json | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/appconfig/appconfig.go b/appconfig/appconfig.go index 688feb1e8..4ebfd848f 100644 --- a/appconfig/appconfig.go +++ b/appconfig/appconfig.go @@ -477,8 +477,11 @@ func (ac *AppConfig) Validate() error { } } - // Validate build secrets + // Validate build configuration if ac.Build != nil { + if filepath.IsAbs(ac.Build.SourceRoot) { + return &ValidationError{KeyPath: "build.source_root", Message: "build.source_root must be a relative path"} + } seen := make(map[string]bool, len(ac.Build.Secrets)) for i, bs := range ac.Build.Secrets { if err := bs.validate(fmt.Sprintf("build.secrets[%d]", i)); err != nil { diff --git a/appconfig/appconfig_test.go b/appconfig/appconfig_test.go index 1bfd592bd..b657276dd 100644 --- a/appconfig/appconfig_test.go +++ b/appconfig/appconfig_test.go @@ -29,7 +29,6 @@ func TestSourceRoot(t *testing.T) { {".", configDir}, {"../..", dir}, {"src", filepath.Join(configDir, "src")}, - {dir, dir}, } { t.Run(tc.root, func(t *testing.T) { ac, err := Parse([]byte("[build]\nsource_root = " + strconv.Quote(tc.root))) @@ -43,6 +42,8 @@ func TestSourceRoot(t *testing.T) { var absent *AppConfig require.Equal(t, configDir, absent.SourceDir(configDir)) require.Empty(t, absent.UploadFiles(configDir)) + _, err := Parse([]byte("[build]\nsource_root = " + strconv.Quote(dir))) + require.ErrorContains(t, err, "build.source_root must be a relative path") } func TestSourceRootUploads(t *testing.T) { diff --git a/appconfig/source.go b/appconfig/source.go index 3647d8f1d..d8dafac6e 100644 --- a/appconfig/source.go +++ b/appconfig/source.go @@ -11,9 +11,6 @@ func (ac *AppConfig) SourceDir(configDir string) string { if ac == nil || ac.Build == nil || ac.Build.SourceRoot == "" { return configDir } - if filepath.IsAbs(ac.Build.SourceRoot) { - return filepath.Clean(ac.Build.SourceRoot) - } return filepath.Join(configDir, ac.Build.SourceRoot) } diff --git a/docs/docs/app-configuration.md b/docs/docs/app-configuration.md index edb41e230..53ccab8b3 100644 --- a/docs/docs/app-configuration.md +++ b/docs/docs/app-configuration.md @@ -104,7 +104,7 @@ version = "3.12" onbuild = ["npm run build"] ``` -For a monorepo app, `build.source_root` selects a shared upload root and build context. The path is relative to the config root (the directory containing `.miren`), not to `.miren` itself: +For a monorepo app, `build.source_root` selects a shared upload root and build context. The path must be relative to the config root (the directory containing `.miren`), not to `.miren` itself; absolute paths are rejected: ```toml title="services/api/.miren/app.toml" name = "my-api" diff --git a/docs/static/app-toml.schema.json b/docs/static/app-toml.schema.json index 63ed0192e..091a75297 100644 --- a/docs/static/app-toml.schema.json +++ b/docs/static/app-toml.schema.json @@ -50,7 +50,7 @@ "type": "object", "additionalProperties": false, "properties": { - "source_root": {"type": "string", "description": "Upload root and build context, relative to the config root (the directory containing .miren). Defaults to the config root when omitted."}, + "source_root": {"type": "string", "description": "Upload root and build context. Must be relative to the config root (the directory containing .miren); absolute paths are rejected. Defaults to the config root when omitted."}, "version": {"type": "string", "description": "Language or runtime version."}, "dockerfile": {"type": "string", "description": "Path to a custom Dockerfile, relative to the source root."}, "onbuild": {"type": "array", "description": "Commands run in /app after the main build steps.", "items": {"type": "string"}}, From dd217b5660ab521e82a429830c385ddd37ffbcaf Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Tue, 3 Nov 2026 22:49:20 +0000 Subject: [PATCH 7/7] Normalize Elixir app names and clarify monorepo build inference Map app-name hyphens to underscores when selecting a declared Mix release. Document source-root detection, root Dockerfile precedence, and the remaining reach of relative source paths without adding deploy output. --- docs/docs/deployment.md | 10 ++++++++++ docs/docs/guides/elixir.md | 8 ++++---- pkg/stackbuild/elixir.go | 5 +++-- pkg/stackbuild/elixir_test.go | 6 ++++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/docs/deployment.md b/docs/docs/deployment.md index 18e6d9d81..9ebae3b53 100644 --- a/docs/docs/deployment.md +++ b/docs/docs/deployment.md @@ -74,6 +74,16 @@ In a monorepo, give each independently deployed service its own `.miren` directo Set `build.source_root` when an app needs source outside its own directory. Miren resolves it relative to the **config root**, the directory containing `.miren`, and uses the resulting **source root** as the upload root and build context. Dockerfile and top-level `include` paths are relative to the source root. Without `source_root`, the config root and source root are the same, as before. +:::warning[Review the upload root] +`source_root` must be relative; absolute paths are rejected. Relative paths such as `../../..` can still reach outside your repository: this is not a repository-containment check. Review the configured path and the directory it selects before deploying an unfamiliar repository. +::: + +Without an explicit `build.dockerfile`, build detection runs at the source root, not the service directory. Many monorepos need only `source_root`: Go can select `cmd/`, Rust virtual workspaces use the app name to select the binary, and Elixir prefers a release matching the app name with hyphens converted to underscores. + +:::warning[Root Dockerfiles take precedence] +A `Dockerfile.miren` at the source root takes precedence over stack detection and even an explicit `[services.web] image`. A service sharing that root will use that Dockerfile rather than infer its own build. Set `build.dockerfile` to select the service's Dockerfile explicitly and avoid this ambiguity. +::: + For example, keep the API and background worker configurations alongside their shared source tree: ```text diff --git a/docs/docs/guides/elixir.md b/docs/docs/guides/elixir.md index e1009d226..67bd5d8f3 100644 --- a/docs/docs/guides/elixir.md +++ b/docs/docs/guides/elixir.md @@ -162,9 +162,9 @@ as `1.18.4-erlang-27.3.4.18-debian-bookworm-20260918-slim`. ## Umbrella apps Add a `releases:` block to your umbrella's `mix.exs` so Miren knows which release to -build. Miren prefers a declared release whose name exactly matches the Miren app's -`name` in `.miren/app.toml`; if none matches, it builds the first declared release. -There is no hyphen-to-underscore conversion: `my-shop` does not match `my_shop`. +build. Miren prefers a declared release matching the Miren app's `name` in +`.miren/app.toml`, with hyphens converted to underscores: `my-shop` selects `my_shop`. +If none matches, it builds the first declared release. :::warning[Release selection for existing apps] This preference also applies to existing apps, so an app matching a later release @@ -255,7 +255,7 @@ the release at `/app`, and the web service runs `/app/bin/ start`. - **Detection:** `mix.exs` in the project - **Version:** `[build] version`, else `.tool-versions` / `mise.toml`, else Elixir 1.19 / OTP 28; forms like `1.18`, `1.18.4-otp-27`, or a full bookworm hexpm tag - **Build:** `deps.get`, `deps.compile`, `compile`, `assets.deploy` (if aliased), `release`, all with `MIX_ENV=prod` -- **Release:** prefer an exact Miren app-name match in `releases:`, else its first entry; no hyphen/underscore normalization. Without `releases:`, use the OTP app name; umbrellas need `releases:` +- **Release:** prefer a Miren app-name match in `releases:` after converting hyphens to underscores, else its first entry. Without `releases:`, use the OTP app name; umbrellas need `releases:` - **Umbrella Phoenix:** npm dependencies and `assets.deploy` aliases under `apps/*` are handled automatically before release - **Start command:** `/app/bin/ start` (version-scoped, IP-named for DNSCluster apps); `PHX_SERVER=true` is set for Phoenix - **Secrets:** `SECRET_KEY_BASE` generated by `miren init`; the release bundles its clustering cookie; set `PHX_HOST` to the route host for LiveView diff --git a/pkg/stackbuild/elixir.go b/pkg/stackbuild/elixir.go index 9abbfa59d..4a8a0f529 100644 --- a/pkg/stackbuild/elixir.go +++ b/pkg/stackbuild/elixir.go @@ -120,6 +120,7 @@ func (s *ElixirStack) Init(opts BuildOptions) { s.releaseName = string(s.mixExs[m[2]:m[3]]) s.namedRelease = true if opts.Name != "" { + appRelease := strings.ReplaceAll(opts.Name, "-", "_") // Prefer the app's declared release, not an application nested in // another release's options. Keep the first release as the fallback. start := m[0] + bytes.IndexByte(s.mixExs[m[0]:m[1]], '[') + 1 @@ -131,8 +132,8 @@ func (s *ElixirStack) Init(opts BuildOptions) { case "]": depth-- default: - if depth == 1 && string(token[1]) == opts.Name { - s.releaseName = opts.Name + if depth == 1 && string(token[1]) == appRelease { + s.releaseName = appRelease } } if depth == 0 { diff --git a/pkg/stackbuild/elixir_test.go b/pkg/stackbuild/elixir_test.go index b28a94c90..ea75b9c83 100644 --- a/pkg/stackbuild/elixir_test.go +++ b/pkg/stackbuild/elixir_test.go @@ -130,6 +130,12 @@ end`, appName: "shop", want: "shop", }, + { + name: "hyphenated app selects the underscored release", + mixExs: `def project, do: [apps_path: "apps", releases: [worker: [], my_shop_web: []]]`, + appName: "my-shop-web", + want: "my_shop_web", + }, { name: "nested application is not a release", mixExs: `def project, do: [apps_path: "apps", releases: [