Ctrl K
ComunidadGuías…

__tests__/recorder-remote.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * A recording as a pod of its own (Phase 3): the Job it asks for, the engine handing a recording to the
 * cluster and taking it back when the cluster says no, the pod's journal merged into ours, a pod gone
 * or silent closed as a playable partial, and the pod's own bookkeeping of what is safe to push.
 * No cluster here: the remote is faked, the Job spec is inspected.
 */
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { jobSpec, jobAlive } from '../src/recorder/k8s.js';
import { Recorder, MIN_FREE_BYTES, STALE_MS } from '../src/recorder/engine.js';
import { readySegments } from '../src/recorder/recorder-main.js';

const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'rec-remote-'));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const quiet = { info() {}, warn() {}, debug() {} };
const baseDeps = () => ({ capabilities: () => ({ ok: true }), freeBytes: () => 100 * MIN_FREE_BYTES, sizeOf: () => ({ width: 1280, height: 720 }),
  cookiesFor: async () => [], startDisplay: async () => ({ n: 101, stop() {} }), startPulse: async () => ({ server: 'unix:/x', stop() {} }),
  launchBrowser: async () => ({ pages: () => [{ goto: async () => {}, title: async () => 'T' }], addCookies: async () => {}, close: async () => {} }),
  preparePage: async () => ({}), videoState: async () => ({ ended: false, paused: false, currentTime: 1, duration: 100, href: 'https://x/v' }),
  startFfmpeg: ({ out }) => { fs.writeFileSync(path.join(out, 'index.m3u8'), '#EXTM3U\n'); const proc = { exitCode: null }; return { proc, stop: async () => { proc.exitCode = 0; return { code: 0 }; } }; } });

