Skip to content

Add symlink/junction support for bundle installs with versioned directories#16408

Open
danegsta wants to merge 7 commits intomainfrom
danegsta/installSymlink
Open

Add symlink/junction support for bundle installs with versioned directories#16408
danegsta wants to merge 7 commits intomainfrom
danegsta/installSymlink

Conversation

@danegsta
Copy link
Copy Markdown
Member

Description

This PR adds support for using symlinks and junctions (reparse points) for managing bundle asset directories (managed/, dcp/) during CLI install and upgrade, replacing the previous in-place update approach with versioned directories and atomic link flipping.

Key changes

Bundle install/upgrade architecture:

  • Bundle payloads are now extracted into versioned directories under versions/<version-id>/
  • Public directories (managed/, dcp/) become reparse points (symlinks preferred, junctions as fallback on Windows without Developer Mode) pointing at the active versioned directory
  • Upgrades atomically flip the reparse points to the new version, enabling safe updates even while the current version's executables are in use
  • Stale versioned directories are cleaned up after successful upgrade; locked directories are renamed for cleanup on the next run

Reparse point infrastructure (ReparsePoint.cs, Win32Constants.cs):

  • New ReparsePoint utility class for creating, replacing, and removing directory symlinks/junctions cross-platform
  • Symlink-preferred strategy with junction fallback on Windows when SeCreateSymbolicLinkPrivilege is not available
  • GetReparseTag() method for distinguishing symlinks from junctions via FSCTL_GET_REPARSE_POINT
  • Full P/Invoke layer for Windows junction creation/removal using DeviceIoControl

Testability refactor (IBundlePayloadProvider):

  • Extracted the static OpenPayload() and s_isBundle from BundleService into an injectable IBundlePayloadProvider interface
  • EmbeddedBundlePayloadProvider preserves existing behavior (reads from assembly manifest resource)
  • ProcessPathOverride property enables tests to simulate different CLI binary versions
  • ExtractPayloadAsync split into instance method (uses provider) and static Stream overload (pure extraction logic)

Comprehensive test coverage:

  • ReparsePointTests: 14 tests covering symlink/junction creation, replacement, removal, migration from junction→symlink, and capability-gated scenarios
  • LayoutDiscoveryReparsePointTests: Validates layout discovery resolves through reparse points
  • BundleServiceIntegrationTests: 6 in-process integration tests exercising the full extract→link→upgrade→cleanup pipeline with synthetic tar.gz payloads
  • BundleServiceTests: Updated existing tests for the new provider-based architecture
  • All junction tests use try/finally cleanup to prevent Directory.Delete(recursive: true) from following junctions and deleting target contents

Validation

  • All 35 bundle-related tests pass locally on Windows (14 unit + 6 integration + 14 reparse point + 1 layout discovery)
  • Manually verified v1→v2 upgrade flow with symlink flipping
  • Manually verified concurrent update while app-host session holds file locks on current version executables
  • Manually verified junction→symlink migration when Developer Mode is enabled after initial install

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
    • No
  • Does the change require an update in our Aspire docs?
    • Yes
    • No

danegsta and others added 2 commits April 23, 2026 10:52
Extract the static OpenPayload() and s_isBundle from BundleService into
an injectable IBundlePayloadProvider interface. This decouples payload
access from the assembly resource, enabling integration tests to inject
synthetic tar.gz payloads and exercise the full extract -> link -> upgrade
-> cleanup pipeline in-process.

Changes:
- Add IBundlePayloadProvider interface (HasPayload, OpenPayload)
- Add EmbeddedBundlePayloadProvider (preserves existing behavior)
- Add ProcessPathOverride property to BundleService for test seam
- Split ExtractPayloadAsync into instance + static(Stream) overloads
- Register IBundlePayloadProvider in Program.cs and CliTestHelper DI
- Add TestBundlePayloadProvider test helper (byte[] -> fresh MemoryStream)
- Add 6 BundleServiceIntegrationTests covering:
  - Fresh extraction with synthetic payload
  - Already-up-to-date short-circuit
  - Version upgrade with link flipping and stale cleanup
  - Stale temp/bad directory cleanup
  - Static ExtractPayloadAsync with strip-components behavior
  - Reparse point replacement on upgrade

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 23, 2026 22:23
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 23, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 16408

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 16408"

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reworks Aspire CLI bundle install/upgrade to use versioned bundle directories and reparse points (symlinks/junctions) so upgrades can switch the active bundle atomically instead of updating in-place. It also refactors bundle payload access behind an injectable provider and adds extensive tests around reparse points and the new extraction/upgrade pipeline.

