This commit is contained in:
StellaOps Bot
2025-11-23 23:40:10 +02:00
parent c13355923f
commit 029002ad05
93 changed files with 2160 additions and 285 deletions

View File

@@ -20,6 +20,6 @@
<ItemGroup>
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.FS\\StellaOps.Scanner.Surface.FS.csproj" />
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Surface.Env\\StellaOps.Scanner.Surface.Env.csproj" />
<PackageReference Include="StellaOps.Scanner.Surface.Env" Version="0.1.0-alpha.20251123" />
</ItemGroup>
</Project>

View File

@@ -32,9 +32,8 @@ public sealed class SurfaceManifestStoreOptionsConfigurator : IConfigureOptions<
if (string.IsNullOrWhiteSpace(options.RootDirectory))
{
options.RootDirectory = Path.Combine(
_cacheOptions.Value.ResolveRoot(),
"manifests");
var cacheRoot = _cacheOptions.Value.RootDirectory ?? Path.Combine(Path.GetTempPath(), "stellaops", "surface-cache");
options.RootDirectory = Path.Combine(cacheRoot, "manifests");
}
}
}

View File

@@ -297,8 +297,20 @@ else
});
}
var app = builder.Build();
var app = builder.Build();
// Fail fast if surface configuration is invalid at startup.
using (var validationScope = app.Services.CreateScope())
{
var services = validationScope.ServiceProvider;
var env = services.GetRequiredService<ISurfaceEnvironment>();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
await runner.EnsureAsync(
SurfaceValidationContext.Create(services, "Scanner.WebService.Startup", env.Settings),
app.Lifetime.ApplicationStopping)
.ConfigureAwait(false);
}
var resolvedOptions = app.Services.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
var authorityConfigured = resolvedOptions.Authority.Enabled;
if (authorityConfigured && resolvedOptions.Authority.AllowAnonymousFallback)

View File

@@ -110,6 +110,22 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var results = new List<OSPackageAnalyzerResult>(analyzers.Count);
var surfaceEnvironment = services.GetRequiredService<ISurfaceEnvironment>();
var validatorRunner = services.GetRequiredService<ISurfaceValidatorRunner>();
var validationContext = SurfaceValidationContext.Create(
services,
"Scanner.Worker.OSAnalyzers",
surfaceEnvironment.Settings,
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["jobId"] = context.JobId,
["scanId"] = context.ScanId,
["rootfsPath"] = rootfsPath,
["analyzerCount"] = analyzers.Count
});
await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
foreach (var analyzer in analyzers)
{
cancellationToken.ThrowIfCancellationRequested();

View File

@@ -119,6 +119,20 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
return;
}
var validationContext = SurfaceValidationContext.Create(
_serviceProvider,
"Scanner.Worker.EntryTrace",
_surfaceEnvironment.Settings,
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["jobId"] = context.JobId,
["scanId"] = context.ScanId,
["configPath"] = configPath,
["rootfs"] = metadata.TryGetValue(_workerOptions.Analyzers.RootFilesystemMetadataKey, out var rootfs) ? rootfs : null
});
await _validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
var fileSystemHandle = BuildFileSystem(context.JobId, metadata);
if (fileSystemHandle is null)
{

View File

@@ -145,6 +145,18 @@ builder.Logging.Configure(options =>
var host = builder.Build();
// Fail fast if surface configuration is invalid at startup.
using (var scope = host.Services.CreateScope())
{
var services = scope.ServiceProvider;
var env = services.GetRequiredService<ISurfaceEnvironment>();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
await runner.EnsureAsync(
SurfaceValidationContext.Create(services, "Scanner.Worker.Startup", env.Settings),
host.Services.GetRequiredService<IHostApplicationLifetime>().ApplicationStopping)
.ConfigureAwait(false);
}
await host.RunAsync();
public partial class Program;
@@ -189,9 +201,8 @@ public sealed class SurfaceManifestStoreOptionsConfigurator : IConfigureOptions<
if (string.IsNullOrWhiteSpace(options.RootDirectory))
{
options.RootDirectory = Path.Combine(
_cacheOptions.Value.ResolveRoot(),
"manifests");
var cacheRoot = _cacheOptions.Value.RootDirectory ?? Path.Combine(Path.GetTempPath(), "stellaops", "surface-cache");
options.RootDirectory = Path.Combine(cacheRoot, "manifests");
}
}
}

View File

