Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Workflows.Artifacts;
using CodeSpace.Core.Services.Workflows.Artifacts.Profiles;
using CodeSpace.Core.Services.Workflows.Artifacts.Providers;
using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local;
using CodeSpace.Core.Settings;
using CodeSpace.Messages.Constants;
Expand All @@ -22,11 +23,13 @@ public sealed class AgentRunLogStorageReadiness : IAgentRunLogStorageReadiness
internal const string DefaultProfileStableName = "codespace-agent-run-log-default";
private readonly CodeSpaceDbContext _db;
private readonly TimeProvider _clock;
private readonly IStorageProviderModuleCatalog _modules;

public AgentRunLogStorageReadiness(CodeSpaceDbContext db, TimeProvider clock)
public AgentRunLogStorageReadiness(CodeSpaceDbContext db, TimeProvider clock, IStorageProviderModuleCatalog modules)
{
_db = db;
_clock = clock;
_modules = modules;
}

public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken)
Expand All @@ -53,7 +56,7 @@ public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancell
return;
}

var profile = BuildProfile(teamId, canonicalConfig, _clock.GetUtcNow());
var profile = BuildProfile(teamId, canonicalConfig, NamespaceFingerprint(canonicalConfig), _clock.GetUtcNow());
_db.StorageProfile.Add(profile);
var route = BuildRoute(teamId, profile.Id, _clock.GetUtcNow());
_db.StorageRoute.Add(route);
Expand All @@ -71,20 +74,26 @@ public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancell
}
}

private static StorageProfile BuildProfile(Guid teamId, string canonicalConfig, DateTimeOffset now)
/// <summary>The same derivation admission uses, so this bootstrap's revision names its namespace exactly as a Settings-created one would.</summary>
private string NamespaceFingerprint(string canonicalConfig)
{
using var document = JsonDocument.Parse(canonicalConfig);
return StorageProfileRules.NamespaceFingerprint(_modules.Require(LocalRwxArtifactStorageDriverFactory.TypeKey), document.RootElement);
}

