OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / handlers / releases.js

269 lines · javascript ref:
/**
 * Release RPC handlers (P.5.1).
 *
 * Thin wrappers over svc._releases (ReleaseManager). All mutating handlers
 * require writer; reads are public. This module handles the per-repo
 * version + channel story — not to be confused with the lower-level
 * deploy-service handlers in ../services/deploy.js which manage the
 * hyper-deploy DeployServer itself.
 */

import { readFile } from '#node:fs/promises'
import { join } from '#node:path'
import { requireWriter } from '../auth/check.js'
import { buildAndPublish } from '../ci-orchestrator.js'

export function releaseHandlers (svc) {
  const registry = () => svc.identityRegistry
  const rm = () => {
    if (!svc._releases) throw new Error('release manager not initialised')
    return svc._releases
  }

  return [
    {
      name: 'list-repo-releases',
      fn: async (ctx, params = {}) => {
        if (!params.repo) throw new Error('list-repo-releases requires { repo }')
        return rm().list(params.repo)
      }
    },

    {
      name: 'list-all-releases',
      fn: async () => ({ repos: await rm().list() })
    },

    {
      // The discovery surface a ReleasePuller needs, and the ONLY thing the v2
      // overlay cannot provide for itself: it is content/path addressed with no
      // directory listing, so a peer must be told what exists before it can ask
      // for anything. Returns `<releaseDir>/_index.json` — files with size +
      // content hash, plus channel pointers.
      //
      // Deliberately NOT the v1 `sendFileList` broadcast that hyper-deploy 0.4.1
      // removed: this is solicited, travels on the authenticated hyper-svc
      // channel, and is bounded by RELEASE count rather than by the 100k-file
      // shared dependency cache that blew the 16 MiB frame limit.
      //
      // Regenerated lazily if absent so an origin that has not published since
      // upgrading still answers.
      // ANONYMOUS-CALLABLE, because a puller reaches its origin over swarm-RPC
      // and a swarm ctx carries trust:'network' — the origin would otherwise
      // refuse its own mirror. Exactly the trap that already required dump-keys
      // and list-repos to be opted in for resync-from-origin.
      //
      // Filtered, not open: an anonymous caller sees only repos it could read
      // anyway, so a private repo's versions and hashes do not leak. A trusted
      // (UDS / writer) caller gets everything.
      name: 'release-index',
      fn: async (ctx) => {
        const m = rm()
        let index
        try {
          index = JSON.parse(await readFile(join(m._releaseDir, '_index.json'), 'utf8'))
        } catch {
          index = await m._writeIndex()
        }

        const anon = !ctx || !ctx.identity
        if (!anon || !svc.repoAccess) return index

        const allowed = new Map()
        const mayRead = async (repo) => {
          if (allowed.has(repo)) return allowed.get(repo)
          let ok = true
          try {
            const d = await svc.repoAccess.async(repo, { transport: 'swarm-rpc', identity: null })
            if (d && d.wouldDeny && d.enforced) ok = false
          } catch { ok = true }
          allowed.set(repo, ok)
          return ok
        }

        const files = []
        for (const f of index.files || []) {
          if (f && f.repo && await mayRead(f.repo)) files.push(f)
        }
        const channels = []
        for (const c of index.channels || []) {
          if (c && c.repo && await mayRead(c.repo)) channels.push(c)
        }
        return { ...index, files, channels, filtered: true }
      }
    },

    {
      // Pull release artifacts from this daemon's ORIGIN, now, without waiting
      // for the scheduler's interval. Writer-gated: it rewrites bytes on disk.
      // `{ dryRun: true }` reports the plan and touches nothing — use it first.
      //
      // Returns { ok:false } rather than throwing when this daemon is not a
      // mirror or the puller is disabled, so an operator gets a reason instead
      // of a stack trace.
      name: 'pull-releases',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        if (!svc._releasePull || typeof svc._releasePull.pullNow !== 'function') {
          return {
            ok: false,
            reason: 'release pull is not enabled on this daemon (set PGV3_RELEASE_PULL=1, ' +
              'and note it only pulls from an origin — a primary has none)'
          }
        }
        // `repos` scopes the reconcile to named repos, the same way an event-driven
        // reconcile scopes itself. It was missing here, so operator-issued pulls
        // silently ran against the WHOLE index while appearing scoped — the logs
        // showed no scope tag and the diff covered every release ever published.
        return svc._releasePull.pullNow({
          dryRun: !!params.dryRun,
          repos: Array.isArray(params.repos) ? params.repos.filter(r => typeof r === 'string') : undefined
        })
      }
    },

    {
      name: 'get-release-manifest',
      fn: async (ctx, params = {}) => {
        if (!params.repo || !params.version) {
          throw new Error('get-release-manifest requires { repo, version }')
        }
        const manifest = await rm().getManifest(params.repo, params.version)
        if (!manifest) throw new Error(`release ${params.repo}@${params.version} not found`)
        return manifest
      }
    },

    {
      // Inline-data variant: caller ships the whole payload in-RPC. Fine for
      // small artifacts (metadata + small tarballs). For big binaries, use
      // the HTTP streaming endpoint instead (/api/releases/upload/...).
      name: 'publish-release',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        const { repo, version, channel, artifacts, meta } = params
        if (!repo || !version) throw new Error('publish-release requires { repo, version, artifacts }')
        if (!Array.isArray(artifacts) || artifacts.length === 0) {
          throw new Error('publish-release requires non-empty artifacts array')
        }

        // artifacts may arrive as [{ path, data: base64 }] over JSON-RPC.
        // Decode lazily so the ReleaseManager always sees Buffers.
        const decoded = artifacts.map(a => ({
          path: a.path,
          data: typeof a.data === 'string' && !Buffer.isBuffer(a.data)
            ? Buffer.from(a.data, 'base64')
            : a.data
        }))

        return rm().publish({ repo, version, channel, artifacts: decoded, meta })
      }
    },

    {
      name: 'promote-release',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        const { repo, version, toChannel } = params
        if (!repo || !version || !toChannel) {
          throw new Error('promote-release requires { repo, version, toChannel }')
        }
        return rm().promote(repo, version, toChannel)
      }
    },

    {
      name: 'unpin-release-channel',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        const { repo, channel } = params
        if (!repo || !channel) throw new Error('unpin-release-channel requires { repo, channel }')
        return rm().unpinChannel(repo, channel)
      }
    },

    {
      name: 'delete-release',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        const { repo, version } = params
        if (!repo || !version) throw new Error('delete-release requires { repo, version }')
        return rm().deleteVersion(repo, version)
      }
    },

    {
      name: 'gc-releases',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        return rm().gc({
          repo: params.repo,
          keepN: typeof params.keepN === 'number' ? params.keepN : undefined,
          dryRun: !!params.dryRun
        })
      }
    },

    {
      // What virtual Hyperdrives is this daemon serving, and under what links.
      //
      // NOT in ANONYMOUS_METHODS (pear-git.js), so it needs a trusted caller —
      // UDS operator or a writer. The links themselves are public keys and grant
      // nothing on their own, but enumerating every repo's drive would hand an
      // anonymous caller a map of what this daemon publishes, which the repo
      // listing already gates.
      name: 'vdrive-status',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        const seeder = svc._vdriveSeeder
        if (!seeder) return { enabled: false, drives: [] }
        const drives = seeder.stats()
        if (params.repo) {
          return { enabled: seeder.enabled, drives: drives.filter((d) => d.repo === params.repo) }
        }
        return { enabled: seeder.enabled, drives }
      }
    },

    {
      // Run the repo's .pear-git/ci.yaml against a ref, collect artifacts,
      // publish the resulting release. Returns { ok, version, channel,
      // steps, buildLog } — buildLog is capped at ~1MB of step stdout.
      name: 'build-release',
      fn: async (ctx, params = {}) => {
        await requireWriter(ctx, registry())
        if (!svc._sync) throw new Error('sync service not running')
        let { repo, ref, isTag, version, channel } = params
        if (!repo || !ref) throw new Error('build-release requires { repo, ref }')
        // Normalize fully-qualified refs ("refs/heads/master", "refs/tags/v1")
        // to bare names. autoBuildFromPush pre-strips before reaching
        // buildAndPublish; RPC callers (CLI, HTTP) pass full refs, so mirror
        // that normalization here. Without this, deriveVersion produces
        // versions like "refs/heads/master-abc1234" with embedded slashes
        // that break releaseManager's filesystem layout.
        if (ref.startsWith('refs/tags/')) {
          if (isTag === undefined) isTag = true
          ref = ref.slice('refs/tags/'.length)
        } else if (ref.startsWith('refs/heads/')) {
          if (isTag === undefined) isTag = false
          ref = ref.slice('refs/heads/'.length)
        }
        return buildAndPublish({
          sync: svc._sync,
          releaseManager: rm(),
          // Manual triggers need the seeder too: without it a vdrive publish
          // cannot drop the seeder's exclusive lock on the store, and the new
          // drive is never announced. The push path threads this already.
          vdriveSeeder: svc._vdriveSeeder,
          vdriveRootKey: svc._vdriveRootKey || null,
          repo,
          ref,
          isTag: !!isTag,
          version,
          channel
        })
      }
    }
  ]
}