up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 18:08:55 +02:00
parent 6e45066e37
commit f1a39c4ce3
234 changed files with 24038 additions and 6910 deletions

View File

@@ -0,0 +1,220 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IPackRunApprovalStore"/>.
/// </summary>
public sealed class PostgresPackRunApprovalStore : RepositoryBase<TaskRunnerDataSource>, IPackRunApprovalStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresPackRunApprovalStore(TaskRunnerDataSource dataSource, ILogger<PostgresPackRunApprovalStore> logger)
: base(dataSource, logger)
{
}
public async Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(approvals);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
// Delete existing approvals for this run, then insert all new ones
const string deleteSql = "DELETE FROM taskrunner.pack_run_approvals WHERE run_id = @run_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
AddParameter(deleteCmd, "@run_id", runId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
if (approvals.Count == 0)
{
return;
}
const string insertSql = @"
INSERT INTO taskrunner.pack_run_approvals (
run_id, approval_id, required_grants, step_ids, messages, reason_template,
requested_at, status, actor_id, completed_at, summary
) VALUES (
@run_id, @approval_id, @required_grants, @step_ids, @messages, @reason_template,
@requested_at, @status, @actor_id, @completed_at, @summary
)";
foreach (var approval in approvals)
{
await using var insertCmd = CreateCommand(insertSql, connection);
AddParameter(insertCmd, "@run_id", runId);
AddParameter(insertCmd, "@approval_id", approval.ApprovalId);
AddJsonbParameter(insertCmd, "@required_grants", JsonSerializer.Serialize(approval.RequiredGrants, JsonOptions));
AddJsonbParameter(insertCmd, "@step_ids", JsonSerializer.Serialize(approval.StepIds, JsonOptions));
AddJsonbParameter(insertCmd, "@messages", JsonSerializer.Serialize(approval.Messages, JsonOptions));
AddParameter(insertCmd, "@reason_template", (object?)approval.ReasonTemplate ?? DBNull.Value);
AddParameter(insertCmd, "@requested_at", approval.RequestedAt);
AddParameter(insertCmd, "@status", approval.Status.ToString());
AddParameter(insertCmd, "@actor_id", (object?)approval.ActorId ?? DBNull.Value);
AddParameter(insertCmd, "@completed_at", (object?)approval.CompletedAt ?? DBNull.Value);
AddParameter(insertCmd, "@summary", (object?)approval.Summary ?? DBNull.Value);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
public async Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT approval_id, required_grants, step_ids, messages, reason_template,
requested_at, status, actor_id, completed_at, summary
FROM taskrunner.pack_run_approvals
WHERE run_id = @run_id
ORDER BY requested_at";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunApprovalState>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapApprovalState(reader));
}
return results;
}
public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(approval);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
UPDATE taskrunner.pack_run_approvals
SET required_grants = @required_grants,
step_ids = @step_ids,
messages = @messages,
reason_template = @reason_template,
requested_at = @requested_at,
status = @status,
actor_id = @actor_id,
completed_at = @completed_at,
summary = @summary
WHERE run_id = @run_id AND approval_id = @approval_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
AddParameter(command, "@approval_id", approval.ApprovalId);
AddJsonbParameter(command, "@required_grants", JsonSerializer.Serialize(approval.RequiredGrants, JsonOptions));
AddJsonbParameter(command, "@step_ids", JsonSerializer.Serialize(approval.StepIds, JsonOptions));
AddJsonbParameter(command, "@messages", JsonSerializer.Serialize(approval.Messages, JsonOptions));
AddParameter(command, "@reason_template", (object?)approval.ReasonTemplate ?? DBNull.Value);
AddParameter(command, "@requested_at", approval.RequestedAt);
AddParameter(command, "@status", approval.Status.ToString());
AddParameter(command, "@actor_id", (object?)approval.ActorId ?? DBNull.Value);
AddParameter(command, "@completed_at", (object?)approval.CompletedAt ?? DBNull.Value);
AddParameter(command, "@summary", (object?)approval.Summary ?? DBNull.Value);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (rowsAffected == 0)
{
throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'.");
}
}
private static PackRunApprovalState MapApprovalState(NpgsqlDataReader reader)
{
var approvalId = reader.GetString(0);
var requiredGrantsJson = reader.GetString(1);
var stepIdsJson = reader.GetString(2);
var messagesJson = reader.GetString(3);
var reasonTemplate = reader.IsDBNull(4) ? null : reader.GetString(4);
var requestedAt = reader.GetFieldValue<DateTimeOffset>(5);
var statusString = reader.GetString(6);
var actorId = reader.IsDBNull(7) ? null : reader.GetString(7);
var completedAt = reader.IsDBNull(8) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(8);
var summary = reader.IsDBNull(9) ? null : reader.GetString(9);
var requiredGrants = JsonSerializer.Deserialize<List<string>>(requiredGrantsJson, JsonOptions)
?? new List<string>();
var stepIds = JsonSerializer.Deserialize<List<string>>(stepIdsJson, JsonOptions)
?? new List<string>();
var messages = JsonSerializer.Deserialize<List<string>>(messagesJson, JsonOptions)
?? new List<string>();
if (!Enum.TryParse<PackRunApprovalStatus>(statusString, ignoreCase: true, out var status))
{
status = PackRunApprovalStatus.Pending;
}
return new PackRunApprovalState(
approvalId,
requiredGrants,
stepIds,
messages,
reasonTemplate,
requestedAt,
status,
actorId,
completedAt,
summary);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS taskrunner;
CREATE TABLE IF NOT EXISTS taskrunner.pack_run_approvals (
run_id TEXT NOT NULL,
approval_id TEXT NOT NULL,
required_grants JSONB NOT NULL,
step_ids JSONB NOT NULL,
messages JSONB NOT NULL,
reason_template TEXT,
requested_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL,
actor_id TEXT,
completed_at TIMESTAMPTZ,
summary TEXT,
PRIMARY KEY (run_id, approval_id)
);
CREATE INDEX IF NOT EXISTS idx_pack_run_approvals_status ON taskrunner.pack_run_approvals (status);
CREATE INDEX IF NOT EXISTS idx_pack_run_approvals_requested_at ON taskrunner.pack_run_approvals (requested_at);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -0,0 +1,293 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TaskRunner.Core.Evidence;
namespace StellaOps.TaskRunner.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IPackRunEvidenceStore"/>.
/// </summary>
public sealed class PostgresPackRunEvidenceStore : RepositoryBase<TaskRunnerDataSource>, IPackRunEvidenceStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresPackRunEvidenceStore(TaskRunnerDataSource dataSource, ILogger<PostgresPackRunEvidenceStore> logger)
: base(dataSource, logger)
{
}
public async Task StoreAsync(PackRunEvidenceSnapshot snapshot, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
INSERT INTO taskrunner.pack_run_evidence (
snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
) VALUES (
@snapshot_id, @tenant_id, @run_id, @plan_hash, @created_at, @kind, @materials_json, @root_hash, @metadata_json
)
ON CONFLICT (snapshot_id)
DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
run_id = EXCLUDED.run_id,
plan_hash = EXCLUDED.plan_hash,
created_at = EXCLUDED.created_at,
kind = EXCLUDED.kind,
materials_json = EXCLUDED.materials_json,
root_hash = EXCLUDED.root_hash,
metadata_json = EXCLUDED.metadata_json";
var materialsJson = JsonSerializer.Serialize(snapshot.Materials, JsonOptions);
var metadataJson = snapshot.Metadata is null
? null
: JsonSerializer.Serialize(snapshot.Metadata, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@snapshot_id", snapshot.SnapshotId);
AddParameter(command, "@tenant_id", snapshot.TenantId);
AddParameter(command, "@run_id", snapshot.RunId);
AddParameter(command, "@plan_hash", snapshot.PlanHash);
AddParameter(command, "@created_at", snapshot.CreatedAt);
AddParameter(command, "@kind", snapshot.Kind.ToString());
AddJsonbParameter(command, "@materials_json", materialsJson);
AddParameter(command, "@root_hash", snapshot.RootHash);
if (metadataJson is not null)
{
AddJsonbParameter(command, "@metadata_json", metadataJson);
}
else
{
AddParameter(command, "@metadata_json", DBNull.Value);
}
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<PackRunEvidenceSnapshot?> GetAsync(Guid snapshotId, CancellationToken cancellationToken = default)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
FROM taskrunner.pack_run_evidence
WHERE snapshot_id = @snapshot_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@snapshot_id", snapshotId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapSnapshot(reader);
}
public async Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
FROM taskrunner.pack_run_evidence
WHERE LOWER(tenant_id) = LOWER(@tenant_id) AND run_id = @run_id
ORDER BY created_at";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@tenant_id", tenantId);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunEvidenceSnapshot>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapSnapshot(reader));
}
return results;
}
public async Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
string runId,
CancellationToken cancellationToken = default)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
FROM taskrunner.pack_run_evidence
WHERE run_id = @run_id
ORDER BY created_at";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunEvidenceSnapshot>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapSnapshot(reader));
}
return results;
}
public async Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
string tenantId,
string runId,
PackRunEvidenceSnapshotKind kind,
CancellationToken cancellationToken = default)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT snapshot_id, tenant_id, run_id, plan_hash, created_at, kind, materials_json, root_hash, metadata_json
FROM taskrunner.pack_run_evidence
WHERE LOWER(tenant_id) = LOWER(@tenant_id) AND run_id = @run_id AND kind = @kind
ORDER BY created_at";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@tenant_id", tenantId);
AddParameter(command, "@run_id", runId);
AddParameter(command, "@kind", kind.ToString());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunEvidenceSnapshot>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapSnapshot(reader));
}
return results;
}
public async Task<PackRunEvidenceVerificationResult> VerifyAsync(
Guid snapshotId,
CancellationToken cancellationToken = default)
{
var snapshot = await GetAsync(snapshotId, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return new PackRunEvidenceVerificationResult(
Valid: false,
SnapshotId: snapshotId,
ExpectedHash: string.Empty,
ComputedHash: string.Empty,
Error: "Snapshot not found");
}
// Recompute by creating a new snapshot with same materials
var recomputed = PackRunEvidenceSnapshot.Create(
snapshot.TenantId,
snapshot.RunId,
snapshot.PlanHash,
snapshot.Kind,
snapshot.Materials,
snapshot.Metadata);
var valid = string.Equals(snapshot.RootHash, recomputed.RootHash, StringComparison.Ordinal);
return new PackRunEvidenceVerificationResult(
Valid: valid,
SnapshotId: snapshotId,
ExpectedHash: snapshot.RootHash,
ComputedHash: recomputed.RootHash,
Error: valid ? null : "Root hash mismatch");
}
private static PackRunEvidenceSnapshot MapSnapshot(NpgsqlDataReader reader)
{
var snapshotId = reader.GetGuid(0);
var tenantId = reader.GetString(1);
var runId = reader.GetString(2);
var planHash = reader.GetString(3);
var createdAt = reader.GetFieldValue<DateTimeOffset>(4);
var kindString = reader.GetString(5);
var materialsJson = reader.GetString(6);
var rootHash = reader.GetString(7);
var metadataJson = reader.IsDBNull(8) ? null : reader.GetString(8);
if (!Enum.TryParse<PackRunEvidenceSnapshotKind>(kindString, ignoreCase: true, out var kind))
{
kind = PackRunEvidenceSnapshotKind.RunCompletion;
}
var materials = JsonSerializer.Deserialize<List<PackRunEvidenceMaterial>>(materialsJson, JsonOptions)
?? new List<PackRunEvidenceMaterial>();
IReadOnlyDictionary<string, string>? metadata = null;
if (metadataJson is not null)
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(metadataJson, JsonOptions);
}
return new PackRunEvidenceSnapshot(
snapshotId,
tenantId,
runId,
planHash,
createdAt,
kind,
materials,
rootHash,
metadata);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS taskrunner;
CREATE TABLE IF NOT EXISTS taskrunner.pack_run_evidence (
snapshot_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
run_id TEXT NOT NULL,
plan_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
kind TEXT NOT NULL,
materials_json JSONB NOT NULL,
root_hash TEXT NOT NULL,
metadata_json JSONB
);
CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_run_id ON taskrunner.pack_run_evidence (run_id);
CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_tenant_run ON taskrunner.pack_run_evidence (tenant_id, run_id);
CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_kind ON taskrunner.pack_run_evidence (tenant_id, run_id, kind);
CREATE INDEX IF NOT EXISTS idx_pack_run_evidence_created_at ON taskrunner.pack_run_evidence (created_at);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -0,0 +1,156 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IPackRunLogStore"/>.
/// </summary>
public sealed class PostgresPackRunLogStore : RepositoryBase<TaskRunnerDataSource>, IPackRunLogStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresPackRunLogStore(TaskRunnerDataSource dataSource, ILogger<PostgresPackRunLogStore> logger)
: base(dataSource, logger)
{
}
public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(entry);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
INSERT INTO taskrunner.pack_run_logs (run_id, timestamp, level, event_type, message, step_id, metadata)
VALUES (@run_id, @timestamp, @level, @event_type, @message, @step_id, @metadata)";
var metadataJson = entry.Metadata is null
? null
: JsonSerializer.Serialize(entry.Metadata, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
AddParameter(command, "@timestamp", entry.Timestamp);
AddParameter(command, "@level", entry.Level);
AddParameter(command, "@event_type", entry.EventType);
AddParameter(command, "@message", entry.Message);
AddParameter(command, "@step_id", (object?)entry.StepId ?? DBNull.Value);
if (metadataJson is not null)
{
AddJsonbParameter(command, "@metadata", metadataJson);
}
else
{
AddParameter(command, "@metadata", DBNull.Value);
}
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async IAsyncEnumerable<PackRunLogEntry> ReadAsync(
string runId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT timestamp, level, event_type, message, step_id, metadata
FROM taskrunner.pack_run_logs
WHERE run_id = @run_id
ORDER BY timestamp, id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
yield return MapLogEntry(reader);
}
}
public async Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT EXISTS(SELECT 1 FROM taskrunner.pack_run_logs WHERE run_id = @run_id)";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private static PackRunLogEntry MapLogEntry(NpgsqlDataReader reader)
{
var timestamp = reader.GetFieldValue<DateTimeOffset>(0);
var level = reader.GetString(1);
var eventType = reader.GetString(2);
var message = reader.GetString(3);
var stepId = reader.IsDBNull(4) ? null : reader.GetString(4);
var metadataJson = reader.IsDBNull(5) ? null : reader.GetString(5);
IReadOnlyDictionary<string, string>? metadata = null;
if (metadataJson is not null)
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(metadataJson, JsonOptions);
}
return new PackRunLogEntry(timestamp, level, eventType, message, stepId, metadata);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS taskrunner;
CREATE TABLE IF NOT EXISTS taskrunner.pack_run_logs (
id BIGSERIAL PRIMARY KEY,
run_id TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
level TEXT NOT NULL,
event_type TEXT NOT NULL,
message TEXT NOT NULL,
step_id TEXT,
metadata JSONB
);
CREATE INDEX IF NOT EXISTS idx_pack_run_logs_run_id ON taskrunner.pack_run_logs (run_id);
CREATE INDEX IF NOT EXISTS idx_pack_run_logs_timestamp ON taskrunner.pack_run_logs (timestamp);
CREATE INDEX IF NOT EXISTS idx_pack_run_logs_run_timestamp ON taskrunner.pack_run_logs (run_id, timestamp, id);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -0,0 +1,173 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IPackRunStateStore"/>.
/// </summary>
public sealed class PostgresPackRunStateStore : RepositoryBase<TaskRunnerDataSource>, IPackRunStateStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private bool _tableInitialized;
public PostgresPackRunStateStore(TaskRunnerDataSource dataSource, ILogger<PostgresPackRunStateStore> logger)
: base(dataSource, logger)
{
}
public async Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT run_id, plan_hash, plan_json, failure_policy_json, requested_at, created_at, updated_at, steps_json, tenant_id
FROM taskrunner.pack_run_state
WHERE run_id = @run_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapPackRunState(reader);
}
public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
INSERT INTO taskrunner.pack_run_state (run_id, plan_hash, plan_json, failure_policy_json, requested_at, created_at, updated_at, steps_json, tenant_id)
VALUES (@run_id, @plan_hash, @plan_json, @failure_policy_json, @requested_at, @created_at, @updated_at, @steps_json, @tenant_id)
ON CONFLICT (run_id)
DO UPDATE SET
plan_hash = EXCLUDED.plan_hash,
plan_json = EXCLUDED.plan_json,
failure_policy_json = EXCLUDED.failure_policy_json,
requested_at = EXCLUDED.requested_at,
updated_at = EXCLUDED.updated_at,
steps_json = EXCLUDED.steps_json,
tenant_id = EXCLUDED.tenant_id";
var planJson = JsonSerializer.Serialize(state.Plan, JsonOptions);
var failurePolicyJson = JsonSerializer.Serialize(state.FailurePolicy, JsonOptions);
var stepsJson = JsonSerializer.Serialize(state.Steps, JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@run_id", state.RunId);
AddParameter(command, "@plan_hash", state.PlanHash);
AddJsonbParameter(command, "@plan_json", planJson);
AddJsonbParameter(command, "@failure_policy_json", failurePolicyJson);
AddParameter(command, "@requested_at", state.RequestedAt);
AddParameter(command, "@created_at", state.CreatedAt);
AddParameter(command, "@updated_at", state.UpdatedAt);
AddJsonbParameter(command, "@steps_json", stepsJson);
AddParameter(command, "@tenant_id", (object?)state.TenantId ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT run_id, plan_hash, plan_json, failure_policy_json, requested_at, created_at, updated_at, steps_json, tenant_id
FROM taskrunner.pack_run_state
ORDER BY created_at DESC";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackRunState>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapPackRunState(reader));
}
return results;
}
private static PackRunState MapPackRunState(NpgsqlDataReader reader)
{
var runId = reader.GetString(0);
var planHash = reader.GetString(1);
var planJson = reader.GetString(2);
var failurePolicyJson = reader.GetString(3);
var requestedAt = reader.GetFieldValue<DateTimeOffset>(4);
var createdAt = reader.GetFieldValue<DateTimeOffset>(5);
var updatedAt = reader.GetFieldValue<DateTimeOffset>(6);
var stepsJson = reader.GetString(7);
var tenantId = reader.IsDBNull(8) ? null : reader.GetString(8);
var plan = JsonSerializer.Deserialize<TaskPackPlan>(planJson, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize plan for run '{runId}'");
var failurePolicy = JsonSerializer.Deserialize<TaskPackPlanFailurePolicy>(failurePolicyJson, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize failure policy for run '{runId}'");
var steps = JsonSerializer.Deserialize<Dictionary<string, PackRunStepStateRecord>>(stepsJson, JsonOptions)
?? new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal);
return new PackRunState(
runId,
planHash,
plan,
failurePolicy,
requestedAt,
createdAt,
updatedAt,
steps,
tenantId);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS taskrunner;
CREATE TABLE IF NOT EXISTS taskrunner.pack_run_state (
run_id TEXT PRIMARY KEY,
plan_hash TEXT NOT NULL,
plan_json JSONB NOT NULL,
failure_policy_json JSONB NOT NULL,
requested_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
steps_json JSONB NOT NULL,
tenant_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_pack_run_state_tenant_id ON taskrunner.pack_run_state (tenant_id);
CREATE INDEX IF NOT EXISTS idx_pack_run_state_created_at ON taskrunner.pack_run_state (created_at DESC);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
}

View File

@@ -0,0 +1,60 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.TaskRunner.Core.Evidence;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Storage.Postgres.Repositories;
namespace StellaOps.TaskRunner.Storage.Postgres;
/// <summary>
/// Extension methods for configuring TaskRunner PostgreSQL storage services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds TaskRunner PostgreSQL storage services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddTaskRunnerPostgresStorage(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:TaskRunner")
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<TaskRunnerDataSource>();
// Register repositories as scoped (per-request lifetime)
services.AddScoped<IPackRunStateStore, PostgresPackRunStateStore>();
services.AddScoped<IPackRunApprovalStore, PostgresPackRunApprovalStore>();
services.AddScoped<IPackRunLogStore, PostgresPackRunLogStore>();
services.AddScoped<IPackRunEvidenceStore, PostgresPackRunEvidenceStore>();
return services;
}
/// <summary>
/// Adds TaskRunner PostgreSQL storage services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddTaskRunnerPostgresStorage(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<TaskRunnerDataSource>();
// Register repositories as scoped (per-request lifetime)
services.AddScoped<IPackRunStateStore, PostgresPackRunStateStore>();
services.AddScoped<IPackRunApprovalStore, PostgresPackRunApprovalStore>();
services.AddScoped<IPackRunLogStore, PostgresPackRunLogStore>();
services.AddScoped<IPackRunEvidenceStore, PostgresPackRunEvidenceStore>();
return services;
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.TaskRunner.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.TaskRunner/StellaOps.TaskRunner.Core/StellaOps.TaskRunner.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.TaskRunner.Storage.Postgres;
/// <summary>
/// PostgreSQL data source for TaskRunner module.
/// </summary>
public sealed class TaskRunnerDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for TaskRunner tables.
/// </summary>
public const string DefaultSchemaName = "taskrunner";
/// <summary>
/// Creates a new TaskRunner data source.
/// </summary>
public TaskRunnerDataSource(IOptions<PostgresOptions> options, ILogger<TaskRunnerDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "TaskRunner";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}