feat(platform): complete the Gelios external data loop
This commit is contained in:
parent
def9a24e0d
commit
8a7465cf0e
|
|
@ -172,6 +172,28 @@ runtime and descriptor equality. The ordinary overlay backup is the automatic
|
||||||
rollback source; rollback restores the predecessor gateway and descriptor
|
rollback source; rollback restores the predecessor gateway and descriptor
|
||||||
together before recreating the same backend service.
|
together before recreating the same backend service.
|
||||||
|
|
||||||
|
The successor autonomy/provider-v5 slice keeps MCP authority explicit without
|
||||||
|
turning every write into a second permission dialogue. MCP tool availability is
|
||||||
|
the capability boundary, while the user's current objective is the intent
|
||||||
|
boundary; Engine L2 may act autonomously only inside their intersection. A
|
||||||
|
graph `plan` remains a mandatory machine barrier whose target, diff, revision
|
||||||
|
and blockers are inspected by the agent. It is not a repeated approval prompt
|
||||||
|
after an implementation objective is already authorized. Retries must add new
|
||||||
|
evidence or change the attempted variant, and three identical failures without
|
||||||
|
new evidence or state change are a critical stop. The slice also advances the
|
||||||
|
installer to `0.1.6` and adds `gelios.provider.v5 ->
|
||||||
|
fleet.positions.current.v4` beside the immutable v4/v3 rollback line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs \
|
||||||
|
20260720-004
|
||||||
|
```
|
||||||
|
|
||||||
|
Its seven-entry allowlist recreates only `nodedc-backend`; n8n, L1, databases,
|
||||||
|
credential values and the node-intelligence image remain outside the change.
|
||||||
|
The runner accepts both the exact target and the exact predecessor after an
|
||||||
|
automatic rollback, and rejects every mixed state.
|
||||||
|
|
||||||
For `platform` artifacts, the allowlist includes the versioned Ontology Core,
|
For `platform` artifacts, the allowlist includes the versioned Ontology Core,
|
||||||
the frozen legacy Gelios compatibility service, and the provider-neutral
|
the frozen legacy Gelios compatibility service, and the provider-neutral
|
||||||
External Data Plane sources. The Gelios service remains reproducible only to
|
External Data Plane sources. The Gelios service remains reproducible only to
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const workspaceRoot = resolve(here, "../../..");
|
||||||
|
const engineRoot = resolve(
|
||||||
|
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||||
|
);
|
||||||
|
const artifactRoot = resolve(
|
||||||
|
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||||
|
);
|
||||||
|
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^engine-composite-provider-v4-\d{8}-\d{3}$/.test(patchId)) {
|
||||||
|
throw new Error(
|
||||||
|
"usage: build-engine-composite-provider-v4-artifact.mjs " +
|
||||||
|
"<engine-composite-provider-v4-YYYYMMDD-NNN>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSha256 = Object.freeze({
|
||||||
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
|
||||||
|
"9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a",
|
||||||
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
|
||||||
|
"689c6fbf695e582d983159973a21142787d1b19bb1d343da4c62c03092ac291f",
|
||||||
|
"nodedc-source/server/dataProductPublishGrant/service.js":
|
||||||
|
"83f045f4e0f332644310172ed51bb652d808bf04155b11c02e46bdffc95f7220",
|
||||||
|
"nodedc-source/server/dataProductPublishGrant/store.js":
|
||||||
|
"98662da6acd0489a9cae4b726eb61ce89d9b2c788b2ea95433e9c4f02930c095",
|
||||||
|
});
|
||||||
|
const entries = Object.freeze(Object.keys(targetSha256));
|
||||||
|
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||||
|
|
||||||
|
await assertFresh(artifact);
|
||||||
|
await assertExactSources();
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-composite-provider-v4-"));
|
||||||
|
try {
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
for (const relativePath of entries) {
|
||||||
|
const source = join(engineRoot, relativePath);
|
||||||
|
const destination = join(payload, relativePath);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await copyFile(source, destination, 0);
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
join(stage, "manifest.env"),
|
||||||
|
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||||
|
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||||
|
);
|
||||||
|
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||||
|
encoding: "utf8",
|
||||||
|
flag: "wx",
|
||||||
|
mode: 0o644,
|
||||||
|
});
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
patchId,
|
||||||
|
artifact,
|
||||||
|
sha256: digest(await readFile(artifact)),
|
||||||
|
entries,
|
||||||
|
targetSha256,
|
||||||
|
services: ["nodedc-backend"],
|
||||||
|
transition: "exact-v3-to-v4",
|
||||||
|
providerPackage: "gelios.provider.v4",
|
||||||
|
capabilities: [
|
||||||
|
"gelios.monitoring_config.current.read",
|
||||||
|
"gelios.units.current.read",
|
||||||
|
],
|
||||||
|
dataProductId: "fleet.positions.current.v3",
|
||||||
|
credentialValues: "preserved",
|
||||||
|
untouched: ["n8n", "L1 graph", "Engine UI", "databases"],
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertExactSources() {
|
||||||
|
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||||
|
const path = join(engineRoot, relativePath);
|
||||||
|
const info = await lstat(path);
|
||||||
|
if (!info.isFile() || info.isSymbolicLink()) {
|
||||||
|
throw new Error(`engine_composite_provider_source_unsafe:${relativePath}`);
|
||||||
|
}
|
||||||
|
const actual = digest(await readFile(path));
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
`engine_composite_provider_target_mismatch:${relativePath}:` +
|
||||||
|
`expected=${expected}:actual=${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8"));
|
||||||
|
const provider = catalog?.packages?.[0];
|
||||||
|
const capabilityIds = provider?.capabilities?.map((item) => item.id);
|
||||||
|
const requestUrls = provider?.capabilities?.map((item) => item.request?.url);
|
||||||
|
if (
|
||||||
|
catalog?.schemaVersion !== "nodedc.engine.provider-security-catalog/v1" ||
|
||||||
|
provider?.id !== "gelios.provider.v4" ||
|
||||||
|
provider?.version !== "4.0.0" ||
|
||||||
|
provider?.providerCredential?.credentialType !== "httpBearerAuth" ||
|
||||||
|
JSON.stringify(capabilityIds) !== JSON.stringify([
|
||||||
|
"gelios.monitoring_config.current.read",
|
||||||
|
"gelios.units.current.read",
|
||||||
|
]) ||
|
||||||
|
JSON.stringify(requestUrls) !== JSON.stringify([
|
||||||
|
"https://api.geliospro.com/api/v1/users/me/monitoring-config",
|
||||||
|
"https://api.geliospro.com/api/v1/units?incltrip=true",
|
||||||
|
]) ||
|
||||||
|
provider?.capabilities?.some(
|
||||||
|
(item) => item.dataProductIds?.length !== 1 ||
|
||||||
|
item.dataProductIds[0] !== "fleet.positions.current.v3",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("engine_composite_provider_catalog_projection_mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFresh(path) {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("artifact_already_exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'xb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value) {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ const previouslyIssuedPatchIds = new Set([
|
||||||
'engine-data-product-publish-grant-20260717-001',
|
'engine-data-product-publish-grant-20260717-001',
|
||||||
'engine-data-product-publish-grant-20260717-002',
|
'engine-data-product-publish-grant-20260717-002',
|
||||||
])
|
])
|
||||||
|
const compositeProviderCatalogTargetSha256 = '9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a'
|
||||||
|
|
||||||
if (extra.length || !/^engine-data-product-publish-grant-\d{8}-\d{3}$/.test(patchId)) {
|
if (extra.length || !/^engine-data-product-publish-grant-\d{8}-\d{3}$/.test(patchId)) {
|
||||||
throw new Error('usage: build-engine-data-product-publish-grant-artifact.mjs <fresh-patch-id>')
|
throw new Error('usage: build-engine-data-product-publish-grant-artifact.mjs <fresh-patch-id>')
|
||||||
|
|
@ -109,6 +110,13 @@ try {
|
||||||
'nodedc-source/dist',
|
'nodedc-source/dist',
|
||||||
'nodedc-source/server/tests',
|
'nodedc-source/server/tests',
|
||||||
],
|
],
|
||||||
|
providerPackage: 'gelios.provider.v4',
|
||||||
|
providerRequests: [
|
||||||
|
'https://api.geliospro.com/api/v1/users/me/monitoring-config',
|
||||||
|
'https://api.geliospro.com/api/v1/units?incltrip=true',
|
||||||
|
],
|
||||||
|
dataProductId: 'fleet.positions.current.v3',
|
||||||
|
credentialValues: 'preserved',
|
||||||
preservedPredecessor: Object.keys(credentialSinkPredecessorSha256),
|
preservedPredecessor: Object.keys(credentialSinkPredecessorSha256),
|
||||||
}, null, 2))
|
}, null, 2))
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -116,10 +124,14 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertSourceBoundary() {
|
async function assertSourceBoundary() {
|
||||||
for (const [relativePath, expectedSha256] of Object.entries(credentialSinkPredecessorSha256)) {
|
// These files are deliberately excluded from the artifact. Their exact
|
||||||
const bytes = await readFile(join(engineRoot, relativePath))
|
// installed predecessor is verified by the root-owned runner during plan
|
||||||
if (digest(bytes) !== expectedSha256) {
|
// and apply; the local builder only proves that it cannot package a link or
|
||||||
throw new Error(`engine_credential_sink_predecessor_drift:${relativePath}`)
|
// another unsafe filesystem object in their place.
|
||||||
|
for (const relativePath of Object.keys(credentialSinkPredecessorSha256)) {
|
||||||
|
const info = await lstat(join(engineRoot, relativePath))
|
||||||
|
if (!info.isFile() || info.isSymbolicLink()) {
|
||||||
|
throw new Error(`engine_credential_sink_predecessor_unsafe:${relativePath}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const runtimeOverridePath = join(
|
const runtimeOverridePath = join(
|
||||||
|
|
@ -129,6 +141,13 @@ async function assertSourceBoundary() {
|
||||||
if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) {
|
if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) {
|
||||||
throw new Error('engine_publish_grant_runtime_override_mismatch')
|
throw new Error('engine_publish_grant_runtime_override_mismatch')
|
||||||
}
|
}
|
||||||
|
const catalogPath = join(
|
||||||
|
engineRoot,
|
||||||
|
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
|
||||||
|
)
|
||||||
|
if (digest(await readFile(catalogPath)) !== compositeProviderCatalogTargetSha256) {
|
||||||
|
throw new Error('engine_composite_provider_catalog_target_mismatch')
|
||||||
|
}
|
||||||
|
|
||||||
const indexSource = await readFile(join(engineRoot, 'nodedc-source/server/index.js'), 'utf8')
|
const indexSource = await readFile(join(engineRoot, 'nodedc-source/server/index.js'), 'utf8')
|
||||||
if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) {
|
if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) {
|
||||||
|
|
@ -175,6 +194,32 @@ async function assertSourceBoundary() {
|
||||||
if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) {
|
if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) {
|
||||||
throw new Error('engine_publish_grant_source_set_mismatch')
|
throw new Error('engine_publish_grant_source_set_mismatch')
|
||||||
}
|
}
|
||||||
|
const providerCatalogSource = await readFile(join(grantDirectory, 'providerCatalog.js'), 'utf8')
|
||||||
|
const grantServiceSource = await readFile(join(grantDirectory, 'service.js'), 'utf8')
|
||||||
|
const grantStoreSource = await readFile(join(grantDirectory, 'store.js'), 'utf8')
|
||||||
|
for (const marker of [
|
||||||
|
'function capabilityRequests(capability)',
|
||||||
|
'function exactHttpRequestUrl(n8n)',
|
||||||
|
'capabilityIds',
|
||||||
|
'providerRequestNodeIds',
|
||||||
|
'providerCredentialRefs.size !== 1',
|
||||||
|
]) {
|
||||||
|
if (!providerCatalogSource.includes(marker)) {
|
||||||
|
throw new Error(`engine_composite_provider_resolver_missing:${marker}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!grantServiceSource.includes('capabilities: descriptor.capabilityIds')
|
||||||
|
|| !grantServiceSource.includes('providerRequestNodeIds: descriptor.providerRequestNodeIds')
|
||||||
|
) {
|
||||||
|
throw new Error('engine_composite_provider_plan_projection_missing')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!grantStoreSource.includes('Array.isArray(raw.capabilityIds)')
|
||||||
|
|| !grantStoreSource.includes('Array.isArray(raw.providerRequestNodeIds)')
|
||||||
|
) {
|
||||||
|
throw new Error('engine_composite_provider_store_projection_missing')
|
||||||
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const source = resolve(engineRoot, entry)
|
const source = resolve(engineRoot, entry)
|
||||||
const info = await lstat(source)
|
const info = await lstat(source)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const platformRoot = resolve(here, '../..')
|
||||||
|
const engineRoot = resolve(
|
||||||
|
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, '../NODEDC_ENGINE_INFRA'),
|
||||||
|
)
|
||||||
|
const canonicalArtifactRoot = resolve(here, '../deploy-artifacts')
|
||||||
|
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot)
|
||||||
|
const [transitionId = '20260720-004', ...extra] = process.argv.slice(2)
|
||||||
|
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
|
||||||
|
throw new Error('usage: build-engine-mcp-autonomy-provider-v5-artifact.mjs [YYYYMMDD-NNN]')
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `engine-mcp-autonomy-provider-v5-${transitionId}`
|
||||||
|
const target = join(artifactRoot, `nodedc-${id}.tgz`)
|
||||||
|
const predecessorArtifact = join(
|
||||||
|
canonicalArtifactRoot,
|
||||||
|
'nodedc-engine-mcp-control-plane-20260718-003.tgz',
|
||||||
|
)
|
||||||
|
const predecessorArtifactSha256 = '249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba'
|
||||||
|
const descriptorRel = 'nodedc-source/services/node-intelligence/activation.json'
|
||||||
|
const gatewayRel = 'nodedc-source/server/routes/engineAgentGateway.js'
|
||||||
|
const files = [
|
||||||
|
'nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs',
|
||||||
|
'nodedc-source/server/assets/engine-agent-npm/package.json',
|
||||||
|
'nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz',
|
||||||
|
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
|
||||||
|
'nodedc-source/server/engineAgents/store.js',
|
||||||
|
gatewayRel,
|
||||||
|
descriptorRel,
|
||||||
|
]
|
||||||
|
const expectedSha256 = new Map([
|
||||||
|
[files[0], 'c15c9da4f90f44a4e9e12f3683127e906d614fb98e562faa0c939505c973e074'],
|
||||||
|
[files[1], '2ca8dcab0fa04bb1b21add1f75a9be61d5aa97753443ee1917302b4e0da5780a'],
|
||||||
|
[files[2], 'd007a81cb4e4af569b3c54d3869b0f60b5597e3b531bff232145fa1851d8572a'],
|
||||||
|
[files[3], '63e0741646197f0b1b3c64a4095e1bc8fb3a95ee6caf20b0293f89d869c9e620'],
|
||||||
|
[files[4], '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'],
|
||||||
|
[files[5], '5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c'],
|
||||||
|
])
|
||||||
|
|
||||||
|
await assertFresh(target)
|
||||||
|
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, 'predecessor MCP artifact')
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-mcp-autonomy-provider-v5-'))
|
||||||
|
const payload = join(stage, 'payload')
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true })
|
||||||
|
for (const rel of files.slice(0, -1)) {
|
||||||
|
const source = join(engineRoot, rel)
|
||||||
|
const stat = await lstat(source)
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`)
|
||||||
|
assertSha(await readFile(source), expectedSha256.get(rel), rel)
|
||||||
|
await mkdir(dirname(join(payload, rel)), { recursive: true })
|
||||||
|
await cp(source, join(payload, rel), { force: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = JSON.parse(extractMember(
|
||||||
|
predecessorArtifact,
|
||||||
|
`payload/${descriptorRel}`,
|
||||||
|
))
|
||||||
|
if (
|
||||||
|
descriptor?.action !== 'activate'
|
||||||
|
|| descriptor?.releaseId !== '2.33.2-974a9fb3492f'
|
||||||
|
|| descriptor?.source?.gatewaySha256 !== '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15'
|
||||||
|
|| descriptor?.source?.upstreamProjectionSha256 !== '761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131'
|
||||||
|
) throw new Error('mcp_autonomy_predecessor_descriptor_mismatch')
|
||||||
|
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel)
|
||||||
|
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true })
|
||||||
|
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
|
||||||
|
|
||||||
|
await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, 'utf8')
|
||||||
|
await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8')
|
||||||
|
await mkdir(artifactRoot, { recursive: true })
|
||||||
|
run('python3', ['-c', canonicalTarScript(), target, stage])
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
id,
|
||||||
|
artifact: target,
|
||||||
|
artifactSha256: sha(await readFile(target)),
|
||||||
|
services: ['nodedc-backend'],
|
||||||
|
mcpVersion: '0.6.0',
|
||||||
|
installerVersion: '0.1.6',
|
||||||
|
authority: 'mcp-capability-intersect-user-objective',
|
||||||
|
retryBoundary: 'three-identical-failures-without-new-evidence',
|
||||||
|
providerPackages: ['gelios.provider.v4', 'gelios.provider.v5'],
|
||||||
|
targetDataProduct: 'fleet.positions.current.v4',
|
||||||
|
preserved: ['n8n', 'L1', 'node-intelligence image', 'databases', 'credential values'],
|
||||||
|
files,
|
||||||
|
}, null, 2))
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFresh(path) {
|
||||||
|
try {
|
||||||
|
await lstat(path)
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw new Error('artifact_already_exists')
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMember(archive, member) {
|
||||||
|
const script = [
|
||||||
|
'import pathlib,sys,tarfile',
|
||||||
|
'p=pathlib.Path(sys.argv[1]); name=sys.argv[2]',
|
||||||
|
"with tarfile.open(p,'r:gz') as t:",
|
||||||
|
' m=t.getmember(name)',
|
||||||
|
" if not m.isfile(): raise SystemExit('member-not-file')",
|
||||||
|
' f=t.extractfile(m)',
|
||||||
|
" if f is None: raise SystemExit('member-unreadable')",
|
||||||
|
' sys.stdout.buffer.write(f.read())',
|
||||||
|
].join('\n')
|
||||||
|
return run('python3', ['-c', script, archive, member]).stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
'import gzip,io,pathlib,sys,tarfile',
|
||||||
|
'root=pathlib.Path(sys.argv[2])',
|
||||||
|
"with open(sys.argv[1],'xb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
' for x in paths:',
|
||||||
|
' info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())',
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSha(bytes, expected, label) {
|
||||||
|
const actual = sha(bytes)
|
||||||
|
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(' ', '_')}_sha256_mismatch:${actual}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha(bytes) {
|
||||||
|
return createHash('sha256').update(bytes).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const workspaceRoot = resolve(here, "../../..");
|
||||||
|
const engineRoot = resolve(
|
||||||
|
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||||
|
);
|
||||||
|
const artifactRoot = resolve(
|
||||||
|
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||||
|
);
|
||||||
|
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^engine-provider-authority-diagnostics-\d{8}-\d{3}$/.test(patchId)) {
|
||||||
|
throw new Error(
|
||||||
|
"usage: build-engine-provider-authority-diagnostics-artifact.mjs " +
|
||||||
|
"<engine-provider-authority-diagnostics-YYYYMMDD-NNN>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSha256 = Object.freeze({
|
||||||
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
|
||||||
|
"1a14299167ebe17efd677fa3c59b84c80e12d6846bac6f40f9bcae0729ab22c6",
|
||||||
|
});
|
||||||
|
const entries = Object.freeze(Object.keys(targetSha256));
|
||||||
|
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||||
|
|
||||||
|
await assertFresh(artifact);
|
||||||
|
await assertExactSources();
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-authority-diagnostics-"));
|
||||||
|
try {
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
for (const relativePath of entries) {
|
||||||
|
const destination = join(payload, relativePath);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await copyFile(join(engineRoot, relativePath), destination);
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
join(stage, "manifest.env"),
|
||||||
|
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||||
|
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||||
|
);
|
||||||
|
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||||
|
encoding: "utf8",
|
||||||
|
flag: "wx",
|
||||||
|
mode: 0o644,
|
||||||
|
});
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
patchId,
|
||||||
|
artifact,
|
||||||
|
sha256: digest(await readFile(artifact)),
|
||||||
|
entries,
|
||||||
|
targetSha256,
|
||||||
|
services: ["nodedc-backend"],
|
||||||
|
transition: "exact-safe-provider-authority-reason-codes",
|
||||||
|
providerPackage: "gelios.provider.v4",
|
||||||
|
dataProductId: "fleet.positions.current.v3",
|
||||||
|
credentialValues: "preserved",
|
||||||
|
untouched: ["L2 graph", "n8n", "L1", "Engine UI", "databases"],
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertExactSources() {
|
||||||
|
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||||
|
const sourcePath = join(engineRoot, relativePath);
|
||||||
|
const info = await lstat(sourcePath);
|
||||||
|
if (!info.isFile() || info.isSymbolicLink()) {
|
||||||
|
throw new Error(`engine_provider_authority_diagnostics_source_unsafe:${relativePath}`);
|
||||||
|
}
|
||||||
|
const actual = digest(await readFile(sourcePath));
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
`engine_provider_authority_diagnostics_target_mismatch:${relativePath}:` +
|
||||||
|
`expected=${expected}:actual=${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const source = await readFile(sourcePath, "utf8");
|
||||||
|
for (const marker of [
|
||||||
|
"publish_grant_provider_request_not_exact",
|
||||||
|
"publish_grant_provider_credential_binding_invalid",
|
||||||
|
"publish_grant_provider_transport_policy_not_exact",
|
||||||
|
"publish_grant_provider_request_path_unreachable",
|
||||||
|
"publish_grant_provider_credential_identity_mismatch",
|
||||||
|
]) {
|
||||||
|
if (!source.includes(marker)) {
|
||||||
|
throw new Error(`engine_provider_authority_diagnostics_marker_missing:${marker}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFresh(path) {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("artifact_already_exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'xb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value) {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const workspaceRoot = resolve(here, "../../..");
|
||||||
|
const engineRoot = resolve(
|
||||||
|
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||||
|
);
|
||||||
|
const artifactRoot = resolve(
|
||||||
|
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||||
|
);
|
||||||
|
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^engine-provider-rotating-slot-\d{8}-\d{3}$/.test(patchId)) {
|
||||||
|
throw new Error(
|
||||||
|
"usage: build-engine-provider-rotating-slot-artifact.mjs " +
|
||||||
|
"<engine-provider-rotating-slot-YYYYMMDD-NNN>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSha256 = Object.freeze({
|
||||||
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
|
||||||
|
"17f3e368f3264cbbd708965c9e1fd735aa974f15a1383bf88cd6d14a43dbf32d",
|
||||||
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
|
||||||
|
"f6cf5f4de9e57f87fb904e2136a9f34d494e1ffc289aec3ed9ed788b5583a062",
|
||||||
|
});
|
||||||
|
const entries = Object.freeze(Object.keys(targetSha256));
|
||||||
|
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||||
|
|
||||||
|
await assertFresh(artifact);
|
||||||
|
await assertExactSources();
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-rotating-slot-"));
|
||||||
|
try {
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
for (const relativePath of entries) {
|
||||||
|
const destination = join(payload, relativePath);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await copyFile(join(engineRoot, relativePath), destination);
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
join(stage, "manifest.env"),
|
||||||
|
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||||
|
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||||
|
);
|
||||||
|
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||||
|
encoding: "utf8",
|
||||||
|
flag: "wx",
|
||||||
|
mode: 0o644,
|
||||||
|
});
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
patchId,
|
||||||
|
artifact,
|
||||||
|
sha256: digest(await readFile(artifact)),
|
||||||
|
entries,
|
||||||
|
targetSha256,
|
||||||
|
services: ["nodedc-backend"],
|
||||||
|
transition: "exact-active-credential-slot-alignment",
|
||||||
|
providerPackage: "gelios.provider.v4",
|
||||||
|
authModeId: "gelios.rest-rotating-bearer.v3",
|
||||||
|
credentialSlot: "ndcProviderRotatingAccessApi",
|
||||||
|
credentialValues: "preserved",
|
||||||
|
untouched: ["L2 graph", "n8n", "L1", "Engine UI", "databases"],
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertExactSources() {
|
||||||
|
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||||
|
const path = join(engineRoot, relativePath);
|
||||||
|
const info = await lstat(path);
|
||||||
|
if (!info.isFile() || info.isSymbolicLink()) {
|
||||||
|
throw new Error(`engine_provider_rotating_slot_source_unsafe:${relativePath}`);
|
||||||
|
}
|
||||||
|
const actual = digest(await readFile(path));
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
`engine_provider_rotating_slot_target_mismatch:${relativePath}:` +
|
||||||
|
`expected=${expected}:actual=${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8"));
|
||||||
|
const provider = catalog?.packages?.[0];
|
||||||
|
if (
|
||||||
|
provider?.id !== "gelios.provider.v4" ||
|
||||||
|
provider?.providerCredential?.authModeId !== "gelios.rest-rotating-bearer.v3" ||
|
||||||
|
provider?.providerCredential?.credentialType !== "ndcProviderRotatingAccessApi"
|
||||||
|
) {
|
||||||
|
throw new Error("engine_provider_rotating_slot_catalog_projection_mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFresh(path) {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("artifact_already_exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'xb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value) {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(here, "../..");
|
||||||
|
const artifactRoot = resolve(
|
||||||
|
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||||
|
);
|
||||||
|
const [transitionId = "20260719-006", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
|
||||||
|
throw new Error("usage: build-engine-provider-security-catalog-artifact.mjs [YYYYMMDD-NNN]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `engine-provider-security-catalog-${transitionId}`;
|
||||||
|
const target = join(artifactRoot, `nodedc-${id}.tgz`);
|
||||||
|
const file = "nodedc-source/server/assets/provider-packages/v1/catalog.json";
|
||||||
|
const expectedSha256 = "992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428";
|
||||||
|
const catalogBytes = Buffer.from(`${JSON.stringify({
|
||||||
|
schemaVersion: "nodedc.engine.provider-security-catalog/v1",
|
||||||
|
packages: [{
|
||||||
|
id: "gelios.provider.v3",
|
||||||
|
version: "3.0.0",
|
||||||
|
providerId: "gelios",
|
||||||
|
providerCredential: {
|
||||||
|
authModeId: "gelios.rest-rotating-bearer.v3",
|
||||||
|
credentialType: "httpBearerAuth",
|
||||||
|
},
|
||||||
|
capabilities: [{
|
||||||
|
id: "gelios.units.current.read",
|
||||||
|
classification: "read",
|
||||||
|
status: "implemented",
|
||||||
|
request: {
|
||||||
|
method: "GET",
|
||||||
|
url: "https://api.geliospro.com/api/v1/units",
|
||||||
|
},
|
||||||
|
dataProductIds: ["fleet.positions.current.v2"],
|
||||||
|
}],
|
||||||
|
publisher: {
|
||||||
|
nodeType: "n8n-nodes-ndc.ndcDataProductPublish",
|
||||||
|
credentialType: "ndcDataProductWriterApi",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
await assertFresh(target);
|
||||||
|
if (sha(catalogBytes) !== expectedSha256) throw new Error(`pinned_catalog_sha256_mismatch:${file}`);
|
||||||
|
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-security-catalog-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(join(payload, file)), { recursive: true });
|
||||||
|
await writeFile(join(payload, file), catalogBytes, { flag: "wx", mode: 0o644 });
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${file}\n`, "utf8");
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
run("python3", ["-c", canonicalTarScript(), target, stage]);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
id,
|
||||||
|
artifact: target,
|
||||||
|
artifactSha256: sha(await readFile(target)),
|
||||||
|
services: ["nodedc-backend"],
|
||||||
|
providerPackage: "gelios.provider.v3",
|
||||||
|
providerCredential: "httpBearerAuth",
|
||||||
|
endpoint: "https://api.geliospro.com/api/v1/units",
|
||||||
|
dataProductId: "fleet.positions.current.v2",
|
||||||
|
preserved: ["n8n", "L1 graph", "Engine UI", "databases", "provider credential values"],
|
||||||
|
files: [file],
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFresh(path) {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("artifact_already_exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha(value) {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const workspaceRoot = resolve(here, "../../..");
|
||||||
|
const engineRoot = resolve(
|
||||||
|
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||||
|
);
|
||||||
|
const artifactRoot = resolve(
|
||||||
|
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||||
|
);
|
||||||
|
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^engine-provider-target-host-policy-\d{8}-\d{3}$/.test(patchId)) {
|
||||||
|
throw new Error(
|
||||||
|
"usage: build-engine-provider-target-host-policy-artifact.mjs " +
|
||||||
|
"<engine-provider-target-host-policy-YYYYMMDD-NNN>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSha256 = Object.freeze({
|
||||||
|
"nodedc-source/server/routes/n8n.js":
|
||||||
|
"9bc3638e271102abec91bb80413329befb89d60c0e4dc0548f0dd11e93220d0a",
|
||||||
|
});
|
||||||
|
const entries = Object.freeze(Object.keys(targetSha256));
|
||||||
|
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||||
|
|
||||||
|
await assertFresh(artifact);
|
||||||
|
await assertExactSources();
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-target-host-policy-"));
|
||||||
|
try {
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
for (const relativePath of entries) {
|
||||||
|
const destination = join(payload, relativePath);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await copyFile(join(engineRoot, relativePath), destination);
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
join(stage, "manifest.env"),
|
||||||
|
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||||
|
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||||
|
);
|
||||||
|
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||||
|
encoding: "utf8",
|
||||||
|
flag: "wx",
|
||||||
|
mode: 0o644,
|
||||||
|
});
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
patchId,
|
||||||
|
artifact,
|
||||||
|
sha256: digest(await readFile(artifact)),
|
||||||
|
entries,
|
||||||
|
targetSha256,
|
||||||
|
services: ["nodedc-backend"],
|
||||||
|
transition: "exact-provider-literal-target-host",
|
||||||
|
providerPackage: "gelios.provider.v4",
|
||||||
|
dataProductId: "fleet.positions.current.v3",
|
||||||
|
credentialValues: "preserved",
|
||||||
|
untouched: ["L2 graph", "n8n", "L1", "Engine UI", "databases"],
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertExactSources() {
|
||||||
|
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||||
|
const sourcePath = join(engineRoot, relativePath);
|
||||||
|
const info = await lstat(sourcePath);
|
||||||
|
if (!info.isFile() || info.isSymbolicLink()) {
|
||||||
|
throw new Error(`engine_provider_target_host_policy_source_unsafe:${relativePath}`);
|
||||||
|
}
|
||||||
|
const actual = digest(await readFile(sourcePath));
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
`engine_provider_target_host_policy_target_mismatch:${relativePath}:` +
|
||||||
|
`expected=${expected}:actual=${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const source = await readFile(sourcePath, "utf8");
|
||||||
|
for (const marker of [
|
||||||
|
"const eligibleHosts = explicitHosts.length",
|
||||||
|
"allowedHosts: [targetHost]",
|
||||||
|
"credential_host_not_allowed",
|
||||||
|
]) {
|
||||||
|
if (!source.includes(marker)) {
|
||||||
|
throw new Error(`engine_provider_target_host_policy_marker_missing:${marker}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFresh(path) {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("artifact_already_exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'xb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value) {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,9 @@ const files = [
|
||||||
["packages/external-provider-contract/src/data-product.mjs", "platform/packages/external-provider-contract/src/data-product.mjs"],
|
["packages/external-provider-contract/src/data-product.mjs", "platform/packages/external-provider-contract/src/data-product.mjs"],
|
||||||
["packages/external-provider-contract/src/intake-batch.mjs", "platform/packages/external-provider-contract/src/intake-batch.mjs"],
|
["packages/external-provider-contract/src/intake-batch.mjs", "platform/packages/external-provider-contract/src/intake-batch.mjs"],
|
||||||
["packages/external-provider-contract/src/index.mjs", "platform/packages/external-provider-contract/src/index.mjs"],
|
["packages/external-provider-contract/src/index.mjs", "platform/packages/external-provider-contract/src/index.mjs"],
|
||||||
|
["packages/external-provider-contract/src/provider-package.mjs", "platform/packages/external-provider-contract/src/provider-package.mjs"],
|
||||||
["packages/external-provider-contract/src/sensitive-field-policy.mjs", "platform/packages/external-provider-contract/src/sensitive-field-policy.mjs"],
|
["packages/external-provider-contract/src/sensitive-field-policy.mjs", "platform/packages/external-provider-contract/src/sensitive-field-policy.mjs"],
|
||||||
|
["packages/external-provider-contract/providers/gelios", "platform/packages/external-provider-contract/providers/gelios"],
|
||||||
];
|
];
|
||||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||||
const ignoredDirectoryNames = new Set(["test"]);
|
const ignoredDirectoryNames = new Set(["test"]);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const workspaceRoot = resolve(platformRoot, "..");
|
||||||
|
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
|
||||||
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
|
const [patchId = "module-foundry-consumer-policy-v4-20260720-001", ...extra] = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
"registry/data-product-consumer-policies.json",
|
||||||
|
"scripts/validate-registry.mjs",
|
||||||
|
"server/foundry-data-product-consumer.mjs",
|
||||||
|
"server/foundry-data-product-consumer.test.mjs",
|
||||||
|
];
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-module-foundry-consumer-policy-artifact-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const artifact = join(artifactDir, `nodedc-module-foundry-${patchId}.tgz`);
|
||||||
|
const checksum = `${artifact}.sha256`;
|
||||||
|
|
||||||
|
await assertConsumerPolicyBoundary();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const relativePath of files) {
|
||||||
|
const source = resolve(foundryRoot, relativePath);
|
||||||
|
const sourceStat = await lstat(source);
|
||||||
|
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||||
|
throw new Error(`source_file_rejected:${relativePath}`);
|
||||||
|
}
|
||||||
|
const destination = join(payload, relativePath);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
|
||||||
|
const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
|
||||||
|
const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex");
|
||||||
|
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
|
||||||
|
console.log(JSON.stringify({ ok: true, patchId, artifact, checksum, sha256, files }, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertConsumerPolicyBoundary() {
|
||||||
|
const registry = JSON.parse(await readFile(resolve(foundryRoot, files[0]), "utf8"));
|
||||||
|
if (registry?.schemaVersion !== "nodedc.foundry.data-product-consumer-policies/v1") {
|
||||||
|
throw new Error("foundry_consumer_policy_registry_invalid");
|
||||||
|
}
|
||||||
|
const matches = registry.policies?.filter((policy) => (
|
||||||
|
policy?.dataProductId === "fleet.positions.current.v4" && policy?.productVersion === "4.0.0"
|
||||||
|
)) || [];
|
||||||
|
if (matches.length !== 1) throw new Error("foundry_consumer_policy_v4_missing_or_ambiguous");
|
||||||
|
const policy = matches[0];
|
||||||
|
if (
|
||||||
|
policy.id !== "map-moving-object-current-v4"
|
||||||
|
|| policy.version !== "4.0.0"
|
||||||
|
|| policy.staleAfterMs !== null
|
||||||
|
|| policy.statusContract?.attribute !== "signal_state"
|
||||||
|
|| JSON.stringify(policy.statusContract?.allowedValues) !== JSON.stringify(["active", "inactive"])
|
||||||
|
|| policy.statusContract?.missing !== "reject"
|
||||||
|
|| policy.statusContract?.freshness !== "none"
|
||||||
|
|| JSON.stringify(policy.terminalStatuses) !== JSON.stringify(["inactive"])
|
||||||
|
|| policy.removeMode !== "canonical-tombstone-or-snapshot-rebase"
|
||||||
|
) throw new Error("foundry_consumer_policy_v4_contract_invalid");
|
||||||
|
|
||||||
|
const serializedPolicy = JSON.stringify(policy);
|
||||||
|
if (/(provider|tenant|connection|endpoint|credential|token|secret|authorization)/i.test(serializedPolicy)) {
|
||||||
|
throw new Error("foundry_consumer_policy_v4_transport_boundary_violation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumer = await readFile(resolve(foundryRoot, files[2]), "utf8");
|
||||||
|
for (const marker of [
|
||||||
|
"data_product_consumer_fact_status_invalid",
|
||||||
|
"data_product_consumer_status_field_not_projected",
|
||||||
|
'statusContract.freshness === "none"',
|
||||||
|
]) {
|
||||||
|
if (!consumer.includes(marker)) throw new Error(`foundry_consumer_policy_v4_runtime_guard_missing:${marker}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const workspaceRoot = resolve(platformRoot, "..");
|
||||||
|
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
|
||||||
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
|
const [patchId = "module-foundry-filter-toggle-20260720-001", ...extra] = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
"apps/catalog/src/MapFixturePreview.tsx",
|
||||||
|
"apps/catalog/src/mapPresentationProfile.ts",
|
||||||
|
"scripts/map-presentation-filters.test.mjs",
|
||||||
|
];
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-module-foundry-filter-toggle-artifact-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const artifact = join(artifactDir, `nodedc-module-foundry-${patchId}.tgz`);
|
||||||
|
const checksum = `${artifact}.sha256`;
|
||||||
|
|
||||||
|
await assertFilterToggleBoundary();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const relativePath of files) {
|
||||||
|
const source = resolve(foundryRoot, relativePath);
|
||||||
|
const sourceStat = await lstat(source);
|
||||||
|
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||||
|
throw new Error(`source_file_rejected:${relativePath}`);
|
||||||
|
}
|
||||||
|
const destination = join(payload, relativePath);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
|
||||||
|
const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
|
||||||
|
const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex");
|
||||||
|
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
|
||||||
|
console.log(JSON.stringify({ ok: true, patchId, artifact, checksum, sha256, files }, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFilterToggleBoundary() {
|
||||||
|
const helper = await readFile(resolve(foundryRoot, files[1]), "utf8");
|
||||||
|
for (const marker of [
|
||||||
|
"toggleMapPresentationFacetSelection",
|
||||||
|
"const { [field]: _removed, ...unconstrained } = facets",
|
||||||
|
"return unconstrained",
|
||||||
|
]) {
|
||||||
|
if (!helper.includes(marker)) throw new Error(`foundry_filter_toggle_helper_missing:${marker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = await readFile(resolve(foundryRoot, files[0]), "utf8");
|
||||||
|
if (!preview.includes("filters: toggleMapPresentationFacetSelection(filters, field, value)")) {
|
||||||
|
throw new Error("foundry_filter_toggle_component_contract_missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = await readFile(resolve(foundryRoot, files[2]), "utf8");
|
||||||
|
for (const marker of [
|
||||||
|
"interactive deselect of the last chip removes the facet constraint",
|
||||||
|
"interactive deselect preserves other values and facet constraints",
|
||||||
|
"deselecting the last chip remains empty and never normalizes to all",
|
||||||
|
]) {
|
||||||
|
if (!test.includes(marker)) throw new Error(`foundry_filter_toggle_regression_missing:${marker}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
|
const [patchId = "ontology-core-20260719-001", ...extra] = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("usage: build-ontology-core-artifact.mjs [patch-id]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRoot = resolve(platformRoot, "services/ontology-core");
|
||||||
|
const destinationRoot = "platform/ontology-core";
|
||||||
|
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules", "test"]);
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-ontology-core-artifact-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copySafe(sourceRoot, join(payload, destinationRoot));
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${destinationRoot}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
|
||||||
|
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
patchId,
|
||||||
|
artifact: target,
|
||||||
|
sha256: createHash("sha256").update(await readFile(target)).digest("hex"),
|
||||||
|
entries: [destinationRoot],
|
||||||
|
servicesExpected: ["ontology-core", "ai-workspace-hub"],
|
||||||
|
excluded: [".env*", "node_modules", "test", "secrets"],
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySafe(source, destination) {
|
||||||
|
const sourceStat = await lstat(source);
|
||||||
|
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, source)}`);
|
||||||
|
if (sourceStat.isFile()) {
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${relative(platformRoot, source)}`);
|
||||||
|
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||||
|
if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) continue;
|
||||||
|
const childSource = join(source, entry.name);
|
||||||
|
const childDestination = join(destination, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`);
|
||||||
|
await copySafe(childSource, childDestination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(here, "../..");
|
||||||
|
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"));
|
||||||
|
const [transitionId = "20260719-005", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
|
||||||
|
throw new Error("usage: build-platform-gelios-provider-v3-artifact.mjs [YYYYMMDD-NNN]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `platform-gelios-provider-v3-${transitionId}`;
|
||||||
|
const target = join(artifactRoot, `nodedc-${id}.tgz`);
|
||||||
|
const files = [
|
||||||
|
"platform/packages/external-provider-contract/providers/gelios/v3/README.md",
|
||||||
|
"platform/packages/external-provider-contract/providers/gelios/v3/index.mjs",
|
||||||
|
"platform/packages/external-provider-contract/providers/gelios/v3/package.mjs",
|
||||||
|
];
|
||||||
|
const expectedSha256 = new Map([
|
||||||
|
[files[0], "7ac2318e455786895bebf2e7ee75ae9f86e8958fc5d4699fd0c8c603b8a92ca1"],
|
||||||
|
[files[1], "dbb82752248fd45c4d3670dadb435123d0575881bfa9458fc0757a421471de24"],
|
||||||
|
[files[2], "67170084c2d55c3adbed05f1d63c71403689fb59ebc9811e495a55a3c4fa41bf"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await assertFresh(target);
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-platform-gelios-provider-v3-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const rel of files) {
|
||||||
|
const source = join(platformRoot, rel.replace(/^platform\//, ""));
|
||||||
|
const stat = await lstat(source);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`);
|
||||||
|
const bytes = await readFile(source);
|
||||||
|
if (sha(bytes) !== expectedSha256.get(rel)) throw new Error(`source_sha256_mismatch:${rel}`);
|
||||||
|
await mkdir(dirname(join(payload, rel)), { recursive: true });
|
||||||
|
await cp(source, join(payload, rel), { force: false });
|
||||||
|
}
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
run("python3", ["-c", canonicalTarScript(), target, stage]);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
id,
|
||||||
|
artifact: target,
|
||||||
|
artifactSha256: sha(await readFile(target)),
|
||||||
|
services: [],
|
||||||
|
providerPackage: "gelios.provider.v3",
|
||||||
|
dataProductId: "fleet.positions.current.v2",
|
||||||
|
runtimeEffect: "catalog-only; no service restart",
|
||||||
|
files,
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFresh(path) {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("artifact_already_exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha(bytes) {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(here, "../..");
|
||||||
|
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"));
|
||||||
|
const [transitionId = "20260720-001", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
|
||||||
|
throw new Error("usage: build-platform-gelios-provider-v4-artifact.mjs [YYYYMMDD-NNN]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `platform-gelios-provider-v4-${transitionId}`;
|
||||||
|
const target = join(artifactRoot, `nodedc-${id}.tgz`);
|
||||||
|
const files = [
|
||||||
|
"platform/packages/external-provider-contract/src/provider-package.mjs",
|
||||||
|
"platform/packages/external-provider-contract/providers/gelios/v4/README.md",
|
||||||
|
"platform/packages/external-provider-contract/providers/gelios/v4/index.mjs",
|
||||||
|
"platform/packages/external-provider-contract/providers/gelios/v4/package.mjs",
|
||||||
|
"platform/services/external-data-plane/definitions/fleet.positions.current.v3.json",
|
||||||
|
];
|
||||||
|
const expectedSha256 = new Map([
|
||||||
|
[files[0], "8ae73cae0be8cbf8484125e1ccf836fdc245b31872a5c7888ced289ed1895ba2"],
|
||||||
|
[files[1], "9f38f5cb267269b54053fa590a3970abb4b6b9803fc931e997be4293aa6e592a"],
|
||||||
|
[files[2], "b92fb1fed6ff2fe29c3dc77978e7c35dcdf4c7f2af4ae63ada23e9c3119e7fd9"],
|
||||||
|
[files[3], "55a46f825e12bcef2354fefd956e674b72fea68b6997df3fcbe89b0a074ec9b8"],
|
||||||
|
[files[4], "04d458f050313468990849f60d37a8a6c9aadd355137d4084b66556bb4a4b673"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await assertFresh(target);
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-platform-gelios-provider-v4-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const rel of files) {
|
||||||
|
const source = join(platformRoot, rel.replace(/^platform\//, ""));
|
||||||
|
const stat = await lstat(source);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`);
|
||||||
|
const bytes = await readFile(source);
|
||||||
|
if (sha(bytes) !== expectedSha256.get(rel)) throw new Error(`source_sha256_mismatch:${rel}`);
|
||||||
|
await mkdir(dirname(join(payload, rel)), { recursive: true });
|
||||||
|
await cp(source, join(payload, rel), { force: false });
|
||||||
|
}
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
run("python3", ["-c", canonicalTarScript(), target, stage]);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
id,
|
||||||
|
artifact: target,
|
||||||
|
artifactSha256: sha(await readFile(target)),
|
||||||
|
services: ["external-data-plane"],
|
||||||
|
ontologyPackage: "gelios@1.1.0",
|
||||||
|
providerPackage: "gelios.provider.v4",
|
||||||
|
dataProductId: "fleet.positions.current.v3",
|
||||||
|
files,
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFresh(path) {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("artifact_already_exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||||
|
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha(bytes) {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,157 @@
|
||||||
|
import hashlib
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
BUILDER_PATH = SCRIPT_DIR / "build-engine-composite-provider-v4-artifact.mjs"
|
||||||
|
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
|
||||||
|
PATCH_ID = "engine-composite-provider-v4-20991231-999"
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader(
|
||||||
|
"nodedc_engine_composite_provider_v4",
|
||||||
|
str(RUNNER_PATH),
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class EngineCompositeProviderV4Test(unittest.TestCase):
|
||||||
|
def test_predecessor_map_matches_the_applied_release_order(self):
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256,
|
||||||
|
{
|
||||||
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
|
||||||
|
"992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428",
|
||||||
|
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
|
||||||
|
"901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d",
|
||||||
|
"nodedc-source/server/dataProductPublishGrant/service.js":
|
||||||
|
"6a417b25b080c05b40c771df4e2dca163491af2c9083ff73dc7e54ad3b360241",
|
||||||
|
"nodedc-source/server/dataProductPublishGrant/store.js":
|
||||||
|
"a92303b2732e21f68c1ac732fa26e4983cbadf3515741cee983b916a283d754c",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def build(self, artifact_dir):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", str(BUILDER_PATH), PATCH_ID],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
def test_builder_is_deterministic_and_emits_exact_backend_only_slice(self):
|
||||||
|
current_sha256 = {
|
||||||
|
relative_path: hashlib.sha256((ENGINE_ROOT / relative_path).read_bytes()).hexdigest()
|
||||||
|
for relative_path in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES
|
||||||
|
}
|
||||||
|
if current_sha256 != RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256:
|
||||||
|
self.skipTest("historical v4 builder source has advanced to its exact successor")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-composite-v4-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
first = self.build(root / "first")
|
||||||
|
second = self.build(root / "second")
|
||||||
|
first_artifact = Path(first["artifact"])
|
||||||
|
second_artifact = Path(second["artifact"])
|
||||||
|
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
|
||||||
|
self.assertEqual(
|
||||||
|
first["sha256"],
|
||||||
|
hashlib.sha256(first_artifact.read_bytes()).hexdigest(),
|
||||||
|
)
|
||||||
|
self.assertEqual(first["services"], ["nodedc-backend"])
|
||||||
|
self.assertEqual(first["credentialValues"], "preserved")
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(first["entries"]),
|
||||||
|
RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
extract = root / "extract"
|
||||||
|
with tarfile.open(first_artifact, "r:gz") as archive:
|
||||||
|
archive.extractall(extract, filter="data")
|
||||||
|
entries = tuple(
|
||||||
|
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
|
||||||
|
)
|
||||||
|
payload = extract / "payload"
|
||||||
|
self.assertTrue(RUNNER.is_engine_composite_provider_v4_slice("engine", entries))
|
||||||
|
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)),
|
||||||
|
("http://127.0.0.1:3001/health",),
|
||||||
|
)
|
||||||
|
RUNNER.validate_engine_composite_provider_v4_slice(payload, entries)
|
||||||
|
for relative_path, expected in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256.items():
|
||||||
|
self.assertEqual(
|
||||||
|
hashlib.sha256((payload / relative_path).read_bytes()).hexdigest(),
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_preflight_requires_every_exact_predecessor_and_immutable_backend(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-composite-v4-preflight-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
for relative_path in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES:
|
||||||
|
path = root / relative_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("predecessor\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def exact_sha(path):
|
||||||
|
relative_path = Path(path).relative_to(root).as_posix()
|
||||||
|
return RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256[relative_path]
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", side_effect=exact_sha),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"preflight_engine_credential_backend_runtime",
|
||||||
|
return_value={"mode": "verified-derived-retry"},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = RUNNER.preflight_engine_composite_provider_v4_predecessor()
|
||||||
|
self.assertEqual(result["mode"], "exact-composite-provider-v3-to-v4")
|
||||||
|
self.assertEqual(
|
||||||
|
result["predecessor_sha256"],
|
||||||
|
RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["target_sha256"],
|
||||||
|
RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256,
|
||||||
|
)
|
||||||
|
|
||||||
|
drift_path = root / RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES[1]
|
||||||
|
|
||||||
|
def drift_sha(path):
|
||||||
|
if Path(path) == drift_path:
|
||||||
|
return "0" * 64
|
||||||
|
return exact_sha(path)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", side_effect=drift_sha),
|
||||||
|
):
|
||||||
|
with self.assertRaises(RUNNER.DeployError):
|
||||||
|
RUNNER.preflight_engine_composite_provider_v4_predecessor()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import hashlib
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
BUILDER = SCRIPT_DIR / "build-engine-mcp-autonomy-provider-v5-artifact.mjs"
|
||||||
|
PATCH_ID = "20991231-998"
|
||||||
|
CANONICAL_ARTIFACT = (
|
||||||
|
PLATFORM_ROOT
|
||||||
|
/ "infra/deploy-artifacts/nodedc-engine-mcp-autonomy-provider-v5-20260720-004.tgz"
|
||||||
|
)
|
||||||
|
CANONICAL_SHA256 = "3400954cdce078892a06b04b9bfd85a90f6ea775b1a0caf99eba1f880ef6601c"
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader(
|
||||||
|
"nodedc_engine_mcp_autonomy_provider_v5",
|
||||||
|
str(RUNNER_PATH),
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class EngineMcpAutonomyProviderV5Test(unittest.TestCase):
|
||||||
|
def build(self, artifact_dir):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", str(BUILDER), PATCH_ID],
|
||||||
|
cwd=PLATFORM_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
def test_canonical_artifact_digest_and_members_are_pinned(self):
|
||||||
|
self.assertEqual(
|
||||||
|
hashlib.sha256(CANONICAL_ARTIFACT.read_bytes()).hexdigest(),
|
||||||
|
CANONICAL_SHA256,
|
||||||
|
)
|
||||||
|
self.assertEqual(CANONICAL_ARTIFACT.read_bytes()[4:8], b"\0\0\0\0")
|
||||||
|
with tarfile.open(CANONICAL_ARTIFACT, "r:gz") as archive:
|
||||||
|
for member in archive:
|
||||||
|
self.assertTrue(member.isfile() or member.isdir())
|
||||||
|
self.assertFalse(Path(member.name).name.startswith("._"))
|
||||||
|
|
||||||
|
def test_builder_is_reproducible_and_slice_is_backend_only(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-autonomy-v5-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
first = self.build(root / "first")
|
||||||
|
second = self.build(root / "second")
|
||||||
|
first_artifact = Path(first["artifact"])
|
||||||
|
second_artifact = Path(second["artifact"])
|
||||||
|
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
|
||||||
|
self.assertEqual(
|
||||||
|
first["artifactSha256"],
|
||||||
|
hashlib.sha256(first_artifact.read_bytes()).hexdigest(),
|
||||||
|
)
|
||||||
|
self.assertEqual(first["services"], ["nodedc-backend"])
|
||||||
|
self.assertEqual(
|
||||||
|
first["authority"],
|
||||||
|
"mcp-capability-intersect-user-objective",
|
||||||
|
)
|
||||||
|
|
||||||
|
extract = root / "extract"
|
||||||
|
with tarfile.open(first_artifact, "r:gz") as archive:
|
||||||
|
archive.extractall(extract, filter="data")
|
||||||
|
loaded = root / "loaded"
|
||||||
|
loaded.mkdir()
|
||||||
|
manifest, entries, payload = RUNNER.load_artifact(first_artifact, loaded)
|
||||||
|
descriptor = RUNNER.validate_engine_mcp_autonomy_provider_v5_payload(
|
||||||
|
payload,
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["component"], "engine")
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(entries),
|
||||||
|
RUNNER.ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES,
|
||||||
|
)
|
||||||
|
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
|
||||||
|
self.assertEqual(RUNNER.component_builds("engine", entries), ())
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["source"]["gatewaySha256"],
|
||||||
|
RUNNER.ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256[
|
||||||
|
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_payload_pins_authority_policy_archive_and_catalog_lineage(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-autonomy-v5-policy-") as directory:
|
||||||
|
result = self.build(Path(directory) / "artifact")
|
||||||
|
extract = Path(directory) / "extract"
|
||||||
|
with tarfile.open(result["artifact"], "r:gz") as archive:
|
||||||
|
archive.extractall(extract, filter="data")
|
||||||
|
payload = extract / "payload"
|
||||||
|
installer = (
|
||||||
|
payload
|
||||||
|
/ "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
catalog = json.loads((
|
||||||
|
payload / "nodedc-source/server/assets/provider-packages/v1/catalog.json"
|
||||||
|
).read_text(encoding="utf-8"))
|
||||||
|
self.assertIn("MCP tool availability establishes capability authority", installer)
|
||||||
|
self.assertIn("machine safety barrier, not a permission ceremony", installer)
|
||||||
|
self.assertIn("Three identical failures with no new evidence", installer)
|
||||||
|
self.assertNotIn("only with explicit user confirmation", installer)
|
||||||
|
self.assertEqual(
|
||||||
|
[item["id"] for item in catalog["packages"]],
|
||||||
|
["gelios.provider.v4", "gelios.provider.v5"],
|
||||||
|
)
|
||||||
|
self.assertTrue(all(
|
||||||
|
capability["dataProductIds"] == ["fleet.positions.current.v4"]
|
||||||
|
for package in catalog["packages"] if package["id"] == "gelios.provider.v5"
|
||||||
|
for capability in package["capabilities"]
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_payload_tampering_is_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-autonomy-v5-tamper-") as directory:
|
||||||
|
result = self.build(Path(directory) / "artifact")
|
||||||
|
extract = Path(directory) / "extract"
|
||||||
|
RUNNER.safe_extract(Path(result["artifact"]), extract)
|
||||||
|
entries = RUNNER.parse_files_list(extract / "files.txt")
|
||||||
|
package = extract / "payload/nodedc-source/server/assets/engine-agent-npm/package.json"
|
||||||
|
package.write_text("{}\n", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(RUNNER.DeployError, "target sha256 mismatch"):
|
||||||
|
RUNNER.validate_engine_mcp_autonomy_provider_v5_payload(
|
||||||
|
extract / "payload",
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -13,6 +13,7 @@ from pathlib import Path
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
|
||||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
ENGINE_BUILDER = SCRIPT_DIR / "build-engine-mcp-ontology-sdk-artifact.mjs"
|
ENGINE_BUILDER = SCRIPT_DIR / "build-engine-mcp-ontology-sdk-artifact.mjs"
|
||||||
PLATFORM_BUILDER = SCRIPT_DIR / "build-platform-gelios-provider-v2-artifact.mjs"
|
PLATFORM_BUILDER = SCRIPT_DIR / "build-platform-gelios-provider-v2-artifact.mjs"
|
||||||
|
|
@ -53,6 +54,12 @@ class EngineMcpOntologySdkTest(unittest.TestCase):
|
||||||
self.assertFalse(Path(member.name).name.startswith("._"))
|
self.assertFalse(Path(member.name).name.startswith("._"))
|
||||||
|
|
||||||
def test_engine_builder_is_byte_reproducible(self):
|
def test_engine_builder_is_byte_reproducible(self):
|
||||||
|
current_sha256 = {
|
||||||
|
relative_path: hashlib.sha256((ENGINE_ROOT / relative_path).read_bytes()).hexdigest()
|
||||||
|
for relative_path in RUNNER.ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256
|
||||||
|
}
|
||||||
|
if current_sha256 != RUNNER.ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256:
|
||||||
|
self.skipTest("historical Ontology/SDK builder source has advanced to its exact successor")
|
||||||
outputs = []
|
outputs = []
|
||||||
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-ontology-sdk-") as directory:
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-ontology-sdk-") as directory:
|
||||||
for index in range(2):
|
for index in range(2):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import hashlib
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
BUILDER_PATH = SCRIPT_DIR / "build-engine-provider-authority-diagnostics-artifact.mjs"
|
||||||
|
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
|
||||||
|
PATCH_ID = "engine-provider-authority-diagnostics-20991231-999"
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader(
|
||||||
|
"nodedc_engine_provider_authority_diagnostics",
|
||||||
|
str(RUNNER_PATH),
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class EngineProviderAuthorityDiagnosticsTest(unittest.TestCase):
|
||||||
|
def build(self, artifact_dir):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", str(BUILDER_PATH), PATCH_ID],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
def test_builder_emits_exact_deterministic_one_file_slice(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-provider-authority-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
first = self.build(root / "first")
|
||||||
|
second = self.build(root / "second")
|
||||||
|
first_artifact = Path(first["artifact"])
|
||||||
|
second_artifact = Path(second["artifact"])
|
||||||
|
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
|
||||||
|
self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest())
|
||||||
|
self.assertEqual(first["services"], ["nodedc-backend"])
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(first["entries"]),
|
||||||
|
RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
extract = root / "extract"
|
||||||
|
with tarfile.open(first_artifact, "r:gz") as archive:
|
||||||
|
archive.extractall(extract, filter="data")
|
||||||
|
entries = tuple((extract / "files.txt").read_text(encoding="utf-8").splitlines())
|
||||||
|
payload = extract / "payload"
|
||||||
|
self.assertTrue(RUNNER.is_engine_provider_authority_diagnostics_slice("engine", entries))
|
||||||
|
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)),
|
||||||
|
("http://127.0.0.1:3001/health",),
|
||||||
|
)
|
||||||
|
RUNNER.validate_engine_provider_authority_diagnostics_slice(payload, entries)
|
||||||
|
|
||||||
|
def test_preflight_requires_the_exact_deployed_predecessor(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-provider-authority-preflight-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
relative_path = RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES[0]
|
||||||
|
path = root / relative_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("predecessor\n", encoding="utf-8")
|
||||||
|
expected = RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_PREDECESSOR_SHA256[relative_path]
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", return_value=expected),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"preflight_engine_credential_backend_runtime",
|
||||||
|
return_value={"mode": "verified-derived-retry"},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = RUNNER.preflight_engine_provider_authority_diagnostics_predecessor()
|
||||||
|
self.assertEqual(result["mode"], "exact-provider-authority-reason-codes")
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", return_value="0" * 64),
|
||||||
|
):
|
||||||
|
with self.assertRaises(RUNNER.DeployError):
|
||||||
|
RUNNER.preflight_engine_provider_authority_diagnostics_predecessor()
|
||||||
|
|
||||||
|
def test_healthchecks_dispatch_authority_diagnostics_acceptance(self):
|
||||||
|
entries = RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_ARTIFACT_ENTRIES
|
||||||
|
root = Path("/engine")
|
||||||
|
target_hash = next(iter(RUNNER.ENGINE_PROVIDER_AUTHORITY_DIAGNOSTICS_TARGET_SHA256.values()))
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", return_value=target_hash),
|
||||||
|
mock.patch.object(RUNNER, "healthcheck_compose_service"),
|
||||||
|
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"preflight_engine_credential_backend_runtime",
|
||||||
|
return_value={"mode": "verified-derived-retry"},
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"accept_engine_provider_authority_diagnostics_runtime",
|
||||||
|
return_value={"live": "accepted"},
|
||||||
|
) as acceptance,
|
||||||
|
mock.patch.object(RUNNER, "healthcheck_container"),
|
||||||
|
):
|
||||||
|
RUNNER.run_healthchecks("engine", entries, ("nodedc-backend",))
|
||||||
|
acceptance.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_live_acceptance_uses_exact_reason_code_contract(self):
|
||||||
|
expected_live = "engine-provider-authority-diagnostics:exact-reason-codes:v1"
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
|
||||||
|
mock.patch.object(RUNNER, "engine_backend_container_id", return_value="backend"),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"run_engine_backend_probe",
|
||||||
|
return_value=expected_live,
|
||||||
|
) as backend_probe,
|
||||||
|
):
|
||||||
|
result = RUNNER.accept_engine_provider_authority_diagnostics_runtime()
|
||||||
|
self.assertEqual(result["live"], expected_live)
|
||||||
|
self.assertEqual(backend_probe.call_args.args[1], "Engine provider authority diagnostics")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
import hashlib
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
BUILDER_PATH = SCRIPT_DIR / "build-engine-provider-rotating-slot-artifact.mjs"
|
||||||
|
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
|
||||||
|
PATCH_ID = "engine-provider-rotating-slot-20991231-999"
|
||||||
|
AUTHORITY_SUCCESSOR_SHA256 = "1a14299167ebe17efd677fa3c59b84c80e12d6846bac6f40f9bcae0729ab22c6"
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader(
|
||||||
|
"nodedc_engine_provider_rotating_slot",
|
||||||
|
str(RUNNER_PATH),
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class EngineProviderRotatingSlotTest(unittest.TestCase):
|
||||||
|
def source_has_exact_successor(self):
|
||||||
|
path = ENGINE_ROOT / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js"
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest() == AUTHORITY_SUCCESSOR_SHA256
|
||||||
|
|
||||||
|
def build(self, artifact_dir):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", str(BUILDER_PATH), PATCH_ID],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
def test_builder_emits_exact_deterministic_two_file_slice(self):
|
||||||
|
if self.source_has_exact_successor():
|
||||||
|
self.skipTest("historical rotating-slot builder source has advanced to its exact successor")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-provider-rotating-slot-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
first = self.build(root / "first")
|
||||||
|
second = self.build(root / "second")
|
||||||
|
first_artifact = Path(first["artifact"])
|
||||||
|
second_artifact = Path(second["artifact"])
|
||||||
|
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
|
||||||
|
self.assertEqual(
|
||||||
|
first["sha256"],
|
||||||
|
hashlib.sha256(first_artifact.read_bytes()).hexdigest(),
|
||||||
|
)
|
||||||
|
self.assertEqual(first["services"], ["nodedc-backend"])
|
||||||
|
self.assertEqual(first["credentialSlot"], "ndcProviderRotatingAccessApi")
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(first["entries"]),
|
||||||
|
RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
extract = root / "extract"
|
||||||
|
with tarfile.open(first_artifact, "r:gz") as archive:
|
||||||
|
archive.extractall(extract, filter="data")
|
||||||
|
entries = tuple(
|
||||||
|
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
|
||||||
|
)
|
||||||
|
payload = extract / "payload"
|
||||||
|
self.assertTrue(RUNNER.is_engine_provider_rotating_slot_slice("engine", entries))
|
||||||
|
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)),
|
||||||
|
("http://127.0.0.1:3001/health",),
|
||||||
|
)
|
||||||
|
RUNNER.validate_engine_provider_rotating_slot_slice(payload, entries)
|
||||||
|
|
||||||
|
def test_preflight_is_exact_and_rejects_any_drift(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-provider-rotating-preflight-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
for relative_path in RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES:
|
||||||
|
path = root / relative_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("predecessor\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def exact_sha(path):
|
||||||
|
relative_path = Path(path).relative_to(root).as_posix()
|
||||||
|
return RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256[relative_path]
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", side_effect=exact_sha),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"preflight_engine_credential_backend_runtime",
|
||||||
|
return_value={"mode": "verified-derived-retry"},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = RUNNER.preflight_engine_provider_rotating_slot_predecessor()
|
||||||
|
self.assertEqual(result["mode"], "exact-gelios-v4-credential-slot-alignment")
|
||||||
|
self.assertEqual(
|
||||||
|
result["predecessor_sha256"],
|
||||||
|
RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_PREDECESSOR_SHA256,
|
||||||
|
)
|
||||||
|
|
||||||
|
drift_path = root / RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES[0]
|
||||||
|
|
||||||
|
def drift_sha(path):
|
||||||
|
if Path(path) == drift_path:
|
||||||
|
return "0" * 64
|
||||||
|
return exact_sha(path)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", side_effect=drift_sha),
|
||||||
|
):
|
||||||
|
with self.assertRaises(RUNNER.DeployError):
|
||||||
|
RUNNER.preflight_engine_provider_rotating_slot_predecessor()
|
||||||
|
|
||||||
|
def test_healthchecks_dispatch_exact_rotating_slot_acceptance(self):
|
||||||
|
entries = RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_ARTIFACT_ENTRIES
|
||||||
|
root = Path("/engine")
|
||||||
|
|
||||||
|
def installed_sha(path):
|
||||||
|
relative_path = Path(path).relative_to(root).as_posix()
|
||||||
|
return RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256[relative_path]
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", side_effect=installed_sha),
|
||||||
|
mock.patch.object(RUNNER, "healthcheck_compose_service"),
|
||||||
|
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"preflight_engine_credential_backend_runtime",
|
||||||
|
return_value={"mode": "verified-derived-retry"},
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"accept_engine_provider_rotating_slot_runtime",
|
||||||
|
return_value={"live": "accepted"},
|
||||||
|
) as rotating_acceptance,
|
||||||
|
mock.patch.object(RUNNER, "accept_engine_composite_provider_runtime") as composite_acceptance,
|
||||||
|
mock.patch.object(RUNNER, "healthcheck_container"),
|
||||||
|
):
|
||||||
|
RUNNER.run_healthchecks("engine", entries, ("nodedc-backend",))
|
||||||
|
|
||||||
|
rotating_acceptance.assert_called_once_with()
|
||||||
|
composite_acceptance.assert_not_called()
|
||||||
|
|
||||||
|
def test_live_acceptance_uses_the_rotating_slot_target_contract(self):
|
||||||
|
if self.source_has_exact_successor():
|
||||||
|
self.skipTest("historical rotating-slot live fixture source has advanced to its exact successor")
|
||||||
|
expected_live = (
|
||||||
|
"engine-provider-rotating-slot:gelios.provider.v4:"
|
||||||
|
"ndcProviderRotatingAccessApi:fleet.positions.current.v3:monitoring,units"
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
|
||||||
|
mock.patch.object(RUNNER, "engine_backend_container_id", return_value="backend"),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"run_engine_backend_probe",
|
||||||
|
return_value=expected_live,
|
||||||
|
) as backend_probe,
|
||||||
|
):
|
||||||
|
result = RUNNER.accept_engine_provider_rotating_slot_runtime()
|
||||||
|
|
||||||
|
self.assertEqual(result["live"], expected_live)
|
||||||
|
self.assertEqual(
|
||||||
|
result["target_sha256"],
|
||||||
|
RUNNER.ENGINE_PROVIDER_ROTATING_SLOT_TARGET_SHA256,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
backend_probe.call_args.args[1],
|
||||||
|
"Engine provider rotating slot authority",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
BUILDER_PATH = SCRIPT_DIR / "build-engine-provider-security-catalog-artifact.mjs"
|
||||||
|
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
|
||||||
|
CATALOG_REL = "nodedc-source/server/assets/provider-packages/v1/catalog.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader("nodedc_engine_provider_catalog", str(RUNNER_PATH))
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class EngineProviderSecurityCatalogTest(unittest.TestCase):
|
||||||
|
def test_builder_emits_exact_backend_only_slice(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-provider-catalog-") as directory:
|
||||||
|
artifact_dir = Path(directory) / "artifacts"
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
|
||||||
|
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", str(BUILDER_PATH), "20991231-999"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
result = json.loads(completed.stdout)
|
||||||
|
artifact = Path(result["artifact"])
|
||||||
|
self.assertEqual(result["services"], ["nodedc-backend"])
|
||||||
|
with tarfile.open(artifact, "r:gz") as archive:
|
||||||
|
archive.extractall(Path(directory) / "extract", filter="data")
|
||||||
|
root = Path(directory) / "extract"
|
||||||
|
entries = tuple((root / "files.txt").read_text(encoding="utf-8").splitlines())
|
||||||
|
payload = root / "payload"
|
||||||
|
self.assertTrue(RUNNER.is_engine_provider_security_catalog_slice("engine", entries))
|
||||||
|
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)),
|
||||||
|
("http://127.0.0.1:3001/health",),
|
||||||
|
)
|
||||||
|
catalog = RUNNER.validate_engine_provider_security_catalog_payload(payload, entries)
|
||||||
|
self.assertEqual(catalog["packages"][0]["id"], "gelios.provider.v3")
|
||||||
|
|
||||||
|
def test_preflight_requires_exact_v1_predecessor_and_immutable_backend(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-provider-predecessor-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
target = root / CATALOG_REL
|
||||||
|
target.parent.mkdir(parents=True)
|
||||||
|
source = subprocess.run(
|
||||||
|
["git", "show", "HEAD:nodedc-source/server/assets/provider-packages/v1/catalog.json"],
|
||||||
|
cwd=ENGINE_ROOT,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
).stdout
|
||||||
|
target.write_bytes(source)
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"preflight_engine_credential_backend_runtime",
|
||||||
|
return_value={"mode": "verified-derived-retry"},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = RUNNER.preflight_engine_provider_security_catalog_predecessor()
|
||||||
|
self.assertEqual(
|
||||||
|
result["catalog_sha256"],
|
||||||
|
RUNNER.ENGINE_PROVIDER_SECURITY_CATALOG_PREDECESSOR_SHA256,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import hashlib
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
BUILDER_PATH = SCRIPT_DIR / "build-engine-provider-target-host-policy-artifact.mjs"
|
||||||
|
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
|
||||||
|
PATCH_ID = "engine-provider-target-host-policy-20991231-999"
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader(
|
||||||
|
"nodedc_engine_provider_target_host_policy",
|
||||||
|
str(RUNNER_PATH),
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class EngineProviderTargetHostPolicyTest(unittest.TestCase):
|
||||||
|
def build(self, artifact_dir):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", str(BUILDER_PATH), PATCH_ID],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
def test_builder_emits_exact_deterministic_one_file_slice(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-provider-target-host-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
first = self.build(root / "first")
|
||||||
|
second = self.build(root / "second")
|
||||||
|
first_artifact = Path(first["artifact"])
|
||||||
|
second_artifact = Path(second["artifact"])
|
||||||
|
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
|
||||||
|
self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest())
|
||||||
|
self.assertEqual(first["services"], ["nodedc-backend"])
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(first["entries"]),
|
||||||
|
RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
extract = root / "extract"
|
||||||
|
with tarfile.open(first_artifact, "r:gz") as archive:
|
||||||
|
archive.extractall(extract, filter="data")
|
||||||
|
entries = tuple((extract / "files.txt").read_text(encoding="utf-8").splitlines())
|
||||||
|
payload = extract / "payload"
|
||||||
|
self.assertTrue(RUNNER.is_engine_provider_target_host_policy_slice("engine", entries))
|
||||||
|
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)),
|
||||||
|
("http://127.0.0.1:3001/health",),
|
||||||
|
)
|
||||||
|
RUNNER.validate_engine_provider_target_host_policy_slice(payload, entries)
|
||||||
|
|
||||||
|
def test_preflight_requires_the_exact_deployed_predecessor(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-provider-target-host-preflight-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
relative_path = RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES[0]
|
||||||
|
path = root / relative_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("predecessor\n", encoding="utf-8")
|
||||||
|
expected = RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_PREDECESSOR_SHA256[relative_path]
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", return_value=expected),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"preflight_engine_credential_backend_runtime",
|
||||||
|
return_value={"mode": "verified-derived-retry"},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = RUNNER.preflight_engine_provider_target_host_policy_predecessor()
|
||||||
|
self.assertEqual(result["mode"], "exact-provider-literal-target-host")
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", return_value="0" * 64),
|
||||||
|
):
|
||||||
|
with self.assertRaises(RUNNER.DeployError):
|
||||||
|
RUNNER.preflight_engine_provider_target_host_policy_predecessor()
|
||||||
|
|
||||||
|
def test_healthchecks_dispatch_target_host_policy_acceptance(self):
|
||||||
|
entries = RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_ARTIFACT_ENTRIES
|
||||||
|
root = Path("/engine")
|
||||||
|
target_hash = next(iter(RUNNER.ENGINE_PROVIDER_TARGET_HOST_POLICY_TARGET_SHA256.values()))
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=root),
|
||||||
|
mock.patch.object(RUNNER, "sha256_file", return_value=target_hash),
|
||||||
|
mock.patch.object(RUNNER, "healthcheck_compose_service"),
|
||||||
|
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"preflight_engine_credential_backend_runtime",
|
||||||
|
return_value={"mode": "verified-derived-retry"},
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"accept_engine_provider_target_host_policy_runtime",
|
||||||
|
return_value={"live": "accepted"},
|
||||||
|
) as acceptance,
|
||||||
|
mock.patch.object(RUNNER, "healthcheck_container"),
|
||||||
|
):
|
||||||
|
RUNNER.run_healthchecks("engine", entries, ("nodedc-backend",))
|
||||||
|
acceptance.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_live_acceptance_uses_exact_literal_host_contract(self):
|
||||||
|
expected_live = "engine-provider-target-host-policy:exact-literal-host:v1"
|
||||||
|
with (
|
||||||
|
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
|
||||||
|
mock.patch.object(RUNNER, "engine_backend_container_id", return_value="backend"),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"run_engine_backend_probe",
|
||||||
|
return_value=expected_live,
|
||||||
|
) as backend_probe,
|
||||||
|
):
|
||||||
|
result = RUNNER.accept_engine_provider_target_host_policy_runtime()
|
||||||
|
self.assertEqual(result["live"], expected_live)
|
||||||
|
self.assertEqual(backend_probe.call_args.args[1], "Engine provider target host policy")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -11,6 +11,10 @@ from pathlib import Path
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
BUILDER = SCRIPT_DIR / "build-engine-data-product-publish-grant-artifact.mjs"
|
BUILDER = SCRIPT_DIR / "build-engine-data-product-publish-grant-artifact.mjs"
|
||||||
|
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
|
||||||
|
HISTORICAL_PROVIDER_CATALOG_SHA256 = (
|
||||||
|
"9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a"
|
||||||
|
)
|
||||||
EXPECTED_ENTRIES = [
|
EXPECTED_ENTRIES = [
|
||||||
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
||||||
"nodedc-source/server/dataProductPublishGrant",
|
"nodedc-source/server/dataProductPublishGrant",
|
||||||
|
|
@ -26,6 +30,13 @@ SUCCESSFUL_CREDENTIAL_SINK_INDEX_SHA256 = (
|
||||||
|
|
||||||
|
|
||||||
class EnginePublishGrantArtifactTest(unittest.TestCase):
|
class EnginePublishGrantArtifactTest(unittest.TestCase):
|
||||||
|
def historical_source_is_current(self):
|
||||||
|
catalog = (
|
||||||
|
ENGINE_ROOT
|
||||||
|
/ "nodedc-source/server/assets/provider-packages/v1/catalog.json"
|
||||||
|
)
|
||||||
|
return hashlib.sha256(catalog.read_bytes()).hexdigest() == HISTORICAL_PROVIDER_CATALOG_SHA256
|
||||||
|
|
||||||
def build(self, artifact_dir, patch_id):
|
def build(self, artifact_dir, patch_id):
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
|
@ -39,6 +50,8 @@ class EnginePublishGrantArtifactTest(unittest.TestCase):
|
||||||
return json.loads(result.stdout)
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
def test_artifact_is_narrow_secret_free_and_deterministic(self):
|
def test_artifact_is_narrow_secret_free_and_deterministic(self):
|
||||||
|
if not self.historical_source_is_current():
|
||||||
|
self.skipTest("historical Publish-grant builder source has advanced to its exact successor")
|
||||||
with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-artifact-") as directory:
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-artifact-") as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
first_dir = root / "first"
|
first_dir = root / "first"
|
||||||
|
|
@ -70,6 +83,18 @@ class EnginePublishGrantArtifactTest(unittest.TestCase):
|
||||||
"payload/nodedc-source/services/backend/data-product-publish-grant/"
|
"payload/nodedc-source/services/backend/data-product-publish-grant/"
|
||||||
"docker-compose.immutable-runtime.yml"
|
"docker-compose.immutable-runtime.yml"
|
||||||
).read().decode("utf-8")
|
).read().decode("utf-8")
|
||||||
|
catalog = json.loads(archive.extractfile(
|
||||||
|
"payload/nodedc-source/server/assets/provider-packages/v1/catalog.json"
|
||||||
|
).read())
|
||||||
|
provider_catalog_source = archive.extractfile(
|
||||||
|
"payload/nodedc-source/server/dataProductPublishGrant/providerCatalog.js"
|
||||||
|
).read().decode("utf-8")
|
||||||
|
service_source = archive.extractfile(
|
||||||
|
"payload/nodedc-source/server/dataProductPublishGrant/service.js"
|
||||||
|
).read().decode("utf-8")
|
||||||
|
store_source = archive.extractfile(
|
||||||
|
"payload/nodedc-source/server/dataProductPublishGrant/store.js"
|
||||||
|
).read().decode("utf-8")
|
||||||
|
|
||||||
self.assertEqual(files, EXPECTED_ENTRIES)
|
self.assertEqual(files, EXPECTED_ENTRIES)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|
@ -115,6 +140,29 @@ class EnginePublishGrantArtifactTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertIn("read_only: true", runtime_override)
|
self.assertIn("read_only: true", runtime_override)
|
||||||
self.assertEqual(runtime_override.count("create_host_path: false"), 2)
|
self.assertEqual(runtime_override.count("create_host_path: false"), 2)
|
||||||
|
provider = catalog["packages"][0]
|
||||||
|
self.assertEqual(first["providerPackage"], "gelios.provider.v4")
|
||||||
|
self.assertEqual(first["dataProductId"], "fleet.positions.current.v3")
|
||||||
|
self.assertEqual(first["credentialValues"], "preserved")
|
||||||
|
self.assertEqual(provider["id"], "gelios.provider.v4")
|
||||||
|
self.assertEqual(
|
||||||
|
[capability["id"] for capability in provider["capabilities"]],
|
||||||
|
[
|
||||||
|
"gelios.monitoring_config.current.read",
|
||||||
|
"gelios.units.current.read",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[capability["request"]["url"] for capability in provider["capabilities"]],
|
||||||
|
[
|
||||||
|
"https://api.geliospro.com/api/v1/users/me/monitoring-config",
|
||||||
|
"https://api.geliospro.com/api/v1/units?incltrip=true",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertIn("function capabilityRequests(capability)", provider_catalog_source)
|
||||||
|
self.assertIn("exactHttpRequestUrl(n8n)", provider_catalog_source)
|
||||||
|
self.assertIn("providerRequestNodeIds", service_source)
|
||||||
|
self.assertIn("providerRequestNodeIds", store_source)
|
||||||
|
|
||||||
def test_builder_requires_a_fresh_never_issued_patch_id(self):
|
def test_builder_requires_a_fresh_never_issued_patch_id(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-id-") as directory:
|
with tempfile.TemporaryDirectory(prefix="nodedc-engine-publish-id-") as directory:
|
||||||
|
|
@ -140,6 +188,8 @@ class EnginePublishGrantArtifactTest(unittest.TestCase):
|
||||||
self.assertNotEqual(result.returncode, 0)
|
self.assertNotEqual(result.returncode, 0)
|
||||||
self.assertIn(expected, result.stderr)
|
self.assertIn(expected, result.stderr)
|
||||||
|
|
||||||
|
if not self.historical_source_is_current():
|
||||||
|
return
|
||||||
self.build(artifact_dir, PATCH_ID)
|
self.build(artifact_dir, PATCH_ID)
|
||||||
duplicate = subprocess.run(
|
duplicate = subprocess.run(
|
||||||
["node", str(BUILDER), PATCH_ID],
|
["node", str(BUILDER), PATCH_ID],
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ EXPECTED_ENTRIES = [
|
||||||
"platform/packages/external-provider-contract/src/data-product.mjs",
|
"platform/packages/external-provider-contract/src/data-product.mjs",
|
||||||
"platform/packages/external-provider-contract/src/intake-batch.mjs",
|
"platform/packages/external-provider-contract/src/intake-batch.mjs",
|
||||||
"platform/packages/external-provider-contract/src/index.mjs",
|
"platform/packages/external-provider-contract/src/index.mjs",
|
||||||
|
"platform/packages/external-provider-contract/src/provider-package.mjs",
|
||||||
"platform/packages/external-provider-contract/src/sensitive-field-policy.mjs",
|
"platform/packages/external-provider-contract/src/sensitive-field-policy.mjs",
|
||||||
|
"platform/packages/external-provider-contract/providers/gelios",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
BUILDER = SCRIPT_DIR / "build-module-foundry-consumer-policy-artifact.mjs"
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
PATCH_ID = "module-foundry-consumer-policy-v4-unit-001"
|
||||||
|
EXPECTED_FILES = (
|
||||||
|
"registry/data-product-consumer-policies.json",
|
||||||
|
"scripts/validate-registry.mjs",
|
||||||
|
"server/foundry-data-product-consumer.mjs",
|
||||||
|
"server/foundry-data-product-consumer.test.mjs",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader(
|
||||||
|
"nodedc_module_foundry_consumer_policy_artifact",
|
||||||
|
str(RUNNER_PATH),
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleFoundryConsumerPolicyArtifactTest(unittest.TestCase):
|
||||||
|
def build(self, artifact_dir):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", str(BUILDER), PATCH_ID],
|
||||||
|
cwd=PLATFORM_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
def test_builder_is_deterministic_exact_scope_and_runner_compatible(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-foundry-policy-v4-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
first = self.build(root / "first")
|
||||||
|
second = self.build(root / "second")
|
||||||
|
first_artifact = Path(first["artifact"])
|
||||||
|
second_artifact = Path(second["artifact"])
|
||||||
|
|
||||||
|
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
|
||||||
|
self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest())
|
||||||
|
self.assertEqual(tuple(first["files"]), EXPECTED_FILES)
|
||||||
|
|
||||||
|
extracted = root / "loaded"
|
||||||
|
extracted.mkdir()
|
||||||
|
manifest, entries, payload = RUNNER.load_artifact(first_artifact, extracted)
|
||||||
|
self.assertEqual(manifest["component"], "module-foundry")
|
||||||
|
self.assertEqual(tuple(entries), EXPECTED_FILES)
|
||||||
|
self.assertEqual(RUNNER.component_services("module-foundry", entries), ("nodedc-module-foundry",))
|
||||||
|
|
||||||
|
registry = json.loads((payload / EXPECTED_FILES[0]).read_text(encoding="utf-8"))
|
||||||
|
policies = [
|
||||||
|
policy for policy in registry["policies"]
|
||||||
|
if policy["dataProductId"] == "fleet.positions.current.v4"
|
||||||
|
and policy["productVersion"] == "4.0.0"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(policies), 1)
|
||||||
|
policy = policies[0]
|
||||||
|
self.assertEqual(policy["id"], "map-moving-object-current-v4")
|
||||||
|
self.assertEqual(policy["statusContract"]["attribute"], "signal_state")
|
||||||
|
self.assertEqual(policy["statusContract"]["allowedValues"], ["active", "inactive"])
|
||||||
|
self.assertEqual(policy["statusContract"]["freshness"], "none")
|
||||||
|
self.assertIsNone(policy["staleAfterMs"])
|
||||||
|
|
||||||
|
with tarfile.open(first_artifact, "r:gz") as archive:
|
||||||
|
members = archive.getmembers()
|
||||||
|
names = [member.name for member in members]
|
||||||
|
regular_payloads = [
|
||||||
|
archive.extractfile(member).read()
|
||||||
|
for member in members
|
||||||
|
if member.isfile()
|
||||||
|
]
|
||||||
|
self.assertFalse(any(Path(name).name.startswith("._") for name in names))
|
||||||
|
self.assertFalse(any("/.git/" in name or "/node_modules/" in name or "/runtime-data/" in name for name in names))
|
||||||
|
self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(regular_payloads))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||||
|
BUILDER = SCRIPT_DIR / "build-module-foundry-filter-toggle-artifact.mjs"
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
PATCH_ID = "module-foundry-filter-toggle-unit-001"
|
||||||
|
EXPECTED_FILES = (
|
||||||
|
"apps/catalog/src/MapFixturePreview.tsx",
|
||||||
|
"apps/catalog/src/mapPresentationProfile.ts",
|
||||||
|
"scripts/map-presentation-filters.test.mjs",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader(
|
||||||
|
"nodedc_module_foundry_filter_toggle_artifact",
|
||||||
|
str(RUNNER_PATH),
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleFoundryFilterToggleArtifactTest(unittest.TestCase):
|
||||||
|
def build(self, artifact_dir):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", str(BUILDER), PATCH_ID],
|
||||||
|
cwd=PLATFORM_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
def test_builder_is_deterministic_exact_scope_and_runner_compatible(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-foundry-filter-toggle-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
first = self.build(root / "first")
|
||||||
|
second = self.build(root / "second")
|
||||||
|
first_artifact = Path(first["artifact"])
|
||||||
|
second_artifact = Path(second["artifact"])
|
||||||
|
|
||||||
|
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
|
||||||
|
self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest())
|
||||||
|
self.assertEqual(tuple(first["files"]), EXPECTED_FILES)
|
||||||
|
|
||||||
|
extracted = root / "loaded"
|
||||||
|
extracted.mkdir()
|
||||||
|
manifest, entries, payload = RUNNER.load_artifact(first_artifact, extracted)
|
||||||
|
self.assertEqual(manifest["component"], "module-foundry")
|
||||||
|
self.assertEqual(tuple(entries), EXPECTED_FILES)
|
||||||
|
self.assertEqual(RUNNER.component_services("module-foundry", entries), ("nodedc-module-foundry",))
|
||||||
|
|
||||||
|
helper = (payload / EXPECTED_FILES[1]).read_text(encoding="utf-8")
|
||||||
|
preview = (payload / EXPECTED_FILES[0]).read_text(encoding="utf-8")
|
||||||
|
regression = (payload / EXPECTED_FILES[2]).read_text(encoding="utf-8")
|
||||||
|
self.assertIn("toggleMapPresentationFacetSelection", helper)
|
||||||
|
self.assertIn("return unconstrained", helper)
|
||||||
|
self.assertIn("filters: toggleMapPresentationFacetSelection(filters, field, value)", preview)
|
||||||
|
self.assertIn("interactive deselect of the last chip removes the facet constraint", regression)
|
||||||
|
|
||||||
|
with tarfile.open(first_artifact, "r:gz") as archive:
|
||||||
|
members = archive.getmembers()
|
||||||
|
names = [member.name for member in members]
|
||||||
|
regular_payloads = [
|
||||||
|
archive.extractfile(member).read()
|
||||||
|
for member in members
|
||||||
|
if member.isfile()
|
||||||
|
]
|
||||||
|
self.assertFalse(any(Path(name).name.startswith("._") for name in names))
|
||||||
|
self.assertFalse(any("/.git/" in name or "/node_modules/" in name or "/runtime-data/" in name for name in names))
|
||||||
|
self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(regular_payloads))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
|
|
@ -372,8 +372,10 @@ class CanonicalPlatformRegistryTest(unittest.TestCase):
|
||||||
|
|
||||||
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
||||||
original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256
|
original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256
|
||||||
|
original_installed_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256
|
||||||
RUNNER.COMPONENTS["engine"]["payload_root"] = root
|
RUNNER.COMPONENTS["engine"]["payload_root"] = root
|
||||||
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
|
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
|
||||||
|
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected
|
||||||
try:
|
try:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
RUNNER.preflight_engine_data_product_publish_grant_predecessor(),
|
RUNNER.preflight_engine_data_product_publish_grant_predecessor(),
|
||||||
|
|
@ -391,6 +393,7 @@ class CanonicalPlatformRegistryTest(unittest.TestCase):
|
||||||
RUNNER.preflight_engine_data_product_publish_grant_predecessor()
|
RUNNER.preflight_engine_data_product_publish_grant_predecessor()
|
||||||
finally:
|
finally:
|
||||||
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes
|
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes
|
||||||
|
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = original_installed_hashes
|
||||||
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
||||||
|
|
||||||
def test_installed_publish_update_preserves_foundation_and_allows_only_grant_source_changes(self):
|
def test_installed_publish_update_preserves_foundation_and_allows_only_grant_source_changes(self):
|
||||||
|
|
@ -441,8 +444,10 @@ class CanonicalPlatformRegistryTest(unittest.TestCase):
|
||||||
|
|
||||||
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
||||||
original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256
|
original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256
|
||||||
|
original_installed_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256
|
||||||
RUNNER.COMPONENTS["engine"]["payload_root"] = root
|
RUNNER.COMPONENTS["engine"]["payload_root"] = root
|
||||||
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
|
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
|
||||||
|
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected
|
||||||
try:
|
try:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload),
|
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload),
|
||||||
|
|
@ -470,6 +475,99 @@ class CanonicalPlatformRegistryTest(unittest.TestCase):
|
||||||
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)
|
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)
|
||||||
finally:
|
finally:
|
||||||
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes
|
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes
|
||||||
|
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = original_installed_hashes
|
||||||
|
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
||||||
|
|
||||||
|
def test_composite_provider_update_requires_exact_v3_to_v4_catalog_and_four_paths(self):
|
||||||
|
engine_source = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
|
||||||
|
current_target_sha256 = {
|
||||||
|
rel: hashlib.sha256((engine_source / rel).read_bytes()).hexdigest()
|
||||||
|
for rel in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES
|
||||||
|
}
|
||||||
|
if current_target_sha256 != RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256:
|
||||||
|
self.skipTest("historical v3-to-v4 fixture source has advanced to its exact successor")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-composite-provider-update-") as directory:
|
||||||
|
base = Path(directory)
|
||||||
|
root = base / "installed"
|
||||||
|
payload = base / "payload"
|
||||||
|
root.mkdir()
|
||||||
|
payload.mkdir()
|
||||||
|
|
||||||
|
v3_catalog = {
|
||||||
|
"schemaVersion": "nodedc.engine.provider-security-catalog/v1",
|
||||||
|
"packages": [{
|
||||||
|
"id": "gelios.provider.v3",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"providerId": "gelios",
|
||||||
|
"providerCredential": {
|
||||||
|
"authModeId": "gelios.rest-rotating-bearer.v3",
|
||||||
|
"credentialType": "httpBearerAuth",
|
||||||
|
},
|
||||||
|
"capabilities": [{
|
||||||
|
"id": "gelios.units.current.read",
|
||||||
|
"classification": "read",
|
||||||
|
"status": "implemented",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://api.geliospro.com/api/v1/units",
|
||||||
|
},
|
||||||
|
"dataProductIds": ["fleet.positions.current.v2"],
|
||||||
|
}],
|
||||||
|
"publisher": {
|
||||||
|
"nodeType": "n8n-nodes-ndc.ndcDataProductPublish",
|
||||||
|
"credentialType": "ndcDataProductWriterApi",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
installed_catalog = f"{json.dumps(v3_catalog, indent=2)}\n"
|
||||||
|
self.assertEqual(
|
||||||
|
hashlib.sha256(installed_catalog.encode()).hexdigest(),
|
||||||
|
RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256,
|
||||||
|
)
|
||||||
|
|
||||||
|
for rel in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES:
|
||||||
|
if rel == "nodedc-source/server/dataProductPublishGrant":
|
||||||
|
continue
|
||||||
|
for destination in (root, payload):
|
||||||
|
path = destination / rel
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(f"shared:{rel}\n", encoding="utf-8")
|
||||||
|
for name in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES:
|
||||||
|
rel = f"nodedc-source/server/dataProductPublishGrant/{name}"
|
||||||
|
for destination in (root, payload):
|
||||||
|
path = destination / rel
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(f"installed:{name}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
catalog_rel = RUNNER.ENGINE_PROVIDER_SECURITY_CATALOG_REL
|
||||||
|
(root / catalog_rel).write_text(installed_catalog, encoding="utf-8")
|
||||||
|
for rel in RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS:
|
||||||
|
source = engine_source / rel
|
||||||
|
(payload / rel).write_bytes(source.read_bytes())
|
||||||
|
|
||||||
|
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
||||||
|
RUNNER.COMPONENTS["engine"]["payload_root"] = root
|
||||||
|
try:
|
||||||
|
with mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"validate_installed_engine_data_product_publish_grant_foundation",
|
||||||
|
return_value="compose-sha256",
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload),
|
||||||
|
{
|
||||||
|
"mode": "installed-composite-provider-update",
|
||||||
|
"compose_sha256": "compose-sha256",
|
||||||
|
"changed_paths": RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
(root / catalog_rel).write_text("{}\n", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RUNNER.DeployError,
|
||||||
|
"composite provider catalog transition mismatch",
|
||||||
|
):
|
||||||
|
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)
|
||||||
|
finally:
|
||||||
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
||||||
|
|
||||||
def test_edp_healthcheck_requires_database_and_managed_provisioning(self):
|
def test_edp_healthcheck_requires_database_and_managed_provisioning(self):
|
||||||
|
|
|
||||||
|
|
@ -48,14 +48,18 @@ read-capability передаёт все entities, которые provider воз
|
||||||
boundary запрещены; сортировка и видимость принадлежат Data Product consumer и
|
boundary запрещены; сортировка и видимость принадлежат Data Product consumer и
|
||||||
Foundry.
|
Foundry.
|
||||||
|
|
||||||
Первый production-shaped package — `providers/gelios/v1`. Он фиксирует реальный
|
Текущий production-shaped package — `providers/gelios/v5`. Он сохраняет два
|
||||||
Gelios REST `GET /api/v1/units` transport contract и точный output
|
официальных Gelios REST safe-read: `GET /api/v1/users/me/monitoring-config` и
|
||||||
`fleet.positions.current.v1@1.0.0` с revision
|
`GET /api/v1/units?incltrip=true`, а также точный immutable output
|
||||||
`ontology.map.moving_object.v1`. Все Data Product fields используют snake_case.
|
`fleet.positions.current.v4@4.0.0` с revision
|
||||||
Его optional `tokenLifecycle` точно описывает два provider-issued artifacts —
|
`ontology.map.moving_object.v3`. Единственные monitoring-state fields —
|
||||||
`access` и `refresh`: request использует `access`, а refresh пока имеет режим
|
`signal_state` (`active|inactive`) и `movement_state` (`moving|stopped`),
|
||||||
`operator_managed`. Это metadata без secret values и без заявления о
|
полученные из Ontology Gelios `v1.1.0`. Все Data Product fields используют
|
||||||
реализованном автоматическом refresh.
|
snake_case. `fieldContracts` фиксирует тип и обязательность каждого поля, а
|
||||||
|
закрытые state values проверяются и в provider mapping, и на каждом publish в
|
||||||
|
External Data Plane. Native rotating credential использует `access`, а
|
||||||
|
`refresh` остаётся внутри NDC L2 Credentials. Предыдущие Data Product versions
|
||||||
|
не переписываются и остаются legacy-compatible.
|
||||||
|
|
||||||
## Проверяемые v1 contracts
|
## Проверяемые v1 contracts
|
||||||
|
|
||||||
|
|
@ -64,8 +68,11 @@ Gelios REST `GET /api/v1/units` transport contract и точный output
|
||||||
запрещены; publisher представлен system-managed declaration/status без ref.
|
запрещены; publisher представлен system-managed declaration/status без ref.
|
||||||
- `Collection Profile` — явная policy сбора. `manual` не может скрыто содержать
|
- `Collection Profile` — явная policy сбора. `manual` не может скрыто содержать
|
||||||
polling interval; `realtime` требует interval не чаще одного раза в секунду.
|
polling interval; `realtime` требует interval не чаще одного раза в секунду.
|
||||||
- `Data Product` — нормализованный versioned output с semantic types, полями и
|
- `Data Product` — нормализованный versioned output с semantic types, полями,
|
||||||
внутренней аудиторией.
|
optional `fieldContracts` и внутренней аудиторией. Если `fieldContracts`
|
||||||
|
задан, он обязан покрывать точный набор fields; каждый contract задаёт
|
||||||
|
`type`, `required`, optional closed `enum` и числовые bounds. Отсутствие
|
||||||
|
contracts допустимо только для опубликованных legacy versions.
|
||||||
- `Intake Batch` — canonical **scoped** record, который External Data Plane
|
- `Intake Batch` — canonical **scoped** record, который External Data Plane
|
||||||
валидирует и сохраняет: source, contract revision, idempotency, restricted
|
валидирует и сохраняет: source, contract revision, idempotency, restricted
|
||||||
raw envelope и canonical facts. Его `source` содержит `providerId`,
|
raw envelope и canonical facts. Его `source` содержит `providerId`,
|
||||||
|
|
@ -153,10 +160,11 @@ Provider auth и внутренние workload capabilities используют
|
||||||
credential domains. После сохранения ядро владеет secret, а graph и MCP
|
credential domains. После сохранения ядро владеет secret, а graph и MCP
|
||||||
используют только opaque reference.
|
используют только opaque reference.
|
||||||
|
|
||||||
Gelios выдаёт ровно access token и refresh token. Текущий credential type
|
Gelios выдаёт ровно access token и refresh token. Production credential type
|
||||||
`httpBearerAuth` использует access token для HTTP request. Автоматический обмен
|
`ndcProviderRotatingAccessApi` использует access token для HTTP request и выполняет
|
||||||
refresh → access в текущем runtime не доказан и поэтому не заявлен: package
|
refresh → access в native n8n `preAuthentication`; provider package v3 фиксирует
|
||||||
фиксирует `refreshMode: operator_managed`. Название credential с текстом вроде
|
`refreshMode: runtime_managed`. Оба secret artifact остаются в native Credentials и не
|
||||||
|
попадают в graph, package или trace. Название credential с текстом вроде
|
||||||
`read access` является лишь локальной меткой; read-классификацию задают
|
`read access` является лишь локальной меткой; read-классификацию задают
|
||||||
разрешённые endpoint/method в capability catalog и workflow policy, а не scope
|
разрешённые endpoint/method в capability catalog и workflow policy, а не scope
|
||||||
самого access token. В deployed Engine exact method/path policy ещё не
|
самого access token. В deployed Engine exact method/path policy ещё не
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Gelios provider package v3
|
||||||
|
|
||||||
|
Version 3 describes the production REST transport used by the L2 workflow:
|
||||||
|
`GET https://api.geliospro.com/api/v1/units`. The provider credential keeps the
|
||||||
|
Gelios access/refresh pair inside native NDC L2 Credentials; n8n refreshes the
|
||||||
|
access token at request time and exposes neither secret to the graph. The
|
||||||
|
normalized output is the immutable provider-neutral Data Product
|
||||||
|
`fleet.positions.current.v2@2.0.0`.
|
||||||
|
|
||||||
|
The product adds four orthogonal state facets owned by L2 semantic mapping:
|
||||||
|
|
||||||
|
- `availability_state`: `online`, `offline`, `unknown`;
|
||||||
|
- `motion_state`: `moving`, `stationary`, `unknown`;
|
||||||
|
- `position_state`: `valid`, `low_quality`, `missing`;
|
||||||
|
- `freshness_state`: `fresh`, `stale`.
|
||||||
|
|
||||||
|
`state_policy_version` identifies the mapping policy that produced the facets.
|
||||||
|
`operational_status` remains in the product as a backwards-compatible coarse
|
||||||
|
state; presentation classes, colours, pin proportions and label geometry remain
|
||||||
|
Foundry-owned and are never part of this provider package.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export {
|
||||||
|
GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||||
|
GELIOS_PROVIDER_PACKAGE_ID,
|
||||||
|
GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
GELIOS_UNIT_SOURCE_ID_PREFIX,
|
||||||
|
geliosProviderPackageV3,
|
||||||
|
} from "./package.mjs";
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { geliosProviderPackageV1 } from "../v1/package.mjs";
|
||||||
|
|
||||||
|
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v3";
|
||||||
|
export const GELIOS_PROVIDER_PACKAGE_VERSION = "3.0.0";
|
||||||
|
export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v2";
|
||||||
|
export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "2.0.0";
|
||||||
|
export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v2";
|
||||||
|
export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-";
|
||||||
|
|
||||||
|
const FIELD_POLICY_ID = "gelios.positions.current.fields.v2";
|
||||||
|
const AUTH_MODE_ID = "gelios.rest-rotating-bearer.v3";
|
||||||
|
const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v3";
|
||||||
|
const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v3";
|
||||||
|
const MAPPING_ID = "gelios.units.to.fleet.positions.current.v3";
|
||||||
|
const TEMPLATE_ID = "gelios.positions.current.l2.v3";
|
||||||
|
const STATE_POLICY_VERSION = "map-moving-object-state/v1";
|
||||||
|
|
||||||
|
const targetFields = Object.freeze([
|
||||||
|
"availability_state",
|
||||||
|
"course_degrees",
|
||||||
|
"display_name",
|
||||||
|
"elevation_meters",
|
||||||
|
"freshness_state",
|
||||||
|
"geometry",
|
||||||
|
"hdop",
|
||||||
|
"horizontal_accuracy_meters",
|
||||||
|
"motion_state",
|
||||||
|
"object_kind",
|
||||||
|
"operational_status",
|
||||||
|
"position_source",
|
||||||
|
"position_state",
|
||||||
|
"position_valid",
|
||||||
|
"quality_flags",
|
||||||
|
"satellite_count",
|
||||||
|
"speed_kph",
|
||||||
|
"state_policy_version",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const value = structuredClone(geliosProviderPackageV1);
|
||||||
|
value.id = GELIOS_PROVIDER_PACKAGE_ID;
|
||||||
|
value.version = GELIOS_PROVIDER_PACKAGE_VERSION;
|
||||||
|
value.manifest = {
|
||||||
|
...value.manifest,
|
||||||
|
id: "gelios.provider.manifest.v3",
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
authModeIds: [AUTH_MODE_ID],
|
||||||
|
fieldPolicyIds: [FIELD_POLICY_ID],
|
||||||
|
collectionProfileIds: [REALTIME_PROFILE_ID, MANUAL_PROFILE_ID],
|
||||||
|
dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID],
|
||||||
|
mappingContractIds: [MAPPING_ID],
|
||||||
|
l2TemplateIds: [TEMPLATE_ID],
|
||||||
|
};
|
||||||
|
value.authModes = [{
|
||||||
|
...value.authModes[0],
|
||||||
|
id: AUTH_MODE_ID,
|
||||||
|
tokenLifecycle: {
|
||||||
|
artifacts: ["access", "refresh"],
|
||||||
|
requestArtifact: "access",
|
||||||
|
refreshMode: "runtime_managed",
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
value.capabilities = [{
|
||||||
|
...value.capabilities[0],
|
||||||
|
authModeId: AUTH_MODE_ID,
|
||||||
|
}];
|
||||||
|
value.fieldPolicies = [{
|
||||||
|
...value.fieldPolicies[0],
|
||||||
|
id: FIELD_POLICY_ID,
|
||||||
|
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
targetFields: [...targetFields],
|
||||||
|
}];
|
||||||
|
value.collectionProfiles = value.collectionProfiles.map((profile, index) => ({
|
||||||
|
...profile,
|
||||||
|
id: index === 0 ? REALTIME_PROFILE_ID : MANUAL_PROFILE_ID,
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
mappingContractId: MAPPING_ID,
|
||||||
|
fieldPolicyId: FIELD_POLICY_ID,
|
||||||
|
l2TemplateId: TEMPLATE_ID,
|
||||||
|
}));
|
||||||
|
value.dataProducts = [{
|
||||||
|
id: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||||
|
deliveryMode: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fields: [...targetFields],
|
||||||
|
history: {
|
||||||
|
mode: "sampled",
|
||||||
|
intervalMs: 60000,
|
||||||
|
strategy: "latest-per-entity-per-bucket",
|
||||||
|
retentionDays: 90,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
value.mappingContracts = [{
|
||||||
|
...value.mappingContracts[0],
|
||||||
|
id: MAPPING_ID,
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
fieldPolicyId: FIELD_POLICY_ID,
|
||||||
|
target: {
|
||||||
|
...value.mappingContracts[0].target,
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||||
|
},
|
||||||
|
derivations: {
|
||||||
|
...value.mappingContracts[0].derivations,
|
||||||
|
availability_state: {
|
||||||
|
kind: "ordered_rules",
|
||||||
|
rules: [
|
||||||
|
"observed_age_gt_limit.offline",
|
||||||
|
"otherwise.online",
|
||||||
|
],
|
||||||
|
default: "unknown",
|
||||||
|
parameters: { staleAfterMs: 60000 },
|
||||||
|
},
|
||||||
|
freshness_state: {
|
||||||
|
kind: "ordered_rules",
|
||||||
|
rules: [
|
||||||
|
"observed_age_gt_limit.stale",
|
||||||
|
"otherwise.fresh",
|
||||||
|
],
|
||||||
|
default: "stale",
|
||||||
|
parameters: { staleAfterMs: 60000 },
|
||||||
|
},
|
||||||
|
motion_state: {
|
||||||
|
kind: "ordered_rules",
|
||||||
|
rules: [
|
||||||
|
"speed_kph_gte_threshold.moving",
|
||||||
|
"speed_kph_lt_threshold.stationary",
|
||||||
|
"otherwise.unknown",
|
||||||
|
],
|
||||||
|
default: "unknown",
|
||||||
|
parameters: { movingThresholdKph: 1 },
|
||||||
|
},
|
||||||
|
position_state: {
|
||||||
|
kind: "ordered_rules",
|
||||||
|
rules: [
|
||||||
|
"position_invalid.missing",
|
||||||
|
"position_quality_below_threshold.low_quality",
|
||||||
|
"otherwise.valid",
|
||||||
|
],
|
||||||
|
default: "missing",
|
||||||
|
parameters: {
|
||||||
|
minSatelliteCount: 4,
|
||||||
|
maxHdop: 5,
|
||||||
|
maxHorizontalAccuracyMeters: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fact: {
|
||||||
|
...value.mappingContracts[0].fact,
|
||||||
|
attributes: {
|
||||||
|
...value.mappingContracts[0].fact.attributes,
|
||||||
|
availability_state: { derive: "availability_state" },
|
||||||
|
freshness_state: { derive: "freshness_state" },
|
||||||
|
motion_state: { derive: "motion_state" },
|
||||||
|
position_state: { derive: "position_state" },
|
||||||
|
state_policy_version: { constant: STATE_POLICY_VERSION },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
value.l2Templates = [{
|
||||||
|
...value.l2Templates[0],
|
||||||
|
id: TEMPLATE_ID,
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
credentialBindings: value.l2Templates[0].credentialBindings.map((binding) => (
|
||||||
|
binding.role === "provider" ? { ...binding, authModeId: AUTH_MODE_ID } : binding
|
||||||
|
)),
|
||||||
|
steps: value.l2Templates[0].steps.map((step) => {
|
||||||
|
if (step.kind === "semantic_mapping") return { ...step, mappingContractId: MAPPING_ID };
|
||||||
|
if (step.kind === "data_product_publish") return { ...step, dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID };
|
||||||
|
return step;
|
||||||
|
}),
|
||||||
|
}];
|
||||||
|
|
||||||
|
export const geliosProviderPackageV3 = deepFreeze(value);
|
||||||
|
|
||||||
|
function deepFreeze(input) {
|
||||||
|
if (!input || typeof input !== "object" || Object.isFrozen(input)) return input;
|
||||||
|
Object.freeze(input);
|
||||||
|
for (const child of Object.values(input)) deepFreeze(child);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Gelios provider package v4
|
||||||
|
|
||||||
|
Version 4 is the first provider package whose monitoring states are derived
|
||||||
|
strictly from the Gelios Ontology `v1.1.0` value contracts.
|
||||||
|
|
||||||
|
The collection uses two official safe-read capabilities with the same native
|
||||||
|
rotating Gelios credential:
|
||||||
|
|
||||||
|
- `GET /api/v1/users/me/monitoring-config` supplies the account/user
|
||||||
|
`signalActiveDuration` and `signalSomewhatInactiveDuration` values used by
|
||||||
|
the official monitoring client;
|
||||||
|
- `GET /api/v1/units?incltrip=true` supplies the complete credential-visible
|
||||||
|
unit set, last-message time, speed and position facts.
|
||||||
|
|
||||||
|
The immutable provider-neutral output is
|
||||||
|
`fleet.positions.current.v3@3.0.0`. Its only monitoring-state fields are:
|
||||||
|
|
||||||
|
- `signal_state`: `active` or `inactive`;
|
||||||
|
- `movement_state`: `moving` or `stopped`.
|
||||||
|
|
||||||
|
The realtime collection profile resolves the signal threshold in one explicit
|
||||||
|
order: a positive `signalSomewhatInactiveDuration`, then a positive
|
||||||
|
`signalActiveDuration`, then the Robot2B profile fallback of `120` seconds.
|
||||||
|
The fallback is profile metadata with observable provenance; it is never a
|
||||||
|
silent mapper default. If no live threshold and no positive profile fallback
|
||||||
|
exist, publication fails closed instead of manufacturing `inactive` facts.
|
||||||
|
|
||||||
|
There is no unknown, freshness, GPS-quality, position-quality, parked,
|
||||||
|
no-position or aggregate operational-status state. Missing geometry remains an
|
||||||
|
absent geometry fact and never becomes a status. Labels, counters, colours and
|
||||||
|
layout remain Foundry presentation metadata, but Foundry may only use the exact
|
||||||
|
values declared by Ontology.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export {
|
||||||
|
GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||||
|
GELIOS_PROVIDER_PACKAGE_ID,
|
||||||
|
GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
GELIOS_UNIT_SOURCE_ID_PREFIX,
|
||||||
|
geliosProviderPackageV4,
|
||||||
|
} from "./package.mjs";
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { geliosProviderPackageV3 } from "../v3/package.mjs";
|
||||||
|
|
||||||
|
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v4";
|
||||||
|
export const GELIOS_PROVIDER_PACKAGE_VERSION = "4.0.0";
|
||||||
|
export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v3";
|
||||||
|
export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "3.0.0";
|
||||||
|
export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v3";
|
||||||
|
export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-";
|
||||||
|
|
||||||
|
const UNIT_CAPABILITY_ID = "gelios.units.current.read";
|
||||||
|
const MONITORING_CONFIG_CAPABILITY_ID = "gelios.monitoring_config.current.read";
|
||||||
|
const FIELD_POLICY_ID = "gelios.positions.current.fields.v3";
|
||||||
|
const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v4";
|
||||||
|
const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v4";
|
||||||
|
const MAPPING_ID = "gelios.units.to.fleet.positions.current.v4";
|
||||||
|
const TEMPLATE_ID = "gelios.positions.current.l2.v4";
|
||||||
|
|
||||||
|
const targetFields = Object.freeze([
|
||||||
|
"course_degrees",
|
||||||
|
"display_name",
|
||||||
|
"elevation_meters",
|
||||||
|
"geometry",
|
||||||
|
"hdop",
|
||||||
|
"horizontal_accuracy_meters",
|
||||||
|
"movement_state",
|
||||||
|
"object_kind",
|
||||||
|
"position_source",
|
||||||
|
"satellite_count",
|
||||||
|
"signal_state",
|
||||||
|
"speed_kph",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const value = structuredClone(geliosProviderPackageV3);
|
||||||
|
const unitCapability = value.capabilities.find((capability) => capability.id === UNIT_CAPABILITY_ID);
|
||||||
|
const baseMapping = value.mappingContracts[0];
|
||||||
|
const baseAttributes = baseMapping.fact.attributes;
|
||||||
|
const authModeId = value.authModes[0].id;
|
||||||
|
|
||||||
|
value.id = GELIOS_PROVIDER_PACKAGE_ID;
|
||||||
|
value.version = GELIOS_PROVIDER_PACKAGE_VERSION;
|
||||||
|
value.manifest = {
|
||||||
|
...value.manifest,
|
||||||
|
id: "gelios.provider.manifest.v4",
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
ontology: {
|
||||||
|
packageId: "gelios",
|
||||||
|
revision: "ontology.gelios.v1_1",
|
||||||
|
},
|
||||||
|
capabilityIds: [MONITORING_CONFIG_CAPABILITY_ID, UNIT_CAPABILITY_ID],
|
||||||
|
fieldPolicyIds: [FIELD_POLICY_ID],
|
||||||
|
collectionProfileIds: [REALTIME_PROFILE_ID, MANUAL_PROFILE_ID],
|
||||||
|
dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID],
|
||||||
|
mappingContractIds: [MAPPING_ID],
|
||||||
|
l2TemplateIds: [TEMPLATE_ID],
|
||||||
|
};
|
||||||
|
value.capabilities = [{
|
||||||
|
id: MONITORING_CONFIG_CAPABILITY_ID,
|
||||||
|
classification: "read",
|
||||||
|
status: "implemented",
|
||||||
|
authModeId,
|
||||||
|
request: {
|
||||||
|
method: "GET",
|
||||||
|
baseUrl: "https://api.geliospro.com",
|
||||||
|
path: "/api/v1/users/me/monitoring-config",
|
||||||
|
query: {},
|
||||||
|
response: {
|
||||||
|
collectionPaths: ["$", "data"],
|
||||||
|
pagination: "single_bounded_response",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entityScope: {
|
||||||
|
mode: "all_visible_to_credential",
|
||||||
|
refresh: "each_collection_run",
|
||||||
|
businessEntityFilter: "forbidden",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
...unitCapability,
|
||||||
|
request: {
|
||||||
|
...unitCapability.request,
|
||||||
|
query: { incltrip: "true" },
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
value.fieldPolicies = [{
|
||||||
|
...value.fieldPolicies[0],
|
||||||
|
id: FIELD_POLICY_ID,
|
||||||
|
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
targetFields: [...targetFields],
|
||||||
|
}];
|
||||||
|
value.collectionProfiles = value.collectionProfiles.map((profile, index) => ({
|
||||||
|
...profile,
|
||||||
|
id: index === 0 ? REALTIME_PROFILE_ID : MANUAL_PROFILE_ID,
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
capabilityIds: [MONITORING_CONFIG_CAPABILITY_ID, UNIT_CAPABILITY_ID],
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
mappingContractId: MAPPING_ID,
|
||||||
|
fieldPolicyId: FIELD_POLICY_ID,
|
||||||
|
l2TemplateId: TEMPLATE_ID,
|
||||||
|
}));
|
||||||
|
value.dataProducts = [{
|
||||||
|
id: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||||
|
deliveryMode: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fields: [...targetFields],
|
||||||
|
history: {
|
||||||
|
mode: "sampled",
|
||||||
|
intervalMs: 60000,
|
||||||
|
strategy: "latest-per-entity-per-bucket",
|
||||||
|
retentionDays: 90,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
value.mappingContracts = [{
|
||||||
|
...baseMapping,
|
||||||
|
id: MAPPING_ID,
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
sourceCapabilityId: UNIT_CAPABILITY_ID,
|
||||||
|
fieldPolicyId: FIELD_POLICY_ID,
|
||||||
|
target: {
|
||||||
|
...baseMapping.target,
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||||
|
},
|
||||||
|
derivations: {
|
||||||
|
signal_state: {
|
||||||
|
kind: "ordered_rules",
|
||||||
|
rules: [
|
||||||
|
"last_message_missing.inactive",
|
||||||
|
"last_message_age_lt_resolved_monitoring_limit.active",
|
||||||
|
"otherwise.inactive",
|
||||||
|
],
|
||||||
|
default: "inactive",
|
||||||
|
parameters: {
|
||||||
|
monitoringConfigCapability: MONITORING_CONFIG_CAPABILITY_ID,
|
||||||
|
activeDurationPath: "signalActiveDuration",
|
||||||
|
somewhatInactiveDurationPath: "signalSomewhatInactiveDuration",
|
||||||
|
inactiveDurationPath: "signalSomewhatInactiveDuration",
|
||||||
|
collectionProfileFallbackSeconds: 120,
|
||||||
|
thresholdResolution: "somewhatInactive_then_active_then_profileFallback",
|
||||||
|
lastMessageTimePath: "lastMsg.time",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
movement_state: {
|
||||||
|
kind: "ordered_rules",
|
||||||
|
rules: [
|
||||||
|
"integer_speed_gt_threshold.moving",
|
||||||
|
"otherwise.stopped",
|
||||||
|
],
|
||||||
|
default: "stopped",
|
||||||
|
parameters: {
|
||||||
|
speedPath: "lastMsg.speed",
|
||||||
|
movingThresholdKph: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fact: {
|
||||||
|
...baseMapping.fact,
|
||||||
|
attributes: {
|
||||||
|
course_degrees: baseAttributes.course_degrees,
|
||||||
|
display_name: baseAttributes.display_name,
|
||||||
|
elevation_meters: baseAttributes.elevation_meters,
|
||||||
|
hdop: baseAttributes.hdop,
|
||||||
|
horizontal_accuracy_meters: baseAttributes.horizontal_accuracy_meters,
|
||||||
|
movement_state: { derive: "movement_state" },
|
||||||
|
object_kind: baseAttributes.object_kind,
|
||||||
|
position_source: baseAttributes.position_source,
|
||||||
|
satellite_count: baseAttributes.satellite_count,
|
||||||
|
signal_state: { derive: "signal_state" },
|
||||||
|
speed_kph: baseAttributes.speed_kph,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
value.l2Templates = [{
|
||||||
|
...value.l2Templates[0],
|
||||||
|
id: TEMPLATE_ID,
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
steps: [{
|
||||||
|
id: "collection.trigger",
|
||||||
|
kind: "collection_trigger",
|
||||||
|
collectionProfileDriven: true,
|
||||||
|
}, {
|
||||||
|
id: "provider.fetch-monitoring-config",
|
||||||
|
kind: "provider_request",
|
||||||
|
capabilityId: MONITORING_CONFIG_CAPABILITY_ID,
|
||||||
|
}, {
|
||||||
|
id: "provider.fetch-units",
|
||||||
|
kind: "provider_request",
|
||||||
|
capabilityId: UNIT_CAPABILITY_ID,
|
||||||
|
}, {
|
||||||
|
id: "provider.extract-units",
|
||||||
|
kind: "extract_items",
|
||||||
|
capabilityId: UNIT_CAPABILITY_ID,
|
||||||
|
}, {
|
||||||
|
id: "ontology.map",
|
||||||
|
kind: "semantic_mapping",
|
||||||
|
mappingContractId: MAPPING_ID,
|
||||||
|
}, {
|
||||||
|
id: "data-product.publish",
|
||||||
|
kind: "data_product_publish",
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
nodeType: "n8n-nodes-ndc.ndcDataProductPublish",
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
|
||||||
|
export const geliosProviderPackageV4 = deepFreeze(value);
|
||||||
|
|
||||||
|
function deepFreeze(input) {
|
||||||
|
if (!input || typeof input !== "object" || Object.isFrozen(input)) return input;
|
||||||
|
Object.freeze(input);
|
||||||
|
for (const child of Object.values(input)) deepFreeze(child);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Gelios provider package v5
|
||||||
|
|
||||||
|
Version 5 preserves the Ontology-exact monitoring logic from provider package
|
||||||
|
v4 and publishes it through the immutable `fleet.positions.current.v4@4.0.0`
|
||||||
|
contract.
|
||||||
|
|
||||||
|
The Data Product now declares a machine-enforced contract for every published
|
||||||
|
field. In particular:
|
||||||
|
|
||||||
|
- `signal_state` is required and accepts only `active` or `inactive`;
|
||||||
|
- `movement_state` is required and accepts only `moving` or `stopped`;
|
||||||
|
- identity/presentation attributes are typed and required where the mapping
|
||||||
|
must always produce them;
|
||||||
|
- optional telemetry is type-checked and bounded where the physical domain has
|
||||||
|
a stable lower or upper limit;
|
||||||
|
- geometry remains optional, but when present it must be a valid GeoJSON Point.
|
||||||
|
|
||||||
|
The provider mapping is checked against the same contract before it can be
|
||||||
|
packaged, and External Data Plane checks every publish at runtime. Existing
|
||||||
|
v1-v3 products remain immutable and backward compatible.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export {
|
||||||
|
GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||||
|
GELIOS_PROVIDER_PACKAGE_ID,
|
||||||
|
GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
GELIOS_UNIT_SOURCE_ID_PREFIX,
|
||||||
|
geliosProviderPackageV5,
|
||||||
|
} from "./package.mjs";
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { geliosProviderPackageV4 } from "../v4/package.mjs";
|
||||||
|
|
||||||
|
export const GELIOS_PROVIDER_PACKAGE_ID = "gelios.provider.v5";
|
||||||
|
export const GELIOS_PROVIDER_PACKAGE_VERSION = "5.0.0";
|
||||||
|
export const GELIOS_POSITIONS_DATA_PRODUCT_ID = "fleet.positions.current.v4";
|
||||||
|
export const GELIOS_POSITIONS_DATA_PRODUCT_VERSION = "4.0.0";
|
||||||
|
export const GELIOS_POSITIONS_ONTOLOGY_REVISION = "ontology.map.moving_object.v3";
|
||||||
|
export const GELIOS_UNIT_SOURCE_ID_PREFIX = "gelios-unit-";
|
||||||
|
|
||||||
|
const FIELD_POLICY_ID = "gelios.positions.current.fields.v4";
|
||||||
|
const REALTIME_PROFILE_ID = "gelios.positions.current.realtime.v5";
|
||||||
|
const MANUAL_PROFILE_ID = "gelios.positions.current.manual.v5";
|
||||||
|
const MAPPING_ID = "gelios.units.to.fleet.positions.current.v5";
|
||||||
|
const TEMPLATE_ID = "gelios.positions.current.l2.v5";
|
||||||
|
|
||||||
|
const value = structuredClone(geliosProviderPackageV4);
|
||||||
|
const dataProduct = {
|
||||||
|
id: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||||
|
deliveryMode: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fields: [...value.dataProducts[0].fields],
|
||||||
|
fieldContracts: {
|
||||||
|
course_degrees: { type: "number", required: false, minimum: 0, maximum: 360 },
|
||||||
|
display_name: { type: "string", required: true },
|
||||||
|
elevation_meters: { type: "number", required: false },
|
||||||
|
geometry: { type: "point", required: false },
|
||||||
|
hdop: { type: "number", required: false, minimum: 0 },
|
||||||
|
horizontal_accuracy_meters: { type: "number", required: false, minimum: 0 },
|
||||||
|
movement_state: { type: "string", required: true, enum: ["moving", "stopped"] },
|
||||||
|
object_kind: { type: "string", required: true },
|
||||||
|
position_source: { type: "string", required: true },
|
||||||
|
satellite_count: { type: "number", required: false, minimum: 0 },
|
||||||
|
signal_state: { type: "string", required: true, enum: ["active", "inactive"] },
|
||||||
|
speed_kph: { type: "number", required: false, minimum: 0 },
|
||||||
|
},
|
||||||
|
history: { ...value.dataProducts[0].history },
|
||||||
|
};
|
||||||
|
|
||||||
|
value.id = GELIOS_PROVIDER_PACKAGE_ID;
|
||||||
|
value.version = GELIOS_PROVIDER_PACKAGE_VERSION;
|
||||||
|
value.manifest = {
|
||||||
|
...value.manifest,
|
||||||
|
id: "gelios.provider.manifest.v5",
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
fieldPolicyIds: [FIELD_POLICY_ID],
|
||||||
|
collectionProfileIds: [REALTIME_PROFILE_ID, MANUAL_PROFILE_ID],
|
||||||
|
dataProductIds: [GELIOS_POSITIONS_DATA_PRODUCT_ID],
|
||||||
|
mappingContractIds: [MAPPING_ID],
|
||||||
|
l2TemplateIds: [TEMPLATE_ID],
|
||||||
|
};
|
||||||
|
value.fieldPolicies = value.fieldPolicies.map((policy) => ({
|
||||||
|
...policy,
|
||||||
|
id: FIELD_POLICY_ID,
|
||||||
|
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
}));
|
||||||
|
value.collectionProfiles = value.collectionProfiles.map((profile, index) => ({
|
||||||
|
...profile,
|
||||||
|
id: index === 0 ? REALTIME_PROFILE_ID : MANUAL_PROFILE_ID,
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
mappingContractId: MAPPING_ID,
|
||||||
|
fieldPolicyId: FIELD_POLICY_ID,
|
||||||
|
l2TemplateId: TEMPLATE_ID,
|
||||||
|
}));
|
||||||
|
value.dataProducts = [dataProduct];
|
||||||
|
value.mappingContracts = value.mappingContracts.map((mapping) => ({
|
||||||
|
...mapping,
|
||||||
|
id: MAPPING_ID,
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
fieldPolicyId: FIELD_POLICY_ID,
|
||||||
|
target: {
|
||||||
|
...mapping.target,
|
||||||
|
dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID,
|
||||||
|
version: GELIOS_POSITIONS_DATA_PRODUCT_VERSION,
|
||||||
|
ontologyRevision: GELIOS_POSITIONS_ONTOLOGY_REVISION,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const attributes = value.mappingContracts[0].fact.attributes;
|
||||||
|
Object.assign(attributes.course_degrees, { minimum: 0, maximum: 360, omitIfInvalid: true });
|
||||||
|
Object.assign(attributes.hdop, { minimum: 0, omitIfInvalid: true });
|
||||||
|
Object.assign(attributes.horizontal_accuracy_meters, { minimum: 0, omitIfInvalid: true });
|
||||||
|
Object.assign(attributes.satellite_count, { minimum: 0, omitIfInvalid: true });
|
||||||
|
Object.assign(attributes.speed_kph, { minimum: 0, omitIfInvalid: true });
|
||||||
|
value.l2Templates = value.l2Templates.map((template) => ({
|
||||||
|
...template,
|
||||||
|
id: TEMPLATE_ID,
|
||||||
|
version: GELIOS_PROVIDER_PACKAGE_VERSION,
|
||||||
|
steps: template.steps.map((step) => {
|
||||||
|
if (step.kind === "semantic_mapping") return { ...step, mappingContractId: MAPPING_ID };
|
||||||
|
if (step.kind === "data_product_publish") return { ...step, dataProductId: GELIOS_POSITIONS_DATA_PRODUCT_ID };
|
||||||
|
return step;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const geliosProviderPackageV5 = deepFreeze(value);
|
||||||
|
|
||||||
|
function deepFreeze(input) {
|
||||||
|
if (!input || typeof input !== "object" || Object.isFrozen(input)) return input;
|
||||||
|
Object.freeze(input);
|
||||||
|
for (const child of Object.values(input)) deepFreeze(child);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ const TOKEN_REFRESH_MODES = new Set(["not_applicable", "operator_managed", "runt
|
||||||
const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]);
|
const COLLECTION_MODES = new Set(["realtime", "manual", "history", "weekly"]);
|
||||||
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
|
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
|
||||||
const HISTORY_MODES = new Set(["none", "all", "sampled"]);
|
const HISTORY_MODES = new Set(["none", "all", "sampled"]);
|
||||||
|
const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point"]);
|
||||||
const L2_STEP_KINDS = new Set([
|
const L2_STEP_KINDS = new Set([
|
||||||
"collection_trigger",
|
"collection_trigger",
|
||||||
"provider_request",
|
"provider_request",
|
||||||
|
|
@ -115,8 +116,10 @@ const DATA_PRODUCT_KEYS = new Set([
|
||||||
"deliveryMode",
|
"deliveryMode",
|
||||||
"semanticTypes",
|
"semanticTypes",
|
||||||
"fields",
|
"fields",
|
||||||
|
"fieldContracts",
|
||||||
"history",
|
"history",
|
||||||
]);
|
]);
|
||||||
|
const FIELD_CONTRACT_KEYS = new Set(["type", "required", "enum", "minimum", "maximum"]);
|
||||||
const HISTORY_KEYS = new Set(["mode", "intervalMs", "strategy", "retentionDays"]);
|
const HISTORY_KEYS = new Set(["mode", "intervalMs", "strategy", "retentionDays"]);
|
||||||
const MAPPING_KEYS = new Set([
|
const MAPPING_KEYS = new Set([
|
||||||
"schemaVersion",
|
"schemaVersion",
|
||||||
|
|
@ -130,7 +133,10 @@ const MAPPING_KEYS = new Set([
|
||||||
]);
|
]);
|
||||||
const MAPPING_TARGET_KEYS = new Set(["dataProductId", "version", "ontologyRevision", "semanticType"]);
|
const MAPPING_TARGET_KEYS = new Set(["dataProductId", "version", "ontologyRevision", "semanticType"]);
|
||||||
const FACT_KEYS = new Set(["sourceId", "semanticType", "observedAt", "geometry", "attributes"]);
|
const FACT_KEYS = new Set(["sourceId", "semanticType", "observedAt", "geometry", "attributes"]);
|
||||||
const EXPRESSION_KEYS = new Set(["strategy", "paths", "coerce", "prefix", "fallback", "constant", "derive", "omitIfMissing"]);
|
const EXPRESSION_KEYS = new Set([
|
||||||
|
"strategy", "paths", "coerce", "prefix", "fallback", "constant", "derive",
|
||||||
|
"omitIfMissing", "omitIfInvalid", "minimum", "maximum",
|
||||||
|
]);
|
||||||
const GEOMETRY_KEYS = new Set(["type", "longitude", "latitude", "omitIfInvalid"]);
|
const GEOMETRY_KEYS = new Set(["type", "longitude", "latitude", "omitIfInvalid"]);
|
||||||
const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]);
|
const DERIVATION_KEYS = new Set(["kind", "rules", "default", "parameters"]);
|
||||||
const TEMPLATE_KEYS = new Set([
|
const TEMPLATE_KEYS = new Set([
|
||||||
|
|
@ -226,9 +232,6 @@ export function validateProviderPackage(value) {
|
||||||
}
|
}
|
||||||
for (const profile of collectionProfiles) {
|
for (const profile of collectionProfiles) {
|
||||||
const selectedCapabilityIds = Array.isArray(profile.capabilityIds) ? profile.capabilityIds : [];
|
const selectedCapabilityIds = Array.isArray(profile.capabilityIds) ? profile.capabilityIds : [];
|
||||||
if (selectedCapabilityIds.length !== 1) {
|
|
||||||
errors.push(`collectionProfile.${profile.id}.capabilityIds_must_select_one_v1_read`);
|
|
||||||
}
|
|
||||||
selectedCapabilityIds.forEach((id) => {
|
selectedCapabilityIds.forEach((id) => {
|
||||||
assertReference(id, capabilityIds, `collectionProfile.${profile.id}.capabilityIds`, errors);
|
assertReference(id, capabilityIds, `collectionProfile.${profile.id}.capabilityIds`, errors);
|
||||||
const capability = capabilities.find((item) => item.id === id);
|
const capability = capabilities.find((item) => item.id === id);
|
||||||
|
|
@ -247,10 +250,6 @@ export function validateProviderPackage(value) {
|
||||||
if (!selectedCapabilityIds.includes(mapping.sourceCapabilityId)) {
|
if (!selectedCapabilityIds.includes(mapping.sourceCapabilityId)) {
|
||||||
errors.push(`collectionProfile.${profile.id}.mapping_source_capability_must_be_selected`);
|
errors.push(`collectionProfile.${profile.id}.mapping_source_capability_must_be_selected`);
|
||||||
}
|
}
|
||||||
if (selectedCapabilityIds.length !== 1
|
|
||||||
|| selectedCapabilityIds[0] !== mapping.sourceCapabilityId) {
|
|
||||||
errors.push(`collectionProfile.${profile.id}.capability_chain_must_be_exact_singleton`);
|
|
||||||
}
|
|
||||||
if (mapping.fieldPolicyId !== profile.fieldPolicyId) errors.push(`collectionProfile.${profile.id}.mapping_field_policy_mismatch`);
|
if (mapping.fieldPolicyId !== profile.fieldPolicyId) errors.push(`collectionProfile.${profile.id}.mapping_field_policy_mismatch`);
|
||||||
if (mapping.target?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.mapping_data_product_mismatch`);
|
if (mapping.target?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.mapping_data_product_mismatch`);
|
||||||
}
|
}
|
||||||
|
|
@ -258,12 +257,19 @@ export function validateProviderPackage(value) {
|
||||||
errors.push(`collectionProfile.${profile.id}.field_policy_data_product_mismatch`);
|
errors.push(`collectionProfile.${profile.id}.field_policy_data_product_mismatch`);
|
||||||
}
|
}
|
||||||
if (template) {
|
if (template) {
|
||||||
const [, requestStep, extractStep, mappingStep, publishStep] = Array.isArray(template.steps) ? template.steps : [];
|
const steps = Array.isArray(template.steps) ? template.steps : [];
|
||||||
if (!selectedCapabilityIds.includes(requestStep?.capabilityId) || extractStep?.capabilityId !== requestStep?.capabilityId) {
|
const requestSteps = steps.filter((step) => step?.kind === "provider_request");
|
||||||
|
const extractSteps = steps.filter((step) => step?.kind === "extract_items");
|
||||||
|
const mappingSteps = steps.filter((step) => step?.kind === "semantic_mapping");
|
||||||
|
const publishSteps = steps.filter((step) => step?.kind === "data_product_publish");
|
||||||
|
const executedCapabilityIds = requestSteps.map((step) => step.capabilityId);
|
||||||
|
if (!sameSet(selectedCapabilityIds, executedCapabilityIds)
|
||||||
|
|| extractSteps.length !== 1
|
||||||
|
|| extractSteps[0]?.capabilityId !== mapping?.sourceCapabilityId) {
|
||||||
errors.push(`collectionProfile.${profile.id}.template_capability_chain_mismatch`);
|
errors.push(`collectionProfile.${profile.id}.template_capability_chain_mismatch`);
|
||||||
}
|
}
|
||||||
if (mappingStep?.mappingContractId !== profile.mappingContractId) errors.push(`collectionProfile.${profile.id}.template_mapping_mismatch`);
|
if (mappingSteps.length !== 1 || mappingSteps[0]?.mappingContractId !== profile.mappingContractId) errors.push(`collectionProfile.${profile.id}.template_mapping_mismatch`);
|
||||||
if (publishStep?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.template_data_product_mismatch`);
|
if (publishSteps.length !== 1 || publishSteps[0]?.dataProductId !== profile.dataProductId) errors.push(`collectionProfile.${profile.id}.template_data_product_mismatch`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const mapping of mappingContracts) {
|
for (const mapping of mappingContracts) {
|
||||||
|
|
@ -315,13 +321,15 @@ export function validateProviderPackage(value) {
|
||||||
const providerBinding = Array.isArray(template.credentialBindings)
|
const providerBinding = Array.isArray(template.credentialBindings)
|
||||||
? template.credentialBindings.find((binding) => binding?.role === "provider")
|
? template.credentialBindings.find((binding) => binding?.role === "provider")
|
||||||
: undefined;
|
: undefined;
|
||||||
const providerRequest = Array.isArray(template.steps)
|
const providerRequests = Array.isArray(template.steps)
|
||||||
? template.steps.find((step) => step?.kind === "provider_request")
|
? template.steps.filter((step) => step?.kind === "provider_request")
|
||||||
: undefined;
|
: [];
|
||||||
const requestCapability = capabilities.find((capability) => capability.id === providerRequest?.capabilityId);
|
for (const providerRequest of providerRequests) {
|
||||||
if (providerBinding && requestCapability
|
const requestCapability = capabilities.find((capability) => capability.id === providerRequest?.capabilityId);
|
||||||
&& providerBinding.authModeId !== requestCapability.authModeId) {
|
if (providerBinding && requestCapability
|
||||||
errors.push(`l2Template.${template.id}.provider_credential_auth_mode_mismatch`);
|
&& providerBinding.authModeId !== requestCapability.authModeId) {
|
||||||
|
errors.push(`l2Template.${template.id}.provider_credential_auth_mode_mismatch`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,6 +565,7 @@ function validateDataProductDefinition(value, path, errors) {
|
||||||
if (Array.isArray(value.fields) && value.fields.some((field) => SECRET_FIELD_NAME.test(String(field)))) {
|
if (Array.isArray(value.fields) && value.fields.some((field) => SECRET_FIELD_NAME.test(String(field)))) {
|
||||||
errors.push(`${path}.fields_must_not_contain_secret_fields`);
|
errors.push(`${path}.fields_must_not_contain_secret_fields`);
|
||||||
}
|
}
|
||||||
|
validateFieldContracts(value.fieldContracts, value.fields, `${path}.fieldContracts`, errors);
|
||||||
if (!isPlainObject(value.history)) {
|
if (!isPlainObject(value.history)) {
|
||||||
errors.push(`${path}.history_must_be_object`);
|
errors.push(`${path}.history_must_be_object`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -688,8 +697,8 @@ function validateL2Template(value, path, errors) {
|
||||||
});
|
});
|
||||||
if (!roles.has("provider") || !roles.has("publisher")) errors.push(`${path}.credentialBindings_roles_invalid`);
|
if (!roles.has("provider") || !roles.has("publisher")) errors.push(`${path}.credentialBindings_roles_invalid`);
|
||||||
}
|
}
|
||||||
if (!Array.isArray(value.steps) || value.steps.length !== 5) {
|
if (!Array.isArray(value.steps) || value.steps.length < 5) {
|
||||||
errors.push(`${path}.steps_must_define_five_boundary_steps`);
|
errors.push(`${path}.steps_must_define_complete_boundary`);
|
||||||
} else {
|
} else {
|
||||||
const stepIds = new Set();
|
const stepIds = new Set();
|
||||||
value.steps.forEach((step, index) => {
|
value.steps.forEach((step, index) => {
|
||||||
|
|
@ -701,26 +710,41 @@ function validateL2Template(value, path, errors) {
|
||||||
stepIds.add(step.id);
|
stepIds.add(step.id);
|
||||||
if (!L2_STEP_KINDS.has(step.kind)) errors.push(`${stepPath}.kind_invalid`);
|
if (!L2_STEP_KINDS.has(step.kind)) errors.push(`${stepPath}.kind_invalid`);
|
||||||
});
|
});
|
||||||
const expectedKinds = [
|
const requestSteps = value.steps.filter((step) => step?.kind === "provider_request");
|
||||||
"collection_trigger",
|
const extractSteps = value.steps.filter((step) => step?.kind === "extract_items");
|
||||||
"provider_request",
|
const mappingSteps = value.steps.filter((step) => step?.kind === "semantic_mapping");
|
||||||
"extract_items",
|
const publishSteps = value.steps.filter((step) => step?.kind === "data_product_publish");
|
||||||
"semantic_mapping",
|
const requestStart = 1;
|
||||||
"data_product_publish",
|
const extractIndex = value.steps.findIndex((step) => step?.kind === "extract_items");
|
||||||
];
|
const expectedSequence = (
|
||||||
if (!value.steps.every((step, index) => step?.kind === expectedKinds[index])) errors.push(`${path}.steps_sequence_invalid`);
|
value.steps[0]?.kind === "collection_trigger"
|
||||||
|
&& requestSteps.length >= 1
|
||||||
|
&& value.steps.slice(requestStart, extractIndex).every((step) => step?.kind === "provider_request")
|
||||||
|
&& extractSteps.length === 1
|
||||||
|
&& mappingSteps.length === 1
|
||||||
|
&& publishSteps.length === 1
|
||||||
|
&& value.steps.at(-3)?.kind === "extract_items"
|
||||||
|
&& value.steps.at(-2)?.kind === "semantic_mapping"
|
||||||
|
&& value.steps.at(-1)?.kind === "data_product_publish"
|
||||||
|
);
|
||||||
|
if (!expectedSequence) errors.push(`${path}.steps_sequence_invalid`);
|
||||||
if (value.steps[0]?.collectionProfileDriven !== true) errors.push(`${path}.collection_trigger_must_be_profile_driven`);
|
if (value.steps[0]?.collectionProfileDriven !== true) errors.push(`${path}.collection_trigger_must_be_profile_driven`);
|
||||||
if (!value.steps[1]?.capabilityId || value.steps[1]?.mappingContractId !== undefined || value.steps[1]?.dataProductId !== undefined) {
|
for (const step of requestSteps) {
|
||||||
errors.push(`${path}.provider_request_shape_invalid`);
|
if (!step?.capabilityId || step?.mappingContractId !== undefined || step?.dataProductId !== undefined) {
|
||||||
|
errors.push(`${path}.provider_request_shape_invalid`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!value.steps[2]?.capabilityId || value.steps[2]?.mappingContractId !== undefined || value.steps[2]?.dataProductId !== undefined) {
|
const extractStep = extractSteps[0];
|
||||||
|
if (!extractStep?.capabilityId || extractStep?.mappingContractId !== undefined || extractStep?.dataProductId !== undefined) {
|
||||||
errors.push(`${path}.extract_items_shape_invalid`);
|
errors.push(`${path}.extract_items_shape_invalid`);
|
||||||
}
|
}
|
||||||
if (value.steps[1]?.capabilityId !== value.steps[2]?.capabilityId) errors.push(`${path}.request_extract_capability_mismatch`);
|
if (!requestSteps.some((step) => step.capabilityId === extractStep?.capabilityId)) errors.push(`${path}.request_extract_capability_mismatch`);
|
||||||
if (!value.steps[3]?.mappingContractId || value.steps[3]?.capabilityId !== undefined || value.steps[3]?.dataProductId !== undefined) {
|
const mappingStep = mappingSteps[0];
|
||||||
|
if (!mappingStep?.mappingContractId || mappingStep?.capabilityId !== undefined || mappingStep?.dataProductId !== undefined) {
|
||||||
errors.push(`${path}.semantic_mapping_shape_invalid`);
|
errors.push(`${path}.semantic_mapping_shape_invalid`);
|
||||||
}
|
}
|
||||||
if (!value.steps[4]?.dataProductId || value.steps[4]?.nodeType !== "n8n-nodes-ndc.ndcDataProductPublish") {
|
const publishStep = publishSteps[0];
|
||||||
|
if (!publishStep?.dataProductId || publishStep?.nodeType !== "n8n-nodes-ndc.ndcDataProductPublish") {
|
||||||
errors.push(`${path}.data_product_publish_shape_invalid`);
|
errors.push(`${path}.data_product_publish_shape_invalid`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -745,6 +769,122 @@ function validateMappingAgainstProduct(mapping, product, errors) {
|
||||||
}
|
}
|
||||||
const mappedFields = [...Object.keys(mapping.fact?.attributes || {}), ...(mapping.fact?.geometry ? ["geometry"] : [])];
|
const mappedFields = [...Object.keys(mapping.fact?.attributes || {}), ...(mapping.fact?.geometry ? ["geometry"] : [])];
|
||||||
if (!sameSet(mappedFields, product.fields)) errors.push(`${path}.fact_fields_must_match_data_product_fields`);
|
if (!sameSet(mappedFields, product.fields)) errors.push(`${path}.fact_fields_must_match_data_product_fields`);
|
||||||
|
const contracts = isPlainObject(product.fieldContracts) ? product.fieldContracts : {};
|
||||||
|
for (const [field, contract] of Object.entries(contracts)) {
|
||||||
|
if (!isPlainObject(contract)) continue;
|
||||||
|
if (field === "geometry") {
|
||||||
|
if (contract.type !== "point") errors.push(`${path}.fact.geometry_contract_must_be_point`);
|
||||||
|
if (contract.required === true && mapping.fact?.geometry?.omitIfInvalid === true) {
|
||||||
|
errors.push(`${path}.fact.geometry_required_but_mapping_can_omit`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const expression = mapping.fact?.attributes?.[field];
|
||||||
|
if (!isPlainObject(expression)) continue;
|
||||||
|
validateExpressionAgainstFieldContract(expression, contract, mapping.derivations, `${path}.fact.attributes.${field}`, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFieldContracts(value, fields, path, errors) {
|
||||||
|
if (value === undefined) return;
|
||||||
|
if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`);
|
||||||
|
const names = Object.keys(value);
|
||||||
|
if (names.length && !sameSet(names, fields)) errors.push(`${path}_must_exactly_match_fields`);
|
||||||
|
for (const [field, contract] of Object.entries(value)) {
|
||||||
|
requiredIdentifier(field, `${path}.${field}`, errors);
|
||||||
|
if (SECRET_FIELD_NAME.test(field)) errors.push(`${path}.${field}_secret_field_not_allowed`);
|
||||||
|
validateFieldContract(contract, `${path}.${field}`, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFieldContract(value, path, errors) {
|
||||||
|
if (!isPlainObject(value)) return errors.push(`${path}_must_be_object`);
|
||||||
|
rejectUnknownKeys(value, FIELD_CONTRACT_KEYS, path, errors);
|
||||||
|
if (!FIELD_CONTRACT_TYPES.has(value.type)) errors.push(`${path}.type_invalid`);
|
||||||
|
if (typeof value.required !== "boolean") errors.push(`${path}.required_must_be_boolean`);
|
||||||
|
if (value.enum !== undefined) {
|
||||||
|
if (!Array.isArray(value.enum) || value.enum.length === 0 || new Set(value.enum.map(stableLiteral)).size !== value.enum.length) {
|
||||||
|
errors.push(`${path}.enum_must_be_nonempty_unique_array`);
|
||||||
|
} else if (new Set(["point", "string_array"]).has(value.type)
|
||||||
|
|| value.enum.some((item) => !fieldContractValueMatchesType(item, value.type))) {
|
||||||
|
errors.push(`${path}.enum_value_type_invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.minimum !== undefined || value.maximum !== undefined) {
|
||||||
|
if (value.type !== "number") errors.push(`${path}.range_only_allowed_for_number`);
|
||||||
|
if (value.minimum !== undefined && !Number.isFinite(value.minimum)) errors.push(`${path}.minimum_invalid`);
|
||||||
|
if (value.maximum !== undefined && !Number.isFinite(value.maximum)) errors.push(`${path}.maximum_invalid`);
|
||||||
|
if (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum) {
|
||||||
|
errors.push(`${path}.range_invalid`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value.enum) && value.enum.some((item) => (
|
||||||
|
typeof item === "number"
|
||||||
|
&& ((Number.isFinite(value.minimum) && item < value.minimum)
|
||||||
|
|| (Number.isFinite(value.maximum) && item > value.maximum))
|
||||||
|
))) errors.push(`${path}.enum_value_out_of_range`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExpressionAgainstFieldContract(expression, contract, derivations, path, errors) {
|
||||||
|
if (contract.required === true && expression.omitIfMissing === true) {
|
||||||
|
errors.push(`${path}_required_but_mapping_can_omit`);
|
||||||
|
}
|
||||||
|
if (contract.required === true && expression.omitIfInvalid === true) {
|
||||||
|
errors.push(`${path}_required_but_mapping_can_omit_invalid`);
|
||||||
|
}
|
||||||
|
if (expression.paths !== undefined) {
|
||||||
|
const expectedCoerce = new Map([["string", "string"], ["number", "number"], ["boolean", "boolean"]]).get(contract.type);
|
||||||
|
if (expectedCoerce && expression.coerce !== expectedCoerce) errors.push(`${path}.coerce_must_match_field_contract`);
|
||||||
|
if (Array.isArray(contract.enum)) errors.push(`${path}.enum_field_must_use_constant_or_derivation`);
|
||||||
|
if (contract.type === "number") {
|
||||||
|
if (!Object.is(expression.minimum, contract.minimum)) errors.push(`${path}.minimum_must_match_field_contract`);
|
||||||
|
if (!Object.is(expression.maximum, contract.maximum)) errors.push(`${path}.maximum_must_match_field_contract`);
|
||||||
|
if ((contract.minimum !== undefined || contract.maximum !== undefined) && expression.omitIfInvalid !== true) {
|
||||||
|
errors.push(`${path}.bounded_number_must_omit_invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expression.constant !== undefined) {
|
||||||
|
validateMappedValueAgainstFieldContract(expression.constant, contract, `${path}.constant`, errors);
|
||||||
|
}
|
||||||
|
if (typeof expression.derive === "string") {
|
||||||
|
const derivation = isPlainObject(derivations) ? derivations[expression.derive] : undefined;
|
||||||
|
if (!isPlainObject(derivation)) return;
|
||||||
|
validateMappedValueAgainstFieldContract(derivation.default, contract, `${path}.derivation.default`, errors);
|
||||||
|
if (Array.isArray(contract.enum) && Array.isArray(derivation.rules)) {
|
||||||
|
for (const [index, rule] of derivation.rules.entries()) {
|
||||||
|
const output = typeof rule === "string" ? rule.slice(rule.lastIndexOf(".") + 1) : undefined;
|
||||||
|
validateMappedValueAgainstFieldContract(output, contract, `${path}.derivation.rules[${index}]`, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMappedValueAgainstFieldContract(value, contract, path, errors) {
|
||||||
|
if (!fieldContractValueMatchesType(value, contract.type)) errors.push(`${path}_type_mismatch`);
|
||||||
|
if (Array.isArray(contract.enum) && !contract.enum.some((allowed) => Object.is(allowed, value))) {
|
||||||
|
errors.push(`${path}_not_in_field_contract_enum`);
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (Number.isFinite(contract.minimum) && value < contract.minimum) errors.push(`${path}_below_field_contract_minimum`);
|
||||||
|
if (Number.isFinite(contract.maximum) && value > contract.maximum) errors.push(`${path}_above_field_contract_maximum`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldContractValueMatchesType(value, type) {
|
||||||
|
if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||||
|
if (type === "point") {
|
||||||
|
return isPlainObject(value)
|
||||||
|
&& value.type === "Point"
|
||||||
|
&& Array.isArray(value.coordinates)
|
||||||
|
&& value.coordinates.length === 2
|
||||||
|
&& value.coordinates.every(Number.isFinite);
|
||||||
|
}
|
||||||
|
return typeof value === type && (type !== "number" || Number.isFinite(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableLiteral(value) {
|
||||||
|
return `${typeof value}:${JSON.stringify(value)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateExpression(value, path, errors) {
|
function validateExpression(value, path, errors) {
|
||||||
|
|
@ -769,6 +909,18 @@ function validateExpression(value, path, errors) {
|
||||||
if (value.fallback !== undefined && value.fallback !== "collection_received_at") errors.push(`${path}.fallback_invalid`);
|
if (value.fallback !== undefined && value.fallback !== "collection_received_at") errors.push(`${path}.fallback_invalid`);
|
||||||
if (value.derive !== undefined) requiredIdentifier(value.derive, `${path}.derive`, errors);
|
if (value.derive !== undefined) requiredIdentifier(value.derive, `${path}.derive`, errors);
|
||||||
if (value.omitIfMissing !== undefined && typeof value.omitIfMissing !== "boolean") errors.push(`${path}.omitIfMissing_must_be_boolean`);
|
if (value.omitIfMissing !== undefined && typeof value.omitIfMissing !== "boolean") errors.push(`${path}.omitIfMissing_must_be_boolean`);
|
||||||
|
if (value.omitIfInvalid !== undefined && typeof value.omitIfInvalid !== "boolean") errors.push(`${path}.omitIfInvalid_must_be_boolean`);
|
||||||
|
if (value.minimum !== undefined || value.maximum !== undefined) {
|
||||||
|
if (value.coerce !== "number") errors.push(`${path}.range_requires_number_coercion`);
|
||||||
|
if (value.minimum !== undefined && !Number.isFinite(value.minimum)) errors.push(`${path}.minimum_invalid`);
|
||||||
|
if (value.maximum !== undefined && !Number.isFinite(value.maximum)) errors.push(`${path}.maximum_invalid`);
|
||||||
|
if (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum) {
|
||||||
|
errors.push(`${path}.range_invalid`);
|
||||||
|
}
|
||||||
|
if (value.omitIfInvalid !== true) errors.push(`${path}.range_requires_omitIfInvalid`);
|
||||||
|
} else if (value.omitIfInvalid !== undefined) {
|
||||||
|
errors.push(`${path}.omitIfInvalid_requires_range`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateDerivation(value, path, errors) {
|
function validateDerivation(value, path, errors) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import {
|
||||||
geliosUnitsCurrentFixtureV1,
|
geliosUnitsCurrentFixtureV1,
|
||||||
} from "../providers/gelios/v1/index.mjs";
|
} from "../providers/gelios/v1/index.mjs";
|
||||||
import { geliosProviderPackageV2 } from "../providers/gelios/v2/index.mjs";
|
import { geliosProviderPackageV2 } from "../providers/gelios/v2/index.mjs";
|
||||||
|
import { geliosProviderPackageV3 } from "../providers/gelios/v3/index.mjs";
|
||||||
|
import { geliosProviderPackageV4 } from "../providers/gelios/v4/index.mjs";
|
||||||
|
import { geliosProviderPackageV5 } from "../providers/gelios/v5/index.mjs";
|
||||||
import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs";
|
import { normalizeDataProductDefinition } from "../../../services/external-data-plane/src/data-product-policy.mjs";
|
||||||
|
|
||||||
const expectedFields = [
|
const expectedFields = [
|
||||||
|
|
@ -35,6 +38,197 @@ const expectedFields = [
|
||||||
|
|
||||||
assert.deepEqual(validateProviderPackage(geliosProviderPackageV1), { ok: true, errors: [] });
|
assert.deepEqual(validateProviderPackage(geliosProviderPackageV1), { ok: true, errors: [] });
|
||||||
assert.deepEqual(validateProviderPackage(geliosProviderPackageV2), { ok: true, errors: [] });
|
assert.deepEqual(validateProviderPackage(geliosProviderPackageV2), { ok: true, errors: [] });
|
||||||
|
assert.deepEqual(validateProviderPackage(geliosProviderPackageV3), { ok: true, errors: [] });
|
||||||
|
assert.deepEqual(validateProviderPackage(geliosProviderPackageV4), { ok: true, errors: [] });
|
||||||
|
assert.deepEqual(validateProviderPackage(geliosProviderPackageV5), { ok: true, errors: [] });
|
||||||
|
|
||||||
|
const strictProduct = geliosProviderPackageV5.dataProducts[0];
|
||||||
|
const registeredStrictProduct = JSON.parse(await readFile(new URL(
|
||||||
|
"../../../services/external-data-plane/definitions/fleet.positions.current.v4.json",
|
||||||
|
import.meta.url,
|
||||||
|
), "utf8"));
|
||||||
|
const geliosOntologyEntities = JSON.parse(await readFile(new URL(
|
||||||
|
"../../../services/ontology-core/catalog/domain-packages/gelios/entities.json",
|
||||||
|
import.meta.url,
|
||||||
|
), "utf8"));
|
||||||
|
assert.equal(geliosProviderPackageV5.id, "gelios.provider.v5");
|
||||||
|
assert.equal(strictProduct.id, "fleet.positions.current.v4");
|
||||||
|
assert.deepEqual(strictProduct, registeredStrictProduct);
|
||||||
|
assert.deepEqual(strictProduct.fieldContracts.signal_state.enum, ["active", "inactive"]);
|
||||||
|
assert.deepEqual(strictProduct.fieldContracts.movement_state.enum, ["moving", "stopped"]);
|
||||||
|
for (const ontologyEntityId of ["gelios.signal_state", "gelios.movement_state"]) {
|
||||||
|
const valueContract = geliosOntologyEntities.entities.find((entity) => entity.id === ontologyEntityId)?.valueContract;
|
||||||
|
assert.ok(valueContract, `${ontologyEntityId} must expose an Ontology valueContract`);
|
||||||
|
assert.deepEqual(
|
||||||
|
strictProduct.fieldContracts[valueContract.field].enum,
|
||||||
|
valueContract.values.map(({ value: state }) => state),
|
||||||
|
`${valueContract.field} Data Product enum must equal the Ontology value contract`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const withInvalidClosedStateDefault = structuredClone(geliosProviderPackageV5);
|
||||||
|
withInvalidClosedStateDefault.mappingContracts[0].derivations.signal_state.default = "invented";
|
||||||
|
assert.equal(
|
||||||
|
validateProviderPackage(withInvalidClosedStateDefault).errors.includes(
|
||||||
|
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.signal_state.derivation.default_not_in_field_contract_enum",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const withInvalidClosedStateRule = structuredClone(geliosProviderPackageV5);
|
||||||
|
withInvalidClosedStateRule.mappingContracts[0].derivations.movement_state.rules[0] = "integer_speed_gt_threshold.teleporting";
|
||||||
|
assert.equal(
|
||||||
|
validateProviderPackage(withInvalidClosedStateRule).errors.includes(
|
||||||
|
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.movement_state.derivation.rules[0]_not_in_field_contract_enum",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const withOptionalRequiredState = structuredClone(geliosProviderPackageV5);
|
||||||
|
withOptionalRequiredState.mappingContracts[0].fact.attributes.signal_state.omitIfMissing = true;
|
||||||
|
assert.equal(
|
||||||
|
validateProviderPackage(withOptionalRequiredState).errors.includes(
|
||||||
|
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.signal_state_required_but_mapping_can_omit",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const withMalformedFieldContract = structuredClone(geliosProviderPackageV5);
|
||||||
|
withMalformedFieldContract.dataProducts[0].fieldContracts.signal_state = null;
|
||||||
|
assert.doesNotThrow(() => validateProviderPackage(withMalformedFieldContract));
|
||||||
|
assert.equal(validateProviderPackage(withMalformedFieldContract).ok, false);
|
||||||
|
|
||||||
|
const withUnboundedNumericMapping = structuredClone(geliosProviderPackageV5);
|
||||||
|
delete withUnboundedNumericMapping.mappingContracts[0].fact.attributes.speed_kph.minimum;
|
||||||
|
assert.equal(
|
||||||
|
validateProviderPackage(withUnboundedNumericMapping).errors.includes(
|
||||||
|
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.speed_kph.minimum_must_match_field_contract",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const withNonOmittingBoundedMapping = structuredClone(geliosProviderPackageV5);
|
||||||
|
withNonOmittingBoundedMapping.mappingContracts[0].fact.attributes.course_degrees.omitIfInvalid = false;
|
||||||
|
assert.equal(
|
||||||
|
validateProviderPackage(withNonOmittingBoundedMapping).errors.includes(
|
||||||
|
"mappingContract.gelios.units.to.fleet.positions.current.v5.fact.attributes.course_degrees.bounded_number_must_omit_invalid",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canonicalMonitoringProduct = geliosProviderPackageV4.dataProducts[0];
|
||||||
|
const canonicalMonitoringMapping = geliosProviderPackageV4.mappingContracts[0];
|
||||||
|
const registeredCanonicalMonitoringProduct = JSON.parse(await readFile(new URL(
|
||||||
|
"../../../services/external-data-plane/definitions/fleet.positions.current.v3.json",
|
||||||
|
import.meta.url,
|
||||||
|
), "utf8"));
|
||||||
|
assert.equal(geliosProviderPackageV4.id, "gelios.provider.v4");
|
||||||
|
assert.equal(geliosProviderPackageV4.version, "4.0.0");
|
||||||
|
assert.deepEqual(geliosProviderPackageV4.manifest.ontology, {
|
||||||
|
packageId: "gelios",
|
||||||
|
revision: "ontology.gelios.v1_1",
|
||||||
|
});
|
||||||
|
assert.deepEqual(geliosProviderPackageV4.collectionProfiles[0].capabilityIds, [
|
||||||
|
"gelios.monitoring_config.current.read",
|
||||||
|
"gelios.units.current.read",
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
geliosProviderPackageV4.capabilities.find((item) => item.id === "gelios.monitoring_config.current.read").request.path,
|
||||||
|
"/api/v1/users/me/monitoring-config",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
geliosProviderPackageV4.capabilities.find((item) => item.id === "gelios.units.current.read").request.query.incltrip,
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
assert.deepEqual(canonicalMonitoringProduct, registeredCanonicalMonitoringProduct);
|
||||||
|
assert.deepEqual(canonicalMonitoringProduct.fields.filter((field) => field.endsWith("_state")), [
|
||||||
|
"movement_state",
|
||||||
|
"signal_state",
|
||||||
|
]);
|
||||||
|
for (const forbiddenField of [
|
||||||
|
"availability_state",
|
||||||
|
"freshness_state",
|
||||||
|
"operational_status",
|
||||||
|
"position_state",
|
||||||
|
"state_policy_version",
|
||||||
|
]) {
|
||||||
|
assert.equal(canonicalMonitoringProduct.fields.includes(forbiddenField), false);
|
||||||
|
}
|
||||||
|
assert.deepEqual(canonicalMonitoringMapping.derivations.signal_state.rules, [
|
||||||
|
"last_message_missing.inactive",
|
||||||
|
"last_message_age_lt_resolved_monitoring_limit.active",
|
||||||
|
"otherwise.inactive",
|
||||||
|
]);
|
||||||
|
assert.equal(canonicalMonitoringMapping.derivations.signal_state.default, "inactive");
|
||||||
|
assert.equal(canonicalMonitoringMapping.derivations.signal_state.parameters.activeDurationPath, "signalActiveDuration");
|
||||||
|
assert.equal(
|
||||||
|
canonicalMonitoringMapping.derivations.signal_state.parameters.somewhatInactiveDurationPath,
|
||||||
|
"signalSomewhatInactiveDuration",
|
||||||
|
);
|
||||||
|
assert.equal(canonicalMonitoringMapping.derivations.signal_state.parameters.collectionProfileFallbackSeconds, 120);
|
||||||
|
assert.equal(
|
||||||
|
canonicalMonitoringMapping.derivations.signal_state.parameters.thresholdResolution,
|
||||||
|
"somewhatInactive_then_active_then_profileFallback",
|
||||||
|
);
|
||||||
|
assert.equal(canonicalMonitoringMapping.derivations.movement_state.parameters.movingThresholdKph, 2);
|
||||||
|
assert.equal(canonicalMonitoringMapping.derivations.movement_state.default, "stopped");
|
||||||
|
assert.deepEqual(
|
||||||
|
geliosProviderPackageV4.l2Templates[0].steps.map((step) => step.kind),
|
||||||
|
[
|
||||||
|
"collection_trigger",
|
||||||
|
"provider_request",
|
||||||
|
"provider_request",
|
||||||
|
"extract_items",
|
||||||
|
"semantic_mapping",
|
||||||
|
"data_product_publish",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stateAwareProduct = geliosProviderPackageV3.dataProducts[0];
|
||||||
|
const stateAwareMapping = geliosProviderPackageV3.mappingContracts[0];
|
||||||
|
const registeredStateAwareProduct = JSON.parse(await readFile(new URL(
|
||||||
|
"../../../services/external-data-plane/definitions/fleet.positions.current.v2.json",
|
||||||
|
import.meta.url,
|
||||||
|
), "utf8"));
|
||||||
|
assert.equal(geliosProviderPackageV3.id, "gelios.provider.v3");
|
||||||
|
assert.equal(geliosProviderPackageV3.version, "3.0.0");
|
||||||
|
assert.equal(geliosProviderPackageV3.authModes[0].id, "gelios.rest-rotating-bearer.v3");
|
||||||
|
assert.deepEqual(geliosProviderPackageV3.authModes[0].transport, {
|
||||||
|
placement: "header",
|
||||||
|
name: "Authorization",
|
||||||
|
});
|
||||||
|
assert.deepEqual(geliosProviderPackageV3.authModes[0].tokenLifecycle, {
|
||||||
|
artifacts: ["access", "refresh"],
|
||||||
|
requestArtifact: "access",
|
||||||
|
refreshMode: "runtime_managed",
|
||||||
|
});
|
||||||
|
assert.equal(geliosProviderPackageV3.capabilities[0].request.baseUrl, "https://api.geliospro.com");
|
||||||
|
assert.equal(geliosProviderPackageV3.capabilities[0].request.path, "/api/v1/units");
|
||||||
|
assert.deepEqual(geliosProviderPackageV3.capabilities[0].request.query, {});
|
||||||
|
assert.equal(
|
||||||
|
geliosProviderPackageV3.l2Templates[0].credentialBindings.find((binding) => binding.role === "provider").authModeId,
|
||||||
|
"gelios.rest-rotating-bearer.v3",
|
||||||
|
);
|
||||||
|
assert.equal(stateAwareProduct.id, "fleet.positions.current.v2");
|
||||||
|
assert.equal(stateAwareProduct.version, "2.0.0");
|
||||||
|
assert.equal(stateAwareProduct.ontologyRevision, "ontology.map.moving_object.v2");
|
||||||
|
assert.deepEqual(stateAwareProduct, registeredStateAwareProduct);
|
||||||
|
assert.deepEqual(
|
||||||
|
["availability_state", "freshness_state", "motion_state", "position_state"].map((field) => (
|
||||||
|
stateAwareMapping.fact.attributes[field]
|
||||||
|
)),
|
||||||
|
[
|
||||||
|
{ derive: "availability_state" },
|
||||||
|
{ derive: "freshness_state" },
|
||||||
|
{ derive: "motion_state" },
|
||||||
|
{ derive: "position_state" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(stateAwareMapping.fact.attributes.state_policy_version.constant, "map-moving-object-state/v1");
|
||||||
|
assert.equal(stateAwareMapping.derivations.motion_state.parameters.movingThresholdKph, 1);
|
||||||
|
assert.equal(stateAwareMapping.derivations.position_state.parameters.minSatelliteCount, 4);
|
||||||
|
assert.equal(stateAwareMapping.derivations.position_state.parameters.maxHdop, 5);
|
||||||
|
assert.equal(stateAwareMapping.derivations.position_state.parameters.maxHorizontalAccuracyMeters, 100);
|
||||||
|
|
||||||
const geliosSdkAuth = geliosProviderPackageV2.authModes[0];
|
const geliosSdkAuth = geliosProviderPackageV2.authModes[0];
|
||||||
const geliosSdkUnitsRead = geliosProviderPackageV2.capabilities[0];
|
const geliosSdkUnitsRead = geliosProviderPackageV2.capabilities[0];
|
||||||
|
|
@ -318,7 +512,7 @@ for (const profile of withUnexecutedSecondCapability.collectionProfiles) {
|
||||||
}
|
}
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateProviderPackage(withUnexecutedSecondCapability).errors.includes(
|
validateProviderPackage(withUnexecutedSecondCapability).errors.includes(
|
||||||
"collectionProfile.gelios.positions.current.realtime.v1.capabilityIds_must_select_one_v1_read",
|
"collectionProfile.gelios.positions.current.realtime.v1.template_capability_chain_mismatch",
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"id": "fleet.positions.current.v2",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"ontologyRevision": "ontology.map.moving_object.v2",
|
||||||
|
"deliveryMode": "snapshot+patch",
|
||||||
|
"semanticTypes": [
|
||||||
|
"map.moving_object"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
"availability_state",
|
||||||
|
"course_degrees",
|
||||||
|
"display_name",
|
||||||
|
"elevation_meters",
|
||||||
|
"freshness_state",
|
||||||
|
"geometry",
|
||||||
|
"hdop",
|
||||||
|
"horizontal_accuracy_meters",
|
||||||
|
"motion_state",
|
||||||
|
"object_kind",
|
||||||
|
"operational_status",
|
||||||
|
"position_source",
|
||||||
|
"position_state",
|
||||||
|
"position_valid",
|
||||||
|
"quality_flags",
|
||||||
|
"satellite_count",
|
||||||
|
"speed_kph",
|
||||||
|
"state_policy_version"
|
||||||
|
],
|
||||||
|
"history": {
|
||||||
|
"mode": "sampled",
|
||||||
|
"intervalMs": 60000,
|
||||||
|
"strategy": "latest-per-entity-per-bucket",
|
||||||
|
"retentionDays": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"id": "fleet.positions.current.v3",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"ontologyRevision": "ontology.map.moving_object.v3",
|
||||||
|
"deliveryMode": "snapshot+patch",
|
||||||
|
"semanticTypes": [
|
||||||
|
"map.moving_object"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
"course_degrees",
|
||||||
|
"display_name",
|
||||||
|
"elevation_meters",
|
||||||
|
"geometry",
|
||||||
|
"hdop",
|
||||||
|
"horizontal_accuracy_meters",
|
||||||
|
"movement_state",
|
||||||
|
"object_kind",
|
||||||
|
"position_source",
|
||||||
|
"satellite_count",
|
||||||
|
"signal_state",
|
||||||
|
"speed_kph"
|
||||||
|
],
|
||||||
|
"history": {
|
||||||
|
"mode": "sampled",
|
||||||
|
"intervalMs": 60000,
|
||||||
|
"strategy": "latest-per-entity-per-bucket",
|
||||||
|
"retentionDays": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
{
|
||||||
|
"id": "fleet.positions.current.v4",
|
||||||
|
"version": "4.0.0",
|
||||||
|
"ontologyRevision": "ontology.map.moving_object.v3",
|
||||||
|
"deliveryMode": "snapshot+patch",
|
||||||
|
"semanticTypes": [
|
||||||
|
"map.moving_object"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
"course_degrees",
|
||||||
|
"display_name",
|
||||||
|
"elevation_meters",
|
||||||
|
"geometry",
|
||||||
|
"hdop",
|
||||||
|
"horizontal_accuracy_meters",
|
||||||
|
"movement_state",
|
||||||
|
"object_kind",
|
||||||
|
"position_source",
|
||||||
|
"satellite_count",
|
||||||
|
"signal_state",
|
||||||
|
"speed_kph"
|
||||||
|
],
|
||||||
|
"fieldContracts": {
|
||||||
|
"course_degrees": {
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 360
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"elevation_meters": {
|
||||||
|
"type": "number",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"type": "point",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"hdop": {
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"horizontal_accuracy_meters": {
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"movement_state": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"enum": [
|
||||||
|
"moving",
|
||||||
|
"stopped"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"object_kind": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"position_source": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"satellite_count": {
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"signal_state": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"enum": [
|
||||||
|
"active",
|
||||||
|
"inactive"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"speed_kph": {
|
||||||
|
"type": "number",
|
||||||
|
"required": false,
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"mode": "sampled",
|
||||||
|
"intervalMs": 60000,
|
||||||
|
"strategy": "latest-per-entity-per-bucket",
|
||||||
|
"retentionDays": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"start": "node src/server.mjs",
|
"start": "node src/server.mjs",
|
||||||
"dev": "node --watch src/server.mjs",
|
"dev": "node --watch src/server.mjs",
|
||||||
"check": "node --check src/server.mjs && node --check src/schema.mjs && node --check src/config.mjs && node --check src/intake-policy.mjs && node --check src/writer-binding.mjs && node --check src/reader-binding.mjs && node --check src/reader-source-scope.mjs && node --check src/data-product-policy.mjs && node --check src/data-product-delivery.mjs && node --check src/definitions.mjs && node --check src/managed-provisioner-auth.mjs",
|
"check": "node --check src/server.mjs && node --check src/schema.mjs && node --check src/config.mjs && node --check src/intake-policy.mjs && node --check src/writer-binding.mjs && node --check src/reader-binding.mjs && node --check src/reader-source-scope.mjs && node --check src/data-product-policy.mjs && node --check src/data-product-delivery.mjs && node --check src/definitions.mjs && node --check src/managed-provisioner-auth.mjs",
|
||||||
"test": "node test/config.test.mjs && node test/managed-provisioner-auth.test.mjs && node test/intake-policy.test.mjs && node test/writer-binding.test.mjs && node test/reader-binding.test.mjs && node test/reader-source-scope.test.mjs && node test/data-product-policy.test.mjs && node test/definitions.test.mjs",
|
"test": "node test/config.test.mjs && node test/managed-provisioner-auth.test.mjs && node test/intake-policy.test.mjs && node test/writer-binding.test.mjs && node test/reader-binding.test.mjs && node test/reader-source-scope.test.mjs && node test/data-product-policy.test.mjs && node test/data-product-delivery.test.mjs && node test/definitions.test.mjs",
|
||||||
"test:integration": "node test/data-product-delivery.integration.test.mjs && node test/api.integration.test.mjs",
|
"test:integration": "node test/data-product-delivery.integration.test.mjs && node test/api.integration.test.mjs",
|
||||||
"test:all": "npm test && npm run test:integration"
|
"test:all": "npm test && npm run test:integration"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export async function loadDataProductDefinition(db, dataProductId, { activeOnly
|
||||||
const result = await db.query(
|
const result = await db.query(
|
||||||
`select id, version, ontology_revision as "ontologyRevision",
|
`select id, version, ontology_revision as "ontologyRevision",
|
||||||
delivery_mode as "deliveryMode", semantic_types as "semanticTypes",
|
delivery_mode as "deliveryMode", semantic_types as "semanticTypes",
|
||||||
fields, history_policy as "historyPolicy", active,
|
fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active,
|
||||||
created_at as "createdAt", updated_at as "updatedAt"
|
created_at as "createdAt", updated_at as "updatedAt"
|
||||||
from external_data_plane_products
|
from external_data_plane_products
|
||||||
where id = $1 ${activeOnly ? "and active = true" : ""}`,
|
where id = $1 ${activeOnly ? "and active = true" : ""}`,
|
||||||
|
|
@ -25,8 +25,8 @@ export async function loadDataProductDefinition(db, dataProductId, { activeOnly
|
||||||
export async function persistDataProductDefinition(db, definition) {
|
export async function persistDataProductDefinition(db, definition) {
|
||||||
const result = await db.query(
|
const result = await db.query(
|
||||||
`insert into external_data_plane_products (
|
`insert into external_data_plane_products (
|
||||||
id, version, ontology_revision, delivery_mode, semantic_types, fields, history_policy
|
id, version, ontology_revision, delivery_mode, semantic_types, fields, field_contracts, history_policy
|
||||||
) values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb)
|
) values ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb)
|
||||||
on conflict (id) do update set
|
on conflict (id) do update set
|
||||||
active = true,
|
active = true,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
|
|
@ -35,10 +35,11 @@ export async function persistDataProductDefinition(db, definition) {
|
||||||
and external_data_plane_products.delivery_mode = excluded.delivery_mode
|
and external_data_plane_products.delivery_mode = excluded.delivery_mode
|
||||||
and external_data_plane_products.semantic_types = excluded.semantic_types
|
and external_data_plane_products.semantic_types = excluded.semantic_types
|
||||||
and external_data_plane_products.fields = excluded.fields
|
and external_data_plane_products.fields = excluded.fields
|
||||||
|
and external_data_plane_products.field_contracts = excluded.field_contracts
|
||||||
and external_data_plane_products.history_policy = excluded.history_policy
|
and external_data_plane_products.history_policy = excluded.history_policy
|
||||||
returning id, version, ontology_revision as "ontologyRevision",
|
returning id, version, ontology_revision as "ontologyRevision",
|
||||||
delivery_mode as "deliveryMode", semantic_types as "semanticTypes",
|
delivery_mode as "deliveryMode", semantic_types as "semanticTypes",
|
||||||
fields, history_policy as "historyPolicy", active,
|
fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active,
|
||||||
created_at as "createdAt", updated_at as "updatedAt"`,
|
created_at as "createdAt", updated_at as "updatedAt"`,
|
||||||
[
|
[
|
||||||
definition.id,
|
definition.id,
|
||||||
|
|
@ -47,6 +48,7 @@ export async function persistDataProductDefinition(db, definition) {
|
||||||
definition.deliveryMode,
|
definition.deliveryMode,
|
||||||
JSON.stringify(definition.semanticTypes),
|
JSON.stringify(definition.semanticTypes),
|
||||||
JSON.stringify(definition.fields),
|
JSON.stringify(definition.fields),
|
||||||
|
JSON.stringify(definition.fieldContracts || {}),
|
||||||
JSON.stringify(definition.history),
|
JSON.stringify(definition.history),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -658,6 +660,8 @@ function publishFingerprint(batch) {
|
||||||
export function assertPublishMatchesDefinition(batch, definition) {
|
export function assertPublishMatchesDefinition(batch, definition) {
|
||||||
const semanticTypes = new Set(Array.isArray(definition?.semanticTypes) ? definition.semanticTypes : []);
|
const semanticTypes = new Set(Array.isArray(definition?.semanticTypes) ? definition.semanticTypes : []);
|
||||||
const fields = new Set(Array.isArray(definition?.fields) ? definition.fields : []);
|
const fields = new Set(Array.isArray(definition?.fields) ? definition.fields : []);
|
||||||
|
const fieldContracts = definition?.fieldContracts && typeof definition.fieldContracts === "object"
|
||||||
|
&& !Array.isArray(definition.fieldContracts) ? definition.fieldContracts : {};
|
||||||
const entityKeys = new Set();
|
const entityKeys = new Set();
|
||||||
for (const fact of batch?.facts || []) {
|
for (const fact of batch?.facts || []) {
|
||||||
if (!semanticTypes.has(fact.semanticType)) throw deliveryError("data_product_semantic_type_forbidden", 422);
|
if (!semanticTypes.has(fact.semanticType)) throw deliveryError("data_product_semantic_type_forbidden", 422);
|
||||||
|
|
@ -672,9 +676,55 @@ export function assertPublishMatchesDefinition(batch, definition) {
|
||||||
if (fact.geometry !== undefined && !geometryDeclared(fields)) {
|
if (fact.geometry !== undefined && !geometryDeclared(fields)) {
|
||||||
throw deliveryError("data_product_geometry_forbidden", 422);
|
throw deliveryError("data_product_geometry_forbidden", 422);
|
||||||
}
|
}
|
||||||
|
for (const [field, contract] of Object.entries(fieldContracts)) {
|
||||||
|
const resolved = resolveContractField(fact, field);
|
||||||
|
if (!resolved.present) {
|
||||||
|
if (contract.required === true) throw deliveryError("data_product_field_required", 422);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertFieldContractValue(resolved.value, contract);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveContractField(fact, field) {
|
||||||
|
if (field === "geometry") return { present: fact.geometry !== undefined, value: fact.geometry };
|
||||||
|
if (field === "source_id") return { present: fact.sourceId !== undefined, value: fact.sourceId };
|
||||||
|
if (field === "semantic_type") return { present: fact.semanticType !== undefined, value: fact.semanticType };
|
||||||
|
if (field === "observed_at") return { present: fact.observedAt !== undefined, value: fact.observedAt };
|
||||||
|
const attribute = field.startsWith("attributes.") ? field.slice("attributes.".length) : field;
|
||||||
|
const attributes = fact.attributes || {};
|
||||||
|
return { present: Object.hasOwn(attributes, attribute), value: attributes[attribute] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFieldContractValue(value, contract) {
|
||||||
|
if (!fieldContractValueMatchesType(value, contract.type)) {
|
||||||
|
throw deliveryError("data_product_field_type_invalid", 422);
|
||||||
|
}
|
||||||
|
if (Array.isArray(contract.enum) && !contract.enum.some((allowed) => Object.is(allowed, value))) {
|
||||||
|
throw deliveryError("data_product_field_value_forbidden", 422);
|
||||||
|
}
|
||||||
|
if (typeof value === "number" && (
|
||||||
|
(Number.isFinite(contract.minimum) && value < contract.minimum)
|
||||||
|
|| (Number.isFinite(contract.maximum) && value > contract.maximum)
|
||||||
|
)) throw deliveryError("data_product_field_value_out_of_range", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldContractValueMatchesType(value, type) {
|
||||||
|
if (type === "string_array") return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||||
|
if (type === "point") {
|
||||||
|
return value?.type === "Point"
|
||||||
|
&& Array.isArray(value.coordinates)
|
||||||
|
&& value.coordinates.length === 2
|
||||||
|
&& value.coordinates.every(Number.isFinite)
|
||||||
|
&& value.coordinates[0] >= -180
|
||||||
|
&& value.coordinates[0] <= 180
|
||||||
|
&& value.coordinates[1] >= -90
|
||||||
|
&& value.coordinates[1] <= 90;
|
||||||
|
}
|
||||||
|
return typeof value === type && (type !== "number" || Number.isFinite(value));
|
||||||
|
}
|
||||||
|
|
||||||
function geometryDeclared(fields) {
|
function geometryDeclared(fields) {
|
||||||
return fields.has("geometry")
|
return fields.has("geometry")
|
||||||
|| fields.has("coordinates")
|
|| fields.has("coordinates")
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||||
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
||||||
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
|
const DELIVERY_MODES = new Set(["snapshot", "snapshot+patch", "query"]);
|
||||||
const HISTORY_MODES = new Set(["none", "all", "sampled"]);
|
const HISTORY_MODES = new Set(["none", "all", "sampled"]);
|
||||||
|
const FIELD_CONTRACT_TYPES = new Set(["string", "number", "boolean", "string_array", "point"]);
|
||||||
const DEFINITION_KEYS = new Set([
|
const DEFINITION_KEYS = new Set([
|
||||||
"id", "version", "ontologyRevision", "deliveryMode", "semanticTypes", "fields", "history",
|
"id", "version", "ontologyRevision", "deliveryMode", "semanticTypes", "fields", "fieldContracts", "history",
|
||||||
]);
|
]);
|
||||||
const HISTORY_KEYS = new Set(["mode", "intervalMs", "retentionDays", "strategy"]);
|
const HISTORY_KEYS = new Set(["mode", "intervalMs", "retentionDays", "strategy"]);
|
||||||
|
const FIELD_CONTRACT_KEYS = new Set(["type", "required", "enum", "minimum", "maximum"]);
|
||||||
|
|
||||||
export function normalizeDataProductDefinition(value) {
|
export function normalizeDataProductDefinition(value) {
|
||||||
if (!isPlainObject(value) || !hasOnlyKeys(value, DEFINITION_KEYS)) {
|
if (!isPlainObject(value) || !hasOnlyKeys(value, DEFINITION_KEYS)) {
|
||||||
|
|
@ -30,8 +32,58 @@ export function normalizeDataProductDefinition(value) {
|
||||||
}
|
}
|
||||||
if (!semanticTypes.length || !fields.length) throw policyError("data_product_definition_shape_invalid");
|
if (!semanticTypes.length || !fields.length) throw policyError("data_product_definition_shape_invalid");
|
||||||
|
|
||||||
|
const fieldContracts = normalizeFieldContracts(value.fieldContracts, fields);
|
||||||
const history = normalizeHistoryPolicy(value.history);
|
const history = normalizeHistoryPolicy(value.history);
|
||||||
return Object.freeze({ id, version, ontologyRevision, deliveryMode, semanticTypes, fields, history });
|
return Object.freeze({ id, version, ontologyRevision, deliveryMode, semanticTypes, fields, fieldContracts, history });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFieldContracts(value, fields) {
|
||||||
|
if (value === undefined) return Object.freeze({});
|
||||||
|
if (!isPlainObject(value)) throw policyError("data_product_field_contracts_invalid");
|
||||||
|
const names = Object.keys(value);
|
||||||
|
if (names.length === 0) return Object.freeze({});
|
||||||
|
if (names.length !== fields.length || fields.some((field) => !Object.hasOwn(value, field))) {
|
||||||
|
throw policyError("data_product_field_contracts_must_match_fields");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = {};
|
||||||
|
for (const field of fields) normalized[field] = normalizeFieldContract(value[field]);
|
||||||
|
return Object.freeze(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFieldContract(value) {
|
||||||
|
if (!isPlainObject(value) || !hasOnlyKeys(value, FIELD_CONTRACT_KEYS)) {
|
||||||
|
throw policyError("data_product_field_contract_invalid");
|
||||||
|
}
|
||||||
|
const type = string(value.type);
|
||||||
|
if (!FIELD_CONTRACT_TYPES.has(type) || typeof value.required !== "boolean") {
|
||||||
|
throw policyError("data_product_field_contract_shape_invalid");
|
||||||
|
}
|
||||||
|
const contract = { type, required: value.required };
|
||||||
|
if (value.enum !== undefined) {
|
||||||
|
if (!Array.isArray(value.enum) || value.enum.length === 0
|
||||||
|
|| new Set(value.enum.map(stableLiteral)).size !== value.enum.length
|
||||||
|
|| new Set(["point", "string_array"]).has(type)
|
||||||
|
|| value.enum.some((item) => !fieldContractValueMatchesType(item, type))) {
|
||||||
|
throw policyError("data_product_field_contract_enum_invalid");
|
||||||
|
}
|
||||||
|
contract.enum = Object.freeze([...value.enum].sort((left, right) => stableLiteral(left).localeCompare(stableLiteral(right))));
|
||||||
|
}
|
||||||
|
if (value.minimum !== undefined || value.maximum !== undefined) {
|
||||||
|
if (type !== "number"
|
||||||
|
|| (value.minimum !== undefined && !Number.isFinite(value.minimum))
|
||||||
|
|| (value.maximum !== undefined && !Number.isFinite(value.maximum))
|
||||||
|
|| (Number.isFinite(value.minimum) && Number.isFinite(value.maximum) && value.minimum > value.maximum)) {
|
||||||
|
throw policyError("data_product_field_contract_range_invalid");
|
||||||
|
}
|
||||||
|
if (value.minimum !== undefined) contract.minimum = value.minimum;
|
||||||
|
if (value.maximum !== undefined) contract.maximum = value.maximum;
|
||||||
|
if (contract.enum?.some((item) => (
|
||||||
|
(contract.minimum !== undefined && item < contract.minimum)
|
||||||
|
|| (contract.maximum !== undefined && item > contract.maximum)
|
||||||
|
))) throw policyError("data_product_field_contract_enum_out_of_range");
|
||||||
|
}
|
||||||
|
return Object.freeze(contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeHistoryPolicy(value = { mode: "none" }) {
|
export function normalizeHistoryPolicy(value = { mode: "none" }) {
|
||||||
|
|
@ -62,6 +114,7 @@ export function safeDataProductDefinition(row) {
|
||||||
deliveryMode: row.deliveryMode,
|
deliveryMode: row.deliveryMode,
|
||||||
semanticTypes: array(row.semanticTypes),
|
semanticTypes: array(row.semanticTypes),
|
||||||
fields: array(row.fields),
|
fields: array(row.fields),
|
||||||
|
fieldContracts: isPlainObject(row.fieldContracts) ? row.fieldContracts : {},
|
||||||
history: isPlainObject(row.historyPolicy) ? row.historyPolicy : {},
|
history: isPlainObject(row.historyPolicy) ? row.historyPolicy : {},
|
||||||
active: row.active === true,
|
active: row.active === true,
|
||||||
createdAt: iso(row.createdAt),
|
createdAt: iso(row.createdAt),
|
||||||
|
|
@ -116,3 +169,11 @@ function isPlainObject(value) {
|
||||||
function hasOnlyKeys(value, allowed) {
|
function hasOnlyKeys(value, allowed) {
|
||||||
return Object.keys(value).every((key) => allowed.has(key));
|
return Object.keys(value).every((key) => allowed.has(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldContractValueMatchesType(value, type) {
|
||||||
|
return typeof value === type && (type !== "number" || Number.isFinite(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableLiteral(value) {
|
||||||
|
return `${typeof value}:${JSON.stringify(value)}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,33 @@ export async function migrate(pool) {
|
||||||
delivery_mode text not null,
|
delivery_mode text not null,
|
||||||
semantic_types jsonb not null,
|
semantic_types jsonb not null,
|
||||||
fields jsonb not null,
|
fields jsonb not null,
|
||||||
|
field_contracts jsonb not null default '{}'::jsonb,
|
||||||
history_policy jsonb not null,
|
history_policy jsonb not null,
|
||||||
active boolean not null default true,
|
active boolean not null default true,
|
||||||
created_at timestamptz not null default now(),
|
created_at timestamptz not null default now(),
|
||||||
updated_at timestamptz not null default now(),
|
updated_at timestamptz not null default now(),
|
||||||
check (jsonb_typeof(semantic_types) = 'array' and jsonb_array_length(semantic_types) > 0),
|
check (jsonb_typeof(semantic_types) = 'array' and jsonb_array_length(semantic_types) > 0),
|
||||||
check (jsonb_typeof(fields) = 'array' and jsonb_array_length(fields) > 0)
|
check (jsonb_typeof(fields) = 'array' and jsonb_array_length(fields) > 0),
|
||||||
|
constraint external_data_plane_products_field_contracts_object_ck
|
||||||
|
check (jsonb_typeof(field_contracts) = 'object')
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
await pool.query("alter table external_data_plane_products add column if not exists field_contracts jsonb not null default '{}'::jsonb");
|
||||||
|
await pool.query(`
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from pg_constraint
|
||||||
|
where conrelid = 'external_data_plane_products'::regclass
|
||||||
|
and conname = 'external_data_plane_products_field_contracts_object_ck'
|
||||||
|
) then
|
||||||
|
alter table external_data_plane_products
|
||||||
|
add constraint external_data_plane_products_field_contracts_object_ck
|
||||||
|
check (jsonb_typeof(field_contracts) = 'object');
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
create table if not exists external_data_plane_batches (
|
create table if not exists external_data_plane_batches (
|
||||||
|
|
|
||||||
|
|
@ -1179,7 +1179,7 @@ async function listGrantedProducts(binding) {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`select id, version, ontology_revision as "ontologyRevision",
|
`select id, version, ontology_revision as "ontologyRevision",
|
||||||
delivery_mode as "deliveryMode", semantic_types as "semanticTypes",
|
delivery_mode as "deliveryMode", semantic_types as "semanticTypes",
|
||||||
fields, history_policy as "historyPolicy", active,
|
fields, field_contracts as "fieldContracts", history_policy as "historyPolicy", active,
|
||||||
created_at as "createdAt", updated_at as "updatedAt"
|
created_at as "createdAt", updated_at as "updatedAt"
|
||||||
from external_data_plane_products
|
from external_data_plane_products
|
||||||
where active = true and id = any($1::text[])
|
where active = true and id = any($1::text[])
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ try {
|
||||||
deliveryMode: "snapshot+patch",
|
deliveryMode: "snapshot+patch",
|
||||||
semanticTypes: ["map.moving_object"],
|
semanticTypes: ["map.moving_object"],
|
||||||
fields: ["source_id", "observed_at", "geometry", "status"],
|
fields: ["source_id", "observed_at", "geometry", "status"],
|
||||||
|
fieldContracts: {
|
||||||
|
source_id: { type: "string", required: true },
|
||||||
|
observed_at: { type: "string", required: true },
|
||||||
|
geometry: { type: "point", required: true },
|
||||||
|
status: { type: "string", required: true },
|
||||||
|
},
|
||||||
history: {
|
history: {
|
||||||
mode: "sampled",
|
mode: "sampled",
|
||||||
intervalMs: 60_000,
|
intervalMs: 60_000,
|
||||||
|
|
@ -47,6 +53,7 @@ try {
|
||||||
});
|
});
|
||||||
await persistDataProductDefinition(pool, definition);
|
await persistDataProductDefinition(pool, definition);
|
||||||
const storedDefinition = await loadDataProductDefinition(pool, definition.id);
|
const storedDefinition = await loadDataProductDefinition(pool, definition.id);
|
||||||
|
assert.deepEqual(storedDefinition.fieldContracts, definition.fieldContracts);
|
||||||
const binding = {
|
const binding = {
|
||||||
id: "reader-binding-test",
|
id: "reader-binding-test",
|
||||||
bindingKey: "managed-reader-binding-test",
|
bindingKey: "managed-reader-binding-test",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { assertPublishMatchesDefinition } from "../src/data-product-delivery.mjs";
|
||||||
|
import { normalizeDataProductDefinition } from "../src/data-product-policy.mjs";
|
||||||
|
|
||||||
|
const definition = normalizeDataProductDefinition({
|
||||||
|
id: "test.positions.current.v2",
|
||||||
|
version: "2.0.0",
|
||||||
|
ontologyRevision: "ontology.test.positions.v1",
|
||||||
|
deliveryMode: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fields: ["display_name", "geometry", "movement_state", "signal_state", "speed_kph"],
|
||||||
|
fieldContracts: {
|
||||||
|
display_name: { type: "string", required: true },
|
||||||
|
geometry: { type: "point", required: false },
|
||||||
|
movement_state: { type: "string", required: true, enum: ["moving", "stopped"] },
|
||||||
|
signal_state: { type: "string", required: true, enum: ["active", "inactive"] },
|
||||||
|
speed_kph: { type: "number", required: false, minimum: 0 },
|
||||||
|
},
|
||||||
|
history: { mode: "none", retentionDays: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fact = {
|
||||||
|
sourceId: "unit-1",
|
||||||
|
semanticType: "map.moving_object",
|
||||||
|
observedAt: "2026-07-20T10:00:00.000Z",
|
||||||
|
geometry: { type: "Point", coordinates: [37.6173, 55.7558] },
|
||||||
|
attributes: {
|
||||||
|
display_name: "Unit 1",
|
||||||
|
movement_state: "moving",
|
||||||
|
signal_state: "active",
|
||||||
|
speed_kph: 12,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const batch = { facts: [fact] };
|
||||||
|
assert.doesNotThrow(() => assertPublishMatchesDefinition(batch, definition));
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => assertPublishMatchesDefinition({
|
||||||
|
facts: [{ ...fact, attributes: { ...fact.attributes, movement_state: "teleporting" } }],
|
||||||
|
}, definition),
|
||||||
|
(error) => error?.status === 422 && error?.code === "data_product_field_value_forbidden",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertPublishMatchesDefinition({
|
||||||
|
facts: [{ ...fact, attributes: { ...fact.attributes, signal_state: "invented" } }],
|
||||||
|
}, definition),
|
||||||
|
(error) => error?.status === 422 && error?.code === "data_product_field_value_forbidden",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertPublishMatchesDefinition({
|
||||||
|
facts: [{
|
||||||
|
...fact,
|
||||||
|
attributes: Object.fromEntries(Object.entries(fact.attributes).filter(([field]) => field !== "signal_state")),
|
||||||
|
}],
|
||||||
|
}, definition),
|
||||||
|
(error) => error?.status === 422 && error?.code === "data_product_field_required",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertPublishMatchesDefinition({
|
||||||
|
facts: [{ ...fact, attributes: { ...fact.attributes, speed_kph: "12" } }],
|
||||||
|
}, definition),
|
||||||
|
(error) => error?.status === 422 && error?.code === "data_product_field_type_invalid",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertPublishMatchesDefinition({
|
||||||
|
facts: [{ ...fact, attributes: { ...fact.attributes, speed_kph: -1 } }],
|
||||||
|
}, definition),
|
||||||
|
(error) => error?.status === 422 && error?.code === "data_product_field_value_out_of_range",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertPublishMatchesDefinition({
|
||||||
|
facts: [{ ...fact, geometry: { type: "Point", coordinates: [181, 55.7558] } }],
|
||||||
|
}, definition),
|
||||||
|
(error) => error?.status === 422 && error?.code === "data_product_field_type_invalid",
|
||||||
|
);
|
||||||
|
|
||||||
|
const legacyDefinition = normalizeDataProductDefinition({
|
||||||
|
id: "test.positions.current.v1",
|
||||||
|
version: "1.0.0",
|
||||||
|
ontologyRevision: "ontology.test.positions.v1",
|
||||||
|
deliveryMode: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fields: ["movement_state", "signal_state"],
|
||||||
|
history: { mode: "none", retentionDays: 1 },
|
||||||
|
});
|
||||||
|
assert.doesNotThrow(() => assertPublishMatchesDefinition({
|
||||||
|
facts: [{
|
||||||
|
sourceId: "unit-legacy",
|
||||||
|
semanticType: "map.moving_object",
|
||||||
|
attributes: { movement_state: "teleporting", signal_state: "invented" },
|
||||||
|
}],
|
||||||
|
}, legacyDefinition));
|
||||||
|
|
||||||
|
console.log("external-data-plane data product delivery policy: ok");
|
||||||
|
|
@ -24,6 +24,8 @@ assert.deepEqual(input.semanticTypes, ["vehicle.trike", "map.moving_object"]);
|
||||||
assert.deepEqual(input.fields, ["status", "source_id", "observed_at", "geometry"]);
|
assert.deepEqual(input.fields, ["status", "source_id", "observed_at", "geometry"]);
|
||||||
assert.equal(Object.isFrozen(definition.semanticTypes), true);
|
assert.equal(Object.isFrozen(definition.semanticTypes), true);
|
||||||
assert.equal(Object.isFrozen(definition.fields), true);
|
assert.equal(Object.isFrozen(definition.fields), true);
|
||||||
|
assert.deepEqual(definition.fieldContracts, {});
|
||||||
|
assert.equal(Object.isFrozen(definition.fieldContracts), true);
|
||||||
|
|
||||||
const reordered = normalizeDataProductDefinition({
|
const reordered = normalizeDataProductDefinition({
|
||||||
...input,
|
...input,
|
||||||
|
|
@ -78,4 +80,49 @@ assert.throws(
|
||||||
/data_product_definition_fields_duplicate/,
|
/data_product_definition_fields_duplicate/,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const strict = normalizeDataProductDefinition({
|
||||||
|
...input,
|
||||||
|
fields: ["geometry", "movement_state", "signal_state", "speed_kph"],
|
||||||
|
fieldContracts: {
|
||||||
|
geometry: { type: "point", required: false },
|
||||||
|
movement_state: { type: "string", required: true, enum: ["stopped", "moving"] },
|
||||||
|
signal_state: { type: "string", required: true, enum: ["inactive", "active"] },
|
||||||
|
speed_kph: { type: "number", required: false, minimum: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(strict.fieldContracts.movement_state.enum, ["moving", "stopped"]);
|
||||||
|
assert.equal(Object.isFrozen(strict.fieldContracts), true);
|
||||||
|
assert.equal(Object.isFrozen(strict.fieldContracts.signal_state), true);
|
||||||
|
assert.equal(Object.isFrozen(strict.fieldContracts.signal_state.enum), true);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeDataProductDefinition({
|
||||||
|
...input,
|
||||||
|
fieldContracts: { status: { type: "string", required: true } },
|
||||||
|
}),
|
||||||
|
/data_product_field_contracts_must_match_fields/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeDataProductDefinition({
|
||||||
|
...input,
|
||||||
|
fieldContracts: Object.fromEntries(input.fields.map((field) => [
|
||||||
|
field,
|
||||||
|
field === "status"
|
||||||
|
? { type: "string", required: true, enum: ["active", 1] }
|
||||||
|
: { type: field === "geometry" ? "point" : "string", required: false },
|
||||||
|
])),
|
||||||
|
}),
|
||||||
|
/data_product_field_contract_enum_invalid/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeDataProductDefinition({
|
||||||
|
...strict,
|
||||||
|
fieldContracts: {
|
||||||
|
...strict.fieldContracts,
|
||||||
|
speed_kph: { type: "number", required: false, minimum: 10, maximum: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/data_product_field_contract_range_invalid/,
|
||||||
|
);
|
||||||
|
|
||||||
console.log("external-data-plane data product policy: ok");
|
console.log("external-data-plane data product policy: ok");
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,12 @@ import { join } from "node:path";
|
||||||
import { loadDataProductDefinitions } from "../src/definitions.mjs";
|
import { loadDataProductDefinitions } from "../src/definitions.mjs";
|
||||||
|
|
||||||
const bundled = await loadDataProductDefinitions();
|
const bundled = await loadDataProductDefinitions();
|
||||||
assert.deepEqual(bundled.map((definition) => definition.id), ["fleet.positions.current.v1"]);
|
assert.deepEqual(bundled.map((definition) => definition.id), [
|
||||||
|
"fleet.positions.current.v1",
|
||||||
|
"fleet.positions.current.v2",
|
||||||
|
"fleet.positions.current.v3",
|
||||||
|
"fleet.positions.current.v4",
|
||||||
|
]);
|
||||||
assert.deepEqual(bundled[0].semanticTypes, ["map.moving_object"]);
|
assert.deepEqual(bundled[0].semanticTypes, ["map.moving_object"]);
|
||||||
assert.equal(bundled[0].ontologyRevision, "ontology.map.moving_object.v1");
|
assert.equal(bundled[0].ontologyRevision, "ontology.map.moving_object.v1");
|
||||||
assert.equal(bundled[0].history.mode, "sampled");
|
assert.equal(bundled[0].history.mode, "sampled");
|
||||||
|
|
@ -15,6 +20,32 @@ assert.equal(bundled[0].history.retentionDays, 90);
|
||||||
assert.equal(bundled[0].fields.includes("geometry"), true);
|
assert.equal(bundled[0].fields.includes("geometry"), true);
|
||||||
assert.equal(bundled[0].fields.includes("providerUnitId"), false);
|
assert.equal(bundled[0].fields.includes("providerUnitId"), false);
|
||||||
assert.equal(bundled[0].fields.includes("provider_unit_id"), false);
|
assert.equal(bundled[0].fields.includes("provider_unit_id"), false);
|
||||||
|
assert.equal(bundled[1].version, "2.0.0");
|
||||||
|
assert.equal(bundled[1].ontologyRevision, "ontology.map.moving_object.v2");
|
||||||
|
assert.deepEqual(
|
||||||
|
bundled[1].fields.filter((field) => field.endsWith("_state")),
|
||||||
|
["availability_state", "freshness_state", "motion_state", "position_state"],
|
||||||
|
);
|
||||||
|
assert.equal(bundled[2].version, "3.0.0");
|
||||||
|
assert.equal(bundled[2].ontologyRevision, "ontology.map.moving_object.v3");
|
||||||
|
assert.deepEqual(
|
||||||
|
bundled[2].fields.filter((field) => field.endsWith("_state")),
|
||||||
|
["movement_state", "signal_state"],
|
||||||
|
);
|
||||||
|
assert.equal(bundled[2].fields.includes("operational_status"), false);
|
||||||
|
assert.equal(bundled[3].version, "4.0.0");
|
||||||
|
assert.equal(bundled[3].ontologyRevision, "ontology.map.moving_object.v3");
|
||||||
|
assert.deepEqual(bundled[3].fieldContracts.signal_state, {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
enum: ["active", "inactive"],
|
||||||
|
});
|
||||||
|
assert.deepEqual(bundled[3].fieldContracts.movement_state, {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
enum: ["moving", "stopped"],
|
||||||
|
});
|
||||||
|
assert.equal(Object.keys(bundled[3].fieldContracts).length, bundled[3].fields.length);
|
||||||
|
|
||||||
const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-"));
|
const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-definitions-"));
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"updatedAt": "2026-07-16",
|
"updatedAt": "2026-07-20",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
{ "alias": "gelios", "canonicalId": "gelios.integration" },
|
{ "alias": "gelios", "canonicalId": "gelios.integration" },
|
||||||
{ "alias": "gelius", "canonicalId": "gelios.integration" },
|
{ "alias": "gelius", "canonicalId": "gelios.integration" },
|
||||||
|
|
@ -13,7 +13,12 @@
|
||||||
{ "alias": "последняя телеметрия гелиос", "canonicalId": "gelios.telemetry_snapshot" },
|
{ "alias": "последняя телеметрия гелиос", "canonicalId": "gelios.telemetry_snapshot" },
|
||||||
{ "alias": "сырая телеметрия гелиос", "canonicalId": "gelios.raw_telemetry_message" },
|
{ "alias": "сырая телеметрия гелиос", "canonicalId": "gelios.raw_telemetry_message" },
|
||||||
{ "alias": "позиция объекта гелиос", "canonicalId": "gelios.position_fix" },
|
{ "alias": "позиция объекта гелиос", "canonicalId": "gelios.position_fix" },
|
||||||
{ "alias": "статус объекта гелиос", "canonicalId": "gelios.operational_status" },
|
{ "alias": "статус связи гелиос", "canonicalId": "gelios.signal_state" },
|
||||||
|
{ "alias": "на связи гелиос", "canonicalId": "gelios.signal_state" },
|
||||||
|
{ "alias": "не на связи гелиос", "canonicalId": "gelios.signal_state" },
|
||||||
|
{ "alias": "статус движения гелиос", "canonicalId": "gelios.movement_state" },
|
||||||
|
{ "alias": "в движении гелиос", "canonicalId": "gelios.movement_state" },
|
||||||
|
{ "alias": "неподвижные гелиос", "canonicalId": "gelios.movement_state" },
|
||||||
{ "alias": "датчик гелиос", "canonicalId": "gelios.sensor_definition" },
|
{ "alias": "датчик гелиос", "canonicalId": "gelios.sensor_definition" },
|
||||||
{ "alias": "показание датчика", "canonicalId": "gelios.sensor_reading" },
|
{ "alias": "показание датчика", "canonicalId": "gelios.sensor_reading" },
|
||||||
{ "alias": "калибровка датчика", "canonicalId": "gelios.sensor_conversion" },
|
{ "alias": "калибровка датчика", "canonicalId": "gelios.sensor_conversion" },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"updatedAt": "2026-07-16",
|
"updatedAt": "2026-07-20",
|
||||||
"entities": [
|
"entities": [
|
||||||
{ "id": "gelios.integration", "name": "Gelios Integration", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "NDC L2 connection", "summary": "One tenant-scoped Gelios provider connection backed by one opaque NDC L2 credential reference. It owns no UI, renderer, secret value or provider-specific Platform service." },
|
{ "id": "gelios.integration", "name": "Gelios Integration", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "NDC L2 connection", "summary": "One tenant-scoped Gelios provider connection backed by one opaque NDC L2 credential reference. It owns no UI, renderer, secret value or provider-specific Platform service." },
|
||||||
{ "id": "gelios.access_scope", "name": "Gelios Access Scope", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "Gelios credential visibility + NDC L2 connection policy", "summary": "Dynamic collection boundary: selected safe-read capabilities include every entity visible to the bound credential on each run. Entity allowlists and provider-group business filters are not part of collection scope." },
|
{ "id": "gelios.access_scope", "name": "Gelios Access Scope", "surface": "platform", "status": ["product-required", "source-evidenced"], "authority": "Gelios credential visibility + NDC L2 connection policy", "summary": "Dynamic collection boundary: selected safe-read capabilities include every entity visible to the bound credential on each run. Entity allowlists and provider-group business filters are not part of collection scope." },
|
||||||
|
|
@ -11,7 +11,9 @@
|
||||||
{ "id": "gelios.raw_telemetry_message", "name": "Raw Telemetry Message", "surface": "telemetry", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Provider hardware message preserved only behind restricted raw-data policy; it is not the default Studio or analytics contract." },
|
{ "id": "gelios.raw_telemetry_message", "name": "Raw Telemetry Message", "surface": "telemetry", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Provider hardware message preserved only behind restricted raw-data policy; it is not the default Studio or analytics contract." },
|
||||||
{ "id": "gelios.position_fix", "name": "Position Fix", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Time-qualified geographic position with latitude, longitude, height, course, speed, satellite and quality attributes for one unit." },
|
{ "id": "gelios.position_fix", "name": "Position Fix", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Time-qualified geographic position with latitude, longitude, height, course, speed, satellite and quality attributes for one unit." },
|
||||||
{ "id": "gelios.telemetry_parameter", "name": "Telemetry Parameter", "surface": "telemetry", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Name/value parameter received with a message. Parameters are dynamic and require a namespaced registry and whitelist before product exposure." },
|
{ "id": "gelios.telemetry_parameter", "name": "Telemetry Parameter", "surface": "telemetry", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Name/value parameter received with a message. Parameters are dynamic and require a namespaced registry and whitelist before product exposure." },
|
||||||
{ "id": "gelios.operational_status", "name": "Operational Status", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Derived unit state such as active, stale, moving, parked, no-position or low-GPS; distinct from raw provider message fields." },
|
{ "id": "gelios.signal_state", "name": "Gelios Signal State", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "Gelios monitoring client + monitoring-config", "summary": "Official Gelios unit connection filter. Field signal_state has exactly active (На связи / Active units) and inactive (Не на связи / Inactive units). The client resolves its active window from signalActiveDuration and signalSomewhatInactiveDuration; a missing message is inactive. A connection profile may provide one explicit positive fallback when both account values are empty, otherwise publication fails closed.", "valueContract": { "field": "signal_state", "values": [{ "value": "active", "label": "На связи", "sourceLabel": "Active units" }, { "value": "inactive", "label": "Не на связи", "sourceLabel": "Inactive units" }], "sourcePaths": ["lastMsg.time", "monitoringConfig.signalActiveDuration", "monitoringConfig.signalSomewhatInactiveDuration"], "rules": ["missing_last_message.inactive", "last_message_age_lt_resolved_monitoring_limit.active", "otherwise.inactive"] } },
|
||||||
|
{ "id": "gelios.movement_state", "name": "Gelios Movement State", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "Gelios monitoring client", "summary": "Official Gelios unit movement filter. Field movement_state has exactly moving (В движении / Moving units) and stopped (Неподвижные / Stopped units). The monitoring client classifies integer last-message speed greater than 2 as moving; a missing message or speed not greater than 2 is stopped.", "valueContract": { "field": "movement_state", "values": [{ "value": "moving", "label": "В движении", "sourceLabel": "Moving units" }, { "value": "stopped", "label": "Неподвижные", "sourceLabel": "Stopped units" }], "sourcePaths": ["lastMsg.speed"], "rules": ["integer_speed_gt_2.moving", "otherwise.stopped"] } },
|
||||||
|
{ "id": "gelios.operational_status", "name": "Legacy NDC Operational Status", "surface": "telemetry", "status": ["tech-debt-noncanonical"], "authority": "Legacy NDC mapping only", "summary": "Noncanonical legacy aggregate retained only to identify and retire old v1/v2 contracts. It is not a Gelios status, must not enter new Data Products and must not drive Foundry filters, counters, colours or classes." },
|
||||||
{ "id": "gelios.sensor_definition", "name": "Sensor Definition", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Declared sensor on a unit with message parameter, type, unit of measure, visibility and optional fuel semantics." },
|
{ "id": "gelios.sensor_definition", "name": "Sensor Definition", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Declared sensor on a unit with message parameter, type, unit of measure, visibility and optional fuel semantics." },
|
||||||
{ "id": "gelios.sensor_reading", "name": "Sensor Reading", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Time-qualified normalized reading of a defined sensor, retaining both numeric value and human-readable representation only when permitted by a versioned field policy." },
|
{ "id": "gelios.sensor_reading", "name": "Sensor Reading", "surface": "telemetry", "status": ["source-evidenced", "product-required"], "authority": "NDC L2 semantic mapping", "summary": "Time-qualified normalized reading of a defined sensor, retaining both numeric value and human-readable representation only when permitted by a versioned field policy." },
|
||||||
{ "id": "gelios.sensor_conversion", "name": "Sensor Conversion", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Calibration/conversion rule or row for translating raw sensor values into operational measures." },
|
{ "id": "gelios.sensor_conversion", "name": "Sensor Conversion", "surface": "domain", "status": ["source-evidenced"], "authority": "Gelios Pro", "summary": "Calibration/conversion rule or row for translating raw sensor values into operational measures." },
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"updatedAt": "2026-07-16",
|
"updatedAt": "2026-07-20",
|
||||||
"sourceRoots": [
|
"sourceRoots": [
|
||||||
{
|
{
|
||||||
"surface": "gelios-rest",
|
"surface": "gelios-rest",
|
||||||
"path": "https://api.geliospro.com/docs",
|
"path": "https://api.geliospro.com/openapi.json",
|
||||||
"mode": "OpenAPI inspection and safe read-only runtime audit; no write routes executed"
|
"mode": "Official OpenAPI inspection for GET /api/v1/units and GET /api/v1/users/me/monitoring-config; no write routes executed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"surface": "gelios-monitoring-client",
|
||||||
|
"path": "https://geliospro.com/js/app.js",
|
||||||
|
"mode": "Official public monitoring-client inspection for active/inactive and moving/stopped derivation rules"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"surface": "gelios-monitoring-localization",
|
||||||
|
"path": "https://geliospro.com/tmp/ru-RU.js",
|
||||||
|
"mode": "Official public Russian labels for Active units, Inactive units, Moving units and Stopped units"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"surface": "ndc-l2-legacy-evidence",
|
"surface": "ndc-l2-legacy-evidence",
|
||||||
|
|
@ -28,6 +38,8 @@
|
||||||
"gelios.unit",
|
"gelios.unit",
|
||||||
"gelios.telemetry_snapshot",
|
"gelios.telemetry_snapshot",
|
||||||
"gelios.position_fix",
|
"gelios.position_fix",
|
||||||
|
"gelios.signal_state",
|
||||||
|
"gelios.movement_state",
|
||||||
"gelios.sensor_definition",
|
"gelios.sensor_definition",
|
||||||
"gelios.sensor_reading",
|
"gelios.sensor_reading",
|
||||||
"gelios.geozone",
|
"gelios.geozone",
|
||||||
|
|
@ -42,6 +54,7 @@
|
||||||
"restrictions": [
|
"restrictions": [
|
||||||
"Do not copy access/refresh tokens, passwords, hardware decrypt keys or runtime snapshots into Ontology Core.",
|
"Do not copy access/refresh tokens, passwords, hardware decrypt keys or runtime snapshots into Ontology Core.",
|
||||||
"Do not make a current NDC L2 workflow node, renderer entities or provider group names canonical domain identities.",
|
"Do not make a current NDC L2 workflow node, renderer entities or provider group names canonical domain identities.",
|
||||||
|
"Do not promote operational_status, freshness, GPS quality, missing position or unknown fallback buckets to Gelios monitoring states. The official filter value contracts are signal_state active/inactive and movement_state moving/stopped only.",
|
||||||
"Do not invoke command send, create, update, delete or purge routes as ontology evidence.",
|
"Do not invoke command send, create, update, delete or purge routes as ontology evidence.",
|
||||||
"Robot2B pilot counts (107 credential-visible units and 95 legacy snapshot units) are historical evidence only; canonical collection scope is dynamically all entities visible to the bound credential.",
|
"Robot2B pilot counts (107 credential-visible units and 95 legacy snapshot units) are historical evidence only; canonical collection scope is dynamically all entities visible to the bound credential.",
|
||||||
"Do not bulk-download history, media or full geozone geometry before collection policy and storage architecture are approved."
|
"Do not bulk-download history, media or full geozone geometry before collection policy and storage architecture are approved."
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"updatedAt": "2026-07-16",
|
"updatedAt": "2026-07-20",
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"id": "guardrail.gelios.ontology_not_runtime_store",
|
"id": "guardrail.gelios.ontology_not_runtime_store",
|
||||||
|
|
@ -32,6 +32,12 @@
|
||||||
"summary": "Cesium pins and labels must consume stable gelios.unit and normalized gelios.position_fix through map.moving_object. They must not use raw message IDs, transient Cesium entity IDs or provider credentials as map identity.",
|
"summary": "Cesium pins and labels must consume stable gelios.unit and normalized gelios.position_fix through map.moving_object. They must not use raw message IDs, transient Cesium entity IDs or provider credentials as map identity.",
|
||||||
"entityIds": ["gelios.unit", "gelios.position_fix", "gelios.telemetry_snapshot", "map.moving_object", "map.pin", "map.label"]
|
"entityIds": ["gelios.unit", "gelios.position_fix", "gelios.telemetry_snapshot", "map.moving_object", "map.pin", "map.label"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "guardrail.gelios.monitoring_states_are_closed",
|
||||||
|
"severity": "error",
|
||||||
|
"summary": "Gelios monitoring state is a closed source-evidenced contract: signal_state is active or inactive; movement_state is moving or stopped. NDC L2, Data Products and Foundry must not add unknown, stale, freshness, GPS, position-quality, parked, no-position or aggregate operational-status values.",
|
||||||
|
"entityIds": ["gelios.telemetry_snapshot", "gelios.signal_state", "gelios.movement_state", "gelios.operational_status", "map.state_facet", "map.style_profile"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "guardrail.gelios.raw_and_pii_are_restricted",
|
"id": "guardrail.gelios.raw_and_pii_are_restricted",
|
||||||
"severity": "warning",
|
"severity": "warning",
|
||||||
|
|
@ -56,6 +62,9 @@
|
||||||
["gelios.unit", "gelios.tracker_device"],
|
["gelios.unit", "gelios.tracker_device"],
|
||||||
["gelios.telemetry_snapshot", "gelios.raw_telemetry_message"],
|
["gelios.telemetry_snapshot", "gelios.raw_telemetry_message"],
|
||||||
["gelios.position_fix", "map.moving_object"],
|
["gelios.position_fix", "map.moving_object"],
|
||||||
|
["gelios.signal_state", "gelios.movement_state"],
|
||||||
|
["gelios.signal_state", "gelios.operational_status"],
|
||||||
|
["gelios.movement_state", "gelios.operational_status"],
|
||||||
["gelios.command_template", "gelios.command_dispatch"],
|
["gelios.command_template", "gelios.command_dispatch"],
|
||||||
["gelios.command_dispatch", "gelios.command_delivery"],
|
["gelios.command_dispatch", "gelios.command_delivery"],
|
||||||
["gelios.geozone", "map.zone"]
|
["gelios.geozone", "map.zone"]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "gelios",
|
"id": "gelios",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"updatedAt": "2026-07-16",
|
"updatedAt": "2026-07-20",
|
||||||
"status": "product-required/source-evidenced",
|
"status": "product-required/source-evidenced",
|
||||||
"summary": "Provider-neutral Gelios Pro telemetry, fleet, spatial, reporting and command-domain ontology. It describes canonical meanings and contracts; it does not store credentials, account scope or runtime telemetry."
|
"summary": "Source-evidenced Gelios Pro telemetry, fleet, spatial, reporting and command-domain ontology. Unit monitoring exposes only the official signal and movement value contracts; consumers may not invent additional operational states."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"updatedAt": "2026-07-16",
|
"updatedAt": "2026-07-20",
|
||||||
"relations": [
|
"relations": [
|
||||||
{ "id": "gelios.integration.is_provider_connection", "from": ["gelios.integration"], "to": ["integration.connection"], "status": "product-required", "summary": "A Gelios integration is a tenant-scoped instance of the common external provider connection contract." },
|
{ "id": "gelios.integration.is_provider_connection", "from": ["gelios.integration"], "to": ["integration.connection"], "status": "product-required", "summary": "A Gelios integration is a tenant-scoped instance of the common external provider connection contract." },
|
||||||
{ "id": "gelios.access_scope.specializes_integration_scope", "from": ["gelios.access_scope"], "to": ["integration.access_scope"], "status": "product-required", "summary": "Gelios credential-visible capability scope specializes the common provider access-scope contract." },
|
{ "id": "gelios.access_scope.specializes_integration_scope", "from": ["gelios.access_scope"], "to": ["integration.access_scope"], "status": "product-required", "summary": "Gelios credential-visible capability scope specializes the common provider access-scope contract." },
|
||||||
|
|
@ -17,7 +17,10 @@
|
||||||
{ "id": "gelios.unit.has_telemetry_snapshot", "from": ["gelios.unit"], "to": ["gelios.telemetry_snapshot"], "status": "product-required", "summary": "The versioned NDC L2 mapping produces normalized current telemetry for every valid returned unit." },
|
{ "id": "gelios.unit.has_telemetry_snapshot", "from": ["gelios.unit"], "to": ["gelios.telemetry_snapshot"], "status": "product-required", "summary": "The versioned NDC L2 mapping produces normalized current telemetry for every valid returned unit." },
|
||||||
{ "id": "gelios.raw_message.normalizes_to_snapshot", "from": ["gelios.raw_telemetry_message"], "to": ["gelios.telemetry_snapshot"], "status": "product-required", "summary": "Restricted raw provider telemetry can be normalized into the stable snapshot contract." },
|
{ "id": "gelios.raw_message.normalizes_to_snapshot", "from": ["gelios.raw_telemetry_message"], "to": ["gelios.telemetry_snapshot"], "status": "product-required", "summary": "Restricted raw provider telemetry can be normalized into the stable snapshot contract." },
|
||||||
{ "id": "gelios.telemetry_snapshot.has_position_fix", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.position_fix"], "status": "product-required", "summary": "A current telemetry snapshot can carry one time-qualified position fix." },
|
{ "id": "gelios.telemetry_snapshot.has_position_fix", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.position_fix"], "status": "product-required", "summary": "A current telemetry snapshot can carry one time-qualified position fix." },
|
||||||
{ "id": "gelios.telemetry_snapshot.has_operational_status", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.operational_status"], "status": "product-required", "summary": "The semantic mapping derives normalized operational state from telemetry and data-quality policy." },
|
{ "id": "gelios.telemetry_snapshot.has_signal_state", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.signal_state"], "status": "product-required", "summary": "The current snapshot carries the exact Gelios active/inactive monitoring classification derived from official message-age settings." },
|
||||||
|
{ "id": "gelios.telemetry_snapshot.has_movement_state", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.movement_state"], "status": "product-required", "summary": "The current snapshot carries the exact Gelios moving/stopped monitoring classification derived from official last-message speed logic." },
|
||||||
|
{ "id": "gelios.signal_state.is_map_state_facet", "from": ["gelios.signal_state"], "to": ["map.state_facet"], "status": "product-required", "summary": "Foundry may expose the signal_state value contract as a generic map facet without changing its values or labels." },
|
||||||
|
{ "id": "gelios.movement_state.is_map_state_facet", "from": ["gelios.movement_state"], "to": ["map.state_facet"], "status": "product-required", "summary": "Foundry may expose the movement_state value contract as a generic map facet without changing its values or labels." },
|
||||||
{ "id": "gelios.telemetry_snapshot.has_parameter", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.telemetry_parameter"], "status": "source-evidenced", "summary": "A provider snapshot can contain dynamic telemetry parameters." },
|
{ "id": "gelios.telemetry_snapshot.has_parameter", "from": ["gelios.telemetry_snapshot"], "to": ["gelios.telemetry_parameter"], "status": "source-evidenced", "summary": "A provider snapshot can contain dynamic telemetry parameters." },
|
||||||
{ "id": "gelios.unit.has_sensor", "from": ["gelios.unit"], "to": ["gelios.sensor_definition"], "status": "source-evidenced", "summary": "A unit exposes zero or more sensor definitions." },
|
{ "id": "gelios.unit.has_sensor", "from": ["gelios.unit"], "to": ["gelios.sensor_definition"], "status": "source-evidenced", "summary": "A unit exposes zero or more sensor definitions." },
|
||||||
{ "id": "gelios.sensor_definition.has_conversion", "from": ["gelios.sensor_definition"], "to": ["gelios.sensor_conversion"], "status": "source-evidenced", "summary": "A sensor can define conversion/calibration rows." },
|
{ "id": "gelios.sensor_definition.has_conversion", "from": ["gelios.sensor_definition"], "to": ["gelios.sensor_conversion"], "status": "source-evidenced", "summary": "A sensor can define conversion/calibration rows." },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"updatedAt": "2026-07-12",
|
"updatedAt": "2026-07-19",
|
||||||
"entities": [
|
"entities": [
|
||||||
{ "id": "map.view", "name": "Map View", "surface": "map", "status": ["product-required"], "authority": "NDC Module Studio + Ontology Core", "summary": "Provider-neutral map work view composed by an approved Map Page template." },
|
{ "id": "map.view", "name": "Map View", "surface": "map", "status": ["product-required"], "authority": "NDC Module Studio + Ontology Core", "summary": "Provider-neutral map work view composed by an approved Map Page template." },
|
||||||
{ "id": "map.viewport", "name": "Map Viewport", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Camera/view state including position, orientation, zoom or height, focus and saved view presets." },
|
{ "id": "map.viewport", "name": "Map Viewport", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Camera/view state including position, orientation, zoom or height, focus and saved view presets." },
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
{ "id": "map.grid_layer", "name": "Map Grid Layer", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Planetary or situational grid with configurable modes, levels of detail, step, radius and visual primitives." },
|
{ "id": "map.grid_layer", "name": "Map Grid Layer", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Planetary or situational grid with configurable modes, levels of detail, step, radius and visual primitives." },
|
||||||
{ "id": "map.place_target", "name": "Map Place Target", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Geographic place target such as a city or territory with location, pulse, radius and distance-dependent presentation." },
|
{ "id": "map.place_target", "name": "Map Place Target", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Geographic place target such as a city or territory with location, pulse, radius and distance-dependent presentation." },
|
||||||
{ "id": "map.moving_object", "name": "Map Moving Object", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Dynamic spatial object such as transport, robot or vehicle with identity, position, movement and status." },
|
{ "id": "map.moving_object", "name": "Map Moving Object", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Dynamic spatial object such as transport, robot or vehicle with identity, position, movement and status." },
|
||||||
|
{ "id": "map.state_facet", "name": "Map State Facet", "surface": "map", "status": ["product-required"], "authority": "Bound Data Product + Ontology Core", "summary": "A state dimension explicitly declared by the bound Data Product and its ontology value contract. Map and Foundry have no default facets and may not add values, fallback buckets or classifications of their own." },
|
||||||
{ "id": "map.pin", "name": "Map Pin", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Reusable provider-neutral elevated-spike presentation with ground anchor, stem, head, label anchor, semantic colour/status and camera-height visibility rules." },
|
{ "id": "map.pin", "name": "Map Pin", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Reusable provider-neutral elevated-spike presentation with ground anchor, stem, head, label anchor, semantic colour/status and camera-height visibility rules." },
|
||||||
{ "id": "map.label", "name": "Map Label", "surface": "map", "status": ["source-evidenced", "product-required"], "authority": "NDC Module Studio", "summary": "Reusable information plate attached to a spatial subject with style, size variant, anchor, offset and visibility rules." },
|
{ "id": "map.label", "name": "Map Label", "surface": "map", "status": ["source-evidenced", "product-required"], "authority": "NDC Module Studio", "summary": "Reusable information plate attached to a spatial subject with style, size variant, anchor, offset and visibility rules." },
|
||||||
{ "id": "map.zone", "name": "Map Zone", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Polygon or multipolygon zone, sector or geofence with optional height, extrusion and level-dependent style." },
|
{ "id": "map.zone", "name": "Map Zone", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Polygon or multipolygon zone, sector or geofence with optional height, extrusion and level-dependent style." },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"updatedAt": "2026-07-12",
|
"updatedAt": "2026-07-19",
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"id": "guardrail.map.provider_neutral_domain",
|
"id": "guardrail.map.provider_neutral_domain",
|
||||||
|
|
@ -26,6 +26,12 @@
|
||||||
"summary": "Transport, station, city and other labels must use the shared Map Label contract with semantic variants instead of source-specific label implementations.",
|
"summary": "Transport, station, city and other labels must use the shared Map Label contract with semantic variants instead of source-specific label implementations.",
|
||||||
"entityIds": ["map.label", "map.moving_object", "map.station", "map.stop", "map.terminal", "map.place_target"]
|
"entityIds": ["map.label", "map.moving_object", "map.station", "map.stop", "map.terminal", "map.place_target"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "guardrail.map.presentation_uses_normalized_state_facets",
|
||||||
|
"severity": "error",
|
||||||
|
"summary": "Foundry presentation classes, filters, counters and sorting must consume only state facets and exact values declared by the bound Data Product and Ontology value contract. Renderer and profile code must not classify raw values or add fallback states.",
|
||||||
|
"entityIds": ["map.moving_object", "map.state_facet", "map.style_profile", "map.renderer_adapter"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "guardrail.map.mass_geometry_requires_strategy",
|
"id": "guardrail.map.mass_geometry_requires_strategy",
|
||||||
"severity": "warning",
|
"severity": "warning",
|
||||||
|
|
@ -42,6 +48,7 @@
|
||||||
"blockedConflations": [
|
"blockedConflations": [
|
||||||
["map.view", "map.renderer_adapter"],
|
["map.view", "map.renderer_adapter"],
|
||||||
["map.moving_object", "map.pin"],
|
["map.moving_object", "map.pin"],
|
||||||
|
["map.state_facet", "map.style_profile"],
|
||||||
["map.station", "map.label"],
|
["map.station", "map.label"],
|
||||||
["map.route", "map.track_segment"],
|
["map.route", "map.track_segment"],
|
||||||
["map.visibility_rule", "map.provider_capability"],
|
["map.visibility_rule", "map.provider_capability"],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "map",
|
"id": "map",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"updatedAt": "2026-07-12",
|
"updatedAt": "2026-07-19",
|
||||||
"status": "product-required/source-evidenced",
|
"status": "product-required/source-evidenced",
|
||||||
"summary": "Provider-neutral map domain ontology for NDC Module Studio views, spatial entities, transport layers, visibility rules, selection, and replaceable renderer adapters."
|
"summary": "Provider-neutral map domain ontology for NDC Module Studio views, spatial entities, orthogonal state facets, transport layers, visibility rules, selection, and replaceable renderer adapters."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
{
|
{
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"updatedAt": "2026-07-12",
|
"updatedAt": "2026-07-19",
|
||||||
"relations": [
|
"relations": [
|
||||||
{ "id": "map.view.uses_viewport", "from": ["map.view"], "to": ["map.viewport"], "status": "product-required", "summary": "A Map View owns provider-neutral viewport state." },
|
{ "id": "map.view.uses_viewport", "from": ["map.view"], "to": ["map.viewport"], "status": "product-required", "summary": "A Map View owns provider-neutral viewport state." },
|
||||||
{ "id": "map.view.contains_layer", "from": ["map.view"], "to": ["map.base_layer", "map.building_layer", "map.grid_layer"], "status": "product-required", "summary": "A Map View composes enabled spatial layers." },
|
{ "id": "map.view.contains_layer", "from": ["map.view"], "to": ["map.base_layer", "map.building_layer", "map.grid_layer"], "status": "product-required", "summary": "A Map View composes enabled spatial layers." },
|
||||||
{ "id": "map.view.displays_subject", "from": ["map.view"], "to": ["map.place_target", "map.moving_object", "map.zone", "map.route", "map.track_segment", "map.station", "map.stop", "map.terminal"], "status": "product-required", "summary": "A Map View displays domain subjects through approved page slots." },
|
{ "id": "map.view.displays_subject", "from": ["map.view"], "to": ["map.place_target", "map.moving_object", "map.zone", "map.route", "map.track_segment", "map.station", "map.stop", "map.terminal"], "status": "product-required", "summary": "A Map View displays domain subjects through approved page slots." },
|
||||||
{ "id": "map.moving_object.uses_pin", "from": ["map.moving_object"], "to": ["map.pin"], "status": "product-required", "summary": "A moving object can use the shared pin presentation." },
|
{ "id": "map.moving_object.uses_pin", "from": ["map.moving_object"], "to": ["map.pin"], "status": "product-required", "summary": "A moving object can use the shared pin presentation." },
|
||||||
|
{ "id": "map.moving_object.has_state_facet", "from": ["map.moving_object"], "to": ["map.state_facet"], "status": "product-required", "summary": "A moving object exposes only the state dimensions explicitly declared by its bound Data Product and ontology value contracts." },
|
||||||
|
{ "id": "map.style_profile.classifies_state_facet", "from": ["map.style_profile"], "to": ["map.state_facet"], "status": "product-required", "summary": "A versioned Foundry style profile maps normalized state facets to renderer-neutral presentation classes, filter groups and sort order." },
|
||||||
{ "id": "map.subject.has_label", "from": ["map.place_target", "map.moving_object", "map.station", "map.stop", "map.terminal"], "to": ["map.label"], "status": "product-required", "summary": "Spatial subjects can use the common label contract and semantic size variants." },
|
{ "id": "map.subject.has_label", "from": ["map.place_target", "map.moving_object", "map.station", "map.stop", "map.terminal"], "to": ["map.label"], "status": "product-required", "summary": "Spatial subjects can use the common label contract and semantic size variants." },
|
||||||
{ "id": "map.zone.uses_style_profile", "from": ["map.zone"], "to": ["map.style_profile"], "status": "product-required", "summary": "Zones use semantic fill, outline, height and level-dependent styling." },
|
{ "id": "map.zone.uses_style_profile", "from": ["map.zone"], "to": ["map.style_profile"], "status": "product-required", "summary": "Zones use semantic fill, outline, height and level-dependent styling." },
|
||||||
{ "id": "map.route.contains_track_segment", "from": ["map.route"], "to": ["map.track_segment"], "status": "product-required", "summary": "A logical route is rendered from one or more track segments." },
|
{ "id": "map.route.contains_track_segment", "from": ["map.route"], "to": ["map.track_segment"], "status": "product-required", "summary": "A logical route is rendered from one or more track segments." },
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Package: `catalog/domain-packages/gelios`
|
Package: `catalog/domain-packages/gelios`
|
||||||
|
|
||||||
Status: `v1.0.0`, source-evidenced and product-required.
|
Status: `v1.1.0`, source-evidenced and product-required.
|
||||||
|
|
||||||
## Purpose and boundary
|
## Purpose and boundary
|
||||||
|
|
||||||
|
|
@ -15,8 +15,8 @@ model. Runtime transport, collection cadence and semantic mapping belong to an
|
||||||
isolated NDC L2 connection instance. External Data Plane persists and delivers
|
isolated NDC L2 connection instance. External Data Plane persists and delivers
|
||||||
provider-neutral Data Products. Ontology Core describes meaning only.
|
provider-neutral Data Products. Ontology Core describes meaning only.
|
||||||
|
|
||||||
The matching contract/data artifact is
|
The current matching contract/data artifact is
|
||||||
`platform/packages/external-provider-contract/providers/gelios/v1`. Adding a
|
`platform/packages/external-provider-contract/providers/gelios/v4`. Adding a
|
||||||
second account creates another connection instance with different opaque
|
second account creates another connection instance with different opaque
|
||||||
credential references; it does not create another ontology package, Platform
|
credential references; it does not create another ontology package, Platform
|
||||||
service or custom node.
|
service or custom node.
|
||||||
|
|
@ -24,14 +24,15 @@ service or custom node.
|
||||||
## Provider authentication boundary
|
## Provider authentication boundary
|
||||||
|
|
||||||
Gelios issues exactly two provider secret artifacts: an access token and a
|
Gelios issues exactly two provider secret artifacts: an access token and a
|
||||||
refresh token. The current NDC L2 `httpBearerAuth` request binding uses the
|
refresh token. The current rotating credential uses the access token for
|
||||||
access token. Automatic refresh has not been proven in the deployed runtime, so
|
requests and keeps refresh inside native NDC L2 Credentials; neither token
|
||||||
the matching provider package records refresh as `operator_managed`; neither
|
value belongs in Ontology, a workflow graph, MCP, Ops or a trace.
|
||||||
token value belongs in Ontology, a workflow graph, MCP, Ops or a trace.
|
|
||||||
|
|
||||||
A credential label such as `read access` is operator metadata, not a Gelios
|
A credential label such as `read access` is operator metadata, not a Gelios
|
||||||
token scope. `gelios.units.current.read` is classified as read because the
|
token scope. `gelios.units.current.read` and
|
||||||
approved workflow transport is `GET /api/v1/units`. The label does not create a
|
`gelios.monitoring_config.current.read` are classified as read because the
|
||||||
|
approved transports are `GET /api/v1/units` and
|
||||||
|
`GET /api/v1/users/me/monitoring-config`. The label does not create a
|
||||||
separate read token or constrain other rights that Gelios may have granted to
|
separate read token or constrain other rights that Gelios may have granted to
|
||||||
the same access token. The scoped Data Product writer credential later in the
|
the same access token. The scoped Data Product writer credential later in the
|
||||||
runtime path is an internal NDC/External Data Plane capability, not a third
|
runtime path is an internal NDC/External Data Plane capability, not a third
|
||||||
|
|
@ -66,7 +67,7 @@ parsed or renumbered. A unit can use a
|
||||||
expose sensor, fuel, maintenance and custom-field configurations.
|
expose sensor, fuel, maintenance and custom-field configurations.
|
||||||
|
|
||||||
Hardware IDs, IMEI, phones, address, decrypt-related fields, raw `params` and
|
Hardware IDs, IMEI, phones, address, decrypt-related fields, raw `params` and
|
||||||
unclassified sensor payloads are excluded by the v1 field policy. New provider
|
unclassified sensor payloads are excluded by the current field policy. New provider
|
||||||
fields remain dropped until they are evidenced, classified and introduced by a
|
fields remain dropped until they are evidenced, classified and introduced by a
|
||||||
new package/ontology revision.
|
new package/ontology revision.
|
||||||
|
|
||||||
|
|
@ -76,12 +77,12 @@ new package/ontology revision.
|
||||||
`gelios.position_fix` is its time-qualified spatial portion; it is not a pin or
|
`gelios.position_fix` is its time-qualified spatial portion; it is not a pin or
|
||||||
other renderer object.
|
other renderer object.
|
||||||
|
|
||||||
The first approved output is exactly:
|
The current approved output is exactly:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Data Product: fleet.positions.current.v1
|
Data Product: fleet.positions.current.v3
|
||||||
version: 1.0.0
|
version: 3.0.0
|
||||||
ontology revision: ontology.map.moving_object.v1
|
ontology revision: ontology.map.moving_object.v3
|
||||||
semantic type: map.moving_object
|
semantic type: map.moving_object
|
||||||
delivery: snapshot+patch
|
delivery: snapshot+patch
|
||||||
history: sampled, latest entity per 60-second bucket, 90 days
|
history: sampled, latest entity per 60-second bucket, 90 days
|
||||||
|
|
@ -96,21 +97,51 @@ elevation_meters
|
||||||
geometry
|
geometry
|
||||||
hdop
|
hdop
|
||||||
horizontal_accuracy_meters
|
horizontal_accuracy_meters
|
||||||
|
movement_state
|
||||||
object_kind
|
object_kind
|
||||||
operational_status
|
|
||||||
position_source
|
position_source
|
||||||
position_valid
|
|
||||||
quality_flags
|
|
||||||
satellite_count
|
satellite_count
|
||||||
|
signal_state
|
||||||
speed_kph
|
speed_kph
|
||||||
```
|
```
|
||||||
|
|
||||||
All attribute names are snake_case. GeoJSON `geometry` is omitted when valid
|
All attribute names are snake_case. GeoJSON `geometry` is omitted when valid
|
||||||
coordinates are unavailable, but the unit remains in the product with
|
coordinates are unavailable, but the unit remains in the product. Missing
|
||||||
`position_valid=false`, `operational_status=no_position` and appropriate
|
geometry is not converted into a Gelios monitoring status. If the provider has
|
||||||
`quality_flags`. If the provider has no last-message timestamp, the collection
|
no last-message timestamp, the collection receive time becomes the explicit
|
||||||
receive time becomes the explicit `observedAt` fallback so the credential-visible
|
`observedAt` fallback so the credential-visible unit is not silently dropped.
|
||||||
unit is not silently dropped.
|
|
||||||
|
### Official monitoring states
|
||||||
|
|
||||||
|
Gelios does not return a ready-made operational-status enum from
|
||||||
|
`GET /api/v1/units`. Its monitoring client derives two independent, closed
|
||||||
|
value contracts from official source facts and settings:
|
||||||
|
|
||||||
|
```text
|
||||||
|
signal_state:
|
||||||
|
active -> На связи / Active units
|
||||||
|
inactive -> Не на связи / Inactive units
|
||||||
|
|
||||||
|
movement_state:
|
||||||
|
moving -> В движении / Moving units
|
||||||
|
stopped -> Неподвижные / Stopped units
|
||||||
|
```
|
||||||
|
|
||||||
|
The official client first marks a unit active while the age of `lastMsg.time`
|
||||||
|
is below `monitoring-config.signalActiveDuration`, then keeps it active through
|
||||||
|
the positive `signalSomewhatInactiveDuration` window. A missing message is
|
||||||
|
inactive. When an account returns empty monitoring durations, the connection
|
||||||
|
profile may supply an explicit, versioned positive fallback; its provenance is
|
||||||
|
runtime policy and does not create another ontology state. Without a resolved
|
||||||
|
positive threshold publication must fail closed. `movement_state=moving` when
|
||||||
|
the integer `lastMsg.speed` is greater than `2`; otherwise it is `stopped`.
|
||||||
|
The values and Russian labels are the official Gelios monitoring-client
|
||||||
|
contract.
|
||||||
|
|
||||||
|
There is no `unknown`, `fresh`, `stale`, GPS-quality, position-quality,
|
||||||
|
`parked`, `no_position` or aggregate `operational_status` value in this
|
||||||
|
contract. `gelios.operational_status` is retained in the catalog only as a
|
||||||
|
`tech-debt-noncanonical` marker for retiring old v1/v2 products.
|
||||||
|
|
||||||
### Sensors and operational semantics
|
### Sensors and operational semantics
|
||||||
|
|
||||||
|
|
@ -128,7 +159,7 @@ Gelios is a source domain. The provider-neutral relation is:
|
||||||
```text
|
```text
|
||||||
gelios.unit + gelios.position_fix
|
gelios.unit + gelios.position_fix
|
||||||
-> map.moving_object
|
-> map.moving_object
|
||||||
-> fleet.positions.current.v1
|
-> fleet.positions.current.v3
|
||||||
-> Foundry Data Product binding
|
-> Foundry Data Product binding
|
||||||
-> map layers, selection and telemetry panel
|
-> map layers, selection and telemetry panel
|
||||||
```
|
```
|
||||||
|
|
@ -148,7 +179,7 @@ concepts. `gelios.command_dispatch`, `gelios.command_delivery` and
|
||||||
No collection run, workflow, map click or autonomous agent may create a
|
No collection run, workflow, map click or autonomous agent may create a
|
||||||
dispatch. A future command path requires explicit human intent, confirmation,
|
dispatch. A future command path requires explicit human intent, confirmation,
|
||||||
separate authorization, immutable audit and delivery reconciliation. This
|
separate authorization, immutable audit and delivery reconciliation. This
|
||||||
ontology and the Gelios v1 provider package expose no command transport.
|
ontology and the Gelios v4 provider package expose no command transport.
|
||||||
|
|
||||||
## Canonical runtime flow
|
## Canonical runtime flow
|
||||||
|
|
||||||
|
|
@ -182,6 +213,9 @@ physical storage.
|
||||||
temporary credential issuance, configuration mutation and downloads remain
|
temporary credential issuance, configuration mutation and downloads remain
|
||||||
excluded until separately classified.
|
excluded until separately classified.
|
||||||
- Dynamic provider fields are dropped until evidenced and classified.
|
- Dynamic provider fields are dropped until evidenced and classified.
|
||||||
|
- Gelios monitoring filters use only the exact `signal_state` and
|
||||||
|
`movement_state` value contracts. L2, Data Products and Foundry cannot add
|
||||||
|
fallback state values.
|
||||||
- Geozones and history require bounded loading, paging/cursors, retention and
|
- Geozones and history require bounded loading, paging/cursors, retention and
|
||||||
volume controls.
|
volume controls.
|
||||||
- Command send and mutation capabilities remain red even when provider access
|
- Command send and mutation capabilities remain red even when provider access
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,11 @@
|
||||||
"elevation_meters": 0,
|
"elevation_meters": 0,
|
||||||
"hdop": 0.8,
|
"hdop": 0.8,
|
||||||
"horizontal_accuracy_meters": 4.2,
|
"horizontal_accuracy_meters": 4.2,
|
||||||
|
"movement_state": "stopped",
|
||||||
"object_kind": "tracked_unit",
|
"object_kind": "tracked_unit",
|
||||||
"operational_status": "active",
|
|
||||||
"position_source": "gelios",
|
"position_source": "gelios",
|
||||||
"position_valid": true,
|
|
||||||
"quality_flags": [],
|
|
||||||
"satellite_count": 11,
|
"satellite_count": 11,
|
||||||
|
"signal_state": "active",
|
||||||
"speed_kph": 0
|
"speed_kph": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,20 @@ function safeEntity(value) {
|
||||||
status: stringList(value?.status),
|
status: stringList(value?.status),
|
||||||
authority: cleanString(value?.authority, 240),
|
authority: cleanString(value?.authority, 240),
|
||||||
summary: cleanString(value?.summary, 2000),
|
summary: cleanString(value?.summary, 2000),
|
||||||
|
...(value?.valueContract ? { valueContract: safeValueContract(value.valueContract) } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeValueContract(value) {
|
||||||
|
return {
|
||||||
|
field: cleanString(value?.field, 120),
|
||||||
|
values: (Array.isArray(value?.values) ? value.values : []).map((item) => ({
|
||||||
|
value: cleanString(item?.value, 120),
|
||||||
|
label: cleanString(item?.label, 240),
|
||||||
|
sourceLabel: cleanString(item?.sourceLabel, 240),
|
||||||
|
})),
|
||||||
|
sourcePaths: stringList(value?.sourcePaths),
|
||||||
|
rules: stringList(value?.rules),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,10 @@ try {
|
||||||
|
|
||||||
const search = await rpc(baseUrl, TOKEN, 3, 'tools/call', {
|
const search = await rpc(baseUrl, TOKEN, 3, 'tools/call', {
|
||||||
name: 'ontology_search',
|
name: 'ontology_search',
|
||||||
arguments: { query: 'трайк', limit: 10 },
|
arguments: { query: 'юнит гелиос', limit: 10 },
|
||||||
})
|
})
|
||||||
assert.equal(search.result.isError, undefined)
|
assert.equal(search.result.isError, undefined)
|
||||||
assert.equal(search.result.structuredContent.query, 'трайк')
|
assert.equal(search.result.structuredContent.query, 'юнит гелиос')
|
||||||
assert.equal(search.result.structuredContent.results.some((item) => item.entity?.id === 'gelios.unit'), true)
|
assert.equal(search.result.structuredContent.results.some((item) => item.entity?.id === 'gelios.unit'), true)
|
||||||
|
|
||||||
const entity = await rpc(baseUrl, TOKEN, 4, 'tools/call', {
|
const entity = await rpc(baseUrl, TOKEN, 4, 'tools/call', {
|
||||||
|
|
@ -50,6 +50,16 @@ try {
|
||||||
assert.equal(entity.result.structuredContent.entity.id, 'gelios.integration')
|
assert.equal(entity.result.structuredContent.entity.id, 'gelios.integration')
|
||||||
assert.equal(JSON.stringify(entity.result.structuredContent).includes('/Users/'), false)
|
assert.equal(JSON.stringify(entity.result.structuredContent).includes('/Users/'), false)
|
||||||
|
|
||||||
|
const signalState = await rpc(baseUrl, TOKEN, 41, 'tools/call', {
|
||||||
|
name: 'ontology_get_entity',
|
||||||
|
arguments: { entityId: 'gelios.signal_state' },
|
||||||
|
})
|
||||||
|
assert.equal(signalState.result.structuredContent.entity.valueContract.field, 'signal_state')
|
||||||
|
assert.deepEqual(
|
||||||
|
signalState.result.structuredContent.entity.valueContract.values.map((item) => item.value),
|
||||||
|
['active', 'inactive'],
|
||||||
|
)
|
||||||
|
|
||||||
const guardrails = await rpc(baseUrl, TOKEN, 5, 'tools/call', {
|
const guardrails = await rpc(baseUrl, TOKEN, 5, 'tools/call', {
|
||||||
name: 'ontology_get_guardrails',
|
name: 'ontology_get_guardrails',
|
||||||
arguments: { entityId: 'gelios.command_dispatch' },
|
arguments: { entityId: 'gelios.command_dispatch' },
|
||||||
|
|
@ -70,6 +80,7 @@ try {
|
||||||
'mcp_initialize',
|
'mcp_initialize',
|
||||||
'read_only_tool_catalog',
|
'read_only_tool_catalog',
|
||||||
'gelios_alias_resolution',
|
'gelios_alias_resolution',
|
||||||
|
'gelios_value_contract_visible',
|
||||||
'gelios_command_guardrail_visible',
|
'gelios_command_guardrail_visible',
|
||||||
'evidence_paths_not_exposed',
|
'evidence_paths_not_exposed',
|
||||||
'internal_bearer_required',
|
'internal_bearer_required',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { loadCatalog, serviceRoot } from './catalog.mjs'
|
||||||
const ID_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$/
|
const ID_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$/
|
||||||
const RULE_ID_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/
|
const RULE_ID_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/
|
||||||
const ROLE_ID_RE = /^[a-z][a-z0-9_]*$/
|
const ROLE_ID_RE = /^[a-z][a-z0-9_]*$/
|
||||||
|
const FIELD_ID_RE = /^[a-z][a-z0-9_]*$/
|
||||||
const ALLOWED_RELATION_STATUSES = new Set([
|
const ALLOWED_RELATION_STATUSES = new Set([
|
||||||
'source-confirmed',
|
'source-confirmed',
|
||||||
'source-evidenced',
|
'source-evidenced',
|
||||||
|
|
@ -92,6 +93,7 @@ export async function validateCatalog() {
|
||||||
for (const status of entity.status || []) {
|
for (const status of entity.status || []) {
|
||||||
assert(statusVocabulary.has(status), `entity ${entity.id} uses unknown status: ${status}`, errors)
|
assert(statusVocabulary.has(status), `entity ${entity.id} uses unknown status: ${status}`, errors)
|
||||||
}
|
}
|
||||||
|
validateValueContract(entity.valueContract, entity.id, errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertUnique(relations.relations.map((relation) => relation.id), 'relation', errors)
|
assertUnique(relations.relations.map((relation) => relation.id), 'relation', errors)
|
||||||
|
|
@ -354,6 +356,32 @@ export async function validateCatalog() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateValueContract(valueContract, entityId, errors) {
|
||||||
|
if (valueContract === undefined) return
|
||||||
|
const label = `entity ${entityId}.valueContract`
|
||||||
|
assert(valueContract && typeof valueContract === 'object' && !Array.isArray(valueContract), `${label} must be object`, errors)
|
||||||
|
if (!valueContract || typeof valueContract !== 'object' || Array.isArray(valueContract)) return
|
||||||
|
assert(FIELD_ID_RE.test(valueContract.field || ''), `${label}.field invalid`, errors)
|
||||||
|
assert(Array.isArray(valueContract.values) && valueContract.values.length > 0, `${label}.values must be non-empty array`, errors)
|
||||||
|
const values = []
|
||||||
|
for (const [index, item] of (valueContract.values || []).entries()) {
|
||||||
|
const itemLabel = `${label}.values[${index}]`
|
||||||
|
assert(item && typeof item === 'object' && !Array.isArray(item), `${itemLabel} must be object`, errors)
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
|
||||||
|
assert(FIELD_ID_RE.test(item.value || ''), `${itemLabel}.value invalid`, errors)
|
||||||
|
assert(typeof item.label === 'string' && item.label.trim(), `${itemLabel}.label required`, errors)
|
||||||
|
assert(typeof item.sourceLabel === 'string' && item.sourceLabel.trim(), `${itemLabel}.sourceLabel required`, errors)
|
||||||
|
values.push(item.value)
|
||||||
|
}
|
||||||
|
assert(new Set(values).size === values.length, `${label}.values duplicate value`, errors)
|
||||||
|
for (const key of ['sourcePaths', 'rules']) {
|
||||||
|
assert(Array.isArray(valueContract[key]) && valueContract[key].length > 0, `${label}.${key} must be non-empty array`, errors)
|
||||||
|
for (const item of valueContract[key] || []) {
|
||||||
|
assert(typeof item === 'string' && item.trim(), `${label}.${key} contains empty item`, errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
const result = await validateCatalog()
|
const result = await validateCatalog()
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue