Persist typed provisioning preflight facts
This commit is contained in:
parent
4d08b93d23
commit
e0c755adf1
10 changed files with 288 additions and 21 deletions
|
|
@ -119,7 +119,7 @@
|
|||
- [x] restricted root helper через Unix socket (первый allowlisted action `preflight`);
|
||||
- [x] structured redacted events, bounded Operator history и progress;
|
||||
- [x] `NeedsReconciliation` после истечения execution TTL и неопределённого результата;
|
||||
- [ ] desired/factual configuration и drift;
|
||||
- [ ] desired/factual configuration и drift (типизированные factual preflight facts уже сохраняются);
|
||||
- [x] запрет параллельных активных заданий на одном Node (безопасный первый вариант).
|
||||
|
||||
## Этап 9 — базовая настройка и пользователи
|
||||
|
|
|
|||
|
|
@ -144,33 +144,34 @@ internal sealed class AgentClient(AgentOptions options)
|
|||
return;
|
||||
}
|
||||
|
||||
var currentProgress = job.ProgressPercent;
|
||||
ProvisioningPreflightResult result;
|
||||
try
|
||||
{
|
||||
var helper = new ProvisioningHelperClient(options.ProvisioningSocketPath);
|
||||
var result = await helper.RunPreflightAsync(job, cancellationToken);
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Running, 40,
|
||||
"inspect-host", "preflight.inspected", "Host inspection completed.", cancellationToken);
|
||||
currentProgress = 40;
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Verifying, 80,
|
||||
"verify-host", "preflight.verifying", "Verifying preflight result.", cancellationToken);
|
||||
currentProgress = 80;
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Completed, 100,
|
||||
"completed", "preflight.completed", "Preflight completed.", cancellationToken);
|
||||
Console.WriteLine(
|
||||
$"Preflight {job.Id} completed: {result.OperatingSystem} "
|
||||
+ $"{result.OperatingSystemVersion} {result.Architecture}.");
|
||||
result = await helper.RunPreflightAsync(job, cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Failed, currentProgress,
|
||||
client, job, ProvisioningJobStates.Failed, job.ProgressPercent,
|
||||
"preflight", "preflight.failed", "Preflight helper failed.", cancellationToken);
|
||||
Console.Error.WriteLine($"Preflight {job.Id} failed: {exception.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
await ReportPreflightFactsAsync(client, job, result, cancellationToken);
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Running, 40,
|
||||
"inspect-host", "preflight.inspected", "Host inspection completed.", cancellationToken);
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Verifying, 80,
|
||||
"verify-host", "preflight.verifying", "Verifying preflight result.", cancellationToken);
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Completed, 100,
|
||||
"completed", "preflight.completed", "Preflight completed.", cancellationToken);
|
||||
Console.WriteLine(
|
||||
$"Preflight {job.Id} completed: {result.OperatingSystem} "
|
||||
+ $"{result.OperatingSystemVersion} {result.Architecture}.");
|
||||
}
|
||||
|
||||
private static async Task ReportProvisioningAsync(
|
||||
|
|
@ -193,6 +194,22 @@ internal sealed class AgentClient(AgentOptions options)
|
|||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task ReportPreflightFactsAsync(
|
||||
HttpClient client,
|
||||
ProvisioningJob job,
|
||||
ProvisioningPreflightResult facts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new ProvisioningPreflightReportRequest(
|
||||
facts, DateTimeOffset.UtcNow, CreateOperationId(job.Id, "facts"));
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
$"api/v1/agents/provisioning/jobs/{job.Id}/preflight-facts",
|
||||
request,
|
||||
SmmJsonContext.Default.ProvisioningPreflightReportRequest,
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static string CreateOperationId(string jobId, string state)
|
||||
{
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes($"{jobId}:{state}"));
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace ServerMonitorManager.Control;
|
|||
|
||||
public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||
{
|
||||
private const int CurrentSchemaVersion = 5;
|
||||
private const int CurrentSchemaVersion = 6;
|
||||
private readonly ControlOptions _options = options.Value;
|
||||
private readonly string _connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
|
|
@ -230,6 +230,27 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
|||
await addStructuredEventFields.ExecuteNonQueryAsync(cancellationToken);
|
||||
await migration.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (schemaVersion < 6)
|
||||
{
|
||||
await using var migration =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var addNodePreflightFacts = connection.CreateCommand();
|
||||
addNodePreflightFacts.Transaction = migration;
|
||||
addNodePreflightFacts.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS node_preflight_facts (
|
||||
node_id TEXT PRIMARY KEY REFERENCES agents(node_id) ON DELETE CASCADE,
|
||||
schema_version INTEGER NOT NULL,
|
||||
facts_json TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL,
|
||||
source_job_id TEXT NOT NULL REFERENCES provisioning_jobs(id),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 6;
|
||||
""";
|
||||
await addNodePreflightFacts.ExecuteNonQueryAsync(cancellationToken);
|
||||
await migration.CommitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ControlMaintenanceResult> MaintainAsync(
|
||||
|
|
|
|||
|
|
@ -426,10 +426,59 @@ agents.MapPost("/provisioning/jobs/{id}/progress", async (
|
|||
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
||||
}
|
||||
});
|
||||
agents.MapPost("/provisioning/jobs/{id}/preflight-facts", async (
|
||||
string id,
|
||||
ProvisioningPreflightReportRequest request,
|
||||
HttpContext context,
|
||||
ControlStore controlStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var nodeId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(nodeId)
|
||||
|| !NodeIdValidator.IsValid(nodeId)
|
||||
|| !ProvisioningJobValidator.IsValidId(id)
|
||||
|| !ProvisioningJobValidator.IsValid(request))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["preflightFacts"] = ["Invalid job id, facts, observation time, or idempotency key."]
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var facts = await controlStore.RecordPreflightFactsAsync(
|
||||
nodeId, id, request, cancellationToken);
|
||||
return facts is null ? Results.NotFound() : Results.Ok(facts);
|
||||
}
|
||||
catch (IdempotencyConflictException)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
||||
}
|
||||
catch (ProvisioningTransitionException exception)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");
|
||||
control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) =>
|
||||
Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray()));
|
||||
control.MapGet("/agents/{nodeId}/facts/preflight", async (
|
||||
string nodeId,
|
||||
ControlStore controlStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!NodeIdValidator.IsValid(nodeId))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["nodeId"] = ["Invalid node id."]
|
||||
});
|
||||
}
|
||||
var facts = await controlStore.GetPreflightFactsAsync(nodeId, cancellationToken);
|
||||
return facts is null ? Results.NotFound() : Results.Ok(facts);
|
||||
});
|
||||
control.MapPost("/agents/{nodeId}/provisioning/jobs", async (
|
||||
string nodeId,
|
||||
ProvisioningJobCreateRequest request,
|
||||
|
|
@ -879,6 +928,15 @@ internal static class ProvisioningJobValidator
|
|||
&& request.Message.Length <= 512
|
||||
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
||||
|
||||
public static bool IsValid(ProvisioningPreflightReportRequest request)
|
||||
=> request.ObservedAt >= DateTimeOffset.UtcNow.AddHours(-1)
|
||||
&& request.ObservedAt <= DateTimeOffset.UtcNow.AddMinutes(1)
|
||||
&& request.Facts is not null
|
||||
&& IsSafeFact(request.Facts.OperatingSystem, 32)
|
||||
&& IsSafeFact(request.Facts.OperatingSystemVersion, 64)
|
||||
&& request.Facts.Architecture is "x64" or "x86" or "arm" or "arm64"
|
||||
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
||||
|
||||
public static bool IsValidId(string id)
|
||||
=> id.Length == 32 && Guid.TryParseExact(id, "N", out _);
|
||||
|
||||
|
|
@ -890,4 +948,10 @@ internal static class ProvisioningJobValidator
|
|||
&& value.All(character => character is >= 'a' and <= 'z'
|
||||
or >= '0' and <= '9'
|
||||
or '.' or '-' or '_');
|
||||
|
||||
private static bool IsSafeFact(string? value, int maximumLength)
|
||||
=> value is not null
|
||||
&& value.Length is >= 1 && value.Length <= maximumLength
|
||||
&& value.All(character => char.IsAsciiLetterOrDigit(character)
|
||||
|| character is '_' or '-' or '.');
|
||||
}
|
||||
|
|
|
|||
100
src/ServerMonitorManager.Control/ProvisioningFactsStore.cs
Normal file
100
src/ServerMonitorManager.Control/ProvisioningFactsStore.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ServerMonitorManager.Core;
|
||||
|
||||
namespace ServerMonitorManager.Control;
|
||||
|
||||
public sealed partial class ControlStore
|
||||
{
|
||||
public async Task<NodePreflightFacts?> RecordPreflightFactsAsync(
|
||||
string nodeId,
|
||||
string jobId,
|
||||
ProvisioningPreflightReportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var operationKey = $"provisioning-preflight:{nodeId}:{jobId}:{request.IdempotencyKey}";
|
||||
var requestHash = Fingerprint(request, SmmJsonContext.Default.ProvisioningPreflightReportRequest);
|
||||
var cached = await ReadIdempotentAsync(
|
||||
connection, transaction, operationKey, requestHash,
|
||||
SmmJsonContext.Default.NodePreflightFacts, cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var job = await ReadProvisioningJobAsync(connection, transaction, jobId, cancellationToken);
|
||||
if (job is null || !string.Equals(job.NodeId, nodeId, StringComparison.Ordinal))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
if (job.ActionType != "preflight" || job.SchemaVersion != 1
|
||||
|| job.State != ProvisioningJobStates.Preflight)
|
||||
{
|
||||
throw new ProvisioningTransitionException(job.State, "PreflightFacts");
|
||||
}
|
||||
|
||||
var facts = new NodePreflightFacts(
|
||||
nodeId, 1, request.Facts, request.ObservedAt, jobId, DateTimeOffset.UtcNow);
|
||||
var upsert = connection.CreateCommand();
|
||||
upsert.Transaction = transaction;
|
||||
upsert.CommandText = """
|
||||
INSERT INTO node_preflight_facts(
|
||||
node_id, schema_version, facts_json, observed_at, source_job_id, updated_at)
|
||||
VALUES ($node, $schema, $facts, $observed, $job, $updated)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
schema_version = excluded.schema_version,
|
||||
facts_json = excluded.facts_json,
|
||||
observed_at = excluded.observed_at,
|
||||
source_job_id = excluded.source_job_id,
|
||||
updated_at = excluded.updated_at;
|
||||
""";
|
||||
upsert.Parameters.AddWithValue("$node", facts.NodeId);
|
||||
upsert.Parameters.AddWithValue("$schema", facts.SchemaVersion);
|
||||
upsert.Parameters.AddWithValue(
|
||||
"$facts", JsonSerializer.Serialize(facts.Facts, SmmJsonContext.Default.ProvisioningPreflightResult));
|
||||
upsert.Parameters.AddWithValue("$observed", facts.ObservedAt.ToString("O"));
|
||||
upsert.Parameters.AddWithValue("$job", facts.SourceJobId);
|
||||
upsert.Parameters.AddWithValue("$updated", facts.UpdatedAt.ToString("O"));
|
||||
await upsert.ExecuteNonQueryAsync(cancellationToken);
|
||||
await WriteProvisioningEventAsync(
|
||||
connection, transaction, jobId, "preflight.facts-recorded", job.State,
|
||||
"Validated preflight facts were recorded.", facts.UpdatedAt, cancellationToken);
|
||||
await WriteIdempotentAsync(
|
||||
connection, transaction, operationKey, requestHash, facts,
|
||||
SmmJsonContext.Default.NodePreflightFacts, cancellationToken);
|
||||
await WriteAuditAsync(
|
||||
connection, transaction, nodeId, "provisioning.preflight.facts", jobId,
|
||||
JsonSerializer.Serialize(new { facts.SchemaVersion, facts.ObservedAt }), cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return facts;
|
||||
}
|
||||
|
||||
public async Task<NodePreflightFacts?> GetPreflightFactsAsync(
|
||||
string nodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT schema_version, facts_json, observed_at, source_job_id, updated_at
|
||||
FROM node_preflight_facts WHERE node_id = $node;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$node", nodeId);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var facts = JsonSerializer.Deserialize(
|
||||
reader.GetString(1), SmmJsonContext.Default.ProvisioningPreflightResult)
|
||||
?? throw new InvalidDataException("Stored preflight facts are invalid.");
|
||||
return new NodePreflightFacts(
|
||||
nodeId, reader.GetInt32(0), facts, DateTimeOffset.Parse(reader.GetString(2)),
|
||||
reader.GetString(3), DateTimeOffset.Parse(reader.GetString(4)));
|
||||
}
|
||||
}
|
||||
|
|
@ -179,6 +179,7 @@ public sealed record ProvisioningHelperResponse(
|
|||
string Message,
|
||||
ProvisioningPreflightResult? Preflight);
|
||||
|
||||
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
|
||||
public sealed record ProvisioningPreflightResult(
|
||||
string OperatingSystem,
|
||||
string OperatingSystemVersion,
|
||||
|
|
@ -189,6 +190,20 @@ public sealed record ProvisioningPreflightResult(
|
|||
bool HasWireGuard,
|
||||
bool HasApt);
|
||||
|
||||
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
|
||||
public sealed record ProvisioningPreflightReportRequest(
|
||||
ProvisioningPreflightResult Facts,
|
||||
DateTimeOffset ObservedAt,
|
||||
string IdempotencyKey);
|
||||
|
||||
public sealed record NodePreflightFacts(
|
||||
string NodeId,
|
||||
int SchemaVersion,
|
||||
ProvisioningPreflightResult Facts,
|
||||
DateTimeOffset ObservedAt,
|
||||
string SourceJobId,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record ProvisioningJob(
|
||||
string Id,
|
||||
string NodeId,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ namespace ServerMonitorManager.Core;
|
|||
[JsonSerializable(typeof(ProvisioningHelperRequest))]
|
||||
[JsonSerializable(typeof(ProvisioningHelperResponse))]
|
||||
[JsonSerializable(typeof(ProvisioningPreflightResult))]
|
||||
[JsonSerializable(typeof(ProvisioningPreflightReportRequest))]
|
||||
[JsonSerializable(typeof(NodePreflightFacts))]
|
||||
[JsonSerializable(typeof(ProvisioningJob))]
|
||||
[JsonSerializable(typeof(ProvisioningJob[]))]
|
||||
[JsonSerializable(typeof(ProvisioningEvent))]
|
||||
|
|
|
|||
|
|
@ -168,6 +168,54 @@ public sealed class ControlApiTests : IAsyncDisposable
|
|||
Assert.Equal(HttpStatusCode.NoContent, (await assigned.GetAsync(
|
||||
"/api/v1/agents/provisioning/jobs/next", cancellationToken)).StatusCode);
|
||||
|
||||
var preflightReport = new
|
||||
{
|
||||
facts = new
|
||||
{
|
||||
operatingSystem = "ubuntu",
|
||||
operatingSystemVersion = "24.04",
|
||||
architecture = "x64",
|
||||
hasSystemd = true,
|
||||
hasSshd = true,
|
||||
hasNftables = true,
|
||||
hasWireGuard = false,
|
||||
hasApt = true
|
||||
},
|
||||
observedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = Guid.NewGuid().ToString()
|
||||
};
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await other.PostAsJsonAsync(
|
||||
$"/api/v1/agents/provisioning/jobs/{created.Id}/preflight-facts",
|
||||
preflightReport,
|
||||
cancellationToken)).StatusCode);
|
||||
using var factsResponse = await assigned.PostAsJsonAsync(
|
||||
$"/api/v1/agents/provisioning/jobs/{created.Id}/preflight-facts",
|
||||
preflightReport,
|
||||
cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, factsResponse.StatusCode);
|
||||
var recorded = await factsResponse.Content.ReadFromJsonAsync<ServerMonitorManager.Core.NodePreflightFacts>(
|
||||
cancellationToken);
|
||||
Assert.Equal(nodeId, recorded!.NodeId);
|
||||
Assert.Equal("ubuntu", recorded.Facts.OperatingSystem);
|
||||
using var replayResponse = await assigned.PostAsJsonAsync(
|
||||
$"/api/v1/agents/provisioning/jobs/{created.Id}/preflight-facts",
|
||||
preflightReport,
|
||||
cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, replayResponse.StatusCode);
|
||||
var replayed = await replayResponse.Content
|
||||
.ReadFromJsonAsync<ServerMonitorManager.Core.NodePreflightFacts>(cancellationToken);
|
||||
Assert.Equal(recorded.UpdatedAt, replayed!.UpdatedAt);
|
||||
|
||||
using var operatorClient = _factory.CreateClient();
|
||||
operatorClient.DefaultRequestHeaders.Add("X-Test-Identity", "windows-pc");
|
||||
operatorClient.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
|
||||
using var storedFactsResponse = await operatorClient.GetAsync(
|
||||
$"/api/v1/control/agents/{nodeId}/facts/preflight", cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, storedFactsResponse.StatusCode);
|
||||
var stored = await storedFactsResponse.Content
|
||||
.ReadFromJsonAsync<ServerMonitorManager.Core.NodePreflightFacts>(cancellationToken);
|
||||
Assert.Equal(created.Id, stored!.SourceJobId);
|
||||
|
||||
var progress = new
|
||||
{
|
||||
state = "Running",
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ public sealed class ControlMaintenanceTests : IAsyncDisposable
|
|||
await verify.OpenAsync(cancellationToken);
|
||||
var version = verify.CreateCommand();
|
||||
version.CommandText = "PRAGMA user_version;";
|
||||
Assert.Equal(5L, (long)(await version.ExecuteScalarAsync(cancellationToken))!);
|
||||
Assert.Equal(6L, (long)(await version.ExecuteScalarAsync(cancellationToken))!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
|||
await migrated.OpenAsync(TestContext.Current.CancellationToken);
|
||||
var version = migrated.CreateCommand();
|
||||
version.CommandText = "PRAGMA user_version;";
|
||||
Assert.Equal(5L, Convert.ToInt64(await version.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
||||
Assert.Equal(6L, Convert.ToInt64(await version.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
||||
var table = migrated.CreateCommand();
|
||||
table.CommandText = "SELECT COUNT(*) FROM pragma_table_info('provisioning_jobs');";
|
||||
Assert.Equal(18L, Convert.ToInt64(await table.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
||||
|
|
|
|||
Loading…
Reference in a new issue