diff --git a/CHANGELOG.md b/CHANGELOG.md index 22bae8d..784a0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This project follows semantic versioning once stable releases begin. - Clarified across English and Simplified Chinese documentation that 1.1.1 is the latest published version while M2 APIs and examples reflect unreleased `master` work planned for 1.2.0 (#186). - Closed the M2 repository and pull review gaps around custom-CA chains, credential scoping, and configured-index request credentials (#184). +- Completed dependency parity follow-ups for list status, descendant globals, vendored charts, alias staging, and exact lock-file builds (#185). - Normalized packaged root and nested `Chart.yaml` metadata to Helm-compatible LF payloads on every platform while preserving source chart files (#180). - Hardened repository state with secure atomic writes, Helm-compatible cross-platform cache paths, and collision-safe cache identities (#181). - Documented complete packaging, repository, pull, and dependency workflows in English and Simplified Chinese, with compile-checked examples and explicit OCI/provenance boundaries (#141). diff --git a/src/HelmSharp.Action/HelmClient.cs b/src/HelmSharp.Action/HelmClient.cs index 47a8078..76fbc36 100644 --- a/src/HelmSharp.Action/HelmClient.cs +++ b/src/HelmSharp.Action/HelmClient.cs @@ -1062,6 +1062,10 @@ public async Task DependencyUpdateAsync( var output = new StringBuilder(); var resolvedDependencies = new List(chart.Dependencies.Count); var stagedArchives = new List(chart.Dependencies.Count); + var localDependencyNames = chart.Dependencies + .Where(dependency => string.IsNullOrWhiteSpace(dependency.Repository)) + .Select(dependency => dependency.Name) + .ToHashSet(StringComparer.Ordinal); var errors = new List(); try @@ -1079,14 +1083,25 @@ public async Task DependencyUpdateAsync( foreach (var dependency in chart.Dependencies) { cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(dependency.Repository)) - { - errors.Add($"Dependency '{dependency.Name}' does not specify a repository."); - continue; - } - try { + if (string.IsNullOrWhiteSpace(dependency.Repository)) + { + var local = await ResolveVendoredDependencyAsync( + chartPath, + dependency.Name, + dependency.Version, + exactVersion: false, + cancellationToken); + output.AppendLine( + $"Resolved local dependency: {dependency.Name} ({local.Version}) from charts/{dependency.Name}"); + resolvedDependencies.Add(new HelmResolvedDependency( + dependency.Name, + dependency.Version ?? local.Version, + string.Empty)); + continue; + } + var staged = await HelmDependencySource.StageAsync( repo, configuredRepositories, @@ -1099,6 +1114,7 @@ public async Task DependencyUpdateAsync( verifyDigest: true, refreshConfiguredRepository: !request.SkipRepositoryRefresh, requireConfiguredCache: request.SkipRepositoryRefresh, + exactVersion: false, cancellationToken); output.AppendLine( $"Resolved dependency: {dependency.Name} ({staged.Version}) from {dependency.Repository}"); @@ -1129,6 +1145,7 @@ public async Task DependencyUpdateAsync( await InstallStagedDependencyArchivesAsync( chartsDir, stagedArchives, + localDependencyNames, output, cancellationToken); @@ -1169,6 +1186,7 @@ private static async Task FilesHaveSameDigestAsync( private static async Task InstallStagedDependencyArchivesAsync( string chartsDirectory, IReadOnlyList stagedArchives, + IReadOnlySet localDependencyNames, StringBuilder output, CancellationToken cancellationToken) { @@ -1202,6 +1220,10 @@ private static async Task InstallStagedDependencyArchivesAsync( { if (!desiredArchiveNames.Contains(Path.GetFileName(existingArchive))) { + var existingChart = await HelmChartLoader.LoadAsync(existingArchive, cancellationToken); + if (localDependencyNames.Contains(existingChart.Name)) + continue; + File.Delete(existingArchive); output.AppendLine($"Deleted outdated dependency: {existingArchive}"); } @@ -1575,6 +1597,10 @@ public async Task DependencyBuildAsync( Directory.CreateDirectory(stagingDirectory); var output = new StringBuilder(); var stagedArchives = new List(lockFile.Dependencies.Count); + var localDependencyNames = lockFile.Dependencies + .Where(dependency => string.IsNullOrWhiteSpace(dependency.Repository)) + .Select(dependency => dependency.Name) + .ToHashSet(StringComparer.Ordinal); var errors = new List(); try @@ -1592,14 +1618,22 @@ public async Task DependencyBuildAsync( foreach (var dependency in lockFile.Dependencies) { cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(dependency.Repository)) - { - errors.Add($"Dependency '{dependency.Name}' has no repository in Chart.lock."); - continue; - } - try { + if (string.IsNullOrWhiteSpace(dependency.Repository)) + { + await ResolveVendoredDependencyAsync( + chartPath, + dependency.Name, + dependency.Version, + exactVersion: false, + cancellationToken); + output.AppendLine( + $"Using local locked dependency: {dependency.Name} ({dependency.Version}) " + + $"from charts/{dependency.Name}"); + continue; + } + output.AppendLine( $"Downloading locked dependency: {dependency.Name} ({dependency.Version}) " + $"from {dependency.Repository}"); @@ -1615,6 +1649,7 @@ public async Task DependencyBuildAsync( request.VerifyDigests, refreshConfiguredRepository: false, requireConfiguredCache: true, + exactVersion: true, cancellationToken); var archivePath = staged.ArchivePath; if (!File.Exists(archivePath)) @@ -1646,6 +1681,7 @@ await VerifyDependencyArchiveDigestAsync( await InstallStagedDependencyArchivesAsync( chartsDirectory, stagedArchives, + localDependencyNames, output, cancellationToken); output.AppendLine("Dependencies rebuilt from Chart.lock."); @@ -1687,6 +1723,42 @@ private static async Task VerifyDependencyArchiveDigestAsync( } } + private static async Task ResolveVendoredDependencyAsync( + string parentChartPath, + string dependencyName, + string? requestedVersion, + bool exactVersion, + CancellationToken cancellationToken) + { + var dependencyPath = Path.Combine(parentChartPath, "charts", dependencyName); + if (!Directory.Exists(dependencyPath)) + { + throw new DirectoryNotFoundException( + $"Local dependency directory was not found: {dependencyPath}"); + } + + var chart = await HelmChartLoader.LoadAsync(dependencyPath, cancellationToken); + if (!string.Equals(chart.Name, dependencyName, StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Local dependency chart '{chart.Name}' does not match dependency '{dependencyName}'."); + } + + var versionMatches = exactVersion + ? string.Equals(chart.Version, requestedVersion?.Trim(), StringComparison.Ordinal) + : HelmChartVersionResolver.Satisfies(chart.Version, requestedVersion); + if (!versionMatches) + { + var expectation = exactVersion + ? $"locked version '{requestedVersion}'" + : $"constraint '{requestedVersion}'"; + throw new InvalidDataException( + $"Local dependency '{dependencyName}' version '{chart.Version}' does not match {expectation}."); + } + + return chart; + } + public async Task DependencyListAsync( string chartPath, CancellationToken cancellationToken = default) diff --git a/src/HelmSharp.Action/HelmDependencyLockFile.cs b/src/HelmSharp.Action/HelmDependencyLockFile.cs index 2e4bb2c..1356c96 100644 --- a/src/HelmSharp.Action/HelmDependencyLockFile.cs +++ b/src/HelmSharp.Action/HelmDependencyLockFile.cs @@ -182,5 +182,7 @@ private static void AddListIfNotEmpty( }; private static string SerializeScalar(string value) - => HelmYaml.Serialize(value).TrimEnd('\r', '\n'); + => value.Length == 0 + ? "\"\"" + : HelmYaml.Serialize(value).TrimEnd('\r', '\n'); } diff --git a/src/HelmSharp.Action/HelmDependencySource.cs b/src/HelmSharp.Action/HelmDependencySource.cs index a436f04..62040c5 100644 --- a/src/HelmSharp.Action/HelmDependencySource.cs +++ b/src/HelmSharp.Action/HelmDependencySource.cs @@ -19,6 +19,7 @@ public static async Task StageAsync( bool verifyDigest, bool refreshConfiguredRepository, bool requireConfiguredCache, + bool exactVersion, CancellationToken cancellationToken) { if (repositoryReference.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) @@ -29,6 +30,7 @@ public static async Task StageAsync( versionConstraint, repositoryReference, destination, + exactVersion, cancellationToken); } @@ -52,7 +54,8 @@ public static async Task StageAsync( ChartReference = $"{configured.Name}/{dependencyName}", Version = versionConstraint, Destination = destination, - VerifyDigest = verifyDigest + VerifyDigest = verifyDigest, + ExactVersion = exactVersion }; } else @@ -72,7 +75,8 @@ public static async Task StageAsync( RepositoryUrl = repositoryReference, Version = versionConstraint, Destination = destination, - VerifyDigest = verifyDigest + VerifyDigest = verifyDigest, + ExactVersion = exactVersion }; } @@ -93,6 +97,7 @@ private static async Task StageFileDependencyAsync( string? versionConstraint, string repositoryReference, string destination, + bool exactVersion, CancellationToken cancellationToken) { var fileReference = Uri.UnescapeDataString(repositoryReference["file://".Length..]); @@ -110,11 +115,15 @@ private static async Task StageFileDependencyAsync( throw new InvalidDataException( $"File dependency chart '{chart.Name}' does not match dependency '{dependencyName}'."); } - if (!HelmChartVersionResolver.Satisfies(chart.Version, versionConstraint)) + if (exactVersion + ? !string.Equals(chart.Version, versionConstraint?.Trim(), StringComparison.Ordinal) + : !HelmChartVersionResolver.Satisfies(chart.Version, versionConstraint)) { + var expectation = exactVersion + ? $"locked version '{versionConstraint}'" + : $"constraint '{versionConstraint}'"; throw new InvalidDataException( - $"File dependency '{dependencyName}' version '{chart.Version}' does not satisfy " + - $"constraint '{versionConstraint}'."); + $"File dependency '{dependencyName}' version '{chart.Version}' does not match {expectation}."); } var archivePath = await HelmChartPackager.PackageAsync( diff --git a/src/HelmSharp.Action/HelmDependencyStatusInspector.cs b/src/HelmSharp.Action/HelmDependencyStatusInspector.cs index 3f95c92..b949d1a 100644 --- a/src/HelmSharp.Action/HelmDependencyStatusInspector.cs +++ b/src/HelmSharp.Action/HelmDependencyStatusInspector.cs @@ -16,10 +16,7 @@ public static async Task InspectAsync( HelmChartDependency dependency, CancellationToken cancellationToken) { - if (IsDisabled(parent, dependency)) - return "disabled"; - - var expectedVersion = GetExpectedVersion(parent, dependency); + var expectedVersion = dependency.Version; if (Directory.Exists(chartPath)) { var chartsDirectory = Path.Combine(chartPath, "charts"); @@ -190,61 +187,4 @@ private static string InspectChart( : "wrong version"; } - private static string? GetExpectedVersion(HelmChart parent, HelmChartDependency dependency) - { - var lockEntry = parent.LockEntries.FirstOrDefault(entry => - string.Equals(entry.Name, dependency.Name, StringComparison.OrdinalIgnoreCase) && - (string.IsNullOrWhiteSpace(entry.Repository) || - string.IsNullOrWhiteSpace(dependency.Repository) || - string.Equals(entry.Repository, dependency.Repository, StringComparison.Ordinal))); - return string.IsNullOrWhiteSpace(lockEntry?.Version) - ? dependency.Version - : lockEntry.Version; - } - - private static bool IsDisabled(HelmChart parent, HelmChartDependency dependency) - { - if (!dependency.Enabled) - return true; - - if (string.IsNullOrWhiteSpace(dependency.Condition)) - return false; - - var values = HelmYaml.DeserializeDictionary(parent.ValuesYaml); - foreach (var condition in dependency.Condition.Split( - ',', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (TryGetBoolean(values, condition, out var enabled)) - return !enabled; - } - - return false; - } - - private static bool TryGetBoolean( - IReadOnlyDictionary values, - string path, - out bool value) - { - object? current = values; - foreach (var segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries)) - { - if (current is not IReadOnlyDictionary map || - !map.TryGetValue(segment, out current)) - { - value = default; - return false; - } - } - - if (current is bool boolean) - { - value = boolean; - return true; - } - - value = default; - return false; - } } diff --git a/src/HelmSharp.Chart/HelmValues.cs b/src/HelmSharp.Chart/HelmValues.cs index 15307ff..25c7ff0 100644 --- a/src/HelmSharp.Chart/HelmValues.cs +++ b/src/HelmSharp.Chart/HelmValues.cs @@ -95,10 +95,11 @@ public static class HelmValues foreach (var child in node.Children) { - var childOverrides = GetMap(result, child.Identity) - ?? new Dictionary(StringComparer.Ordinal); + var childOverrides = GetMap(result, child.Identity) is { } configured + ? CloneDictionary(configured) + : new Dictionary(StringComparer.Ordinal); + MergeGlobalValues(childOverrides, GetMap(result, "global")); var childValues = BuildChartValues(child.Chart, childOverrides, child); - MergeGlobalValues(childValues, GetMap(result, "global")); result[child.Identity] = childValues; } diff --git a/src/HelmSharp.Repo/HelmChartRepository.cs b/src/HelmSharp.Repo/HelmChartRepository.cs index d5f930c..72d18ee 100644 --- a/src/HelmSharp.Repo/HelmChartRepository.cs +++ b/src/HelmSharp.Repo/HelmChartRepository.cs @@ -509,7 +509,8 @@ private async Task PullTraditionalChartAsync( request.Version, username, password, - cancellationToken); + cancellationToken, + exactVersion: request.ExactVersion); } else if (chartReference.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || chartReference.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) @@ -523,7 +524,8 @@ private async Task PullTraditionalChartAsync( request.Version, username, password, - cancellationToken); + cancellationToken, + exactVersion: request.ExactVersion); } else { @@ -543,7 +545,7 @@ private async Task PullTraditionalChartAsync( password ??= configuredRepository.Password; var indexRepository = CopyRepositoryWithCredentials(configuredRepository, username, password); var index = await LoadConfiguredRepositoryIndexAsync(indexRepository, cancellationToken); - entry = ResolveChartVersion(index, chartName, request.Version); + entry = ResolveChartVersion(index, chartName, request.Version, request.ExactVersion); } var entryUrl = entry.Urls.FirstOrDefault() @@ -688,18 +690,24 @@ private static HelmRepository CopyRepositoryWithCredentials( private static HelmChartVersion ResolveChartVersion( HelmRepoIndex index, string chartName, - string? versionConstraint) + string? versionConstraint, + bool exactVersion = false) { if (!index.Entries.TryGetValue(chartName, out var versions)) throw new InvalidOperationException($"Chart '{chartName}' not found in repository."); - var entry = HelmChartVersionResolver.Resolve(versions, versionConstraint); + var entry = exactVersion + ? versions.FirstOrDefault(candidate => + string.Equals(candidate.Version, versionConstraint?.Trim(), StringComparison.Ordinal)) + : HelmChartVersionResolver.Resolve(versions, versionConstraint); if (entry is not null) return entry; var requested = string.IsNullOrWhiteSpace(versionConstraint) ? "latest stable version" - : $"version constraint '{versionConstraint}'"; + : exactVersion + ? $"exact version '{versionConstraint}'" + : $"version constraint '{versionConstraint}'"; throw new InvalidOperationException( $"Chart '{chartName}' has no version satisfying {requested}. Available versions: " + string.Join(", ", versions.Select(candidate => candidate.Version))); @@ -749,22 +757,11 @@ internal async Task ResolveChartVersionAsync( string? versionConstraint = null, string? username = null, string? password = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool exactVersion = false) { var index = await FetchRepoIndexAsync(repoUrl, username, password, cancellationToken); - if (!index.Entries.TryGetValue(chartName, out var versions)) - throw new InvalidOperationException($"Chart '{chartName}' not found in repository"); - - var entry = HelmChartVersionResolver.Resolve(versions, versionConstraint); - if (entry is not null) - return entry; - - var requested = string.IsNullOrWhiteSpace(versionConstraint) - ? "latest stable version" - : $"version constraint '{versionConstraint}'"; - var available = string.Join(", ", versions.Select(version => version.Version)); - throw new InvalidOperationException( - $"Chart '{chartName}' has no version satisfying {requested}. Available versions: {available}"); + return ResolveChartVersion(index, chartName, versionConstraint, exactVersion); } private async Task PullFromOciAsync( diff --git a/src/HelmSharp.Repo/HelmPullRequest.cs b/src/HelmSharp.Repo/HelmPullRequest.cs index 70d0ef7..d8b3485 100644 --- a/src/HelmSharp.Repo/HelmPullRequest.cs +++ b/src/HelmSharp.Repo/HelmPullRequest.cs @@ -37,4 +37,6 @@ public sealed class HelmPullRequest /// Gets or sets whether repository-provided archive digest validation is requested. The default is . public bool VerifyDigest { get; set; } = true; + + internal bool ExactVersion { get; set; } } diff --git a/tests/HelmSharp.Tests/DependencyListStatusTests.cs b/tests/HelmSharp.Tests/DependencyListStatusTests.cs index 4d7efc9..2ceabe4 100644 --- a/tests/HelmSharp.Tests/DependencyListStatusTests.cs +++ b/tests/HelmSharp.Tests/DependencyListStatusTests.cs @@ -69,19 +69,23 @@ public async Task DependencyListAsync_NoDependenciesMatchesHelmWarning() Assert.Equal(NormalizeNewlines(helmResult.Stdout), NormalizeNewlines(sharpResult.StandardOutput)); } - [Fact] - public async Task DependencyListAsync_LockVersionOverridesPermissiveDeclaration() + [HelmCliFact] + public async Task DependencyListAsync_UsesDeclaredConstraintInsteadOfLockedVersion() { var chartDirectory = CopyFixture("with-lock"); await AddPackagedDependencyAsync(chartDirectory, "archive-ok", "1.2.7"); - var result = await CreateClient().DependencyListAsync(chartDirectory); + var sharpResult = await CreateClient().DependencyListAsync(chartDirectory); + var helmResult = await HelmCliRunner.DependencyListAsync(chartDirectory, CancellationToken.None); - Assert.Equal("wrong version", FindStatus(ParseRows(result.StandardOutput), "archive-ok")); + AssertSuccess("HelmSharp dependency list", sharpResult.ExitCode, sharpResult.StandardError); + AssertSuccess("helm dependency list", helmResult.ExitCode, helmResult.Stderr); + Assert.Equal(ParseRows(helmResult.Stdout), ParseRows(sharpResult.StandardOutput)); + Assert.Equal("ok", FindStatus(ParseRows(sharpResult.StandardOutput), "archive-ok")); } - [Fact] - public async Task DependencyListAsync_RecognizesAliasDirectoryAndDisabledCondition() + [HelmCliFact] + public async Task DependencyListAsync_RecognizesAliasDirectoryAndIgnoresDisabledCondition() { var chartDirectory = Path.Combine(_tempDirectory, "alias-and-condition"); await WriteTextAsync(Path.Combine(chartDirectory, "Chart.yaml"), """ @@ -104,11 +108,15 @@ await WriteTextAsync(Path.Combine(chartDirectory, "values.yaml"), """ """); await AddUnpackedDependencyAsync(chartDirectory, "cache", "alias-target", "4.0.0"); - var result = await CreateClient().DependencyListAsync(chartDirectory); - var rows = ParseRows(result.StandardOutput); + var sharpResult = await CreateClient().DependencyListAsync(chartDirectory); + var helmResult = await HelmCliRunner.DependencyListAsync(chartDirectory, CancellationToken.None); + var rows = ParseRows(sharpResult.StandardOutput); + AssertSuccess("HelmSharp dependency list", sharpResult.ExitCode, sharpResult.StandardError); + AssertSuccess("helm dependency list", helmResult.ExitCode, helmResult.Stderr); + Assert.Equal(ParseRows(helmResult.Stdout), rows); Assert.Equal("unpacked", FindStatus(rows, "alias-target")); - Assert.Equal("disabled", FindStatus(rows, "optional-dep")); + Assert.Equal("missing", FindStatus(rows, "optional-dep")); } [HelmCliFact] diff --git a/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/Chart.yaml b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/Chart.yaml index 7337b21..45e3593 100644 --- a/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/Chart.yaml +++ b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/Chart.yaml @@ -1,3 +1,8 @@ apiVersion: v2 name: grand version: 1.0.0 +dependencies: + - name: great + version: 1.0.0 + repository: file://charts/great + condition: global.descendantsEnabled diff --git a/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/charts/great/Chart.yaml b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/charts/great/Chart.yaml new file mode 100644 index 0000000..c461013 --- /dev/null +++ b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/charts/great/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: great +version: 1.0.0 diff --git a/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/charts/great/templates/configmap.yaml b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/charts/great/templates/configmap.yaml new file mode 100644 index 0000000..b94f96a --- /dev/null +++ b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/charts/great/templates/configmap.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: great diff --git a/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/charts/great/values.yaml b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/charts/great/values.yaml new file mode 100644 index 0000000..6f12cd3 --- /dev/null +++ b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/charts/child/charts/grand/charts/great/values.yaml @@ -0,0 +1 @@ +marker: should-not-render diff --git a/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/values.yaml b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/values.yaml index f3430cf..1d6924f 100644 --- a/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/values.yaml +++ b/tests/HelmSharp.Tests/Fixtures/Charts/dependency-values/values.yaml @@ -1,5 +1,6 @@ global: shared: parent-global + descendantsEnabled: false child: nonbool: ignored enabled: true diff --git a/tests/HelmSharp.Tests/PackagingRepositoryGoldenTests.cs b/tests/HelmSharp.Tests/PackagingRepositoryGoldenTests.cs index 8bc5666..3588d44 100644 --- a/tests/HelmSharp.Tests/PackagingRepositoryGoldenTests.cs +++ b/tests/HelmSharp.Tests/PackagingRepositoryGoldenTests.cs @@ -1226,6 +1226,191 @@ await ReadArchiveSnapshotAsync(helmArchive), await ReadArchiveSnapshotAsync(sharpArchive)); } + [HelmCliFact] + public async Task DependencyUpdateAndBuild_VendoredDependencyWithoutRepositoryMatchesHelm() + { + var sharpChart = await CreateVendoredDependencyParentChartAsync("sharp-vendored-parent"); + var helmChart = await CreateVendoredDependencyParentChartAsync("helm-vendored-parent"); + var client = CreateNoProxyClient("vendored-dependency"); + + var sharpUpdate = await client.DependencyUpdateAsync(sharpChart); + var helmUpdate = await HelmCliRunner.DependencyUpdateAsync(helmChart, CancellationToken.None); + + AssertOperationSucceeded("vendored dependency HelmSharp update", sharpUpdate); + AssertOperationSucceeded("vendored dependency Helm update", helmUpdate); + var helmLock = ReadDependencyLockSnapshot(helmChart); + var sharpLock = ReadDependencyLockSnapshot(sharpChart); + Assert.Equal(helmLock.Digest, sharpLock.Digest); + Assert.Equal(helmLock.Dependencies, sharpLock.Dependencies); + Assert.True(Directory.Exists(Path.Combine(sharpChart, "charts", "local-child"))); + Assert.Empty(Directory.EnumerateFiles(Path.Combine(sharpChart, "charts"), "*.tgz")); + + var sharpLockBeforeBuild = await File.ReadAllBytesAsync(Path.Combine(sharpChart, "Chart.lock")); + var helmLockBeforeBuild = await File.ReadAllBytesAsync(Path.Combine(helmChart, "Chart.lock")); + var sharpBuild = await client.DependencyBuildAsync(sharpChart); + var helmBuild = await HelmCliRunner.DependencyBuildAsync(helmChart, CancellationToken.None); + + AssertOperationSucceeded("vendored dependency HelmSharp build", sharpBuild); + AssertOperationSucceeded("vendored dependency Helm build", helmBuild); + Assert.Equal(sharpLockBeforeBuild, await File.ReadAllBytesAsync(Path.Combine(sharpChart, "Chart.lock"))); + Assert.Equal(helmLockBeforeBuild, await File.ReadAllBytesAsync(Path.Combine(helmChart, "Chart.lock"))); + Assert.True(Directory.Exists(Path.Combine(sharpChart, "charts", "local-child"))); + } + + [HelmCliFact] + public async Task DependencyBuildAsync_VendoredPartialLockRemainsAConstraintLikeHelm() + { + var sharpChart = await CreateVendoredDependencyParentChartAsync( + "sharp-vendored-partial-lock", + "1.0", + "1.0"); + var helmChart = await CreateVendoredDependencyParentChartAsync( + "helm-vendored-partial-lock", + "1.0", + "1.0"); + var client = CreateNoProxyClient("vendored-partial-lock"); + + var sharpUpdate = await client.DependencyUpdateAsync(sharpChart); + var helmUpdate = await HelmCliRunner.DependencyUpdateAsync(helmChart, CancellationToken.None); + AssertOperationSucceeded("vendored partial lock HelmSharp update", sharpUpdate); + AssertOperationSucceeded("vendored partial lock Helm update", helmUpdate); + Assert.Equal("1.0", Assert.Single(ReadDependencyLockSnapshot(sharpChart).Dependencies).Version); + Assert.Equal("1.0", Assert.Single(ReadDependencyLockSnapshot(helmChart).Dependencies).Version); + + await RewriteVendoredDependencyVersionAsync(sharpChart, "1.0.1"); + await RewriteVendoredDependencyVersionAsync(helmChart, "1.0.1"); + var sharpBuild = await client.DependencyBuildAsync(sharpChart); + var helmBuild = await HelmCliRunner.DependencyBuildAsync(helmChart, CancellationToken.None); + + AssertOperationSucceeded("vendored partial lock HelmSharp build", sharpBuild); + AssertOperationSucceeded("vendored partial lock Helm build", helmBuild); + } + + [HelmCliFact] + public async Task DependencyUpdateAndBuild_PreserveLocalArchivesAndDeleteStaleArchivesLikeHelm() + { + var sharpChart = await CreateVendoredDependencyParentChartAsync("sharp-vendored-archive-cleanup"); + var helmChart = await CreateVendoredDependencyParentChartAsync("helm-vendored-archive-cleanup"); + var sharpChartsDirectory = Path.Combine(sharpChart, "charts"); + var helmChartsDirectory = Path.Combine(helmChart, "charts"); + var sharpLocalArchive = await HelmChartPackager.PackageAsync( + Path.Combine(sharpChartsDirectory, "local-child"), + sharpChartsDirectory, + cancellationToken: CancellationToken.None); + var helmLocalArchive = await HelmChartPackager.PackageAsync( + Path.Combine(helmChartsDirectory, "local-child"), + helmChartsDirectory, + cancellationToken: CancellationToken.None); + await PackageDependencyChartVersionAsync("9.9.9", sharpChartsDirectory); + await PackageDependencyChartVersionAsync("9.9.9", helmChartsDirectory); + var sharpStaleArchive = Path.Combine(sharpChartsDirectory, "child-dep-9.9.9.tgz"); + var helmStaleArchive = Path.Combine(helmChartsDirectory, "child-dep-9.9.9.tgz"); + var client = CreateNoProxyClient("vendored-archive-cleanup"); + + var sharpUpdate = await client.DependencyUpdateAsync(sharpChart); + var helmUpdate = await HelmCliRunner.DependencyUpdateAsync(helmChart, CancellationToken.None); + + AssertOperationSucceeded("vendored archive cleanup HelmSharp update", sharpUpdate); + AssertOperationSucceeded("vendored archive cleanup Helm update", helmUpdate); + Assert.True(File.Exists(sharpLocalArchive)); + Assert.True(File.Exists(helmLocalArchive)); + Assert.False(File.Exists(sharpStaleArchive)); + Assert.False(File.Exists(helmStaleArchive)); + + await PackageDependencyChartVersionAsync("9.9.9", sharpChartsDirectory); + await PackageDependencyChartVersionAsync("9.9.9", helmChartsDirectory); + + var sharpBuild = await client.DependencyBuildAsync(sharpChart); + var helmBuild = await HelmCliRunner.DependencyBuildAsync(helmChart, CancellationToken.None); + + AssertOperationSucceeded("vendored archive cleanup HelmSharp build", sharpBuild); + AssertOperationSucceeded("vendored archive cleanup Helm build", helmBuild); + Assert.True(File.Exists(sharpLocalArchive)); + Assert.True(File.Exists(helmLocalArchive)); + Assert.False(File.Exists(sharpStaleArchive)); + Assert.False(File.Exists(helmStaleArchive)); + } + + [HelmCliFact] + public async Task DependencyUpdateAndBuild_DuplicateRepositoryAliasesShareArchiveLikeHelm() + { + var repoDir = Path.Combine(_tempDir, "duplicate-alias-repository"); + Directory.CreateDirectory(repoDir); + await PackageDependencyChartVersionAsync("1.2.3", repoDir); + await using var server = await LocalFileServer.StartAsync(repoDir); + await HelmRepoIndexer.GenerateIndexAsync(repoDir, server.BaseUrl, CancellationToken.None); + var sharpChart = await CreateDuplicateRepositoryAliasParentChartAsync( + "sharp-duplicate-alias-parent", + server.BaseUrl); + var helmChart = await CreateDuplicateRepositoryAliasParentChartAsync( + "helm-duplicate-alias-parent", + server.BaseUrl); + var client = CreateNoProxyClient("duplicate-alias-dependency"); + + var sharpUpdate = await client.DependencyUpdateAsync(sharpChart); + var helmUpdate = await HelmCliRunner.DependencyUpdateAsync(helmChart, CancellationToken.None); + + AssertOperationSucceeded("duplicate alias HelmSharp update", sharpUpdate); + AssertOperationSucceeded("duplicate alias Helm update", helmUpdate); + var helmLock = ReadDependencyLockSnapshot(helmChart); + var sharpLock = ReadDependencyLockSnapshot(sharpChart); + Assert.Equal(helmLock.Digest, sharpLock.Digest); + Assert.Equal(helmLock.Dependencies, sharpLock.Dependencies); + Assert.Single(Directory.EnumerateFiles(Path.Combine(sharpChart, "charts"), "*.tgz")); + + File.Delete(Assert.Single(Directory.EnumerateFiles(Path.Combine(sharpChart, "charts"), "*.tgz"))); + File.Delete(Assert.Single(Directory.EnumerateFiles(Path.Combine(helmChart, "charts"), "*.tgz"))); + var sharpBuild = await client.DependencyBuildAsync(sharpChart); + using var helmHome = HelmCliRunner.CreateHome(); + var repoAdd = await HelmCliRunner.RepoAddAsync("local", server.BaseUrl, helmHome, CancellationToken.None); + AssertOperationSucceeded("helm repo add for duplicate alias build", repoAdd); + var helmBuild = await HelmCliRunner.DependencyBuildAsync(helmChart, helmHome, CancellationToken.None); + + AssertOperationSucceeded("duplicate alias HelmSharp build", sharpBuild); + AssertOperationSucceeded("duplicate alias Helm build", helmBuild); + Assert.Single(Directory.EnumerateFiles(Path.Combine(sharpChart, "charts"), "*.tgz")); + } + + [HelmCliFact] + public async Task DependencyBuildAsync_UsesExactPartialVersionFromLock() + { + var repoDir = Path.Combine(_tempDir, "exact-lock-repository"); + Directory.CreateDirectory(repoDir); + await PackageDependencyChartVersionAsync("1.0", repoDir); + await using var server = await LocalFileServer.StartAsync(repoDir); + await HelmRepoIndexer.GenerateIndexAsync(repoDir, server.BaseUrl, CancellationToken.None); + var sharpChart = await CreateExactLockParentChartAsync("sharp-exact-lock-parent", server.BaseUrl); + var helmChart = await CreateExactLockParentChartAsync("helm-exact-lock-parent", server.BaseUrl); + var client = CreateNoProxyClient("exact-lock-dependency"); + + var sharpUpdate = await client.DependencyUpdateAsync(sharpChart); + var helmUpdate = await HelmCliRunner.DependencyUpdateAsync(helmChart, CancellationToken.None); + AssertOperationSucceeded("exact lock HelmSharp update", sharpUpdate); + AssertOperationSucceeded("exact lock Helm update", helmUpdate); + var sharpLock = ReadDependencyLockSnapshot(sharpChart); + var helmLock = ReadDependencyLockSnapshot(helmChart); + Assert.Equal("1.0", Assert.Single(sharpLock.Dependencies).Version); + Assert.Equal(helmLock.Dependencies, sharpLock.Dependencies); + Assert.Equal(helmLock.Digest, sharpLock.Digest); + + File.Delete(Path.Combine(sharpChart, "charts", "child-dep-1.0.tgz")); + File.Delete(Path.Combine(helmChart, "charts", "child-dep-1.0.tgz")); + await PackageDependencyChartVersionAsync("1.0.1", repoDir); + await HelmRepoIndexer.GenerateIndexAsync(repoDir, server.BaseUrl, CancellationToken.None); + + var sharpBuild = await client.DependencyBuildAsync(sharpChart); + using var helmHome = HelmCliRunner.CreateHome(); + var repoAdd = await HelmCliRunner.RepoAddAsync("local", server.BaseUrl, helmHome, CancellationToken.None); + AssertOperationSucceeded("helm repo add for exact lock build", repoAdd); + var helmBuild = await HelmCliRunner.DependencyBuildAsync(helmChart, helmHome, CancellationToken.None); + + AssertOperationSucceeded("exact lock HelmSharp build", sharpBuild); + AssertOperationSucceeded("exact lock Helm build", helmBuild); + Assert.True(File.Exists(Path.Combine(sharpChart, "charts", "child-dep-1.0.tgz"))); + Assert.False(File.Exists(Path.Combine(sharpChart, "charts", "child-dep-1.0.1.tgz"))); + await AssertDependencyPackageMatchesAsync(helmChart, sharpChart, "1.0"); + } + [Fact] public async Task DependencyUpdateAsync_IsIdempotentAndReplacesStaleConstraintResult() { @@ -1441,7 +1626,7 @@ private async Task PackageDependencyChartVersionAsync(string version, string des var chartDir = await CreateChartAsync($"child-dep-source-{version}", $""" apiVersion: v2 name: child-dep - version: {version} + version: "{version}" appVersion: v2 description: Local dependency chart type: application @@ -1487,6 +1672,78 @@ private async Task CreateAliasedFileParentChartAsync(string directoryNam return chartDir; } + private async Task CreateVendoredDependencyParentChartAsync( + string directoryName, + string versionConstraint = "~1.2.0", + string vendoredVersion = "1.2.3") + { + var chartDir = await CreateChartAsync(directoryName, $""" + apiVersion: v2 + name: vendored-parent + version: 0.1.0 + dependencies: + - name: local-child + alias: vendored + version: "{versionConstraint}" + """); + await WriteTextAsync(Path.Combine(chartDir, "values.yaml"), "parent: true\n"); + await WriteTextAsync(Path.Combine(chartDir, "charts", "local-child", "Chart.yaml"), $""" + apiVersion: v2 + name: local-child + version: "{vendoredVersion}" + """); + await WriteTextAsync( + Path.Combine(chartDir, "charts", "local-child", "values.yaml"), + "marker: vendored\n"); + return chartDir; + } + + private static async Task RewriteVendoredDependencyVersionAsync(string chartPath, string version) + { + await WriteTextAsync(Path.Combine(chartPath, "charts", "local-child", "Chart.yaml"), $""" + apiVersion: v2 + name: local-child + version: "{version}" + """); + } + + private async Task CreateDuplicateRepositoryAliasParentChartAsync( + string directoryName, + string repositoryUrl) + { + var chartDir = await CreateChartAsync(directoryName, $""" + apiVersion: v2 + name: duplicate-alias-parent + version: 0.1.0 + dependencies: + - name: child-dep + alias: cache + version: 1.2.3 + repository: "{repositoryUrl}" + - name: child-dep + alias: session + version: 1.2.3 + repository: "{repositoryUrl}" + """); + await WriteTextAsync(Path.Combine(chartDir, "values.yaml"), "parent: true\n"); + return chartDir; + } + + private async Task CreateExactLockParentChartAsync(string directoryName, string repositoryUrl) + { + var chartDir = await CreateChartAsync(directoryName, $""" + apiVersion: v2 + name: exact-lock-parent + version: 0.1.0 + dependencies: + - name: child-dep + version: "1.0" + repository: "{repositoryUrl}" + """); + await WriteTextAsync(Path.Combine(chartDir, "values.yaml"), "parent: true\n"); + return chartDir; + } + private async Task CreateChartAsync(string directoryName, string chartYaml) { var chartDir = Path.Combine(_tempDir, directoryName); @@ -1669,6 +1926,12 @@ private static string GetSinglePackagePath(string destination) private static HelmClient CreateClient() => new(new StaticHelmOptionsProvider()); + private HelmClient CreateNoProxyClient(string name) + => new( + new StaticHelmOptionsProvider(), + (_, _, _, _) => throw new NotSupportedException(), + () => CreateNoProxyRepository(name)); + private HelmChartRepository CreateNoProxyRepository(string name) { var options = new HelmRepositoryOptions