Add standalone Linux bootstrap foundation #5
10 changed files with 387 additions and 5 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 now accepts only versioned, module-hashed allowlisted requests through a local Unix socket; the first executable action is read-only Linux `preflight`. 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; the first executable action is read-only Linux `preflight`. Its 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (типизированные factual preflight facts уже сохраняются);
|
||||
- [ ] desired/factual configuration и drift (`preflight` завершён; остальные action type ещё не подключены);
|
||||
- [x] запрет параллельных активных заданий на одном Node (безопасный первый вариант).
|
||||
|
||||
## Этап 9 — базовая настройка и пользователи
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace ServerMonitorManager.Control;
|
|||
|
||||
public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
||||
{
|
||||
private const int CurrentSchemaVersion = 6;
|
||||
private const int CurrentSchemaVersion = 7;
|
||||
private readonly ControlOptions _options = options.Value;
|
||||
private readonly string _connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
|
|
@ -251,6 +251,28 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
|
|||
await addNodePreflightFacts.ExecuteNonQueryAsync(cancellationToken);
|
||||
await migration.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (schemaVersion < 7)
|
||||
{
|
||||
await using var migration =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var addPreflightDesiredState = connection.CreateCommand();
|
||||
addPreflightDesiredState.Transaction = migration;
|
||||
addPreflightDesiredState.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS node_preflight_desired_state (
|
||||
node_id TEXT PRIMARY KEY REFERENCES agents(node_id) ON DELETE CASCADE,
|
||||
schema_version INTEGER NOT NULL,
|
||||
desired_json TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL,
|
||||
audit_reason TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 7;
|
||||
""";
|
||||
await addPreflightDesiredState.ExecuteNonQueryAsync(cancellationToken);
|
||||
await migration.CommitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ControlMaintenanceResult> MaintainAsync(
|
||||
|
|
|
|||
|
|
@ -479,6 +479,52 @@ control.MapGet("/agents/{nodeId}/facts/preflight", async (
|
|||
var facts = await controlStore.GetPreflightFactsAsync(nodeId, cancellationToken);
|
||||
return facts is null ? Results.NotFound() : Results.Ok(facts);
|
||||
});
|
||||
control.MapPut("/agents/{nodeId}/desired/preflight", async (
|
||||
string nodeId,
|
||||
PreflightDesiredStateUpdateRequest request,
|
||||
HttpContext context,
|
||||
ControlStore controlStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!NodeIdValidator.IsValid(nodeId) || !PreflightDesiredStateValidator.IsValid(request))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["preflightDesiredState"] =
|
||||
["Invalid schema, requirements, architectures, audit reason, or idempotency key."]
|
||||
});
|
||||
}
|
||||
try
|
||||
{
|
||||
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
||||
var desired = await controlStore.SetPreflightDesiredStateAsync(
|
||||
nodeId, request, actor, cancellationToken);
|
||||
return Results.Ok(desired);
|
||||
}
|
||||
catch (IdempotencyConflictException)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
||||
}
|
||||
catch (ProvisioningNodeNotFoundException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
});
|
||||
control.MapGet("/agents/{nodeId}/drift/preflight", async (
|
||||
string nodeId,
|
||||
ControlStore controlStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!NodeIdValidator.IsValid(nodeId))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["nodeId"] = ["Invalid node id."]
|
||||
});
|
||||
}
|
||||
var assessment = await controlStore.AssessPreflightDriftAsync(nodeId, cancellationToken);
|
||||
return assessment is null ? Results.NotFound() : Results.Ok(assessment);
|
||||
});
|
||||
control.MapPost("/agents/{nodeId}/provisioning/jobs", async (
|
||||
string nodeId,
|
||||
ProvisioningJobCreateRequest request,
|
||||
|
|
@ -955,3 +1001,19 @@ internal static class ProvisioningJobValidator
|
|||
&& value.All(character => char.IsAsciiLetterOrDigit(character)
|
||||
|| character is '_' or '-' or '.');
|
||||
}
|
||||
|
||||
internal static class PreflightDesiredStateValidator
|
||||
{
|
||||
private static readonly HashSet<string> SupportedArchitectures =
|
||||
new(["x64", "x86", "arm", "arm64"], StringComparer.Ordinal);
|
||||
|
||||
public static bool IsValid(PreflightDesiredStateUpdateRequest request)
|
||||
=> request.SchemaVersion == 1
|
||||
&& request.Desired is not null
|
||||
&& request.Desired.AllowedArchitectures is { Length: >= 1 and <= 4 }
|
||||
&& request.Desired.AllowedArchitectures.Distinct(StringComparer.Ordinal).Count()
|
||||
== request.Desired.AllowedArchitectures.Length
|
||||
&& request.Desired.AllowedArchitectures.All(SupportedArchitectures.Contains)
|
||||
&& request.AuditReason is { Length: >= 1 and <= 256 }
|
||||
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,4 +97,176 @@ public sealed partial class ControlStore
|
|||
nodeId, reader.GetInt32(0), facts, DateTimeOffset.Parse(reader.GetString(2)),
|
||||
reader.GetString(3), DateTimeOffset.Parse(reader.GetString(4)));
|
||||
}
|
||||
|
||||
public async Task<NodePreflightDesiredState> SetPreflightDesiredStateAsync(
|
||||
string nodeId,
|
||||
PreflightDesiredStateUpdateRequest request,
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var operationKey = $"preflight-desired:{actor}:{nodeId}:{request.IdempotencyKey}";
|
||||
var requestHash = Fingerprint(request, SmmJsonContext.Default.PreflightDesiredStateUpdateRequest);
|
||||
var cached = await ReadIdempotentAsync(
|
||||
connection, transaction, operationKey, requestHash,
|
||||
SmmJsonContext.Default.NodePreflightDesiredState, 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 readVersion = connection.CreateCommand();
|
||||
readVersion.Transaction = transaction;
|
||||
readVersion.CommandText =
|
||||
"SELECT version FROM node_preflight_desired_state WHERE node_id = $node;";
|
||||
readVersion.Parameters.AddWithValue("$node", nodeId);
|
||||
var existingVersion = await readVersion.ExecuteScalarAsync(cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var desired = new NodePreflightDesiredState(
|
||||
nodeId, request.SchemaVersion, request.Desired,
|
||||
existingVersion is null ? 1 : Convert.ToInt64(existingVersion) + 1,
|
||||
actor, request.AuditReason, now);
|
||||
var upsert = connection.CreateCommand();
|
||||
upsert.Transaction = transaction;
|
||||
upsert.CommandText = """
|
||||
INSERT INTO node_preflight_desired_state(
|
||||
node_id, schema_version, desired_json, version,
|
||||
updated_by, audit_reason, updated_at)
|
||||
VALUES ($node, $schema, $desired, $version, $actor, $reason, $updated)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
schema_version = excluded.schema_version,
|
||||
desired_json = excluded.desired_json,
|
||||
version = excluded.version,
|
||||
updated_by = excluded.updated_by,
|
||||
audit_reason = excluded.audit_reason,
|
||||
updated_at = excluded.updated_at;
|
||||
""";
|
||||
upsert.Parameters.AddWithValue("$node", desired.NodeId);
|
||||
upsert.Parameters.AddWithValue("$schema", desired.SchemaVersion);
|
||||
upsert.Parameters.AddWithValue(
|
||||
"$desired",
|
||||
JsonSerializer.Serialize(desired.Desired, SmmJsonContext.Default.PreflightDesiredRequirements));
|
||||
upsert.Parameters.AddWithValue("$version", desired.Version);
|
||||
upsert.Parameters.AddWithValue("$actor", desired.UpdatedBy);
|
||||
upsert.Parameters.AddWithValue("$reason", desired.AuditReason);
|
||||
upsert.Parameters.AddWithValue("$updated", desired.UpdatedAt.ToString("O"));
|
||||
await upsert.ExecuteNonQueryAsync(cancellationToken);
|
||||
await WriteIdempotentAsync(
|
||||
connection, transaction, operationKey, requestHash, desired,
|
||||
SmmJsonContext.Default.NodePreflightDesiredState, cancellationToken);
|
||||
await WriteAuditAsync(
|
||||
connection, transaction, actor, "provisioning.preflight.desired", nodeId,
|
||||
JsonSerializer.Serialize(new { desired.SchemaVersion, desired.Version }), cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return desired;
|
||||
}
|
||||
|
||||
public async Task<NodePreflightDesiredState?> GetPreflightDesiredStateAsync(
|
||||
string nodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT schema_version, desired_json, version, updated_by, audit_reason, updated_at
|
||||
FROM node_preflight_desired_state 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 desired = JsonSerializer.Deserialize(
|
||||
reader.GetString(1), SmmJsonContext.Default.PreflightDesiredRequirements)
|
||||
?? throw new InvalidDataException("Stored preflight desired state is invalid.");
|
||||
return new NodePreflightDesiredState(
|
||||
nodeId, reader.GetInt32(0), desired, reader.GetInt64(2), reader.GetString(3),
|
||||
reader.GetString(4), DateTimeOffset.Parse(reader.GetString(5)));
|
||||
}
|
||||
|
||||
public async Task<PreflightDriftAssessment?> AssessPreflightDriftAsync(
|
||||
string nodeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using (var connection = await OpenAsync(cancellationToken))
|
||||
{
|
||||
var exists = connection.CreateCommand();
|
||||
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)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var desired = await GetPreflightDesiredStateAsync(nodeId, cancellationToken);
|
||||
var facts = await GetPreflightFactsAsync(nodeId, cancellationToken);
|
||||
return PreflightDriftEvaluator.Assess(nodeId, desired, facts);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PreflightDriftEvaluator
|
||||
{
|
||||
public static PreflightDriftAssessment Assess(
|
||||
string nodeId,
|
||||
NodePreflightDesiredState? desired,
|
||||
NodePreflightFacts? facts)
|
||||
{
|
||||
if (desired is null)
|
||||
{
|
||||
return new PreflightDriftAssessment(
|
||||
nodeId, PreflightDriftStatuses.NotConfigured, [], null, facts);
|
||||
}
|
||||
if (facts is null)
|
||||
{
|
||||
return new PreflightDriftAssessment(
|
||||
nodeId, PreflightDriftStatuses.Unknown, [PreflightDriftCodes.FactsMissing], desired, null);
|
||||
}
|
||||
|
||||
var drift = new List<string>();
|
||||
AddMissing(
|
||||
drift, desired.Desired.RequireSystemd, facts.Facts.HasSystemd,
|
||||
PreflightDriftCodes.SystemdMissing);
|
||||
AddMissing(
|
||||
drift, desired.Desired.RequireSshd, facts.Facts.HasSshd,
|
||||
PreflightDriftCodes.SshdMissing);
|
||||
AddMissing(
|
||||
drift, desired.Desired.RequireNftables, facts.Facts.HasNftables,
|
||||
PreflightDriftCodes.NftablesMissing);
|
||||
AddMissing(
|
||||
drift, desired.Desired.RequireWireGuard, facts.Facts.HasWireGuard,
|
||||
PreflightDriftCodes.WireGuardMissing);
|
||||
AddMissing(
|
||||
drift, desired.Desired.RequireApt, facts.Facts.HasApt,
|
||||
PreflightDriftCodes.AptMissing);
|
||||
if (!desired.Desired.AllowedArchitectures.Contains(
|
||||
facts.Facts.Architecture, StringComparer.Ordinal))
|
||||
{
|
||||
drift.Add(PreflightDriftCodes.ArchitectureUnsupported);
|
||||
}
|
||||
return new PreflightDriftAssessment(
|
||||
nodeId,
|
||||
drift.Count == 0 ? PreflightDriftStatuses.InSync : PreflightDriftStatuses.Drifted,
|
||||
[.. drift], desired, facts);
|
||||
}
|
||||
|
||||
private static void AddMissing(List<string> drift, bool required, bool present, string code)
|
||||
{
|
||||
if (required && !present)
|
||||
{
|
||||
drift.Add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,6 +204,57 @@ public sealed record NodePreflightFacts(
|
|||
string SourceJobId,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
|
||||
public sealed record PreflightDesiredRequirements(
|
||||
bool RequireSystemd,
|
||||
bool RequireSshd,
|
||||
bool RequireNftables,
|
||||
bool RequireWireGuard,
|
||||
bool RequireApt,
|
||||
string[] AllowedArchitectures);
|
||||
|
||||
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
|
||||
public sealed record PreflightDesiredStateUpdateRequest(
|
||||
int SchemaVersion,
|
||||
PreflightDesiredRequirements Desired,
|
||||
string AuditReason,
|
||||
string IdempotencyKey);
|
||||
|
||||
public sealed record NodePreflightDesiredState(
|
||||
string NodeId,
|
||||
int SchemaVersion,
|
||||
PreflightDesiredRequirements Desired,
|
||||
long Version,
|
||||
string UpdatedBy,
|
||||
string AuditReason,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record PreflightDriftAssessment(
|
||||
string NodeId,
|
||||
string Status,
|
||||
string[] DriftCodes,
|
||||
NodePreflightDesiredState? Desired,
|
||||
NodePreflightFacts? Facts);
|
||||
|
||||
public static class PreflightDriftStatuses
|
||||
{
|
||||
public const string NotConfigured = "NotConfigured";
|
||||
public const string Unknown = "Unknown";
|
||||
public const string InSync = "InSync";
|
||||
public const string Drifted = "Drifted";
|
||||
}
|
||||
|
||||
public static class PreflightDriftCodes
|
||||
{
|
||||
public const string FactsMissing = "facts.missing";
|
||||
public const string SystemdMissing = "systemd.missing";
|
||||
public const string SshdMissing = "sshd.missing";
|
||||
public const string NftablesMissing = "nftables.missing";
|
||||
public const string WireGuardMissing = "wireguard.missing";
|
||||
public const string AptMissing = "apt.missing";
|
||||
public const string ArchitectureUnsupported = "architecture.unsupported";
|
||||
}
|
||||
|
||||
public sealed record ProvisioningJob(
|
||||
string Id,
|
||||
string NodeId,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ namespace ServerMonitorManager.Core;
|
|||
[JsonSerializable(typeof(ProvisioningPreflightResult))]
|
||||
[JsonSerializable(typeof(ProvisioningPreflightReportRequest))]
|
||||
[JsonSerializable(typeof(NodePreflightFacts))]
|
||||
[JsonSerializable(typeof(PreflightDesiredRequirements))]
|
||||
[JsonSerializable(typeof(PreflightDesiredStateUpdateRequest))]
|
||||
[JsonSerializable(typeof(NodePreflightDesiredState))]
|
||||
[JsonSerializable(typeof(PreflightDriftAssessment))]
|
||||
[JsonSerializable(typeof(ProvisioningJob))]
|
||||
[JsonSerializable(typeof(ProvisioningJob[]))]
|
||||
[JsonSerializable(typeof(ProvisioningEvent))]
|
||||
|
|
|
|||
|
|
@ -216,6 +216,77 @@ public sealed class ControlApiTests : IAsyncDisposable
|
|||
.ReadFromJsonAsync<ServerMonitorManager.Core.NodePreflightFacts>(cancellationToken);
|
||||
Assert.Equal(created.Id, stored!.SourceJobId);
|
||||
|
||||
var desiredRequest = new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
desired = new
|
||||
{
|
||||
requireSystemd = true,
|
||||
requireSshd = true,
|
||||
requireNftables = true,
|
||||
requireWireGuard = true,
|
||||
requireApt = true,
|
||||
allowedArchitectures = new[] { "x64", "arm64" }
|
||||
},
|
||||
auditReason = "Detect missing host capabilities",
|
||||
idempotencyKey = Guid.NewGuid().ToString()
|
||||
};
|
||||
using var desiredResponse = await operatorClient.PutAsJsonAsync(
|
||||
$"/api/v1/control/agents/{nodeId}/desired/preflight",
|
||||
desiredRequest,
|
||||
cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, desiredResponse.StatusCode);
|
||||
var desired = await desiredResponse.Content
|
||||
.ReadFromJsonAsync<ServerMonitorManager.Core.NodePreflightDesiredState>(cancellationToken);
|
||||
Assert.Equal(1, desired!.Version);
|
||||
using var desiredReplayResponse = await operatorClient.PutAsJsonAsync(
|
||||
$"/api/v1/control/agents/{nodeId}/desired/preflight",
|
||||
desiredRequest,
|
||||
cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, desiredReplayResponse.StatusCode);
|
||||
var desiredReplay = await desiredReplayResponse.Content
|
||||
.ReadFromJsonAsync<ServerMonitorManager.Core.NodePreflightDesiredState>(cancellationToken);
|
||||
Assert.Equal(desired.UpdatedAt, desiredReplay!.UpdatedAt);
|
||||
|
||||
using var driftResponse = await operatorClient.GetAsync(
|
||||
$"/api/v1/control/agents/{nodeId}/drift/preflight", cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, driftResponse.StatusCode);
|
||||
var drift = await driftResponse.Content
|
||||
.ReadFromJsonAsync<ServerMonitorManager.Core.PreflightDriftAssessment>(cancellationToken);
|
||||
Assert.Equal("Drifted", drift!.Status);
|
||||
Assert.Equal(["wireguard.missing"], drift.DriftCodes);
|
||||
|
||||
var synchronizedDesiredRequest = new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
desired = new
|
||||
{
|
||||
requireSystemd = true,
|
||||
requireSshd = true,
|
||||
requireNftables = true,
|
||||
requireWireGuard = false,
|
||||
requireApt = true,
|
||||
allowedArchitectures = new[] { "x64", "arm64" }
|
||||
},
|
||||
auditReason = "Accept host without WireGuard tooling",
|
||||
idempotencyKey = Guid.NewGuid().ToString()
|
||||
};
|
||||
using var synchronizedDesiredResponse = await operatorClient.PutAsJsonAsync(
|
||||
$"/api/v1/control/agents/{nodeId}/desired/preflight",
|
||||
synchronizedDesiredRequest,
|
||||
cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, synchronizedDesiredResponse.StatusCode);
|
||||
var synchronizedDesired = await synchronizedDesiredResponse.Content
|
||||
.ReadFromJsonAsync<ServerMonitorManager.Core.NodePreflightDesiredState>(cancellationToken);
|
||||
Assert.Equal(2, synchronizedDesired!.Version);
|
||||
using var synchronizedDriftResponse = await operatorClient.GetAsync(
|
||||
$"/api/v1/control/agents/{nodeId}/drift/preflight", cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, synchronizedDriftResponse.StatusCode);
|
||||
var synchronizedDrift = await synchronizedDriftResponse.Content
|
||||
.ReadFromJsonAsync<ServerMonitorManager.Core.PreflightDriftAssessment>(cancellationToken);
|
||||
Assert.Equal("InSync", synchronizedDrift!.Status);
|
||||
Assert.Empty(synchronizedDrift.DriftCodes);
|
||||
|
||||
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(6L, (long)(await version.ExecuteScalarAsync(cancellationToken))!);
|
||||
Assert.Equal(7L, (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(6L, Convert.ToInt64(await version.ExecuteScalarAsync(TestContext.Current.CancellationToken)));
|
||||
Assert.Equal(7L, 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