Add standalone Linux bootstrap foundation #5
7 changed files with 262 additions and 12 deletions
|
|
@ -109,7 +109,7 @@
|
||||||
## Этап 8 — Provisioning control plane
|
## Этап 8 — Provisioning control plane
|
||||||
|
|
||||||
- [x] модели и SQLite migration v2 для ProvisioningJob;
|
- [x] модели и SQLite migration v2 для ProvisioningJob;
|
||||||
- [ ] state machine, confirmations, cancellation, retry и rollback;
|
- [x] state machine, confirmations, cancellation, retry и rollback;
|
||||||
- [x] создание, чтение, подтверждение и отмена через Operator API;
|
- [x] создание, чтение, подтверждение и отмена через Operator API;
|
||||||
- [ ] выполнение, retry, verification и rollback в полной state machine;
|
- [ ] выполнение, retry, verification и rollback в полной state machine;
|
||||||
- [x] обязательные idempotency key, audit reason и job TTL;
|
- [x] обязательные idempotency key, audit reason и job TTL;
|
||||||
|
|
|
||||||
|
|
@ -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 = 3;
|
private const int CurrentSchemaVersion = 4;
|
||||||
private readonly ControlOptions _options = options.Value;
|
private readonly ControlOptions _options = options.Value;
|
||||||
private readonly string _connectionString = new SqliteConnectionStringBuilder
|
private readonly string _connectionString = new SqliteConnectionStringBuilder
|
||||||
{
|
{
|
||||||
|
|
@ -196,6 +196,23 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||||
await migrateProgress.ExecuteNonQueryAsync(cancellationToken);
|
await migrateProgress.ExecuteNonQueryAsync(cancellationToken);
|
||||||
await migration.CommitAsync(cancellationToken);
|
await migration.CommitAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (schemaVersion < 4)
|
||||||
|
{
|
||||||
|
await using var migration =
|
||||||
|
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
var strengthenProvisioningLock = connection.CreateCommand();
|
||||||
|
strengthenProvisioningLock.Transaction = migration;
|
||||||
|
strengthenProvisioningLock.CommandText = """
|
||||||
|
DROP INDEX IF EXISTS ux_provisioning_jobs_active_node;
|
||||||
|
CREATE UNIQUE INDEX ux_provisioning_jobs_active_node
|
||||||
|
ON provisioning_jobs(node_id)
|
||||||
|
WHERE state NOT IN ('Completed', 'Cancelled', 'RolledBack');
|
||||||
|
PRAGMA user_version = 4;
|
||||||
|
""";
|
||||||
|
await strengthenProvisioningLock.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
await migration.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ControlMaintenanceResult> MaintainAsync(
|
public async Task<ControlMaintenanceResult> MaintainAsync(
|
||||||
|
|
@ -234,6 +251,17 @@ 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)
|
||||||
|
SELECT id, $now, 'job.rollback.reconciliation.required', 'NeedsReconciliation',
|
||||||
|
'Rollback lease expired; factual state must be inspected.'
|
||||||
|
FROM provisioning_jobs
|
||||||
|
WHERE expires_at <= $now AND state = 'RollingBack';
|
||||||
|
UPDATE provisioning_jobs SET
|
||||||
|
state = 'NeedsReconciliation', updated_at = $now,
|
||||||
|
current_step = 'rollback-reconcile', version = version + 1,
|
||||||
|
last_error = 'job.rollback.ttl_expired'
|
||||||
|
WHERE expires_at <= $now AND state = 'RollingBack';
|
||||||
|
SELECT changes();
|
||||||
DELETE FROM metric_samples WHERE recorded_at < $metric_cutoff;
|
DELETE FROM metric_samples WHERE recorded_at < $metric_cutoff;
|
||||||
SELECT changes();
|
SELECT changes();
|
||||||
DELETE FROM idempotency WHERE created_at < $idempotency_cutoff;
|
DELETE FROM idempotency WHERE created_at < $idempotency_cutoff;
|
||||||
|
|
@ -254,7 +282,7 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||||
command.Parameters.AddWithValue(
|
command.Parameters.AddWithValue(
|
||||||
"$audit_cutoff", now.AddDays(-_options.AuditRetentionDays).ToString("O"));
|
"$audit_cutoff", now.AddDays(-_options.AuditRetentionDays).ToString("O"));
|
||||||
command.Parameters.AddWithValue("$now", now.ToString("O"));
|
command.Parameters.AddWithValue("$now", now.ToString("O"));
|
||||||
var changes = new int[8];
|
var changes = new int[9];
|
||||||
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
|
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
|
||||||
{
|
{
|
||||||
for (var index = 0; index < changes.Length; index++)
|
for (var index = 0; index < changes.Length; index++)
|
||||||
|
|
@ -272,8 +300,8 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||||
optimize.CommandText = "PRAGMA optimize; PRAGMA wal_checkpoint(PASSIVE);";
|
optimize.CommandText = "PRAGMA optimize; PRAGMA wal_checkpoint(PASSIVE);";
|
||||||
await optimize.ExecuteNonQueryAsync(cancellationToken);
|
await optimize.ExecuteNonQueryAsync(cancellationToken);
|
||||||
return new ControlMaintenanceResult(
|
return new ControlMaintenanceResult(
|
||||||
changes[2], changes[3], changes[4], changes[5] + changes[6] + changes[7],
|
changes[3], changes[4], changes[5], changes[6] + changes[7] + changes[8],
|
||||||
changes[0], changes[1]);
|
changes[0], changes[1] + changes[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task BackupDatabaseAsync(string destinationPath, CancellationToken cancellationToken = default)
|
public async Task BackupDatabaseAsync(string destinationPath, CancellationToken cancellationToken = default)
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,37 @@ control.MapPost("/provisioning/jobs/{id}/retry", async (
|
||||||
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
control.MapPost("/provisioning/jobs/{id}/rollback", async (
|
||||||
|
string id,
|
||||||
|
ProvisioningJobCommandRequest request,
|
||||||
|
HttpContext context,
|
||||||
|
ControlStore controlStore,
|
||||||
|
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 = await controlStore.StartProvisioningRollbackAsync(
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
control.MapPost("/automations/token", async (
|
control.MapPost("/automations/token", async (
|
||||||
AutomationTokenCreateRequest request,
|
AutomationTokenCreateRequest request,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
|
|
@ -822,6 +853,9 @@ internal static class ProvisioningJobValidator
|
||||||
or ProvisioningJobStates.Completed
|
or ProvisioningJobStates.Completed
|
||||||
or ProvisioningJobStates.Failed
|
or ProvisioningJobStates.Failed
|
||||||
or ProvisioningJobStates.NeedsReconciliation
|
or ProvisioningJobStates.NeedsReconciliation
|
||||||
|
or ProvisioningJobStates.RollingBack
|
||||||
|
or ProvisioningJobStates.RolledBack
|
||||||
|
or ProvisioningJobStates.RollbackFailed
|
||||||
&& request.ProgressPercent is >= 0 and <= 100
|
&& request.ProgressPercent is >= 0 and <= 100
|
||||||
&& IsSafeCode(request.Step, 64)
|
&& IsSafeCode(request.Step, 64)
|
||||||
&& IsSafeCode(request.EventCode, 64)
|
&& IsSafeCode(request.EventCode, 64)
|
||||||
|
|
|
||||||
|
|
@ -109,22 +109,29 @@ public sealed partial class ControlStore
|
||||||
command.Transaction = transaction;
|
command.Transaction = transaction;
|
||||||
command.CommandText = """
|
command.CommandText = """
|
||||||
UPDATE provisioning_jobs SET
|
UPDATE provisioning_jobs SET
|
||||||
state = $preflight,
|
state = CASE WHEN state = $queued THEN $preflight ELSE $rolling_back END,
|
||||||
|
current_step = CASE
|
||||||
|
WHEN state = $queued THEN 'preflight'
|
||||||
|
ELSE 'rollback'
|
||||||
|
END,
|
||||||
updated_at = $now,
|
updated_at = $now,
|
||||||
version = version + 1
|
version = version + 1
|
||||||
WHERE id = (
|
WHERE id = (
|
||||||
SELECT id FROM provisioning_jobs
|
SELECT id FROM provisioning_jobs
|
||||||
WHERE node_id = $node
|
WHERE node_id = $node
|
||||||
AND state = $queued
|
AND (state = $queued
|
||||||
|
OR (state = $rolling_back AND current_step = 'rollback-queued'))
|
||||||
AND expires_at > $now
|
AND expires_at > $now
|
||||||
ORDER BY created_at, id
|
ORDER BY CASE WHEN state = $rolling_back THEN 0 ELSE 1 END, created_at, id
|
||||||
LIMIT 1)
|
LIMIT 1)
|
||||||
AND state = $queued
|
AND (state = $queued
|
||||||
|
OR (state = $rolling_back AND current_step = 'rollback-queued'))
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
""";
|
""";
|
||||||
command.Parameters.AddWithValue("$node", nodeId);
|
command.Parameters.AddWithValue("$node", nodeId);
|
||||||
command.Parameters.AddWithValue("$queued", ProvisioningJobStates.Queued);
|
command.Parameters.AddWithValue("$queued", ProvisioningJobStates.Queued);
|
||||||
command.Parameters.AddWithValue("$preflight", ProvisioningJobStates.Preflight);
|
command.Parameters.AddWithValue("$preflight", ProvisioningJobStates.Preflight);
|
||||||
|
command.Parameters.AddWithValue("$rolling_back", ProvisioningJobStates.RollingBack);
|
||||||
command.Parameters.AddWithValue("$now", now.ToString("O"));
|
command.Parameters.AddWithValue("$now", now.ToString("O"));
|
||||||
ProvisioningJob? job;
|
ProvisioningJob? job;
|
||||||
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
|
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
|
||||||
|
|
@ -209,6 +216,7 @@ public sealed partial class ControlStore
|
||||||
Version = current.Version + 1,
|
Version = current.Version + 1,
|
||||||
LastError = request.State is ProvisioningJobStates.Failed
|
LastError = request.State is ProvisioningJobStates.Failed
|
||||||
or ProvisioningJobStates.NeedsReconciliation
|
or ProvisioningJobStates.NeedsReconciliation
|
||||||
|
or ProvisioningJobStates.RollbackFailed
|
||||||
? request.EventCode
|
? request.EventCode
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
|
|
@ -284,7 +292,8 @@ public sealed partial class ControlStore
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (current.State is not (ProvisioningJobStates.Failed
|
if (current.State is not (ProvisioningJobStates.Failed
|
||||||
or ProvisioningJobStates.NeedsReconciliation))
|
or ProvisioningJobStates.NeedsReconciliation)
|
||||||
|
|| current.CurrentStep == "rollback-reconcile")
|
||||||
{
|
{
|
||||||
throw new ProvisioningTransitionException(current.State, ProvisioningJobStates.Queued);
|
throw new ProvisioningTransitionException(current.State, ProvisioningJobStates.Queued);
|
||||||
}
|
}
|
||||||
|
|
@ -338,6 +347,88 @@ public sealed partial class ControlStore
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ProvisioningJob?> StartProvisioningRollbackAsync(
|
||||||
|
string id,
|
||||||
|
ProvisioningJobCommandRequest 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-rollback:{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;
|
||||||
|
}
|
||||||
|
if (current.State is not (ProvisioningJobStates.Failed
|
||||||
|
or ProvisioningJobStates.NeedsReconciliation
|
||||||
|
or ProvisioningJobStates.RollbackFailed))
|
||||||
|
{
|
||||||
|
throw new ProvisioningTransitionException(current.State, ProvisioningJobStates.RollingBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var lifetime = current.ExpiresAt - current.CreatedAt;
|
||||||
|
if (lifetime < TimeSpan.FromMinutes(5))
|
||||||
|
{
|
||||||
|
lifetime = TimeSpan.FromMinutes(5);
|
||||||
|
}
|
||||||
|
var updated = current with
|
||||||
|
{
|
||||||
|
State = ProvisioningJobStates.RollingBack,
|
||||||
|
ProgressPercent = 0,
|
||||||
|
CurrentStep = "rollback-queued",
|
||||||
|
UpdatedAt = now,
|
||||||
|
ExpiresAt = now.Add(lifetime),
|
||||||
|
Version = current.Version + 1,
|
||||||
|
LastError = null
|
||||||
|
};
|
||||||
|
var update = connection.CreateCommand();
|
||||||
|
update.Transaction = transaction;
|
||||||
|
update.CommandText = """
|
||||||
|
UPDATE provisioning_jobs SET
|
||||||
|
state = $state, progress_percent = 0, current_step = $step,
|
||||||
|
updated_at = $updated, expires_at = $expires,
|
||||||
|
version = $version, last_error = NULL
|
||||||
|
WHERE id = $id AND version = $previous_version;
|
||||||
|
""";
|
||||||
|
update.Parameters.AddWithValue("$state", updated.State);
|
||||||
|
update.Parameters.AddWithValue("$step", updated.CurrentStep);
|
||||||
|
update.Parameters.AddWithValue("$updated", updated.UpdatedAt.ToString("O"));
|
||||||
|
update.Parameters.AddWithValue("$expires", updated.ExpiresAt.ToString("O"));
|
||||||
|
update.Parameters.AddWithValue("$version", updated.Version);
|
||||||
|
update.Parameters.AddWithValue("$id", id);
|
||||||
|
update.Parameters.AddWithValue("$previous_version", current.Version);
|
||||||
|
if (await update.ExecuteNonQueryAsync(cancellationToken) != 1)
|
||||||
|
{
|
||||||
|
throw new ProvisioningTransitionException(current.State, ProvisioningJobStates.RollingBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteProvisioningEventAsync(
|
||||||
|
connection, transaction, id, "job.rollback.queued", updated.State,
|
||||||
|
request.Reason, now, cancellationToken);
|
||||||
|
await WriteIdempotentAsync(
|
||||||
|
connection, transaction, operationKey, requestHash, updated,
|
||||||
|
SmmJsonContext.Default.ProvisioningJob, cancellationToken);
|
||||||
|
await WriteAuditAsync(
|
||||||
|
connection, transaction, actor, "provisioning.job.rollback", id,
|
||||||
|
JsonSerializer.Serialize(new { request.Reason }), cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<ProvisioningJob?> TransitionProvisioningJobAsync(
|
private async Task<ProvisioningJob?> TransitionProvisioningJobAsync(
|
||||||
string id,
|
string id,
|
||||||
ProvisioningJobCommandRequest request,
|
ProvisioningJobCommandRequest request,
|
||||||
|
|
@ -509,6 +600,10 @@ internal static class ProvisioningStateMachine
|
||||||
or ProvisioningJobStates.Running
|
or ProvisioningJobStates.Running
|
||||||
or ProvisioningJobStates.Verifying;
|
or ProvisioningJobStates.Verifying;
|
||||||
}
|
}
|
||||||
|
if (target == ProvisioningJobStates.RollbackFailed)
|
||||||
|
{
|
||||||
|
return current == ProvisioningJobStates.RollingBack;
|
||||||
|
}
|
||||||
return (current, target) switch
|
return (current, target) switch
|
||||||
{
|
{
|
||||||
(ProvisioningJobStates.Preflight, ProvisioningJobStates.Preflight) => true,
|
(ProvisioningJobStates.Preflight, ProvisioningJobStates.Preflight) => true,
|
||||||
|
|
@ -517,6 +612,8 @@ internal static class ProvisioningStateMachine
|
||||||
(ProvisioningJobStates.Running, ProvisioningJobStates.Verifying) => true,
|
(ProvisioningJobStates.Running, ProvisioningJobStates.Verifying) => true,
|
||||||
(ProvisioningJobStates.Verifying, ProvisioningJobStates.Verifying) => true,
|
(ProvisioningJobStates.Verifying, ProvisioningJobStates.Verifying) => true,
|
||||||
(ProvisioningJobStates.Verifying, ProvisioningJobStates.Completed) => targetProgress == 100,
|
(ProvisioningJobStates.Verifying, ProvisioningJobStates.Completed) => targetProgress == 100,
|
||||||
|
(ProvisioningJobStates.RollingBack, ProvisioningJobStates.RollingBack) => true,
|
||||||
|
(ProvisioningJobStates.RollingBack, ProvisioningJobStates.RolledBack) => targetProgress == 100,
|
||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,5 +193,8 @@ public static class ProvisioningJobStates
|
||||||
public const string Completed = "Completed";
|
public const string Completed = "Completed";
|
||||||
public const string Failed = "Failed";
|
public const string Failed = "Failed";
|
||||||
public const string NeedsReconciliation = "NeedsReconciliation";
|
public const string NeedsReconciliation = "NeedsReconciliation";
|
||||||
|
public const string RollingBack = "RollingBack";
|
||||||
|
public const string RolledBack = "RolledBack";
|
||||||
|
public const string RollbackFailed = "RollbackFailed";
|
||||||
public const string Cancelled = "Cancelled";
|
public const string Cancelled = "Cancelled";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(3L, (long)(await version.ExecuteScalarAsync(cancellationToken))!);
|
Assert.Equal(4L, (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(3L, Convert.ToInt64(await version.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
Assert.Equal(4L, 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)));
|
||||||
|
|
@ -172,6 +172,94 @@ public sealed class ControlStoreTests : IAsyncDisposable
|
||||||
Assert.Equal(ProvisioningJobStates.Completed, completed!.State);
|
Assert.Equal(ProvisioningJobStates.Completed, completed!.State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FailedJobRollbackIsNodeScopedRecoverableAndTerminal()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var store = CreateStore();
|
||||||
|
await store.InitializeAsync(cancellationToken);
|
||||||
|
await EnrollAgentAsync(store, "home", "7788", cancellationToken);
|
||||||
|
using var parameters = JsonDocument.Parse("{}");
|
||||||
|
var created = await store.CreateProvisioningJobAsync(
|
||||||
|
"home",
|
||||||
|
new ProvisioningJobCreateRequest(
|
||||||
|
"preflight", 1, parameters.RootElement.Clone(), 5,
|
||||||
|
"Rollback test", Guid.NewGuid().ToString()),
|
||||||
|
"operator",
|
||||||
|
cancellationToken);
|
||||||
|
Assert.NotNull(await store.ClaimNextProvisioningJobAsync("home", cancellationToken));
|
||||||
|
var failed = await store.ReportProvisioningProgressAsync(
|
||||||
|
"home", created.Id,
|
||||||
|
new ProvisioningJobProgressRequest(
|
||||||
|
ProvisioningJobStates.Failed, 20, "inspect-os", "preflight.failed",
|
||||||
|
"Preflight failed.", Guid.NewGuid().ToString()),
|
||||||
|
cancellationToken);
|
||||||
|
Assert.Equal(ProvisioningJobStates.Failed, failed!.State);
|
||||||
|
await Assert.ThrowsAsync<SqliteException>(() => store.CreateProvisioningJobAsync(
|
||||||
|
"home",
|
||||||
|
new ProvisioningJobCreateRequest(
|
||||||
|
"preflight", 1, parameters.RootElement.Clone(), 5,
|
||||||
|
"Must remain blocked", Guid.NewGuid().ToString()),
|
||||||
|
"operator",
|
||||||
|
cancellationToken));
|
||||||
|
|
||||||
|
var rollbackRequest = new ProvisioningJobCommandRequest(
|
||||||
|
"Restore preflight state", Guid.NewGuid().ToString());
|
||||||
|
var queued = await store.StartProvisioningRollbackAsync(
|
||||||
|
created.Id, rollbackRequest, "operator", cancellationToken);
|
||||||
|
var replay = await store.StartProvisioningRollbackAsync(
|
||||||
|
created.Id, rollbackRequest, "operator", cancellationToken);
|
||||||
|
Assert.Equal(queued!.Id, replay!.Id);
|
||||||
|
Assert.Equal("rollback-queued", queued.CurrentStep);
|
||||||
|
var claimed = await store.ClaimNextProvisioningJobAsync("home", cancellationToken);
|
||||||
|
Assert.Equal(ProvisioningJobStates.RollingBack, claimed!.State);
|
||||||
|
Assert.Equal("rollback", claimed.CurrentStep);
|
||||||
|
Assert.Null(await store.ClaimNextProvisioningJobAsync("home", cancellationToken));
|
||||||
|
|
||||||
|
var maintenance = await store.MaintainAsync(
|
||||||
|
queued.ExpiresAt.AddSeconds(1), cancellationToken);
|
||||||
|
Assert.Equal(1, maintenance.ProvisioningJobsNeedingReconciliation);
|
||||||
|
var uncertain = await store.GetProvisioningJobAsync(created.Id, cancellationToken);
|
||||||
|
Assert.Equal(ProvisioningJobStates.NeedsReconciliation, uncertain!.State);
|
||||||
|
Assert.Equal("rollback-reconcile", uncertain.CurrentStep);
|
||||||
|
await Assert.ThrowsAsync<ProvisioningTransitionException>(() =>
|
||||||
|
store.RetryProvisioningJobAsync(
|
||||||
|
created.Id,
|
||||||
|
new ProvisioningJobCommandRequest("Unsafe retry", Guid.NewGuid().ToString()),
|
||||||
|
"operator",
|
||||||
|
cancellationToken));
|
||||||
|
|
||||||
|
await store.StartProvisioningRollbackAsync(
|
||||||
|
created.Id,
|
||||||
|
new ProvisioningJobCommandRequest("Resume rollback", Guid.NewGuid().ToString()),
|
||||||
|
"operator",
|
||||||
|
cancellationToken);
|
||||||
|
Assert.NotNull(await store.ClaimNextProvisioningJobAsync("home", cancellationToken));
|
||||||
|
var rolling = await store.ReportProvisioningProgressAsync(
|
||||||
|
"home", created.Id,
|
||||||
|
new ProvisioningJobProgressRequest(
|
||||||
|
ProvisioningJobStates.RollingBack, 50, "restore", "rollback.progress",
|
||||||
|
"Restoring backup.", Guid.NewGuid().ToString()),
|
||||||
|
cancellationToken);
|
||||||
|
var rolledBack = await store.ReportProvisioningProgressAsync(
|
||||||
|
"home", created.Id,
|
||||||
|
new ProvisioningJobProgressRequest(
|
||||||
|
ProvisioningJobStates.RolledBack, 100, "rollback-complete", "rollback.completed",
|
||||||
|
"Backup restored.", Guid.NewGuid().ToString()),
|
||||||
|
cancellationToken);
|
||||||
|
Assert.Equal(50, rolling!.ProgressPercent);
|
||||||
|
Assert.Equal(ProvisioningJobStates.RolledBack, rolledBack!.State);
|
||||||
|
Assert.Null(rolledBack.LastError);
|
||||||
|
var replacement = await store.CreateProvisioningJobAsync(
|
||||||
|
"home",
|
||||||
|
new ProvisioningJobCreateRequest(
|
||||||
|
"preflight", 1, parameters.RootElement.Clone(), 5,
|
||||||
|
"Allowed after rollback", Guid.NewGuid().ToString()),
|
||||||
|
"operator",
|
||||||
|
cancellationToken);
|
||||||
|
Assert.Equal(ProvisioningJobStates.Queued, replacement.State);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DiagnosticsExportOmitsRawIdentitiesAndNormalizesStates()
|
public void DiagnosticsExportOmitsRawIdentitiesAndNormalizesStates()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue