Add redacted provisioning event history
This commit is contained in:
parent
3ae9f4717b
commit
a15b0dd7b3
9 changed files with 126 additions and 17 deletions
|
|
@ -117,7 +117,7 @@
|
||||||
- [x] начальные строгие JSON schemas v1 для `preflight` и `system.base-install`;
|
- [x] начальные строгие JSON schemas v1 для `preflight` и `system.base-install`;
|
||||||
- [ ] versioned JSON schemas для остальных action type;
|
- [ ] versioned JSON schemas для остальных action type;
|
||||||
- [ ] restricted root helper через Unix socket;
|
- [ ] restricted root helper через Unix socket;
|
||||||
- [ ] structured redacted events и progress;
|
- [x] structured redacted events, bounded Operator history и progress;
|
||||||
- [x] `NeedsReconciliation` после истечения execution TTL и неопределённого результата;
|
- [x] `NeedsReconciliation` после истечения execution TTL и неопределённого результата;
|
||||||
- [ ] desired/factual configuration и drift;
|
- [ ] desired/factual configuration и drift;
|
||||||
- [x] запрет параллельных активных заданий на одном Node (безопасный первый вариант).
|
- [x] запрет параллельных активных заданий на одном Node (безопасный первый вариант).
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ namespace ServerMonitorManager.Control;
|
||||||
|
|
||||||
public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||||
{
|
{
|
||||||
private const int CurrentSchemaVersion = 4;
|
private const int CurrentSchemaVersion = 5;
|
||||||
private readonly ControlOptions _options = options.Value;
|
private readonly ControlOptions _options = options.Value;
|
||||||
private readonly string _connectionString = new SqliteConnectionStringBuilder
|
private readonly string _connectionString = new SqliteConnectionStringBuilder
|
||||||
{
|
{
|
||||||
|
|
@ -213,6 +213,23 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||||
await strengthenProvisioningLock.ExecuteNonQueryAsync(cancellationToken);
|
await strengthenProvisioningLock.ExecuteNonQueryAsync(cancellationToken);
|
||||||
await migration.CommitAsync(cancellationToken);
|
await migration.CommitAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (schemaVersion < 5)
|
||||||
|
{
|
||||||
|
await using var migration =
|
||||||
|
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
var addStructuredEventFields = connection.CreateCommand();
|
||||||
|
addStructuredEventFields.Transaction = migration;
|
||||||
|
addStructuredEventFields.CommandText = """
|
||||||
|
ALTER TABLE provisioning_events
|
||||||
|
ADD COLUMN step TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE provisioning_events
|
||||||
|
ADD COLUMN progress_percent INTEGER NOT NULL DEFAULT 0;
|
||||||
|
PRAGMA user_version = 5;
|
||||||
|
""";
|
||||||
|
await addStructuredEventFields.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
await migration.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ControlMaintenanceResult> MaintainAsync(
|
public async Task<ControlMaintenanceResult> MaintainAsync(
|
||||||
|
|
@ -225,9 +242,10 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||||
var command = connection.CreateCommand();
|
var command = connection.CreateCommand();
|
||||||
command.Transaction = transaction;
|
command.Transaction = transaction;
|
||||||
command.CommandText = """
|
command.CommandText = """
|
||||||
INSERT INTO provisioning_events(job_id, recorded_at, event_type, state, message)
|
INSERT INTO provisioning_events(
|
||||||
|
job_id, recorded_at, event_type, state, message, step, progress_percent)
|
||||||
SELECT id, $now, 'job.expired', 'Cancelled',
|
SELECT id, $now, 'job.expired', 'Cancelled',
|
||||||
'Provisioning job expired before execution.'
|
'Provisioning job expired before execution.', 'expired', progress_percent
|
||||||
FROM provisioning_jobs
|
FROM provisioning_jobs
|
||||||
WHERE expires_at <= $now
|
WHERE expires_at <= $now
|
||||||
AND state IN ('Queued', 'AwaitingConfirmation');
|
AND state IN ('Queued', 'AwaitingConfirmation');
|
||||||
|
|
@ -238,9 +256,11 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||||
WHERE expires_at <= $now
|
WHERE expires_at <= $now
|
||||||
AND state IN ('Queued', 'AwaitingConfirmation');
|
AND state IN ('Queued', 'AwaitingConfirmation');
|
||||||
SELECT changes();
|
SELECT changes();
|
||||||
INSERT INTO provisioning_events(job_id, recorded_at, event_type, state, message)
|
INSERT INTO provisioning_events(
|
||||||
|
job_id, recorded_at, event_type, state, message, step, progress_percent)
|
||||||
SELECT id, $now, 'job.reconciliation.required', 'NeedsReconciliation',
|
SELECT id, $now, 'job.reconciliation.required', 'NeedsReconciliation',
|
||||||
'Execution lease expired; factual state must be inspected.'
|
'Execution lease expired; factual state must be inspected.',
|
||||||
|
'reconcile', progress_percent
|
||||||
FROM provisioning_jobs
|
FROM provisioning_jobs
|
||||||
WHERE expires_at <= $now
|
WHERE expires_at <= $now
|
||||||
AND state IN ('Preflight', 'Running', 'Verifying');
|
AND state IN ('Preflight', 'Running', 'Verifying');
|
||||||
|
|
@ -251,9 +271,11 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||||
WHERE expires_at <= $now
|
WHERE expires_at <= $now
|
||||||
AND state IN ('Preflight', 'Running', 'Verifying');
|
AND state IN ('Preflight', 'Running', 'Verifying');
|
||||||
SELECT changes();
|
SELECT changes();
|
||||||
INSERT INTO provisioning_events(job_id, recorded_at, event_type, state, message)
|
INSERT INTO provisioning_events(
|
||||||
|
job_id, recorded_at, event_type, state, message, step, progress_percent)
|
||||||
SELECT id, $now, 'job.rollback.reconciliation.required', 'NeedsReconciliation',
|
SELECT id, $now, 'job.rollback.reconciliation.required', 'NeedsReconciliation',
|
||||||
'Rollback lease expired; factual state must be inspected.'
|
'Rollback lease expired; factual state must be inspected.',
|
||||||
|
'rollback-reconcile', progress_percent
|
||||||
FROM provisioning_jobs
|
FROM provisioning_jobs
|
||||||
WHERE expires_at <= $now AND state = 'RollingBack';
|
WHERE expires_at <= $now AND state = 'RollingBack';
|
||||||
UPDATE provisioning_jobs SET
|
UPDATE provisioning_jobs SET
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,23 @@ control.MapGet("/provisioning/jobs/{id}", async (
|
||||||
var job = await controlStore.GetProvisioningJobAsync(id, cancellationToken);
|
var job = await controlStore.GetProvisioningJobAsync(id, cancellationToken);
|
||||||
return job is null ? Results.NotFound() : Results.Ok(job);
|
return job is null ? Results.NotFound() : Results.Ok(job);
|
||||||
});
|
});
|
||||||
|
control.MapGet("/provisioning/jobs/{id}/events", async (
|
||||||
|
string id,
|
||||||
|
int? limit,
|
||||||
|
ControlStore controlStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (!ProvisioningJobValidator.IsValidId(id) || limit is < 1 or > 200)
|
||||||
|
{
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["provisioningEvents"] = ["Invalid provisioning job id or limit (1-200)."]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var events = await controlStore.ListProvisioningEventsAsync(
|
||||||
|
id, limit ?? 100, cancellationToken);
|
||||||
|
return events is null ? Results.NotFound() : Results.Ok(events.ToArray());
|
||||||
|
});
|
||||||
control.MapPost("/provisioning/jobs/{id}/confirm", async (
|
control.MapPost("/provisioning/jobs/{id}/confirm", async (
|
||||||
string id,
|
string id,
|
||||||
ProvisioningJobCommandRequest request,
|
ProvisioningJobCommandRequest request,
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,8 @@ public sealed partial class ControlStore
|
||||||
|
|
||||||
await WriteProvisioningEventAsync(
|
await WriteProvisioningEventAsync(
|
||||||
connection, transaction, id, request.EventCode, request.State,
|
connection, transaction, id, request.EventCode, request.State,
|
||||||
request.Message, now, cancellationToken);
|
$"Agent reported '{request.EventCode}' for step '{request.Step}' at "
|
||||||
|
+ $"{request.ProgressPercent}%.", now, cancellationToken);
|
||||||
await WriteIdempotentAsync(
|
await WriteIdempotentAsync(
|
||||||
connection, transaction, operationKey, requestHash, updated,
|
connection, transaction, operationKey, requestHash, updated,
|
||||||
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
|
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
|
||||||
|
|
@ -266,6 +267,44 @@ public sealed partial class ControlStore
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ProvisioningEvent>?> ListProvisioningEventsAsync(
|
||||||
|
string id,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenAsync(cancellationToken);
|
||||||
|
var exists = connection.CreateCommand();
|
||||||
|
exists.CommandText = "SELECT EXISTS(SELECT 1 FROM provisioning_jobs WHERE id = $id);";
|
||||||
|
exists.Parameters.AddWithValue("$id", id);
|
||||||
|
if (Convert.ToInt32(await exists.ExecuteScalarAsync(cancellationToken)) != 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<ProvisioningEvent>();
|
||||||
|
var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT sequence, job_id, recorded_at, event_type, state,
|
||||||
|
step, progress_percent, message
|
||||||
|
FROM provisioning_events
|
||||||
|
WHERE job_id = $id
|
||||||
|
ORDER BY sequence DESC
|
||||||
|
LIMIT $limit;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$id", id);
|
||||||
|
command.Parameters.AddWithValue("$limit", Math.Clamp(limit, 1, 200));
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
result.Add(new ProvisioningEvent(
|
||||||
|
reader.GetInt64(0), reader.GetString(1), DateTimeOffset.Parse(reader.GetString(2)),
|
||||||
|
reader.GetString(3), reader.GetString(4), reader.GetString(5),
|
||||||
|
reader.GetInt32(6), reader.GetString(7)));
|
||||||
|
}
|
||||||
|
result.Reverse();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ProvisioningJob?> RetryProvisioningJobAsync(
|
public async Task<ProvisioningJob?> RetryProvisioningJobAsync(
|
||||||
string id,
|
string id,
|
||||||
ProvisioningJobCommandRequest request,
|
ProvisioningJobCommandRequest request,
|
||||||
|
|
@ -336,7 +375,7 @@ public sealed partial class ControlStore
|
||||||
|
|
||||||
await WriteProvisioningEventAsync(
|
await WriteProvisioningEventAsync(
|
||||||
connection, transaction, id, "job.retry.queued", updated.State,
|
connection, transaction, id, "job.retry.queued", updated.State,
|
||||||
request.Reason, now, cancellationToken);
|
"Operator queued a retry after reconciliation.", now, cancellationToken);
|
||||||
await WriteIdempotentAsync(
|
await WriteIdempotentAsync(
|
||||||
connection, transaction, operationKey, requestHash, updated,
|
connection, transaction, operationKey, requestHash, updated,
|
||||||
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
|
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
|
||||||
|
|
@ -418,7 +457,7 @@ public sealed partial class ControlStore
|
||||||
|
|
||||||
await WriteProvisioningEventAsync(
|
await WriteProvisioningEventAsync(
|
||||||
connection, transaction, id, "job.rollback.queued", updated.State,
|
connection, transaction, id, "job.rollback.queued", updated.State,
|
||||||
request.Reason, now, cancellationToken);
|
"Operator queued a rollback.", now, cancellationToken);
|
||||||
await WriteIdempotentAsync(
|
await WriteIdempotentAsync(
|
||||||
connection, transaction, operationKey, requestHash, updated,
|
connection, transaction, operationKey, requestHash, updated,
|
||||||
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
|
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
|
||||||
|
|
@ -494,7 +533,8 @@ public sealed partial class ControlStore
|
||||||
}
|
}
|
||||||
|
|
||||||
await WriteProvisioningEventAsync(
|
await WriteProvisioningEventAsync(
|
||||||
connection, transaction, id, eventType, targetState, request.Reason, now, cancellationToken);
|
connection, transaction, id, eventType, targetState,
|
||||||
|
$"Operator requested '{eventType}'.", now, cancellationToken);
|
||||||
await WriteIdempotentAsync(
|
await WriteIdempotentAsync(
|
||||||
connection, transaction, operationKey, requestHash, updated,
|
connection, transaction, operationKey, requestHash, updated,
|
||||||
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
|
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
|
||||||
|
|
@ -569,8 +609,12 @@ public sealed partial class ControlStore
|
||||||
var command = connection.CreateCommand();
|
var command = connection.CreateCommand();
|
||||||
command.Transaction = transaction;
|
command.Transaction = transaction;
|
||||||
command.CommandText = """
|
command.CommandText = """
|
||||||
INSERT INTO provisioning_events(job_id, recorded_at, event_type, state, message)
|
INSERT INTO provisioning_events(
|
||||||
VALUES ($job, $recorded, $event, $state, $message);
|
job_id, recorded_at, event_type, state, message, step, progress_percent)
|
||||||
|
SELECT $job, $recorded, $event, $state, $message,
|
||||||
|
current_step, progress_percent
|
||||||
|
FROM provisioning_jobs
|
||||||
|
WHERE id = $job;
|
||||||
""";
|
""";
|
||||||
command.Parameters.AddWithValue("$job", jobId);
|
command.Parameters.AddWithValue("$job", jobId);
|
||||||
command.Parameters.AddWithValue("$recorded", recordedAt.ToString("O"));
|
command.Parameters.AddWithValue("$recorded", recordedAt.ToString("O"));
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,16 @@ public sealed record ProvisioningJob(
|
||||||
string CurrentStep,
|
string CurrentStep,
|
||||||
string? LastError);
|
string? LastError);
|
||||||
|
|
||||||
|
public sealed record ProvisioningEvent(
|
||||||
|
long Sequence,
|
||||||
|
string JobId,
|
||||||
|
DateTimeOffset RecordedAt,
|
||||||
|
string EventType,
|
||||||
|
string State,
|
||||||
|
string Step,
|
||||||
|
int ProgressPercent,
|
||||||
|
string Message);
|
||||||
|
|
||||||
public static class ProvisioningJobStates
|
public static class ProvisioningJobStates
|
||||||
{
|
{
|
||||||
public const string Queued = "Queued";
|
public const string Queued = "Queued";
|
||||||
|
|
|
||||||
|
|
@ -30,4 +30,6 @@ namespace ServerMonitorManager.Core;
|
||||||
[JsonSerializable(typeof(ProvisioningJobProgressRequest))]
|
[JsonSerializable(typeof(ProvisioningJobProgressRequest))]
|
||||||
[JsonSerializable(typeof(ProvisioningJob))]
|
[JsonSerializable(typeof(ProvisioningJob))]
|
||||||
[JsonSerializable(typeof(ProvisioningJob[]))]
|
[JsonSerializable(typeof(ProvisioningJob[]))]
|
||||||
|
[JsonSerializable(typeof(ProvisioningEvent))]
|
||||||
|
[JsonSerializable(typeof(ProvisioningEvent[]))]
|
||||||
public sealed partial class SmmJsonContext : JsonSerializerContext;
|
public sealed partial class SmmJsonContext : JsonSerializerContext;
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,13 @@ public sealed class ControlApiTests : IAsyncDisposable
|
||||||
Assert.Equal(ServerMonitorManager.Core.ProvisioningJobStates.AwaitingConfirmation, job.State);
|
Assert.Equal(ServerMonitorManager.Core.ProvisioningJobStates.AwaitingConfirmation, job.State);
|
||||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync(
|
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync(
|
||||||
$"/api/v1/control/provisioning/jobs/{job.Id}", cancellationToken)).StatusCode);
|
$"/api/v1/control/provisioning/jobs/{job.Id}", cancellationToken)).StatusCode);
|
||||||
|
using var eventsResponse = await client.GetAsync(
|
||||||
|
$"/api/v1/control/provisioning/jobs/{job.Id}/events?limit=10", cancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
|
||||||
|
var events = await eventsResponse.Content.ReadFromJsonAsync<ServerMonitorManager.Core.ProvisioningEvent[]>(
|
||||||
|
cancellationToken);
|
||||||
|
Assert.NotNull(events);
|
||||||
|
Assert.NotEmpty(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ public sealed class ControlMaintenanceTests : IAsyncDisposable
|
||||||
await verify.OpenAsync(cancellationToken);
|
await verify.OpenAsync(cancellationToken);
|
||||||
var version = verify.CreateCommand();
|
var version = verify.CreateCommand();
|
||||||
version.CommandText = "PRAGMA user_version;";
|
version.CommandText = "PRAGMA user_version;";
|
||||||
Assert.Equal(4L, (long)(await version.ExecuteScalarAsync(cancellationToken))!);
|
Assert.Equal(5L, (long)(await version.ExecuteScalarAsync(cancellationToken))!);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
||||||
await migrated.OpenAsync(TestContext.Current.CancellationToken);
|
await migrated.OpenAsync(TestContext.Current.CancellationToken);
|
||||||
var version = migrated.CreateCommand();
|
var version = migrated.CreateCommand();
|
||||||
version.CommandText = "PRAGMA user_version;";
|
version.CommandText = "PRAGMA user_version;";
|
||||||
Assert.Equal(4L, Convert.ToInt64(await version.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
Assert.Equal(5L, Convert.ToInt64(await version.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
||||||
var table = migrated.CreateCommand();
|
var table = migrated.CreateCommand();
|
||||||
table.CommandText = "SELECT COUNT(*) FROM pragma_table_info('provisioning_jobs');";
|
table.CommandText = "SELECT COUNT(*) FROM pragma_table_info('provisioning_jobs');";
|
||||||
Assert.Equal(18L, Convert.ToInt64(await table.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
Assert.Equal(18L, Convert.ToInt64(await table.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
||||||
|
|
@ -113,7 +113,7 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
||||||
|
|
||||||
var preflight = new ProvisioningJobProgressRequest(
|
var preflight = new ProvisioningJobProgressRequest(
|
||||||
ProvisioningJobStates.Preflight, 10, "inspect-os", "preflight.progress",
|
ProvisioningJobStates.Preflight, 10, "inspect-os", "preflight.progress",
|
||||||
"Operating system detected.", Guid.NewGuid().ToString());
|
"secret-token-should-never-be-persisted", Guid.NewGuid().ToString());
|
||||||
Assert.Null(await store.ReportProvisioningProgressAsync(
|
Assert.Null(await store.ReportProvisioningProgressAsync(
|
||||||
"other", created.Id, preflight, cancellationToken));
|
"other", created.Id, preflight, cancellationToken));
|
||||||
var firstProgress = await store.ReportProvisioningProgressAsync(
|
var firstProgress = await store.ReportProvisioningProgressAsync(
|
||||||
|
|
@ -170,6 +170,13 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
||||||
Assert.Equal(ProvisioningJobStates.Running, running!.State);
|
Assert.Equal(ProvisioningJobStates.Running, running!.State);
|
||||||
Assert.Equal(ProvisioningJobStates.Verifying, verifying!.State);
|
Assert.Equal(ProvisioningJobStates.Verifying, verifying!.State);
|
||||||
Assert.Equal(ProvisioningJobStates.Completed, completed!.State);
|
Assert.Equal(ProvisioningJobStates.Completed, completed!.State);
|
||||||
|
var events = await store.ListProvisioningEventsAsync(created.Id, 100, cancellationToken);
|
||||||
|
Assert.NotNull(events);
|
||||||
|
var preflightEvent = Assert.Single(events!, item => item.EventType == "preflight.progress");
|
||||||
|
Assert.Equal("inspect-os", preflightEvent.Step);
|
||||||
|
Assert.Equal(10, preflightEvent.ProgressPercent);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
events!, item => item.Message.Contains("secret-token", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue