OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / check-truenas-package.mjs

291 lines · javascript ref:
#!/usr/bin/env node

import fs from 'node:fs'
import crypto from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const appRoot = path.join(repoRoot, 'truenas-app')
const expectedFiles = [
  'README.md',
  'app.yaml',
  'item.yaml',
  'ix_values.yaml',
  'questions.yaml',
  'templates/docker-compose.yaml',
  'templates/library/base_v2_3_8/render.py',
  'templates/test_values/basic-values.yaml',
  'vendor/truenas-apps/LICENSE.LGPL-3.0',
  'vendor/truenas-apps/NOTICE.md',
  'vendor/truenas-apps/PROVENANCE.json'
]

const errors = []

for (const rel of expectedFiles) {
  if (!fs.existsSync(path.join(appRoot, rel))) errors.push(`missing truenas-app/${rel}`)
}

if (errors.length === 0) validatePackage()

if (errors.length) {
  console.error('TrueNAS package validation failed:')
  for (const error of errors) console.error(`- ${error}`)
  process.exit(1)
}

console.log('TrueNAS Community package validates for Blindspark.')

function validatePackage () {
  const rootPackage = JSON.parse(read(path.join(repoRoot, 'package.json')))
  const readme = read(path.join(appRoot, 'README.md'))
  const app = read(path.join(appRoot, 'app.yaml'))
  const values = read(path.join(appRoot, 'ix_values.yaml'))
  const questions = read(path.join(appRoot, 'questions.yaml'))
  const template = read(path.join(appRoot, 'templates', 'docker-compose.yaml'))
  const testValues = read(path.join(appRoot, 'templates', 'test_values', 'basic-values.yaml'))
  const provenance = JSON.parse(read(path.join(appRoot, 'vendor', 'truenas-apps', 'PROVENANCE.json')))

  const upstreamVersion = topScalar(app, 'app_version')
  const catalogVersion = topScalar(app, 'version')
  const imageTag = imageScalar(values, 'ghcr.io/bigdestiny2/p2p-hiverelay', 'tag')

  equal(topScalar(app, 'name'), 'blindspark', 'catalog app name')
  equal(topScalar(app, 'train'), 'community', 'catalog train')
  equal(upstreamVersion, rootPackage.version, 'catalog upstream version')
  equal(imageTag, rootPackage.version, 'catalog image tag')
  matches(catalogVersion, /^\d+\.\d+\.\d+$/, 'catalog package version')
  equal(topScalar(app, 'lib_version'), '2.3.8', 'TrueNAS rendering library version')
  equal(
    topScalar(app, 'lib_version_hash'),
    'cd75c897a1e8fef54b5bd00d0d8849f240bc50db2ef650eccc0ee74f3b2b2dc1',
    'TrueNAS rendering library hash'
  )
  validateVendorProvenance(provenance, app)
  includesAll(readme, [`Upstream HiveRelay release: \`${rootPackage.version}\``], 'TrueNAS app README')

  includesAll(app, [
    'uid: 999',
    'gid: 999',
    'user_name: Host user is [netdata]',
    'group_name: Host group is [docker]',
    'icon: https://media.sys.truenas.net/apps/blindspark/icons/icon.svg'
  ], 'app metadata')

  includesAll(questions, [
    'variable: api_key',
    'min_length: 32',
    'private: true',
    'variable: accept_mode',
    'default: review',
    'variable: expose_token',
    'default: 30452'
  ], 'install questions')
  const exposeTokenQuestion = questionBlock(questions, 'expose_token')
  includesAll(exposeTokenQuestion, ['type: boolean', 'default: false'], 'expose-token question')

  includesAll(template, [
    'c1.set_user(values.consts.run_as_user, values.consts.run_as_group)',
    'tpl.deps.perms(values.consts.perms_container_name)',
    'c1.healthcheck.set_test("wget"',
    'c1.environment.add_env("HIVERELAY_API_KEY"',
    'c1.environment.add_env("HIVERELAY_ACCEPT_MODE"',
    'c1.environment.add_env("HIVERELAY_MAX_STORAGE"',
    'c1.environment.add_env("HIVERELAY_UI_EXPOSE_TOKEN"',
    'c1.add_storage("/data"',
    'tpl.portals.add(values.network.web_port, {"scheme": "http", "path": "/"})'
  ], 'compose template')

  excludesAll(template, ['privileged', 'network_mode: host', 'set_user(0'], 'compose template')
  includesAll(testValues, [
    'accept_mode: review',
    'max_storage_gb: 10',
    'expose_token: false',
    'port_number: 30452'
  ], 'basic test values')
}

