Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/syntax/storybook.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ For local Kibana testing, `yarn storybook_docs shared_ux --serve` serves the reg
http://127.0.0.1:6007/storybook-docs/docs_registry.json
```

## Environment-dependent registry

The registry URL is often environment-dependent (a local server, a per-PR preview bucket, or the published `main` artifact). Rather than hand-editing `docset.yml` per environment, `registry` supports shell-style environment-variable interpolation with a committed default:

```yaml
storybook:
registry: ${KIBANA_STORYBOOK_REGISTRY:-https://ci-artifacts.kibana.dev/storybooks/main/storybook-docs/docs_registry.json}
```

The committed value is then identical across all environments:

- `${VAR}` resolves to the value of `VAR`, or empty when unset.
- `${VAR:-default}` resolves to `VAR` when set and non-empty, otherwise to `default`.
- With no environment variable set (for example a `main` build), the committed `default` is used. To target a different registry, export the variable before building, for example `KIBANA_STORYBOOK_REGISTRY=http://127.0.0.1:6007/storybook-docs/docs_registry.json docs-builder serve`.

Because docs-builder renders untrusted branches, only an explicit allow-list of variable names is interpolated. Currently that is `KIBANA_STORYBOOK_REGISTRY`. Any other variable is left literal and a warning is emitted, so a `docset.yml` can never read arbitrary build secrets such as `${AWS_SECRET_ACCESS_KEY}`.

When the interpolated registry is unreachable (for example an ephemeral per-PR registry that has not been published yet), docs-builder falls back to the committed default. If the committed default cannot be read either, the build reports an error.

## Usage

Use a registry ID directly:
Expand Down
11 changes: 8 additions & 3 deletions src/Elastic.Documentation.Configuration/BuildContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati

public GitCheckoutInformation Git { get; }

public IEnvironmentVariables Environment { get; }

public IDiagnosticsCollector Collector { get; }

public bool Force { get; init; }
Expand Down Expand Up @@ -74,9 +76,10 @@ public string? UrlPathPrefix
public BuildContext(
IDiagnosticsCollector collector,
ScopedFileSystem fileSystem,
IConfigurationContext configurationContext
IConfigurationContext configurationContext,
IEnvironmentVariables? environment = null
)
: this(collector, fileSystem, fileSystem, configurationContext, ExportOptions.Default, null, null)
: this(collector, fileSystem, fileSystem, configurationContext, ExportOptions.Default, null, null, environment: environment)
{
}

Expand All @@ -88,13 +91,15 @@ public BuildContext(
IReadOnlySet<Exporter> availableExporters,
string? source = null,
string? output = null,
GitCheckoutInformation? gitCheckoutInformation = null
GitCheckoutInformation? gitCheckoutInformation = null,
IEnvironmentVariables? environment = null
)
{
Collector = collector;
ReadFileSystem = readFileSystem;
WriteFileSystem = writeFileSystem;
AvailableExporters = availableExporters;
Environment = environment ?? SystemEnvironmentVariables.Instance;
SearchConfiguration = configurationContext.SearchConfiguration;
VersionsConfiguration = configurationContext.VersionsConfiguration;
ConfigurationFileProvider = configurationContext.ConfigurationFileProvider;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ public record ConfigurationFile

public string? StorybookRegistry { get; }

/// <summary>
/// Environment-independent <c>storybook.registry</c> value (committed default). Non-null only when an allow-listed
/// environment variable changed <see cref="StorybookRegistry"/>, so the directive can degrade to the committed
/// registry when the environment-supplied one is unreachable (e.g. an ephemeral PR registry that is not yet published).
/// </summary>
public string? StorybookRegistryFallback { get; }

/// <summary>
/// Resolved API configurations with template and specification file information.
/// </summary>
Expand Down Expand Up @@ -248,7 +255,18 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte
}

if (docSetFile.Storybook is not null)
StorybookRegistry = docSetFile.Storybook.Registry?.Trim();
{
var interpolated = EnvironmentInterpolation.Interpolate(
docSetFile.Storybook.Registry?.Trim(),
context.Environment,
name => context.EmitWarning(
context.ConfigurationPath,
$"'storybook.registry' references environment variable '{name}' which is not allow-listed for interpolation and is left literal. Allowed: {string.Join(", ", EnvironmentInterpolation.AllowedVariables)}."
)
);
StorybookRegistry = interpolated.Value;
StorybookRegistryFallback = interpolated.Fallback;
}

// Process products from docset - resolve ProductLinks to Product objects
if (docSetFile.Products.Count > 0)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Collections.Frozen;
using System.Text;
using System.Text.RegularExpressions;
using Elastic.Documentation;

namespace Elastic.Documentation.Configuration;

/// <summary>
/// Outcome of interpolating shell-style <c>${VAR}</c> / <c>${VAR:-default}</c> expressions into a config value.
/// </summary>
/// <param name="Value">The resolved value, using environment variables where set and defaults otherwise.</param>
/// <param name="Fallback">
/// The environment-independent value (every expression replaced by its committed default). Non-null only when an
/// allow-listed environment variable actually changed the result, so consumers can degrade to the committed default
/// if the environment-supplied value turns out to be unusable (e.g. an ephemeral PR registry that 404s).
/// </param>
public sealed record InterpolatedValue(string? Value, string? Fallback);

/// <summary>
/// Resolves shell-style <c>${VAR}</c> and <c>${VAR:-default}</c> expressions in committed config values.
/// docs-builder renders untrusted PR branches, so interpolation is restricted to an explicit allow-list — naive access
/// to the full process environment would let a malicious <c>docset.yml</c> exfiltrate CI secrets (e.g. <c>${AWS_SECRET_ACCESS_KEY}</c>).
/// </summary>
public static partial class EnvironmentInterpolation
{
/// <summary>Environment variable names that may be interpolated into committed config values.</summary>
public static readonly FrozenSet<string> AllowedVariables =
new HashSet<string>(StringComparer.Ordinal) { "KIBANA_STORYBOOK_REGISTRY" }.ToFrozenSet(StringComparer.Ordinal);

[GeneratedRegex(@"\$\{(?<name>[A-Za-z_][A-Za-z0-9_]*)(?::-(?<default>[^}]*))?\}", RegexOptions.CultureInvariant)]
private static partial Regex ExpressionRegex();

/// <summary>
/// Interpolates allow-listed environment variables into <paramref name="raw"/>. Non-allow-listed expressions are
/// left literal (never read from the environment) and reported via <paramref name="onDisallowed"/>.
/// </summary>
public static InterpolatedValue Interpolate(string? raw, IEnvironmentVariables environment, Action<string>? onDisallowed = null)
{
if (string.IsNullOrEmpty(raw) || !raw.Contains("${", StringComparison.Ordinal))
return new InterpolatedValue(raw, null);

var resolved = new StringBuilder(raw.Length);
var committed = new StringBuilder(raw.Length);
var lastIndex = 0;
var environmentChangedValue = false;

foreach (Match match in ExpressionRegex().Matches(raw))
{
var literal = raw[lastIndex..match.Index];
_ = resolved.Append(literal);
_ = committed.Append(literal);
lastIndex = match.Index + match.Length;

var name = match.Groups["name"].Value;
var defaultGroup = match.Groups["default"];
var defaultValue = defaultGroup.Success ? defaultGroup.Value : string.Empty;

if (!AllowedVariables.Contains(name))
{
onDisallowed?.Invoke(name);
_ = resolved.Append(match.Value);
_ = committed.Append(match.Value);
continue;
}

var environmentValue = environment.GetEnvironmentVariable(name);
if (!string.IsNullOrEmpty(environmentValue))
{
_ = resolved.Append(environmentValue);
environmentChangedValue = true;
}
else
_ = resolved.Append(defaultValue);

_ = committed.Append(defaultValue);
}

var tail = raw[lastIndex..];
_ = resolved.Append(tail);
_ = committed.Append(tail);

var resolvedValue = resolved.ToString();
var committedValue = committed.ToString();
var fallback = environmentChangedValue && !string.Equals(resolvedValue, committedValue, StringComparison.Ordinal)
? committedValue
: null;

return new InterpolatedValue(resolvedValue, fallback);
}
}
3 changes: 3 additions & 0 deletions src/Elastic.Documentation/IDocumentationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public interface IDocumentationSetContext : IDocumentationContext
{
IDirectoryInfo DocumentationSourceDirectory { get; }
GitCheckoutInformation Git { get; }

/// <summary>Environment variables used to resolve env-dependent config values; injectable so tests are deterministic.</summary>
IEnvironmentVariables Environment { get; }
}

public static class DocumentationContextExtensions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,42 @@ private bool TryLoadRegistry(out StorybookRegistry registry)
return false;
}

