offload: CSV/HTML reports + forge offload/verify CLI w/ MHL manifests per destination; end-to-end verified incl tamper detection — 2 tests
This commit is contained in:
parent
17fdaa150d
commit
297477f342
3 changed files with 153 additions and 2 deletions
50
Sources/ForgeOffload/OffloadReportWriter.swift
Normal file
50
Sources/ForgeOffload/OffloadReportWriter.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Offload report renderers: CSV (machine) + HTML (producer handoff).
|
||||||
|
public enum OffloadReportWriter {
|
||||||
|
|
||||||
|
public static func csv(_ report: OffloadReport) -> String {
|
||||||
|
var out = "relative_path,size,xxh64,verified,skipped\n"
|
||||||
|
for f in report.files {
|
||||||
|
out += "\(f.relativePath),\(f.size),\(f.xxh64),\(f.verified),\(f.skipped)\n"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func formatBytes(_ bytes: UInt64) -> String {
|
||||||
|
let mb = Double(bytes) / 1_000_000
|
||||||
|
if mb >= 1000 {
|
||||||
|
return String(format: "%.2f GB", mb / 1000)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f MB", mb)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func html(_ report: OffloadReport) -> String {
|
||||||
|
let allVerified = report.files.allSatisfy(\.verified)
|
||||||
|
var rows = ""
|
||||||
|
for f in report.files {
|
||||||
|
rows += """
|
||||||
|
<tr><td>\(f.relativePath)</td><td>\(formatBytes(f.size))</td>\
|
||||||
|
<td><code>\(f.xxh64)</code></td>\
|
||||||
|
<td>\(f.verified ? "OK" : "FAIL")</td>\
|
||||||
|
<td>\(f.skipped ? "skipped" : "copied")</td></tr>\n
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
return """
|
||||||
|
<html>
|
||||||
|
<head><title>Forge Offload Report</title>
|
||||||
|
<style>body{font-family:sans-serif}table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:4px 8px}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Offload Report — \(allVerified ? "VERIFIED" : "FAILURES PRESENT")</h1>
|
||||||
|
<p>Source: \(report.source)</p>
|
||||||
|
<p>Destinations: \(report.destinations.joined(separator: ", "))</p>
|
||||||
|
<p>\(report.files.count) files, \(formatBytes(report.totalBytes)), \(String(format: "%.1f", report.duration))s</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>File</th><th>Size</th><th>xxHash64</th><th>Verify</th><th>Action</th></tr>
|
||||||
|
\(rows)</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,13 +7,74 @@ import ForgeColor
|
||||||
import ForgeSim
|
import ForgeSim
|
||||||
import ForgeLibrary
|
import ForgeLibrary
|
||||||
import ForgeExport
|
import ForgeExport
|
||||||
|
import ForgeOffload
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Forge: AsyncParsableCommand {
|
struct Forge: AsyncParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "forge",
|
commandName: "forge",
|
||||||
abstract: "On-set live grading: push looks to cameras over IP.",
|
abstract: "On-set live grading + DIT toolkit: camera look push, clip logging, offload, proxies.",
|
||||||
subcommands: [Push.self, Status.self, Sim.self, Export.self])
|
subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self])
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Offload: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(abstract: "Verified copy of a card to one or more destinations, with MHL manifest.")
|
||||||
|
|
||||||
|
@Argument(help: "Source directory (camera card)")
|
||||||
|
var source: String
|
||||||
|
|
||||||
|
@Option(name: [.customLong("to")], help: "Destination directory (repeatable)")
|
||||||
|
var destinations: [String]
|
||||||
|
|
||||||
|
@Flag(name: .long, help: "Re-copy destination files with mismatched size (interrupted copies)")
|
||||||
|
var overwritePartial = false
|
||||||
|
|
||||||
|
@Option(name: .long, help: "Write HTML report to this path")
|
||||||
|
var report: String?
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
guard !destinations.isEmpty else {
|
||||||
|
throw ValidationError("at least one --to destination required")
|
||||||
|
}
|
||||||
|
let engine = CopyEngine()
|
||||||
|
let result = try engine.offload(source: source, destinations: destinations, overwritePartial: overwritePartial)
|
||||||
|
|
||||||
|
// MHL manifest at each destination root.
|
||||||
|
let files = result.files.map(\.relativePath)
|
||||||
|
for dest in destinations {
|
||||||
|
try MHL.write(root: dest, creator: "Forge", files: files, to: dest + "/" + ((source as NSString).lastPathComponent) + ".mhl")
|
||||||
|
}
|
||||||
|
if let reportPath = report {
|
||||||
|
try OffloadReportWriter.html(result).write(toFile: reportPath, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let copied = result.files.filter { !$0.skipped }.count
|
||||||
|
print("offloaded \(result.files.count) files (\(copied) copied, \(result.files.count - copied) skipped), \(OffloadReportWriter.formatBytes(result.totalBytes)) to \(destinations.count) destination(s) — all verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Verify: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(abstract: "Verify a tree against an MHL manifest.")
|
||||||
|
|
||||||
|
@Argument(help: "Path to .mhl manifest")
|
||||||
|
var manifest: String
|
||||||
|
|
||||||
|
@Option(name: .long, help: "Tree root (default: manifest's directory)")
|
||||||
|
var root: String?
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let manifestText = try String(contentsOfFile: manifest, encoding: .utf8)
|
||||||
|
let treeRoot = root ?? (manifest as NSString).deletingLastPathComponent
|
||||||
|
let result = try MHL.verify(manifest: manifestText, root: treeRoot)
|
||||||
|
if result.passed {
|
||||||
|
print("PASSED: \(result.checked) files verified")
|
||||||
|
} else {
|
||||||
|
print("FAILED: \(result.failures.count)/\(result.checked) files:")
|
||||||
|
for f in result.failures {
|
||||||
|
print(" \(f.path): \(f.reason)")
|
||||||
|
}
|
||||||
|
throw ExitCode(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Export: AsyncParsableCommand {
|
struct Export: AsyncParsableCommand {
|
||||||
|
|
|
||||||
40
Tests/ForgeOffloadTests/ReportTests.swift
Normal file
40
Tests/ForgeOffloadTests/ReportTests.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
@testable import ForgeOffload
|
||||||
|
|
||||||
|
final class ReportTests: XCTestCase {
|
||||||
|
|
||||||
|
func makeReport() -> OffloadReport {
|
||||||
|
OffloadReport(
|
||||||
|
source: "/Volumes/CARD_A001",
|
||||||
|
destinations: ["/RAID/A001", "/SHUTTLE/A001"],
|
||||||
|
files: [
|
||||||
|
FileRecord(relativePath: "CLIPS/C001.mov", size: 1_000_000, xxh64: "abc123def4567890", verified: true, skipped: false),
|
||||||
|
FileRecord(relativePath: "CLIPS/C002.mov", size: 2_500_000, xxh64: "1111222233334444", verified: true, skipped: true),
|
||||||
|
],
|
||||||
|
totalBytes: 3_500_000,
|
||||||
|
duration: 12.5,
|
||||||
|
startedAt: Date(timeIntervalSince1970: 1_780_000_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV report golden shape.
|
||||||
|
func testCSVReport() {
|
||||||
|
let csv = OffloadReportWriter.csv(makeReport())
|
||||||
|
let lines = csv.split(separator: "\n").map(String.init)
|
||||||
|
XCTAssertEqual(lines[0], "relative_path,size,xxh64,verified,skipped")
|
||||||
|
XCTAssertEqual(lines[1], "CLIPS/C001.mov,1000000,abc123def4567890,true,false")
|
||||||
|
XCTAssertEqual(lines[2], "CLIPS/C002.mov,2500000,1111222233334444,true,true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML report contains summary + rows.
|
||||||
|
func testHTMLReport() {
|
||||||
|
let html = OffloadReportWriter.html(makeReport())
|
||||||
|
XCTAssertTrue(html.contains("<html>"))
|
||||||
|
XCTAssertTrue(html.contains("/Volumes/CARD_A001"))
|
||||||
|
XCTAssertTrue(html.contains("/RAID/A001"))
|
||||||
|
XCTAssertTrue(html.contains("CLIPS/C001.mov"))
|
||||||
|
XCTAssertTrue(html.contains("2 files"))
|
||||||
|
XCTAssertTrue(html.contains("3.5 MB") || html.contains("3,5 MB"))
|
||||||
|
XCTAssertTrue(html.contains("VERIFIED"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue