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:
+71
-54
@@ -2,9 +2,11 @@ package nwsync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -60,42 +62,86 @@ type EmitResult struct {
|
||||
BlobsWritten int
|
||||
}
|
||||
|
||||
// Emit explodes one artifact — a .hak/.erf/.mod or a loose file such as the
|
||||
// TLK — into NWSync blobs plus a NSYM manifest describing only that artifact.
|
||||
func Emit(artifactPath, outDir string) (EmitResult, error) {
|
||||
resources, err := readArtifact(artifactPath)
|
||||
// EmitOptions describes one emit run.
|
||||
type EmitOptions struct {
|
||||
ArtifactKey string // depot key of the artifact; the NSYM key is derived from it
|
||||
ArtifactPath string // the file on disk
|
||||
As string // name override, for a TLK whose filename is not its published name
|
||||
OutDir string // write locally instead of uploading — the conformance path
|
||||
Sink sink // test seam; nil means OutDir or the zone
|
||||
}
|
||||
|
||||
// Emit explodes one artifact — a .hak/.erf or a loose file such as the TLK —
|
||||
// into NWSync blobs plus a NSYM manifest describing only that artifact.
|
||||
//
|
||||
// Blobs go up as they are produced and the index lands last, so the presence of
|
||||
// an index is the publication marker: an artifact whose emit died halfway has
|
||||
// real blobs in the zone and no index, which is unambiguous. Blob names are
|
||||
// content hashes, so re-running skips whatever already landed.
|
||||
func Emit(options EmitOptions) (EmitResult, error) {
|
||||
artifact, err := os.ReadFile(options.ArtifactPath)
|
||||
if err != nil {
|
||||
return EmitResult{}, fmt.Errorf("read artifact: %w", err)
|
||||
}
|
||||
if err := checkArtifactKey(options.ArtifactKey, artifact); err != nil {
|
||||
return EmitResult{}, err
|
||||
}
|
||||
name := options.As
|
||||
if name == "" {
|
||||
name = path.Base(options.ArtifactKey)
|
||||
}
|
||||
extension := path.Ext(name)
|
||||
name = strings.TrimSuffix(name, extension)
|
||||
|
||||
key, err := resolveIndexKey(options.ArtifactKey, options.OutDir)
|
||||
if err != nil {
|
||||
return EmitResult{}, err
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(artifactPath), filepath.Ext(artifactPath))
|
||||
|
||||
entries, blobs, onDiskBytes, err := emitResources(resources, outDir)
|
||||
resources, err := readArtifact(options.ArtifactPath, artifact, name)
|
||||
if err != nil {
|
||||
return EmitResult{}, err
|
||||
}
|
||||
|
||||
target, err := openSink(options.OutDir, options.Sink)
|
||||
if err != nil {
|
||||
return EmitResult{}, err
|
||||
}
|
||||
|
||||
entries, blobs, onDiskBytes, err := emitResources(resources, target)
|
||||
if err != nil {
|
||||
return EmitResult{}, err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return EmitResult{}, fmt.Errorf("%s: nothing to index (no publishable resources)", artifactPath)
|
||||
return EmitResult{}, fmt.Errorf("%s: nothing to index (no publishable resources)", options.ArtifactPath)
|
||||
}
|
||||
|
||||
data, err := writeManifest(entries)
|
||||
if err != nil {
|
||||
return EmitResult{}, err
|
||||
}
|
||||
manifestPath := filepath.Join(outDir, name+".nsym")
|
||||
if err := writeManifestPair(manifestPath, data, entries, onDiskBytes, Sidecar{ModuleName: name}); err != nil {
|
||||
if err := putManifestPair(target, key, data, entries, onDiskBytes, Sidecar{ModuleName: name}); err != nil {
|
||||
return EmitResult{}, err
|
||||
}
|
||||
return EmitResult{Name: name, ManifestPath: manifestPath, Entries: len(entries), BlobsWritten: blobs}, nil
|
||||
return EmitResult{Name: name, ManifestPath: target.describe(key), Entries: len(entries), BlobsWritten: blobs}, nil
|
||||
}
|
||||
|
||||
// openSink returns the zone sink, or a local directory when outDir is set.
|
||||
func openSink(outDir string, injected sink) (sink, error) {
|
||||
if injected != nil {
|
||||
return injected, nil
|
||||
}
|
||||
if outDir != "" {
|
||||
return dirSink{root: outDir}, nil
|
||||
}
|
||||
return newZoneSink(context.Background(), os.Getenv)
|
||||
}
|
||||
|
||||
// readArtifact returns the resources of an ERF/HAK/MOD, or the single resource
|
||||
// a loose file represents. Upstream's resman does the same dispatch on the
|
||||
// file's first three bytes.
|
||||
func readArtifact(path string) ([]erf.Resource, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read artifact: %w", err)
|
||||
}
|
||||
// file's first three bytes. name is the artifact's published name, which for a
|
||||
// loose file is also its resref.
|
||||
func readArtifact(path string, data []byte, name string) ([]erf.Resource, error) {
|
||||
if len(data) >= 3 {
|
||||
switch string(data[:3]) {
|
||||
case "ERF", "HAK":
|
||||
@@ -111,20 +157,19 @@ func readArtifact(path string) ([]erf.Resource, error) {
|
||||
return nil, fmt.Errorf("%s: a module is never emitted; a manifest is haks plus the TLK", path)
|
||||
}
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
extension := filepath.Ext(base)
|
||||
extension := filepath.Ext(filepath.Base(path))
|
||||
restype, ok := erf.ResourceTypeForExtension(extension)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: unknown resource type %q", path, extension)
|
||||
}
|
||||
return []erf.Resource{{
|
||||
Name: strings.TrimSuffix(base, extension),
|
||||
Name: name,
|
||||
Type: restype,
|
||||
Data: data,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func emitResources(resources []erf.Resource, outDir string) ([]Entry, int, int64, error) {
|
||||
func emitResources(resources []erf.Resource, target sink) ([]Entry, int, int64, error) {
|
||||
// A resref appearing twice inside one artifact resolves to the last one,
|
||||
// the way resman lets the last container added win.
|
||||
order := make([]Identity, 0, len(resources))
|
||||
@@ -164,7 +209,7 @@ func emitResources(resources []erf.Resource, outDir string) ([]Entry, int, int64
|
||||
ResRef: identity.ResRef,
|
||||
ResType: identity.ResType,
|
||||
})
|
||||
written, err := writeBlob(outDir, sum, resource.Data)
|
||||
written, err := target.putBlob(fmt.Sprintf("%x", sum), func() []byte { return compressBlob(resource.Data) })
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
@@ -187,35 +232,10 @@ func created() int64 {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
|
||||
// writeBlob writes one NWCompressedBuffer blob and returns its size on disk,
|
||||
// or 0 if the blob already existed. Blob names are content hashes, so an
|
||||
// existing name is existing content.
|
||||
func writeBlob(outDir string, sum [20]byte, data []byte) (int64, error) {
|
||||
path := blobPath(outDir, fmt.Sprintf("%x", sum))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return 0, fmt.Errorf("create blob directory: %w", err)
|
||||
}
|
||||
blob := compressBlob(data)
|
||||
if err := os.WriteFile(path, blob, 0o644); err != nil {
|
||||
return 0, fmt.Errorf("write blob: %w", err)
|
||||
}
|
||||
return int64(len(blob)), nil
|
||||
}
|
||||
|
||||
// writeManifestPair writes a NSYM manifest and its .json sidecar. The caller
|
||||
// supplies the sidecar fields it knows; the rest are derived from the entries.
|
||||
// data must be the serialised form of entries.
|
||||
func writeManifestPair(manifestPath string, data []byte, entries []Entry, onDiskBytes int64, sidecar Sidecar) error {
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create manifest directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(manifestPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write manifest: %w", err)
|
||||
}
|
||||
|
||||
// putManifestPair stores a NSYM manifest and its .json sidecar at key. The
|
||||
// caller supplies the sidecar fields it knows; the rest are derived from the
|
||||
// entries. data must be the serialised form of entries.
|
||||
func putManifestPair(target sink, key string, data []byte, entries []Entry, onDiskBytes int64, sidecar Sidecar) error {
|
||||
var totalBytes int64
|
||||
clientContents := false
|
||||
for _, entry := range entries {
|
||||
@@ -240,8 +260,5 @@ func writeManifestPair(manifestPath string, data []byte, entries []Entry, onDisk
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(manifestPath+".json", body, 0o644); err != nil {
|
||||
return fmt.Errorf("write sidecar: %w", err)
|
||||
}
|
||||
return nil
|
||||
return target.putIndex(key, data, body)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user