Changes:

  • Introduce cross-platform ReparsePoint utilities (Windows symlink-with-junction-fallback) and Win32 constants for junction P/Invoke.
  • Update BundleService to extract into versions/<version-id>/ and flip managed/ + dcp/ via reparse points, with cleanup of stale versions.
  • Add IBundlePayloadProvider (+ embedded/test/null implementations) and new unit/integration tests covering extraction, upgrades, and link behaviors.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/Aspire.Cli.Tests/Utils/ReparsePointTests.cs Adds unit tests covering creation/replacement/removal and Windows junction/symlink migration behaviors.
tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs Extends test DI setup with a default no-op IBundlePayloadProvider factory.
tests/Aspire.Cli.Tests/TestServices/TestBundlePayloadProvider.cs Adds a test payload provider that returns a fresh stream per call.
tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs Validates layout discovery works when managed/ and dcp/ are reparse points.
tests/Aspire.Cli.Tests/BundleServiceTests.cs Updates unit tests for provider-based bundle detection and new versioned-layout helpers.
tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs Adds end-to-end tests for extract → link flip → upgrade → cleanup using synthetic tar.gz payloads.
src/Aspire.Cli/Utils/Win32Constants.cs Introduces shared Win32 constants for reparse-point P/Invoke.
src/Aspire.Cli/Utils/ReparsePoint.cs Implements reparse-point creation/replacement/removal and Windows junction P/Invoke + tag detection.
src/Aspire.Cli/Program.cs Registers IBundlePayloadProvider (embedded provider) in DI.
src/Aspire.Cli/Bundles/IBundlePayloadProvider.cs Defines injectable bundle payload provider interface.
src/Aspire.Cli/Bundles/EmbeddedBundlePayloadProvider.cs Implements payload provider backed by an embedded resource.
src/Aspire.Cli/Bundles/BundleService.cs Implements versioned extraction layout, link flipping/rollback, stale version cleanup, and provider-based payload extraction.

Comment thread src/Aspire.Cli/Utils/ReparsePoint.cs Outdated
Comment thread src/Aspire.Cli/Utils/ReparsePoint.cs
Comment thread src/Aspire.Cli/Bundles/BundleService.cs Outdated
danegsta and others added 2 commits April 23, 2026 16:01
… migration

- CreateOrReplace now throws InvalidOperationException when existing path
  is a real directory (not a reparse point), preventing silent recursive
  deletion of directory contents
- WriteMountPointReparseData validates reparseDataLength fits in ushort
  and total buffer size stays within 16KB maximum, throwing
  PathTooLongException for overly long junction targets
- TryFlipLinks always renames legacy directories to .old siblings instead
  of deleting them, deferring cleanup until after post-flip validation
  succeeds to prevent data loss if link creation fails

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The InvalidOperationException guard for existing real directories was
inside the Windows-only branch. On macOS/Linux, CreateOrReplace would
fall through to rename(2) which fails with EISDIR instead. Move the
guard before the platform-specific branches so it applies consistently.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@davidfowl
Copy link
Copy Markdown
Contributor

davidfowl commented Apr 24, 2026

🧪 I tested the dogfood CLI for this PR on Windows.

✅ What worked

  • ✅ The installed CLI reports 13.3.0-pr.16408.gd6c77284, so the dogfood binary matches this PR.
  • ✅ With --channel local, a fresh install produces the expected new layout:
    • versions/ contains a single active versioned directory
    • managed/ becomes a junction into that versioned directory
    • dcp/ becomes a junction into that versioned directory
  • ✅ With that same workaround, an aspire-starter create/start/wait/describe/stop smoke test succeeded, so the new layout works on a clean install.

❌ Issues found

  • ❌ Fresh PR install plus aspire new ... --source <pr hive> fails unless --channel local is also passed.
    • Without it, I consistently hit: No channel found matching 'pr-16408'.
  • ❌ Upgrade scenario: installing the current public CLI first and then upgrading to this PR build did not migrate the existing bundle layout.
    • After upgrade, the bundle marker stayed on the old stable version
    • versions/ was still absent
    • managed/ and dcp/ remained normal directories instead of reparse points

📌 Summary

There look to be two issues here:

  1. ⚠️ PR-hive template creation appears to require --channel local unexpectedly.
  2. ⚠️ Upgrade/migration from the existing on-disk layout to the new versioned/junction layout does not seem to occur.

@danegsta
Copy link
Copy Markdown
Member Author

🧪 I tested the dogfood CLI for this PR on Windows.