function validateVendorProvenance (provenance, app) {
  const libraryRoot = path.join(appRoot, 'templates', 'library', 'base_v2_3_8')
  const license = read(path.join(appRoot, 'vendor', 'truenas-apps', 'LICENSE.LGPL-3.0'))
  const notice = read(path.join(appRoot, 'vendor', 'truenas-apps', 'NOTICE.md'))
  const expectedDeviations = ['__init__.py', 'tests/__init__.py']
  const computed = computeContentIntegrity(libraryRoot)

  equal(provenance.schema, 'hiverelay-vendored-source-provenance-v1', 'vendor provenance schema')
  equal(provenance.version, '2.3.8', 'vendor library version')
  equal(provenance.license, 'LGPL-3.0-only', 'vendor license')
  equal(provenance.licenseSha256, crypto.createHash('sha256').update(license).digest('hex'), 'computed vendor license SHA-256')
  equal(provenance.localPath, 'truenas-app/templates/library/base_v2_3_8', 'vendor local path')
  equal(provenance.upstream?.repository, 'https://github.com/truenas/apps.git', 'vendor upstream repository')
  equal(provenance.upstream?.commit, '531009fca352356237287dbcc119c1365307ab86', 'vendor upstream commit')
  equal(provenance.upstream?.sourcePath, 'ix-dev/community/bambuddy/templates/library/base_v2_3_8', 'vendor upstream source path')
  equal(provenance.upstream?.tree, '3d946a0dbe832cb16e5da40c4a5d6f0dafb2be21', 'vendor upstream Git tree')
  equal(
    provenance.upstream?.officialLibraryHash,
    'cd75c897a1e8fef54b5bd00d0d8849f240bc50db2ef650eccc0ee74f3b2b2dc1',
    'vendor official library hash'
  )
  equal(provenance.upstream?.officialLibraryHash, topScalar(app, 'lib_version_hash'), 'vendor/app official library hash binding')
  equal(provenance.localContent?.algorithm, 'sha256(relative-path NUL decimal-byte-length NUL file-bytes NUL; paths sorted lexicographically)', 'vendor content-integrity algorithm')
  equal(provenance.localContent?.sha256, computed.sha256, 'computed vendor content SHA-256')
  equal(provenance.localContent?.fileCount, computed.fileCount, 'computed vendor file count')
  equal(provenance.localContent?.byteCount, computed.byteCount, 'computed vendor byte count')
  equal(
    provenance.localContent?.normalizedUpstreamGitTreeAlgorithm,
    'Git tree SHA-1 after replacing only the two declared newline-only deviations with their recorded upstream empty blobs',
    'normalized upstream Git-tree algorithm'
  )

  includesAll(license, [
    'GNU LESSER GENERAL PUBLIC LICENSE',
    'Version 3, 29 June 2007'
  ], 'vendored TrueNAS license')
  includesAll(notice, [
    'They are not covered by HiveRelay\'s Apache-2.0 license.',
    'LGPL-3.0-only',
    provenance.upstream.commit,
    provenance.upstream.tree,
    provenance.upstream.officialLibraryHash
  ], 'vendored TrueNAS notice')

  const deviations = Array.isArray(provenance.deviations) ? provenance.deviations : []
  equal(JSON.stringify(deviations.map(item => item.path)), JSON.stringify(expectedDeviations), 'vendor deviation paths')
  for (const deviation of deviations) {
    const bytes = fs.readFileSync(path.join(libraryRoot, deviation.path))
    equal(bytes.toString('hex'), '0a', `vendor deviation bytes for ${deviation.path}`)
    equal(deviation.upstreamGitBlob, 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', `vendor upstream empty blob for ${deviation.path}`)
    equal(deviation.localGitBlob, gitBlobHash(bytes), `vendor local blob for ${deviation.path}`)
    equal(deviation.reason, 'newline-only package marker', `vendor deviation reason for ${deviation.path}`)
  }

  const normalizedTree = computeGitTree(libraryRoot, new Set(expectedDeviations))
  equal(provenance.localContent?.normalizedUpstreamGitTree, normalizedTree, 'content-computed normalized upstream Git tree')
  equal(normalizedTree, provenance.upstream.tree, 'normalized vendor content/upstream Git tree binding')
}

function computeContentIntegrity (root) {
  const files = listFiles(root)
  const hash = crypto.createHash('sha256')
  let byteCount = 0

  for (const rel of files) {
    const bytes = fs.readFileSync(path.join(root, rel))
    byteCount += bytes.length
    hash.update(rel)
    hash.update('\0')
    hash.update(String(bytes.length))
    hash.update('\0')
    hash.update(bytes)
    hash.update('\0')
  }

  return { sha256: hash.digest('hex'), fileCount: files.length, byteCount }
}

function listFiles (root, prefix = '') {
  const files = []
  for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true })) {
    const rel = prefix ? `${prefix}/${entry.name}` : entry.name
    if (entry.isDirectory()) files.push(...listFiles(root, rel))
    else if (entry.isFile()) files.push(rel)
    else errors.push(`vendor library contains unsupported entry ${rel}`)
  }
  return files.sort()
}