if (TryReadRegistry(rawRegistry, out var registryJson, out var error))
return TryDeserializeRegistry(rawRegistry, registryJson, out registry);

// Only an environment-supplied registry is treated as best-effort: an ephemeral per-PR URL may not be published
// yet, so degrade to the committed default. A committed/static registry (no env fallback) that fails to read is
// an authoring error and stays a hard error so typos and broken paths don't silently drop every embed.
var fallback = Build.Configuration.StorybookRegistryFallback;
var hasEnvironmentFallback = !string.IsNullOrWhiteSpace(fallback)
&& !string.Equals(fallback, rawRegistry, StringComparison.Ordinal);
if (!hasEnvironmentFallback)
{
this.EmitError($"storybook registry could not be read: {rawRegistry}", error);
return false;
}

this.EmitWarning($"storybook registry '{rawRegistry}' could not be read; falling back to the committed default '{fallback}'.");

if (TryReadRegistry(fallback!, out registryJson, out error))
return TryDeserializeRegistry(fallback!, registryJson, out registry);

this.EmitError($"storybook registry could not be read: {fallback}", error);
return false;
}

private bool TryReadRegistry(string rawRegistry, out string registryJson, out Exception? error)
{
try
{
var registryJson = ReadRegistry(rawRegistry);
return TryDeserializeRegistry(rawRegistry, registryJson, out registry);
registryJson = ReadRegistry(rawRegistry);
error = null;
return true;
}
catch (Exception e)
{
this.EmitError($"storybook registry could not be read: {rawRegistry}", e);
registryJson = string.Empty;
error = e;
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ public class ConfigurationFileApiTests
[Fact]
public void ConfigurationFile_ProcessesNewApiSequenceConfiguration()
{
// Arrange
// Arrange
var docSetFile = new DocumentationSetFile
{
Api = new Dictionary<string, ApiProductSequence>
Expand Down Expand Up @@ -459,5 +459,6 @@ private sealed class MockDocumentationSetContext(
public BuildType BuildType => BuildType.Isolated;
public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory;
public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem);
public IEnvironmentVariables Environment => SystemEnvironmentVariables.Instance;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,6 @@ private sealed class MockDocumentationSetContext(
public BuildType BuildType => BuildType.Isolated;
public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory;
public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem);
public IEnvironmentVariables Environment => SystemEnvironmentVariables.Instance;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Collections.Frozen;
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using AwesomeAssertions;
using Elastic.Documentation.Configuration.Builder;
using Elastic.Documentation.Configuration.Products;
using Elastic.Documentation.Configuration.Toc;
using Elastic.Documentation.Configuration.Versions;
using Elastic.Documentation.Diagnostics;
using Nullean.ScopedFileSystem;

namespace Elastic.Documentation.Configuration.Tests;

public class ConfigurationFileStorybookRegistryTests
{
private const string Default = "https://ci-artifacts.kibana.dev/storybooks/main/storybook-docs/docs_registry.json";
private const string Expression = $"${{KIBANA_STORYBOOK_REGISTRY:-{Default}}}";

[Fact]
public void UnsetVariable_ResolvesToCommittedDefault_WithNoFallback()
{
var config = CreateConfiguration(Expression, new MockEnvironment());

config.StorybookRegistry.Should().Be(Default);
config.StorybookRegistryFallback.Should().BeNull();
}

[Fact]
public void SetVariable_ResolvesToEnvironmentValue_AndExposesDefaultAsFallback()
{
const string prRegistry = "https://ci-artifacts.kibana.dev/storybooks/pr-42/storybook-docs/docs_registry.json";
var config = CreateConfiguration(Expression, new MockEnvironment { ["KIBANA_STORYBOOK_REGISTRY"] = prRegistry });

config.StorybookRegistry.Should().Be(prRegistry);
config.StorybookRegistryFallback.Should().Be(Default);
}

[Fact]
public void DisallowedVariable_IsLeftLiteral_AndWarns()
{
var collector = new DiagnosticsCollector([]);
var config = CreateConfiguration("${AWS_SECRET_ACCESS_KEY:-fallback}", new MockEnvironment { ["AWS_SECRET_ACCESS_KEY"] = "super-secret" }, collector);

config.StorybookRegistry.Should().Be("${AWS_SECRET_ACCESS_KEY:-fallback}");
config.StorybookRegistry.Should().NotContain("super-secret");
collector.Warnings.Should().Be(1, "a disallowed interpolation variable must emit exactly one warning");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private static ConfigurationFile CreateConfiguration(string registry, IEnvironmentVariables environment, DiagnosticsCollector? collector = null)
{
collector ??= new DiagnosticsCollector([]);
var root = Paths.WorkingDirectoryRoot.FullName;
var configFilePath = Path.Join(root, "docs", "_docset.yml");
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
{ configFilePath, new MockFileData("") }
}, root);

var configPath = fileSystem.FileInfo.New(configFilePath);
var docsDir = fileSystem.DirectoryInfo.New(Path.Join(root, "docs"));

var context = new MockDocumentationSetContext(collector, fileSystem, configPath, docsDir, environment);
var versionsConfig = new VersionsConfiguration { VersioningSystems = new Dictionary<VersioningSystemId, VersioningSystem>() };
var productsConfig = new ProductsConfiguration
{
Products = new Dictionary<string, Product>().ToFrozenDictionary(),
PublicReferenceProducts = new Dictionary<string, Product>().ToFrozenDictionary(),
ProductDisplayNames = new Dictionary<string, string>().ToFrozenDictionary()
};

var docSet = new DocumentationSetFile
{
Project = "test",
TableOfContents = [],
Storybook = new DocumentationSetStorybook { Registry = registry }
};

return new ConfigurationFile(docSet, context, versionsConfig, productsConfig);
}

private sealed class MockEnvironment : IEnvironmentVariables
{
private readonly Dictionary<string, string?> _variables = [with(StringComparer.Ordinal)];

public string? this[string name]
{
set => _variables[name] = value;
}

public string? GetEnvironmentVariable(string name) => _variables.GetValueOrDefault(name);

public bool IsRunningOnCI => false;
}

private sealed class MockDocumentationSetContext(
IDiagnosticsCollector collector,
IFileSystem fileSystem,
IFileInfo configurationPath,
IDirectoryInfo documentationSourceDirectory,
IEnvironmentVariables environment)
: IDocumentationSetContext
{
public IDiagnosticsCollector Collector => collector;
public ScopedFileSystem ReadFileSystem => WriteFileSystem;
public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem);
public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts"));
public IFileInfo ConfigurationPath => configurationPath;
public BuildType BuildType => BuildType.Isolated;
public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory;
public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem);
public IEnvironmentVariables Environment => environment;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,6 @@ private sealed class MockDocumentationSetContext(
public BuildType BuildType => BuildType.Isolated;
public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory;
public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem);
public IEnvironmentVariables Environment => SystemEnvironmentVariables.Instance;
}
}
Loading
Loading