diff --git a/docs/syntax/automated_settings.md b/docs/syntax/automated_settings.md index 87b3873c77..f7761f4047 100644 --- a/docs/syntax/automated_settings.md +++ b/docs/syntax/automated_settings.md @@ -11,6 +11,23 @@ The `{settings}` directive is generic. Although the largest current examples com :::: ``` +#### Options + +`:deployment: ` +: Filters the rendered settings to only those available for the specified deployment type. When omitted, all settings are shown regardless of deployment. + + Valid values: `ech` (Elastic Cloud Hosted), `ece` (Elastic Cloud Enterprise), `eck` (Elastic Cloud on Kubernetes), `self` (self-managed). + + A setting is considered available for a deployment type if its `applies_to.deployment` block explicitly lists that deployment with a non-removed lifecycle. If a setting has `applies_to` metadata but no entry for the requested deployment, it is treated as unavailable and hidden. + + Settings with no `applies_to` metadata at all are always shown, regardless of the filter. + + ```markdown + ::::{settings} /syntax/settings-with-applies-example.yml + :deployment: ech + :::: + ``` + ### Schema The schema below reflects the structure currently supported by docs-builder. For the original settings-gen schema that inspired this format, see [the Kibana schema reference](https://github.com/elastic/kibana/tree/main/docs/settings-gen#schema). diff --git a/docs/testing/kibana-settings-yaml-samples.md b/docs/testing/kibana-settings-yaml-samples.md index 61e0d9ef2d..fdcb5e0666 100644 --- a/docs/testing/kibana-settings-yaml-samples.md +++ b/docs/testing/kibana-settings-yaml-samples.md @@ -33,3 +33,29 @@ Some descriptions use links and anchors that target the real Kibana reference pa :::{settings} /testing/kibana-security-settings.yml ::: + +## Deployment filter preview + +The `:deployment:` option filters settings to only those available for the specified deployment type. +Settings with no `applies_to` are always shown. Settings with `applies_to` that do not explicitly list +the deployment are treated as unavailable. + +Accepted values: `ech`, `ece`, `eck`, `self`. + +### kibana-general-settings.yml — ECH only + +:::{settings} /testing/kibana-general-settings.yml +:deployment: ech +::: + +### kibana-general-settings.yml — self-managed only + +:::{settings} /testing/kibana-general-settings.yml +:deployment: self +::: + +### kibana-security-settings.yml — ECH only + +:::{settings} /testing/kibana-security-settings.yml +:deployment: ech +::: diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index a59504c6cd..b0c7891252 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -502,6 +502,7 @@ private static void WriteSettingsBlock(HtmlRenderer renderer, SettingsBlock bloc SettingsCollection = settings, GroupHeadingLevel = block.GroupHeadingLevel, VersionsConfig = block.Build.VersionsConfiguration, + ActiveDeploymentFilter = block.ActiveDeploymentFilter, RenderMarkdown = s => { var normalized = SettingsMarkdownNormalizer.Normalize(s, settings.Product); diff --git a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsBlock.cs b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsBlock.cs index cff7043561..f1b50a2d01 100644 --- a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsBlock.cs @@ -30,6 +30,12 @@ public class SettingsBlock(DirectiveBlockParser parser, ParserContext context) : public bool Found { get; private set; } + /// + /// When set, only settings available for this deployment type are rendered. + /// Accepted values: ech, ece, eck, self. + /// + public string? ActiveDeploymentFilter { get; private set; } + /// /// Heading level for each YAML group title (e.g. 3 when the directive follows an ## heading), same rule as . /// @@ -48,19 +54,26 @@ public IEnumerable GeneratedTableOfContent return []; var level = GroupHeadingLevel; - return settings.Groups.Select(g => new PageTocItem - { - Heading = g.Name ?? string.Empty, - Slug = SettingsViewModel.GroupHeadingSlug(g), - Level = level - }).Where(t => !string.IsNullOrEmpty(t.Slug)); + return settings.Groups + .Where(g => ActiveDeploymentFilter is null || + DeploymentFilter.AnyVisible(g.Settings, ActiveDeploymentFilter, null)) + .Select(g => new PageTocItem + { + Heading = g.Name ?? string.Empty, + Slug = SettingsViewModel.GroupHeadingSlug(g), + Level = level + }).Where(t => !string.IsNullOrEmpty(t.Slug)); } } //TODO add all options from //https://mystmd.org/guide/directives#directive-include - public override void FinalizeAndValidate(ParserContext context) => ExtractInclusionPath(context); + public override void FinalizeAndValidate(ParserContext context) + { + ExtractInclusionPath(context); + ValidateDeploymentFilter(); + } /// /// Records docset substitution keys referenced in raw settings YAML (e.g. page_description) so @@ -117,6 +130,24 @@ private static void PrepareSettingForRendering(Setting setting, ParserContext co PrepareSettingForRendering(child, context); } + private void ValidateDeploymentFilter() + { + var raw = Prop("deployment"); + if (raw is null) + return; + + var trimmed = raw.Trim().ToLowerInvariant(); + if (!DeploymentFilter.ValidValues.Contains(trimmed)) + { + this.EmitWarning( + $"Unknown deployment filter '{raw}'. Valid values are: {string.Join(", ", DeploymentFilter.ValidValues)}." + ); + return; + } + + ActiveDeploymentFilter = trimmed; + } + private void ExtractInclusionPath(ParserContext context) { var includePath = Arguments; @@ -206,33 +237,41 @@ private string[] LoadGeneratedAnchors() if (TryLoadSettings() is not { } settings) return []; - return CollectSettingIds(settings).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + return CollectSettingIds(settings, ActiveDeploymentFilter).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } - private static IEnumerable CollectSettingIds(YamlSettings yaml) + private static IEnumerable CollectSettingIds(YamlSettings yaml, string? deploymentFilter) { if (!string.IsNullOrWhiteSpace(yaml.Id)) yield return yaml.Id; foreach (var group in yaml.Groups) { + var groupVisible = deploymentFilter is null || + DeploymentFilter.AnyVisible(group.Settings, deploymentFilter, null); + if (!groupVisible) + continue; + var groupSlug = SettingsViewModel.GroupHeadingSlug(group); if (!string.IsNullOrEmpty(groupSlug)) yield return groupSlug; - foreach (var id in CollectSettingIds(group.Settings, parentName: null)) + foreach (var id in CollectSettingIds(group.Settings, parentName: null, deploymentFilter)) yield return id; } } - private static IEnumerable CollectSettingIds(Setting[] settings, string? parentName) + private static IEnumerable CollectSettingIds(Setting[] settings, string? parentName, string? deploymentFilter) { foreach (var setting in settings) { + if (deploymentFilter is not null && !setting.IsVisibleForDeployment(deploymentFilter, null)) + continue; + var displayName = SettingsViewModel.ComposeSettingName(parentName, setting.Name); var fragmentId = SettingsViewModel.SettingFragmentId(setting, displayName); if (!string.IsNullOrWhiteSpace(fragmentId)) yield return fragmentId; - foreach (var id in CollectSettingIds(setting.Settings, displayName)) + foreach (var id in CollectSettingIds(setting.Settings, displayName, deploymentFilter)) yield return id; } } diff --git a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsView.cshtml b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsView.cshtml index e3b34554b7..2e58d0a808 100644 --- a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsView.cshtml @@ -6,7 +6,10 @@ @RenderAdmonition("note", Model.SettingsCollection.Note) @foreach (var group in Model.SettingsCollection.Groups) { - @RenderGroup(group) + if (Model.IsGroupVisible(group)) + { + @RenderGroup(group) + } } @functions { @@ -65,12 +68,15 @@ @RenderMarkdown(group.Description) @RenderAdmonition("note", group.Note) @RenderExample(group.Example) -
- @foreach (var setting in group.Settings) +
+ @foreach (var setting in group.Settings) + { + if (Model.IsSettingVisible(setting, null)) { @RenderSetting(setting, null, null) } -
+ } +
return HtmlString.Empty; } @@ -99,7 +105,10 @@
@foreach (var child in setting.Settings) { - @RenderSetting(child, displayName, appliesTo) + if (Model.IsSettingVisible(child, appliesTo)) + { + @RenderSetting(child, displayName, appliesTo) + } }
} diff --git a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsViewModel.cs index 25909cc591..2a3d90e84f 100644 --- a/src/Elastic.Markdown/Myst/Directives/Settings/SettingsViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Settings/SettingsViewModel.cs @@ -22,6 +22,20 @@ public class SettingsViewModel /// Markdown heading level for each group section (1–6). public required int GroupHeadingLevel { get; init; } + /// + /// When set, only settings visible for this deployment type are rendered. + /// Accepted values: ech, ece, eck, self. + /// + public string? ActiveDeploymentFilter { get; init; } + + public bool IsGroupVisible(SettingsGrouping group) => + ActiveDeploymentFilter is null || + DeploymentFilter.AnyVisible(group.Settings, ActiveDeploymentFilter, null); + + public bool IsSettingVisible(Setting setting, ApplicableTo? inheritedAppliesTo) => + ActiveDeploymentFilter is null || + setting.IsVisibleForDeployment(ActiveDeploymentFilter, inheritedAppliesTo); + public string RenderAppliesToInline(ApplicableTo? appliesTo) => RenderAppliesToPlacement(appliesTo, ApplicabilityBadgePlacement.Combined); diff --git a/src/Elastic.Markdown/Myst/Directives/Settings/StructuredSettings.cs b/src/Elastic.Markdown/Myst/Directives/Settings/StructuredSettings.cs index 44182d42e4..9ab3074305 100644 --- a/src/Elastic.Markdown/Myst/Directives/Settings/StructuredSettings.cs +++ b/src/Elastic.Markdown/Myst/Directives/Settings/StructuredSettings.cs @@ -116,3 +116,54 @@ public static class SettingDisplay _ => value.ToString() }; } + +public static class DeploymentFilter +{ + /// Valid filter tokens accepted by the :deployment: directive option. + public static readonly IReadOnlySet ValidValues = + new HashSet(StringComparer.OrdinalIgnoreCase) { "ech", "ece", "eck", "self" }; + + /// + /// Returns the for the given deployment filter key, + /// mapping the canonical ech token to the ess model field. + /// Returns null when the deployment type is not mentioned (i.e. not available). + /// + public static AppliesCollection? GetForDeployment(this DeploymentApplicability deployment, string key) => + key.ToLowerInvariant() switch + { + "ech" => deployment.Ess, + "ece" => deployment.Ece, + "eck" => deployment.Eck, + "self" => deployment.Self, + _ => null + }; + + /// + /// Returns true when the setting should be shown for the given deployment filter. + /// A setting with no applies_to at all is always visible (no restriction). + /// A setting with applies_to that does not explicitly list the deployment is considered unavailable. + /// + public static bool IsVisibleForDeployment(this Setting setting, string deploymentFilter, ApplicableTo? inheritedAppliesTo) + { + var appliesTo = setting.ResolveAppliesTo(inheritedAppliesTo); + + if (appliesTo is null) + return true; + + if (appliesTo.Deployment is not { } deployment) + return false; + + var col = deployment.GetForDeployment(deploymentFilter); + if (col is null) + return false; + + return col.Any(a => a.Lifecycle is not ProductLifecycle.Removed and not ProductLifecycle.Unavailable); + } + + /// Returns true when at least one setting (recursively) in is visible. + public static bool AnyVisible(Setting[] settings, string deploymentFilter, ApplicableTo? inheritedAppliesTo) => + settings.Any(s => + s.IsVisibleForDeployment(deploymentFilter, inheritedAppliesTo) || + AnyVisible(s.Settings, deploymentFilter, s.ResolveAppliesTo(inheritedAppliesTo)) + ); +} diff --git a/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs b/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs index 6ba6f8fa4e..3c98bc2235 100644 --- a/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/SettingsInclusion/IncludeTests.cs @@ -252,6 +252,111 @@ public void DoesNotRenderGenericStackBadgeOrUnavailableSupportedOnEntry() } } +/// +/// Uses the real kibana-general-settings.yml fixture. +/// In that file every setting has an explicit applies_to with either ech: ga or ech: unavailable. +/// - execution_context.enabled → ech: ga, self: ga → ECH visible +/// - console.ui.enabled → ech: unavailable, self: ga → ECH hidden +/// Settings with no applies_to at all (universally available) are also visible. +/// +public class DeploymentFilterEchOnKibanaGeneralSettings(ITestOutputHelper output) : DirectiveTest(output, +$$""" +:::{settings} /{{GeneralSettingsPath.Replace("docs/", "")}} +:deployment: ech +::: +""" +) +{ + private static readonly string GeneralSettingsPath = "docs/testing/kibana-general-settings.yml"; + + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + var fullPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, GeneralSettingsPath); + fileSystem.AddFile(GeneralSettingsPath, System.IO.File.ReadAllText(fullPath)); + } + + [Fact] + public void ShowsEchGaSetting() => + Html.Should().Contain("execution_context.enabled"); + + [Fact] + public void HidesEchUnavailableSetting() => + Html.Should().NotContain("console.ui.enabled"); +} + +/// +/// When a setting has applies_to but ECH is not mentioned at all (only self: ga), +/// it must be treated as unavailable for ECH — "missing means unavailable". +/// Uses the real kibana-general-settings.yml which has self-only and ech:unavailable patterns. +/// +public class DeploymentFilterEchMissingMeansUnavailable(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{settings} _settings/self-only.yml +:deployment: ech +::: +""" +) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + // language=yaml + fileSystem.AddFile("docs/_settings/self-only.yml", """ +groups: + - group: Example + settings: + - setting: self.only.setting + description: Only self is listed — ech is missing so unavailable. + applies_to: + self: ga + - setting: ech.explicit.setting + description: ECH is explicitly listed as ga. + applies_to: + ech: ga + self: ga + - setting: no.applies.to.setting + description: No applies_to at all — universally available. +"""); + } + + [Fact] + public void HidesSettingWhenEchIsMissing() => + Html.Should().NotContain("self.only.setting"); + + [Fact] + public void ShowsSettingWithExplicitEchGa() => + Html.Should().Contain("ech.explicit.setting"); + + [Fact] + public void ShowsSettingWithNoAppliesTo() => + Html.Should().Contain("no.applies.to.setting"); +} + +public class DeploymentFilterWithUnknownValueEmitsWarning(ITestOutputHelper output) : DirectiveTest(output, +$$""" +:::{settings} /{{GeneralSettingsPath.Replace("docs/", "")}} +:deployment: invalid-deployment +::: +""" +) +{ + private static readonly string GeneralSettingsPath = "docs/testing/kibana-general-settings.yml"; + + protected override void AddToFileSystem(MockFileSystem fileSystem) + { + var fullPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, GeneralSettingsPath); + fileSystem.AddFile(GeneralSettingsPath, System.IO.File.ReadAllText(fullPath)); + } + + [Fact] + public void EmitsWarning() => + Collector.Diagnostics.Should() + .Contain(d => d.Severity == Severity.Warning && d.Message.Contains("invalid-deployment")); + + [Fact] + public void StillRendersAllSettingsWhenFilterIsInvalid() => + Html.Should().Contain("execution_context.enabled"); +} + public class AppliesToInlineRoleInDescriptionRendersAsBadge(ITestOutputHelper output) : DirectiveTest(output, """ :::{settings} _settings/applies-to-in-description.yml