feat(nwsync): upload sink, key-addressed CLI, fail-closed publication marker
ci / ci (pull_request) Successful in 3m21s
ci / ci (pull_request) Successful in 3m21s
Resolves the build half of sow-tools#60 and closes the CLI gap sow-tools#53's sweep found: PR #71 shipped #53's original surface, not the one #56, #62 and #65 settled. - `depot.KeyStore` (`ProbeKey`/`PutReader`/`GetKey`) addresses the zone by object key instead of by depot sha, reusing the existing IPv4-pinned transport, retry and tri-state probe. The sha-addressed `Backend` now rides on it; no new HTTP client. - `nwsync` gains a sink: the zone by default, a local tree with `--out DIR` as the conformance path. Blobs upload as they are produced, the index lands last, and a blob already in the zone is skipped without paying for compression. - CLI is now `emit [--as NAME] [--out DIR] <artifact-key> <file>` and `assemble --group-id N [--tlk-key KEY] [--out DIR] <artifact-key>...`. Indexes live beside their artifact with the extension replaced, derived in one place. Flags may follow positionals, which cost a run during sow-tools#59. - Fail-closed: an artifact key whose digest does not match the file is refused, and `assemble` refuses an artifact with no index rather than publishing a manifest missing a hak. Conformance re-checked through the new CLI against upstream nwn_nwsync_write 2.1.2 on sow_vfxs_01.hak: the manifest is still byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
package nwsync
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeZone is a Bunny-shaped object store: PUT stores, GET reads, and the
|
||||
// Checksum header is verified the way Bunny verifies it.
|
||||
type fakeZone struct {
|
||||
mu sync.Mutex
|
||||
objects map[string][]byte
|
||||
puts []string
|
||||
failOn func(key string) bool // when true, the PUT fails
|
||||
}
|
||||
|
||||
func newFakeZone(t *testing.T) (*fakeZone, func(string) string) {
|
||||
t.Helper()
|
||||
zone := &fakeZone{objects: map[string][]byte{}}
|
||||
server := httptest.NewServer(zone)
|
||||
t.Cleanup(server.Close)
|
||||
getenv := func(name string) string {
|
||||
switch name {
|
||||
case "NWSYNC_STORAGE_ZONE":
|
||||
return "sow-nwsync"
|
||||
case "NWSYNC_STORAGE_PASSWORD":
|
||||
return "write-key"
|
||||
case "BUNNY_STORAGE_HOST":
|
||||
return server.URL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return zone, getenv
|
||||
}
|
||||
|
||||
func (z *fakeZone) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimPrefix(r.URL.Path, "/sow-nwsync/")
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
if z.failOn != nil && z.failOn(key) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
if want := strings.ToUpper(hex.EncodeToString(sum[:])); r.Header.Get("Checksum") != want {
|
||||
http.Error(w, "checksum mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
z.mu.Lock()
|
||||
z.objects[key] = body
|
||||
z.puts = append(z.puts, key)
|
||||
z.mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case http.MethodGet:
|
||||
z.mu.Lock()
|
||||
body, ok := z.objects[key]
|
||||
z.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
default:
|
||||
http.Error(w, "unsupported", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (z *zoneSinkFixture) emit(t *testing.T, path string) EmitResult {
|
||||
t.Helper()
|
||||
result, err := Emit(EmitOptions{
|
||||
ArtifactKey: artifactKey(t, path),
|
||||
ArtifactPath: path,
|
||||
Sink: z.sink,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("emit %s: %v", path, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type zoneSinkFixture struct {
|
||||
zone *fakeZone
|
||||
sink sink
|
||||
}
|
||||
|
||||
func newZoneFixture(t *testing.T) *zoneSinkFixture {
|
||||
t.Helper()
|
||||
zone, getenv := newFakeZone(t)
|
||||
target, err := newZoneSink(t.Context(), getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("zone sink: %v", err)
|
||||
}
|
||||
return &zoneSinkFixture{zone: zone, sink: target}
|
||||
}
|
||||
|
||||
func sha1Of(body []byte) [20]byte { return sha1.Sum(body) }
|
||||
|
||||
func TestEmitUploadsBlobsThenIndex(t *testing.T) {
|
||||
fixture := newZoneFixture(t)
|
||||
dir := t.TempDir()
|
||||
hak := filepath.Join(dir, "sow_test_01.hak")
|
||||
body := []byte("texture bytes")
|
||||
writeHak(t, hak, map[string][]byte{"bloodstain1.tga": body, "copy1.txi": body})
|
||||
|
||||
key := artifactKey(t, hak)
|
||||
result := fixture.emit(t, hak)
|
||||
|
||||
index, err := indexKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := fixture.zone.objects[index]; !ok {
|
||||
t.Fatalf("no index at %s; zone holds %v", index, fixture.zone.puts)
|
||||
}
|
||||
if result.BlobsWritten != 1 {
|
||||
t.Errorf("uploaded %d blobs, want 1 (identical content shares a blob)", result.BlobsWritten)
|
||||
}
|
||||
|
||||
// The index is the publication marker, so it must land after every blob it
|
||||
// names — including its own sidecar.
|
||||
last := fixture.zone.puts[len(fixture.zone.puts)-1]
|
||||
if last != index {
|
||||
t.Errorf("index landed at position %d of %d; it must be last", len(fixture.zone.puts), len(fixture.zone.puts))
|
||||
}
|
||||
for _, key := range fixture.zone.puts[:len(fixture.zone.puts)-1] {
|
||||
if strings.HasPrefix(key, "data/sha1/") || key == index+".json" {
|
||||
continue
|
||||
}
|
||||
t.Errorf("unexpected object uploaded before the index: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitSkipsBlobsAlreadyInTheZone(t *testing.T) {
|
||||
fixture := newZoneFixture(t)
|
||||
dir := t.TempDir()
|
||||
hak := filepath.Join(dir, "sow_test_01.hak")
|
||||
writeHak(t, hak, map[string][]byte{"bloodstain1.tga": []byte("blood")})
|
||||
|
||||
first := fixture.emit(t, hak)
|
||||
if first.BlobsWritten != 1 {
|
||||
t.Fatalf("first emit uploaded %d blobs, want 1", first.BlobsWritten)
|
||||
}
|
||||
second := fixture.emit(t, hak)
|
||||
if second.BlobsWritten != 0 {
|
||||
t.Errorf("re-emit uploaded %d blobs, want 0 (a blob name is its content)", second.BlobsWritten)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitLeavesNoIndexWhenAnUploadFails(t *testing.T) {
|
||||
fixture := newZoneFixture(t)
|
||||
fixture.zone.failOn = func(key string) bool { return strings.HasPrefix(key, "data/sha1/") }
|
||||
dir := t.TempDir()
|
||||
hak := filepath.Join(dir, "sow_test_01.hak")
|
||||
writeHak(t, hak, map[string][]byte{"bloodstain1.tga": []byte("blood")})
|
||||
|
||||
_, err := Emit(EmitOptions{
|
||||
ArtifactKey: artifactKey(t, hak),
|
||||
ArtifactPath: hak,
|
||||
Sink: fixture.sink,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("emit reported success after an upload failed")
|
||||
}
|
||||
for key := range fixture.zone.objects {
|
||||
if strings.HasSuffix(key, ".nsym") {
|
||||
t.Errorf("a half-emitted artifact published an index: %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRejectsAKeyThatDoesNotMatchTheFile(t *testing.T) {
|
||||
fixture := newZoneFixture(t)
|
||||
dir := t.TempDir()
|
||||
hak := filepath.Join(dir, "sow_test_01.hak")
|
||||
writeHak(t, hak, map[string][]byte{"bloodstain1.tga": []byte("blood")})
|
||||
|
||||
wrong := "artifacts/haks/sha256/00/11/" + strings.Repeat("0", 64) + ".hak"
|
||||
_, err := Emit(EmitOptions{ArtifactKey: wrong, ArtifactPath: hak, Sink: fixture.sink})
|
||||
if err == nil || !strings.Contains(err.Error(), "hashes to") {
|
||||
t.Fatalf("emit published under a key that names another artifact: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleReadsIndexesFromTheZone(t *testing.T) {
|
||||
fixture := newZoneFixture(t)
|
||||
dir := t.TempDir()
|
||||
topBody := []byte("2da from sow_top")
|
||||
assetBody := []byte("2da from the asset hak")
|
||||
top := filepath.Join(dir, "sow_top.hak")
|
||||
core := filepath.Join(dir, "sow_core_01.hak")
|
||||
writeHak(t, top, map[string][]byte{"appearance.2da": topBody})
|
||||
writeHak(t, core, map[string][]byte{"appearance.2da": assetBody, "bloodstain1.tga": []byte("blood")})
|
||||
tlkPath := filepath.Join(dir, "sow_tlk.tlk")
|
||||
if err := os.WriteFile(tlkPath, []byte("TLK V3.0 payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fixture.emit(t, top)
|
||||
fixture.emit(t, core)
|
||||
if _, err := Emit(EmitOptions{
|
||||
ArtifactKey: artifactKey(t, tlkPath),
|
||||
ArtifactPath: tlkPath,
|
||||
As: "sow_tlk.tlk",
|
||||
Sink: fixture.sink,
|
||||
}); err != nil {
|
||||
t.Fatalf("emit tlk: %v", err)
|
||||
}
|
||||
|
||||
result, err := Assemble(AssembleOptions{
|
||||
ArtifactKeys: []string{artifactKey(t, top), artifactKey(t, core)},
|
||||
TLKKey: artifactKey(t, tlkPath),
|
||||
GroupID: 2,
|
||||
Sink: fixture.sink,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
if result.Entries != 3 {
|
||||
t.Fatalf("merged %d entries, want 3 (appearance.2da is shadowed, the TLK adds one)", result.Entries)
|
||||
}
|
||||
|
||||
manifest, ok := fixture.zone.objects["manifests/"+result.SHA1]
|
||||
if !ok {
|
||||
t.Fatalf("no merged manifest in the zone; it holds %v", fixture.zone.puts)
|
||||
}
|
||||
entries, err := readManifest(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("parse merged manifest: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.ResRef == "appearance" && entry.SHA1 != sha1Of(topBody) {
|
||||
t.Errorf("appearance.2da resolved to the shadowed hak, not the first one given")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user