fix(device-core): adopt legacy model profiles safely
This commit is contained in:
@@ -221,6 +221,11 @@ async function registerModelProfile(client, actor, command) {
|
|||||||
schemaArtifactRef: command.schemaArtifactRef,
|
schemaArtifactRef: command.schemaArtifactRef,
|
||||||
capabilities: command.capabilities,
|
capabilities: command.capabilities,
|
||||||
};
|
};
|
||||||
|
const existingProfile = await findOptionalModelProfileRegistration(
|
||||||
|
client,
|
||||||
|
command.profileRef,
|
||||||
|
);
|
||||||
|
const adoptsLegacyProfile = isLegacyMetadataOnlyProfile(existingProfile);
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`insert into device_model_profiles (
|
`insert into device_model_profiles (
|
||||||
profile_ref,
|
profile_ref,
|
||||||
@@ -237,6 +242,26 @@ async function registerModelProfile(client, actor, command) {
|
|||||||
lifecycle_state
|
lifecycle_state
|
||||||
) values ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, $12)
|
) values ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, $12)
|
||||||
on conflict (profile_ref) do update set
|
on conflict (profile_ref) do update set
|
||||||
|
adapter_version_id = case
|
||||||
|
when device_model_profiles.adapter_version_id is null
|
||||||
|
then excluded.adapter_version_id
|
||||||
|
else device_model_profiles.adapter_version_id
|
||||||
|
end,
|
||||||
|
schema_artifact_ref = case
|
||||||
|
when device_model_profiles.schema_artifact_ref is null
|
||||||
|
then excluded.schema_artifact_ref
|
||||||
|
else device_model_profiles.schema_artifact_ref
|
||||||
|
end,
|
||||||
|
profile_digest = case
|
||||||
|
when device_model_profiles.profile_digest is null
|
||||||
|
then excluded.profile_digest
|
||||||
|
else device_model_profiles.profile_digest
|
||||||
|
end,
|
||||||
|
capabilities = case
|
||||||
|
when cardinality(device_model_profiles.capabilities) = 0
|
||||||
|
then excluded.capabilities
|
||||||
|
else device_model_profiles.capabilities
|
||||||
|
end,
|
||||||
lifecycle_state = excluded.lifecycle_state,
|
lifecycle_state = excluded.lifecycle_state,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where device_model_profiles.schema_version = excluded.schema_version
|
where device_model_profiles.schema_version = excluded.schema_version
|
||||||
@@ -244,7 +269,9 @@ async function registerModelProfile(client, actor, command) {
|
|||||||
and device_model_profiles.model = excluded.model
|
and device_model_profiles.model = excluded.model
|
||||||
and device_model_profiles.device_type = excluded.device_type
|
and device_model_profiles.device_type = excluded.device_type
|
||||||
and device_model_profiles.protocol = excluded.protocol
|
and device_model_profiles.protocol = excluded.protocol
|
||||||
and device_model_profiles.profile = excluded.profile
|
and (
|
||||||
|
(
|
||||||
|
device_model_profiles.profile = excluded.profile
|
||||||
and device_model_profiles.adapter_version_id = excluded.adapter_version_id
|
and device_model_profiles.adapter_version_id = excluded.adapter_version_id
|
||||||
and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref
|
and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref
|
||||||
and device_model_profiles.profile_digest = excluded.profile_digest
|
and device_model_profiles.profile_digest = excluded.profile_digest
|
||||||
@@ -260,6 +287,23 @@ async function registerModelProfile(client, actor, command) {
|
|||||||
and excluded.lifecycle_state = 'retired'
|
and excluded.lifecycle_state = 'retired'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
jsonb_typeof(device_model_profiles.profile) = 'object'
|
||||||
|
and device_model_profiles.profile ->> 'schemaVersion' = excluded.schema_version
|
||||||
|
and device_model_profiles.profile ->> 'profileRef' = excluded.profile_ref
|
||||||
|
and device_model_profiles.profile ->> 'vendor' = excluded.vendor
|
||||||
|
and device_model_profiles.profile ->> 'model' = excluded.model
|
||||||
|
and device_model_profiles.profile ->> 'deviceType' = excluded.device_type
|
||||||
|
and device_model_profiles.profile ->> 'protocol' = excluded.protocol
|
||||||
|
and device_model_profiles.adapter_version_id is null
|
||||||
|
and device_model_profiles.schema_artifact_ref is null
|
||||||
|
and device_model_profiles.profile_digest is null
|
||||||
|
and cardinality(device_model_profiles.capabilities) = 0
|
||||||
|
and device_model_profiles.lifecycle_state = 'active'
|
||||||
|
and excluded.lifecycle_state = 'draft'
|
||||||
|
)
|
||||||
|
)
|
||||||
returning profile_ref, schema_version, vendor, model, device_type,
|
returning profile_ref, schema_version, vendor, model, device_type,
|
||||||
protocol, adapter_version_id, schema_artifact_ref, profile_digest,
|
protocol, adapter_version_id, schema_artifact_ref, profile_digest,
|
||||||
capabilities, lifecycle_state, created_at, updated_at,
|
capabilities, lifecycle_state, created_at, updated_at,
|
||||||
@@ -286,6 +330,8 @@ async function registerModelProfile(client, actor, command) {
|
|||||||
await addAudit(client, {
|
await addAudit(client, {
|
||||||
eventType: row.created
|
eventType: row.created
|
||||||
? "model_profile.registered"
|
? "model_profile.registered"
|
||||||
|
: adoptsLegacyProfile
|
||||||
|
? "model_profile.registry_adopted"
|
||||||
: "model_profile.lifecycle_updated",
|
: "model_profile.lifecycle_updated",
|
||||||
actorRef: actor.userRef,
|
actorRef: actor.userRef,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -302,6 +348,28 @@ async function registerModelProfile(client, actor, command) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findOptionalModelProfileRegistration(client, profileRef) {
|
||||||
|
const result = await client.query(
|
||||||
|
`select profile_ref, adapter_version_id, schema_artifact_ref,
|
||||||
|
profile_digest, capabilities, lifecycle_state
|
||||||
|
from device_model_profiles
|
||||||
|
where profile_ref = $1
|
||||||
|
for update`,
|
||||||
|
[profileRef],
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLegacyMetadataOnlyProfile(profile) {
|
||||||
|
return profile != null
|
||||||
|
&& profile.adapter_version_id == null
|
||||||
|
&& profile.schema_artifact_ref == null
|
||||||
|
&& profile.profile_digest == null
|
||||||
|
&& Array.isArray(profile.capabilities)
|
||||||
|
&& profile.capabilities.length === 0
|
||||||
|
&& profile.lifecycle_state === "active";
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureEdge(client, actor, command) {
|
async function ensureEdge(client, actor, command) {
|
||||||
assertPlatformCatalogAuthority(actor);
|
assertPlatformCatalogAuthority(actor);
|
||||||
const channelProvided = command.channel !== undefined;
|
const channelProvided = command.channel !== undefined;
|
||||||
|
|||||||
+14
@@ -17,3 +17,17 @@ test("catalog, Edge and route upserts enforce irreversible lifecycle transitions
|
|||||||
assert.doesNotMatch(source, /device_(?:adapter_versions|model_profiles|edges|routes)\.lifecycle_state = 'retired'[\s\S]{0,160}excluded\.lifecycle_state = 'active'/);
|
assert.doesNotMatch(source, /device_(?:adapter_versions|model_profiles|edges|routes)\.lifecycle_state = 'retired'[\s\S]{0,160}excluded\.lifecycle_state = 'active'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("legacy model adoption is a one-way exact-identity registry transition", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../src/infrastructure-repository.mjs", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(source, /profile ->> 'schemaVersion' = excluded\.schema_version/);
|
||||||
|
assert.match(source, /profile ->> 'profileRef' = excluded\.profile_ref/);
|
||||||
|
assert.match(source, /profile ->> 'vendor' = excluded\.vendor/);
|
||||||
|
assert.match(source, /profile ->> 'model' = excluded\.model/);
|
||||||
|
assert.match(source, /profile ->> 'deviceType' = excluded\.device_type/);
|
||||||
|
assert.match(source, /profile ->> 'protocol' = excluded\.protocol/);
|
||||||
|
assert.match(source, /adapter_version_id is null[\s\S]*schema_artifact_ref is null[\s\S]*profile_digest is null[\s\S]*cardinality\(device_model_profiles\.capabilities\) = 0[\s\S]*lifecycle_state = 'active'[\s\S]*excluded\.lifecycle_state = 'draft'/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const now = new Date("2026-08-10T00:00:00.000Z");
|
|||||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||||
const edgeId = "22222222-2222-4222-8222-222222222222";
|
const edgeId = "22222222-2222-4222-8222-222222222222";
|
||||||
const routeId = "33333333-3333-4333-8333-333333333333";
|
const routeId = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const adapterPackageId = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const adapterVersionId = "55555555-5555-4555-8555-555555555555";
|
||||||
|
|
||||||
test("commits an owner-authorized generic Edge registration", async () => {
|
test("commits an owner-authorized generic Edge registration", async () => {
|
||||||
const actor = managementActor("owner");
|
const actor = managementActor("owner");
|
||||||
@@ -55,6 +57,147 @@ test("commits an owner-authorized generic Edge registration", async () => {
|
|||||||
assert.equal(client.released, true);
|
assert.equal(client.released, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("adopts a legacy metadata-only model profile into the versioned registry", async () => {
|
||||||
|
const actor = managementActor("owner");
|
||||||
|
const command = normalizeDeviceManagementCommand("model_profile.register", {
|
||||||
|
adapterVersionRef: `adapter-version:${adapterVersionId}`,
|
||||||
|
profileRef: "vendor.model.protocol.v1",
|
||||||
|
schemaVersion: "nodedc.device-model-profile.v1",
|
||||||
|
vendor: "Example Vendor",
|
||||||
|
model: "Model One",
|
||||||
|
deviceType: "tracker",
|
||||||
|
protocol: "GENERIC_TCP",
|
||||||
|
schemaArtifactRef: "artifact:model-profiles/vendor-model-v1",
|
||||||
|
profileDigest: `sha256:${"e".repeat(64)}`,
|
||||||
|
capabilities: ["telemetry.observe"],
|
||||||
|
});
|
||||||
|
const client = scriptedClient([
|
||||||
|
step("begin"),
|
||||||
|
step("insert into device_management_command_receipts", {
|
||||||
|
rows: [{ id: "receipt-profile" }],
|
||||||
|
}),
|
||||||
|
step("from device_adapter_versions av", {
|
||||||
|
rows: [{
|
||||||
|
id: adapterVersionId,
|
||||||
|
adapter_package_id: adapterPackageId,
|
||||||
|
version: "1.0.0",
|
||||||
|
runtime_package_ref: "artifact:device-adapters/vendor-model-1.0.0",
|
||||||
|
content_digest: `sha256:${"f".repeat(64)}`,
|
||||||
|
contract_version: "nodedc.device-adapter.v1",
|
||||||
|
capabilities: ["telemetry.observe"],
|
||||||
|
lifecycle_state: "active",
|
||||||
|
package_lifecycle_state: "active",
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
step("from device_model_profiles", {
|
||||||
|
rows: [{
|
||||||
|
profile_ref: command.profileRef,
|
||||||
|
adapter_version_id: null,
|
||||||
|
schema_artifact_ref: null,
|
||||||
|
profile_digest: null,
|
||||||
|
capabilities: [],
|
||||||
|
lifecycle_state: "active",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
step("insert into device_model_profiles", {
|
||||||
|
rows: [{
|
||||||
|
profile_ref: command.profileRef,
|
||||||
|
schema_version: command.schemaVersion,
|
||||||
|
vendor: command.vendor,
|
||||||
|
model: command.model,
|
||||||
|
device_type: command.deviceType,
|
||||||
|
protocol: command.protocol,
|
||||||
|
adapter_version_id: adapterVersionId,
|
||||||
|
schema_artifact_ref: command.schemaArtifactRef,
|
||||||
|
profile_digest: command.profileDigest,
|
||||||
|
capabilities: command.capabilities,
|
||||||
|
lifecycle_state: "draft",
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
created: false,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
step("insert into device_audit_events"),
|
||||||
|
step("update device_management_command_receipts"),
|
||||||
|
step("commit"),
|
||||||
|
]);
|
||||||
|
const repository = repositoryWithClient(client);
|
||||||
|
|
||||||
|
const result = await repository.executeManagementCommand(commandInput({
|
||||||
|
actor,
|
||||||
|
commandKind: "model_profile.register",
|
||||||
|
command,
|
||||||
|
digestCharacter: "e",
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(result.replayed, false);
|
||||||
|
assert.equal(result.result.created, false);
|
||||||
|
assert.equal(
|
||||||
|
result.result.modelProfile.adapterVersionRef,
|
||||||
|
`adapter-version:${adapterVersionId}`,
|
||||||
|
);
|
||||||
|
assert.equal(result.result.modelProfile.lifecycleState, "draft");
|
||||||
|
assert.equal(client.remaining(), 0);
|
||||||
|
assert.equal(client.released, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps non-legacy model profile identity conflicts fail-closed", async () => {
|
||||||
|
const actor = managementActor("owner");
|
||||||
|
const command = normalizeDeviceManagementCommand("model_profile.register", {
|
||||||
|
adapterVersionRef: `adapter-version:${adapterVersionId}`,
|
||||||
|
profileRef: "vendor.model.protocol.v1",
|
||||||
|
schemaVersion: "nodedc.device-model-profile.v1",
|
||||||
|
vendor: "Example Vendor",
|
||||||
|
model: "Model One",
|
||||||
|
deviceType: "tracker",
|
||||||
|
protocol: "GENERIC_TCP",
|
||||||
|
schemaArtifactRef: "artifact:model-profiles/vendor-model-v1",
|
||||||
|
profileDigest: `sha256:${"e".repeat(64)}`,
|
||||||
|
capabilities: ["telemetry.observe"],
|
||||||
|
});
|
||||||
|
const client = scriptedClient([
|
||||||
|
step("begin"),
|
||||||
|
step("insert into device_management_command_receipts", {
|
||||||
|
rows: [{ id: "receipt-profile-conflict" }],
|
||||||
|
}),
|
||||||
|
step("from device_adapter_versions av", {
|
||||||
|
rows: [{
|
||||||
|
id: adapterVersionId,
|
||||||
|
adapter_package_id: adapterPackageId,
|
||||||
|
lifecycle_state: "active",
|
||||||
|
package_lifecycle_state: "active",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
step("from device_model_profiles", {
|
||||||
|
rows: [{
|
||||||
|
profile_ref: command.profileRef,
|
||||||
|
adapter_version_id: null,
|
||||||
|
schema_artifact_ref: "artifact:legacy-but-partial",
|
||||||
|
profile_digest: null,
|
||||||
|
capabilities: [],
|
||||||
|
lifecycle_state: "active",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
step("insert into device_model_profiles"),
|
||||||
|
step("rollback"),
|
||||||
|
]);
|
||||||
|
const repository = repositoryWithClient(client);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
repository.executeManagementCommand(commandInput({
|
||||||
|
actor,
|
||||||
|
commandKind: "model_profile.register",
|
||||||
|
command,
|
||||||
|
digestCharacter: "f",
|
||||||
|
})),
|
||||||
|
/device_model_profile_identity_conflict/,
|
||||||
|
);
|
||||||
|
assert.equal(client.remaining(), 0);
|
||||||
|
assert.equal(client.released, true);
|
||||||
|
});
|
||||||
|
|
||||||
test("denies project route mutation without an explicit project grant", async () => {
|
test("denies project route mutation without an explicit project grant", async () => {
|
||||||
const actor = managementActor("owner");
|
const actor = managementActor("owner");
|
||||||
const command = normalizeDeviceManagementCommand("route.ensure", {
|
const command = normalizeDeviceManagementCommand("route.ensure", {
|
||||||
|
|||||||
Reference in New Issue
Block a user