Add pre-confirmation base install plans
This commit is contained in:
parent
7be4f153fb
commit
2111a09503
13 changed files with 363 additions and 16 deletions
|
|
@ -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. The Provisioning control plane persists versioned jobs, enforces TTL/audit/idempotency and one active job per Node, and supports confirmation, progress, reconciliation, retry, and rollback states. A restricted root helper accepts only versioned, module-hashed allowlisted requests through a local Unix socket. It executes read-only Linux `preflight` and can build a deterministic, non-mutating `system.base-install` plan from catalog-backed parameters. Typed desired and factual states are persisted, versioned, idempotent, and compared through fixed drift codes exposed to Operators. CI exercises process boundaries, authorization, Agent parsing, Desktop contracts, and concurrent heartbeat/replay. Still required are mutating Provisioning actions with factual-state verification, physical WireGuard/nftables/reboot acceptance, trusted public code signing, 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 Provisioning control plane persists versioned jobs, enforces TTL/audit/idempotency and one active job per Node, and supports confirmation, progress, reconciliation, retry, and rollback states. A restricted root helper accepts only versioned, module-hashed allowlisted requests through a local Unix socket. It executes read-only Linux `preflight` and builds a deterministic, non-mutating `system.base-install` plan from catalog-backed parameters. Control independently validates and stores that immutable plan before the job enters `AwaitingConfirmation`; confirmed base-install jobs remain safely queued until the mutating executor and factual verification are implemented. Typed desired and factual states are persisted, versioned, idempotent, and compared through fixed drift codes exposed to Operators. CI exercises process boundaries, authorization, Agent parsing, Desktop contracts, and concurrent heartbeat/replay. Still required are mutating Provisioning actions with factual-state verification, physical WireGuard/nftables/reboot acceptance, trusted public code signing, Xray, and clients for additional platforms.
|
||||
|
||||
## License and project policy
|
||||
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ GET /api/v1/nodes/{nodeId}/configuration
|
|||
POST /api/v1/nodes/{nodeId}/provisioning/preflight
|
||||
POST /api/v1/nodes/{nodeId}/provisioning/jobs
|
||||
GET /api/v1/provisioning/jobs/{jobId}
|
||||
GET /api/v1/provisioning/jobs/{jobId}/plan
|
||||
POST /api/v1/provisioning/jobs/{jobId}/confirm
|
||||
POST /api/v1/provisioning/jobs/{jobId}/cancel
|
||||
POST /api/v1/provisioning/jobs/{jobId}/rollback
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@
|
|||
- [x] начальные строгие JSON schemas v1 для `preflight` и `system.base-install`;
|
||||
- [ ] versioned JSON schemas для остальных action type;
|
||||
- [x] restricted root helper через Unix socket (`preflight` и non-mutating plan для `system.base-install`);
|
||||
- [x] двухфазный `system.base-install`: сохранённый проверенный plan до Operator confirmation;
|
||||
- [x] structured redacted events, bounded Operator history и progress;
|
||||
- [x] `NeedsReconciliation` после истечения execution TTL и неопределённого результата;
|
||||
- [ ] desired/factual configuration и drift (`preflight` завершён; остальные action type ещё не подключены);
|
||||
|
|
|
|||
|
|
@ -136,7 +136,36 @@ internal sealed class AgentClient(AgentOptions options)
|
|||
cancellationToken)
|
||||
?? throw new InvalidOperationException("Control service returned an empty provisioning job.");
|
||||
|
||||
if (job.ActionType != "preflight" || job.SchemaVersion != 1)
|
||||
if (job.SchemaVersion != 1)
|
||||
{
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Failed, job.ProgressPercent,
|
||||
"dispatch", "action.unsupported", "Unsupported provisioning action.", cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.ActionType == "system.base-install"
|
||||
&& job.State == ProvisioningJobStates.Preflight)
|
||||
{
|
||||
try
|
||||
{
|
||||
var helper = new ProvisioningHelperClient(options.ProvisioningSocketPath);
|
||||
var plan = await helper.CreateBaseInstallPlanAsync(job, cancellationToken);
|
||||
await ReportBaseInstallPlanAsync(client, job, plan, cancellationToken);
|
||||
Console.WriteLine($"Base installation plan {job.Id} is awaiting confirmation.");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Failed, job.ProgressPercent,
|
||||
"preflight", "base-install.plan-failed",
|
||||
"Base installation plan generation failed.", cancellationToken);
|
||||
Console.Error.WriteLine($"Base installation plan {job.Id} failed: {exception.Message}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.ActionType != "preflight")
|
||||
{
|
||||
await ReportProvisioningAsync(
|
||||
client, job, ProvisioningJobStates.Failed, job.ProgressPercent,
|
||||
|
|
@ -210,6 +239,22 @@ internal sealed class AgentClient(AgentOptions options)
|
|||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task ReportBaseInstallPlanAsync(
|
||||
HttpClient client,
|
||||
ProvisioningJob job,
|
||||
SystemBaseInstallPlan plan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new SystemBaseInstallPlanReportRequest(
|
||||
plan, CreateOperationId(job.Id, "base-install-plan"));
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
$"api/v1/agents/provisioning/jobs/{job.Id}/base-install-plan",
|
||||
request,
|
||||
SmmJsonContext.Default.SystemBaseInstallPlanReportRequest,
|
||||
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 = 7;
|
||||
private const int CurrentSchemaVersion = 8;
|
||||
private readonly ControlOptions _options = options.Value;
|
||||
private readonly string _connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
|
|
@ -273,6 +273,25 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
|||
await addPreflightDesiredState.ExecuteNonQueryAsync(cancellationToken);
|
||||
await migration.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (schemaVersion < 8)
|
||||
{
|
||||
await using var migration =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var addBaseInstallPlans = connection.CreateCommand();
|
||||
addBaseInstallPlans.Transaction = migration;
|
||||
addBaseInstallPlans.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS provisioning_base_install_plans (
|
||||
job_id TEXT PRIMARY KEY REFERENCES provisioning_jobs(id) ON DELETE CASCADE,
|
||||
schema_version INTEGER NOT NULL,
|
||||
plan_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 8;
|
||||
""";
|
||||
await addBaseInstallPlans.ExecuteNonQueryAsync(cancellationToken);
|
||||
await migration.CommitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ControlMaintenanceResult> MaintainAsync(
|
||||
|
|
|
|||
|
|
@ -460,6 +460,44 @@ agents.MapPost("/provisioning/jobs/{id}/preflight-facts", async (
|
|||
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
||||
}
|
||||
});
|
||||
agents.MapPost("/provisioning/jobs/{id}/base-install-plan", async (
|
||||
string id,
|
||||
SystemBaseInstallPlanReportRequest 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[]>
|
||||
{
|
||||
["baseInstallPlan"] = ["Invalid job id, plan, or idempotency key."]
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var plan = await controlStore.RecordBaseInstallPlanAsync(
|
||||
nodeId, id, request, cancellationToken);
|
||||
return plan is null ? Results.NotFound() : Results.Ok(plan);
|
||||
}
|
||||
catch (IdempotencyConflictException)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
||||
}
|
||||
catch (ProvisioningTransitionException exception)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
||||
}
|
||||
catch (ProvisioningPlanValidationException exception)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails { Title = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");
|
||||
control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) =>
|
||||
|
|
@ -581,6 +619,21 @@ control.MapGet("/provisioning/jobs/{id}", async (
|
|||
var job = await controlStore.GetProvisioningJobAsync(id, cancellationToken);
|
||||
return job is null ? Results.NotFound() : Results.Ok(job);
|
||||
});
|
||||
control.MapGet("/provisioning/jobs/{id}/plan", async (
|
||||
string id,
|
||||
ControlStore controlStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!ProvisioningJobValidator.IsValidId(id))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["baseInstallPlan"] = ["Invalid provisioning job id."]
|
||||
});
|
||||
}
|
||||
var plan = await controlStore.GetBaseInstallPlanAsync(id, cancellationToken);
|
||||
return plan is null ? Results.NotFound() : Results.Ok(plan);
|
||||
});
|
||||
control.MapGet("/provisioning/jobs/{id}/events", async (
|
||||
string id,
|
||||
int? limit,
|
||||
|
|
@ -997,6 +1050,12 @@ internal static class ProvisioningJobValidator
|
|||
&& request.Facts.Architecture is "x64" or "x86" or "arm" or "arm64"
|
||||
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
||||
|
||||
public static bool IsValid(SystemBaseInstallPlanReportRequest request)
|
||||
=> request.Plan is not null
|
||||
&& request.Plan.Packages is { Length: <= 64 }
|
||||
&& request.Plan.Warnings is { Length: <= 2 }
|
||||
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
||||
|
||||
public static bool IsValidId(string id)
|
||||
=> id.Length == 32 && Guid.TryParseExact(id, "N", out _);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ServerMonitorManager.Core;
|
||||
|
||||
namespace ServerMonitorManager.Control;
|
||||
|
||||
public sealed partial class ControlStore
|
||||
{
|
||||
public async Task<ProvisioningBaseInstallPlanRecord?> RecordBaseInstallPlanAsync(
|
||||
string nodeId,
|
||||
string jobId,
|
||||
SystemBaseInstallPlanReportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var operationKey = $"provisioning-base-install-plan:{nodeId}:{jobId}:{request.IdempotencyKey}";
|
||||
var requestHash = Fingerprint(
|
||||
request, SmmJsonContext.Default.SystemBaseInstallPlanReportRequest);
|
||||
var cached = await ReadIdempotentAsync(
|
||||
connection, transaction, operationKey, requestHash,
|
||||
SmmJsonContext.Default.ProvisioningBaseInstallPlanRecord, 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 != "system.base-install" || job.SchemaVersion != 1
|
||||
|| job.State != ProvisioningJobStates.Preflight || !job.ConfirmationRequired
|
||||
|| job.ConfirmedAt is not null)
|
||||
{
|
||||
throw new ProvisioningTransitionException(job.State, ProvisioningJobStates.AwaitingConfirmation);
|
||||
}
|
||||
if (!SystemBaseInstallSchema.TryParse(job.Parameters, out var parameters)
|
||||
|| parameters is null
|
||||
|| !SystemBaseInstallSchema.IsValidPlan(parameters, request.Plan))
|
||||
{
|
||||
throw new ProvisioningPlanValidationException();
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = new ProvisioningBaseInstallPlanRecord(
|
||||
jobId, nodeId, job.SchemaVersion, request.Plan, now);
|
||||
var insert = connection.CreateCommand();
|
||||
insert.Transaction = transaction;
|
||||
insert.CommandText = """
|
||||
INSERT INTO provisioning_base_install_plans(
|
||||
job_id, schema_version, plan_json, created_at)
|
||||
VALUES ($job, $schema, $plan, $created);
|
||||
""";
|
||||
insert.Parameters.AddWithValue("$job", record.JobId);
|
||||
insert.Parameters.AddWithValue("$schema", record.SchemaVersion);
|
||||
insert.Parameters.AddWithValue(
|
||||
"$plan", JsonSerializer.Serialize(record.Plan, SmmJsonContext.Default.SystemBaseInstallPlan));
|
||||
insert.Parameters.AddWithValue("$created", record.CreatedAt.ToString("O"));
|
||||
await insert.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
var update = connection.CreateCommand();
|
||||
update.Transaction = transaction;
|
||||
update.CommandText = """
|
||||
UPDATE provisioning_jobs SET
|
||||
state = $state,
|
||||
progress_percent = 25,
|
||||
current_step = 'awaiting-confirmation',
|
||||
updated_at = $updated,
|
||||
version = version + 1
|
||||
WHERE id = $id AND node_id = $node AND version = $version
|
||||
AND state = $preflight AND confirmed_at IS NULL;
|
||||
""";
|
||||
update.Parameters.AddWithValue("$state", ProvisioningJobStates.AwaitingConfirmation);
|
||||
update.Parameters.AddWithValue("$updated", now.ToString("O"));
|
||||
update.Parameters.AddWithValue("$id", job.Id);
|
||||
update.Parameters.AddWithValue("$node", nodeId);
|
||||
update.Parameters.AddWithValue("$version", job.Version);
|
||||
update.Parameters.AddWithValue("$preflight", ProvisioningJobStates.Preflight);
|
||||
if (await update.ExecuteNonQueryAsync(cancellationToken) != 1)
|
||||
{
|
||||
throw new ProvisioningTransitionException(job.State, ProvisioningJobStates.AwaitingConfirmation);
|
||||
}
|
||||
|
||||
await WriteProvisioningEventAsync(
|
||||
connection, transaction, jobId, "base-install.plan-recorded",
|
||||
ProvisioningJobStates.AwaitingConfirmation,
|
||||
"Validated base installation plan is awaiting operator confirmation.", now,
|
||||
cancellationToken);
|
||||
await WriteIdempotentAsync(
|
||||
connection, transaction, operationKey, requestHash, record,
|
||||
SmmJsonContext.Default.ProvisioningBaseInstallPlanRecord, cancellationToken);
|
||||
await WriteAuditAsync(
|
||||
connection, transaction, nodeId, "provisioning.base-install.plan", jobId,
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
record.SchemaVersion,
|
||||
PackageCount = record.Plan.Packages.Length,
|
||||
WarningCodes = record.Plan.Warnings
|
||||
}),
|
||||
cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task<ProvisioningBaseInstallPlanRecord?> GetBaseInstallPlanAsync(
|
||||
string jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT p.schema_version, p.plan_json, p.created_at, j.node_id
|
||||
FROM provisioning_base_install_plans p
|
||||
INNER JOIN provisioning_jobs j ON j.id = p.job_id
|
||||
WHERE p.job_id = $job;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$job", jobId);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var plan = JsonSerializer.Deserialize(
|
||||
reader.GetString(1), SmmJsonContext.Default.SystemBaseInstallPlan)
|
||||
?? throw new InvalidDataException("Stored base installation plan is invalid.");
|
||||
return new ProvisioningBaseInstallPlanRecord(
|
||||
jobId, reader.GetString(3), reader.GetInt32(0), plan,
|
||||
DateTimeOffset.Parse(reader.GetString(2)));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProvisioningPlanValidationException()
|
||||
: Exception("Base installation plan does not match the validated job parameters.");
|
||||
|
|
@ -42,9 +42,7 @@ public sealed partial class ControlStore
|
|||
request.ActionType,
|
||||
request.SchemaVersion,
|
||||
request.Parameters.Clone(),
|
||||
confirmationRequired
|
||||
? ProvisioningJobStates.AwaitingConfirmation
|
||||
: ProvisioningJobStates.Queued,
|
||||
ProvisioningJobStates.Queued,
|
||||
confirmationRequired,
|
||||
request.AuditReason,
|
||||
actor,
|
||||
|
|
@ -119,12 +117,14 @@ public sealed partial class ControlStore
|
|||
WHERE id = (
|
||||
SELECT id FROM provisioning_jobs
|
||||
WHERE node_id = $node
|
||||
AND (state = $queued
|
||||
AND ((state = $queued
|
||||
AND (confirmation_required = 0 OR confirmed_at IS NULL))
|
||||
OR (state = $rolling_back AND current_step = 'rollback-queued'))
|
||||
AND expires_at > $now
|
||||
ORDER BY CASE WHEN state = $rolling_back THEN 0 ELSE 1 END, created_at, id
|
||||
LIMIT 1)
|
||||
AND (state = $queued
|
||||
AND ((state = $queued
|
||||
AND (confirmation_required = 0 OR confirmed_at IS NULL))
|
||||
OR (state = $rolling_back AND current_step = 'rollback-queued'))
|
||||
RETURNING *;
|
||||
""";
|
||||
|
|
@ -512,6 +512,7 @@ public sealed partial class ControlStore
|
|||
var updated = current with
|
||||
{
|
||||
State = targetState,
|
||||
CurrentStep = setConfirmedAt ? "confirmed-queued" : current.CurrentStep,
|
||||
UpdatedAt = now,
|
||||
ConfirmedAt = setConfirmedAt ? now : current.ConfirmedAt,
|
||||
CancelledAt = targetState == ProvisioningJobStates.Cancelled ? now : current.CancelledAt,
|
||||
|
|
@ -521,11 +522,12 @@ public sealed partial class ControlStore
|
|||
update.Transaction = transaction;
|
||||
update.CommandText = """
|
||||
UPDATE provisioning_jobs SET
|
||||
state = $state, updated_at = $updated, confirmed_at = $confirmed,
|
||||
state = $state, current_step = $step, updated_at = $updated, confirmed_at = $confirmed,
|
||||
cancelled_at = $cancelled, version = $version
|
||||
WHERE id = $id AND version = $previous_version;
|
||||
""";
|
||||
AddProvisioningJobParameters(update, updated);
|
||||
update.Parameters.AddWithValue("$step", updated.CurrentStep);
|
||||
update.Parameters.AddWithValue("$previous_version", current.Version);
|
||||
if (await update.ExecuteNonQueryAsync(cancellationToken) != 1)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -272,6 +272,18 @@ public sealed record SystemBaseInstallPlan(
|
|||
string RebootPolicy,
|
||||
string[] Warnings);
|
||||
|
||||
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
|
||||
public sealed record SystemBaseInstallPlanReportRequest(
|
||||
SystemBaseInstallPlan Plan,
|
||||
string IdempotencyKey);
|
||||
|
||||
public sealed record ProvisioningBaseInstallPlanRecord(
|
||||
string JobId,
|
||||
string NodeId,
|
||||
int SchemaVersion,
|
||||
SystemBaseInstallPlan Plan,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public static class PreflightDriftStatuses
|
||||
{
|
||||
public const string NotConfigured = "NotConfigured";
|
||||
|
|
@ -374,6 +386,9 @@ public static class SystemBaseInstallCatalogDefinition
|
|||
|
||||
public static class SystemBaseInstallSchema
|
||||
{
|
||||
private static readonly HashSet<string> AllowedPlanWarnings =
|
||||
new(["apt.missing", "timezone.missing"], StringComparer.Ordinal);
|
||||
|
||||
public static bool TryParse(JsonElement json, out SystemBaseInstallParameters? parameters)
|
||||
{
|
||||
try
|
||||
|
|
@ -405,6 +420,27 @@ public static class SystemBaseInstallSchema
|
|||
&& parameters.VmSwappiness is >= 0 and <= 200
|
||||
&& parameters.RebootPolicy == "never";
|
||||
|
||||
public static bool IsValidPlan(
|
||||
SystemBaseInstallParameters parameters,
|
||||
SystemBaseInstallPlan plan)
|
||||
=> plan is not null
|
||||
&& string.Equals(plan.Timezone, parameters.Timezone, StringComparison.Ordinal)
|
||||
&& string.Equals(plan.Locale, parameters.Locale, StringComparison.Ordinal)
|
||||
&& plan.AptUpdate == parameters.AptUpdate
|
||||
&& plan.AptUpgrade == parameters.AptUpgrade
|
||||
&& plan.Packages is not null
|
||||
&& plan.Packages.SequenceEqual(
|
||||
SystemBaseInstallCatalogDefinition.ExpandGroups(parameters.PackageGroupIds),
|
||||
StringComparer.Ordinal)
|
||||
&& string.Equals(plan.SwapMode, parameters.SwapMode, StringComparison.Ordinal)
|
||||
&& plan.SwapSizeMiB == parameters.SwapSizeMiB
|
||||
&& plan.VmSwappiness == parameters.VmSwappiness
|
||||
&& plan.EnableUnattendedUpgrades == parameters.EnableUnattendedUpgrades
|
||||
&& string.Equals(plan.RebootPolicy, parameters.RebootPolicy, StringComparison.Ordinal)
|
||||
&& plan.Warnings is { Length: <= 2 }
|
||||
&& plan.Warnings.Distinct(StringComparer.Ordinal).Count() == plan.Warnings.Length
|
||||
&& plan.Warnings.All(AllowedPlanWarnings.Contains);
|
||||
|
||||
private static bool IsSafeTimezone(string? value)
|
||||
=> value is { Length: >= 1 and <= 64 }
|
||||
&& value[0] is not '/' and not '.'
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ namespace ServerMonitorManager.Core;
|
|||
[JsonSerializable(typeof(SystemPackageGroup[]))]
|
||||
[JsonSerializable(typeof(SystemBaseInstallCatalog))]
|
||||
[JsonSerializable(typeof(SystemBaseInstallPlan))]
|
||||
[JsonSerializable(typeof(SystemBaseInstallPlanReportRequest))]
|
||||
[JsonSerializable(typeof(ProvisioningBaseInstallPlanRecord))]
|
||||
[JsonSerializable(typeof(ProvisioningJob))]
|
||||
[JsonSerializable(typeof(ProvisioningJob[]))]
|
||||
[JsonSerializable(typeof(ProvisioningEvent))]
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ public sealed class ControlApiTests : IAsyncDisposable
|
|||
var job = await response.Content.ReadFromJsonAsync<ServerMonitorManager.Core.ProvisioningJob>(
|
||||
cancellationToken);
|
||||
Assert.NotNull(job);
|
||||
Assert.Equal(ServerMonitorManager.Core.ProvisioningJobStates.AwaitingConfirmation, job.State);
|
||||
Assert.Equal(ServerMonitorManager.Core.ProvisioningJobStates.Queued, job.State);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync(
|
||||
"/api/v1/control/agents/home/provisioning/jobs",
|
||||
new
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ public sealed class ControlMaintenanceTests : IAsyncDisposable
|
|||
await verify.OpenAsync(cancellationToken);
|
||||
var version = verify.CreateCommand();
|
||||
version.CommandText = "PRAGMA user_version;";
|
||||
Assert.Equal(7L, (long)(await version.ExecuteScalarAsync(cancellationToken))!);
|
||||
Assert.Equal(8L, (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(7L, Convert.ToInt64(await version.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
||||
Assert.Equal(8L, 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)));
|
||||
|
|
@ -44,9 +44,9 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
|||
var store = CreateStore();
|
||||
await store.InitializeAsync(cancellationToken);
|
||||
await EnrollAgentAsync(store, "home", "A1B2", cancellationToken);
|
||||
using var parameters = JsonDocument.Parse("{}");
|
||||
var parameters = CreateBaseInstallParameters();
|
||||
var request = new ProvisioningJobCreateRequest(
|
||||
"system.base-install", 1, parameters.RootElement.Clone(), 60,
|
||||
"system.base-install", 1, parameters, 60,
|
||||
"Prepare test server", Guid.NewGuid().ToString());
|
||||
|
||||
var created = await store.CreateProvisioningJobAsync("home", request, "operator", cancellationToken);
|
||||
|
|
@ -54,13 +54,42 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
|||
|
||||
Assert.Equal(created.Id, replay.Id);
|
||||
Assert.Equal(created.State, replay.State);
|
||||
Assert.Equal(ProvisioningJobStates.AwaitingConfirmation, created.State);
|
||||
Assert.Equal(ProvisioningJobStates.Queued, 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());
|
||||
await Assert.ThrowsAsync<ProvisioningTransitionException>(() => store.ConfirmProvisioningJobAsync(
|
||||
created.Id, confirmation, "operator", cancellationToken));
|
||||
|
||||
var claimed = await store.ClaimNextProvisioningJobAsync("home", cancellationToken);
|
||||
Assert.Equal(ProvisioningJobStates.Preflight, claimed!.State);
|
||||
var expectedPlan = CreateBaseInstallPlan();
|
||||
var invalidPlan = expectedPlan with { Packages = [.. expectedPlan.Packages, "untrusted-package"] };
|
||||
await Assert.ThrowsAsync<ProvisioningPlanValidationException>(() => store.RecordBaseInstallPlanAsync(
|
||||
"home", created.Id,
|
||||
new SystemBaseInstallPlanReportRequest(invalidPlan, Guid.NewGuid().ToString()),
|
||||
cancellationToken));
|
||||
|
||||
var planRequest = new SystemBaseInstallPlanReportRequest(
|
||||
expectedPlan, Guid.NewGuid().ToString());
|
||||
var recorded = await store.RecordBaseInstallPlanAsync(
|
||||
"home", created.Id, planRequest, cancellationToken);
|
||||
var planReplay = await store.RecordBaseInstallPlanAsync(
|
||||
"home", created.Id, planRequest, cancellationToken);
|
||||
Assert.Equal(recorded!.JobId, planReplay!.JobId);
|
||||
Assert.Equal(recorded.NodeId, planReplay.NodeId);
|
||||
Assert.Equal(recorded.CreatedAt, planReplay.CreatedAt);
|
||||
Assert.Equal(recorded.Plan.Packages, planReplay.Plan.Packages);
|
||||
Assert.Equal(expectedPlan.Packages, recorded!.Plan.Packages);
|
||||
Assert.Equal(
|
||||
ProvisioningJobStates.AwaitingConfirmation,
|
||||
(await store.GetProvisioningJobAsync(created.Id, cancellationToken))!.State);
|
||||
var readablePlan = await store.GetBaseInstallPlanAsync(created.Id, cancellationToken);
|
||||
Assert.Equal(expectedPlan.Packages, readablePlan!.Plan.Packages);
|
||||
|
||||
var confirmed = await store.ConfirmProvisioningJobAsync(
|
||||
created.Id, confirmation, "operator", cancellationToken);
|
||||
var confirmationReplay = await store.ConfirmProvisioningJobAsync(
|
||||
|
|
@ -68,7 +97,9 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
|||
Assert.Equal(confirmed!.Id, confirmationReplay!.Id);
|
||||
Assert.Equal(confirmed.State, confirmationReplay.State);
|
||||
Assert.Equal(ProvisioningJobStates.Queued, confirmed.State);
|
||||
Assert.Equal("confirmed-queued", confirmed.CurrentStep);
|
||||
Assert.NotNull(confirmed.ConfirmedAt);
|
||||
Assert.Null(await store.ClaimNextProvisioningJobAsync("home", cancellationToken));
|
||||
|
||||
var cancelled = await store.CancelProvisioningJobAsync(
|
||||
created.Id,
|
||||
|
|
@ -80,7 +111,7 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
|||
|
||||
var next = await store.CreateProvisioningJobAsync(
|
||||
"home", request with { IdempotencyKey = Guid.NewGuid().ToString() }, "operator", cancellationToken);
|
||||
Assert.Equal(ProvisioningJobStates.AwaitingConfirmation, next.State);
|
||||
Assert.Equal(ProvisioningJobStates.Queued, next.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -744,6 +775,19 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
|||
}));
|
||||
}
|
||||
|
||||
private static JsonElement CreateBaseInstallParameters()
|
||||
=> JsonSerializer.SerializeToElement(
|
||||
new SystemBaseInstallParameters(
|
||||
"UTC", "en_US.UTF-8", true, false, 1, ["core"],
|
||||
"disabled", null, 60, true, "never"),
|
||||
SmmJsonContext.Default.SystemBaseInstallParameters);
|
||||
|
||||
private static SystemBaseInstallPlan CreateBaseInstallPlan()
|
||||
=> new(
|
||||
"UTC", "en_US.UTF-8", true, false,
|
||||
SystemBaseInstallCatalogDefinition.ExpandGroups(["core"]),
|
||||
"disabled", null, 60, true, "never", []);
|
||||
|
||||
private static async Task EnrollAgentAsync(
|
||||
ControlStore store,
|
||||
string nodeId,
|
||||
|
|
|
|||
Loading…
Reference in a new issue