Repository navigation
Support independent monorepo deployments with source roots - #1412
Conversation
phinze
left a comment
There was a problem hiding this comment.
OK! After that helpful walkthrough yesterday and sitting with it for a bit, I think 🤖 and I found a narrower change that solves for the use case a little more cleanly.
Today, we have one concept of a "root" as expressed by AppCentric.Dir aka --dir.
We can split this concept into two different roots:
- Config root: where
.miren/lives - Source root: the directory the CLI uploads and the server builds from (and what
[build] dockerfileresolves against)
As implemented here, profiles essentially allow independent selection of config file, while keeping both roots bound to --dir.
I think if we decouple the source root instead, we get the same benefits with a simpler change and an easier mental model for users.
If we add [build] source_root to app.toml, that lets .miren dirs sit naturally in subdirs while referencing parents.
So e.g. imagine a Go repo with bigapp at the root and sideservice sharing its module:
repo/
├── go.mod
├── .miren/app.toml # name = "bigapp"
├── cmd/
│ ├── bigapp/main.go
│ └── sideservice/
│ ├── main.go
│ └── .miren/app.toml # name = "sideservice"
└── pkg/shared/...
# cmd/sideservice/.miren/app.toml
name = "sideservice"
[build]
source_root = "../.."The source root becomes the repo root, so the build sees go.mod and pkg/shared. The Go stack already builds ./cmd/<app name> when it finds a cmd/ dir, so it picks ./cmd/sideservice with no further config. miren deploy -d cmd/sideservice works from the root, cd cmd/sideservice && miren deploy works too, and bigapp is unchanged.
Rust works the same way today: with a virtual workspace at the root, the stack falls back to the app name to pick the binary out of target/release/. Elixir umbrellas need a small tweak, since the stack currently builds whichever entry comes first in releases:. Choosing the release that matches the app name would bring it in line. So the app name already does the selecting in two of three stacks.
A layout the stack can't infer just names its Dockerfile, which resolves against source_root:
# services/sideservice/.miren/app.toml
name = "sideservice"
[build]
source_root = "../.."
dockerfile = "services/sideservice/Dockerfile"The neat part about this is it can reuse your tarx.FileOverride impl, we just use it to project .miren/app.toml into the root of the tarball.
So one new config in app.toml, no global anything, multiple deployment roots supported. Whatchya think?
1cb95a4 to
4ea087c
Compare
|
Follow-up to #1412 (review): Reproduced the missing-parent failure by extending TestSourceRootUploads to unpack with the actual tarx.TarFS extractor: the no-default layout failed with ENOENT. Prepared a client-side writer fix that emits a synthetic directory header when an override parent is absent, while retaining existing directory metadata when present. No server changes. The upload tests now verify extracted contents for full and filtered archives, and TestAnalyzeSourceRoot no longer creates a root .miren directory. Elixir app-name matching is intentional as part of the accepted source-root proposal. Updated the guide and quick reference to state the exact-match preference, first-entry fallback, lack of hyphen/underscore normalization, and impact on existing apps matching a later release. Verification: hack/it ./appconfig ./pkg/tarx ./cli/commands -p 1 passed (1,395 tests); make lint passed with zero issues. These follow-ups are local, uncommitted and unpushed, pending author approval to publish. The PR head has not changed. — e + 🤖 |
4ea087c to
888052e
Compare
|
The locally verified follow-ups are now published after rebasing onto main: 01d23eaf adds missing parent-directory headers and extraction regressions; 888052e8 clarifies Elixir matching and existing-app behavior. The post-rebase affected-package suite passed (1,399 tests), and make lint passed again before committing. CI is running on the new head. — e + 🤖 |
phinze
left a comment
There was a problem hiding this comment.
This turned out really nicely! The split between config root and source root reads cleanly through runDeploy. Approving. Three comments inline: the one I would most like to see some movement on (here or in a fast follow) is how far source_root can reach, since right now a repo can point the build context anywhere on the deployer's disk. The other two are a docs paragraph on auto-detection and an Elixir naming nit.
--p+🤖
| if ac == nil || ac.Build == nil || ac.Build.SourceRoot == "" { | ||
| return configDir | ||
| } | ||
| if filepath.IsAbs(ac.Build.SourceRoot) { |
There was a problem hiding this comment.
🤖 Nothing bounds source_root right now. Absolute paths are accepted, so is any number of ../, and gating only checks that the result is a directory. So a cloned repo can set source_root = "/home/you" (or enough ../ to get there), and miren deploy will upload that directory as the build context for a Dockerfile the repo controls. Before this PR the build context was always the directory you pointed -d at.
To be fair about scale: deploying an untrusted repo already means running its code on your cluster. What is new is reach into the deployer's local disk, which seems worth some guard.
How far to go is your call. The cheap end needs no new decisions: reject absolute paths, and print the resolved source root during deploy whenever it differs from the config root, so a surprising ../../../.. is visible. The thorough end is keeping the resolved root inside the enclosing repo. pkg/git already shells out to both git and jj, so finding the root is small, but it brings questions (no VCS, submodules, missing binaries) that might be better as a follow-up issue than as part of this PR.
There was a problem hiding this comment.
I checked this against appconfig/source.go:14-16 and you're right. SourceDir returns filepath.Clean(ac.Build.SourceRoot) for absolute paths and filepath.Join(configDir, ...) for relative ones. Nothing compares the result to configDir, any repo root, or the -d target. I missed this in both earlier reviews, so thanks for catching it. I agree the new risk is reach into the deployer's local disk, not code execution on the cluster. The cheap end you describe looks right for this PR: reject absolute paths, and print the resolved root whenever it differs from the config dir. A repo-containment check can be a follow-up. It's the author's call, but if nothing else lands I'd want at least the visibility line, so a ../../.. can't pass silently.
|
|
||
| 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. |
There was a problem hiding this comment.
🤖 Worth a paragraph here on what happens when an app sets source_root but no dockerfile. Detection now runs against the source root, which cuts both ways.
The good side: stack inference already selects by app name in a few stacks. The Go stack builds ./cmd/<name>, a Rust virtual workspace picks the <name> binary, and Elixir now prefers the <name> release. So many services need nothing beyond source_root, and every example here sets dockerfile, which hides that.
The trap: resolveBuildSource (servers/build/build_saga.go:1019) prefers a Dockerfile.miren at the upload root over stack detection and even over an explicit [services.web] image. If the root app has a Dockerfile.miren, a service pointing source_root at the root quietly builds the root app. Saying so, and that setting dockerfile avoids it, would save someone a confusing deploy.
There was a problem hiding this comment.
I can confirm the precedence. In resolveBuildSource, the osStat(path, "Dockerfile.miren") check comes before both webImageSource(ac) and stackbuild.DetectStack. So a root Dockerfile.miren beats a service's explicit [services.web] image as well as detection. Last round I flagged the auto-detect-from-source-root behaviour in general, but I hadn't noticed it overrides an explicit image too. That makes a docs paragraph more worthwhile. I agree it should cover both sides: name-based inference often works with only source_root, and a root Dockerfile.miren wins unless dockerfile is set.
| case "]": | ||
| depth-- | ||
| default: | ||
| if depth == 1 && string(token[1]) == opts.Name { |
There was a problem hiding this comment.
🤖 Non-blocking, but the docs added in this PR use hyphenated app names (my-api, my-worker), while release keys are atoms like my_api. An umbrella monorepo following those docs will miss this match and quietly fall back to the first release. Normalizing - to _ in opts.Name before comparing seems cheap, and I cannot think of a case where someone would want my-api not to match my_api. If you would rather keep it exact, the docs already say so, so this is a judgment call.
There was a problem hiding this comment.
Last round I treated exact matching as settled, because the author said it was intentional and documented it. You've found a gap I didn't weigh: the same docs use hyphenated names like my-api, and Mix release keys can't contain hyphens, so those examples never get the exact match. Normalizing - to _ on the app-name side before comparing seems low-risk to me. I agree it's a judgment call, and I wouldn't block on it.
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.
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.
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.
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.
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.
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.
4f62021 to
dd217b5
Compare
There was a problem hiding this comment.
🍪 biscuit: ✅ ready to merge — auto-review, non-blocking
This is ready for human review. Since my last review the branch has been rebased onto main: the head moved from 4f62021 to dd217b5, the seven commit messages are the same, and pr_status now reports the PR as mergeable, so the rebase point is settled. I found no new defects in the rebased code.
The server-version gap I raised still stands, and my thread at docs/docs/deployment.md:149 stays open:
UploadFiles(appconfig/source.go:19-24) still copies the service'sapp.tomlinto the upload byte for byte, sobuild.source_rootreaches the server.loadAppConfig(servers/build/build.go:1238) parses that file withappconfig.Parse, which callsDisallowUnknownFields(). A server built before this PR will therefore reject the key.- Line 149 and the PR description still say the server needs no changes.
Either remove the key from the projected copy, or document that this feature needs a server that knows source_root. Either would close the thread.
I'm still not recommending approval, for the same reasons as last time:
- The override path in
makeTarWithFilterruns on every deploy. - The Elixir release preference changes which release existing umbrella apps build.
- The server-compatibility question above still needs a decision.
phinze's threads and my old profile handoff thread are still for people to close.
🍪 full review note · reviewed at dd217b5
Why
A monorepo can contain several independently deployed apps that share source code but need different builds, scaling settings, and deployment targets. Each app can now keep an ordinary
.mirendirectory alongside its service while building from a shared repository root.Approach and tradeoffs
Set
[build] source_root = "../.."inservices/api/.miren/app.toml, then deploy withmiren deploy -d services/apior from that service's directory. The path resolves relative to the directory containing.miren, not to.mirenitself. Dockerfile and include paths use the source root; deployment targets stay service-local. Omittingsource_rootpreserves the existing build context.Absolute source roots are rejected by appconfig validation. Relative parent traversal remains supported and can reach outside the repository; repository confinement is not part of this change. The docs explain that detection runs at the source root and that a root
Dockerfile.mirentakes precedence over detection and an explicit web image unlessbuild.dockerfileselects a Dockerfile explicitly.The client projects the selected app config into the upload-root
.miren/app.toml, including its hash and metadata in delta manifests, without changing repository files. The server receives its usual layout, so no server or RPC configuration-selection changes are needed. This replaces the earlier profile design: there is no--profile,MIREN_PROFILE, or custom early flag parsing.Elixir builds prefer a declared Mix release matching the app name with hyphens converted to underscores, allowing
my-shopto selectmy_shopwhile retaining the first-release fallback. This applies to literal release lists, not arbitrary evaluated Elixir expressions.Verification
hack/it ./appconfig ./cli/... ./pkg/tarx ./pkg/deploygating ./pkg/stackbuild ./servers/build -p 1passed; 2,109 tests reported with seven Docker-dependent skips because Docker was unavailable..mirendirectory. The writer now emits the missing directory header; full and filtered upload tests round-trip through the realTarFSextractor, and CLI-to-server analysis runs without a root.mirenfixture.origin/main,hack/it ./appconfig ./pkg/tarx ./cli/commands -p 1passed: 1,399 tests.make lint: docs lint and both golangci-lint runs passed with zero issues, including a rerun before the latest review-fix commits.go generate ./...produced no further changes.git diff --checkpassed after the follow-ups.Thread: https://ampcode.com/threads/T-90d45f7e-d058-40ca-b3c1-1c78ff4670b1