OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / check-community-appliance-packages.mjs

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

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

const here = path.dirname(fileURLToPath(import.meta.url))
const defaultRoot = path.resolve(here, '..')

if (isMain()) main()

export function validateCommunityAppliancePackages (opts = {}) {
  const root = path.resolve(opts.root || defaultRoot)
  const errors = []
  const tagOnlyImages = []
  const rootPackage = readJson(path.join(root, 'package.json'), errors)
  const version = rootPackage && rootPackage.version

  requireFiles(root, [
    'unraid-app/README.md',
    'unraid-app/ca_profile.xml',
    'unraid-app/icon.svg',
    'unraid-app/templates/blindspark.xml',
    'zimaos-app/README.md',
    'zimaos-app/Apps/Blindspark/docker-compose.yml',
    'zimaos-app/Apps/Blindspark/icon.svg',
    'runtipi-app/README.md',
    'runtipi-app/apps/blindspark/config.json',
    'runtipi-app/apps/blindspark/docker-compose.yml',
    'runtipi-app/apps/blindspark/metadata/description.md',
    'runtipi-app/apps/blindspark/metadata/logo.jpg',
    'hexos-app/README.md',
    'hexos-app/blindspark.json'
  ], errors)

  if (errors.length === 0) {
    validateUnraid(root, version, errors, tagOnlyImages)
    validateZimaOs(root, version, errors, tagOnlyImages)
    validateRuntipi(root, version, errors, tagOnlyImages)
    validateHexOs(root, version, errors)
  }

  return {
    ok: errors.length === 0,
    packages: ['unraid', 'zimaos-casaos', 'runtipi', 'hexos'],
    tagOnlyImages,
    errors
  }
}

function main () {
  const result = validateCommunityAppliancePackages()
  if (!result.ok) {
    console.error('Community appliance package validation failed:')
    for (const error of result.errors) console.error(`- ${error}`)
    process.exit(1)
  }

  console.log('Community appliance packages validate for Unraid, ZimaOS/CasaOS, Runtipi, and HexOS.')
  if (result.tagOnlyImages.length) {
    console.log(`Release preparation will replace ${result.tagOnlyImages.length} authoring tag(s) with the released image digest.`)
  }
}

function validateUnraid (root, version, errors, tagOnlyImages) {
  const profile = read(path.join(root, 'unraid-app', 'ca_profile.xml'))
  const template = read(path.join(root, 'unraid-app', 'templates', 'blindspark.xml'))

  includesAll(profile, [
    '<CommunityApplications>',
    '<Profile>',
    'P2P-Hiverelay/main/unraid-app/icon.svg'
  ], 'Unraid repository profile', errors)
  includesAll(template, [
    '<Container version="2">',
    '<Name>Blindspark</Name>',
    '<Network>bridge</Network>',
    '<Privileged>false</Privileged>',
    '<WebUI>http://[IP]:[PORT:9100]</WebUI>',
    '<TemplateURL>https://raw.githubusercontent.com/bigdestiny2/P2P-Hiverelay/main/unraid-app/templates/blindspark.xml</TemplateURL>',
    '<License>Apache-2.0</License>',
    'Target="HIVERELAY_API_KEY"',
    'Required="true" Mask="true"',
    'Target="HIVERELAY_ACCEPT_MODE" Default="review"',
    'Target="HIVERELAY_UI_EXPOSE_TOKEN" Default="true"'
  ], 'Unraid application template', errors)
  excludesAll(template, ['YOUR_', '<Privileged>true</Privileged>'], 'Unraid application template', errors)
  matches(template, /<Date>\d{4}-\d{2}-\d{2}<\/Date>/, 'Unraid release date', errors)
  includesAll(template, [`<Changes>HiveRelay ${version}</Changes>`], 'Unraid release metadata', errors)
  const repository = xmlValue(template, 'Repository', errors, 'Unraid image repository')
  validateImage(repository, version, 'Unraid', errors, tagOnlyImages)
}

function validateZimaOs (root, version, errors, tagOnlyImages) {
  const compose = read(path.join(root, 'zimaos-app', 'Apps', 'Blindspark', 'docker-compose.yml'))
  const metadataVersion = scalar(compose, /^ {2}version:\s*["']?([^"'\n]+)["']?\s*$/m, errors, 'ZimaOS metadata version')

  includesAll(compose, [
    'name: blindspark',
    'source: /DATA/AppData/$AppID/data',
    'target: /data',
    'key_file=/data/.management-key',
    'randomBytes(32)',
    'export HIVERELAY_API_KEY=',
    'HIVERELAY_ACCEPT_MODE: review',
    'HIVERELAY_UI_EXPOSE_TOKEN: "true"',
    'id: com.hiverelay.blindspark',
    'main: blindspark',
    'port_map: "30452"',
    'category: Networking',
    'architectures:',
    'update_at: "'
  ], 'ZimaOS/CasaOS package', errors)
  excludesAll(compose, ['privileged: true', 'network_mode: host'], 'ZimaOS/CasaOS package', errors)
  equal(metadataVersion, version, 'ZimaOS metadata version', errors)
  const image = scalar(compose, /^\s+image:\s*(\S+)\s*$/m, errors, 'ZimaOS image')
  validateImage(image, version, 'ZimaOS', errors, tagOnlyImages)
}

function validateRuntipi (root, version, errors, tagOnlyImages) {
  const appRoot = path.join(root, 'runtipi-app', 'apps', 'blindspark')
  const config = readJson(path.join(appRoot, 'config.json'), errors)
  const compose = read(path.join(appRoot, 'docker-compose.yml'))
  if (!config) return

  equal(config.id, 'blindspark', 'Runtipi app id', errors)
  equal(config.name, 'Blindspark', 'Runtipi app name', errors)
  equal(config.version, version, 'Runtipi upstream version', errors)
  equal(config.available, true, 'Runtipi availability', errors)
  equal(config.dynamic_config, true, 'Runtipi dynamic Compose flag', errors)
  equal(config.port, 30452, 'Runtipi default host port', errors)
  matches(String(config.tipi_version), /^[1-9]\d*$/, 'Runtipi package revision', errors)
  matches(String(config.updated_at), /^[1-9]\d{11,}$/, 'Runtipi update timestamp', errors)
  includesAll(JSON.stringify(config), [
    '"type":"random"',
    '"min":48',
    '"required":true',
    '"env_variable":"HIVERELAY_API_KEY"'
  ], 'Runtipi management key field', errors)
  includesAll(compose, [
    'HIVERELAY_API_KEY=$' + '{HIVERELAY_API_KEY}',
    'HIVERELAY_ACCEPT_MODE=review',
    'HIVERELAY_UI_EXPOSE_TOKEN=true',
    'internal_port: 9100',
    'is_main: true',
    'schema_version: 2'
  ], 'Runtipi Compose package', errors)
  excludesAll(compose, ['privileged: true', 'network_mode: host'], 'Runtipi Compose package', errors)
  const image = scalar(compose, /^\s+image:\s*['"]?([^'"\s]+)['"]?\s*$/m, errors, 'Runtipi image')
  validateImage(image, version, 'Runtipi', errors, tagOnlyImages)
}

function validateHexOs (root, version, errors) {
  const config = readJson(path.join(root, 'hexos-app', 'blindspark.json'), errors)
  if (!config) return

  equal(config.version, 4, 'HexOS script format', errors)
  matches(config.script && config.script.version, /^\d+\.\d+\.\d+$/, 'HexOS curation revision', errors)
  includesAll(config.script && config.script.changeLog, [`HiveRelay ${version}`], 'HexOS upstream version', errors)
  includesAll(JSON.stringify(config), [
    '"ports":[30452]',
    '"api_key":"$RANDOM_STRING(48)"',
    '"accept_mode":"review"',
    '"expose_token":true',
    '"port_number":30452',
    '"memory":"$MEMORY(5%, 2048)"'
  ], 'HexOS curation', errors)
}

function validateImage (value, version, label, errors, tagOnlyImages) {
  const match = String(value || '').match(/^ghcr\.io\/bigdestiny2\/p2p-hiverelay:(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:@(sha256:[a-f0-9]{64}))?$/i)
  if (!match) {
    errors.push(`${label} image reference is invalid: ${value || '<missing>'}`)
    return
  }
  equal(match[1], version, `${label} image version`, errors)
  if (!match[2]) tagOnlyImages.push(label)
}

function requireFiles (root, files, errors) {
  for (const rel of files) {
    if (!fs.existsSync(path.join(root, rel))) errors.push(`missing ${rel}`)
  }
}

function readJson (file, errors) {
  try {
    return JSON.parse(read(file))
  } catch (err) {
    errors.push(`invalid JSON in ${path.basename(file)}: ${err.message}`)
    return null
  }
}

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

function xmlValue (text, key, errors, label) {
  return scalar(text, new RegExp(`<${key}>([^<]+)</${key}>`), errors, label)
}

function scalar (text, pattern, errors, label) {
  const match = String(text || '').match(pattern)
  if (!match) {
    errors.push(`${label} is missing`)
    return ''
  }
  return match[1].trim()
}

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

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

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

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

function isMain () {
  return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
}