private static StorageProfile BuildProfile(Guid teamId, string canonicalConfig, string namespaceFingerprint, DateTimeOffset now)
{
var profile = new StorageProfile
{
Id = Guid.NewGuid(), TeamId = teamId, StableName = DefaultProfileStableName, CurrentRevision = 1,
State = StorageProfileState.Active, CreatedDate = now, CreatedBy = SystemUsers.SeederId,
LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId,
};
using var document = JsonDocument.Parse(canonicalConfig);
profile.Revisions.Add(new StorageProfileRevision
{
Id = Guid.NewGuid(), TeamId = teamId, StorageProfileId = profile.Id, Revision = 1,
ProviderTypeKey = LocalRwxArtifactStorageDriverFactory.TypeKey, NonSecretConfigJson = canonicalConfig,
CredentialRef = null, NamespaceFingerprint = StorageProfileRules.NamespaceFingerprint(LocalRwxArtifactStorageDriverFactory.TypeKey, document.RootElement),
CredentialRef = null, NamespaceFingerprint = namespaceFingerprint,
CreatedDate = now, CreatedBy = SystemUsers.SeederId,
});
return profile;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,53 @@ public static string NamespaceFingerprint(string providerTypeKey, JsonElement na
return "sha256:" + Convert.ToHexString(SHA256.HashData(input)).ToLowerInvariant();
}

/// <summary>
/// The identity of the PHYSICAL namespace a configuration reaches: the provider's own canonical projection of it,
/// hashed. The one derivation behind the fingerprint a revision stores and behind the retention reaper's later
/// comparison of two revisions' objects (<see cref="TryObjectIdentity"/>), so the two cannot disagree about what
/// "the same namespace" means.
/// </summary>
public static string NamespaceFingerprint(IStorageProviderModule module, JsonElement nonSecretConfiguration)
{
var namespaceConfig = module.GetNamespaceConfiguration(nonSecretConfiguration);
if (namespaceConfig.ValueKind != JsonValueKind.Object)
throw new InvalidOperationException($"Storage provider '{module.TypeKey}' namespace configuration projection must be a JSON object.");

return NamespaceFingerprint(module.TypeKey, namespaceConfig);
}

/// <summary>
/// WHICH physical object a STORED placement's key reaches, derived now from its revision's configuration rather
/// than read from the fingerprint the revision stored, or null when it cannot be derived: the provider is not
/// registered in this build, the configuration no longer parses, or the provider cannot place the key.
///
/// <para>A provider that can name the object itself (<see cref="IStorageProviderPhysicalObjects"/>) is asked for it,
/// because for a filesystem a link beneath the root can carry a key out of the namespace its root names. Every other
/// provider's object is its namespace and the key together.</para>
///
/// <para>Null is "unknown", never "different". A caller comparing objects must treat it as possibly the same object
/// as anything at that key, which is why every failure lands here instead of propagating: the comparison this feeds
/// decides whether bytes are deleted, and there only a kept byte is a safe mistake.</para>
/// </summary>
public static string? TryObjectIdentity(IStorageProviderModuleCatalog catalog, string providerTypeKey, string nonSecretConfigJson, string objectKey)
{
try
{
var module = catalog.Get(providerTypeKey);
if (module == null) return null;

using var configuration = JsonDocument.Parse(nonSecretConfigJson);
return ObjectIdentity(module, configuration.RootElement, objectKey);
}
catch (Exception)
{
return null;
}
}

private static string ObjectIdentity(IStorageProviderModule module, JsonElement configuration, string objectKey) =>
module is IStorageProviderPhysicalObjects physical ? physical.PhysicalObject(configuration, objectKey) : $"{NamespaceFingerprint(module, configuration)}/{objectKey}";

public static bool TryParseCredentialRef(string? value, out StorageProfileCredentialReference reference)
{
reference = default;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,10 @@ private async Task<PreparedRevision> PrepareRevisionAsync(Guid teamId, string pr
ExecuteRule(() => module.EnsureConfigurationReadable(canonicalElement));

var normalizedCredentialRef = await ValidateCredentialAsync(teamId, module.TypeKey, credentialRef, cancellationToken).ConfigureAwait(false);
var namespaceConfig = module.GetNamespaceConfiguration(canonicalElement);
if (namespaceConfig.ValueKind != JsonValueKind.Object)
throw new InvalidOperationException($"Storage provider '{module.TypeKey}' namespace configuration projection must be a JSON object.");
var fingerprint = ExecuteRule(() => StorageProfileRules.NamespaceFingerprint(module.TypeKey, namespaceConfig));

// The fingerprint names the PHYSICAL namespace the provider says this configuration reaches, not its text, so two
// spellings of one destination store one fingerprint. The configuration itself is persisted as written.
var fingerprint = ExecuteRule(() => StorageProfileRules.NamespaceFingerprint(module, canonicalElement));
return new PreparedRevision(module.TypeKey, canonicalConfig, normalizedCredentialRef, fingerprint);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ public sealed class AliyunOssStorageProviderModule : IStorageProviderModule, ISt
/// </summary>
public void EnsureConfigurationReadable(JsonElement nonSecretConfiguration) => AliyunOssTarget.Parse(nonSecretConfiguration);

/// <summary>
/// The bucket and prefix the configuration reaches, read through the same parser activation uses: a configuration
/// the driver could never open has no namespace to name, so it is refused rather than given an identity.
/// </summary>
public JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration) => AliyunOssTarget.Parse(nonSecretConfiguration).NamespaceConfiguration();

private static JsonElement ParseSchema(string json)
{
using var document = JsonDocument.Parse(json);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ public static AliyunOssTarget Parse(JsonElement configuration)
};
}

/// <summary>
/// The bucket namespace this target addresses, spelled one way: the canonical configuration every spelling that
/// reaches the same bucket and prefix projects to.
///
/// <para>The host is only a ROUTE to a bucket: the public, <c>-internal</c> VPC and accelerate hosts, with or
/// without the <c>https://</c> scheme, all reach the bucket of one region. A custom domain or proxy cannot say
/// which bucket it fronts, and an identity that cannot tell two routes apart must merge them rather than split
/// them; OSS bucket names are unique service-wide and every request is signed for the bucket's region, so the one
/// OSS bucket any route can reach is that region's bucket of that name. So the identity names the region's public
/// host whatever host was written. An empty key prefix is the bucket root and is spelled by leaving it out. A
/// configuration already written this way projects to exactly itself, which keeps the fingerprint every such
/// revision already stored.</para>
/// </summary>
public JsonElement NamespaceConfiguration()
{
var namespaceConfiguration = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["bucket"] = Bucket,
["endpoint"] = $"{ServiceHostPrefix}{Region}{ServiceDomainSuffix}",
};

if (KeyPrefix.Length > 0) namespaceConfiguration["keyPrefix"] = KeyPrefix;

using var document = JsonDocument.Parse(JsonSerializer.Serialize(namespaceConfiguration));
return document.RootElement.Clone();
}

/// <summary>
/// Projects a caller's object key onto a bucket key, refusing anything that could escape the profile's prefix.
/// Traversal and empty segments are rejected rather than normalized so two different caller keys can never
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,18 @@ public interface IStorageProviderModule
JsonElement SecretSchema { get; }

/// <summary>
/// Project the non-secret configuration fields that identify the provider namespace. The control plane canonicalizes
/// and hashes this server-owned projection; clients can never submit a fingerprint. Providers with operational
/// tuning fields should override the conservative default so those fields do not create a new namespace identity.
/// Project the non-secret configuration onto the PHYSICAL namespace it reaches, spelled one way. The control plane
/// canonicalizes and hashes this server-owned projection into the revision's fingerprint (clients can never submit
/// one), and the retention reaper re-derives it from stored revisions to decide whether one object key is one
/// physical object across teams — unless the provider names that object itself
/// (<see cref="IStorageProviderPhysicalObjects"/>), because its keys can leave the namespace its root names.
///
/// <para>So two configurations that reach the same namespace MUST project identically — a root with and without a
/// trailing separator, a host and its alias — and where a provider cannot tell, it projects them together: a
/// merged identity only keeps bytes, a split one lets one team's purge delete another's. A configuration that
/// reaches no namespace at all throws. Operational tuning fields belong outside the projection. The default is the
/// raw configuration, which is right only for a provider whose configuration has exactly one spelling; every
/// shipped module overrides it.</para>
/// </summary>
JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration) => nonSecretConfiguration.Clone();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Text.Json;

namespace CodeSpace.Core.Services.Workflows.Artifacts.Providers;

/// <summary>
/// A provider that can name the PHYSICAL object one of its keys reaches, because its namespace alone cannot: a
/// filesystem destination resolves every key through the operating system, which follows a link anywhere beneath the
/// configured root, so two roots that are different directories can still lead one key to one file.
///
/// <para>A sibling of <see cref="IStorageProviderModule"/> (Rule 7): for an object store the bucket and prefix the
/// namespace projection names, plus the key, already ARE the object, and such a provider must not have to say so.</para>
///
/// <para>The retention reaper asks this at sweep time, for every placement that shares a key with the one it is about
/// to delete, because object keys carry no team segment and the answer decides whether a delete removes another team's
/// bytes. So two keys that reach one object MUST answer identically, and an object that cannot be established throws:
/// the caller reads a throw as "possibly the same as anything", which only ever keeps a byte.</para>
/// </summary>
public interface IStorageProviderPhysicalObjects
{
/// <summary>The object a delete of <paramref name="objectKey"/> would remove under this configuration, spelled one way, as it stands now.</summary>
string PhysicalObject(JsonElement nonSecretConfiguration, string objectKey);
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ public sealed class LocalLegacyStorageProviderModule : IStorageProviderModule, I
public StorageProviderCapabilities Capabilities => StorageProviderCapabilities.HealthProbe | StorageProviderCapabilities.StreamingRead;
public Type FactoryType => typeof(LocalLegacyArtifactStorageDriverFactory);

/// <summary>The directory the root reaches, not the text that names it: see <see cref="LocalRootPath"/>.</summary>
public JsonElement GetNamespaceConfiguration(JsonElement nonSecretConfiguration) => LocalRootPath.NamespaceConfiguration(nonSecretConfiguration);

public string? ResolveLegacyObjectKey(JsonElement nonSecretConfiguration, string sha256, string recordedLocator)
{
var key = LegacyLocalObjectKeys.For(sha256);
Expand Down
Loading
Loading