diff --git a/appconfig/appconfig.go b/appconfig/appconfig.go index 97b9dc49e..4ebfd848f 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 { @@ -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 6a81fbc17..b657276dd 100644 --- a/appconfig/appconfig_test.go +++ b/appconfig/appconfig_test.go @@ -1,15 +1,157 @@ package appconfig import ( + "archive/tar" + "bytes" + "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")}, + } { + 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)) + _, 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) { + 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) + 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) + 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"]) + } + } + 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 { + 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..d8dafac6e --- /dev/null +++ b/appconfig/source.go @@ -0,0 +1,24 @@ +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 + } + 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..d1e4472a2 100644 --- a/cli/commands/deploy_test.go +++ b/cli/commands/deploy_test.go @@ -1,17 +1,57 @@ 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() + 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/docs/docs/app-configuration.md b/docs/docs/app-configuration.md index 4036bafa5..53ccab8b3 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 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" + +[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..9ebae3b53 100644 --- a/docs/docs/deployment.md +++ b/docs/docs/deployment.md @@ -68,6 +68,102 @@ 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. + +:::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 +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 +220,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/docs/guides/elixir.md b/docs/docs/guides/elixir.md index 419d43142..67bd5d8f3 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 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 +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 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/docs/static/app-toml.schema.json b/docs/static/app-toml.schema.json index 2f1e2e058..091a75297 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. 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."}, + "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"}} 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/stackbuild/elixir.go b/pkg/stackbuild/elixir.go index 131fb4337..4a8a0f529 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,31 @@ 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 != "" { + 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 + 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]) == appRelease { + s.releaseName = appRelease + } + } + 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..ea75b9c83 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,60 @@ 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: "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: [ + 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()) + } }) } diff --git a/pkg/tarx/tarx.go b/pkg/tarx/tarx.go index 4cafc7ec2..4e2fc6091 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 { @@ -330,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 { @@ -366,8 +366,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