From 0fedef3861a8a7a0192fe54428a4c06e7ba06d70 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 07:37:06 +0100 Subject: [PATCH 1/7] fix(tower): keep scope dimension select controlled so first selection renders The scoped values field passed `value={undefined}` to Radix Select when the field was empty, putting it in uncontrolled mode. The first selection transitioned the prop from undefined to a string, and the `{stringValue}` children-override path didn't reflect the change, leaving the trigger blank until the next interaction. Pass `?? ''` so the select stays controlled and let `SelectValue` render the matching item's text natively. --- .../tower/data/ScopedValuesField.tsx | 72 +++++++++---------- 1 file changed, 32 insertions(+), 40 deletions(-) diff --git a/src/GroundControl.Tower/src/components/tower/data/ScopedValuesField.tsx b/src/GroundControl.Tower/src/components/tower/data/ScopedValuesField.tsx index fc95555f..6726414c 100644 --- a/src/GroundControl.Tower/src/components/tower/data/ScopedValuesField.tsx +++ b/src/GroundControl.Tower/src/components/tower/data/ScopedValuesField.tsx @@ -64,50 +64,42 @@ export function ScopedValuesField({ { - const stringValue = (dimensionField.value as string | undefined) || undefined; - - return ( - - ); - }} + render={({ field: dimensionField }) => ( + + )} /> { - const stringValue = (valueField.value as string | undefined) || undefined; - - return ( - - ); - }} + render={({ field: valueField }) => ( + + )} /> Date: Sun, 10 May 2026 08:09:00 +0100 Subject: [PATCH 2/7] fix(api): use entry's own scopes for variable interpolation in ResolveAsync --- .../Features/Snapshots/SnapshotResolver.cs | 3 +- .../Snapshots/SnapshotResolverTests.cs | 69 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs b/src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs index 07e4572f..91d5734a 100644 --- a/src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs +++ b/src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs @@ -10,7 +10,6 @@ namespace GroundControl.Api.Features.Snapshots; internal sealed class SnapshotResolver { public const int MaxBsonSizeBytes = 16_777_216; - private static readonly Dictionary EmptyScopes = []; private static readonly JsonSerializerOptions CanonicalJsonOptions = new() { WriteIndented = false }; private readonly IConfigEntryStore _configEntryStore; @@ -81,7 +80,7 @@ public async Task ResolveAsync(Project project, string? d foreach (var scopedValue in plaintextValues) { - var result = _interpolator.Interpolate(scopedValue.Value, EmptyScopes, projectVariablesDict, globalVariablesDict); + var result = _interpolator.Interpolate(scopedValue.Value, scopedValue.Scopes, projectVariablesDict, globalVariablesDict); resolvedValues.Add(new ScopedValue(result.Value, scopedValue.Scopes)); if (result.UsedSensitiveVariable) diff --git a/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs b/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs index e0d1f76d..7b5aace7 100644 --- a/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs +++ b/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs @@ -4,12 +4,14 @@ using GroundControl.Api.Features.Projects.Contracts; using GroundControl.Api.Features.Snapshots; using GroundControl.Api.Features.Templates.Contracts; +using GroundControl.Api.Features.Variables.Contracts; using GroundControl.Api.Shared.Security.Protection; using GroundControl.Persistence.Contracts; using GroundControl.Persistence.Stores; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; +using ScopedValueRequest = GroundControl.Api.Features.ConfigEntries.Contracts.ScopedValueRequest; namespace GroundControl.Api.Tests.Snapshots; @@ -336,6 +338,73 @@ await apiClient.PostAsJsonAsync( jwt.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "prod" && v.Value == "https://prod"); } + [Fact] + public async Task ResolveAsync_InterpolatesVariableUsingEntryOwnScope_WhenEntryHasScopedValues() + { + // Arrange — a scoped variable plus a config entry whose per-scope values reference that + // variable. Each per-scope occurrence of the placeholder must resolve against the entry's + // own scope context, not the variable's unscoped default. + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + + var scopeResponse = await apiClient.PostAsJsonAsync( + "/api/scopes", + new GroundControl.Api.Features.Scopes.Contracts.CreateScopeRequest { Dimension = "Environment", AllowedValues = ["dev", "prod"] }, + WebJsonSerializerOptions, + TestCancellationToken); + scopeResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var project = await CreateProjectAsync(apiClient); + + var variableRequest = new CreateVariableRequest + { + Name = "myVariable", + Scope = VariableScope.Project, + ProjectId = project.Id, + Values = + [ + new Features.Variables.Contracts.ScopedValueRequest { Value = "1 Default" }, + new Features.Variables.Contracts.ScopedValueRequest { Value = "1 Dev", Scopes = new Dictionary { ["Environment"] = "dev" } }, + new Features.Variables.Contracts.ScopedValueRequest { Value = "1 Prod", Scopes = new Dictionary { ["Environment"] = "prod" } }, + ], + }; + var variableResponse = await apiClient.PostAsJsonAsync("/api/variables", variableRequest, WebJsonSerializerOptions, TestCancellationToken); + variableResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var entryRequest = new CreateConfigEntryRequest + { + Key = "MyConfigEntry", + OwnerId = project.Id, + OwnerType = ConfigEntryOwnerType.Project, + ValueType = "String", + IsSensitive = false, + Values = + [ + new ScopedValueRequest { Value = "{{myVariable}}" }, + new ScopedValueRequest { Value = "{{myVariable}}", Scopes = new Dictionary { ["Environment"] = "dev" } }, + new ScopedValueRequest { Value = "{{myVariable}}", Scopes = new Dictionary { ["Environment"] = "prod" } }, + ], + }; + var entryResponse = await apiClient.PostAsJsonAsync("/api/config-entries", entryRequest, WebJsonSerializerOptions, TestCancellationToken); + entryResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var resolver = factory.Services.GetRequiredService(); + var projectStore = factory.Services.GetRequiredService(); + var loaded = await projectStore.GetByIdAsync(project.Id, TestCancellationToken); + loaded.ShouldNotBeNull(); + + // Act + var result = await resolver.ResolveAsync(loaded, description: null, TestCancellationToken); + + // Assert + var entry = result.PlaintextEntries.ShouldHaveSingleItem(); + entry.Key.ShouldBe("MyConfigEntry"); + entry.Values.Count.ShouldBe(3); + entry.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "1 Default"); + entry.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "1 Dev"); + entry.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "prod" && v.Value == "1 Prod"); + } + private static async Task CreateTemplateAsync(HttpClient apiClient) { var request = new CreateTemplateRequest From 4d2e5bcadc809b1fd45f1c5ac9fef486d1c7090f Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 12:17:50 +0100 Subject: [PATCH 3/7] chore: add .planning directory to ignored files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 685902e7..2692f5c7 100644 --- a/.gitignore +++ b/.gitignore @@ -499,6 +499,7 @@ docs/superpowers/ # Project specific files and folders planning/ +.planning/ tmp/ appsettings.local.json samples/GroundControl.Samples.LinkConsole/groundcontrol-cache.json From 7797a8c5b5a3e99f7b153dbbe266a86c1cbf8024 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 12:50:43 +0100 Subject: [PATCH 4/7] feat(api): fan out scoped variables across config entries at snapshot publish config entries that reference a scoped variable via `{{name}}` now produce one resolved tuple per scope the referenced variable touches in the published snapshot, instead of a single unscoped default. fan-out is the dim-space cartesian over the referenced variables' scope dimensions, with explicit literal scope tuples on the entry winning over fan-out emissions for the same final tuple. strict-unresolved policy still blocks publish via 422 when a required target tuple has no resolution. implementation lives behind two new modules: ResolvedEntryBuilder (per-entry orchestrator) and TargetTupleBuilder (dim-space helper). placeholder regex is deduplicated through PlaceholderScanner and the MatchCollection is reused between scan and substitute paths to avoid M extra regex passes per source value. templates merge first, fan-out runs once on the merged entry set. per-entry sensitivity preserved. --- .../Features/Snapshots/PlaceholderScanner.cs | 42 ++ .../Snapshots/ResolvedEntryBuilder.cs | 265 ++++++++++++ .../Features/Snapshots/SnapshotResolver.cs | 28 +- .../Features/Snapshots/SnapshotsModule.cs | 1 + .../Features/Snapshots/TargetTupleBuilder.cs | 137 ++++++ .../Snapshots/VariableInterpolator.cs | 65 ++- .../Snapshots/ResolvedEntryBuilderTests.cs | 311 ++++++++++++++ .../Snapshots/SnapshotResolverTests.cs | 402 ++++++++++++++++++ .../Snapshots/TargetTupleBuilderTests.cs | 128 ++++++ .../VariableInterpolationWorkflow.cs | 162 +++++++ 10 files changed, 1508 insertions(+), 33 deletions(-) create mode 100644 src/GroundControl.Api/Features/Snapshots/PlaceholderScanner.cs create mode 100644 src/GroundControl.Api/Features/Snapshots/ResolvedEntryBuilder.cs create mode 100644 src/GroundControl.Api/Features/Snapshots/TargetTupleBuilder.cs create mode 100644 tests/GroundControl.Api.Tests/Snapshots/ResolvedEntryBuilderTests.cs create mode 100644 tests/GroundControl.Api.Tests/Snapshots/TargetTupleBuilderTests.cs diff --git a/src/GroundControl.Api/Features/Snapshots/PlaceholderScanner.cs b/src/GroundControl.Api/Features/Snapshots/PlaceholderScanner.cs new file mode 100644 index 00000000..8765ad5f --- /dev/null +++ b/src/GroundControl.Api/Features/Snapshots/PlaceholderScanner.cs @@ -0,0 +1,42 @@ +using System.Text.RegularExpressions; + +namespace GroundControl.Api.Features.Snapshots; + +/// +/// Scans a string for {{name}} placeholders and extracts the names. +/// +internal static partial class PlaceholderScanner +{ + /// + /// Gets the regex used to identify {{name}} placeholders. The single capture group is + /// the placeholder name. Exposed to so the substitution + /// path uses the exact same pattern as the scan path. + /// + [GeneratedRegex(@"\{\{(\w+)\}\}")] + internal static partial Regex PlaceholderPattern { get; } + + /// + /// Extracts the placeholder names from a pre-computed match collection. + /// + /// + /// Split from the regex run so a caller (notably ) that fans the + /// same source value out across many target tuples can pass the same + /// to both the scan-for-names step and the per-tuple substitute step in , + /// avoiding M extra regex passes per source value. + /// + public static IReadOnlyList ExtractNames(MatchCollection matches) + { + if (matches.Count == 0) + { + return []; + } + + var names = new List(matches.Count); + foreach (Match match in matches) + { + names.Add(match.Groups[1].Value); + } + + return names; + } +} \ No newline at end of file diff --git a/src/GroundControl.Api/Features/Snapshots/ResolvedEntryBuilder.cs b/src/GroundControl.Api/Features/Snapshots/ResolvedEntryBuilder.cs new file mode 100644 index 00000000..4db45b2f --- /dev/null +++ b/src/GroundControl.Api/Features/Snapshots/ResolvedEntryBuilder.cs @@ -0,0 +1,265 @@ +using System.Collections.Frozen; +using GroundControl.Persistence.Contracts; + +namespace GroundControl.Api.Features.Snapshots; + +/// +/// Fans out a single config entry into the resolved per-scope-tuple values that will be persisted +/// into a snapshot. +/// +/// +/// For each source scoped value on the entry the builder: +/// +/// Scans the value text for {{variableName}} placeholders and resolves each name +/// against the project then global variable lookup (preserving the existing two-tier +/// precedence). +/// Generates the dimension-space (dim-space) cartesian set of target scope tuples via +/// based on the scope dimensions referenced by the resolved +/// variables. +/// Merges each target with the source's own scope tuple (dropping conflicting +/// combinations) and produces a final scope tuple. +/// Calls once per final tuple to substitute the +/// placeholders against the existing scope-aware resolution rules. +/// Within a single source value, deduplicates emissions that produce the same final +/// tuple by retaining the most-specific target. +/// +/// Across source values for the same entry, an emission whose source had a more specific scope +/// tuple wins over a fan-out emission whose source had a less-specific (or empty) one — this is +/// the rule that makes literal scoped values on the entry override fan-out from a default-scoped +/// sibling that references a variable. +/// +/// Sensitivity propagates per-entry: if any sensitive variable contributes to any emission the +/// entry's flag is set. Per-tuple sensitivity is out of +/// scope and intentionally not modeled. +/// +/// Emissions are emitted in canonical order so that the same project state always produces the +/// same snapshot bytes — preserving the diff-hash determinism property the publish-after-preview +/// 409 gate relies on. +/// +internal sealed class ResolvedEntryBuilder +{ + private readonly VariableInterpolator _interpolator; + + public ResolvedEntryBuilder(VariableInterpolator interpolator) + { + _interpolator = interpolator ?? throw new ArgumentNullException(nameof(interpolator)); + } + + /// + /// Builds the fanned-out resolved entry for a single config entry. + /// + /// The entry's source scoped values, with sensitive values already decrypted. + /// Project-level plaintext variables keyed by name (checked first). + /// Global plaintext variables keyed by name (fallback). + /// The resolved values, the unresolved placeholder names, and the sensitivity flag. + public ResolvedEntryBuildResult Build( + IReadOnlyList plaintextValues, + IReadOnlyDictionary projectVariables, + IReadOnlyDictionary globalVariables) + { + ArgumentNullException.ThrowIfNull(plaintextValues); + ArgumentNullException.ThrowIfNull(projectVariables); + ArgumentNullException.ThrowIfNull(globalVariables); + + var entryEmissions = new Dictionary(StringComparer.Ordinal); + var unresolved = new HashSet(StringComparer.OrdinalIgnoreCase); + var usedSensitive = false; + + foreach (var source in plaintextValues) + { + var matches = PlaceholderScanner.PlaceholderPattern.Matches(source.Value); + var placeholderNames = PlaceholderScanner.ExtractNames(matches); + var referencedVariables = ResolveReferencedVariables(placeholderNames, projectVariables, globalVariables); + var targets = referencedVariables.Count == 0 ? [EmptyTuple] : TargetTupleBuilder.Build(referencedVariables); + + var sourceEmissions = new Dictionary(StringComparer.Ordinal); + + foreach (var target in targets) + { + if (!TryMerge(source.Scopes, target, out var final)) + { + continue; + } + + var interpolation = _interpolator.Interpolate(source.Value, matches, final, projectVariables, globalVariables); + if (interpolation.UsedSensitiveVariable) + { + usedSensitive = true; + } + + foreach (var name in interpolation.UnresolvedPlaceholders) + { + unresolved.Add(name); + } + + var key = CanonicalKey(final); + var emission = new Emission(final, interpolation.Value, source.Scopes.Count, target.Count); + + if (!sourceEmissions.TryGetValue(key, out var existing) || emission.TargetSpecificity > existing.TargetSpecificity) + { + sourceEmissions[key] = emission; + } + } + + foreach (var pair in sourceEmissions) + { + if (entryEmissions.TryGetValue(pair.Key, out var existing)) + { + if (Wins(pair.Value, existing)) + { + entryEmissions[pair.Key] = pair.Value; + } + } + else + { + entryEmissions[pair.Key] = pair.Value; + } + } + } + + var values = entryEmissions.Values + .OrderBy(e => CanonicalKey(e.Final), StringComparer.Ordinal) + .Select(emission => new ScopedValue(emission.Value, ToCanonicalScopeDictionary(emission.Final))) + .ToList(); + + return new ResolvedEntryBuildResult + { + Values = values, + UnresolvedPlaceholders = unresolved, + UsedSensitiveVariable = usedSensitive, + }; + } + + private static List ResolveReferencedVariables( + IReadOnlyList placeholderNames, + IReadOnlyDictionary projectVariables, + IReadOnlyDictionary globalVariables) + { + if (placeholderNames.Count == 0) + { + return []; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(placeholderNames.Count); + + foreach (var name in placeholderNames) + { + if (!seen.Add(name)) + { + continue; + } + + if (projectVariables.TryGetValue(name, out var variable) || globalVariables.TryGetValue(name, out variable)) + { + result.Add(variable); + } + } + + return result; + } + + private static bool TryMerge( + Dictionary sourceScope, + IReadOnlyDictionary target, + out Dictionary final) + { + final = new Dictionary(sourceScope.Count + target.Count, StringComparer.OrdinalIgnoreCase); + foreach (var pair in sourceScope) + { + final[pair.Key] = pair.Value; + } + + foreach (var pair in target) + { + if (final.TryGetValue(pair.Key, out var existing)) + { + if (!string.Equals(existing, pair.Value, StringComparison.Ordinal)) + { + return false; + } + + continue; + } + + final[pair.Key] = pair.Value; + } + + return true; + } + + /// + /// Compares two emissions for the same final scope tuple and returns true when + /// should replace . + /// Order: source-scope specificity (more dims wins, encoding the explicit-wins rule), then + /// target-scope specificity as a tiebreaker. When both tie, the incumbent stays — and since + /// the outer loop iterates plaintextValues in store order, that ordering decides the + /// winner. is materialized from a stable persistence order, + /// so the choice is deterministic across resolves. + /// + private static bool Wins(Emission candidate, Emission incumbent) + { + if (candidate.SourceSpecificity > incumbent.SourceSpecificity) + { + return true; + } + + return candidate.SourceSpecificity == incumbent.SourceSpecificity && candidate.TargetSpecificity > incumbent.TargetSpecificity; + } + + private static string CanonicalKey(IReadOnlyDictionary tuple) + { + if (tuple.Count == 0) + { + return string.Empty; + } + + var pairs = tuple.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => $"{p.Key}={p.Value}"); + return string.Join("|", pairs); + } + + /// + /// Builds a scope dictionary with keys inserted in canonical (ordinal-sorted) order. The diff + /// hash computation in already canonicalizes scope keys when + /// hashing, so this sort is defensive for downstream consumers (Tower preview, audit logs, + /// ad-hoc snapshot comparisons) that read directly and would + /// benefit from a stable iteration order across resolves. + /// + private static Dictionary ToCanonicalScopeDictionary(IReadOnlyDictionary tuple) + { + var result = new Dictionary(tuple.Count, StringComparer.OrdinalIgnoreCase); + foreach (var pair in tuple.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + result[pair.Key] = pair.Value; + } + + return result; + } + + private static readonly FrozenDictionary EmptyTuple = FrozenDictionary.Empty; + + private sealed record Emission(IReadOnlyDictionary Final, string Value, int SourceSpecificity, int TargetSpecificity); +} + +/// +/// Output of for a single config entry. +/// +internal sealed record ResolvedEntryBuildResult +{ + /// + /// Gets the resolved scope-tuple/value pairs the snapshot will carry for this entry. + /// + public required IList Values { get; init; } + + /// + /// Gets the placeholder names that could not be resolved against any of the entry's required + /// target tuples. A non-empty set blocks publish under the strict-unresolved policy. + /// + public required IReadOnlySet UnresolvedPlaceholders { get; init; } + + /// + /// Gets a value indicating whether any sensitive variable contributed to any emission. The + /// caller propagates this onto the entry's flag. + /// + public required bool UsedSensitiveVariable { get; init; } +} \ No newline at end of file diff --git a/src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs b/src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs index 91d5734a..372eefa3 100644 --- a/src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs +++ b/src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs @@ -14,20 +14,20 @@ internal sealed class SnapshotResolver private readonly IConfigEntryStore _configEntryStore; private readonly IVariableStore _variableStore; - private readonly VariableInterpolator _interpolator; + private readonly ResolvedEntryBuilder _resolvedEntryBuilder; private readonly SensitiveSourceValueProtector _sourceProtector; private readonly ISnapshotStore _snapshotStore; public SnapshotResolver( IConfigEntryStore configEntryStore, IVariableStore variableStore, - VariableInterpolator interpolator, + ResolvedEntryBuilder resolvedEntryBuilder, SensitiveSourceValueProtector sourceProtector, ISnapshotStore snapshotStore) { _configEntryStore = configEntryStore ?? throw new ArgumentNullException(nameof(configEntryStore)); _variableStore = variableStore ?? throw new ArgumentNullException(nameof(variableStore)); - _interpolator = interpolator ?? throw new ArgumentNullException(nameof(interpolator)); + _resolvedEntryBuilder = resolvedEntryBuilder ?? throw new ArgumentNullException(nameof(resolvedEntryBuilder)); _sourceProtector = sourceProtector ?? throw new ArgumentNullException(nameof(sourceProtector)); _snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore)); } @@ -75,31 +75,19 @@ public async Task ResolveAsync(Project project, string? d foreach (var entry in mergedEntries) { var plaintextValues = _sourceProtector.UnprotectValues(entry.Values, entry.IsSensitive); - var resolvedValues = new List(plaintextValues.Count); - var entryUsedSensitiveVariable = false; + var buildResult = _resolvedEntryBuilder.Build(plaintextValues, projectVariablesDict, globalVariablesDict); - foreach (var scopedValue in plaintextValues) + foreach (var name in buildResult.UnresolvedPlaceholders) { - var result = _interpolator.Interpolate(scopedValue.Value, scopedValue.Scopes, projectVariablesDict, globalVariablesDict); - resolvedValues.Add(new ScopedValue(result.Value, scopedValue.Scopes)); - - if (result.UsedSensitiveVariable) - { - entryUsedSensitiveVariable = true; - } - - foreach (var unresolved in result.UnresolvedPlaceholders) - { - unresolvedPlaceholders.Add(unresolved); - } + unresolvedPlaceholders.Add(name); } resolvedEntries.Add(new ResolvedEntry { Key = entry.Key, ValueType = entry.ValueType, - IsSensitive = entry.IsSensitive || entryUsedSensitiveVariable, - Values = resolvedValues, + IsSensitive = entry.IsSensitive || buildResult.UsedSensitiveVariable, + Values = buildResult.Values, }); } diff --git a/src/GroundControl.Api/Features/Snapshots/SnapshotsModule.cs b/src/GroundControl.Api/Features/Snapshots/SnapshotsModule.cs index c6f0f82f..bddd43e8 100644 --- a/src/GroundControl.Api/Features/Snapshots/SnapshotsModule.cs +++ b/src/GroundControl.Api/Features/Snapshots/SnapshotsModule.cs @@ -7,6 +7,7 @@ internal sealed class SnapshotsModule : IWebApiModule public void OnServiceConfiguration(WebApplicationBuilder builder) { builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/GroundControl.Api/Features/Snapshots/TargetTupleBuilder.cs b/src/GroundControl.Api/Features/Snapshots/TargetTupleBuilder.cs new file mode 100644 index 00000000..e97e679e --- /dev/null +++ b/src/GroundControl.Api/Features/Snapshots/TargetTupleBuilder.cs @@ -0,0 +1,137 @@ +namespace GroundControl.Api.Features.Snapshots; + +/// +/// Generates the dim-space cartesian set of target scope tuples that a config entry value must +/// be resolved against, based on the scope dimensions referenced by the entry's variables. +/// +/// +/// Per-dimension domain is the union of distinct values referenced by the variables, plus an +/// "unspecified" axis (modeled as the dimension being absent from the tuple). The cartesian +/// product across these per-dimension domains yields the targets to materialize. +/// +/// Variables that have only an unscoped default contribute no dimensions; an empty referenced- +/// variable list produces a single empty target tuple, matching the variable's runtime fallback +/// to its default. +/// +internal static class TargetTupleBuilder +{ + /// + /// Builds the target scope tuples for the given referenced variables. + /// + /// Variables that the entry value's placeholders resolve to. + /// + /// A canonically ordered list of target scope tuples. When no dimensions are referenced the + /// list contains a single empty tuple. + /// + public static IReadOnlyList> Build(IReadOnlyCollection referencedVariables) + { + ArgumentNullException.ThrowIfNull(referencedVariables); + + var dimensionValues = CollectDimensionValues(referencedVariables); + if (dimensionValues.Count == 0) + { + return [new Dictionary(StringComparer.OrdinalIgnoreCase)]; + } + + var (dimensions, domains) = BuildPerDimensionDomains(dimensionValues); + return ExpandCartesian(dimensions, domains); + } + + /// + /// Collects the distinct values referenced per scope dimension across every scoped value of + /// every input variable. Returned as with + /// buckets so dimension and value ordering is canonical without an + /// extra sort pass — load-bearing for diff-hash determinism. + /// + private static SortedDictionary> CollectDimensionValues(IReadOnlyCollection referencedVariables) + { + var result = new SortedDictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var variable in referencedVariables) + { + foreach (var scopedValue in variable.Values) + { + foreach (var (dimension, value) in scopedValue.Scopes) + { + if (!result.TryGetValue(dimension, out var values)) + { + result[dimension] = values = new SortedSet(StringComparer.Ordinal); + } + + values.Add(value); + } + } + } + + return result; + } + + /// + /// Materializes the per-dimension domain arrays. Each domain is prefixed with the + /// "unspecified" sentinel () so the cartesian expansion includes the + /// case where the dimension is absent from the resulting tuple. + /// + private static (string[] Dimensions, string?[][] Domains) BuildPerDimensionDomains(SortedDictionary> dimensionValues) + { + var dimensions = new string[dimensionValues.Count]; + var domains = new string?[dimensionValues.Count][]; + var index = 0; + + foreach (var pair in dimensionValues) + { + dimensions[index] = pair.Key; + + var domain = new string?[pair.Value.Count + 1]; + domain[0] = null; + var domainIndex = 1; + foreach (var value in pair.Value) + { + domain[domainIndex++] = value; + } + + domains[index] = domain; + index++; + } + + return (dimensions, domains); + } + + private static List> ExpandCartesian(string[] dimensions, string?[][] domains) + { + // Tracked as long so a pathological combination doesn't silently overflow the int capacity + // hint. The 16MB BSON guard on the snapshot publisher catches runaway expansion long before + // we'd ever reach int.MaxValue tuples; this is just to keep the capacity hint sane. + var totalCount = domains.Aggregate(1, (current, domain) => current * domain.Length); + + var capacity = totalCount is > 0 and <= int.MaxValue ? (int)totalCount : 0; + var results = capacity > 0 ? new List>(capacity) : []; + var current = new string?[dimensions.Length]; + + Recurse(0, dimensions, domains, current, results); + return results; + } + + private static void Recurse(int depth, string[] dimensions, string?[][] domains, string?[] current, List> results) + { + if (depth == dimensions.Length) + { + var tuple = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < dimensions.Length; i++) + { + if (current[i] is { } value) + { + tuple[dimensions[i]] = value; + } + } + + results.Add(tuple); + return; + } + + foreach (var value in domains[depth]) + { + current[depth] = value; + Recurse(depth + 1, dimensions, domains, current, results); + } + } +} \ No newline at end of file diff --git a/src/GroundControl.Api/Features/Snapshots/VariableInterpolator.cs b/src/GroundControl.Api/Features/Snapshots/VariableInterpolator.cs index 64544398..034a35a9 100644 --- a/src/GroundControl.Api/Features/Snapshots/VariableInterpolator.cs +++ b/src/GroundControl.Api/Features/Snapshots/VariableInterpolator.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.RegularExpressions; using GroundControl.Api.Shared.Resolvers; using GroundControl.Persistence.Contracts; @@ -8,7 +9,7 @@ namespace GroundControl.Api.Features.Snapshots; /// Resolves {{variableName}} placeholders in config entry values using a two-tier /// variable system (project-level first, then global) with scope-aware resolution. /// -internal sealed partial class VariableInterpolator +internal sealed class VariableInterpolator { private readonly IScopeResolver _scopeResolver; @@ -34,7 +35,27 @@ public InterpolationResult Interpolate( IReadOnlyDictionary projectVariables, IReadOnlyDictionary globalVariables) { - var matches = PlaceholderPattern.Matches(value); + var matches = PlaceholderScanner.PlaceholderPattern.Matches(value); + return Interpolate(value, matches, clientScopes, projectVariables, globalVariables); + } + + /// + /// Overload that accepts a pre-computed match collection so callers that fan out the same + /// source value across many client scopes can scan once and reuse the matches per tuple, + /// avoiding M extra regex passes for an entry that produces M target tuples. + /// + /// The raw config entry value the matches were taken from. + /// Matches produced by against . + /// The client's scope dimension-value pairs for resolution. + /// Project-level plaintext variables keyed by variable name (checked first). + /// Global plaintext variables keyed by variable name (fallback). + public InterpolationResult Interpolate( + string value, + MatchCollection matches, + IReadOnlyDictionary clientScopes, + IReadOnlyDictionary projectVariables, + IReadOnlyDictionary globalVariables) + { if (matches.Count == 0) { return new InterpolationResult { Value = value, UnresolvedPlaceholders = [], UsedSensitiveVariable = false }; @@ -42,12 +63,19 @@ public InterpolationResult Interpolate( var unresolved = new List(); var usedSensitive = false; - var result = PlaceholderPattern.Replace(value, match => + var builder = new StringBuilder(value.Length); + var lastIndex = 0; + + foreach (Match match in matches) { - var name = match.Groups[1].Value; + if (match.Index > lastIndex) + { + builder.Append(value, lastIndex, match.Index - lastIndex); + } - // Two-tier lookup: project variables take priority over global + var name = match.Groups[1].Value; var resolved = TryResolve(name, projectVariables, clientScopes) ?? TryResolve(name, globalVariables, clientScopes); + if (resolved is not null) { if (resolved.IsSensitive) @@ -55,14 +83,28 @@ public InterpolationResult Interpolate( usedSensitive = true; } - return resolved.Value; + builder.Append(resolved.Value); + } + else + { + unresolved.Add(name); + builder.Append(match.Value); } - unresolved.Add(name); - return match.Value; - }); + lastIndex = match.Index + match.Length; + } + + if (lastIndex < value.Length) + { + builder.Append(value, lastIndex, value.Length - lastIndex); + } - return new InterpolationResult { Value = result, UnresolvedPlaceholders = unresolved, UsedSensitiveVariable = usedSensitive }; + return new InterpolationResult + { + Value = builder.ToString(), + UnresolvedPlaceholders = unresolved, + UsedSensitiveVariable = usedSensitive + }; } private ResolvedVariable? TryResolve(string variableName, IReadOnlyDictionary variables, IReadOnlyDictionary clientScopes) @@ -76,9 +118,6 @@ public InterpolationResult Interpolate( return scopedValue is null ? null : new ResolvedVariable(scopedValue.Value, variable.IsSensitive); } - [GeneratedRegex(@"\{\{(\w+)\}\}")] - private static partial Regex PlaceholderPattern { get; } - private sealed record ResolvedVariable(string Value, bool IsSensitive); } diff --git a/tests/GroundControl.Api.Tests/Snapshots/ResolvedEntryBuilderTests.cs b/tests/GroundControl.Api.Tests/Snapshots/ResolvedEntryBuilderTests.cs new file mode 100644 index 00000000..fe248fe4 --- /dev/null +++ b/tests/GroundControl.Api.Tests/Snapshots/ResolvedEntryBuilderTests.cs @@ -0,0 +1,311 @@ +using GroundControl.Api.Features.Snapshots; +using GroundControl.Api.Shared.Resolvers; +using GroundControl.Persistence.Contracts; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; + +namespace GroundControl.Api.Tests.Snapshots; + +public sealed class ResolvedEntryBuilderTests +{ + private readonly ResolvedEntryBuilder _sut; + + public ResolvedEntryBuilderTests() + { + var scopeResolver = new ScopeResolver(NullLogger.Instance); + var interpolator = new VariableInterpolator(scopeResolver); + _sut = new ResolvedEntryBuilder(interpolator); + } + + [Fact] + public void Build_SingleVariableWithScopedValues_FansOutEmissionsPerScope() + { + // Arrange — scopeless entry referencing a variable that has per-environment values. + var variable = CreateVariable( + new ScopedValue("default value", []), + new ScopedValue("dev value", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("prod value", new Dictionary { ["Environment"] = "prod" })); + + var projectVariables = new Dictionary { ["myVar"] = variable }; + IReadOnlyList source = [new ScopedValue("{{myVar}}", [])]; + + // Act + var result = _sut.Build(source, projectVariables, EmptyLookup); + + // Assert — three emissions: dev, prod, unscoped default. + result.Values.Count.ShouldBe(3); + result.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "default value"); + result.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "dev value"); + result.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "prod" && v.Value == "prod value"); + result.UnresolvedPlaceholders.ShouldBeEmpty(); + result.UsedSensitiveVariable.ShouldBeFalse(); + } + + [Fact] + public void Build_TwoVariablesSharedDimension_CombinesByDimensionValueWithoutCartesianMismatch() + { + // Arrange — both variables on Environment. The expected behavior is one emission per + // distinct env value with each variable resolved correctly for that env. + var first = CreateVariable( + new ScopedValue("first-default", []), + new ScopedValue("first-dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("first-prod", new Dictionary { ["Environment"] = "prod" })); + + var second = CreateVariable( + new ScopedValue("second-default", []), + new ScopedValue("second-dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("second-prod", new Dictionary { ["Environment"] = "prod" })); + + var projectVariables = new Dictionary + { + ["first"] = first, + ["second"] = second, + }; + IReadOnlyList source = [new ScopedValue("{{first}}/{{second}}", [])]; + + // Act + var result = _sut.Build(source, projectVariables, EmptyLookup); + + // Assert — three emissions, each with both vars resolved against the same env value. + result.Values.Count.ShouldBe(3); + result.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "first-default/second-default"); + result.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "first-dev/second-dev"); + result.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "prod" && v.Value == "first-prod/second-prod"); + } + + [Fact] + public void Build_TwoVariablesDisjointDimensions_FansOutAcrossCartesianOfDimensions() + { + // Arrange — env-scoped and region-scoped variables with defaults. Cartesian = 4 cells: + // (dev,us), (dev,unspecified), (unspecified,us), (unspecified,unspecified). + var envVar = CreateVariable( + new ScopedValue("env-default", []), + new ScopedValue("env-dev", new Dictionary { ["Environment"] = "dev" })); + + var regionVar = CreateVariable( + new ScopedValue("region-default", []), + new ScopedValue("region-us", new Dictionary { ["Region"] = "us" })); + + var projectVariables = new Dictionary + { + ["env"] = envVar, + ["region"] = regionVar, + }; + IReadOnlyList source = [new ScopedValue("{{env}}-{{region}}", [])]; + + // Act + var result = _sut.Build(source, projectVariables, EmptyLookup); + + // Assert + result.Values.Count.ShouldBe(4); + result.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "env-default-region-default"); + result.Values.ShouldContain(v => v.Scopes.Count == 1 && v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "env-dev-region-default"); + result.Values.ShouldContain(v => v.Scopes.Count == 1 && v.Scopes.GetValueOrDefault("Region") == "us" && v.Value == "env-default-region-us"); + result.Values.ShouldContain(v => v.Scopes.Count == 2 && v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Scopes.GetValueOrDefault("Region") == "us" && v.Value == "env-dev-region-us"); + } + + [Fact] + public void Build_DisjointDimensions_SourcePinnedToOne_CollapsesPinnedAxisAndFansOutOther() + { + // Arrange — the entry source is pinned to Region=us. Variables touch Environment and + // Region disjointly. The Region axis must collapse to a single value (us) while the + // Environment axis still fans out. Proves TryMerge and TargetTupleBuilder cooperate when + // the source scope intersects only one of the variables' dimensions. + var envVar = CreateVariable( + new ScopedValue("env-default", []), + new ScopedValue("env-dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("env-prod", new Dictionary { ["Environment"] = "prod" })); + + var regionVar = CreateVariable( + new ScopedValue("region-default", []), + new ScopedValue("region-us", new Dictionary { ["Region"] = "us" }), + new ScopedValue("region-eu", new Dictionary { ["Region"] = "eu" })); + + var projectVariables = new Dictionary + { + ["env"] = envVar, + ["region"] = regionVar, + }; + IReadOnlyList source = + [ + new ScopedValue("{{env}}-{{region}}", new Dictionary { ["Region"] = "us" }), + ]; + + // Act + var result = _sut.Build(source, projectVariables, EmptyLookup); + + // Assert — only Region=us survives the merge; Environment fans out to {dev, prod, default}. + result.Values.Count.ShouldBe(3); + foreach (var emission in result.Values) + { + emission.Scopes.GetValueOrDefault("Region").ShouldBe("us"); + } + + result.Values.ShouldContain(v => !v.Scopes.ContainsKey("Environment") && v.Value == "env-default-region-us"); + result.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "env-dev-region-us"); + result.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "prod" && v.Value == "env-prod-region-us"); + } + + [Fact] + public void Build_SourceScopeConflictsWithVariableScope_DropsConflictingCombinations() + { + // Arrange — entry pinned to Env=dev, variable defines Env=dev/Env=prod tuples. + var variable = CreateVariable( + new ScopedValue("dev value", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("prod value", new Dictionary { ["Environment"] = "prod" })); + + var projectVariables = new Dictionary { ["myVar"] = variable }; + IReadOnlyList source = [new ScopedValue("{{myVar}}", new Dictionary { ["Environment"] = "dev" })]; + + // Act + var result = _sut.Build(source, projectVariables, EmptyLookup); + + // Assert — Env=prod target is dropped (conflicts with source); only Env=dev survives. + var only = result.Values.ShouldHaveSingleItem(); + only.Scopes.GetValueOrDefault("Environment").ShouldBe("dev"); + only.Value.ShouldBe("dev value"); + } + + [Fact] + public void Build_ExplicitScopedValueWinsOverFanOutWithSameFinalTuple() + { + // Arrange — entry has both a default-scope source (referencing a variable) and an explicit + // Env=prod source with a literal value. The explicit emission must win over the fan-out + // emission for Env=prod. + var variable = CreateVariable( + new ScopedValue("default", []), + new ScopedValue("var-dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("var-prod", new Dictionary { ["Environment"] = "prod" })); + + var projectVariables = new Dictionary { ["myVar"] = variable }; + IReadOnlyList source = + [ + new ScopedValue("{{myVar}}", []), + new ScopedValue("explicit-prod", new Dictionary { ["Environment"] = "prod" }), + ]; + + // Act + var result = _sut.Build(source, projectVariables, EmptyLookup); + + // Assert + result.Values.Count.ShouldBe(3); + result.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "default"); + result.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "var-dev"); + + // The Env=prod emission must be the explicit literal, not the variable's prod tuple. + var prodEmission = result.Values.Single(v => v.Scopes.GetValueOrDefault("Environment") == "prod"); + prodEmission.Value.ShouldBe("explicit-prod"); + } + + [Fact] + public void Build_UnknownVariableName_ReportsNameAsUnresolved() + { + // Arrange + IReadOnlyList source = [new ScopedValue("{{unknown}}", [])]; + + // Act + var result = _sut.Build(source, EmptyLookup, EmptyLookup); + + // Assert + result.UnresolvedPlaceholders.ShouldContain("unknown"); + } + + [Fact] + public void Build_VariableWithoutDefault_ReferencedFromUnscopedEntry_ReportsUnresolved() + { + // Arrange — variable defines only Env=dev/prod tuples, no default. The unspecified target + // requires the unscoped default which doesn't exist; strict policy must flag it. + var variable = CreateVariable( + new ScopedValue("dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("prod", new Dictionary { ["Environment"] = "prod" })); + + var projectVariables = new Dictionary { ["myVar"] = variable }; + IReadOnlyList source = [new ScopedValue("{{myVar}}", [])]; + + // Act + var result = _sut.Build(source, projectVariables, EmptyLookup); + + // Assert + result.UnresolvedPlaceholders.ShouldContain("myVar"); + } + + [Fact] + public void Build_SensitiveVariableContributesToOneEmission_SetsEntryUsedSensitiveTrue() + { + // Arrange + var variable = new PlaintextVariable + { + Values = + [ + new ScopedValue("default", []), + new ScopedValue("dev secret", new Dictionary { ["Environment"] = "dev" }), + ], + IsSensitive = true, + }; + var projectVariables = new Dictionary { ["secret"] = variable }; + IReadOnlyList source = [new ScopedValue("{{secret}}", [])]; + + // Act + var result = _sut.Build(source, projectVariables, EmptyLookup); + + // Assert + result.UsedSensitiveVariable.ShouldBeTrue(); + } + + [Fact] + public void Build_RepeatedInvocationOnSameInputs_ProducesIdenticalEmissions() + { + // Arrange — determinism property: same inputs always yield the same outputs in the same order. + var variable = CreateVariable( + new ScopedValue("default", []), + new ScopedValue("dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("prod", new Dictionary { ["Environment"] = "prod" })); + + var projectVariables = new Dictionary { ["myVar"] = variable }; + IReadOnlyList source = [new ScopedValue("{{myVar}}", [])]; + + // Act + var first = _sut.Build(source, projectVariables, EmptyLookup); + var second = _sut.Build(source, projectVariables, EmptyLookup); + + // Assert — emissions match by position (canonical ordering). + first.Values.Count.ShouldBe(second.Values.Count); + for (var i = 0; i < first.Values.Count; i++) + { + second.Values[i].Value.ShouldBe(first.Values[i].Value); + second.Values[i].Scopes.Count.ShouldBe(first.Values[i].Scopes.Count); + foreach (var pair in first.Values[i].Scopes) + { + second.Values[i].Scopes[pair.Key].ShouldBe(pair.Value); + } + } + } + + [Fact] + public void Build_LiteralValueWithoutPlaceholders_PassesThroughWithSourceScope() + { + // Arrange — pure literal entry, no variables referenced. + IReadOnlyList source = + [ + new ScopedValue("hello", []), + new ScopedValue("hello-prod", new Dictionary { ["Environment"] = "prod" }), + ]; + + // Act + var result = _sut.Build(source, EmptyLookup, EmptyLookup); + + // Assert + result.Values.Count.ShouldBe(2); + result.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "hello"); + result.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "prod" && v.Value == "hello-prod"); + } + + private static PlaintextVariable CreateVariable(params ScopedValue[] values) => new() + { + Values = values, + IsSensitive = false, + }; + + private static readonly Dictionary EmptyLookup = new(StringComparer.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs b/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs index 7b5aace7..06de167c 100644 --- a/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs +++ b/tests/GroundControl.Api.Tests/Snapshots/SnapshotResolverTests.cs @@ -405,6 +405,408 @@ public async Task ResolveAsync_InterpolatesVariableUsingEntryOwnScope_WhenEntryH entry.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "prod" && v.Value == "1 Prod"); } + [Fact] + public async Task ResolveAsync_FansOutScopelessEntry_AcrossReferencedVariableScopes() + { + // Arrange — the original user scenario: a config entry with only a default value + // referencing a scoped variable. The published snapshot should carry one resolved value + // per variable scope tuple, with no per-scope authoring on the entry itself. + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + + await EnsureScopeAsync(apiClient, "Environment", ["dev", "prod"]); + + var project = await CreateProjectAsync(apiClient); + await CreateScopedVariableAsync(apiClient, project.Id, "myVariable", defaultValue: "default value", scopedValues: + [ + ("dev", "dev value"), + ("prod", "prod value"), + ]); + + var entryRequest = new CreateConfigEntryRequest + { + Key = "MyConfigEntry", + OwnerId = project.Id, + OwnerType = ConfigEntryOwnerType.Project, + ValueType = "String", + IsSensitive = false, + Values = [new ScopedValueRequest { Value = "{{myVariable}}" }], + }; + var entryResponse = await apiClient.PostAsJsonAsync("/api/config-entries", entryRequest, WebJsonSerializerOptions, TestCancellationToken); + entryResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var resolver = factory.Services.GetRequiredService(); + var projectStore = factory.Services.GetRequiredService(); + var loaded = await projectStore.GetByIdAsync(project.Id, TestCancellationToken); + loaded.ShouldNotBeNull(); + + // Act + var result = await resolver.ResolveAsync(loaded, description: null, TestCancellationToken); + + // Assert + var entry = result.PlaintextEntries.ShouldHaveSingleItem(); + entry.Key.ShouldBe("MyConfigEntry"); + entry.Values.Count.ShouldBe(3); + entry.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "default value"); + entry.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "dev value"); + entry.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "prod" && v.Value == "prod value"); + result.UnresolvedPlaceholders.ShouldBeEmpty(); + result.IsPublishable.ShouldBeTrue(); + } + + [Fact] + public async Task ResolveAsync_FansOutTemplateEntry_UsingProjectVariablePool() + { + // Arrange — the template defines an entry referencing a variable name; the project + // attaching the template supplies the variable values. Fan-out runs once on the merged + // entry set using the project's variable pool. + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + + await EnsureScopeAsync(apiClient, "Environment", ["dev", "prod"]); + + var template = await CreateTemplateAsync(apiClient); + await CreateOwnedConfigEntryAsync(apiClient, "Jwt:Authority", template.Id, ConfigEntryOwnerType.Template, value: "https://{{base_domain}}"); + + var project = await CreateProjectAsync(apiClient, templateIds: [template.Id]); + await CreateScopedVariableAsync(apiClient, project.Id, "base_domain", defaultValue: "default.local", scopedValues: + [ + ("dev", "dev.local"), + ("prod", "prod.local"), + ]); + + var resolver = factory.Services.GetRequiredService(); + var projectStore = factory.Services.GetRequiredService(); + var loaded = await projectStore.GetByIdAsync(project.Id, TestCancellationToken); + loaded.ShouldNotBeNull(); + + // Act + var result = await resolver.ResolveAsync(loaded, description: null, TestCancellationToken); + + // Assert + var entry = result.PlaintextEntries.ShouldHaveSingleItem(); + entry.Key.ShouldBe("Jwt:Authority"); + entry.Values.Count.ShouldBe(3); + entry.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "https://default.local"); + entry.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "https://dev.local"); + entry.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "prod" && v.Value == "https://prod.local"); + } + + [Fact] + public async Task ResolveAsync_TemplateAttachedToTwoProjects_UsesEachProjectsVariableValues() + { + // Arrange — two projects share a template but supply different variable values; each + // project's resolved snapshot must reflect its own variables, not the other's. + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + + await EnsureScopeAsync(apiClient, "Environment", ["dev", "prod"]); + + var template = await CreateTemplateAsync(apiClient); + await CreateOwnedConfigEntryAsync(apiClient, "api.url", template.Id, ConfigEntryOwnerType.Template, value: "https://{{host}}"); + + var firstProject = await CreateProjectAsync(apiClient, templateIds: [template.Id]); + await CreateScopedVariableAsync(apiClient, firstProject.Id, "host", defaultValue: "first.example", scopedValues: + [ + ("dev", "first-dev.example"), + ]); + + var secondProject = await CreateProjectAsync(apiClient, templateIds: [template.Id]); + await CreateScopedVariableAsync(apiClient, secondProject.Id, "host", defaultValue: "second.example", scopedValues: + [ + ("dev", "second-dev.example"), + ]); + + var resolver = factory.Services.GetRequiredService(); + var projectStore = factory.Services.GetRequiredService(); + + var firstLoaded = await projectStore.GetByIdAsync(firstProject.Id, TestCancellationToken); + firstLoaded.ShouldNotBeNull(); + var secondLoaded = await projectStore.GetByIdAsync(secondProject.Id, TestCancellationToken); + secondLoaded.ShouldNotBeNull(); + + // Act + var firstResult = await resolver.ResolveAsync(firstLoaded, description: null, TestCancellationToken); + var secondResult = await resolver.ResolveAsync(secondLoaded, description: null, TestCancellationToken); + + // Assert + var firstEntry = firstResult.PlaintextEntries.ShouldHaveSingleItem(); + firstEntry.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "https://first.example"); + firstEntry.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "https://first-dev.example"); + + var secondEntry = secondResult.PlaintextEntries.ShouldHaveSingleItem(); + secondEntry.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "https://second.example"); + secondEntry.Values.ShouldContain(v => v.Scopes.GetValueOrDefault("Environment") == "dev" && v.Value == "https://second-dev.example"); + } + + [Fact] + public async Task ResolveAsync_VariableMissingDefault_ReportsUnresolvedAndBlocksPublish() + { + // Arrange — strict-unresolved policy: a variable defines per-scope tuples but no default, + // and a scopeless entry references it. The unspecified target tuple has no value to fall + // back to, so the placeholder is reported as unresolved and the snapshot is not publishable. + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + + await EnsureScopeAsync(apiClient, "Environment", ["dev", "prod"]); + + var project = await CreateProjectAsync(apiClient); + await CreateScopedVariableAsync(apiClient, project.Id, "myVariable", defaultValue: null, scopedValues: + [ + ("dev", "dev value"), + ("prod", "prod value"), + ]); + + var entryRequest = new CreateConfigEntryRequest + { + Key = "MyConfigEntry", + OwnerId = project.Id, + OwnerType = ConfigEntryOwnerType.Project, + ValueType = "String", + IsSensitive = false, + Values = [new ScopedValueRequest { Value = "{{myVariable}}" }], + }; + var entryResponse = await apiClient.PostAsJsonAsync("/api/config-entries", entryRequest, WebJsonSerializerOptions, TestCancellationToken); + entryResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var resolver = factory.Services.GetRequiredService(); + var projectStore = factory.Services.GetRequiredService(); + var loaded = await projectStore.GetByIdAsync(project.Id, TestCancellationToken); + loaded.ShouldNotBeNull(); + + // Act + var result = await resolver.ResolveAsync(loaded, description: null, TestCancellationToken); + + // Assert + result.UnresolvedPlaceholders.ShouldContain("myVariable"); + result.IsPublishable.ShouldBeFalse(); + } + + [Fact] + public async Task ResolveAsync_SensitiveVariableUsedInFanOut_AllTuplesEncryptedAndFlaggedSensitive() + { + // Arrange — a sensitive scoped variable referenced by a non-sensitive entry. Per Decision 8 + // (per-entry sensitivity), one sensitive contribution flips the whole entry to sensitive. + // This test runs the full pipeline so the encrypted entries are written through the + // protector and decrypted back, proving sensitivity survives the storage round-trip on + // every fanned-out tuple. + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + + await EnsureScopeAsync(apiClient, "Environment", ["dev", "prod"]); + + var project = await CreateProjectAsync(apiClient); + + var variableRequest = new CreateVariableRequest + { + Name = "secretToken", + Scope = VariableScope.Project, + ProjectId = project.Id, + IsSensitive = true, + Values = + [ + new Features.Variables.Contracts.ScopedValueRequest { Value = "default-secret" }, + new Features.Variables.Contracts.ScopedValueRequest { Value = "dev-secret", Scopes = new Dictionary { ["Environment"] = "dev" } }, + new Features.Variables.Contracts.ScopedValueRequest { Value = "prod-secret", Scopes = new Dictionary { ["Environment"] = "prod" } }, + ], + }; + var variableResponse = await apiClient.PostAsJsonAsync("/api/variables", variableRequest, WebJsonSerializerOptions, TestCancellationToken); + variableResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var entryRequest = new CreateConfigEntryRequest + { + Key = "auth.token", + OwnerId = project.Id, + OwnerType = ConfigEntryOwnerType.Project, + ValueType = "String", + IsSensitive = false, + Values = [new ScopedValueRequest { Value = "{{secretToken}}" }], + }; + var entryResponse = await apiClient.PostAsJsonAsync("/api/config-entries", entryRequest, WebJsonSerializerOptions, TestCancellationToken); + entryResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var resolver = factory.Services.GetRequiredService(); + var protector = factory.Services.GetRequiredService(); + var projectStore = factory.Services.GetRequiredService(); + var loaded = await projectStore.GetByIdAsync(project.Id, TestCancellationToken); + loaded.ShouldNotBeNull(); + + // Act + var result = await resolver.ResolveAsync(loaded, description: null, TestCancellationToken); + + // Assert — the entry flips to sensitive even though IsSensitive on the request was false. + var plaintextEntry = result.PlaintextEntries.ShouldHaveSingleItem(); + plaintextEntry.IsSensitive.ShouldBeTrue(); + plaintextEntry.Values.Count.ShouldBe(3); + + var encryptedEntry = result.EncryptedEntries.ShouldHaveSingleItem(); + encryptedEntry.IsSensitive.ShouldBeTrue(); + encryptedEntry.Values.Count.ShouldBe(3); + + // Each fanned-out tuple is encrypted at rest and decrypts back to the per-scope plaintext. + foreach (var encrypted in encryptedEntry.Values) + { + encrypted.Value.ShouldNotContain("secret"); + var decrypted = protector.Unprotect(encrypted.Value); + if (encrypted.Scopes.Count == 0) + { + decrypted.ShouldBe("default-secret"); + } + else + { + var environment = encrypted.Scopes.GetValueOrDefault("Environment"); + decrypted.ShouldBe(environment switch + { + "dev" => "dev-secret", + "prod" => "prod-secret", + _ => throw new InvalidOperationException($"Unexpected scope tuple: {environment}"), + }); + } + } + } + + [Fact] + public async Task ResolveAsync_DiffHash_StableAcrossResolves_WhenExplicitScopedValueOverridesFanOut() + { + // Arrange — the entry mixes a default-scope source (referencing a scoped variable, which + // fans out) with an explicit Env=prod source (literal). Determinism must hold across + // resolves for this mixed shape: the explicit-wins dedup runs against canonical-ordered + // emissions, and a flake here would surface as preview-vs-publish 409 spam in production. + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + + await EnsureScopeAsync(apiClient, "Environment", ["dev", "prod"]); + + var project = await CreateProjectAsync(apiClient); + await CreateScopedVariableAsync(apiClient, project.Id, "myVariable", defaultValue: "default", scopedValues: + [ + ("dev", "var-dev"), + ("prod", "var-prod"), + ]); + + var entryRequest = new CreateConfigEntryRequest + { + Key = "MyConfigEntry", + OwnerId = project.Id, + OwnerType = ConfigEntryOwnerType.Project, + ValueType = "String", + IsSensitive = false, + Values = + [ + new ScopedValueRequest { Value = "{{myVariable}}" }, + new ScopedValueRequest { Value = "explicit-prod", Scopes = new Dictionary { ["Environment"] = "prod" } }, + ], + }; + var entryResponse = await apiClient.PostAsJsonAsync("/api/config-entries", entryRequest, WebJsonSerializerOptions, TestCancellationToken); + entryResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var resolver = factory.Services.GetRequiredService(); + var projectStore = factory.Services.GetRequiredService(); + var loaded = await projectStore.GetByIdAsync(project.Id, TestCancellationToken); + loaded.ShouldNotBeNull(); + + // Act + var firstResolve = await resolver.ResolveAsync(loaded, description: null, TestCancellationToken); + var secondResolve = await resolver.ResolveAsync(loaded, description: null, TestCancellationToken); + + // Assert + firstResolve.DiffHash.ShouldNotBeNullOrEmpty(); + secondResolve.DiffHash.ShouldBe(firstResolve.DiffHash); + + // Sanity check that the explicit-wins behavior is actually in play (not just trivially + // equal because fan-out collapsed): the prod tuple must be the literal, not the variable's. + var entry = firstResolve.PlaintextEntries.ShouldHaveSingleItem(); + entry.Values.Single(v => v.Scopes.GetValueOrDefault("Environment") == "prod").Value.ShouldBe("explicit-prod"); + } + + [Fact] + public async Task ResolveAsync_DiffHash_StableAcrossResolvesOfFannedOutEntry() + { + // Arrange — fan-out is a pure function of project state, so two resolves must yield the + // same diff hash. Guards against spurious 409s in the publish-after-preview gate when the + // resolved entry contains many fan-out tuples. + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + + await EnsureScopeAsync(apiClient, "Environment", ["dev", "prod"]); + + var project = await CreateProjectAsync(apiClient); + await CreateScopedVariableAsync(apiClient, project.Id, "myVariable", defaultValue: "default", scopedValues: + [ + ("dev", "dev"), + ("prod", "prod"), + ]); + + var entryRequest = new CreateConfigEntryRequest + { + Key = "MyConfigEntry", + OwnerId = project.Id, + OwnerType = ConfigEntryOwnerType.Project, + ValueType = "String", + IsSensitive = false, + Values = [new ScopedValueRequest { Value = "{{myVariable}}" }], + }; + var entryResponse = await apiClient.PostAsJsonAsync("/api/config-entries", entryRequest, WebJsonSerializerOptions, TestCancellationToken); + entryResponse.StatusCode.ShouldBe(HttpStatusCode.Created); + + var resolver = factory.Services.GetRequiredService(); + var projectStore = factory.Services.GetRequiredService(); + var loaded = await projectStore.GetByIdAsync(project.Id, TestCancellationToken); + loaded.ShouldNotBeNull(); + + // Act + var firstResolve = await resolver.ResolveAsync(loaded, description: null, TestCancellationToken); + var secondResolve = await resolver.ResolveAsync(loaded, description: null, TestCancellationToken); + + // Assert + firstResolve.DiffHash.ShouldNotBeNullOrEmpty(); + secondResolve.DiffHash.ShouldBe(firstResolve.DiffHash); + } + + private static async Task EnsureScopeAsync(HttpClient apiClient, string dimension, IReadOnlyList allowedValues) + { + var response = await apiClient.PostAsJsonAsync( + "/api/scopes", + new GroundControl.Api.Features.Scopes.Contracts.CreateScopeRequest { Dimension = dimension, AllowedValues = [.. allowedValues] }, + WebJsonSerializerOptions, + TestCancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.Created); + } + + private static async Task CreateScopedVariableAsync( + HttpClient apiClient, + Guid projectId, + string name, + string? defaultValue, + IReadOnlyList<(string Environment, string Value)> scopedValues) + { + var values = new List(); + if (defaultValue is not null) + { + values.Add(new Features.Variables.Contracts.ScopedValueRequest { Value = defaultValue }); + } + + foreach (var (environment, value) in scopedValues) + { + values.Add(new Features.Variables.Contracts.ScopedValueRequest + { + Value = value, + Scopes = new Dictionary { ["Environment"] = environment }, + }); + } + + var request = new CreateVariableRequest + { + Name = name, + Scope = VariableScope.Project, + ProjectId = projectId, + Values = values, + }; + + var response = await apiClient.PostAsJsonAsync("/api/variables", request, WebJsonSerializerOptions, TestCancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.Created); + } + private static async Task CreateTemplateAsync(HttpClient apiClient) { var request = new CreateTemplateRequest diff --git a/tests/GroundControl.Api.Tests/Snapshots/TargetTupleBuilderTests.cs b/tests/GroundControl.Api.Tests/Snapshots/TargetTupleBuilderTests.cs new file mode 100644 index 00000000..dbfb1a43 --- /dev/null +++ b/tests/GroundControl.Api.Tests/Snapshots/TargetTupleBuilderTests.cs @@ -0,0 +1,128 @@ +using GroundControl.Api.Features.Snapshots; +using GroundControl.Persistence.Contracts; +using Shouldly; +using Xunit; + +namespace GroundControl.Api.Tests.Snapshots; + +public sealed class TargetTupleBuilderTests +{ + [Fact] + public void Build_SingleVariableSingleDimension_ProducesTuplePerValuePlusUnspecified() + { + // Arrange + var variable = CreateVariable( + new ScopedValue("default", []), + new ScopedValue("dev value", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("prod value", new Dictionary { ["Environment"] = "prod" })); + + // Act + var targets = TargetTupleBuilder.Build([variable]); + + // Assert + targets.Count.ShouldBe(3); + targets.ShouldContain(t => t.Count == 0); + targets.ShouldContain(t => t.Count == 1 && t["Environment"] == "dev"); + targets.ShouldContain(t => t.Count == 1 && t["Environment"] == "prod"); + } + + [Fact] + public void Build_VariableWithOnlyDefault_ProducesEmptyTupleOnly() + { + // Arrange — variable has no scoped tuples, only the unscoped default. + var variable = CreateVariable(new ScopedValue("default", [])); + + // Act + var targets = TargetTupleBuilder.Build([variable]); + + // Assert + var only = targets.ShouldHaveSingleItem(); + only.Count.ShouldBe(0); + } + + [Fact] + public void Build_TwoVariablesSharedDimension_CollapsesToSingleTupleSet() + { + // Arrange — both variables touch Environment with overlapping and unique values. + var first = CreateVariable( + new ScopedValue("a-default", []), + new ScopedValue("a-dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("a-prod", new Dictionary { ["Environment"] = "prod" })); + + var second = CreateVariable( + new ScopedValue("b-default", []), + new ScopedValue("b-dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("b-staging", new Dictionary { ["Environment"] = "staging" })); + + // Act + var targets = TargetTupleBuilder.Build([first, second]); + + // Assert — Environment domain is {dev, prod, staging, unspecified} -> 4 tuples, no duplicates. + targets.Count.ShouldBe(4); + targets.ShouldContain(t => t.Count == 0); + targets.ShouldContain(t => t.Count == 1 && t["Environment"] == "dev"); + targets.ShouldContain(t => t.Count == 1 && t["Environment"] == "prod"); + targets.ShouldContain(t => t.Count == 1 && t["Environment"] == "staging"); + } + + [Fact] + public void Build_TwoVariablesDisjointDimensions_ProducesCartesianProduct() + { + // Arrange — one variable touches Environment, another touches Region. + var envVariable = CreateVariable( + new ScopedValue("dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("prod", new Dictionary { ["Environment"] = "prod" })); + + var regionVariable = CreateVariable( + new ScopedValue("us", new Dictionary { ["Region"] = "us" }), + new ScopedValue("eu", new Dictionary { ["Region"] = "eu" })); + + // Act + var targets = TargetTupleBuilder.Build([envVariable, regionVariable]); + + // Assert — (dev, prod, unspecified) x (us, eu, unspecified) = 9 tuples. + targets.Count.ShouldBe(9); + targets.ShouldContain(t => t.Count == 0); + targets.ShouldContain(t => t.Count == 1 && t.GetValueOrDefault("Environment") == "dev"); + targets.ShouldContain(t => t.Count == 1 && t.GetValueOrDefault("Region") == "us"); + targets.ShouldContain(t => t.Count == 2 && t.GetValueOrDefault("Environment") == "dev" && t.GetValueOrDefault("Region") == "us"); + targets.ShouldContain(t => t.Count == 2 && t.GetValueOrDefault("Environment") == "prod" && t.GetValueOrDefault("Region") == "eu"); + } + + [Fact] + public void Build_VariableWithoutDefault_StillEmitsUnspecifiedTuple() + { + // Arrange — variable has scoped tuples but no unscoped default. Strict policy lets the + // resolver flag the unspecified target as unresolved at interpolation time; the builder's + // job is just to enumerate the targets. + var variable = CreateVariable( + new ScopedValue("dev", new Dictionary { ["Environment"] = "dev" }), + new ScopedValue("prod", new Dictionary { ["Environment"] = "prod" })); + + // Act + var targets = TargetTupleBuilder.Build([variable]); + + // Assert + targets.Count.ShouldBe(3); + targets.ShouldContain(t => t.Count == 0); + targets.ShouldContain(t => t.Count == 1 && t["Environment"] == "dev"); + targets.ShouldContain(t => t.Count == 1 && t["Environment"] == "prod"); + } + + [Fact] + public void Build_NoReferencedVariables_ReturnsSingleEmptyTuple() + { + // Act + var targets = TargetTupleBuilder.Build([]); + + // Assert + var only = targets.ShouldHaveSingleItem(); + only.Count.ShouldBe(0); + } + + private static PlaintextVariable CreateVariable(params ScopedValue[] values) => new() + { + Values = values, + IsSensitive = false, + }; +} \ No newline at end of file diff --git a/tests/GroundControl.E2E.Tests/Scenarios/VariableInterpolationWorkflow.cs b/tests/GroundControl.E2E.Tests/Scenarios/VariableInterpolationWorkflow.cs index ea5e9f82..8a83f6af 100644 --- a/tests/GroundControl.E2E.Tests/Scenarios/VariableInterpolationWorkflow.cs +++ b/tests/GroundControl.E2E.Tests/Scenarios/VariableInterpolationWorkflow.cs @@ -17,6 +17,11 @@ public sealed class VariableInterpolationWorkflow : EndToEndTestBase private const string SnapshotIdKey = "SnapshotId"; private const string ClientIdKey = "ClientId"; private const string ClientSecretKey = "ClientSecret"; + private const string ScopedSnapshotIdKey = "ScopedSnapshotId"; + private const string DevClientIdKey = "DevClientId"; + private const string DevClientSecretKey = "DevClientSecret"; + private const string ProdClientIdKey = "ProdClientId"; + private const string ProdClientSecretKey = "ProdClientSecret"; public VariableInterpolationWorkflow(AspireFixture fixture) : base(fixture) { } @@ -188,4 +193,161 @@ public Task Step07_LinkSdkReceivesInterpolatedValue() => RunStep(7, () => return Task.CompletedTask; }); + + [Fact, Step(8)] + public Task Step08_CreateTierScope() => RunStep(8, async () => + { + // Arrange & Act + var result = await Cli.RunAsync(TestCancellationToken, + "scope", "create", + "--dimension", "tier", + "--values", "dev,prod"); + + // Assert + result.ShouldSucceed(); + }); + + [Fact, Step(9)] + public Task Step09_CreateScopedVariable() => RunStep(9, async () => + { + // Arrange — variable defines per-tier values plus an unscoped default. The PRD's strict + // policy says fan-out must materialize each tuple in the published snapshot. + var projectId = Get(ProjectIdKey); + + // Act + var result = await Cli.RunAsync(TestCancellationToken, + "variable", "create", + "--name", "feature_flag", + "--scope", "Project", + "--project-id", projectId.ToString(), + "--value", "default=baseline", + "--value", "tier:dev=dev-feature", + "--value", "tier:prod=prod-feature"); + + // Assert + result.ShouldSucceed(); + }); + + [Fact, Step(10)] + public Task Step10_AddScopelessConfigEntryReferencingScopedVariable() => RunStep(10, async () => + { + // Arrange — entry has only a default value referencing the scoped variable. Fan-out at + // publish must produce one tuple per tier value plus the unscoped default. + var projectId = Get(ProjectIdKey); + + // Act + var result = await Cli.RunAsync(TestCancellationToken, + "config-entry", "create", + "--key", "feature:value", + "--owner-id", projectId.ToString(), + "--owner-type", "Project", + "--value-type", "String", + "--value", "default={{feature_flag}}"); + + // Assert + result.ShouldSucceed(); + }); + + [Fact, Step(11)] + public Task Step11_PublishSnapshotWithFanOut() => RunStep(11, async () => + { + // Arrange + var projectId = Get(ProjectIdKey); + + // Act + var result = await Cli.RunAsync(TestCancellationToken, + "snapshot", "publish", + "--project-id", projectId.ToString(), + "--description", "Scoped variable fan-out snapshot"); + + // Assert + result.ShouldSucceed(); + var snapshot = result.ParseOutput(); + Set(ScopedSnapshotIdKey, snapshot.Id); + + var detail = await ApiClient.GetSnapshotHandlerAsync( + projectId, snapshot.Id, decrypt: true, cancellationToken: TestCancellationToken); + + var entry = detail.Entries.FirstOrDefault(e => e.Key == "feature:value"); + entry.ShouldNotBeNull(); + entry.Values.Count.ShouldBe(3); + entry.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "baseline"); + entry.Values.ShouldContain(v => v.Scopes.ContainsKey("tier") && v.Scopes["tier"] == "dev" && v.Value == "dev-feature"); + entry.Values.ShouldContain(v => v.Scopes.ContainsKey("tier") && v.Scopes["tier"] == "prod" && v.Value == "prod-feature"); + }); + + [Fact, Step(12)] + public Task Step12_CreateDevAndProdClients() => RunStep(12, async () => + { + // Arrange + var projectId = Get(ProjectIdKey); + + // Act — dev client + var devResult = await Cli.RunAsync(TestCancellationToken, + "client", "create", + "--project-id", projectId.ToString(), + "--name", "e2e-fanout-dev", + "--scopes", "tier=dev"); + + // Assert + devResult.ShouldSucceed(); + var devClient = devResult.ParseOutput(); + Set(DevClientIdKey, devClient.Id); + Set(DevClientSecretKey, devClient.ClientSecret); + + // Act — prod client + var prodResult = await Cli.RunAsync(TestCancellationToken, + "client", "create", + "--project-id", projectId.ToString(), + "--name", "e2e-fanout-prod", + "--scopes", "tier=prod"); + + // Assert + prodResult.ShouldSucceed(); + var prodClient = prodResult.ParseOutput(); + Set(ProdClientIdKey, prodClient.Id); + Set(ProdClientSecretKey, prodClient.ClientSecret); + }); + + [Fact, Step(13)] + public Task Step13_DevAndProdClientsReceiveDifferentFannedOutValues() => RunStep(13, () => + { + // Arrange + var devClientId = Get(DevClientIdKey); + var devClientSecret = Get(DevClientSecretKey); + var prodClientId = Get(ProdClientIdKey); + var prodClientSecret = Get(ProdClientSecretKey); + + var devBuilder = new ConfigurationBuilder(); + devBuilder.AddGroundControl(opts => + { + opts.ServerUrl = new Uri(Fixture.ApiBaseUrl); + opts.ClientId = devClientId.ToString(); + opts.ClientSecret = devClientSecret; + opts.StartupTimeout = TimeSpan.FromSeconds(15); + opts.ConnectionMode = ConnectionMode.StartupOnly; + opts.EnableLocalCache = false; + }); + + var prodBuilder = new ConfigurationBuilder(); + prodBuilder.AddGroundControl(opts => + { + opts.ServerUrl = new Uri(Fixture.ApiBaseUrl); + opts.ClientId = prodClientId.ToString(); + opts.ClientSecret = prodClientSecret; + opts.StartupTimeout = TimeSpan.FromSeconds(15); + opts.ConnectionMode = ConnectionMode.StartupOnly; + opts.EnableLocalCache = false; + }); + + // Act + var devConfiguration = devBuilder.Build(); + var prodConfiguration = prodBuilder.Build(); + + // Assert + devConfiguration["feature:value"].ShouldBe("dev-feature"); + prodConfiguration["feature:value"].ShouldBe("prod-feature"); + + return Task.CompletedTask; + }); } \ No newline at end of file From 3b21e58b204369945ea30509b58a3397eefed1dd Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 12:57:03 +0100 Subject: [PATCH 5/7] docs: describe scoped variable fan-out at snapshot publish variables.md, concepts.md, Domain-Model.md, and Technical-Architecture.md described the publish pipeline as per-placeholder substitution against the client's scopes. that model predates the fan-out behavior on the bug-fixes branch, where a scopeless config entry referencing a scoped variable produces one snapshot value per tuple the variable touches. updated the variable resolution doc to lead with fan-out, document the strict-unresolved policy, and reword the worked example to show the materialized per-environment snapshot values. updated the snapshot resolution process in the domain model to describe target generation, source-scope merge, explicit-wins dedup, and per-entry sensitivity. nudged the technical architecture pipeline diagram and the concepts overview to match. --- docs/design-docs/Domain-Model.md | 19 +++---- docs/design-docs/Technical-Architecture.md | 2 +- docs/guide/concepts.md | 6 +-- docs/guide/variables.md | 63 +++++++++++++++++----- 4 files changed, 63 insertions(+), 27 deletions(-) diff --git a/docs/design-docs/Domain-Model.md b/docs/design-docs/Domain-Model.md index 809fd37b..492b60d8 100644 --- a/docs/design-docs/Domain-Model.md +++ b/docs/design-docs/Domain-Model.md @@ -417,19 +417,20 @@ When an admin publishes a snapshot for a project: 1. **Collect entries**: Gather all config entries from the project's attached templates and the project's own entries. 2. **Merge with override**: For any key that exists in both a template and the project, the project-level entry takes precedence (full replacement of all scoped values for that key). -3. **Interpolate variables**: For each scoped value in each entry: - a. Find all `{{variableName}}` references in the value string. - b. For each variable, resolve its value using the two-tier system: - - First check for a project-level variable with a matching scope. - - Fall back to the global variable with a matching scope. - - Use the same scope resolution algorithm (most-specific match wins). - c. Replace the placeholder with the resolved variable value. -4. **Validate**: Ensure all variable references were resolved (no unresolved `{{...}}` placeholders remain). -5. **Encrypt sensitive values**: Encrypt values marked as sensitive using the configured encryption provider. +3. **Fan out and interpolate variables**: For each merged entry, expand its source scoped values into the dim-space cartesian of scope tuples touched by the variables they reference, then resolve each variable per tuple: + a. Scan each source value for `{{variableName}}` references and look each name up using the two-tier system (project-level first, then global). + b. Build the set of target scope tuples by taking, per referenced dimension, the union of distinct values plus an "unspecified" axis, and producing the cartesian product. + c. Merge each target with the entry's own source scope, dropping conflicting combinations. + d. For each surviving final tuple, run scope resolution (most-specific match wins, falling back to the variable's unscoped default) and substitute the placeholder with the resolved value. + e. Within one source value, deduplicate emissions for the same final tuple by retaining the most-specific target. Across source values for the same entry, an emission whose source had a more specific scope tuple wins — the **explicit-wins** rule lets a literal scoped value on the entry override a fan-out emission from a scopeless sibling. +4. **Validate**: Ensure every required target tuple resolved. A placeholder is unresolved if its name matches no variable, or if `ScopeResolver` returns no value for at least one required target tuple. Any unresolved placeholder blocks publish (HTTP 422) with the offending name reported back. +5. **Encrypt sensitive values**: Encrypt values marked as sensitive using the configured encryption provider. Per-entry sensitivity is preserved: any sensitive variable contributing to any tuple flips the entire resolved entry to sensitive. 6. **Store snapshot**: Persist the immutable snapshot with a new incremented version number. 7. **Activate**: Set the project's `activeSnapshotId` to the new snapshot. 8. **Notify**: Trigger the change notification system to alert connected clients. +Fan-out is deterministic: given the same project state, two resolves produce identical resolved entries (canonical scope-tuple ordering across emissions), so the diff hash gating preview-vs-publish 409 detection remains stable. Scopeless config entries that reference scoped variables produce one snapshot value per scope tuple the variable touches — clients later pick the matching tuple at read time without any further interpolation. + **Failure modes and atomicity:** - Steps 1–5 are pure computation with no side effects. If any step fails, no snapshot is created. diff --git a/docs/design-docs/Technical-Architecture.md b/docs/design-docs/Technical-Architecture.md index 6707452d..b2c59c59 100644 --- a/docs/design-docs/Technical-Architecture.md +++ b/docs/design-docs/Technical-Architecture.md @@ -90,7 +90,7 @@ Admin publishes snapshot for project Server resolves: merge templates + project overrides │ ▼ -Server interpolates variables per scope variant +Server fans entries out across referenced variables' scope tuples and interpolates per tuple │ ▼ Server encrypts sensitive values diff --git a/docs/guide/concepts.md b/docs/guide/concepts.md index 37339859..caa19fb4 100644 --- a/docs/guide/concepts.md +++ b/docs/guide/concepts.md @@ -49,7 +49,7 @@ Variables come in two tiers: Use variables for values that appear in many entries, such as a connection string prefix, an API endpoint, or a shared secret. This lets you change the value in one place instead of updating every entry that uses it. -Variables are resolved at publish time. If a configuration value references a variable that is undefined or cannot be resolved for the target scope, the publish fails with an error telling you exactly which variable is missing. +Variables are resolved at publish time, and a scoped variable's scope dimensions propagate to every entry that references it: a scopeless entry like `MyEntry = "{{MyVariable}}"` automatically picks up the variable's per-scope values in the published snapshot, without you having to redeclare the scope tuples on each entry. If a configuration value references a variable that is undefined or cannot be resolved for any required scope tuple, the publish fails with an error telling you exactly which variable is missing. For a full reference of variable structure, ownership tiers, two-tier resolution, sensitivity, and group/system-wide visibility rules, see [Variables](variables.md). @@ -72,11 +72,11 @@ An entry can be marked as **sensitive**. Sensitive values are encrypted at rest A snapshot is an immutable, point-in-time capture of a project's fully resolved configuration. You create a snapshot by performing a "publish" action, which: 1. Merges template entries with project entries (project entries take precedence) -2. Interpolates all variable references +2. Fans each entry out across the scope tuples its referenced variables touch and substitutes the variables per tuple, so a scopeless entry referencing a per-environment variable produces one resolved value per environment in the snapshot 3. Encrypts sensitive values 4. Stores the result as a new, versioned snapshot -Clients always receive configuration from the **active** snapshot. Snapshots are versioned sequentially (1, 2, 3, ...) and cannot be modified after creation. +Clients always receive configuration from the **active** snapshot — they pick the matching scope tuple from the snapshot at request time without re-running interpolation. Snapshots are versioned sequentially (1, 2, 3, ...) and cannot be modified after creation. If you need to revert a configuration change, activate a previous snapshot. The old snapshot becomes the active one and all clients immediately receive that version's configuration. diff --git a/docs/guide/variables.md b/docs/guide/variables.md index 0099a8ab..deb4ab05 100644 --- a/docs/guide/variables.md +++ b/docs/guide/variables.md @@ -42,7 +42,7 @@ The `scope` field puts a variable in one of two tiers: `projectId` is required and must reference an existing project. `groupId` must be `null` — a project variable inherits its group through the project. -A project variable with the same `name` as a global variable shadows the global for that project (see [Two-tier resolution](#two-tier-resolution)). +A project variable with the same `name` as a global variable shadows the global for that project (see [Two-tier lookup](#two-tier-lookup)). ## Scoped values @@ -63,31 +63,57 @@ A single variable typically holds one unscoped default plus one variant per envi ## How a placeholder resolves -When a snapshot is published for a project, every config entry value is scanned for `{{name}}` placeholders. Each placeholder is resolved using a **two-tier**, **most-specific-scope-wins** algorithm. +When a snapshot is published for a project, every config entry value is scanned for `{{name}}` placeholders. The publish pipeline **fans out** each scoped value across the scope tuples its referenced variables touch, resolving each variable per tuple. The snapshot stores the materialized per-tuple values; the client read path picks one tuple at request time using the same scope-matching rules without any further interpolation. -### Two-tier resolution +### Fan-out at publish -For each placeholder `{{name}}`: +For each source scoped value on the entry: -1. Look up `name` in the project's project-scope variables. -2. If found, attempt scope resolution against the client's scopes (see below). If a value resolves, use it. -3. Otherwise, look up `name` in the project's visible globals. -4. If a global match resolves, use it. -5. If neither tier yields a value, the placeholder is **unresolved** and the publish fails with the offending name reported back. +1. **Scan placeholders.** Every `{{name}}` is mapped to a variable via the [two-tier lookup](#two-tier-lookup) below. +2. **Generate target tuples.** The set of scope dimensions referenced by the resolved variables forms the target dim-space; per-dimension domain is the union of distinct values plus an "unspecified" axis. The cartesian product yields the targets to materialize. +3. **Merge with the source's own scope.** A target whose dimension value conflicts with the source scope on the same dimension is dropped; surviving targets become the final scope tuple. +4. **Substitute per target.** [`VariableInterpolator`](../../src/GroundControl.Api/Features/Snapshots/VariableInterpolator.cs) is invoked once per final tuple. Each placeholder is resolved against that tuple by [`ScopeResolver`](../../src/GroundControl.Api/Shared/Resolvers/ScopeResolver.cs) (most-specific scope wins, falling back to the variable's unscoped default). -The implementation lives in [`VariableInterpolator`](../../src/GroundControl.Api/Features/Snapshots/VariableInterpolator.cs). +After fan-out across every source value of the entry, emissions for the same final scope tuple are deduplicated: + +- Within one source value, the most-specific target wins. +- Across source values, an emission whose source had a more specific scope tuple wins — this is the **explicit-wins rule**: a literal scoped value on the entry overrides a fan-out emission from a default-scoped sibling that references a variable. + +The orchestration lives in [`ResolvedEntryBuilder`](../../src/GroundControl.Api/Features/Snapshots/ResolvedEntryBuilder.cs); target enumeration is [`TargetTupleBuilder`](../../src/GroundControl.Api/Features/Snapshots/TargetTupleBuilder.cs). + +#### Practical effect + +A scopeless config entry like `MyConfigEntry = "{{MyVariable}}"` referencing a variable with `Environment = dev/prod` tuples produces one resolved snapshot value per environment plus the unscoped default. You don't have to redeclare scope tuples on every entry that uses a scoped variable. + +### Two-tier lookup + +For each placeholder `{{name}}`, the publisher looks the name up in two tiers: + +1. The project's project-scope variables. +2. The project's visible globals. + +The first tier that contains the name supplies the variable; the global tier is a fallback, not a merge. Within the variable, scope-matching against a target tuple uses the same most-specific-wins algorithm described in [Scope resolution within a tier](#scope-resolution-within-a-tier). ### Scope resolution within a tier -Within a single variable's `values` list, [`ScopeResolver`](../../src/GroundControl.Api/Shared/Resolvers/ScopeResolver.cs) picks one variant: +Within a single variable's `values` list, [`ScopeResolver`](../../src/GroundControl.Api/Shared/Resolvers/ScopeResolver.cs) picks one variant for the target tuple: -1. Filter to candidates whose `scopes` map is a **full match** of the client's scopes — every dimension in the candidate must equal the client's value (case-insensitive on the dimension name, exact on the value). +1. Filter to candidates whose `scopes` map is a **full match** of the target — every dimension in the candidate must equal the target's value (case-insensitive on the dimension name, exact on the value). 2. Of the matches, the candidate with the **most dimensions** wins. 3. If no scoped candidate matches, fall back to the unscoped default (`scopes = {}`). -4. If there isn't even an unscoped default, the variable contributes no value and resolution falls through to the next tier (or fails). +4. If there isn't even an unscoped default, the variable contributes no value for this target tuple and the placeholder is **unresolved** — the publish fails with the offending name reported back ([strict-unresolved policy](#strict-unresolved-policy)). A tie at the same specificity logs a warning and returns the first match — design your scoped values so combinations don't collide. +### Strict-unresolved policy + +A placeholder is considered unresolved (and blocks publish with HTTP 422) under either condition: + +- The name does not match any variable in the project lookup nor the global lookup. +- The variable exists but `ScopeResolver` returns no match for at least one target tuple required by fan-out — typically a variable with no unscoped default referenced by an entry whose targets include the empty tuple. + +To unblock, add a default to the variable, or scope the entry more tightly so its targets only span tuples the variable covers. + ### Visibility from a project's perspective For a project `P` in group `G`, the variables visible at publish time are: @@ -139,6 +165,7 @@ Enforced by partial unique indexes (case-insensitive) in [`VariableConfiguration - **Variables can't reference variables.** `{{...}}` is rejected on write inside variable values; only config-entry values may contain placeholders. - **Resolution is publish-time, not write-time.** A config entry can be saved with `{{Foo}}` even if `Foo` doesn't exist yet. The publish call is what fails when the placeholder can't be resolved. - **Tied scope specificity.** If two scoped values in the same variable match a client with the same dimension count, you get a warning log and a non-deterministic pick. Make scope combinations unambiguous. +- **Snapshot size grows with fan-out.** A scopeless entry that references a variable with many scope tuples produces one snapshot value per tuple. Combined with multi-dimensional variables this expands cartesian-style. The 16MB BSON snapshot limit catches runaway expansion at publish — keep multi-dimensional variables narrow when an entry references several of them. ## Worked examples @@ -169,7 +196,15 @@ In a config entry: "values": [{ "value": "{{ApiBase}}/v1" }] } ``` -A client bound to `{Environment: staging}` resolves to `https://api.staging.example.com/v1`. +The entry has a single scopeless source value. At publish, fan-out expands it into three resolved snapshot values — one per `Environment` tuple the variable touches plus the unscoped default: + +| Snapshot scope | Resolved value | +|---|---| +| `{}` | `https://api.example.com/v1` | +| `{Environment: staging}` | `https://api.staging.example.com/v1` | +| `{Environment: prod}` | `https://api.example.com/v1` | + +A client bound to `{Environment: staging}` is then served `https://api.staging.example.com/v1` straight from the snapshot — no further interpolation runs. ### Group-owned secret with a per-project override From b830947254a0b115f2d0ceebdcd2b2baa2d529ce Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 13:05:39 +0100 Subject: [PATCH 6/7] docs: rewrite guide docs in plainer language for end users the user-facing guide was leaning on internal jargon (fan-out, dim-space cartesian, target tuples, emissions, dedup-by-most-specific-target) and was linking out to internal C# classes from the user docs. swapped those for plain-english descriptions with worked examples that show what users see in the published snapshot, and dropped the implementation-detail links. also replaced em-dashes in inline prose across the guide (kept conventional em-dashes inside link labels and "what's next?" lists, since those are typographic separators not prose interruptions). renamed two-tier-resolution section anchor to two-tier-lookup with the matching cross-references updated. --- docs/guide/concepts.md | 8 +- docs/guide/getting-started.md | 2 +- docs/guide/sdk/caching.md | 2 +- docs/guide/server/authentication.md | 4 +- docs/guide/server/configuration.md | 2 +- docs/guide/server/deployment.md | 4 +- docs/guide/variables.md | 145 ++++++++++++++-------------- 7 files changed, 86 insertions(+), 81 deletions(-) diff --git a/docs/guide/concepts.md b/docs/guide/concepts.md index caa19fb4..93674ecc 100644 --- a/docs/guide/concepts.md +++ b/docs/guide/concepts.md @@ -49,9 +49,9 @@ Variables come in two tiers: Use variables for values that appear in many entries, such as a connection string prefix, an API endpoint, or a shared secret. This lets you change the value in one place instead of updating every entry that uses it. -Variables are resolved at publish time, and a scoped variable's scope dimensions propagate to every entry that references it: a scopeless entry like `MyEntry = "{{MyVariable}}"` automatically picks up the variable's per-scope values in the published snapshot, without you having to redeclare the scope tuples on each entry. If a configuration value references a variable that is undefined or cannot be resolved for any required scope tuple, the publish fails with an error telling you exactly which variable is missing. +Variables are resolved at publish time. A scoped variable's per-scope values automatically propagate to every entry that references it. So a scopeless entry like `MyEntry = "{{MyVariable}}"` ends up with one resolved value per scope in the published snapshot, without you having to repeat the scope tuples on every entry. If a configuration value references a variable that is undefined or cannot be resolved for one of the scopes it needs, the publish fails with an error telling you exactly which variable is missing. -For a full reference of variable structure, ownership tiers, two-tier resolution, sensitivity, and group/system-wide visibility rules, see [Variables](variables.md). +For a full reference of variable structure, ownership tiers, the two-tier lookup, sensitivity, and group/system-wide visibility rules, see [Variables](variables.md). ## Configuration Entries @@ -72,11 +72,11 @@ An entry can be marked as **sensitive**. Sensitive values are encrypted at rest A snapshot is an immutable, point-in-time capture of a project's fully resolved configuration. You create a snapshot by performing a "publish" action, which: 1. Merges template entries with project entries (project entries take precedence) -2. Fans each entry out across the scope tuples its referenced variables touch and substitutes the variables per tuple, so a scopeless entry referencing a per-environment variable produces one resolved value per environment in the snapshot +2. Resolves every variable reference, expanding scopeless entries that use scoped variables into one resolved value per scope 3. Encrypts sensitive values 4. Stores the result as a new, versioned snapshot -Clients always receive configuration from the **active** snapshot — they pick the matching scope tuple from the snapshot at request time without re-running interpolation. Snapshots are versioned sequentially (1, 2, 3, ...) and cannot be modified after creation. +Clients always receive configuration from the **active** snapshot. They pick the matching scope from the snapshot at request time, with no further resolution work. Snapshots are versioned sequentially (1, 2, 3, ...) and cannot be modified after creation. If you need to revert a configuration change, activate a previous snapshot. The old snapshot becomes the active one and all clients immediately receive that version's configuration. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index d429fb21..38809198 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -28,7 +28,7 @@ cd GroundControl aspire start src/GroundControl.AppHost ``` -The Aspire dashboard opens in your browser. Find the `api` resource — note the HTTP URL it is bound to. The rest of this guide uses `http://localhost:8080` as a placeholder; substitute the URL from the dashboard. +The Aspire dashboard opens in your browser. Find the `api` resource and note the HTTP URL it is bound to. The rest of this guide uses `http://localhost:8080` as a placeholder; substitute the URL from the dashboard. Verify the API is ready: diff --git a/docs/guide/sdk/caching.md b/docs/guide/sdk/caching.md index 7c6ccc0a..bbe6fad3 100644 --- a/docs/guide/sdk/caching.md +++ b/docs/guide/sdk/caching.md @@ -66,7 +66,7 @@ builder.Configuration.AddGroundControl(options => ``` - Only entries the server has marked as sensitive go through the protector; non-sensitive entries (feature flags, URLs, thresholds) stay plaintext and remain readable for diagnostics. -- If the protector is not configured, every entry is cached plaintext — an explicit opt-out. +- If the protector is not configured, every entry is cached plaintext. This is an explicit opt-out. - The SDK treats ciphertext as opaque; key rotation and algorithm versioning are your protector's responsibility. - If `Unprotect` throws, or if the cache was written under a different protector configuration than the one in effect now, the file is treated as a cache miss and the next save overwrites it. - Cache portability depends entirely on your protector (e.g., DPAPI keys are per-machine; an AES implementation with a shared key is portable). diff --git a/docs/guide/server/authentication.md b/docs/guide/server/authentication.md index af9d098b..309fcc13 100644 --- a/docs/guide/server/authentication.md +++ b/docs/guide/server/authentication.md @@ -13,7 +13,7 @@ graph TD ## None (development) -All requests are treated as a system admin — no login required. No auth endpoints are exposed. This mode is suitable for local development and personal homelab use. +All requests are treated as a system admin, with no login required. No auth endpoints are exposed. This mode is suitable for local development and personal homelab use. ```json { @@ -65,7 +65,7 @@ openssl rand -base64 32 - Password: the value of `Authentication:Seed:AdminPassword` - Full system admin permissions -> **Note:** The admin seed is idempotent — restarting the server won't duplicate the account. If you change the password in the seed config, it updates the existing admin's password. +> **Note:** The admin seed is idempotent. Restarting the server won't duplicate the account. If you change the password in the seed config, it updates the existing admin's password. ### JWT settings diff --git a/docs/guide/server/configuration.md b/docs/guide/server/configuration.md index a5ef98b5..51bba897 100644 --- a/docs/guide/server/configuration.md +++ b/docs/guide/server/configuration.md @@ -89,7 +89,7 @@ Keys are stored on the file system and the key XML is encrypted at rest with an } ``` -> **Certificate rotation:** generate the new cert, deploy it as `FileSystemCertificate:Path`, move the old cert into `FileSystemCertificate:PreviousPaths`, and perform a rolling restart. New key ring entries are encrypted with the new cert; entries written under the previous cert remain decryptable as long as that cert stays in the previous list. Remove a cert from `PreviousPaths` only after every key encrypted with it has expired (90+ days by default) or been re-encrypted — otherwise the data those keys protect becomes permanently unreadable. +> **Certificate rotation:** generate the new cert, deploy it as `FileSystemCertificate:Path`, move the old cert into `FileSystemCertificate:PreviousPaths`, and perform a rolling restart. New key ring entries are encrypted with the new cert; entries written under the previous cert remain decryptable as long as that cert stays in the previous list. Remove a cert from `PreviousPaths` only after every key encrypted with it has expired (90+ days by default) or been re-encrypted. Otherwise the data those keys protect becomes permanently unreadable. ### Redis mode diff --git a/docs/guide/server/deployment.md b/docs/guide/server/deployment.md index ddcaf25e..5589daa2 100644 --- a/docs/guide/server/deployment.md +++ b/docs/guide/server/deployment.md @@ -1,6 +1,6 @@ # Deploying GroundControl -> **Heads up:** GroundControl is still under active development. This guide covers **local development only** — running the server on your machine for evaluation or contribution. Production-deployment guidance (multi-instance hardening, Data Protection key management, change-notifier topology, container images) will be published in a later release. +> **Heads up:** GroundControl is still under active development. This guide covers **local development only**: running the server on your machine for evaluation or contribution. Production-deployment guidance (multi-instance hardening, Data Protection key management, change-notifier topology, container images) will be published in a later release. ## Prerequisites @@ -26,7 +26,7 @@ curl http://localhost:8080/healthz/ready # substitute the URL from the dashboa ## Running without Aspire -If you prefer to run the API directly against your own MongoDB instance — for example, when contributing and debugging a single project — set the required environment variables and run `dotnet run`: +If you prefer to run the API directly against your own MongoDB instance (for example, when contributing and debugging a single project), set the required environment variables and run `dotnet run`: ```bash export ConnectionStrings__Storage="mongodb://localhost:27017" diff --git a/docs/guide/variables.md b/docs/guide/variables.md index deb4ab05..bfe0c495 100644 --- a/docs/guide/variables.md +++ b/docs/guide/variables.md @@ -1,52 +1,52 @@ # Variables -Variables are named placeholders that get interpolated into configuration entry values at snapshot publish time. They let you keep one source of truth for any value that appears in many entries — connection-string prefixes, API endpoints, shared secrets — and change it in one place instead of editing every entry that uses it. +Variables are named values you can reference inside configuration entries using `{{name}}` placeholders. Use them when the same value shows up in many entries (a connection-string prefix, an API endpoint, a shared secret) so you can change it in one place and have every entry that uses it pick up the new value the next time you publish. -This page covers what a variable looks like, how its visibility is determined, how a placeholder gets resolved, and the edge cases you need to know about. +This page covers what a variable looks like, who can see it, how its value gets baked into a published snapshot, and the edge cases worth knowing. ## Anatomy of a variable -A variable has a name, an ownership tier, and a list of values. Each value is qualified by zero or more scope dimensions. +A variable has a name, an ownership tier, and one or more values. Each value can be qualified with scope dimensions like `Environment` or `Region`. | Field | Type | Purpose | |---|---|---| -| `name` | string | The key used in `{{name}}` placeholders. Case-insensitive within its uniqueness key. | +| `name` | string | The key used in `{{name}}` placeholders. Case-insensitive. | | `description` | string? | Optional human-readable note. | | `scope` | `Global` \| `Project` | Ownership tier. See [Ownership tiers](#ownership-tiers). | | `groupId` | Guid? | For `Global` variables only. `null` means system-wide; otherwise the variable belongs to that group. Forbidden on `Project` variables. | | `projectId` | Guid? | Required on `Project` variables; forbidden on `Global` variables. | -| `values` | `ScopedValue[]` | One or more scoped value variants. See [Scoped values](#scoped-values). | -| `isSensitive` | bool | Encrypts at rest, masks as `***` in API responses, and propagates sensitivity to any snapshot entry that interpolates the variable. | -| `version` | long | Optimistic-concurrency token. Required on update/delete via `If-Match`. | +| `values` | `ScopedValue[]` | One or more scoped values. See [Scoped values](#scoped-values). | +| `isSensitive` | bool | Encrypts the value at rest, masks it as `***` in API responses, and marks any entry that uses it as sensitive too. | +| `version` | long | Used for optimistic concurrency. Required on update/delete via `If-Match`. | The full field list including audit timestamps is in [Domain Model — Variable](../design-docs/Domain-Model.md#variable). ## Ownership tiers -The `scope` field puts a variable in one of two tiers: +The `scope` field puts a variable in one of two tiers. ### Global -`scope = Global`. Used to define values shared across many projects. +`scope = Global`. Used for values shared across many projects. -`groupId` controls visibility: +`groupId` controls who can see it: -- **`groupId = null`** — system-wide global. Every project, in every group (and ungrouped projects), can resolve this variable. -- **`groupId = X`** — group-owned global. Only projects whose `Project.GroupId` equals `X` can resolve it. +- **`groupId = null`** is a system-wide global. Every project in every group, including projects with no group, can resolve it. +- **`groupId = X`** is a group-owned global. Only projects whose `Project.GroupId` equals `X` can resolve it. `projectId` must be `null` on global variables. ### Project -`scope = Project`. Used to override a global variable's value for one specific project, or to define a value that only that project needs. +`scope = Project`. Used to override a global variable's value for a specific project, or to define a value that only that project needs. -`projectId` is required and must reference an existing project. `groupId` must be `null` — a project variable inherits its group through the project. +`projectId` is required and must reference an existing project. `groupId` must be `null`. A project variable inherits its group through the project. A project variable with the same `name` as a global variable shadows the global for that project (see [Two-tier lookup](#two-tier-lookup)). ## Scoped values -Each entry in `values` represents the variable's value for a specific scope combination: +Each entry in `values` is a value for a specific scope combination: ```json { @@ -55,64 +55,71 @@ Each entry in `values` represents the variable's value for a specific scope comb } ``` -- `scopes` is a dimension → value map. Dimensions must already exist in the Scopes registry; values must be in the dimension's allowed-values set. Validated on write by [`CreateVariableValidator`](../../src/GroundControl.Api/Features/Variables/CreateVariableValidator.cs). -- An empty `scopes` map (`{}`) marks the **unscoped default** — used when no scoped variant matches the requesting client. -- `value` is always a string. The interpolation rule below treats it as a literal: variable values **cannot themselves contain `{{...}}`** placeholders. Nested interpolation is rejected on write. +- `scopes` is a dimension-to-value map. Dimensions must already exist in the Scopes registry, and values must be in the dimension's allowed-values set. The server validates this on write. +- An empty `scopes` map (`{}`) is the **unscoped default**. It is used when no scoped variant matches the requesting client. +- `value` is always a string. Variable values cannot themselves contain `{{...}}` placeholders. The server rejects nested interpolation on write. -A single variable typically holds one unscoped default plus one variant per environment/region/tier combination it needs to differ on. +A typical variable holds one unscoped default plus one variant per environment, region, or tier it needs to differ on. -## How a placeholder resolves +## How a variable gets used -When a snapshot is published for a project, every config entry value is scanned for `{{name}}` placeholders. The publish pipeline **fans out** each scoped value across the scope tuples its referenced variables touch, resolving each variable per tuple. The snapshot stores the materialized per-tuple values; the client read path picks one tuple at request time using the same scope-matching rules without any further interpolation. +When you publish a snapshot, the server scans every config entry value for `{{name}}` placeholders and bakes the resolved values straight into the snapshot. From that point on, clients just read pre-resolved values; no further interpolation runs at request time. -### Fan-out at publish +### Scoped variables propagate to scopeless entries -For each source scoped value on the entry: +If a config entry has only a default value but references a scoped variable, the server expands the entry into one resolved value per scope the variable covers. You don't have to repeat the scope tuples on the entry itself. -1. **Scan placeholders.** Every `{{name}}` is mapped to a variable via the [two-tier lookup](#two-tier-lookup) below. -2. **Generate target tuples.** The set of scope dimensions referenced by the resolved variables forms the target dim-space; per-dimension domain is the union of distinct values plus an "unspecified" axis. The cartesian product yields the targets to materialize. -3. **Merge with the source's own scope.** A target whose dimension value conflicts with the source scope on the same dimension is dropped; surviving targets become the final scope tuple. -4. **Substitute per target.** [`VariableInterpolator`](../../src/GroundControl.Api/Features/Snapshots/VariableInterpolator.cs) is invoked once per final tuple. Each placeholder is resolved against that tuple by [`ScopeResolver`](../../src/GroundControl.Api/Shared/Resolvers/ScopeResolver.cs) (most-specific scope wins, falling back to the variable's unscoped default). +For example, given this variable: -After fan-out across every source value of the entry, emissions for the same final scope tuple are deduplicated: +| `scopes` | `value` | +|---|---| +| `{}` | `https://api.example.com` | +| `{Environment: dev}` | `https://api.dev.example.com` | +| `{Environment: prod}` | `https://api.prod.example.com` | -- Within one source value, the most-specific target wins. -- Across source values, an emission whose source had a more specific scope tuple wins — this is the **explicit-wins rule**: a literal scoped value on the entry overrides a fan-out emission from a default-scoped sibling that references a variable. +And this scopeless entry: -The orchestration lives in [`ResolvedEntryBuilder`](../../src/GroundControl.Api/Features/Snapshots/ResolvedEntryBuilder.cs); target enumeration is [`TargetTupleBuilder`](../../src/GroundControl.Api/Features/Snapshots/TargetTupleBuilder.cs). +```json +{ "key": "ApiUrl", "values": [{ "value": "{{ApiBase}}" }] } +``` -#### Practical effect +The published snapshot for that entry contains three values: -A scopeless config entry like `MyConfigEntry = "{{MyVariable}}"` referencing a variable with `Environment = dev/prod` tuples produces one resolved snapshot value per environment plus the unscoped default. You don't have to redeclare scope tuples on every entry that uses a scoped variable. +| `scopes` | resolved value | +|---|---| +| `{}` | `https://api.example.com` | +| `{Environment: dev}` | `https://api.dev.example.com` | +| `{Environment: prod}` | `https://api.prod.example.com` | + +A client bound to `{Environment: dev}` reads `https://api.dev.example.com` directly from the snapshot. ### Two-tier lookup -For each placeholder `{{name}}`, the publisher looks the name up in two tiers: +For each placeholder `{{name}}`, the server looks the name up in two tiers: 1. The project's project-scope variables. -2. The project's visible globals. +2. The project's visible globals (system-wide first, then the project's group). + +The first tier that contains the name supplies the variable. The global tier is a fallback, not a merge. -The first tier that contains the name supplies the variable; the global tier is a fallback, not a merge. Within the variable, scope-matching against a target tuple uses the same most-specific-wins algorithm described in [Scope resolution within a tier](#scope-resolution-within-a-tier). +### Most-specific scope wins -### Scope resolution within a tier +Within a single variable's `values` list, the server picks the variant whose `scopes` map is fully contained in the target scope and has the most dimensions in common. If nothing matches, it falls back to the unscoped default. -Within a single variable's `values` list, [`ScopeResolver`](../../src/GroundControl.Api/Shared/Resolvers/ScopeResolver.cs) picks one variant for the target tuple: +If two scoped values are equally specific for the same target, the result is unpredictable. Design your scoped values so combinations don't collide. -1. Filter to candidates whose `scopes` map is a **full match** of the target — every dimension in the candidate must equal the target's value (case-insensitive on the dimension name, exact on the value). -2. Of the matches, the candidate with the **most dimensions** wins. -3. If no scoped candidate matches, fall back to the unscoped default (`scopes = {}`). -4. If there isn't even an unscoped default, the variable contributes no value for this target tuple and the placeholder is **unresolved** — the publish fails with the offending name reported back ([strict-unresolved policy](#strict-unresolved-policy)). +### Literal scoped values override variables -A tie at the same specificity logs a warning and returns the first match — design your scoped values so combinations don't collide. +If you set a scoped value on the entry itself, it always wins over a value the publisher would have produced from a referenced variable. So an entry that authors `Environment: prod = "literal-prod-value"` keeps that literal even when the variable it references would have produced something different for `prod`. -### Strict-unresolved policy +### When publish fails -A placeholder is considered unresolved (and blocks publish with HTTP 422) under either condition: +Publish blocks with a clear 422 error if any placeholder cannot resolve: -- The name does not match any variable in the project lookup nor the global lookup. -- The variable exists but `ScopeResolver` returns no match for at least one target tuple required by fan-out — typically a variable with no unscoped default referenced by an entry whose targets include the empty tuple. +- The name doesn't match any variable in either tier (typo, deleted variable). +- The variable exists but has no value for one of the scope combinations the entry needs (typically a variable with no unscoped default, referenced by an entry that needs a default). -To unblock, add a default to the variable, or scope the entry more tightly so its targets only span tuples the variable covers. +Fix it by adding the missing variable, adding an unscoped default to the variable, or scoping the entry tightly enough that only covered combinations are needed. ### Visibility from a project's perspective @@ -126,17 +133,15 @@ For a project `P` in group `G`, the variables visible at publish time are: | Global variables where `groupId = some other group` | **No** | | Project variables on a different project | **No** | -Implemented by [`VariableStore.GetGlobalVariablesForGroupAsync`](../../src/GroundControl.Persistence.MongoDb/Stores/VariableStore.cs) and [`SnapshotResolver.ResolveAndInterpolateAsync`](../../src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs). - ## Sensitivity Setting `isSensitive = true` does three things: -1. **Encryption at rest** — values are encrypted by `SensitiveSourceValueProtector` before being written to MongoDB. -2. **Masking on read** — API responses replace each value with `***` unless the caller has the `sensitive_values:decrypt` permission and adds `?decrypt=true`. -3. **Sensitivity propagation** — any snapshot config entry that interpolates a sensitive variable is itself treated as sensitive. The flag flips on the resolved entry even if the entry was authored as non-sensitive. +1. **Encryption at rest.** Values are encrypted before being written to MongoDB. +2. **Masking on read.** API responses replace each value with `***` unless the caller has the `sensitive_values:decrypt` permission and adds `?decrypt=true`. +3. **Sensitivity propagation.** Any snapshot entry that uses a sensitive variable becomes sensitive itself, even if the entry was authored as non-sensitive. -The mask sentinel `***` is reserved: you cannot save a sensitive variable whose plaintext value is literally `***` (the validator rejects it) — it would otherwise be indistinguishable from a masked read. +The mask sentinel `***` is reserved. You cannot save a sensitive variable whose plaintext value is literally `***`. The validator rejects it because it would be indistinguishable from a masked read. ## Choosing the right tier @@ -146,26 +151,26 @@ The mask sentinel `***` is reserved: you cannot save a sensitive variable whose | One value shared across every project in a single group | `Global`, `groupId = ` | | A per-project tweak of a shared value (same name) | `Project` variable with the same `name` as the global | | A value only one project ever uses | `Project` variable, no global counterpart | -| Different values per environment but the same name everywhere | One variable with multiple `ScopedValue` entries (`{Environment: prod}`, `{Environment: staging}`, plus an unscoped default) | -| Sharing a single value across **two specific groups** but not others | Not directly supported — either make it system-wide and accept the broader visibility, or duplicate it as a group-owned global in each group | +| Different values per environment but the same name everywhere | One variable with multiple `ScopedValue` entries (one per environment), plus an unscoped default | +| Sharing a single value across **two specific groups** but not others | Not directly supported. Either make it system-wide and accept the broader visibility, or duplicate it as a group-owned global in each group. | ## Uniqueness rules -Enforced by partial unique indexes (case-insensitive) in [`VariableConfiguration`](../../src/GroundControl.Persistence.MongoDb/Conventions/VariableConfiguration.cs): +The server enforces these constraints (case-insensitive on `name`): -- `(scope=Global, groupId, name)` is unique. Two globals can share a name only if they have different `groupId`s (including `null`). -- `(scope=Project, projectId, name)` is unique. +- Two `Global` variables can share a name only if they have different `groupId`s (including `null`). +- Two `Project` variables can share a name only if they belong to different projects. -`name` is treated case-insensitively for both uniqueness and placeholder lookup. +`name` is case-insensitive everywhere it is looked up. -## Sharp edges +## Things to watch out for -- **Same name at system-wide and group tier.** A `Global` variable with `groupId = null` and another `Global` variable with `groupId = X` are both stored — the unique index allows it because `groupId` differs. From a project in group `X`, both end up in the same lookup dictionary keyed by name, so whichever the dictionary build encounters last wins. The result is **order-dependent**. Don't rely on this for project-specific overrides — use a `Project`-scope variable instead. -- **No multi-group sharing.** There is no link table, no `groupId[]`, and no template-style attachment. A variable belongs to exactly one tier (system-wide or one group, or one project). -- **Variables can't reference variables.** `{{...}}` is rejected on write inside variable values; only config-entry values may contain placeholders. -- **Resolution is publish-time, not write-time.** A config entry can be saved with `{{Foo}}` even if `Foo` doesn't exist yet. The publish call is what fails when the placeholder can't be resolved. -- **Tied scope specificity.** If two scoped values in the same variable match a client with the same dimension count, you get a warning log and a non-deterministic pick. Make scope combinations unambiguous. -- **Snapshot size grows with fan-out.** A scopeless entry that references a variable with many scope tuples produces one snapshot value per tuple. Combined with multi-dimensional variables this expands cartesian-style. The 16MB BSON snapshot limit catches runaway expansion at publish — keep multi-dimensional variables narrow when an entry references several of them. +- **System-wide and group globals with the same name.** A `Global` variable with `groupId = null` and another with `groupId = X` are both valid because their `groupId`s differ. From a project in group `X`, both end up in the lookup keyed by name and the result is unpredictable. Don't rely on this for project-specific overrides; use a `Project`-scope variable instead. +- **No multi-group sharing.** A variable belongs to exactly one tier (system-wide, one group, or one project). There is no way to share it across two specific groups without duplication. +- **Variables can't reference variables.** `{{...}}` is rejected on write inside variable values. Only config entry values may contain placeholders. +- **Resolution is publish-time, not write-time.** A config entry can be saved with `{{Foo}}` even if `Foo` doesn't exist yet. The publish call fails when the placeholder can't be resolved. +- **Tied scope specificity.** If two scoped values in the same variable are equally specific for a request, the pick is unpredictable. Design combinations so they are unambiguous. +- **Snapshots can grow when many entries reference scoped variables.** Each scopeless entry that uses a scoped variable produces one snapshot value per scope. Multi-dimensional variables expand into a larger combination set. The 16 MB snapshot limit catches runaway expansion at publish time, but keep it in mind for variables that span many dimensions. ## Worked examples @@ -196,7 +201,7 @@ In a config entry: "values": [{ "value": "{{ApiBase}}/v1" }] } ``` -The entry has a single scopeless source value. At publish, fan-out expands it into three resolved snapshot values — one per `Environment` tuple the variable touches plus the unscoped default: +The entry has one value with no scope on the entry itself. When you publish, the server expands it across the variable's environments: | Snapshot scope | Resolved value | |---|---| @@ -204,7 +209,7 @@ The entry has a single scopeless source value. At publish, fan-out expands it in | `{Environment: staging}` | `https://api.staging.example.com/v1` | | `{Environment: prod}` | `https://api.example.com/v1` | -A client bound to `{Environment: staging}` is then served `https://api.staging.example.com/v1` straight from the snapshot — no further interpolation runs. +A client bound to `{Environment: staging}` is served `https://api.staging.example.com/v1` directly from the snapshot. ### Group-owned secret with a per-project override @@ -237,7 +242,7 @@ One project in that group needs to point at a dedicated read-replica. Define a p } ``` -The reports project in `prod` resolves `{{PrimaryDb}}` to the read-replica string. In any other environment the project variable has no matching scope, so resolution falls back to the global's unscoped default. Other projects in the same group are unaffected — they keep using the global value. +The reports project in `prod` resolves `{{PrimaryDb}}` to the read-replica string. In any other environment, the project variable has no matching scope, so resolution falls back to the global's unscoped default. Other projects in the same group are unaffected. They keep using the global value. ## Related From 8df1e1943141b3506ed92084b79d505766d36a1f Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 13:29:10 +0100 Subject: [PATCH 7/7] fix(tests): rename tier scope to release scope in variable interpolation workflow --- .../VariableInterpolationWorkflow.cs | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/GroundControl.E2E.Tests/Scenarios/VariableInterpolationWorkflow.cs b/tests/GroundControl.E2E.Tests/Scenarios/VariableInterpolationWorkflow.cs index 8a83f6af..99c7ff88 100644 --- a/tests/GroundControl.E2E.Tests/Scenarios/VariableInterpolationWorkflow.cs +++ b/tests/GroundControl.E2E.Tests/Scenarios/VariableInterpolationWorkflow.cs @@ -195,12 +195,15 @@ public Task Step07_LinkSdkReceivesInterpolatedValue() => RunStep(7, () => }); [Fact, Step(8)] - public Task Step08_CreateTierScope() => RunStep(8, async () => + public Task Step08_CreateReleaseScope() => RunStep(8, async () => { - // Arrange & Act + // Arrange & Act — use a workflow-unique dimension name. The assembly-level AspireFixture + // shares MongoDB across scenarios, so reusing a dimension owned by another workflow + // (e.g., 'tier' from ScopedValueResolutionWorkflow) collides with that workflow's scope + // values and silently breaks downstream variable creation. var result = await Cli.RunAsync(TestCancellationToken, "scope", "create", - "--dimension", "tier", + "--dimension", "release", "--values", "dev,prod"); // Assert @@ -210,7 +213,7 @@ public Task Step08_CreateTierScope() => RunStep(8, async () => [Fact, Step(9)] public Task Step09_CreateScopedVariable() => RunStep(9, async () => { - // Arrange — variable defines per-tier values plus an unscoped default. The PRD's strict + // Arrange — variable defines per-release values plus an unscoped default. The PRD's strict // policy says fan-out must materialize each tuple in the published snapshot. var projectId = Get(ProjectIdKey); @@ -221,8 +224,8 @@ public Task Step09_CreateScopedVariable() => RunStep(9, async () => "--scope", "Project", "--project-id", projectId.ToString(), "--value", "default=baseline", - "--value", "tier:dev=dev-feature", - "--value", "tier:prod=prod-feature"); + "--value", "release:dev=dev-feature", + "--value", "release:prod=prod-feature"); // Assert result.ShouldSucceed(); @@ -272,8 +275,8 @@ public Task Step11_PublishSnapshotWithFanOut() => RunStep(11, async () => entry.ShouldNotBeNull(); entry.Values.Count.ShouldBe(3); entry.Values.ShouldContain(v => v.Scopes.Count == 0 && v.Value == "baseline"); - entry.Values.ShouldContain(v => v.Scopes.ContainsKey("tier") && v.Scopes["tier"] == "dev" && v.Value == "dev-feature"); - entry.Values.ShouldContain(v => v.Scopes.ContainsKey("tier") && v.Scopes["tier"] == "prod" && v.Value == "prod-feature"); + entry.Values.ShouldContain(v => v.Scopes.ContainsKey("release") && v.Scopes["release"] == "dev" && v.Value == "dev-feature"); + entry.Values.ShouldContain(v => v.Scopes.ContainsKey("release") && v.Scopes["release"] == "prod" && v.Value == "prod-feature"); }); [Fact, Step(12)] @@ -287,7 +290,7 @@ public Task Step12_CreateDevAndProdClients() => RunStep(12, async () => "client", "create", "--project-id", projectId.ToString(), "--name", "e2e-fanout-dev", - "--scopes", "tier=dev"); + "--scopes", "release=dev"); // Assert devResult.ShouldSucceed(); @@ -300,7 +303,7 @@ public Task Step12_CreateDevAndProdClients() => RunStep(12, async () => "client", "create", "--project-id", projectId.ToString(), "--name", "e2e-fanout-prod", - "--scopes", "tier=prod"); + "--scopes", "release=prod"); // Assert prodResult.ShouldSucceed();