Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,30 @@ Operations command line tool.

### Validate deployment consistency

Check the active deployment by pointer on every catalyst, usage:
Check the active deployment by pointer on every catalyst, verify convergence across catalysts, and check asset bundle status via the asset bundle registry.

```
npx @dcl/opscli pointer-consistency --pointer "0,0"
npx @dcl/opscli pointer-consistency --pointer "0,0" --env zone
npx @dcl/opscli pointer-consistency --cid "bafkrei..."
```

- `--pointer` Scene pointer coordinate (e.g. `"0,0"`)
- `--cid` Entity ID to resolve the pointer from (alternative to `--pointer`)
- `--env` Environment: `org` (default), `today`, or `zone`

### Check world asset bundle status

Check asset bundle conversion status for all scenes in a world. Fetches scenes from the worlds-content-server, queries the asset bundle registry, and reports entity ID match, global status, and per-platform versions.

```
npx @dcl/opscli world-ab-status --world "dalkia.dcl.eth"
npx @dcl/opscli world-ab-status --world "dalkia.dcl.eth" --env zone
```

- `--world` World name (e.g. `"dalkia.dcl.eth"`)
- `--env` Environment: `org` (default), `today`, or `zone`

### Schedule asset bundle conversion

```
Expand Down
4 changes: 3 additions & 1 deletion src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import queueAbConversionAbout from './commands/queue-ab-conversion-about'
import checkAbConversion from './commands/check-ab-conversion'
import circleci from './commands/circleci'
import concatSnapshots from './commands/concat-snapshots'
import worldAbStatus from './commands/world-ab-status'

export class CliError extends Error {}

Expand All @@ -31,7 +32,8 @@ const commands = {
'queue-ab-conversion-about': queueAbConversionAbout,
'check-ab-conversion': checkAbConversion,
circleci: circleci,
'concat-snapshots': concatSnapshots
'concat-snapshots': concatSnapshots,
'world-ab-status': worldAbStatus
}

async function main() {
Expand Down
140 changes: 117 additions & 23 deletions src/commands/pointer-consistency.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,140 @@
import arg from 'arg'
import { fetch } from 'undici'
import { ago } from '../helpers/ago'
import { assert } from '../helpers/assert'
import { daoCatalysts, fetchEntityByPointer } from '../helpers/catalysts'

async function checkAssetBundleAvailability(entityId: string): Promise<void> {
const platforms = [
{ name: 'WebGL', suffix: '' },
{ name: 'Windows', suffix: '_windows' },
{ name: 'Mac', suffix: '_mac' }
]
function getEnvConfig(env: string) {
const registryUrl = `https://asset-bundle-registry.decentraland.${env}`

console.log('> Asset Bundles:')
if (env === 'today') {
return {
catalystUrls: ['https://peer-testing.decentraland.org'],
registryUrl,
peerUrl: 'https://peer-testing.decentraland.org'
}
}
if (env === 'zone') {
return { catalystUrls: ['https://peer.decentraland.zone'], registryUrl, peerUrl: 'https://peer.decentraland.zone' }
}
return { catalystUrls: null, registryUrl, peerUrl: 'https://peer.decentraland.org' } // null = fetch from DAO
}

for (const platform of platforms) {
const url = `https://ab-cdn.decentraland.org/manifest/${entityId}${platform.suffix}.json`
async function resolvePointerFromCid(peerUrl: string, cid: string): Promise<string> {
const response = await fetch(`${peerUrl}/content/entities/active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [cid] })
})

try {
const response = await fetch(url)
const status = response.status === 200 ? '✅' : '❌'
console.log(` ${platform.name.padEnd(8, ' ')} ${status}`)
} catch (error) {
console.log(` ${platform.name.padEnd(8, ' ')} ❌`)
if (!response.ok) {
throw new Error(`Failed to resolve CID ${cid}: catalyst returned ${response.status}`)
}

const entities = (await response.json()) as Array<{ id: string; pointers: string[] }>

if (!entities.length) {
throw new Error(`No active entity found for CID ${cid}`)
}

const pointer = entities[0].pointers[0]
console.log(`> Resolved CID ${cid} to pointer: ${pointer}`)
return pointer
}

function statusLabel(status: string): string {
switch (status) {
case 'complete':
return '✅ complete'
case 'pending':
return '⏳ pending'
case 'failed':
return '❌ failed'
case 'obsolete':
return '🗑️ obsolete'
case 'fallback':
return '🔄 fallback'
default:
return `⚠️ ${status}`
}
}

async function checkAssetBundleStatus(
registryUrl: string,
pointers: string[],
expectedEntityId: string
): Promise<void> {
try {
const response = await fetch(`${registryUrl}/entities/active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pointers })
})