function gitBlobHash (bytes) {
  return crypto.createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
}

function computeGitTree (root, normalizedEmptyPaths, prefix = '') {
  const entries = fs.readdirSync(path.join(root, prefix), { withFileTypes: true })
    .sort((left, right) => Buffer.compare(
      Buffer.from(`${left.name}${left.isDirectory() ? '/' : ''}`),
      Buffer.from(`${right.name}${right.isDirectory() ? '/' : ''}`)
    ))
  const chunks = []

  for (const entry of entries) {
    const rel = prefix ? `${prefix}/${entry.name}` : entry.name
    let mode
    let digest
    if (entry.isDirectory()) {
      mode = '40000'
      digest = computeGitTree(root, normalizedEmptyPaths, rel)
    } else if (entry.isFile()) {
      mode = '100644'
      const bytes = normalizedEmptyPaths.has(rel) ? Buffer.alloc(0) : fs.readFileSync(path.join(root, rel))
      digest = gitBlobHash(bytes)
    } else {
      errors.push(`vendor library contains unsupported Git-tree entry ${rel}`)
      continue
    }
    chunks.push(Buffer.from(`${mode} ${entry.name}\0`))
    chunks.push(Buffer.from(digest, 'hex'))
  }

  const content = Buffer.concat(chunks)
  return crypto.createHash('sha1').update(`tree ${content.length}\0`).update(content).digest('hex')
}

function read (file) {
  return fs.readFileSync(file, 'utf8')
}

function topScalar (text, key) {
  const match = text.match(new RegExp(`^${escapeRegExp(key)}:\\s*["']?([^"'\\n#]+?)["']?\\s*$`, 'm'))
  if (!match) {
    errors.push(`missing top-level ${key}`)
    return ''
  }
  return match[1].trim()
}

function imageScalar (text, repository, key) {
  const pattern = new RegExp(
    `repository:\\s*${escapeRegExp(repository)}\\s*\\n\\s+${escapeRegExp(key)}:\\s*["']?([^"'\\n#]+)`,
    'm'
  )
  const match = text.match(pattern)
  if (!match) {
    errors.push(`missing ${key} for image ${repository}`)
    return ''
  }
  return match[1].trim()
}

function questionBlock (text, variable) {
  const marker = `  - variable: ${variable}\n`
  const start = text.indexOf(marker)
  if (start === -1) return ''
  const next = text.indexOf('\n  - variable: ', start + marker.length)
  return text.slice(start, next === -1 ? text.length : next)
}

function equal (actual, expected, label) {
  if (actual !== expected) errors.push(`${label} must be ${expected}; found ${actual || '<missing>'}`)
}

function matches (actual, pattern, label) {
  if (!pattern.test(actual)) errors.push(`${label} is invalid: ${actual || '<missing>'}`)
}

function includesAll (text, terms, label) {
  for (const term of terms) {
    if (!text.includes(term)) errors.push(`${label} is missing ${JSON.stringify(term)}`)
  }
}

function excludesAll (text, terms, label) {
  for (const term of terms) {
    if (text.includes(term)) errors.push(`${label} must not contain ${JSON.stringify(term)}`)
  }
}

function escapeRegExp (value) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}