✅ What worked

  • ✅ The installed CLI reports 13.3.0-pr.16408.gd6c77284, so the dogfood binary matches this PR.
  • ✅ With --channel local, a fresh install produces the expected new layout:
    • versions/ contains a single active versioned directory
    • managed/ becomes a junction into that versioned directory
    • dcp/ becomes a junction into that versioned directory
  • ✅ With that same workaround, an aspire-starter create/start/wait/describe/stop smoke test succeeded, so the new layout works on a clean install.

❌ Issues found

  • ❌ Fresh PR install plus aspire new ... --source <pr hive> fails unless --channel local is also passed.
    • Without it, I consistently hit: No channel found matching 'pr-16408'.
  • ❌ Upgrade scenario: installing the current public CLI first and then upgrading to this PR build did not migrate the existing bundle layout.
    • After upgrade, the bundle marker stayed on the old stable version
    • versions/ was still absent
    • managed/ and dcp/ remained normal directories instead of reparse points

📌 Summary

There look to be two issues here:

  1. ⚠️ PR-hive template creation appears to require --channel local unexpectedly.
  2. ⚠️ Upgrade/migration from the existing on-disk layout to the new versioned/junction layout does not seem to occur.

I was thinking about the initial upgrade (as well as the potential for needing to downgrade the bundle from the new layout back to the old layout for a time during development to test against stable releases or other PRs). I'm thinking to introduce a bundle symlink pointing to the current version folder (and resolve the dcp and managed folders from there), leaving the top level dcp and managed folders as part of the old layout. That'd mean there'd be no risk of the folders being locked preventing the initial upgrade since we'd be using a fully new folder structure. We'd clean up the old folders with a best effort approach during update.

danegsta and others added 3 commits April 24, 2026 10:14
Replace the two top-level reparse points (managed/ and dcp/) with a
single 'bundle/' reparse point that links directly to the active
versioned directory. Components (managed/, dcp/) are resolved as
subdirectories of the bundle link target.

Layout discovery checks new bundle/ layout first, then falls back to
the legacy managed/+dcp/ layout for backward compatibility. Legacy
top-level managed/ and dcp/ directories are cleaned up best-effort
after successful extraction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CreateLayout.FindDcpPath() was hardcoded to look for NuGet packages
in ~/.nuget/packages. On machines where the global packages folder
is configured elsewhere (e.g. via NUGET_PACKAGES env var or
nuget.config), DCP discovery would fail with 'DCP package not found'.

Now checks the NUGET_PACKAGES environment variable first, falling
back to the default ~/.nuget/packages location.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Instead of manually creating the versioned directory layout and
symlinks/junctions, the localhive scripts now embed the bundle payload
(tar.gz) in the CLI binary via BundlePayloadPath. The CLI's built-in
EnsureExtractedAsync handles versioned layout creation, symlink/junction
management, and cleanup on first run.

All three build modes (native AOT, cross-RID, default dotnet tool) now
produce a CLI binary with an embedded bundle when the bundle build step
is not skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

🎬 CLI E2E Test Recordings — 75 recordings uploaded (commit 5700155)