describe('a recording as a pod of its own', () => {
  it('asks for a Job with the recording\'s token, its own resources, a scratch disk, labels and a TTL — and no service account token', () => {
    const s = jobSpec({ id: 'rec-abc', namespace: 'pod-x', image: 'wvdstoep/ghost-browser:v340', gbUrl: 'http://ghost-browser:3000', token: 'tok', imagePullSecrets: ['regcred'] });
    expect(s.metadata.name).toBe('ghost-browser-rec-rec-abc'); expect(s.metadata.labels['gb/recording']).toBe('rec-abc');
    const c = s.spec.template.spec.containers[0];
    expect(c.image).toBe('wvdstoep/ghost-browser:v340');
    expect(Object.fromEntries(c.env.map((e) => [e.name, e.value]))).toMatchObject({ MODE: 'recorder', RECORDING_ID: 'rec-abc', GB_URL: 'http://ghost-browser:3000', RECORDING_TOKEN: 'tok', RECORDINGS_DIR: '/scratch' });
    expect(c.resources.requests).toEqual({ cpu: '1', memory: '1536Mi' }); expect(c.resources.limits).toEqual({ cpu: '2', memory: '3Gi' });
    expect(s.spec.template.spec.volumes.find((v) => v.name === 'scratch').emptyDir.sizeLimit).toBe('20Gi');
    expect(s.spec.template.spec.automountServiceAccountToken).toBe(false); expect(s.spec.template.spec.imagePullSecrets).toEqual([{ name: 'regcred' }]);
    expect(s.spec.ttlSecondsAfterFinished).toBe(600); expect(s.spec.backoffLimit).toBe(0); expect(s.spec.template.spec.restartPolicy).toBe('Never');
    expect(jobAlive({ status: { active: 1 } })).toBe(true); expect(jobAlive({ status: { succeeded: 1 } })).toBe(false); expect(jobAlive({ status: { failed: 1 } })).toBe(false); expect(jobAlive({ status: {} })).toBe(true);
  });

  it('hands a recording to the cluster: journal says mode job with a token, the pod\'s updates merge in, stop is a flag the pod reads', async () => {
    const launched = []; const remote = { available: () => true, launch: async (rec) => { launched.push(rec.id); return { jobName: 'ghost-browser-rec-' + rec.id }; }, alive: async () => true, stop: () => true };
    const r = new Recorder({ root: tmp(), deps: { ...baseDeps(), remote }, log: quiet });
    const v = r.start({ url: 'https://x/v', profile: 'google', until: 'video-ends' });
    expect(v.mode).toBe('job'); expect(v.live).toBe(true); expect(v.token).toBeUndefined();   // the token never leaves in a view
    expect(r.tokenOf(v.id)).toMatch(/^[0-9a-f]{48}$/); expect(r.running()).toEqual([]); expect(r.runningRemote()).toEqual([v.id]);
    await sleep(20); expect(launched).toEqual([v.id]); expect(r.get(v.id).jobName).toBe('ghost-browser-rec-' + v.id);
    const h = r.handoff(v.id); expect(h).toMatchObject({ id: v.id, url: 'https://x/v', profile: 'google', until: 'video-ends', stop: '' });
    // the pod's numbers are ignored: what counts is what arrived here (the pod deletes what it pushed)
    expect(r.remoteUpdate(v.id, { state: 'recording', seconds: 30, bytes: 1000, segments: 3, pageTitle: 'T', mode: 'local', url: 'https://evil' })).toMatchObject({ state: 'recording', seconds: 0, segments: 0, pageTitle: 'T', mode: 'job', url: 'https://x/v' });
    fs.writeFileSync(path.join(r.dirOf(v.id), 'index.m3u8'), '#EXTM3U\n#EXTINF:10.000,\nseg-00000.ts\n#EXTINF:10.000,\nseg-00001.ts\n'); fs.writeFileSync(path.join(r.dirOf(v.id), 'seg-00000.ts'), Buffer.alloc(40)); fs.writeFileSync(path.join(r.dirOf(v.id), 'seg-00001.ts'), Buffer.alloc(2));
    expect(r.remoteUpdate(v.id, { state: 'recording' })).toMatchObject({ seconds: 20, segments: 2, bytes: 42 });
    expect(r.remove(v.id)).toEqual({ error: 'still recording — stop it first' });
    expect(r.stop(v.id)).toEqual({ ok: true, id: v.id, remote: true }); expect(r.handoff(v.id).stop).toBe('stopped by the owner');
    fs.writeFileSync(path.join(r.dirOf(v.id), 'index.m3u8'), '#EXTM3U\n#EXTINF:10.000,\nseg-00001.ts\n'); fs.writeFileSync(path.join(r.dirOf(v.id), 'seg-00001.ts'), Buffer.alloc(5));
    expect(r.remoteUpdate(v.id, { state: 'done', reason: 'stopped by the owner', seconds: 10, segments: 1, bytes: 5 }).live).toBe(false);
    expect(fs.readFileSync(path.join(r.dirOf(v.id), 'index.m3u8'), 'utf8')).toMatch(/ENDLIST/);
    expect(r.remove(v.id)).toEqual({ removed: true });
  });

  it('records here when the cluster says no, and the journal says why', async () => {
    const remote = { available: () => true, launch: async () => { throw new Error('jobs.batch is forbidden'); }, alive: async () => true, stop: () => true };
    const r = new Recorder({ root: tmp(), deps: { ...baseDeps(), remote }, log: quiet, tickMs: 20 });
    const v = r.start({ url: 'https://x/v', until: 'owner-stop' }); expect(v.mode).toBe('job');
    await sleep(60); const j = r.get(v.id); expect(j.mode).toBe('local'); expect(j.fallback).toMatch(/forbidden/); expect(r.running()).toEqual([v.id]); expect(r.tokenOf(v.id)).toBe('');
    r.stop(v.id); await sleep(80);
  });

  it('a Job the cluster accepted but never gave a pod (a quota) is taken back and the recording runs here', async () => {
    const cancelled = []; const remote = { available: () => true, launch: async () => ({ jobName: 'j-q' }), alive: async () => true, stop: () => true, podExists: async () => false, cancel: async (rec) => { cancelled.push(rec.id); return true; } };
    const r = new Recorder({ root: tmp(), deps: { ...baseDeps(), remote }, log: quiet, tickMs: 20, launchGraceMs: 30 });
    const v = r.start({ url: 'https://x/v', until: 'owner-stop' }); expect(v.mode).toBe('job');
    await sleep(120);
    const j = r.get(v.id); expect(cancelled).toEqual([v.id]); expect(j.mode).toBe('local'); expect(j.fallback).toMatch(/no pod/); expect(r.running()).toEqual([v.id]); expect(r.demand.refusedRemote).toBe(1);
    r.stop(v.id); await sleep(80);
    // a Job that DID get a pod is left alone
    const ok = { ...remote, podExists: async () => true, cancel: async () => { throw new Error('must not cancel'); } };
    const r2 = new Recorder({ root: tmp(), deps: { ...baseDeps(), remote: ok }, log: quiet, launchGraceMs: 20 });
    const w = r2.start({ url: 'https://x/w' }); await sleep(80); expect(r2.get(w.id).mode).toBe('job'); expect(r2.runningRemote()).toEqual([w.id]);
  });

  it('a pod that is gone, or silent too long, leaves a playable partial; a live one is left alone', async () => {
    let t = 1000; let alive = true;
    const remote = { available: () => true, launch: async () => ({ jobName: 'j' }), alive: async () => alive, stop: () => true };
    const r = new Recorder({ root: tmp(), deps: { ...baseDeps(), remote }, log: quiet, clock: () => t });
    const v = r.start({ url: 'https://x/v' }); await sleep(10);
    fs.writeFileSync(path.join(r.dirOf(v.id), 'index.m3u8'), '#EXTM3U\n#EXTINF:10.000,\nseg-00001.ts\n'); fs.writeFileSync(path.join(r.dirOf(v.id), 'seg-00001.ts'), Buffer.alloc(7));
    r.remoteUpdate(v.id, { state: 'recording' });
    expect(await r.reconcile()).toBe(0);
    t += STALE_MS + 1; expect(await r.reconcile()).toBe(1); expect(r.get(v.id)).toMatchObject({ state: 'partial', segments: 1, reason: 'the recorder pod went silent' });
    const w = r.start({ url: 'https://x/w' }); await sleep(10); r.remoteUpdate(w.id, { state: 'recording' }); alive = false;
    expect(await r.reconcile()).toBe(1); expect(r.get(w.id)).toMatchObject({ state: 'failed', reason: 'the recorder pod is gone' });
    expect(r.adopt()).toBe(0);   // adopt() leaves pods of their own to reconcile()
  });

  it('a playback ticket opens one recording\'s media for a while, never another\'s, never after it expires, and survives a restart', () => {
    let t = 5000; const root = tmp();
    const r = new Recorder({ root, deps: baseDeps(), log: quiet, clock: () => t });
    const tk = r.ticket('rec-a', 1000); expect(tk.t).toMatch(/^6000\.[0-9a-f]{32}$/); expect(tk.exp).toBe(6000);
    expect(r.checkTicket('rec-a', tk.t)).toBe(true); expect(r.checkTicket('rec-b', tk.t)).toBe(false); expect(r.checkTicket('rec-a', tk.t.replace(/.$/, 'x'))).toBe(false); expect(r.checkTicket('rec-a', '')).toBe(false);
    t = 6001; expect(r.checkTicket('rec-a', tk.t)).toBe(false);
    const r2 = new Recorder({ root, deps: baseDeps(), log: quiet, clock: () => 5000 }); expect(r2.checkTicket('rec-a', tk.t)).toBe(true);   // the secret is on disk
  });

  it('Phase 5: with a platform wired in, the recorder is asked from the platform, not created in our namespace — same protocol, the pod reaches GB by its full cluster name', async () => {
    const { makePlatformRemote, chooseRemote, makeRemote } = await import('../src/recorder/remote.js');
    const calls = []; const request = async (method, path, body) => { calls.push([method, path, body]); if (method === 'POST') return { jobRef: 'rec-job-7', where: 'byon:node-a' }; if (method === 'GET') return { alive: true, hasPod: true }; return {}; };
    const pr = makePlatformRemote({ url: 'https://platform.test/', token: 'ptok', host: 'ghost-browser-pods.pod-x.svc.cluster.local', request, log: quiet });
    expect(pr.available()).toBe(true); expect(pr.host).toBe('ghost-browser-pods.pod-x.svc.cluster.local');
    const j = await pr.launch({ id: 'rec-1', token: 'rtok', quality: '1080p', maxMinutes: 30, title: 'T' });
    expect(j).toEqual({ jobName: 'rec-job-7', where: 'byon:node-a' });
    expect(calls[0]).toEqual(['POST', '/api/recorders', { recordingId: 'rec-1', gbUrl: 'http://ghost-browser-pods.pod-x.svc.cluster.local:3000', proxyHost: 'ghost-browser-pods.pod-x.svc.cluster.local', token: 'rtok', quality: '1080p', maxMinutes: 30, title: 'T' }]);
    expect(await pr.alive({ jobName: 'rec-job-7' })).toBe(true); expect(await pr.podExists({ jobName: 'rec-job-7' })).toBe(true); expect(await pr.cancel({ jobName: 'rec-job-7' })).toBe(true);
    expect(calls.map((c) => c[0] + ' ' + c[1]).slice(1)).toEqual(['GET /api/recorders/rec-job-7', 'GET /api/recorders/rec-job-7', 'DELETE /api/recorders/rec-job-7']);
    // the platform says no: plan (403) → the engine's fallback path gets a clear reason
    const no = makePlatformRemote({ url: 'https://platform.test', token: 'p', request: async () => { throw Object.assign(new Error('POST /api/recorders → 403: plan'), { status: 403 }); }, log: quiet });
    await expect(no.launch({ id: 'rec-2', token: 't' })).rejects.toThrow(/403: plan/);
    // no platform wired in: our own namespace's Job remote is chosen (and says so through its host)
    expect(makePlatformRemote({ url: '', token: '' }).available()).toBe(false);
    const own = chooseRemote({ log: quiet }); expect(own.host).toBe('ghost-browser-pods'); expect(typeof own.launch).toBe('function');
    expect(makeRemote({ log: quiet, gbUrl: 'http://gb-custom:3000' }).host).toBe('gb-custom');
  });

  it('the pod pushes only segments that are complete: never the one being written, all of them at the end', () => {
    const d = tmp(); for (const n of ['seg-00001.ts', 'seg-00002.ts', 'seg-00003.ts']) fs.writeFileSync(path.join(d, n), '');
    const pushed = new Set(['seg-00001.ts']);
    expect(readySegments(d, pushed)).toEqual(['seg-00002.ts']); expect(readySegments(d, pushed, true)).toEqual(['seg-00002.ts', 'seg-00003.ts']);
    expect(readySegments('/nope', pushed)).toEqual([]);
  });
});