if (!response.ok) {
console.log(`> Asset Bundles: ❌ (registry returned ${response.status})`)
return
}

const results = (await response.json()) as Array<{
id: string
pointers: string[]
status: string
versions: { assets: Record<string, { version: string; buildDate: string }> }
bundles: {
assets: Record<string, string>
}
}>

if (!results.length) {
console.log('> Asset Bundles: no data found')
return
}

for (const entry of results) {
const hashMatch = entry.id === expectedEntityId
console.log(`> Asset Bundle Registry (pointer: ${entry.pointers.join(', ')}):`)
console.log(` Entity ID match: ${hashMatch ? '✅' : '❌'} registry=${entry.id}`)
console.log(` Global status: ${statusLabel(entry.status)}`)

console.log(' Platform status:')
const platforms = ['windows', 'mac', 'webgl']
for (const platform of platforms) {
const assetStatus = entry.bundles?.assets?.[platform] || 'unknown'
const version = entry.versions?.assets?.[platform]
const versionStr = version ? `v${version.version} (${version.buildDate})` : 'N/A'

console.log(` ${platform.padEnd(10)} ${statusLabel(assetStatus).padEnd(4)} version: ${versionStr}`)
}
}
} catch (error: any) {
console.log(`> Asset Bundles: ❌ (${error.message})`)
}
}

export default async function () {
const args = arg({
'--pointer': String
'--pointer': String,
'--cid': String,
'--env': String
})

let pointer = assert(args['--pointer'], '--pointer is missing')
const env = args['--env'] || 'org'
const { catalystUrls, registryUrl, peerUrl } = getEnvConfig(env)

let pointer = args['--pointer'] || (args['--cid'] ? await resolvePointerFromCid(peerUrl, args['--cid']) : null)
if (!pointer) {
throw new Error('--pointer or --cid is required')
}

if (pointer.startsWith('\\')) {
pointer = pointer.substring(1)
}

const catalysts = await daoCatalysts()
const catalysts = catalystUrls
? catalystUrls.map((baseUrl) => ({ baseUrl, owner: '', id: '' }))
: await daoCatalysts()

console.log(` Environment: ${env}`)
console.log(` Got ${catalysts.length} catalysts`)
console.log(`> Fetching pointer in every catalyst: ${JSON.stringify(pointer)}`)

Expand Down Expand Up @@ -73,14 +169,12 @@ export default async function () {
)
console.log(`> Convergent: ${entityIds.size === 1 ? '✅' : '❌'}`)

// Check asset bundle availability for the most recent deployment
// Check asset bundle status via registry
if (deployments.length > 0) {
// Find the most recent deployment
const mostRecent = deployments.reduce((latest, current) =>
current.timestamp > latest.timestamp ? current : latest
)

console.log(`> Most recent deployment entity ID: ${mostRecent.entityId}`)
await checkAssetBundleAvailability(mostRecent.entityId)
await checkAssetBundleStatus(registryUrl, [pointer], mostRecent.entityId)
}
}
140 changes: 140 additions & 0 deletions src/commands/world-ab-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import arg from 'arg'
import { fetch } from 'undici'