View all recordings
Status Test Recording
AddPackageInteractiveWhileAppHostRunningDetached ▶️ View Recording
AddPackageWhileAppHostRunningDetached ▶️ View Recording
AgentCommands_AllHelpOutputs_AreCorrect ▶️ View Recording
AgentInitCommand_DefaultSelection_InstallsSkillOnly ▶️ View Recording
AgentInitCommand_MigratesDeprecatedConfig ▶️ View Recording
AspireAddPackageVersionToDirectoryPackagesProps ▶️ View Recording
AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps ▶️ View Recording
Banner_DisplayedOnFirstRun ▶️ View Recording
Banner_DisplayedWithExplicitFlag ▶️ View Recording
Banner_NotDisplayedWithNoLogoFlag ▶️ View Recording
CertificatesClean_RemovesCertificates ▶️ View Recording
CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate ▶️ View Recording
CertificatesTrust_WithUntrustedCert_TrustsCertificate ▶️ View Recording
ConfigSetGet_CreatesNestedJsonFormat ▶️ View Recording
CreateAndRunAspireStarterProject ▶️ View Recording
CreateAndRunAspireStarterProjectWithBundle ▶️ View Recording
CreateAndRunEmptyAppHostProject ▶️ View Recording
CreateAndRunJavaEmptyAppHostProject ▶️ View Recording
CreateAndRunJsReactProject ▶️ View Recording
CreateAndRunPythonReactProject ▶️ View Recording
CreateAndRunTypeScriptEmptyAppHostProject ▶️ View Recording
CreateAndRunTypeScriptStarterProject ▶️ View Recording
CreateJavaAppHostWithViteApp ▶️ View Recording
CreateTypeScriptAppHostWithViteApp_UsesConfiguredToolchain ▶️ View Recording
DashboardRunWithOtelTracesReturnsNoTraces ▶️ View Recording
DeployK8sBasicApiService ▶️ View Recording
DeployK8sWithGarnet ▶️ View Recording
DeployK8sWithMongoDB ▶️ View Recording
DeployK8sWithMySql ▶️ View Recording
DeployK8sWithPostgres ▶️ View Recording
DeployK8sWithRabbitMQ ▶️ View Recording
DeployK8sWithRedis ▶️ View Recording
DeployK8sWithSqlServer ▶️ View Recording
DeployK8sWithValkey ▶️ View Recording
DeployTypeScriptAppToKubernetes ▶️ View Recording
DescribeCommandResolvesReplicaNames ▶️ View Recording
DescribeCommandShowsRunningResources ▶️ View Recording
DetachFormatJsonProducesValidJson ▶️ View Recording
DetachFormatJsonProducesValidJsonWhenRestartingExistingInstance ▶️ View Recording
DoListStepsShowsPipelineSteps ▶️ View Recording
DocsCommand_RendersInteractiveMarkdownFromLocalSource ▶️ View Recording
DoctorCommand_DetectsDeprecatedAgentConfig ▶️ View Recording
DoctorCommand_TypeScriptAppHostReportsMissingConfiguredToolchain ▶️ View Recording
DoctorCommand_WithSslCertDir_ShowsTrusted ▶️ View Recording
DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted ▶️ View Recording
GlobalMigration_HandlesCommentsAndTrailingCommas ▶️ View Recording
GlobalMigration_HandlesMalformedLegacyJson ▶️ View Recording
GlobalMigration_PreservesAllValueTypes ▶️ View Recording
GlobalMigration_SkipsWhenNewConfigExists ▶️ View Recording
GlobalSettings_MigratedFromLegacyFormat ▶️ View Recording
InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot ▶️ View Recording
InvalidAppHostPathWithComments_IsHealedOnRun ▶️ View Recording
LegacySettingsMigration_AdjustsRelativeAppHostPath ▶️ View Recording
LogsCommandShowsResourceLogs ▶️ View Recording
OtelLogsReturnsStructuredLogsFromStarterAppCore ▶️ View Recording
PsCommandListsRunningAppHost ▶️ View Recording
PsFormatJsonOutputsOnlyJsonToStdout ▶️ View Recording
PublishWithConfigureEnvFileUpdatesEnvOutput ▶️ View Recording
PublishWithDockerComposeServiceCallbackSucceeds ▶️ View Recording
PublishWithoutOutputPathUsesAppHostDirectoryDefault ▶️ View Recording
RestoreGeneratesSdkFiles ▶️ View Recording
RestoreGeneratesSdkFiles_WithConfiguredToolchain ▶️ View Recording
RestoreRefreshesGeneratedSdkAfterAddingIntegration ▶️ View Recording
RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes ▶️ View Recording
RunFromParentDirectory_UsesExistingConfigNearAppHost ▶️ View Recording
SecretCrudOnDotNetAppHost ▶️ View Recording
SecretCrudOnTypeScriptAppHost ▶️ View Recording
StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels ▶️ View Recording
StartAndWaitForTypeScriptSqlServerAppHostWithNativeAssets ▶️ View Recording
StopAllAppHostsFromAppHostDirectory ▶️ View Recording
StopAllAppHostsFromUnrelatedDirectory ▶️ View Recording
StopNonInteractiveMultipleAppHostsShowsError ▶️ View Recording
StopNonInteractiveSingleAppHost ▶️ View Recording
StopWithNoRunningAppHostExitsSuccessfully ▶️ View Recording
UnAwaitedChainsCompileWithAutoResolvePromises ▶️ View Recording

📹 Recordings uploaded automatically from CI run #24910736356

@danegsta
Copy link
Copy Markdown
Member Author

@davidfowl okay, updated to use the new folder layout. The first issue identified in the smoke test (Fresh PR install plus aspire new ... --source <pr hive> fails unless --channel local is also passed. Without it, I consistently hit: No channel found matching 'pr-16408'.) isn't related to this change and is existing behavior.

For the second issue I've run multiple smoke tests and been able to successfully update from old to new layout. Main thing is that the bundle doesn't unpack until a command that actually validates the bundle executes the first time so after running the PR install script the bundle won't unpack until a command like aspire run is invoked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants