Add provisioning job control plane

This commit is contained in:
Ochenstarik 2026-07-19 15:08:29 +07:00
parent 08ccda8aa2
commit 3a8d7da47f
10 changed files with 623 additions and 9 deletions

View file

@ -100,7 +100,7 @@ In the application, generate or copy the monitoring SSH key, add the Hub profile
The current development branch implements dedicated Windows pages for Servers, Links, Sessions, and Settings; SSH monitoring; directional Links; one-time enrollment; separate mTLS Agent, Operator, and source-scoped Automation identities; certificate revocation/re-enrollment; SQLite control state; audit; authenticated event streaming; Windows Control API integration; and a bounded durable Agent buffer with downsampling.
Reconnect reconciliation is implemented with a durable SQLite marker: after a Node returns, the Hub reapplies the latest effective disabled policies and clears the marker only after the firewall confirms success. Control also expires TTL Links through the firewall helper, prunes bounded operational data, versions its SQLite schema, and creates verified backups of SQLite state and the Control CA. CI exercises the Control-to-helper process boundary, HTTP authorization, Agent parsing, Desktop contracts, and a 100-Node concurrent heartbeat/replay scenario. Still required are the project-owned bootstrap, physical WireGuard/nftables/reboot acceptance, trusted public code signing, Provisioning/Xray, and clients for additional platforms.
Reconnect reconciliation is implemented with a durable SQLite marker: after a Node returns, the Hub reapplies the latest effective disabled policies and clears the marker only after the firewall confirms success. Control also expires TTL Links through the firewall helper, prunes bounded operational data, versions its SQLite schema, and creates verified backups of SQLite state and the Control CA. The first Provisioning control-plane slice now persists versioned jobs, enforces TTL/audit/idempotency and one active job per Node, and exposes Operator-only create/read/confirm/cancel endpoints. CI exercises the Control-to-helper process boundary, HTTP authorization, Agent parsing, Desktop contracts, and a 100-Node concurrent heartbeat/replay scenario. Still required are Provisioning execution/retry/rollback and the restricted helper channel, physical WireGuard/nftables/reboot acceptance, trusted public code signing, Xray, and clients for additional platforms.
## License and project policy

View file

@ -108,16 +108,19 @@
## Этап 8 — Provisioning control plane
- [ ] модели и SQLite migrations для ProvisioningJob;
- [x] модели и SQLite migration v2 для ProvisioningJob;
- [ ] state machine, confirmations, cancellation, retry и rollback;
- [ ] обязательные idempotency key, audit reason и job TTL;
- [x] создание, чтение, подтверждение и отмена через Operator API;
- [ ] выполнение, retry, verification и rollback в полной state machine;
- [x] обязательные idempotency key, audit reason и job TTL;
- [ ] Agent job channel только для собственного `node_id`;
- [ ] versioned JSON schemas для каждого action type;
- [x] начальные строгие JSON schemas v1 для `preflight` и `system.base-install`;
- [ ] versioned JSON schemas для остальных action type;
- [ ] restricted root helper через Unix socket;
- [ ] structured redacted events и progress;
- [ ] `NeedsReconciliation` после неопределённого результата;
- [ ] desired/factual configuration и drift;
- [ ] запрет параллельных несовместимых опасных заданий.
- [x] запрет параллельных активных заданий на одном Node (безопасный первый вариант).
## Этап 9 — базовая настройка и пользователи

View file

@ -8,9 +8,9 @@ using ServerMonitorManager.Core;
namespace ServerMonitorManager.Control;
public sealed class ControlStore(IOptions<ControlOptions> options)
public sealed partial class ControlStore(IOptions<ControlOptions> options)
{
private const int CurrentSchemaVersion = 1;
private const int CurrentSchemaVersion = 2;
private readonly ControlOptions _options = options.Value;
private readonly string _connectionString = new SqliteConnectionStringBuilder
{
@ -124,9 +124,61 @@ public sealed class ControlStore(IOptions<ControlOptions> options)
CREATE UNIQUE INDEX IF NOT EXISTS ux_links_active_policy
ON links(source_node_id, target_node_id, protocol, port)
WHERE desired_state = 'Active';
PRAGMA user_version = 1;
""";
await command.ExecuteNonQueryAsync(cancellationToken);
if (schemaVersion < 1)
{
var markVersionOne = connection.CreateCommand();
markVersionOne.CommandText = "PRAGMA user_version = 1;";
await markVersionOne.ExecuteNonQueryAsync(cancellationToken);
}
if (schemaVersion < 2)
{
await using var migration =
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
var migrateProvisioning = connection.CreateCommand();
migrateProvisioning.Transaction = migration;
migrateProvisioning.CommandText = """
CREATE TABLE IF NOT EXISTS provisioning_jobs (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES agents(node_id) ON DELETE CASCADE,
action_type TEXT NOT NULL,
schema_version INTEGER NOT NULL,
parameters_json TEXT NOT NULL,
state TEXT NOT NULL,
confirmation_required INTEGER NOT NULL,
audit_reason TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
confirmed_at TEXT NULL,
cancelled_at TEXT NULL,
version INTEGER NOT NULL,
last_error TEXT NULL
);
CREATE INDEX IF NOT EXISTS ix_provisioning_jobs_node_created
ON provisioning_jobs(node_id, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS ux_provisioning_jobs_active_node
ON provisioning_jobs(node_id)
WHERE state NOT IN ('Completed', 'Cancelled', 'Failed', 'RolledBack', 'RollbackFailed');
CREATE TABLE IF NOT EXISTS provisioning_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL REFERENCES provisioning_jobs(id) ON DELETE CASCADE,
recorded_at TEXT NOT NULL,
event_type TEXT NOT NULL,
state TEXT NOT NULL,
message TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_provisioning_events_job_sequence
ON provisioning_events(job_id, sequence);
PRAGMA user_version = 2;
""";
await migrateProvisioning.ExecuteNonQueryAsync(cancellationToken);
await migration.CommitAsync(cancellationToken);
}
}
public async Task<ControlMaintenanceResult> MaintainAsync(

View file

@ -381,6 +381,76 @@ agents.MapPost("/heartbeat", async (
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.MapPost("/agents/{nodeId}/provisioning/jobs", async (
string nodeId,
ProvisioningJobCreateRequest request,
HttpContext context,
ControlStore controlStore,
CancellationToken cancellationToken) =>
{
if (!NodeIdValidator.IsValid(nodeId) || !ProvisioningJobValidator.IsValid(request))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["provisioningJob"] =
["Invalid node id, action schema, parameters, TTL, audit reason, or idempotency key."]
});
}
try
{
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var job = await controlStore.CreateProvisioningJobAsync(
nodeId, request, actor, cancellationToken);
return Results.Created($"/api/v1/control/provisioning/jobs/{job.Id}", job);
}
catch (IdempotencyConflictException)
{
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
}
catch (ProvisioningNodeNotFoundException)
{
return Results.NotFound();
}
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
{
return Results.Conflict(new ProblemDetails
{
Title = "The node already has an incompatible active provisioning job."
});
}
});
control.MapGet("/provisioning/jobs/{id}", async (
string id,
ControlStore controlStore,
CancellationToken cancellationToken) =>
{
if (!ProvisioningJobValidator.IsValidId(id))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["provisioningJob"] = ["Invalid provisioning job id."]
});
}
var job = await controlStore.GetProvisioningJobAsync(id, cancellationToken);
return job is null ? Results.NotFound() : Results.Ok(job);
});
control.MapPost("/provisioning/jobs/{id}/confirm", async (
string id,
ProvisioningJobCommandRequest request,
HttpContext context,
ControlStore controlStore,
CancellationToken cancellationToken) =>
await ChangeProvisioningJobAsync(
id, request, context, controlStore, confirm: true, cancellationToken));
control.MapPost("/provisioning/jobs/{id}/cancel", async (
string id,
ProvisioningJobCommandRequest request,
HttpContext context,
ControlStore controlStore,
CancellationToken cancellationToken) =>
await ChangeProvisioningJobAsync(
id, request, context, controlStore, confirm: false, cancellationToken));
control.MapPost("/automations/token", async (
AutomationTokenCreateRequest request,
HttpContext context,
@ -577,6 +647,41 @@ automation.MapGet("/links", async (
await app.RunAsync();
return 0;
static async Task<IResult> ChangeProvisioningJobAsync(
string id,
ProvisioningJobCommandRequest request,
HttpContext context,
ControlStore controlStore,
bool confirm,
CancellationToken cancellationToken)
{
if (!ProvisioningJobValidator.IsValidId(id)
|| !ProvisioningJobValidator.IsValid(request))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["provisioningJob"] = ["Invalid job id, reason, or idempotency key."]
});
}
try
{
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var job = confirm
? await controlStore.ConfirmProvisioningJobAsync(id, request, actor, cancellationToken)
: await controlStore.CancelProvisioningJobAsync(id, request, actor, cancellationToken);
return job is null ? Results.NotFound() : Results.Ok(job);
}
catch (IdempotencyConflictException)
{
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
}
catch (ProvisioningTransitionException exception)
{
return Results.Conflict(new ProblemDetails { Title = exception.Message });
}
}
public partial class Program;
internal static class NodeIdValidator
@ -611,3 +716,28 @@ internal static class CertificateReenrollmentValidator
=> request.Reason.Length is >= 1 and <= 200
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
}
internal static class ProvisioningJobValidator
{
private const int MaximumParametersBytes = 16 * 1024;
public static bool IsValid(ProvisioningJobCreateRequest request)
=> request.SchemaVersion == 1
&& request.ActionType is "preflight" or "system.base-install"
&& request.Parameters.ValueKind == JsonValueKind.Object
&& !request.Parameters.EnumerateObject().Any()
&& request.Parameters.GetRawText().Length <= MaximumParametersBytes
&& request.TtlMinutes is >= 5 and <= 1440
&& request.AuditReason.Length is >= 1 and <= 256
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
public static bool IsValid(ProvisioningJobCommandRequest request)
=> request.Reason.Length is >= 1 and <= 256
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
public static bool IsValidId(string id)
=> id.Length == 32 && Guid.TryParseExact(id, "N", out _);
public static bool RequiresConfirmation(string actionType)
=> actionType == "system.base-install";
}

View file

@ -0,0 +1,271 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
using ServerMonitorManager.Core;
namespace ServerMonitorManager.Control;
public sealed partial class ControlStore
{
public async Task<ProvisioningJob> CreateProvisioningJobAsync(
string nodeId,
ProvisioningJobCreateRequest request,
string actor,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
var operationKey = $"provisioning-create:{actor}:{nodeId}:{request.IdempotencyKey}";
var requestHash = Fingerprint(request, SmmJsonContext.Default.ProvisioningJobCreateRequest);
var cached = await ReadIdempotentAsync(
connection, transaction, operationKey, requestHash,
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
if (cached is not null)
{
await transaction.CommitAsync(cancellationToken);
return cached;
}
var exists = connection.CreateCommand();
exists.Transaction = transaction;
exists.CommandText = "SELECT EXISTS(SELECT 1 FROM agents WHERE node_id = $node);";
exists.Parameters.AddWithValue("$node", nodeId);
if (Convert.ToInt32(await exists.ExecuteScalarAsync(cancellationToken)) != 1)
{
throw new ProvisioningNodeNotFoundException(nodeId);
}
var now = DateTimeOffset.UtcNow;
var confirmationRequired = ProvisioningJobValidator.RequiresConfirmation(request.ActionType);
var job = new ProvisioningJob(
Guid.NewGuid().ToString("N"),
nodeId,
request.ActionType,
request.SchemaVersion,
request.Parameters.Clone(),
confirmationRequired
? ProvisioningJobStates.AwaitingConfirmation
: ProvisioningJobStates.Queued,
confirmationRequired,
request.AuditReason,
actor,
now,
now,
now.AddMinutes(request.TtlMinutes),
null,
null,
1,
null);
var insert = connection.CreateCommand();
insert.Transaction = transaction;
insert.CommandText = """
INSERT INTO provisioning_jobs(
id, node_id, action_type, schema_version, parameters_json, state,
confirmation_required, audit_reason, created_by, created_at, updated_at,
expires_at, confirmed_at, cancelled_at, version, last_error)
VALUES(
$id, $node, $action, $schema, $parameters, $state,
$confirmation, $reason, $actor, $created, $updated,
$expires, NULL, NULL, $version, NULL);
""";
AddProvisioningJobParameters(insert, job);
await insert.ExecuteNonQueryAsync(cancellationToken);
await WriteProvisioningEventAsync(
connection, transaction, job.Id, "job.created", job.State,
"Provisioning job accepted by the control plane.", now, cancellationToken);
await WriteIdempotentAsync(
connection, transaction, operationKey, requestHash, job,
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
await WriteAuditAsync(
connection, transaction, actor, "provisioning.job.created", job.Id,
JsonSerializer.Serialize(new { job.NodeId, job.ActionType, job.SchemaVersion, job.AuditReason }),
cancellationToken);
await transaction.CommitAsync(cancellationToken);
return job;
}
public async Task<ProvisioningJob?> GetProvisioningJobAsync(
string id,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM provisioning_jobs WHERE id = $id;";
command.Parameters.AddWithValue("$id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken) ? ReadProvisioningJob(reader) : null;
}
public Task<ProvisioningJob?> ConfirmProvisioningJobAsync(
string id,
ProvisioningJobCommandRequest request,
string actor,
CancellationToken cancellationToken = default)
=> TransitionProvisioningJobAsync(
id, request, actor, ProvisioningJobStates.AwaitingConfirmation,
ProvisioningJobStates.Queued, "job.confirmed", "provisioning.job.confirmed",
setConfirmedAt: true, cancellationToken);
public Task<ProvisioningJob?> CancelProvisioningJobAsync(
string id,
ProvisioningJobCommandRequest request,
string actor,
CancellationToken cancellationToken = default)
=> TransitionProvisioningJobAsync(
id, request, actor, null, ProvisioningJobStates.Cancelled,
"job.cancelled", "provisioning.job.cancelled",
setConfirmedAt: false, cancellationToken);
private async Task<ProvisioningJob?> TransitionProvisioningJobAsync(
string id,
ProvisioningJobCommandRequest request,
string actor,
string? requiredState,
string targetState,
string eventType,
string auditAction,
bool setConfirmedAt,
CancellationToken cancellationToken)
{
await using var connection = await OpenAsync(cancellationToken);
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
var operationKey = $"{eventType}:{actor}:{id}:{request.IdempotencyKey}";
var requestHash = Fingerprint(request, SmmJsonContext.Default.ProvisioningJobCommandRequest);
var cached = await ReadIdempotentAsync(
connection, transaction, operationKey, requestHash,
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
if (cached is not null)
{
await transaction.CommitAsync(cancellationToken);
return cached;
}
var current = await ReadProvisioningJobAsync(connection, transaction, id, cancellationToken);
if (current is null)
{
await transaction.RollbackAsync(cancellationToken);
return null;
}
var allowed = requiredState is not null
? current.State == requiredState
: current.State is ProvisioningJobStates.Queued or ProvisioningJobStates.AwaitingConfirmation;
if (!allowed || current.ExpiresAt <= DateTimeOffset.UtcNow)
{
throw new ProvisioningTransitionException(current.State, targetState);
}
var now = DateTimeOffset.UtcNow;
var updated = current with
{
State = targetState,
UpdatedAt = now,
ConfirmedAt = setConfirmedAt ? now : current.ConfirmedAt,
CancelledAt = targetState == ProvisioningJobStates.Cancelled ? now : current.CancelledAt,
Version = current.Version + 1
};
var update = connection.CreateCommand();
update.Transaction = transaction;
update.CommandText = """
UPDATE provisioning_jobs SET
state = $state, updated_at = $updated, confirmed_at = $confirmed,
cancelled_at = $cancelled, version = $version
WHERE id = $id AND version = $previous_version;
""";
AddProvisioningJobParameters(update, updated);
update.Parameters.AddWithValue("$previous_version", current.Version);
if (await update.ExecuteNonQueryAsync(cancellationToken) != 1)
{
throw new ProvisioningTransitionException(current.State, targetState);
}
await WriteProvisioningEventAsync(
connection, transaction, id, eventType, targetState, request.Reason, now, cancellationToken);
await WriteIdempotentAsync(
connection, transaction, operationKey, requestHash, updated,
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
await WriteAuditAsync(
connection, transaction, actor, auditAction, id,
JsonSerializer.Serialize(new { request.Reason, State = targetState }), cancellationToken);
await transaction.CommitAsync(cancellationToken);
return updated;
}
private static async Task<ProvisioningJob?> ReadProvisioningJobAsync(
SqliteConnection connection,
SqliteTransaction transaction,
string id,
CancellationToken cancellationToken)
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "SELECT * FROM provisioning_jobs WHERE id = $id;";
command.Parameters.AddWithValue("$id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken) ? ReadProvisioningJob(reader) : null;
}
private static ProvisioningJob ReadProvisioningJob(SqliteDataReader reader)
=> new(
reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetInt32(3),
ParseParameters(reader.GetString(4)), reader.GetString(5),
reader.GetInt32(6) == 1, reader.GetString(7), reader.GetString(8),
DateTimeOffset.Parse(reader.GetString(9)), DateTimeOffset.Parse(reader.GetString(10)),
DateTimeOffset.Parse(reader.GetString(11)),
reader.IsDBNull(12) ? null : DateTimeOffset.Parse(reader.GetString(12)),
reader.IsDBNull(13) ? null : DateTimeOffset.Parse(reader.GetString(13)),
reader.GetInt64(14), reader.IsDBNull(15) ? null : reader.GetString(15));
private static JsonElement ParseParameters(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
private static void AddProvisioningJobParameters(SqliteCommand command, ProvisioningJob job)
{
command.Parameters.AddWithValue("$id", job.Id);
command.Parameters.AddWithValue("$node", job.NodeId);
command.Parameters.AddWithValue("$action", job.ActionType);
command.Parameters.AddWithValue("$schema", job.SchemaVersion);
command.Parameters.AddWithValue("$parameters", job.Parameters.GetRawText());
command.Parameters.AddWithValue("$state", job.State);
command.Parameters.AddWithValue("$confirmation", job.ConfirmationRequired ? 1 : 0);
command.Parameters.AddWithValue("$reason", job.AuditReason);
command.Parameters.AddWithValue("$actor", job.CreatedBy);
command.Parameters.AddWithValue("$created", job.CreatedAt.ToString("O"));
command.Parameters.AddWithValue("$updated", job.UpdatedAt.ToString("O"));
command.Parameters.AddWithValue("$expires", job.ExpiresAt.ToString("O"));
command.Parameters.AddWithValue("$confirmed", job.ConfirmedAt is null ? DBNull.Value : job.ConfirmedAt.Value.ToString("O"));
command.Parameters.AddWithValue("$cancelled", job.CancelledAt is null ? DBNull.Value : job.CancelledAt.Value.ToString("O"));
command.Parameters.AddWithValue("$version", job.Version);
}
private static async Task WriteProvisioningEventAsync(
SqliteConnection connection,
SqliteTransaction transaction,
string jobId,
string eventType,
string state,
string message,
DateTimeOffset recordedAt,
CancellationToken cancellationToken)
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO provisioning_events(job_id, recorded_at, event_type, state, message)
VALUES ($job, $recorded, $event, $state, $message);
""";
command.Parameters.AddWithValue("$job", jobId);
command.Parameters.AddWithValue("$recorded", recordedAt.ToString("O"));
command.Parameters.AddWithValue("$event", eventType);
command.Parameters.AddWithValue("$state", state);
command.Parameters.AddWithValue("$message", message);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
public sealed class ProvisioningNodeNotFoundException(string nodeId) : Exception($"Node '{nodeId}' was not found.");
public sealed class ProvisioningTransitionException(string state, string targetState)
: Exception($"Provisioning job cannot transition from '{state}' to '{targetState}'.");

View file

@ -1,3 +1,5 @@
using System.Text.Json;
namespace ServerMonitorManager.Core;
public sealed record EnrollmentRequest(
@ -140,3 +142,40 @@ public sealed record ControlEvent(
string PayloadJson);
public sealed record ControlError(string Error);
public sealed record ProvisioningJobCreateRequest(
string ActionType,
int SchemaVersion,
JsonElement Parameters,
int TtlMinutes,
string AuditReason,
string IdempotencyKey);
public sealed record ProvisioningJobCommandRequest(
string Reason,
string IdempotencyKey);
public sealed record ProvisioningJob(
string Id,
string NodeId,
string ActionType,
int SchemaVersion,
JsonElement Parameters,
string State,
bool ConfirmationRequired,
string AuditReason,
string CreatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset ExpiresAt,
DateTimeOffset? ConfirmedAt,
DateTimeOffset? CancelledAt,
long Version,
string? LastError);
public static class ProvisioningJobStates
{
public const string Queued = "Queued";
public const string AwaitingConfirmation = "AwaitingConfirmation";
public const string Cancelled = "Cancelled";
}

View file

@ -25,4 +25,8 @@ namespace ServerMonitorManager.Core;
[JsonSerializable(typeof(LinkPolicy[]))]
[JsonSerializable(typeof(ControlEvent))]
[JsonSerializable(typeof(ControlError))]
[JsonSerializable(typeof(ProvisioningJobCreateRequest))]
[JsonSerializable(typeof(ProvisioningJobCommandRequest))]
[JsonSerializable(typeof(ProvisioningJob))]
[JsonSerializable(typeof(ProvisioningJob[]))]
public sealed partial class SmmJsonContext : JsonSerializerContext;

View file

@ -76,6 +76,48 @@ public sealed class ControlApiTests : IAsyncDisposable
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task OperatorCanCreateAndReadProvisioningJob()
{
var store = _factory.Services.GetRequiredService<ServerMonitorManager.Control.ControlStore>();
var cancellationToken = TestContext.Current.CancellationToken;
var token = await store.CreateEnrollmentTokenAsync("home", TimeSpan.FromMinutes(10), cancellationToken);
await store.EnrollAsync(
new ServerMonitorManager.Core.EnrollmentRequest(
"home", token, "csr", Guid.NewGuid().ToString()),
() => new ServerMonitorManager.Control.IssuedCertificate(
"certificate", "ca", "F1E2", DateTimeOffset.UtcNow.AddDays(1)),
cancellationToken);
using var anonymous = _factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync(
$"/api/v1/control/provisioning/jobs/{Guid.NewGuid():N}", cancellationToken)).StatusCode);
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-Identity", "windows-pc");
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
using var response = await client.PostAsJsonAsync(
"/api/v1/control/agents/home/provisioning/jobs",
new
{
actionType = "system.base-install",
schemaVersion = 1,
parameters = new { },
ttlMinutes = 60,
auditReason = "API integration test",
idempotencyKey = Guid.NewGuid().ToString()
},
cancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var job = await response.Content.ReadFromJsonAsync<ServerMonitorManager.Core.ProvisioningJob>(
cancellationToken);
Assert.NotNull(job);
Assert.Equal(ServerMonitorManager.Core.ProvisioningJobStates.AwaitingConfirmation, job.State);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync(
$"/api/v1/control/provisioning/jobs/{job.Id}", cancellationToken)).StatusCode);
}
public async ValueTask DisposeAsync() => await _factory.DisposeAsync();
private sealed class ControlApiFactory : WebApplicationFactory<controlapp::Program>

View file

@ -105,7 +105,7 @@ public sealed class ControlMaintenanceTests : IAsyncDisposable
await verify.OpenAsync(cancellationToken);
var version = verify.CreateCommand();
version.CommandText = "PRAGMA user_version;";
Assert.Equal(1L, (long)(await version.ExecuteScalarAsync(cancellationToken))!);
Assert.Equal(2L, (long)(await version.ExecuteScalarAsync(cancellationToken))!);
}
[Fact]

View file

@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using Microsoft.Data.Sqlite;
using System.Text.Json;
using ServerMonitorManager.Control;
using ServerMonitorManager.Core;
using Xunit;
@ -10,6 +11,78 @@ public sealed class ControlStoreTests : IAsyncDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"smm-tests-{Guid.NewGuid():N}");
[Fact]
public async Task VersionOneDatabaseMigratesToProvisioningSchema()
{
Directory.CreateDirectory(_directory);
var databasePath = Path.Combine(_directory, "control.db");
await using (var connection = new SqliteConnection($"Data Source={databasePath}"))
{
await connection.OpenAsync(TestContext.Current.CancellationToken);
var command = connection.CreateCommand();
command.CommandText = "PRAGMA user_version = 1;";
await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}
var store = CreateStore();
await store.InitializeAsync(TestContext.Current.CancellationToken);
await using var migrated = new SqliteConnection($"Data Source={databasePath}");
await migrated.OpenAsync(TestContext.Current.CancellationToken);
var version = migrated.CreateCommand();
version.CommandText = "PRAGMA user_version;";
Assert.Equal(2L, Convert.ToInt64(await version.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
var table = migrated.CreateCommand();
table.CommandText = "SELECT COUNT(*) FROM pragma_table_info('provisioning_jobs');";
Assert.Equal(16L, Convert.ToInt64(await table.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
}
[Fact]
public async Task ProvisioningJobRequiresConfirmationIsIdempotentAndLocksNode()
{
var cancellationToken = TestContext.Current.CancellationToken;
var store = CreateStore();
await store.InitializeAsync(cancellationToken);
await EnrollAgentAsync(store, "home", "A1B2", cancellationToken);
using var parameters = JsonDocument.Parse("{}");
var request = new ProvisioningJobCreateRequest(
"system.base-install", 1, parameters.RootElement.Clone(), 60,
"Prepare test server", Guid.NewGuid().ToString());
var created = await store.CreateProvisioningJobAsync("home", request, "operator", cancellationToken);
var replay = await store.CreateProvisioningJobAsync("home", request, "operator", cancellationToken);
Assert.Equal(created.Id, replay.Id);
Assert.Equal(created.State, replay.State);
Assert.Equal(ProvisioningJobStates.AwaitingConfirmation, created.State);
Assert.True(created.ConfirmationRequired);
await Assert.ThrowsAsync<SqliteException>(() => store.CreateProvisioningJobAsync(
"home", request with { IdempotencyKey = Guid.NewGuid().ToString() }, "operator", cancellationToken));
var confirmation = new ProvisioningJobCommandRequest(
"Approved for test", Guid.NewGuid().ToString());
var confirmed = await store.ConfirmProvisioningJobAsync(
created.Id, confirmation, "operator", cancellationToken);
var confirmationReplay = await store.ConfirmProvisioningJobAsync(
created.Id, confirmation, "operator", cancellationToken);
Assert.Equal(confirmed!.Id, confirmationReplay!.Id);
Assert.Equal(confirmed.State, confirmationReplay.State);
Assert.Equal(ProvisioningJobStates.Queued, confirmed.State);
Assert.NotNull(confirmed.ConfirmedAt);
var cancelled = await store.CancelProvisioningJobAsync(
created.Id,
new ProvisioningJobCommandRequest("Test completed", Guid.NewGuid().ToString()),
"operator",
cancellationToken);
Assert.Equal(ProvisioningJobStates.Cancelled, cancelled!.State);
Assert.NotNull(cancelled.CancelledAt);
var next = await store.CreateProvisioningJobAsync(
"home", request with { IdempotencyKey = Guid.NewGuid().ToString() }, "operator", cancellationToken);
Assert.Equal(ProvisioningJobStates.AwaitingConfirmation, next.State);
}
[Fact]
public void DiagnosticsExportOmitsRawIdentitiesAndNormalizesStates()
{