@@ -29,38 +29,45 @@ public static class ServiceCollectionExtensions
var env = sp.GetRequiredService<ISurfaceEnvironment>();
var options = sp.GetRequiredService<IOptions<SurfaceSecretsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("SurfaceSecrets");
return CreateProvider(env.Settings.Secrets, logger);
return CreateProviderChain(env.Settings.Secrets, logger);
});
return services;
}
private static ISurfaceSecretProvider CreateProvider(SurfaceSecretsConfiguration configuration, ILogger logger)
private static ISurfaceSecretProvider CreateProviderChain(SurfaceSecretsConfiguration configuration, ILogger logger)
{
var providers = new List<ISurfaceSecretProvider>();
switch (configuration.Provider.ToLowerInvariant())
var providers = new List<ISurfaceSecretProvider>
{
case "kubernetes":
providers.Add(new KubernetesSurfaceSecretProvider(configuration, logger));
break;
case "file":
providers.Add(new FileSurfaceSecretProvider(configuration.Root ?? throw new ArgumentException("Secrets root is required for file provider.")));
break;
case "inline":
providers.Add(new InlineSurfaceSecretProvider(configuration));
break;
default:
logger.LogWarning("Unknown surface secret provider '{Provider}'. Falling back to inline provider.", configuration.Provider);
providers.Add(new InlineSurfaceSecretProvider(configuration));
break;
}
CreateProvider(configuration.Provider, configuration, logger)
};
if (!string.IsNullOrWhiteSpace(configuration.FallbackProvider))
if (configuration.HasFallback)
{
providers.Add(new InlineSurfaceSecretProvider(configuration with { Provider = configuration.FallbackProvider }));
providers.Add(CreateProvider(configuration.FallbackProvider!, configuration, logger));
}
return providers.Count == 1 ? providers[0] : new CompositeSurfaceSecretProvider(providers);
}
private static ISurfaceSecretProvider CreateProvider(string providerId, SurfaceSecretsConfiguration configuration, ILogger logger)
{
if (string.IsNullOrWhiteSpace(providerId))
{
throw new ArgumentException("Provider id is required", nameof(providerId));
}
switch (providerId.Trim().ToLowerInvariant())
{
case "kubernetes":
return new KubernetesSurfaceSecretProvider(configuration, logger);
case "file":
return new FileSurfaceSecretProvider(configuration.Root ?? throw new ArgumentException("Secrets root is required for file provider."));
case "inline":
return new InlineSurfaceSecretProvider(configuration);
default:
logger.LogWarning("Unknown surface secret provider '{Provider}'. Falling back to inline provider if allowed; otherwise requests will fail.", providerId);
return new InlineSurfaceSecretProvider(configuration);
}
}
}

View File

@@ -8,6 +8,7 @@ public static class SurfaceValidationIssueCodes
public const string CacheQuotaInvalid = "SURFACE_ENV_CACHE_QUOTA_INVALID";
public const string SecretsProviderUnknown = "SURFACE_SECRET_PROVIDER_UNKNOWN";
public const string SecretsConfigurationMissing = "SURFACE_SECRET_CONFIGURATION_MISSING";
public const string SecretsConfigurationInvalid = "SURFACE_SECRET_FORMAT_INVALID";
public const string TenantMissing = "SURFACE_ENV_TENANT_MISSING";
public const string BucketMissing = "SURFACE_FS_BUCKET_MISSING";
public const string FeatureUnknown = "SURFACE_FEATURE_UNKNOWN";

View File

@@ -35,6 +35,14 @@ internal sealed class SurfaceSecretsValidator : ISurfaceValidator
"Set SCANNER_SURFACE_SECRETS_PROVIDER to 'kubernetes', 'file', or another supported provider."));
}
if (secrets.HasFallback && !KnownProviders.Contains(secrets.FallbackProvider!))
{
issues.Add(SurfaceValidationIssue.Error(
SurfaceValidationIssueCodes.SecretsProviderUnknown,
$"Fallback secrets provider '{secrets.FallbackProvider}' is not recognised.",
"Choose a supported fallback provider (kubernetes | file | inline) or clear SCANNER_SURFACE_SECRETS_FALLBACK_PROVIDER."));
}
if (string.Equals(secrets.Provider, "kubernetes", StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(secrets.Namespace))
{
@@ -53,6 +61,24 @@ internal sealed class SurfaceSecretsValidator : ISurfaceValidator
"Set SCANNER_SURFACE_SECRETS_ROOT to a directory path."));
}
if (string.Equals(secrets.Provider, "file", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(secrets.Root) &&
!Directory.Exists(secrets.Root))
{
issues.Add(SurfaceValidationIssue.Error(
SurfaceValidationIssueCodes.SecretsConfigurationInvalid,
$"File secrets root '{secrets.Root}' does not exist.",
"Ensure SCANNER_SURFACE_SECRETS_ROOT points to an existing directory with 0600-style permissions."));
}
if (string.Equals(secrets.Provider, "inline", StringComparison.OrdinalIgnoreCase) && !secrets.AllowInline)
{
issues.Add(SurfaceValidationIssue.Error(
SurfaceValidationIssueCodes.SecretsConfigurationInvalid,
"Inline secrets provider is selected but AllowInline=false.",
"Either enable SCANNER_SURFACE_SECRETS_ALLOW_INLINE for dev/test or switch provider."));
}
if (string.IsNullOrWhiteSpace(secrets.Tenant))
{
issues.Add(SurfaceValidationIssue.Error(

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Env;
@@ -23,6 +24,31 @@ namespace StellaOps.Scanner.Surface.Secrets.Tests
Assert.NotNull(secretProvider);
}
[Fact]
public async Task AddSurfaceSecrets_UsesFallbackProvider_WhenPrimaryCannotResolve()
{
const string key = "SURFACE_SECRET_TENANT_COMPONENT_REGISTRY_DEFAULT";
Environment.SetEnvironmentVariable(key, Convert.ToBase64String(new byte[] { 9, 9, 9 }));
var services = new ServiceCollection();
services.AddSingleton<ISurfaceEnvironment>(_ => new TestSurfaceEnvironmentWithFallback());
services.AddLogging(builder => builder.ClearProviders());
services.AddSurfaceSecrets();
await using var provider = services.BuildServiceProvider();
var secretProvider = provider.GetRequiredService<ISurfaceSecretProvider>();
var handle = await secretProvider.GetAsync(new SurfaceSecretRequest("tenant", "component", "registry"));
try
{
Assert.Equal(new byte[] { 9, 9, 9 }, handle.AsBytes().ToArray());
}
finally
{
handle.Dispose();
Environment.SetEnvironmentVariable(key, null);
}
}
private sealed class TestSurfaceEnvironment : ISurfaceEnvironment
{
public SurfaceEnvironmentSettings Settings { get; }
@@ -48,5 +74,32 @@ namespace StellaOps.Scanner.Surface.Secrets.Tests
RawVariables = new Dictionary<string, string>();
}
}
private sealed class TestSurfaceEnvironmentWithFallback : ISurfaceEnvironment
{
public SurfaceEnvironmentSettings Settings { get; }
public IReadOnlyDictionary<string, string> RawVariables { get; }
public TestSurfaceEnvironmentWithFallback()
{
var root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"surface",
null,
new DirectoryInfo(Path.GetTempPath()),
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("kubernetes", "tenant", Root: root, Namespace: "ns", FallbackProvider: "inline", AllowInline: true),
"tenant",
new SurfaceTlsConfiguration(null, null, null))
{
CreatedAtUtc = DateTimeOffset.UtcNow
};
RawVariables = new Dictionary<string, string>();
}
}
}
}

View File

@@ -71,6 +71,63 @@ public sealed class SurfaceValidatorRunnerTests
Assert.True(result.IsSuccess);
}
[Fact]
public async Task RunAllAsync_Fails_WhenInlineProviderDisallowed()
{
var directory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString()));
var environment = new SurfaceEnvironmentSettings(
new Uri("https://surface.example.com"),
"surface-cache",
null,
directory,
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("inline", "tenant-a", Root: null, Namespace: null, FallbackProvider: null, AllowInline: false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, null));
var services = CreateServices();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
var context = SurfaceValidationContext.Create(services, "TestComponent", environment);
var result = await runner.RunAllAsync(context);
Assert.False(result.IsSuccess);
Assert.Contains(result.Issues, i => i.Code == SurfaceValidationIssueCodes.SecretsConfigurationInvalid);
}
[Fact]
public async Task RunAllAsync_Fails_WhenFileRootMissing()
{
var missingRoot = Path.Combine(Path.GetTempPath(), "stellaops-tests", "missing-root", Guid.NewGuid().ToString());
var directory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString()))
{
Attributes = FileAttributes.Normal
};
var environment = new SurfaceEnvironmentSettings(
new Uri("https://surface.example.com"),
"surface-cache",
null,
directory,
1024,
false,
Array.Empty<string>(),
new SurfaceSecretsConfiguration("file", "tenant-a", Root: missingRoot, Namespace: null, FallbackProvider: null, AllowInline: false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, null));
var services = CreateServices();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
var context = SurfaceValidationContext.Create(services, "TestComponent", environment);
var result = await runner.RunAllAsync(context);
Assert.False(result.IsSuccess);
Assert.Contains(result.Issues, i => i.Code == SurfaceValidationIssueCodes.SecretsConfigurationInvalid);
}
private static ServiceProvider CreateServices(Action<IServiceCollection>? configure = null)
{
var services = new ServiceCollection();

View File

@@ -5,7 +5,6 @@ using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Worker;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
@@ -29,7 +28,7 @@ public sealed class SurfaceManifestStoreOptionsConfiguratorTests
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()));
var environment = new StubSurfaceEnvironment(settings);
var cacheOptions = Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });
var cacheOptions = Microsoft.Extensions.Options.Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });
var configurator = new SurfaceManifestStoreOptionsConfigurator(environment, cacheOptions);
var options = new SurfaceManifestStoreOptions();