function statusLabel(status: string): string {
switch (status) {
case 'complete':
return '✅ complete'
case 'pending':
return '⏳ pending'
case 'failed':
return '❌ failed'
case 'obsolete':
return '🗑️ obsolete'
case 'fallback':
return '🔄 fallback'
default:
return `⚠️ ${status}`
}
}

type Scene = {
entityId: string
parcels: string[]
entity: {
pointers: string[]
metadata: { display?: { title?: string }; scene?: { base?: string } }
}
}

type RegistryEntity = {
id: string
pointers: string[]
status: string
versions: { assets: Record<string, { version: string; buildDate: string }> }
}

export default async function () {
const args = arg({
'--world': String,
'--env': String
})

const world = args['--world']
if (!world) {
throw new Error('--world is required')
}

const env = args['--env'] || 'org'
const worldsUrl = `https://worlds-content-server.decentraland.${env}`
const registryUrl = `https://asset-bundle-registry.decentraland.${env}`

// Step 1: Discover scenes
console.log(`> Fetching scenes for world: ${world} (${env})`)
const scenesResponse = await fetch(`${worldsUrl}/world/${encodeURIComponent(world)}/scenes`)

if (!scenesResponse.ok) {
throw new Error(`worlds-content-server returned ${scenesResponse.status}`)
}

const { scenes } = (await scenesResponse.json()) as { scenes: Scene[] }

if (!scenes.length) {
console.log('> No scenes found')
return
}

console.log(`> Found ${scenes.length} scene(s)`)

// Collect all pointers and build a map of entityId per pointer
const allPointers: string[] = []
const entityIdByPointer = new Map<string, string>()

for (const scene of scenes) {
const name = scene.entity.metadata?.display?.title || 'Untitled'
const base = scene.entity.metadata?.scene?.base || scene.parcels[0]
console.log(`\n Scene: ${name} (base: ${base}, parcels: ${scene.parcels.length})`)
console.log(` Entity ID: ${scene.entityId}`)

for (const pointer of scene.parcels) {
allPointers.push(pointer)
entityIdByPointer.set(pointer, scene.entityId)
}
}

// Step 2: Check Asset Bundle Registry
console.log(`\n> Querying asset bundle registry...`)
const registryResponse = await fetch(`${registryUrl}/entities/active?world_name=${encodeURIComponent(world)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pointers: allPointers })
})

if (!registryResponse.ok) {
console.log(`> Asset Bundle Registry: ❌ (returned ${registryResponse.status})`)
return
}

const registryEntities = (await registryResponse.json()) as RegistryEntity[]

if (!registryEntities.length) {
console.log('> Asset Bundle Registry: no entities found (never converted)')
return
}

// Match registry entities back to scenes
const registryByEntityId = new Map<string, RegistryEntity>()
for (const entry of registryEntities) {
registryByEntityId.set(entry.id, entry)
}

console.log('')
for (const scene of scenes) {
const name = scene.entity.metadata?.display?.title || 'Untitled'
const base = scene.entity.metadata?.scene?.base || scene.parcels[0]

// Find the registry entry that covers this scene's pointers
const registryEntry = registryEntities.find((e) => e.pointers.some((p) => scene.parcels.includes(p)))

console.log(`> Scene: ${name} (base: ${base})`)
console.log(` Deployed entity: ${scene.entityId}`)

if (!registryEntry) {
console.log(` Registry: ❌ not found (never converted)`)
continue
}

const idMatch = registryEntry.id === scene.entityId
console.log(` Registry entity: ${registryEntry.id}`)
console.log(` Entity ID match: ${idMatch ? '✅' : '🚨 STALE — registry has different entity'}`)
console.log(` Status: ${statusLabel(registryEntry.status)}`)

const platforms = ['windows', 'mac', 'webgl']
console.log(' Versions:')
for (const platform of platforms) {
const version = registryEntry.versions?.assets?.[platform]
const versionStr = version ? `v${version.version} (${version.buildDate})` : 'N/A'
console.log(` ${platform.padEnd(10)} ${versionStr}`)
}
}
}
Loading