diff --git a/backend/deploy/e2e/docker-compose.e2e.yml b/backend/deploy/e2e/docker-compose.e2e.yml index f5fcef319..526b2c982 100644 --- a/backend/deploy/e2e/docker-compose.e2e.yml +++ b/backend/deploy/e2e/docker-compose.e2e.yml @@ -36,6 +36,7 @@ services: # P2 slice 3: Production refuses to start with unconfigured durable roots — the exact discipline this # smoke stack must model. The images prepare these dirs with app ownership; a real deployment mounts volumes at them. Artifacts__StoreDirectory: /var/lib/codespace/artifacts + Artifacts__LocalRwxShared: "true" Agents__RunSpoolDirectory: /var/lib/codespace/spool volumes: - artifacts:/var/lib/codespace/artifacts @@ -59,6 +60,7 @@ services: Authentication__Jwt__SymmetricKey: "${E2E_JWT_KEY}" CODESPACE_VARIABLE_MASTER_KEY: "${E2E_VARIABLE_MASTER_KEY}" Artifacts__StoreDirectory: /var/lib/codespace/artifacts + Artifacts__LocalRwxShared: "true" Agents__RunSpoolDirectory: /var/lib/codespace/spool HangfireHosting: "Worker" CODESPACE_CODEX_CLI_PATH: "/opt/fake-codex" diff --git a/backend/src/CodeSpace.Api/appsettings.json b/backend/src/CodeSpace.Api/appsettings.json index c1a1b5f29..06290cac5 100644 --- a/backend/src/CodeSpace.Api/appsettings.json +++ b/backend/src/CodeSpace.Api/appsettings.json @@ -58,8 +58,9 @@ "PackAllowedHosts": "" }, "Artifacts": { - "_doc": "Root directory for offloaded artifact bytes. Blank uses a path under the system temp dir; point it at a persistent volume in any deployment whose artifacts must outlive the pod.", - "StoreDirectory": "" + "_doc": "StoreDirectory is the root for offloaded artifact bytes. LocalRwxShared is a separate deployment qualification: true means every API/worker instance using local-rwx sees the same namespace (for example one RWX volume). A durable-looking path alone is not proof, so false is the fail-closed default.", + "StoreDirectory": "", + "LocalRwxShared": false }, "Shutdown": { "_doc": "Graceful-shutdown drain budget in seconds. The orchestrator's grace period MUST be at least this or the process is SIGKILLed before in-flight background work drains (k8s: terminationGracePeriodSeconds). 30 matches k8s's own default.", diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs new file mode 100644 index 000000000..42b50aa3a --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageReadiness.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows.Artifacts.Profiles; +using CodeSpace.Core.Services.Workflows.Artifacts.Providers.Local; +using CodeSpace.Core.Settings; +using CodeSpace.Messages.Constants; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace CodeSpace.Core.Services.Agents.AgentRunLogging; + +/// +/// Gives an unconfigured team one real Settings-visible local route only after the deployment explicitly qualifies its +/// local-rwx namespace as shared. This is a missing-only bootstrap, not a routing fallback: an existing route in any +/// lifecycle state or any collision on the reserved profile name remains authoritative, and later Settings revisions can +/// move the data class to any registered cloud provider without changing Agent Run capture. +/// +public sealed class AgentRunLogStorageReadiness : IAgentRunLogStorageReadiness +{ + internal const string DefaultProfileStableName = "codespace-agent-run-log-default"; + private const int AdvisoryLockNamespace = 117; + private readonly CodeSpaceDbContext _db; + private readonly TimeProvider _clock; + + public AgentRunLogStorageReadiness(CodeSpaceDbContext db, TimeProvider clock) + { + _db = db; + _clock = clock; + } + + public async Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken) + { + if (teamId == Guid.Empty || !RuntimeSettings.Current.ArtifactLocalRwxShared) return; + + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + try + { + await _db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock(hashtextextended({teamId.ToString()}, {AdvisoryLockNamespace}))", cancellationToken).ConfigureAwait(false); + if (!await _db.Team.AsNoTracking().AnyAsync(team => team.Id == teamId, cancellationToken).ConfigureAwait(false) + || await _db.StorageRoute.AsNoTracking().AnyAsync(route => route.TeamId == teamId && route.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey, cancellationToken).ConfigureAwait(false)) + { + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return; + } + + var rootPath = DurableRoots.ArtifactStore(RuntimeSettings.Current.ArtifactStoreDirectory); + var canonicalConfig = CanonicalConfig(rootPath); + if (await _db.StorageProfile.AsNoTracking().AnyAsync( + value => value.TeamId == teamId && value.StableName == DefaultProfileStableName, cancellationToken).ConfigureAwait(false)) + { + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return; + } + + var profile = BuildProfile(teamId, canonicalConfig, _clock.GetUtcNow()); + _db.StorageProfile.Add(profile); + var route = BuildRoute(teamId, profile.Id, _clock.GetUtcNow()); + _db.StorageRoute.Add(route); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + route.State = StorageRouteState.Active; + route.LastModifiedDate = _clock.GetUtcNow(); + route.LastModifiedBy = SystemUsers.SeederId; + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (IsUniqueViolation(exception)) + { + await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + _db.ChangeTracker.Clear(); + } + } + + private static StorageProfile BuildProfile(Guid teamId, string canonicalConfig, 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), + CreatedDate = now, CreatedBy = SystemUsers.SeederId, + }); + return profile; + } + + private static StorageRoute BuildRoute(Guid teamId, Guid profileId, DateTimeOffset now) + { + var route = new StorageRoute + { + Id = Guid.NewGuid(), TeamId = teamId, DataClassTypeKey = AgentRunLogStorageResolver.DataClassTypeKey, + CurrentRevision = 1, State = StorageRouteState.Draft, CreatedDate = now, CreatedBy = SystemUsers.SeederId, + LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId, + }; + route.Revisions.Add(new StorageRouteRevision + { + Id = Guid.NewGuid(), TeamId = teamId, StorageRouteId = route.Id, Revision = 1, StorageProfileId = profileId, + ProfileRevisionMode = StorageProfileRevisionMode.CurrentAtWrite, PinnedProfileRevision = null, + CreatedDate = now, CreatedBy = SystemUsers.SeederId, + }); + return route; + } + + private static string CanonicalConfig(string rootPath) + { + using var document = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath })); + return StorageProfileRules.CanonicalJson(document.RootElement); + } + + private static bool IsUniqueViolation(Exception exception) + { + for (Exception? current = exception; current != null; current = current.InnerException) + if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) return true; + return false; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageResolver.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageResolver.cs index 787b29254..6a6cd19f9 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageResolver.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/AgentRunLogStorageResolver.cs @@ -10,14 +10,27 @@ public sealed class AgentRunLogStorageResolver : IAgentRunLogStorageResolver { public const string DataClassTypeKey = "agent-run-log/v1"; private readonly IStorageRouteSnapshotResolver _routes; + private readonly IAgentRunLogStorageReadiness _readiness; - public AgentRunLogStorageResolver(IStorageRouteSnapshotResolver routes) => _routes = routes; + public AgentRunLogStorageResolver(IStorageRouteSnapshotResolver routes, IAgentRunLogStorageReadiness readiness) + { + _routes = routes; + _readiness = readiness; + } public async Task ResolveAsync(Guid teamId, CancellationToken cancellationToken) { - var resolution = await _routes.ResolveAsync(new StorageRouteSnapshotRequest(teamId, DataClassTypeKey), cancellationToken).ConfigureAwait(false); + var request = new StorageRouteSnapshotRequest(teamId, DataClassTypeKey); + var resolution = await _routes.ResolveAsync(request, cancellationToken).ConfigureAwait(false); if (resolution is StorageRouteSnapshotResolution.Cancelled && cancellationToken.IsCancellationRequested) throw new OperationCanceledException(cancellationToken); + if (resolution is StorageRouteSnapshotResolution.Missing) + { + await _readiness.EnsureDefaultRouteAsync(teamId, cancellationToken).ConfigureAwait(false); + resolution = await _routes.ResolveAsync(request, cancellationToken).ConfigureAwait(false); + if (resolution is StorageRouteSnapshotResolution.Cancelled && cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + } return resolution switch { diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/IAgentRunLogCaptureBridge.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/IAgentRunLogCaptureBridge.cs index b945a5df7..0e24269f9 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/IAgentRunLogCaptureBridge.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunLogging/IAgentRunLogCaptureBridge.cs @@ -48,6 +48,16 @@ public interface IAgentRunLogStorageResolver : IScopedDependency Task ResolveAsync(Guid teamId, CancellationToken cancellationToken); } +/// +/// Establishes the explicit Settings-visible default route for a team that has never configured Agent Run log storage, +/// but only when the deployment explicitly qualified local-rwx as shared. Implementations may act only on a missing +/// exact route; lifecycle states and reserved stable names chosen by an operator are final. +/// +public interface IAgentRunLogStorageReadiness : IScopedDependency +{ + Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken); +} + public abstract record AgentRunLogStorageResolution { private AgentRunLogStorageResolution() { } diff --git a/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs b/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs index 312798af3..223767dc0 100644 --- a/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs +++ b/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs @@ -39,6 +39,13 @@ public sealed record RuntimeSettings /// Root directory for offloaded artifact bytes. Null ⇒ a path under the system temp dir. Same durability caveat as the spool: point it at a persistent volume in any deployment whose artifacts must outlive the pod. public string? ArtifactStoreDirectory { get; init; } + /// + /// Explicit deployment qualification that the local-rwx artifact root is one shared namespace visible to every API + /// and worker instance that may use it. False by default: a durable-looking or existing path does not prove two + /// hosts see the same bytes, so it cannot authorize an automatically active team route. + /// + public bool ArtifactLocalRwxShared { get; init; } + /// /// Graceful-shutdown drain budget in seconds — how long the host waits on SIGTERM for in-flight background work /// before exiting. The orchestrator's own grace period MUST be at least this (k8s @@ -78,6 +85,7 @@ public sealed record RuntimeSettings AgentCgroupRoot = Trimmed(configuration["Sandbox:CgroupRoot"]), AgentRunSpoolDirectory = Trimmed(configuration["Agents:RunSpoolDirectory"]), ArtifactStoreDirectory = Trimmed(configuration["Artifacts:StoreDirectory"]), + ArtifactLocalRwxShared = configuration.GetValue("Artifacts:LocalRwxShared", false), ShutdownDrainSeconds = Positive(configuration["Shutdown:DrainSeconds"], DefaultShutdownDrainSeconds), PackAllowedHosts = Trimmed(configuration["Agents:PackAllowedHosts"]), // Secrets. The LEGACY flat keys are still honoured, and that is load-bearing rather than tidy: every diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs index 4d0f08b50..6ff30a431 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunLogRuntimeTests.cs @@ -1,13 +1,17 @@ using System.Collections.Concurrent; using System.Security.Cryptography; +using System.Text.Json; using Autofac; using CodeSpace.Core.Handlers.QueryHandlers.Agents; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Agents.AgentRunLogging; using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.Workflows.Artifacts.Profiles; using CodeSpace.Core.Services.Workflows.Artifacts.Runtime; +using CodeSpace.Core.Settings; using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.Messages.Constants; using CodeSpace.Messages.Enums; using CodeSpace.Messages.Queries.Agents; using Microsoft.EntityFrameworkCore; @@ -148,17 +152,157 @@ public async Task Current_at_write_uses_the_exact_current_route_revision_and_tha } [Fact] - public async Task Missing_inactive_invalid_and_foreign_routes_fail_closed_without_an_active_profile_fallback() + public async Task Missing_route_stays_typed_missing_when_the_deployment_did_not_qualify_local_rwx_as_shared() + { + var world = await SeedWorldAsync(); + var nonTemporaryExistingRoot = Path.Combine(Directory.GetCurrentDirectory(), "agent-log-unqualified", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(nonTemporaryExistingRoot); + using var settings = RuntimeSettings.Override(current => current with + { + ArtifactStoreDirectory = nonTemporaryExistingRoot, + ArtifactLocalRwxShared = false, + }); + try + { + using var scope = _fixture.BeginScope(); + var result = await scope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None); + + result.ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing)); + var db = scope.Resolve(); + (await db.StorageProfile.CountAsync(value => value.TeamId == world.TeamId && value.StableName == AgentRunLogStorageReadiness.DefaultProfileStableName)).ShouldBe(0); + (await db.StorageRoute.CountAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey)).ShouldBe(0); + } + finally + { + Directory.Delete(nonTemporaryExistingRoot, recursive: true); + } + } + + [Fact] + public async Task First_resolution_creates_one_idempotent_settings_visible_local_route_without_selecting_an_unrelated_profile() + { + var world = await SeedWorldAsync(); + var rootPath = Path.Combine(Path.GetTempPath(), "codespace-agent-log-readiness", Guid.NewGuid().ToString("N")); + using var settings = RuntimeSettings.Override(current => current with { ArtifactStoreDirectory = rootPath, ArtifactLocalRwxShared = true }); + using var scope = _fixture.BeginScope(); + var resolver = scope.Resolve(); + + var first = (await resolver.ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBeOfType(); + var second = (await resolver.ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBeOfType(); + + first.ShouldBe(second); + first.StorageProfileId.ShouldNotBe(world.StorageProfileId, "an arbitrary active profile is never a routing fallback"); + var db = scope.Resolve(); + var route = await db.StorageRoute.AsNoTracking().Include(value => value.Revisions) + .SingleAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey); + route.State.ShouldBe(StorageRouteState.Active); + route.CurrentRevision.ShouldBe(1); + route.Revisions.ShouldHaveSingleItem().StorageProfileId.ShouldBe(first.StorageProfileId); + route.Revisions.Single().ProfileRevisionMode.ShouldBe(StorageProfileRevisionMode.CurrentAtWrite); + var profile = await db.StorageProfile.AsNoTracking().Include(value => value.Revisions).SingleAsync(value => value.TeamId == world.TeamId && value.Id == first.StorageProfileId); + profile.StableName.ShouldBe(AgentRunLogStorageReadiness.DefaultProfileStableName); + profile.State.ShouldBe(StorageProfileState.Active); + profile.CreatedBy.ShouldBe(SystemUsers.SeederId); + var revision = profile.Revisions.ShouldHaveSingleItem(); + revision.ProviderTypeKey.ShouldBe("local-rwx/v1"); + revision.CredentialRef.ShouldBeNull(); + using var config = JsonDocument.Parse(revision.NonSecretConfigJson); + config.RootElement.GetProperty("rootPath").GetString().ShouldBe(Path.GetFullPath(rootPath)); + } + + [Fact] + public async Task Reserved_profile_collision_is_not_hijacked_and_missing_route_stays_explicit() + { + var world = await SeedWorldAsync(); + var rootPath = Path.Combine(Path.GetTempPath(), "codespace-agent-log-collision", Guid.NewGuid().ToString("N")); + using var settings = RuntimeSettings.Override(current => current with { ArtifactStoreDirectory = rootPath, ArtifactLocalRwxShared = true }); + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + AddReservedDefaultProfile(db, world, rootPath, DateTimeOffset.UtcNow); + await db.SaveChangesAsync(); + + var result = await scope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None); + + result.ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing)); + (await db.StorageRoute.CountAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey)).ShouldBe(0); + } + + [Fact] + public async Task Concurrent_first_resolutions_converge_on_one_profile_route_and_revision_chain() + { + var world = await SeedWorldAsync(); + using var settings = RuntimeSettings.Override(current => current with { ArtifactLocalRwxShared = true }); + using var firstScope = _fixture.BeginScope(); + using var secondScope = _fixture.BeginScope(); + + var resolutions = await Task.WhenAll( + firstScope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None), + secondScope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None)); + + resolutions.ShouldAllBe(value => value is AgentRunLogStorageResolution.Ready); + resolutions.Cast().Select(value => value.StorageProfileId).Distinct().Count().ShouldBe(1); + using var readScope = _fixture.BeginScope(); + var db = readScope.Resolve(); + (await db.StorageProfile.CountAsync(value => value.TeamId == world.TeamId && value.StableName == AgentRunLogStorageReadiness.DefaultProfileStableName)).ShouldBe(1); + (await db.StorageRoute.CountAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey)).ShouldBe(1); + (await db.StorageRouteRevision.CountAsync(value => value.TeamId == world.TeamId && value.Route.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey)).ShouldBe(1); + } + + [Fact] + public async Task Default_route_persists_and_reads_log_bytes_through_the_profile_driven_local_runtime() + { + var world = await SeedWorldAsync(); + var rootPath = Path.Combine(Path.GetTempPath(), "codespace-agent-log-default-runtime", Guid.NewGuid().ToString("N")); + using var settings = RuntimeSettings.Override(current => current with { ArtifactStoreDirectory = rootPath, ArtifactLocalRwxShared = true }); + try + { + var captureSessionId = Guid.NewGuid(); + var bytes = "durable-default-log"u8.ToArray(); + Guid streamId; + + using (var writeScope = _fixture.BeginScope()) + { + var storage = (await writeScope.Resolve().ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBeOfType(); + var logs = writeScope.Resolve(); + var opened = (await logs.OpenAsync(Open(world, captureSessionId, AgentRunLogKinds.StandardOutput), CancellationToken.None)).ShouldBeOfType(); + streamId = opened.Metadata.StreamId; + var appended = await logs.AppendAsync(Append(world, streamId, captureSessionId, 1, 0, bytes) with + { + StorageProfileId = storage.StorageProfileId, + StorageProfileRevision = storage.StorageProfileRevision, + }, CancellationToken.None); + + appended.ShouldBeOfType(); + } + + using (var readScope = _fixture.BeginScope()) + { + var read = (await readScope.Resolve().ReadRangeAsync( + new AgentRunLogRangeRequest(world.TeamId, streamId, 0, bytes.Length), CancellationToken.None)).ShouldBeOfType(); + read.Bytes.ShouldBe(bytes); + } + Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories).ShouldNotBeEmpty(); + } + finally + { + if (Directory.Exists(rootPath)) Directory.Delete(rootPath, recursive: true); + } + } + + [Fact] + public async Task Missing_routes_get_tenant_defaults_while_inactive_invalid_routes_remain_authoritative_without_fallback() { var world = await SeedWorldAsync(); var foreign = await SeedWorldAsync(); + using var settings = RuntimeSettings.Override(current => current with { ArtifactLocalRwxShared = true }); using var scope = _fixture.BeginScope(); var db = scope.Resolve(); var resolver = scope.Resolve(); - (await resolver.ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing)); - var route = await AddRouteAsync(db, world, world.StorageProfileId, StorageProfileRevisionMode.CurrentAtWrite); - (await resolver.ResolveAsync(foreign.TeamId, CancellationToken.None)).ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing)); + var ready = (await resolver.ResolveAsync(world.TeamId, CancellationToken.None)).ShouldBeOfType(); + var foreignReady = (await resolver.ResolveAsync(foreign.TeamId, CancellationToken.None)).ShouldBeOfType(); + ready.StorageProfileId.ShouldNotBe(foreignReady.StorageProfileId); + var route = await db.StorageRoute.SingleAsync(value => value.TeamId == world.TeamId && value.DataClassTypeKey == AgentRunLogStorageResolver.DataClassTypeKey); route.State = StorageRouteState.Disabled; route.LastModifiedDate = DateTimeOffset.UtcNow; @@ -425,6 +569,27 @@ private static void AddProfile(CodeSpaceDbContext db, World world, Guid profileI db.StorageProfile.Add(profile); } + private static void AddReservedDefaultProfile(CodeSpaceDbContext db, World world, string rootPath, DateTimeOffset now) + { + using var document = JsonDocument.Parse(JsonSerializer.Serialize(new { rootPath = Path.GetFullPath(rootPath) })); + var canonicalConfig = StorageProfileRules.CanonicalJson(document.RootElement); + using var canonical = JsonDocument.Parse(canonicalConfig); + var profile = new StorageProfile + { + Id = Guid.NewGuid(), TeamId = world.TeamId, StableName = AgentRunLogStorageReadiness.DefaultProfileStableName, + State = StorageProfileState.Active, CurrentRevision = 1, CreatedDate = now, CreatedBy = SystemUsers.SeederId, + LastModifiedDate = now, LastModifiedBy = SystemUsers.SeederId, + }; + profile.Revisions.Add(new StorageProfileRevision + { + Id = Guid.NewGuid(), TeamId = world.TeamId, StorageProfileId = profile.Id, Revision = 1, + ProviderTypeKey = "local-rwx/v1", NonSecretConfigJson = canonicalConfig, CredentialRef = null, + NamespaceFingerprint = StorageProfileRules.NamespaceFingerprint("local-rwx/v1", canonical.RootElement), + CreatedDate = now, CreatedBy = SystemUsers.SeederId, + }); + db.StorageProfile.Add(profile); + } + private sealed record World(Guid TeamId, Guid ActorId, Guid StorageProfileId, Guid AgentRunId); private sealed class StubCurrentTeam(Guid id) : ICurrentTeam diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentRunLogStorageResolverTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentRunLogStorageResolverTests.cs index 71efbbfb2..4ae3ab7dc 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/AgentRunLogStorageResolverTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentRunLogStorageResolverTests.cs @@ -18,19 +18,45 @@ public async Task Ready_route_forwards_the_exact_frozen_profile_coordinates_for_ NamespaceFingerprint = $"sha256:{new string('a', 64)}", }; var route = new StubRouteResolver(new StorageRouteSnapshotResolution.Ready(snapshot)); + var readiness = new StubReadiness(); - var result = await new AgentRunLogStorageResolver(route).ResolveAsync(teamId, CancellationToken.None); + var result = await new AgentRunLogStorageResolver(route, readiness).ResolveAsync(teamId, CancellationToken.None); result.ShouldBe(new AgentRunLogStorageResolution.Ready(profileId, 11)); route.Requests.ShouldBe([new StorageRouteSnapshotRequest(teamId, "agent-run-log/v1")]); + readiness.TeamIds.ShouldBeEmpty(); } [Fact] - public async Task Route_policy_failures_are_typed_and_never_fall_back_to_an_unrelated_profile() + public async Task Missing_route_is_bootstrapped_once_then_resolved_through_the_same_exact_route() + { + var teamId = Guid.NewGuid(); + var profileId = Guid.NewGuid(); + var route = new StubRouteResolver( + new StorageRouteSnapshotResolution.Missing(), + new StorageRouteSnapshotResolution.Ready(new StorageRouteSnapshot + { + RouteId = Guid.NewGuid(), RouteRevision = 1, DataClassTypeKey = AgentRunLogStorageResolver.DataClassTypeKey, + StorageProfileId = profileId, StorageProfileRevision = 1, ProviderTypeKey = "local-rwx/v1", + NamespaceFingerprint = $"sha256:{new string('b', 64)}", + })); + var readiness = new StubReadiness(); + + var result = await new AgentRunLogStorageResolver(route, readiness).ResolveAsync(teamId, CancellationToken.None); + + result.ShouldBe(new AgentRunLogStorageResolution.Ready(profileId, 1)); + readiness.TeamIds.ShouldBe([teamId]); + route.Requests.ShouldBe([ + new StorageRouteSnapshotRequest(teamId, AgentRunLogStorageResolver.DataClassTypeKey), + new StorageRouteSnapshotRequest(teamId, AgentRunLogStorageResolver.DataClassTypeKey), + ]); + } + + [Fact] + public async Task Non_missing_route_policy_failures_are_typed_and_never_bootstrap_or_fall_back() { var cases = new (StorageRouteSnapshotResolution Resolution, AgentRunLogStorageProblemCode Expected)[] { - (new StorageRouteSnapshotResolution.Missing(), AgentRunLogStorageProblemCode.Missing), (new StorageRouteSnapshotResolution.RouteNotActive(), AgentRunLogStorageProblemCode.Inactive), (new StorageRouteSnapshotResolution.ProfileNotActive(), AgentRunLogStorageProblemCode.Inactive), (new StorageRouteSnapshotResolution.RouteRevisionMissing(), AgentRunLogStorageProblemCode.Invalid), @@ -43,12 +69,27 @@ public async Task Route_policy_failures_are_typed_and_never_fall_back_to_an_unre foreach (var (resolution, expected) in cases) { var route = new StubRouteResolver(resolution); - var result = await new AgentRunLogStorageResolver(route).ResolveAsync(Guid.NewGuid(), CancellationToken.None); + var readiness = new StubReadiness(); + var result = await new AgentRunLogStorageResolver(route, readiness).ResolveAsync(Guid.NewGuid(), CancellationToken.None); result.ShouldBe(new AgentRunLogStorageResolution.Unavailable(expected)); route.Requests.Count.ShouldBe(1); + readiness.TeamIds.ShouldBeEmpty(); } } + [Fact] + public async Task Missing_route_that_cannot_be_bootstrapped_remains_typed_missing_without_another_profile_fallback() + { + var route = new StubRouteResolver(new StorageRouteSnapshotResolution.Missing(), new StorageRouteSnapshotResolution.Missing()); + var readiness = new StubReadiness(); + + var result = await new AgentRunLogStorageResolver(route, readiness).ResolveAsync(Guid.NewGuid(), CancellationToken.None); + + result.ShouldBe(new AgentRunLogStorageResolution.Unavailable(AgentRunLogStorageProblemCode.Missing)); + readiness.TeamIds.Count.ShouldBe(1); + route.Requests.Count.ShouldBe(2); + } + [Fact] public async Task Caller_cancellation_is_not_downgraded_into_capture_health() { @@ -56,17 +97,28 @@ public async Task Caller_cancellation_is_not_downgraded_into_capture_health() cancellation.Cancel(); var route = new StubRouteResolver(new StorageRouteSnapshotResolution.Cancelled()); - await Should.ThrowAsync(() => new AgentRunLogStorageResolver(route).ResolveAsync(Guid.NewGuid(), cancellation.Token)); + await Should.ThrowAsync(() => new AgentRunLogStorageResolver(route, new StubReadiness()).ResolveAsync(Guid.NewGuid(), cancellation.Token)); } - private sealed class StubRouteResolver(StorageRouteSnapshotResolution result) : IStorageRouteSnapshotResolver + private sealed class StubRouteResolver(params StorageRouteSnapshotResolution[] results) : IStorageRouteSnapshotResolver { public List Requests { get; } = []; public Task ResolveAsync(StorageRouteSnapshotRequest request, CancellationToken cancellationToken) { Requests.Add(request); - return Task.FromResult(result); + return Task.FromResult(results[Math.Min(Requests.Count - 1, results.Length - 1)]); + } + } + + private sealed class StubReadiness : IAgentRunLogStorageReadiness + { + public List TeamIds { get; } = []; + + public Task EnsureDefaultRouteAsync(Guid teamId, CancellationToken cancellationToken) + { + TeamIds.Add(teamId); + return Task.CompletedTask; } } } diff --git a/backend/tests/CodeSpace.UnitTests/Settings/ArtifactStorageQualificationTests.cs b/backend/tests/CodeSpace.UnitTests/Settings/ArtifactStorageQualificationTests.cs new file mode 100644 index 000000000..548512d9b --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Settings/ArtifactStorageQualificationTests.cs @@ -0,0 +1,20 @@ +using CodeSpace.Core.Settings; +using Microsoft.Extensions.Configuration; +using Shouldly; + +namespace CodeSpace.UnitTests.Settings; + +[Trait("Category", "Unit")] +public sealed class ArtifactStorageQualificationTests +{ + [Fact] + public void Local_rwx_shared_qualification_is_false_unless_the_deployment_explicitly_enables_it() + { + RuntimeSettings.Read(Configuration([])).ArtifactLocalRwxShared.ShouldBeFalse(); + RuntimeSettings.Read(Configuration(new() { ["Artifacts:LocalRwxShared"] = "false" })).ArtifactLocalRwxShared.ShouldBeFalse(); + RuntimeSettings.Read(Configuration(new() { ["Artifacts:LocalRwxShared"] = "true" })).ArtifactLocalRwxShared.ShouldBeTrue(); + } + + private static IConfiguration Configuration(Dictionary values) => + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); +} diff --git a/backend/tests/CodeSpace.UnitTests/Settings/DurableStorageTopologyTests.cs b/backend/tests/CodeSpace.UnitTests/Settings/DurableStorageTopologyTests.cs index a18737531..022a6b3ab 100644 --- a/backend/tests/CodeSpace.UnitTests/Settings/DurableStorageTopologyTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Settings/DurableStorageTopologyTests.cs @@ -25,6 +25,28 @@ public void Worker_spool_is_a_declared_named_volume(string relativePath, string volumes.Children.Keys.Select(ScalarValue).ShouldContain(volumeName); } + [Theory] + [InlineData("docker-compose.yml", "codespace-artifacts")] + [InlineData("backend/deploy/e2e/docker-compose.e2e.yml", "artifacts")] + public void Local_rwx_is_qualified_only_in_stacks_where_api_and_worker_share_one_declared_artifact_volume(string relativePath, string volumeName) + { + var root = Mapping(Load(relativePath)); + var services = Mapping(root.Children[Scalar("services")]); + var volumes = Mapping(root.Children[Scalar("volumes")]); + + foreach (var serviceName in new[] { "api", "worker" }) + { + var service = Mapping(services.Children[Scalar(serviceName)]); + var environment = Mapping(service.Children[Scalar("environment")]); + var mounts = Sequence(service.Children[Scalar("volumes")]).Children.Select(ScalarValue).ToArray(); + + ScalarValue(environment.Children[Scalar("Artifacts__LocalRwxShared")]).ShouldBe("true"); + mounts.ShouldContain($"{volumeName}:{CodeSpace.Core.Settings.DurableRoots.ContainerArtifactStore}"); + } + + volumes.Children.Keys.Select(ScalarValue).ShouldContain(volumeName); + } + private static YamlNode Load(string relativePath) { using var reader = File.OpenText(Path.Combine(FindRepoRoot(), relativePath)); diff --git a/docker-compose.yml b/docker-compose.yml index dc4dcb4ea..47b790620 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,8 @@ services: # Stated explicitly rather than inheriting appsettings.json's committed dev key — a Production-labelled pod # silently signing tokens with a source-controlled key is exactly the footgun this line makes visible. Authentication__Jwt__SymmetricKey: "${CODESPACE_JWT_SYMMETRIC_KEY:-local-dev-only-jwt-key-minimum-32-chars-long}" + # Both app roles mount the same named artifact volume below, so local-rwx is a genuinely shared namespace. + Artifacts__LocalRwxShared: "true" volumes: # Artifact metadata lives in Postgres, but offloaded bytes must be visible to both roles. An image-layer path # has the same spelling in each container while still being a different filesystem, and disappears on recreate. @@ -116,6 +118,7 @@ services: # Both pods share ONE key-ring and ONE token signer, so these MUST match the api service's values above. CODESPACE_VARIABLE_MASTER_KEY: "${CODESPACE_VARIABLE_MASTER_KEY:-bG9jYWwtZGV2LW9ubHkta2V5LW5vdC1mb3ItcHJvZCE=}" Authentication__Jwt__SymmetricKey: "${CODESPACE_JWT_SYMMETRIC_KEY:-local-dev-only-jwt-key-minimum-32-chars-long}" + Artifacts__LocalRwxShared: "true" # The full tool fabric and its governance used to be turned on here. Both are committed constants now # (AgentRunExecutor.FullToolCatalogByDefault, McpRequestHandler.GovernanceEnabled) at exactly the values this # file set, so every deployment gets the posture this one had rather than whatever its env happened to say.