From 69033a185f5d70507cc86115ad8d7523d0e2a280 Mon Sep 17 00:00:00 2001 From: gspark-etri Date: Fri, 30 Oct 2026 13:25:47 +0900 Subject: [PATCH 1/9] docs: plan compatible artifact selection and preparation --- .../plans/2026-10-30-compatible-prepare.md | 56 +++++++++++++++++ .../2026-10-30-compatible-prepare-design.md | 62 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-30-compatible-prepare.md create mode 100644 docs/superpowers/specs/2026-10-30-compatible-prepare-design.md diff --git a/docs/superpowers/plans/2026-10-30-compatible-prepare.md b/docs/superpowers/plans/2026-10-30-compatible-prepare.md new file mode 100644 index 0000000..1eb28f8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-compatible-prepare.md @@ -0,0 +1,56 @@ +# Compatible artifact preparation implementation plan + +> Execute inline using executing-plans; one fresh whole-branch reviewer after +> implementation. Continued execution and feature-branch push/PR are authorized. + +Goal: select explicitly compatible artifacts and prepare verified files before +serving startup. Go stdlib plus existing dependencies only. +Spec: ../specs/2026-10-30-compatible-prepare-design.md +Base: origin/main 05c97605b07882f9ceaaeb90b94878f8923f81cf. + +## Constraints and review focus + +No SDK/version guessing, shape padding or NPU-ready claims. Legacy unprofiled +push/pull unchanged. Optional manifest profile is hash-bound, unknown fields and +incomplete identities refused. Requests/candidates validated before file writes. +GC lease spans ref resolution through atomic publication. Readiness only after +all hash checks; destination/cache failures cannot silently return ready. +Review: malicious/incomplete metadata; ref TOCTOU; partial publication/cancel; +concurrent destination ownership; compatibility field omissions and legacy data. + +## Task 1: Profile publication and selection + +Files: pkg/store/{profile.go,profile_test.go,format.go,push.go}; +cmd/onggi/{store.go,prepare_test.go}. +Interfaces: Profile.Validate/DecodeProfile, Store.Select(ctx, []digest.Digest, +Profile) -> Selection/error; Manifest.Profile, PushOptions.Profile; push --profile. + +- [ ] Verify clean Linux baseline on an owned CPU work directory. +- [ ] RED: exact identity matching, legacy exclusion, invalid profile/JSON, + deterministic candidate choice, bad-manifest fail closed, profile roundtrip. +- [ ] Implement bounded metadata validation, optional profile and selection. +- [ ] GREEN focused tests, full suite, commit with validation ledger. + +## Task 2: Atomic CLI preparation and operator documentation + +Files: cmd/onggi/{prepare.go,prepare_test.go,main.go}; README.md/README.ko.md; +docs/{compatible-prepare.md,ko/compatible-prepare.md}; deploy/k8s example. +Interface: prepare CLI per spec, always JSON report; request shares Profile v1. + +- [ ] RED: candidate selection/immutable digest, no-match/no cache writes, + plan-only, hash corruption/cleanup, existing/busy/symlink destinations, + strict profile errors, byte-identical pull and warm node cache reuse. +- [ ] Implement prepare via existing Pull/Cache/Peers, sibling temp+mkdir lock, + store GC lease and atomic rename; never use shared CSI Materializer.Init. +- [ ] GREEN focused tests, full race/vet/build; bilingual docs and examples. +- [ ] One independent branch review, fix important findings with RED->GREEN. +- [ ] Commit/push draft PR, verify exact-head CI, record final gate in PR. + +## Ledger + +- New isolated worktree; prior provider/checkouts untouched. Ruling: first P3 + increment is explicit-candidate exact-profile selection + disk preparation; + full discovery/controller/compile farm and native device warmup remain later. + This avoids inventing cross-repo contracts or guessing vendor compatibility. +- Ruling: strict exact B/S means callers must publish each supported profile; + padding/bucketing is only safe when the runtime explicitly supports it. diff --git a/docs/superpowers/specs/2026-10-30-compatible-prepare-design.md b/docs/superpowers/specs/2026-10-30-compatible-prepare-design.md new file mode 100644 index 0000000..a523c53 --- /dev/null +++ b/docs/superpowers/specs/2026-10-30-compatible-prepare-design.md @@ -0,0 +1,62 @@ +# Compatible artifact preparation + +The user authorized the next integration step after storage/delta delivery and +native resident update validation. Implement the first executable part of P3: +choose an explicitly compatible compiled artifact, then prepare its plain files +before a serving process starts. Keep the existing Go CLI/store and add no +dependencies. This is the implementer's scoped first increment, not a claim +that the whole P3/P4 controller, compiler farm or NPU prewarming is complete. + +## Approach + +Options considered: full Kubernetes controller/CRDs (too many unimplemented +cross-repo contracts); an external catalog file (can drift from artifact bytes); +or optional compatibility metadata inside the content-addressed manifest plus +a CLI consumer. Choose the last: the manifest binds metadata to its files and +existing refs, chunk caches, hash verification and GC leases can be reused. +The user delegated routine design choices and continued execution; implement +inline without another design/permission menu. + +Add `Profile *Profile` to Manifest and PushOptions, omitted for legacy pushes. +Profile v1 requires schema, canonical nonzero SHA256 model-source and compile- +options digests, exact device/runtime/SDK/compiler/firmware/format/layout/topology +strings, positive tensor parallelism, batch and sequence. No version ranges, +unknown fields, blank values, shape padding or inferred vendor compatibility. +Publishers are responsible for accurate metadata; this is neither vendor ABI +certification nor a signed attestation. One declared B/S profile per artifact. + +`onggi push --profile FILE` validates before opening a backend or publishing. +Existing unprofiled manifests and ordinary push/pull remain compatible. +`onggi prepare --request FILE [--plan-only] [--cache DIR] [--peers URLS] + [--workers N] --store S [--dir DEST] ...` +resolves each candidate once under a store GC lease, reads hash-verified +manifests, rejects missing/incompatible profiles with field reasons and chooses +the lexically first compatible manifest digest independently of argument order. +Corrupt/unreadable manifests fail closed. Report all candidates and reasons as +JSON; no match returns nonzero and never creates destination/cache. Plan-only +does not fetch model chunks or create destination/cache. Backend opening follows +the existing CLI's store-creation behavior; plan-only is not a globally read-only +backend API. Caller supplies candidates; automatic inventory discovery is later. + +Prepare pulls the selected immutable digest through existing verified chunk +cache/peer code into a private sibling temp directory, then renames the completed +tree into a previously absent DEST. A sibling mkdir lock prevents cooperating +preparers from writing the same destination. Do not reuse the running CSI root: +Materializer.Init assumes exclusive ownership and cleans unfinished pulls. +On failure, remove only this call's temp/lock, retain cache chunks, return +nonzero, and never publish a partial ready directory. Existing/symlink/busy +destinations are refused. A crashed process may leave its destination lock; +operators must verify no owner remains before removing it. No serving graph or +device API is invoked. Readiness means verified files on disk, not NPU readiness. + +## Acceptance and limits + +Tests must catch missing identity fields, wrong versions/options/topology/shapes, +invalid/unknown JSON, legacy manifests, deterministic selection, ref movement, +hash corruption, failure cleanup, existing targets and busy locks, cache reuse +and byte-identical output. Run the Linux suite/race/vet/build and final S3 CI. +NPU execution and latency improvements are not claimed for this metadata/data +path; earlier RNGD/ATOM resident tests retain their own separate scope. +Document reproducible RNGD/ATOM profile examples and an init-container flow. +Do not merge, deploy, change shared SDK/drivers, edit prior provider work or +allocate NPU hardware. Preserve unrelated hub csichk.sh and old worktrees. From 56f6865d849289c69c4682a825bfdc8556c4fc7c Mon Sep 17 00:00:00 2001 From: gspark-etri Date: Fri, 30 Oct 2026 13:27:08 +0900 Subject: [PATCH 2/9] test: specify exact compatible artifact selection --- pkg/store/profile_test.go | 85 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 pkg/store/profile_test.go diff --git a/pkg/store/profile_test.go b/pkg/store/profile_test.go new file mode 100644 index 0000000..9e99bdb --- /dev/null +++ b/pkg/store/profile_test.go @@ -0,0 +1,85 @@ +package store + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "sort" + "strings" + "testing" + + "github.com/ai-computing/onggi/pkg/backend" + "github.com/ai-computing/onggi/pkg/digest" +) + +func testProfile() Profile { + return Profile{Schema:"onggi.profile.v1",ModelSource:digest.Of([]byte("source")).String(),Options:digest.Of([]byte("options")).String(),Device:"rngd",Runtime:"rngd-pd",SDK:"2026.4.0",Compiler:"2026.4.0",Firmware:"fw1",Format:"runnable",Layout:"rngd-bf16",Topology:"fusion8-mesh2",TP:1,Batch:4,Sequence:256} +} + +func profileManifest(t *testing.T,s *Store,p *Profile) digest.Digest { + t.Helper() + data,err:=json.Marshal(Manifest{Schema:"onggi.manifest.v1",Profile:p}) + if err!=nil {t.Fatal(err)} + d:=digest.Of(data) + if err=s.B.Put(context.Background(),backend.Key{Kind:backend.Manifest,Name:d.Hex()},bytes.NewReader(data),int64(len(data)));err!=nil {t.Fatal(err)} + return d +} + +func TestSelectRequiresEveryIdentityField(t *testing.T) { + ctx:=context.Background();want:=testProfile() + for i:=1;i Date: Fri, 30 Oct 2026 13:28:07 +0900 Subject: [PATCH 3/9] feat: bind exact compatibility profiles to artifact manifests --- pkg/store/format.go | 1 + pkg/store/profile.go | 110 +++++++++++++++++++++++++++++++++++++++++++ pkg/store/push.go | 8 +++- 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 pkg/store/profile.go diff --git a/pkg/store/format.go b/pkg/store/format.go index 5a8bc91..b799617 100644 --- a/pkg/store/format.go +++ b/pkg/store/format.go @@ -256,6 +256,7 @@ type Manifest struct { Bytes int64 `json:"bytes"` Files []File `json:"files"` Labels map[string]string `json:"labels,omitempty"` + Profile *Profile `json:"profile,omitempty"` // optional exact compatibility declaration // Locator names the artifact's locator object (locator.go). Manifests from before it have // none, and pull then reads the store's index and the chunk lists. Locator *digest.Digest `json:"locator,omitempty"` diff --git a/pkg/store/profile.go b/pkg/store/profile.go new file mode 100644 index 0000000..a9f765f --- /dev/null +++ b/pkg/store/profile.go @@ -0,0 +1,110 @@ +package store + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + "unicode" + + "github.com/ai-computing/onggi/pkg/digest" +) + +// Profile is a publisher's exact compatibility declaration, bound to artifact files by the manifest. +// It does not attest vendor ABI compatibility or permit shape padding. +type Profile struct { + Schema string `json:"schema"` + ModelSource string `json:"model_source"` + Options string `json:"options"` + Device string `json:"device"` + Runtime string `json:"runtime"` + SDK string `json:"sdk"` + Compiler string `json:"compiler"` + Firmware string `json:"firmware"` + Format string `json:"format"` + Layout string `json:"layout"` + Topology string `json:"topology"` + TP int `json:"tp"` + Batch int `json:"batch"` + Sequence int `json:"sequence"` +} + +func (p Profile) Validate() error { + if p.Schema!="onggi.profile.v1" {return fmt.Errorf("profile: unknown schema %q",p.Schema)} + for name,value:=range map[string]string{"model_source":p.ModelSource,"options":p.Options} { + d,err:=digest.Parse(value) + if err!=nil||d.IsZero()||d.String()!=value {return fmt.Errorf("profile %s: want canonical nonzero sha256 digest",name)} + } + for name,value:=range map[string]string{"device":p.Device,"runtime":p.Runtime,"sdk":p.SDK,"compiler":p.Compiler,"firmware":p.Firmware,"format":p.Format,"layout":p.Layout,"topology":p.Topology} { + if value==""||strings.TrimSpace(value)!=value||value=="*"||strings.EqualFold(value,"unknown")||strings.IndexFunc(value,unicode.IsControl)>=0 {return fmt.Errorf("profile %s: want explicit nonblank identity",name)} + } + if p.TP<1||p.Batch<1||p.Sequence<1 {return errors.New("profile: tp, batch and sequence must be positive integers")} + return nil +} + +// UnmarshalJSON also validates profiles embedded in manifests, rejecting future/typo fields. +func (p *Profile) UnmarshalJSON(data []byte) error { + type plain Profile + var v plain + dec:=json.NewDecoder(bytes.NewReader(data));dec.DisallowUnknownFields() + if err:=dec.Decode(&v);err!=nil {return fmt.Errorf("profile: %w",err)} + if err:=Profile(v).Validate();err!=nil {return err} + *p=Profile(v) + return nil +} + +// DecodeProfile reads one bounded JSON object with no trailing content. +func DecodeProfile(r io.Reader) (Profile,error) { + var p Profile + data,err:=io.ReadAll(io.LimitReader(r,(64<<10)+1)) + if err!=nil {return p,err} + if len(data)>64<<10 {return p,errors.New("profile exceeds 64 KiB")} + err=json.Unmarshal(data,&p) + return p,err +} + +type Rejection struct { + Manifest string `json:"manifest"` + Reasons []string `json:"reasons"` +} + +type Selection struct { + Manifest string `json:"manifest,omitempty"` + Profile Profile `json:"profile"` + Compatible []string `json:"compatible"` + Rejected []Rejection `json:"rejected"` +} + +var ErrNoCompatible=errors.New("no compatible artifact") + +// Select checks every supplied manifest and deterministically chooses the lowest matching digest. +// Callers protect mutable ref resolution and subsequent use with a store GC lease. +func (s *Store) Select(ctx context.Context,candidates []digest.Digest,want Profile) (Selection,error) { + result:=Selection{Profile:want,Compatible:[]string{},Rejected:[]Rejection{}} + if err:=want.Validate();err!=nil {return result,err} + unique:=map[digest.Digest]bool{} + ds:=append([]digest.Digest(nil),candidates...) + sort.Slice(ds,func(i,j int)bool{return ds[i].String() Date: Fri, 30 Oct 2026 13:29:59 +0900 Subject: [PATCH 4/9] test: specify atomic compatible artifact preparation --- cmd/onggi/prepare_test.go | 108 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 cmd/onggi/prepare_test.go diff --git a/cmd/onggi/prepare_test.go b/cmd/onggi/prepare_test.go new file mode 100644 index 0000000..0278ac6 --- /dev/null +++ b/cmd/onggi/prepare_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ai-computing/onggi/pkg/backend" + bfs "github.com/ai-computing/onggi/pkg/backend/fs" + "github.com/ai-computing/onggi/pkg/chunker" + "github.com/ai-computing/onggi/pkg/digest" + "github.com/ai-computing/onggi/pkg/store" +) + +const prepareProfileJSON=`{"schema":"onggi.profile.v1","model_source":"sha256:1111111111111111111111111111111111111111111111111111111111111111","options":"sha256:2222222222222222222222222222222222222222222222222222222222222222","device":"rngd","runtime":"rngd-pd","sdk":"2026.4.0","compiler":"2026.4.0","firmware":"fw1","format":"runnable","layout":"rngd-bf16","topology":"fusion8-mesh2","tp":1,"batch":4,"sequence":256}` + +type prepareFixture struct {root,source,storage,request string;data []byte} +func newPrepareFixture(t *testing.T) prepareFixture { + t.Helper();root:=t.TempDir();f:=prepareFixture{root,filepath.Join(root,"source"),filepath.Join(root,"store"),filepath.Join(root,"request.json"),bytes.Repeat([]byte("native artifact bytes\n"),10000)} + if err:=os.Mkdir(f.source,0700);err!=nil {t.Fatal(err)} + if err:=os.WriteFile(filepath.Join(f.source,"graph.bin"),f.data,0640);err!=nil {t.Fatal(err)} + if err:=os.WriteFile(f.request,[]byte(prepareProfileJSON),0600);err!=nil {t.Fatal(err)} + var out,errs bytes.Buffer + if code:=push([]string{"--store",f.storage,"--profile",f.request,"--ref","model","--compress","none","--quiet",f.source},&out,&errs);code!=0 {t.Fatalf("profile publication exit %d: %s",code,&errs)} + return f +} + +func runPrepare(t *testing.T,args ...string) (int,map[string]any,string) { + t.Helper();var out,errs bytes.Buffer;code:=prepare(args,&out,&errs);var report map[string]any + if err:=json.Unmarshal(out.Bytes(),&report);err!=nil {t.Fatalf("not a JSON report: %s / %s",&out,&errs)} + return code,report,errs.String() +} + +func TestPreparePublishesExactFilesAndReusesCache(t *testing.T) { + f:=newPrepareFixture(t);cache:=filepath.Join(f.root,"cache") + for i,name:=range []string{"first","second"} { + dest:=filepath.Join(f.root,name);code,report,errtext:=runPrepare(t,"--store",f.storage,"--request",f.request,"--dir",dest,"--cache",cache,"model") + if code!=0||report["ready"]!=true {t.Fatalf("not ready: %d %+v %s",code,report,errtext)} + got,err:=os.ReadFile(filepath.Join(dest,"graph.bin"));if err!=nil||!bytes.Equal(got,f.data){t.Fatalf("bad output: %v",err)} + if i==1 {stats:=report["stats"].(map[string]any);if stats["read_bytes"]!=float64(0)||stats["cache_bytes"].(float64)<=0 {t.Fatalf("warm cache unused: %+v",stats)}} + if _,err=os.Lstat(dest+".onggi-lock");!os.IsNotExist(err){t.Fatal("owned lock leaked")} + } +} + +func TestPrepareNoMatchAndPlanDoNotPrepareFiles(t *testing.T) { + for _,plan:=range []bool{false,true} {t.Run(map[bool]string{false:"mismatch",true:"plan"}[plan],func(t *testing.T){ + f:=newPrepareFixture(t);dest:=filepath.Join(f.root,"output");cache:=filepath.Join(f.root,"cache") + args:=[]string{"--store",f.storage,"--request",f.request,"--dir",dest,"--cache",cache} + if plan {args=append(args,"--plan-only")} else {os.WriteFile(f.request,[]byte(strings.Replace(prepareProfileJSON,`"sequence":256`,`"sequence":512`,1)),0600)} + code,report,_:=runPrepare(t,append(args,"model")...) + if report["ready"]!=false||plan!=(code==0){t.Fatalf("wrong readiness: %d %+v",code,report)} + if !plan {sel:=report["selection"].(map[string]any);if len(sel["rejected"].([]any))!=1 {t.Fatal("no mismatch report")}} + for _,p:=range []string{dest,cache,dest+".onggi-lock"}{if _,err:=os.Lstat(p);!os.IsNotExist(err){t.Fatalf("unexpected write: %s",p)}} + })} +} + +func TestPrepareRefusesExistingAndBusyDestinations(t *testing.T) { + for _,kind:=range []string{"directory","file","symlink","busy"}{t.Run(kind,func(t *testing.T){ + f:=newPrepareFixture(t);dest:=filepath.Join(f.root,"output");sentinel:=filepath.Join(f.root,"sentinel");os.WriteFile(sentinel,[]byte("keep"),0600) + switch kind {case "directory":os.Mkdir(dest,0700);os.WriteFile(filepath.Join(dest,"keep"),[]byte("keep"),0600) + case "file":os.WriteFile(dest,[]byte("keep"),0600) + case "symlink":if err:=os.Symlink(sentinel,dest);err!=nil {t.Skip(err)} + case "busy":os.Mkdir(dest+".onggi-lock",0700);os.WriteFile(filepath.Join(dest+".onggi-lock","owner"),[]byte("other"),0600)} + code,report,_:=runPrepare(t,"--store",f.storage,"--request",f.request,"--dir",dest,"model") + if code==0||report["ready"]!=false {t.Fatal("existing/busy target accepted")} + got,err:=os.ReadFile(sentinel);if err!=nil||string(got)!="keep" {t.Fatal("outside target damaged")} + if kind=="busy" {got,err=os.ReadFile(filepath.Join(dest+".onggi-lock","owner"));if err!=nil||string(got)!="other" {t.Fatal("another owner lock damaged")}} + })} +} + +func TestPrepareCorruptPackNeverPublishesAndCleansOwnedTemp(t *testing.T) { + f:=newPrepareFixture(t);packs,err:=filepath.Glob(filepath.Join(f.storage,"packs","*","*"));if err!=nil||len(packs)==0 {t.Fatal("no pack")} + for _,p:=range packs {data,_:=os.ReadFile(p);for i:=range data {data[i]^=0xff};if err=os.WriteFile(p,data,0600);err!=nil {t.Fatal(err)}} + dest:=filepath.Join(f.root,"output");code,report,_:=runPrepare(t,"--store",f.storage,"--request",f.request,"--dir",dest,"model") + if code==0||report["ready"]!=false {t.Fatal("corrupt bytes published")} + for _,p:=range []string{dest,dest+".onggi-lock"}{if _,err=os.Lstat(p);!os.IsNotExist(err){t.Fatalf("partial state: %s",p)}} + leftovers,_:=filepath.Glob(filepath.Join(f.root,".output.onggi-*"));if len(leftovers)!=0 {t.Fatalf("temp leak: %v",leftovers)} +} + +func TestPrepareInvalidRequestNeverOpensBackend(t *testing.T) { + root:=t.TempDir();request:=filepath.Join(root,"request.json");storage:=filepath.Join(root,"store") + os.WriteFile(request,[]byte(`{"sdk":"unknown"}`),0600) + code,report,_:=runPrepare(t,"--store",storage,"--request",request,"--plan-only","model") + if code!=2||report["ready"]!=false {t.Fatalf("bad request accepted: %d %+v",code,report)} + if _,err:=os.Stat(storage);!os.IsNotExist(err){t.Fatal("backend opened on invalid request")} + var out,errs bytes.Buffer + if push([]string{"--store",storage,"--profile",request,root},&out,&errs)!=2 {t.Fatal("invalid published profile accepted")} + if _,err:=os.Stat(storage);!os.IsNotExist(err){t.Fatal("backend opened on invalid push profile")} +} + +func TestPrepareSelectedDigestSurvivesRefChange(t *testing.T) { + f:=newPrepareFixture(t);ctx:=context.Background();b,err:=bfs.Open(f.storage,backend.DurabilityNone);if err!=nil{t.Fatal(err)} + s,err:=store.Open(ctx,b,chunker.Default,0);if err!=nil{t.Fatal(err)} + first,err:=store.Resolve(ctx,b,"model");if err!=nil{t.Fatal(err)} + p,err:=store.DecodeProfile(strings.NewReader(prepareProfileJSON));if err!=nil{t.Fatal(err)} + selection,err:=s.Select(ctx,[]digest.Digest{first},p);if err!=nil{t.Fatal(err)} + os.WriteFile(filepath.Join(f.source,"graph.bin"),[]byte("different new ref"),0640) + second,_,err:=s.Push(ctx,f.source,store.PushOptions{Profile:&p});if err!=nil{t.Fatal(err)} + if err=b.SetRef(ctx,"model",first.String(),second.String());err!=nil{t.Fatal(err)} + selected,err:=digest.Parse(selection.Manifest);if err!=nil{t.Fatal(err)} + dest:=filepath.Join(f.root,"selected") + if _,err=prepareFiles(ctx,s,selected,dest,store.PullOptions{});err!=nil{t.Fatal(err)} + got,err:=os.ReadFile(filepath.Join(dest,"graph.bin"));if err!=nil||!bytes.Equal(got,f.data){t.Fatal("mutable ref replaced selected digest")} +} From 1d050e509853aea93463cd855268f05d2a0934de Mon Sep 17 00:00:00 2001 From: gspark-etri Date: Fri, 30 Oct 2026 13:30:55 +0900 Subject: [PATCH 5/9] feat: select and atomically prepare compatible artifacts --- cmd/onggi/main.go | 3 ++ cmd/onggi/prepare.go | 103 +++++++++++++++++++++++++++++++++++++++++++ cmd/onggi/store.go | 12 ++++- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 cmd/onggi/prepare.go diff --git a/cmd/onggi/main.go b/cmd/onggi/main.go index ae9f0bc..3580b77 100644 --- a/cmd/onggi/main.go +++ b/cmd/onggi/main.go @@ -23,6 +23,7 @@ const usage = `onggi — content-addressed store for model weights and compiled usage: onggi push --store S [--ref NAME] store a directory; only missing chunks are uploaded onggi pull --store S restore an artifact, every chunk hash-checked + onggi prepare --store S --request FILE --dir DIR ... select compatible files and prepare atomically onggi ls --store S refs and store totals onggi inspect --store S files of an artifact onggi diff --store S what turning base into ref keeps, copies and fetches @@ -55,6 +56,8 @@ func main() { os.Exit(push(os.Args[2:], os.Stdout, os.Stderr)) case "pull": os.Exit(pull(os.Args[2:], os.Stdout, os.Stderr)) + case "prepare": + os.Exit(prepare(os.Args[2:], os.Stdout, os.Stderr)) case "ls": os.Exit(ls(os.Args[2:], os.Stdout, os.Stderr)) case "inspect": diff --git a/cmd/onggi/prepare.go b/cmd/onggi/prepare.go new file mode 100644 index 0000000..6ba8ca4 --- /dev/null +++ b/cmd/onggi/prepare.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/ai-computing/onggi/pkg/backend" + bfs "github.com/ai-computing/onggi/pkg/backend/fs" + "github.com/ai-computing/onggi/pkg/digest" + "github.com/ai-computing/onggi/pkg/store" +) + +func readProfileFile(name string) (store.Profile,error) { + f,err:=os.Open(name) + if err!=nil {return store.Profile{},err} + defer f.Close() + return store.DecodeProfile(f) +} + +type prepareReport struct { + Selection *store.Selection `json:"selection,omitempty"` + Ready bool `json:"ready"` // verified files on disk, not NPU readiness + Directory string `json:"directory,omitempty"` + Stats *store.PullStats `json:"stats,omitempty"` + Error string `json:"error,omitempty"` +} + +func prepare(args []string,stdout,stderr io.Writer) int { + var report prepareReport + fail:=func(code int,err error)int { + report.Error=err.Error();fmt.Fprintln(stderr,"onggi:",err) + if writeJSON(stdout,stderr,report)!=0 {return 1};return code + } + fs:=flag.NewFlagSet("prepare",flag.ContinueOnError);fs.SetOutput(stderr) + sf:=addStoreFlags(fs) + request:=fs.String("request","","exact compatibility profile JSON") + dest:=fs.String("dir","","previously absent destination directory") + cacheDir:=fs.String("cache","","node chunk cache directory") + peers:=fs.String("peers","","comma-separated peer URLs") + workers:=fs.Int("workers",8,"ranges read at once") + planOnly:=fs.Bool("plan-only",false,"select using metadata; do not fetch model chunks or create destination/cache") + if err:=fs.Parse(args);err!=nil {return fail(2,err)} + if fs.NArg()==0||*request==""||(!*planOnly&&*dest=="")||*workers<1 {return fail(2,errors.New("usage: onggi prepare --store S --request FILE [--plan-only | --dir DEST] ...; workers must be positive"))} + want,err:=readProfileFile(*request) + if err!=nil {return fail(2,err)} + ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM);defer stop() + s,refs,err:=sf.open(ctx) + if err!=nil {return fail(1,err)} + release,err:=s.Lease(ctx,"compatible prepare");if err!=nil{return fail(1,err)};defer release() + candidates:=make([]digest.Digest,0,fs.NArg()) + for _,input:=range fs.Args() { + d,err:=store.Resolve(ctx,refs,input) + if err!=nil {return fail(1,fmt.Errorf("candidate %q: %w",input,err))} + candidates=append(candidates,d) + } + selected,err:=s.Select(ctx,candidates,want);report.Selection=&selected + if err!=nil {return fail(1,err)} + if *planOnly {return writeJSON(stdout,stderr,report)} + d,err:=digest.Parse(selected.Manifest);if err!=nil{return fail(1,err)} + o:=store.PullOptions{Workers:*workers,Peers:peerClients(*peers)} + if *cacheDir!="" { + cb,err:=bfs.Open(*cacheDir,backend.DurabilityNone) + if err==nil {o.Cache,err=store.OpenWith(ctx,cb,store.Create{Chunker:s.Config.Chunker,Sub:s.Config.Sub,SubKinds:s.Config.SubKinds,PackSize:s.Config.PackSize})} + if err!=nil {return fail(1,fmt.Errorf("node cache: %w",err))} + } + st,err:=prepareFiles(ctx,s,d,*dest,o) + if err!=nil {return fail(1,err)} + report.Ready=true;report.Directory=*dest;report.Stats=&st + return writeJSON(stdout,stderr,report) +} + +// prepareFiles publishes only a complete tree. It never re-resolves the chosen digest. +func prepareFiles(ctx context.Context,s *store.Store,d digest.Digest,dest string,o store.PullOptions) (store.PullStats,error) { + var st store.PullStats + if err:=ctx.Err();err!=nil{return st,err} + dest=filepath.Clean(dest);parent:=filepath.Dir(dest) + if err:=os.MkdirAll(parent,0755);err!=nil{return st,err} + lock:=dest+".onggi-lock" + if err:=os.Mkdir(lock,0700);err!=nil{return st,fmt.Errorf("destination busy: %w",err)} + defer os.Remove(lock) // never remove a lock this call did not create + if _,err:=os.Lstat(dest);!errors.Is(err,os.ErrNotExist) { + if err==nil {err=errors.New("destination already exists")};return st,err + } + tmp,err:=os.MkdirTemp(parent,"."+filepath.Base(dest)+".onggi-") + if err!=nil{return st,err} + defer os.RemoveAll(tmp) + if err=os.Chmod(tmp,0755);err!=nil{return st,err} + st,err=s.Pull(ctx,d,tmp,o) + if err!=nil{return st,err} + if err=ctx.Err();err!=nil{return st,err} + if _,err=os.Lstat(dest);!errors.Is(err,os.ErrNotExist) { + if err==nil{err=errors.New("destination appeared during preparation")};return st,err + } + if err=os.Rename(tmp,dest);err!=nil{return st,err} + return st,nil +} diff --git a/cmd/onggi/store.go b/cmd/onggi/store.go index 21ba66c..62af533 100644 --- a/cmd/onggi/store.go +++ b/cmd/onggi/store.go @@ -110,6 +110,7 @@ func push(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) sf := addStoreFlags(fs) ref := fs.String("ref", "", "set this ref to the new manifest") + profileFile := fs.String("profile", "", "exact compatibility profile JSON to bind to the artifact") workers := fs.Int("workers", runtime.NumCPU(), "files chunked at once") uploads := fs.Int("uploads", 4, "packs uploaded at once") compress := fs.String("compress", "auto", "how new chunks are stored: auto (zstd or byte planes + Huffman, whichever is smaller), zstd, none") @@ -125,13 +126,22 @@ func push(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "onggi:", err) return 2 } + var profile *store.Profile + if *profileFile != "" { + p, err := readProfileFile(*profileFile) + if err != nil { + fmt.Fprintln(stderr, "onggi:", err) + return 2 + } + profile = &p + } ctx := context.Background() s, refs, err := sf.open(ctx) if err != nil { fmt.Fprintln(stderr, "onggi:", err) return 1 } - o := store.PushOptions{Workers: *workers, Uploads: *uploads, TempDir: *tmp, Compress: mode} + o := store.PushOptions{Workers: *workers, Uploads: *uploads, TempDir: *tmp, Compress: mode, Profile: profile} start := time.Now() if !*quiet { o.Progress = func(st store.PushStats) { From 1f1ca2105445cd6a564dc629e130de8c2b0ef555 Mon Sep 17 00:00:00 2001 From: gspark-etri Date: Fri, 30 Oct 2026 13:34:53 +0900 Subject: [PATCH 6/9] docs: document compatible preparation and validate S3 workflow --- .github/workflows/ci.yml | 27 ++ README.ko.md | 5 + README.md | 5 + cmd/onggi/prepare.go | 237 ++++++++------ cmd/onggi/prepare_test.go | 297 +++++++++++++----- deploy/k8s/prepare-initcontainer.yaml | 47 +++ docs/compatible-prepare.md | 104 ++++++ docs/ko/compatible-prepare.md | 54 ++++ .../plans/2026-10-30-compatible-prepare.md | 34 +- pkg/store/profile.go | 199 +++++++----- pkg/store/profile_test.go | 183 +++++++---- 11 files changed, 879 insertions(+), 313 deletions(-) create mode 100644 deploy/k8s/prepare-initcontainer.yaml create mode 100644 docs/compatible-prepare.md create mode 100644 docs/ko/compatible-prepare.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbf7e8d..abb3607 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,33 @@ jobs: ./onggi pull demo out diff -r src out ./onggi ls + - name: compatible profile preparation and warm cache + run: | + python3 - <<'PY' + import json + profile = dict(schema="onggi.profile.v1", model_source="sha256:"+"1"*64, + options="sha256:"+"2"*64, device="synthetic-ci", runtime="test-v1", + sdk="1", compiler="1", firmware="1", format="test", layout="test", + topology="single", tp=1, batch=4, sequence=256) + with open("profile.json", "w") as f: + json.dump(profile, f) + PY + export ONGGI_STORE="${ONGGI_TEST_S3/onggitest?/onggitest/cli?}" + ./onggi push --profile profile.json --ref compatible src + ./onggi prepare --request profile.json --cache node-cache --dir prepared compatible > prepared.json + diff -r src prepared + ./onggi prepare --request profile.json --cache node-cache --dir prepared-again compatible > prepared-again.json + diff -r src prepared-again + python3 - <<'PY' + import json + with open("prepared.json") as f: + assert json.load(f)["ready"] is True + with open("prepared-again.json") as f: + result = json.load(f) + assert result["ready"] is True + assert result["stats"]["read_bytes"] == 0 + assert result["stats"]["cache_bytes"] > 0 + PY - name: server log if: failure() run: tail -100 "$RUNNER_TEMP/srv/server.log" diff --git a/README.ko.md b/README.ko.md index 6e88b91..a2ae4e8 100644 --- a/README.ko.md +++ b/README.ko.md @@ -25,6 +25,11 @@ Onggi(옹기)는 서로 다른 바이트를 한 번만 저장하고, 복원한 ## 하는 일 +P3의 첫 단계로 [호환 프로필 선택·파일 사전 준비](docs/ko/compatible-prepare.md)를 지원합니다. +`push --profile FILE`로 게시하고 `prepare --request FILE --dir DIR ...`로 +호환 결과물을 골라 노드 캐시를 통해 검증된 파일을 완성한 뒤 게시합니다. +파일 준비 단계이며 NPU 예열과 배치·확장 신호 연동은 후속 작업입니다. + | | | |---|---| | **한 번만 저장** | 내용 기반 청크, 결과물별 매니페스트, 가중치는 배열 방식별로 한 벌 | diff --git a/README.md b/README.md index b0cc0d6..e531c9f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ The Kubernetes Ref API, compile controller and fetches triggered by ai-rm/llm-d ## What it does +The first P3 integration is [exact-profile artifact selection and preparation](docs/compatible-prepare.md): +publish with `push --profile FILE`, then `prepare --request FILE --dir DIR ...` +chooses a compatible build and atomically prepares verified files through the node cache. +This is file readiness; native NPU prewarming and placement/autoscaler integration remain planned. + | | | |---|---| | **Store once** | content-defined chunks, manifests per artifact, weights stored once per weight layout | diff --git a/cmd/onggi/prepare.go b/cmd/onggi/prepare.go index 6ba8ca4..88ba3b8 100644 --- a/cmd/onggi/prepare.go +++ b/cmd/onggi/prepare.go @@ -1,103 +1,164 @@ package main import ( - "context" - "errors" - "flag" - "fmt" - "io" - "os" - "os/signal" - "path/filepath" - "syscall" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "syscall" - "github.com/ai-computing/onggi/pkg/backend" - bfs "github.com/ai-computing/onggi/pkg/backend/fs" - "github.com/ai-computing/onggi/pkg/digest" - "github.com/ai-computing/onggi/pkg/store" + "github.com/ai-computing/onggi/pkg/backend" + bfs "github.com/ai-computing/onggi/pkg/backend/fs" + "github.com/ai-computing/onggi/pkg/digest" + "github.com/ai-computing/onggi/pkg/store" ) -func readProfileFile(name string) (store.Profile,error) { - f,err:=os.Open(name) - if err!=nil {return store.Profile{},err} - defer f.Close() - return store.DecodeProfile(f) +func readProfileFile(name string) (store.Profile, error) { + f, err := os.Open(name) + if err != nil { + return store.Profile{}, err + } + defer f.Close() + return store.DecodeProfile(f) } type prepareReport struct { - Selection *store.Selection `json:"selection,omitempty"` - Ready bool `json:"ready"` // verified files on disk, not NPU readiness - Directory string `json:"directory,omitempty"` - Stats *store.PullStats `json:"stats,omitempty"` - Error string `json:"error,omitempty"` + Selection *store.Selection `json:"selection,omitempty"` + Ready bool `json:"ready"` // verified files on disk, not NPU readiness + Directory string `json:"directory,omitempty"` + Stats *store.PullStats `json:"stats,omitempty"` + Error string `json:"error,omitempty"` } -func prepare(args []string,stdout,stderr io.Writer) int { - var report prepareReport - fail:=func(code int,err error)int { - report.Error=err.Error();fmt.Fprintln(stderr,"onggi:",err) - if writeJSON(stdout,stderr,report)!=0 {return 1};return code - } - fs:=flag.NewFlagSet("prepare",flag.ContinueOnError);fs.SetOutput(stderr) - sf:=addStoreFlags(fs) - request:=fs.String("request","","exact compatibility profile JSON") - dest:=fs.String("dir","","previously absent destination directory") - cacheDir:=fs.String("cache","","node chunk cache directory") - peers:=fs.String("peers","","comma-separated peer URLs") - workers:=fs.Int("workers",8,"ranges read at once") - planOnly:=fs.Bool("plan-only",false,"select using metadata; do not fetch model chunks or create destination/cache") - if err:=fs.Parse(args);err!=nil {return fail(2,err)} - if fs.NArg()==0||*request==""||(!*planOnly&&*dest=="")||*workers<1 {return fail(2,errors.New("usage: onggi prepare --store S --request FILE [--plan-only | --dir DEST] ...; workers must be positive"))} - want,err:=readProfileFile(*request) - if err!=nil {return fail(2,err)} - ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM);defer stop() - s,refs,err:=sf.open(ctx) - if err!=nil {return fail(1,err)} - release,err:=s.Lease(ctx,"compatible prepare");if err!=nil{return fail(1,err)};defer release() - candidates:=make([]digest.Digest,0,fs.NArg()) - for _,input:=range fs.Args() { - d,err:=store.Resolve(ctx,refs,input) - if err!=nil {return fail(1,fmt.Errorf("candidate %q: %w",input,err))} - candidates=append(candidates,d) - } - selected,err:=s.Select(ctx,candidates,want);report.Selection=&selected - if err!=nil {return fail(1,err)} - if *planOnly {return writeJSON(stdout,stderr,report)} - d,err:=digest.Parse(selected.Manifest);if err!=nil{return fail(1,err)} - o:=store.PullOptions{Workers:*workers,Peers:peerClients(*peers)} - if *cacheDir!="" { - cb,err:=bfs.Open(*cacheDir,backend.DurabilityNone) - if err==nil {o.Cache,err=store.OpenWith(ctx,cb,store.Create{Chunker:s.Config.Chunker,Sub:s.Config.Sub,SubKinds:s.Config.SubKinds,PackSize:s.Config.PackSize})} - if err!=nil {return fail(1,fmt.Errorf("node cache: %w",err))} - } - st,err:=prepareFiles(ctx,s,d,*dest,o) - if err!=nil {return fail(1,err)} - report.Ready=true;report.Directory=*dest;report.Stats=&st - return writeJSON(stdout,stderr,report) +func prepare(args []string, stdout, stderr io.Writer) int { + var report prepareReport + fail := func(code int, err error) int { + report.Error = err.Error() + fmt.Fprintln(stderr, "onggi:", err) + if writeJSON(stdout, stderr, report) != 0 { + return 1 + } + return code + } + fs := flag.NewFlagSet("prepare", flag.ContinueOnError) + fs.SetOutput(stderr) + sf := addStoreFlags(fs) + request := fs.String("request", "", "exact compatibility profile JSON") + dest := fs.String("dir", "", "previously absent destination directory") + cacheDir := fs.String("cache", "", "node chunk cache directory") + peers := fs.String("peers", "", "comma-separated peer URLs") + workers := fs.Int("workers", 8, "ranges read at once") + planOnly := fs.Bool("plan-only", false, "select using metadata; do not fetch model chunks or create destination/cache") + if err := fs.Parse(args); err != nil { + return fail(2, err) + } + if fs.NArg() == 0 || *request == "" || (!*planOnly && *dest == "") || *workers < 1 { + return fail(2, errors.New("usage: onggi prepare --store S --request FILE [--plan-only | --dir DEST] ...; workers must be positive")) + } + want, err := readProfileFile(*request) + if err != nil { + return fail(2, err) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + s, refs, err := sf.open(ctx) + if err != nil { + return fail(1, err) + } + release, err := s.Lease(ctx, "compatible prepare") + if err != nil { + return fail(1, err) + } + defer release() + candidates := make([]digest.Digest, 0, fs.NArg()) + for _, input := range fs.Args() { + d, err := store.Resolve(ctx, refs, input) + if err != nil { + return fail(1, fmt.Errorf("candidate %q: %w", input, err)) + } + candidates = append(candidates, d) + } + selected, err := s.Select(ctx, candidates, want) + report.Selection = &selected + if err != nil { + return fail(1, err) + } + if *planOnly { + return writeJSON(stdout, stderr, report) + } + d, err := digest.Parse(selected.Manifest) + if err != nil { + return fail(1, err) + } + o := store.PullOptions{Workers: *workers, Peers: peerClients(*peers)} + if *cacheDir != "" { + cb, err := bfs.Open(*cacheDir, backend.DurabilityNone) + if err == nil { + o.Cache, err = store.OpenWith(ctx, cb, store.Create{Chunker: s.Config.Chunker, Sub: s.Config.Sub, SubKinds: s.Config.SubKinds, PackSize: s.Config.PackSize}) + } + if err != nil { + return fail(1, fmt.Errorf("node cache: %w", err)) + } + } + st, err := prepareFiles(ctx, s, d, *dest, o) + if err != nil { + return fail(1, err) + } + report.Ready = true + report.Directory = *dest + report.Stats = &st + return writeJSON(stdout, stderr, report) } // prepareFiles publishes only a complete tree. It never re-resolves the chosen digest. -func prepareFiles(ctx context.Context,s *store.Store,d digest.Digest,dest string,o store.PullOptions) (store.PullStats,error) { - var st store.PullStats - if err:=ctx.Err();err!=nil{return st,err} - dest=filepath.Clean(dest);parent:=filepath.Dir(dest) - if err:=os.MkdirAll(parent,0755);err!=nil{return st,err} - lock:=dest+".onggi-lock" - if err:=os.Mkdir(lock,0700);err!=nil{return st,fmt.Errorf("destination busy: %w",err)} - defer os.Remove(lock) // never remove a lock this call did not create - if _,err:=os.Lstat(dest);!errors.Is(err,os.ErrNotExist) { - if err==nil {err=errors.New("destination already exists")};return st,err - } - tmp,err:=os.MkdirTemp(parent,"."+filepath.Base(dest)+".onggi-") - if err!=nil{return st,err} - defer os.RemoveAll(tmp) - if err=os.Chmod(tmp,0755);err!=nil{return st,err} - st,err=s.Pull(ctx,d,tmp,o) - if err!=nil{return st,err} - if err=ctx.Err();err!=nil{return st,err} - if _,err=os.Lstat(dest);!errors.Is(err,os.ErrNotExist) { - if err==nil{err=errors.New("destination appeared during preparation")};return st,err - } - if err=os.Rename(tmp,dest);err!=nil{return st,err} - return st,nil +func prepareFiles(ctx context.Context, s *store.Store, d digest.Digest, dest string, o store.PullOptions) (store.PullStats, error) { + var st store.PullStats + if err := ctx.Err(); err != nil { + return st, err + } + dest = filepath.Clean(dest) + parent := filepath.Dir(dest) + if err := os.MkdirAll(parent, 0755); err != nil { + return st, err + } + lock := dest + ".onggi-lock" + if err := os.Mkdir(lock, 0700); err != nil { + return st, fmt.Errorf("destination busy: %w", err) + } + defer os.Remove(lock) // never remove a lock this call did not create + if _, err := os.Lstat(dest); !errors.Is(err, os.ErrNotExist) { + if err == nil { + err = errors.New("destination already exists") + } + return st, err + } + tmp, err := os.MkdirTemp(parent, "."+filepath.Base(dest)+".onggi-") + if err != nil { + return st, err + } + defer os.RemoveAll(tmp) + if err = os.Chmod(tmp, 0755); err != nil { + return st, err + } + st, err = s.Pull(ctx, d, tmp, o) + if err != nil { + return st, err + } + if err = ctx.Err(); err != nil { + return st, err + } + if _, err = os.Lstat(dest); !errors.Is(err, os.ErrNotExist) { + if err == nil { + err = errors.New("destination appeared during preparation") + } + return st, err + } + if err = os.Rename(tmp, dest); err != nil { + return st, err + } + return st, nil } diff --git a/cmd/onggi/prepare_test.go b/cmd/onggi/prepare_test.go index 0278ac6..df21176 100644 --- a/cmd/onggi/prepare_test.go +++ b/cmd/onggi/prepare_test.go @@ -1,108 +1,247 @@ package main import ( - "bytes" - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" - "github.com/ai-computing/onggi/pkg/backend" - bfs "github.com/ai-computing/onggi/pkg/backend/fs" - "github.com/ai-computing/onggi/pkg/chunker" - "github.com/ai-computing/onggi/pkg/digest" - "github.com/ai-computing/onggi/pkg/store" + "github.com/ai-computing/onggi/pkg/backend" + bfs "github.com/ai-computing/onggi/pkg/backend/fs" + "github.com/ai-computing/onggi/pkg/chunker" + "github.com/ai-computing/onggi/pkg/digest" + "github.com/ai-computing/onggi/pkg/store" ) -const prepareProfileJSON=`{"schema":"onggi.profile.v1","model_source":"sha256:1111111111111111111111111111111111111111111111111111111111111111","options":"sha256:2222222222222222222222222222222222222222222222222222222222222222","device":"rngd","runtime":"rngd-pd","sdk":"2026.4.0","compiler":"2026.4.0","firmware":"fw1","format":"runnable","layout":"rngd-bf16","topology":"fusion8-mesh2","tp":1,"batch":4,"sequence":256}` +const prepareProfileJSON = `{"schema":"onggi.profile.v1","model_source":"sha256:1111111111111111111111111111111111111111111111111111111111111111","options":"sha256:2222222222222222222222222222222222222222222222222222222222222222","device":"rngd","runtime":"rngd-pd","sdk":"2026.4.0","compiler":"2026.4.0","firmware":"fw1","format":"runnable","layout":"rngd-bf16","topology":"fusion8-mesh2","tp":1,"batch":4,"sequence":256}` + +type prepareFixture struct { + root, source, storage, request string + data []byte +} -type prepareFixture struct {root,source,storage,request string;data []byte} func newPrepareFixture(t *testing.T) prepareFixture { - t.Helper();root:=t.TempDir();f:=prepareFixture{root,filepath.Join(root,"source"),filepath.Join(root,"store"),filepath.Join(root,"request.json"),bytes.Repeat([]byte("native artifact bytes\n"),10000)} - if err:=os.Mkdir(f.source,0700);err!=nil {t.Fatal(err)} - if err:=os.WriteFile(filepath.Join(f.source,"graph.bin"),f.data,0640);err!=nil {t.Fatal(err)} - if err:=os.WriteFile(f.request,[]byte(prepareProfileJSON),0600);err!=nil {t.Fatal(err)} - var out,errs bytes.Buffer - if code:=push([]string{"--store",f.storage,"--profile",f.request,"--ref","model","--compress","none","--quiet",f.source},&out,&errs);code!=0 {t.Fatalf("profile publication exit %d: %s",code,&errs)} - return f + t.Helper() + root := t.TempDir() + f := prepareFixture{root, filepath.Join(root, "source"), filepath.Join(root, "store"), filepath.Join(root, "request.json"), bytes.Repeat([]byte("native artifact bytes\n"), 10000)} + if err := os.Mkdir(f.source, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(f.source, "graph.bin"), f.data, 0640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f.request, []byte(prepareProfileJSON), 0600); err != nil { + t.Fatal(err) + } + var out, errs bytes.Buffer + if code := push([]string{"--store", f.storage, "--profile", f.request, "--ref", "model", "--compress", "none", "--quiet", f.source}, &out, &errs); code != 0 { + t.Fatalf("profile publication exit %d: %s", code, &errs) + } + return f } -func runPrepare(t *testing.T,args ...string) (int,map[string]any,string) { - t.Helper();var out,errs bytes.Buffer;code:=prepare(args,&out,&errs);var report map[string]any - if err:=json.Unmarshal(out.Bytes(),&report);err!=nil {t.Fatalf("not a JSON report: %s / %s",&out,&errs)} - return code,report,errs.String() +func runPrepare(t *testing.T, args ...string) (int, map[string]any, string) { + t.Helper() + var out, errs bytes.Buffer + code := prepare(args, &out, &errs) + var report map[string]any + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("not a JSON report: %s / %s", &out, &errs) + } + return code, report, errs.String() } func TestPreparePublishesExactFilesAndReusesCache(t *testing.T) { - f:=newPrepareFixture(t);cache:=filepath.Join(f.root,"cache") - for i,name:=range []string{"first","second"} { - dest:=filepath.Join(f.root,name);code,report,errtext:=runPrepare(t,"--store",f.storage,"--request",f.request,"--dir",dest,"--cache",cache,"model") - if code!=0||report["ready"]!=true {t.Fatalf("not ready: %d %+v %s",code,report,errtext)} - got,err:=os.ReadFile(filepath.Join(dest,"graph.bin"));if err!=nil||!bytes.Equal(got,f.data){t.Fatalf("bad output: %v",err)} - if i==1 {stats:=report["stats"].(map[string]any);if stats["read_bytes"]!=float64(0)||stats["cache_bytes"].(float64)<=0 {t.Fatalf("warm cache unused: %+v",stats)}} - if _,err=os.Lstat(dest+".onggi-lock");!os.IsNotExist(err){t.Fatal("owned lock leaked")} - } + f := newPrepareFixture(t) + cache := filepath.Join(f.root, "cache") + for i, name := range []string{"first", "second"} { + dest := filepath.Join(f.root, name) + code, report, errtext := runPrepare(t, "--store", f.storage, "--request", f.request, "--dir", dest, "--cache", cache, "model") + if code != 0 || report["ready"] != true { + t.Fatalf("not ready: %d %+v %s", code, report, errtext) + } + got, err := os.ReadFile(filepath.Join(dest, "graph.bin")) + if err != nil || !bytes.Equal(got, f.data) { + t.Fatalf("bad output: %v", err) + } + if i == 1 { + stats := report["stats"].(map[string]any) + if stats["read_bytes"] != float64(0) || stats["cache_bytes"].(float64) <= 0 { + t.Fatalf("warm cache unused: %+v", stats) + } + } + if _, err = os.Lstat(dest + ".onggi-lock"); !os.IsNotExist(err) { + t.Fatal("owned lock leaked") + } + } } func TestPrepareNoMatchAndPlanDoNotPrepareFiles(t *testing.T) { - for _,plan:=range []bool{false,true} {t.Run(map[bool]string{false:"mismatch",true:"plan"}[plan],func(t *testing.T){ - f:=newPrepareFixture(t);dest:=filepath.Join(f.root,"output");cache:=filepath.Join(f.root,"cache") - args:=[]string{"--store",f.storage,"--request",f.request,"--dir",dest,"--cache",cache} - if plan {args=append(args,"--plan-only")} else {os.WriteFile(f.request,[]byte(strings.Replace(prepareProfileJSON,`"sequence":256`,`"sequence":512`,1)),0600)} - code,report,_:=runPrepare(t,append(args,"model")...) - if report["ready"]!=false||plan!=(code==0){t.Fatalf("wrong readiness: %d %+v",code,report)} - if !plan {sel:=report["selection"].(map[string]any);if len(sel["rejected"].([]any))!=1 {t.Fatal("no mismatch report")}} - for _,p:=range []string{dest,cache,dest+".onggi-lock"}{if _,err:=os.Lstat(p);!os.IsNotExist(err){t.Fatalf("unexpected write: %s",p)}} - })} + for _, plan := range []bool{false, true} { + t.Run(map[bool]string{false: "mismatch", true: "plan"}[plan], func(t *testing.T) { + f := newPrepareFixture(t) + dest := filepath.Join(f.root, "output") + cache := filepath.Join(f.root, "cache") + args := []string{"--store", f.storage, "--request", f.request, "--dir", dest, "--cache", cache} + if plan { + args = append(args, "--plan-only") + } else { + os.WriteFile(f.request, []byte(strings.Replace(prepareProfileJSON, `"sequence":256`, `"sequence":512`, 1)), 0600) + } + code, report, _ := runPrepare(t, append(args, "model")...) + if report["ready"] != false || plan != (code == 0) { + t.Fatalf("wrong readiness: %d %+v", code, report) + } + if !plan { + sel := report["selection"].(map[string]any) + if len(sel["rejected"].([]any)) != 1 { + t.Fatal("no mismatch report") + } + } + for _, p := range []string{dest, cache, dest + ".onggi-lock"} { + if _, err := os.Lstat(p); !os.IsNotExist(err) { + t.Fatalf("unexpected write: %s", p) + } + } + }) + } } func TestPrepareRefusesExistingAndBusyDestinations(t *testing.T) { - for _,kind:=range []string{"directory","file","symlink","busy"}{t.Run(kind,func(t *testing.T){ - f:=newPrepareFixture(t);dest:=filepath.Join(f.root,"output");sentinel:=filepath.Join(f.root,"sentinel");os.WriteFile(sentinel,[]byte("keep"),0600) - switch kind {case "directory":os.Mkdir(dest,0700);os.WriteFile(filepath.Join(dest,"keep"),[]byte("keep"),0600) - case "file":os.WriteFile(dest,[]byte("keep"),0600) - case "symlink":if err:=os.Symlink(sentinel,dest);err!=nil {t.Skip(err)} - case "busy":os.Mkdir(dest+".onggi-lock",0700);os.WriteFile(filepath.Join(dest+".onggi-lock","owner"),[]byte("other"),0600)} - code,report,_:=runPrepare(t,"--store",f.storage,"--request",f.request,"--dir",dest,"model") - if code==0||report["ready"]!=false {t.Fatal("existing/busy target accepted")} - got,err:=os.ReadFile(sentinel);if err!=nil||string(got)!="keep" {t.Fatal("outside target damaged")} - if kind=="busy" {got,err=os.ReadFile(filepath.Join(dest+".onggi-lock","owner"));if err!=nil||string(got)!="other" {t.Fatal("another owner lock damaged")}} - })} + for _, kind := range []string{"directory", "file", "symlink", "busy"} { + t.Run(kind, func(t *testing.T) { + f := newPrepareFixture(t) + dest := filepath.Join(f.root, "output") + sentinel := filepath.Join(f.root, "sentinel") + os.WriteFile(sentinel, []byte("keep"), 0600) + switch kind { + case "directory": + os.Mkdir(dest, 0700) + os.WriteFile(filepath.Join(dest, "keep"), []byte("keep"), 0600) + case "file": + os.WriteFile(dest, []byte("keep"), 0600) + case "symlink": + if err := os.Symlink(sentinel, dest); err != nil { + t.Skip(err) + } + case "busy": + os.Mkdir(dest+".onggi-lock", 0700) + os.WriteFile(filepath.Join(dest+".onggi-lock", "owner"), []byte("other"), 0600) + } + code, report, _ := runPrepare(t, "--store", f.storage, "--request", f.request, "--dir", dest, "model") + if code == 0 || report["ready"] != false { + t.Fatal("existing/busy target accepted") + } + got, err := os.ReadFile(sentinel) + if err != nil || string(got) != "keep" { + t.Fatal("outside target damaged") + } + if kind == "busy" { + got, err = os.ReadFile(filepath.Join(dest+".onggi-lock", "owner")) + if err != nil || string(got) != "other" { + t.Fatal("another owner lock damaged") + } + } + }) + } } func TestPrepareCorruptPackNeverPublishesAndCleansOwnedTemp(t *testing.T) { - f:=newPrepareFixture(t);packs,err:=filepath.Glob(filepath.Join(f.storage,"packs","*","*"));if err!=nil||len(packs)==0 {t.Fatal("no pack")} - for _,p:=range packs {data,_:=os.ReadFile(p);for i:=range data {data[i]^=0xff};if err=os.WriteFile(p,data,0600);err!=nil {t.Fatal(err)}} - dest:=filepath.Join(f.root,"output");code,report,_:=runPrepare(t,"--store",f.storage,"--request",f.request,"--dir",dest,"model") - if code==0||report["ready"]!=false {t.Fatal("corrupt bytes published")} - for _,p:=range []string{dest,dest+".onggi-lock"}{if _,err=os.Lstat(p);!os.IsNotExist(err){t.Fatalf("partial state: %s",p)}} - leftovers,_:=filepath.Glob(filepath.Join(f.root,".output.onggi-*"));if len(leftovers)!=0 {t.Fatalf("temp leak: %v",leftovers)} + f := newPrepareFixture(t) + packs, err := filepath.Glob(filepath.Join(f.storage, "packs", "*", "*")) + if err != nil || len(packs) == 0 { + t.Fatal("no pack") + } + for _, p := range packs { + data, _ := os.ReadFile(p) + for i := range data { + data[i] ^= 0xff + } + if err = os.WriteFile(p, data, 0600); err != nil { + t.Fatal(err) + } + } + dest := filepath.Join(f.root, "output") + code, report, _ := runPrepare(t, "--store", f.storage, "--request", f.request, "--dir", dest, "model") + if code == 0 || report["ready"] != false { + t.Fatal("corrupt bytes published") + } + for _, p := range []string{dest, dest + ".onggi-lock"} { + if _, err = os.Lstat(p); !os.IsNotExist(err) { + t.Fatalf("partial state: %s", p) + } + } + leftovers, _ := filepath.Glob(filepath.Join(f.root, ".output.onggi-*")) + if len(leftovers) != 0 { + t.Fatalf("temp leak: %v", leftovers) + } } func TestPrepareInvalidRequestNeverOpensBackend(t *testing.T) { - root:=t.TempDir();request:=filepath.Join(root,"request.json");storage:=filepath.Join(root,"store") - os.WriteFile(request,[]byte(`{"sdk":"unknown"}`),0600) - code,report,_:=runPrepare(t,"--store",storage,"--request",request,"--plan-only","model") - if code!=2||report["ready"]!=false {t.Fatalf("bad request accepted: %d %+v",code,report)} - if _,err:=os.Stat(storage);!os.IsNotExist(err){t.Fatal("backend opened on invalid request")} - var out,errs bytes.Buffer - if push([]string{"--store",storage,"--profile",request,root},&out,&errs)!=2 {t.Fatal("invalid published profile accepted")} - if _,err:=os.Stat(storage);!os.IsNotExist(err){t.Fatal("backend opened on invalid push profile")} + root := t.TempDir() + request := filepath.Join(root, "request.json") + storage := filepath.Join(root, "store") + os.WriteFile(request, []byte(`{"sdk":"unknown"}`), 0600) + code, report, _ := runPrepare(t, "--store", storage, "--request", request, "--plan-only", "model") + if code != 2 || report["ready"] != false { + t.Fatalf("bad request accepted: %d %+v", code, report) + } + if _, err := os.Stat(storage); !os.IsNotExist(err) { + t.Fatal("backend opened on invalid request") + } + var out, errs bytes.Buffer + if push([]string{"--store", storage, "--profile", request, root}, &out, &errs) != 2 { + t.Fatal("invalid published profile accepted") + } + if _, err := os.Stat(storage); !os.IsNotExist(err) { + t.Fatal("backend opened on invalid push profile") + } } func TestPrepareSelectedDigestSurvivesRefChange(t *testing.T) { - f:=newPrepareFixture(t);ctx:=context.Background();b,err:=bfs.Open(f.storage,backend.DurabilityNone);if err!=nil{t.Fatal(err)} - s,err:=store.Open(ctx,b,chunker.Default,0);if err!=nil{t.Fatal(err)} - first,err:=store.Resolve(ctx,b,"model");if err!=nil{t.Fatal(err)} - p,err:=store.DecodeProfile(strings.NewReader(prepareProfileJSON));if err!=nil{t.Fatal(err)} - selection,err:=s.Select(ctx,[]digest.Digest{first},p);if err!=nil{t.Fatal(err)} - os.WriteFile(filepath.Join(f.source,"graph.bin"),[]byte("different new ref"),0640) - second,_,err:=s.Push(ctx,f.source,store.PushOptions{Profile:&p});if err!=nil{t.Fatal(err)} - if err=b.SetRef(ctx,"model",first.String(),second.String());err!=nil{t.Fatal(err)} - selected,err:=digest.Parse(selection.Manifest);if err!=nil{t.Fatal(err)} - dest:=filepath.Join(f.root,"selected") - if _,err=prepareFiles(ctx,s,selected,dest,store.PullOptions{});err!=nil{t.Fatal(err)} - got,err:=os.ReadFile(filepath.Join(dest,"graph.bin"));if err!=nil||!bytes.Equal(got,f.data){t.Fatal("mutable ref replaced selected digest")} + f := newPrepareFixture(t) + ctx := context.Background() + b, err := bfs.Open(f.storage, backend.DurabilityNone) + if err != nil { + t.Fatal(err) + } + s, err := store.Open(ctx, b, chunker.Default, 0) + if err != nil { + t.Fatal(err) + } + first, err := store.Resolve(ctx, b, "model") + if err != nil { + t.Fatal(err) + } + p, err := store.DecodeProfile(strings.NewReader(prepareProfileJSON)) + if err != nil { + t.Fatal(err) + } + selection, err := s.Select(ctx, []digest.Digest{first}, p) + if err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(f.source, "graph.bin"), []byte("different new ref"), 0640) + second, _, err := s.Push(ctx, f.source, store.PushOptions{Profile: &p}) + if err != nil { + t.Fatal(err) + } + if err = b.SetRef(ctx, "model", first.String(), second.String()); err != nil { + t.Fatal(err) + } + selected, err := digest.Parse(selection.Manifest) + if err != nil { + t.Fatal(err) + } + dest := filepath.Join(f.root, "selected") + if _, err = prepareFiles(ctx, s, selected, dest, store.PullOptions{}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(dest, "graph.bin")) + if err != nil || !bytes.Equal(got, f.data) { + t.Fatal("mutable ref replaced selected digest") + } } diff --git a/deploy/k8s/prepare-initcontainer.yaml b/deploy/k8s/prepare-initcontainer.yaml new file mode 100644 index 0000000..f930ed0 --- /dev/null +++ b/deploy/k8s/prepare-initcontainer.yaml @@ -0,0 +1,47 @@ +# Replace every <...>; supply request.json as a ConfigMap with the exact target profile. +# This prepares files, not NPU graphs. Use a fresh emptyDir destination and an engine-specific +# device request/readiness probe. Do not deploy this template unchanged. +apiVersion: v1 +kind: Pod +metadata: + name: onggi-compatible-prepare +spec: + nodeSelector: + kubernetes.io/hostname: + initContainers: + - name: prepare-artifact + image: + command: ["onggi", "prepare"] + args: + - --request + - /request/request.json + - --cache + - /cache + - --dir + - /models/ready + - + - + env: + - name: ONGGI_STORE + value: + - name: GOMEMLIMIT + value: 1800MiB + # For S3, provide AWS credentials with secretKeyRef as in pull-initcontainer.yaml. + volumeMounts: + - {name: request, mountPath: /request, readOnly: true} + - {name: cache, mountPath: /cache} + - {name: models, mountPath: /models} + containers: + - name: engine + image: + args: ["--model", "/models/ready"] + # Add the actual runtime's device allocation, graph setup and readiness probe. + volumeMounts: + - {name: models, mountPath: /models, readOnly: true} + volumes: + - name: request + configMap: {name: onggi-artifact-request} + - name: cache + hostPath: {path: /var/lib/onggi/cache, type: DirectoryOrCreate} + - name: models + emptyDir: {} diff --git a/docs/compatible-prepare.md b/docs/compatible-prepare.md new file mode 100644 index 0000000..d3cf568 --- /dev/null +++ b/docs/compatible-prepare.md @@ -0,0 +1,104 @@ +# Select compatible artifacts and prepare files + +[한국어](ko/compatible-prepare.md) + +This is the first P3 integration increment. `onggi prepare` selects among +explicit candidates and prepares verified files before an engine starts. +It works with the existing POSIX/S3 stores, node chunk cache and peers. It does +not compile, discover installed SDKs, infer ABI compatibility, allocate an NPU, +warm a device graph or consume placement/autoscaler events. + +## Publish an explicit profile + +Publish each supported batch/sequence profile with its compiled directory: + +```sh +onggi push --store "$STORE" --profile rngd-b4-s256.json --ref llama/rngd/b4-s256 ./build +``` + +Example profile structure (replace the digests and build identities with actual +values; these illustrative identities are not a hardware compatibility claim): + +```json +{ + "schema": "onggi.profile.v1", + "model_source": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "options": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "device": "rngd", + "runtime": "rngd-pd@", + "sdk": "2026.4.0", + "compiler": "", + "firmware": "", + "format": "runnable", + "layout": "", + "topology": "fusion8-mesh2-group2", + "tp": 1, + "batch": 4, + "sequence": 256 +} +``` + +`model_source` identifies an immutable checkpoint file/hash manifest, not a +mutable model name. `options` is the SHA256 of the exact build-options record; +publisher and consumer must use the same canonical file bytes. Include all +relevant options (precision, compiler flags, parallelism, graph grouping, etc.). +Runtime, firmware, layout and topology strings must identify exact builds and +arrangements. An ATOM artifact uses its actual device SKU, rebel/compiler and +firmware identities, immutable runtime build, `rbln` format, actual weight +layout and P/N topology; it does not reuse an RNGD profile or SDK version. +The selector treats identities as opaque exact strings and knows no vendor ABI. + +The manifest's content hash binds this declaration to its file/chunk lists. +Accuracy remains the publisher's responsibility; this is not a signed vendor +attestation. Existing builds without a profile still support ordinary +`push`/`pull`; automatic selection excludes them. No storage-format migration +or duplicate weight bytes are required to add a profiled ref. + +## Select, then prepare + +Create `request.json` with the complete desired profile, in the same schema: + +```sh +onggi prepare --store "$STORE" --request request.json --plan-only \ + llama/rngd/b1-s256 llama/rngd/b4-s256 + +onggi prepare --store "$STORE" --request request.json \ + --cache /var/lib/onggi/cache --dir /models/ready \ + llama/rngd/b1-s256 llama/rngd/b4-s256 +``` + +Every field must match, including exact batch/sequence: larger graphs are not +assumed to support padding. Unknown JSON fields, missing fields, blank/unknown +identities, noncanonical/zero digests and nonpositive dimensions are rejected. +All readable candidates are reported; missing/corrupt manifests fail closed. +For multiple matches, the lexically lowest manifest digest wins, independently +of argument order. Refs resolve once under a GC lease; subsequent ref movement +does not change the selected artifact. For reproducible jobs, supply digests. + +Stdout is a JSON report: `selection` contains the chosen manifest, compatible +digests and rejection reasons; `ready` is true only after verified publication; +`directory` and `stats` describe the published files and cache/store traffic. +Exit status 0 means selection/preparation succeeded; 1 means an operational +failure/no match; 2 means invalid CLI/profile input. Plan-only succeeds with +`ready:false` and does not create destination/cache or fetch model chunks. +Opening a backend follows the existing CLI behavior and can initialize an +empty store; plan-only is not a globally read-only backend operation. + +The destination must be absent. Preparation uses a sibling private temp tree, +hash-checks through the existing pull path, and renames only the completed tree. +A sibling `.onggi-lock` directory excludes cooperating preparers. +Ordinary failures/cancellation remove this call's temp/lock and preserve useful +cache chunks. Existing destinations, symlinks and another preparer's lock are +refused. A process crash can leave temp/lock files; verify the owner is gone +before removing them or choose a fresh destination. There is no automatic +adoption of an existing directory or crash-durable publication guarantee. +File modes remain those of the artifact; arrange engine UID/group accordingly. +Do not share this preparation root with the CSI materializer's managed root. + +The [init-container example](../deploy/k8s/prepare-initcontainer.yaml) gates +engine startup on successful file preparation. The engine still validates its +native artifacts, loads/prepares graphs and reports serving readiness. The +RNGD and ATOM resident-update paths remain separate runtime integrations; +this command does not implement live request draining, variant switching or +device mutation. Cluster-wide discovery, CRDs/compile farm, placement-driven +prefetch and native prewarming remain later P3/P4 work. diff --git a/docs/ko/compatible-prepare.md b/docs/ko/compatible-prepare.md new file mode 100644 index 0000000..d8c6139 --- /dev/null +++ b/docs/ko/compatible-prepare.md @@ -0,0 +1,54 @@ +# 호환 결과물 선택과 파일 사전 준비 + +[English](../compatible-prepare.md) + +P3의 첫 구현입니다. `onggi prepare`는 지정한 후보에서 조건이 정확히 일치하는 +결과물을 골라 엔진 시작 전에 파일을 준비합니다. 기존 POSIX/S3, 노드 청크 캐시, +peer 경로를 재사용합니다. 자동 컴파일·SDK 탐색·NPU 그래프 예열은 포함하지 않습니다. + +```sh +onggi push --store "$STORE" --profile profile.json --ref llama/rngd/b4-s256 ./build +onggi prepare --store "$STORE" --request request.json --plan-only \ + llama/rngd/b1-s256 llama/rngd/b4-s256 +onggi prepare --store "$STORE" --request request.json \ + --cache /var/lib/onggi/cache --dir /models/ready \ + llama/rngd/b1-s256 llama/rngd/b4-s256 +``` + +`profile.json`과 `request.json`은 동일한 `onggi.profile.v1` 구조입니다. +[전체 예제와 필드 설명](../compatible-prepare.md#publish-an-explicit-profile)을 참고하세요. +모델 원본·빌드 옵션의 정규 SHA256 해시, 장치, 런타임 빌드, SDK·컴파일러·펌웨어, +파일 형식, 가중치 배치 방식, 토폴로지, TP, 배치, 시퀀스 길이를 모두 명시해야 합니다. +원본 해시는 변경 불가능한 체크포인트 파일·해시 목록을 식별하며, 옵션 해시는 게시자와 +소비자가 합의한 정확한 옵션 기록 바이트의 해시입니다. 예제의 해시·빌드 식별자는 +실제 값으로 바꿔야 합니다. ATOM은 실제 ATOM 장치·rebel·펌웨어·런타임, rbln 형식, +가중치 배치와 P/N 토폴로지를 사용하며 RNGD 프로필을 공유하지 않습니다. + +어느 필드든 다르면 제외하며 이유를 JSON으로 반환합니다. 큰 배치나 길이의 그래프에 +패딩할 수 있다고 추측하지 않습니다. 누락·오타 필드, 빈 값·unknown, 0/비정규 해시, +양수가 아닌 차원은 거부합니다. 선언은 매니페스트 해시로 파일에 결합되지만, 실제 +정확성은 게시자가 책임집니다. 벤더 ABI 인증이나 서명된 보증은 아닙니다. +기존 프로필 없는 결과물은 일반 push/pull은 되며 자동 선택에서만 제외됩니다. + +여러 후보가 일치하면 매니페스트 해시의 사전순으로 고릅니다. 후보 ref는 GC lease +안에서 한 번만 해석하며, 이후 ref가 바뀌어도 선택한 해시를 복원합니다. 없는 결과물이나 +손상된 매니페스트를 조용히 건너뛰지는 않습니다. 재현 가능한 작업에는 해시를 넘기세요. + +표준 출력은 JSON이며 `selection`에 선택·제외 이유, `ready`에 준비 결과가 담깁니다. +종료 코드 0은 성공, 1은 일치 후보 없음/운영 오류, 2는 잘못된 입력입니다. +`--plan-only`는 `ready:false`로 선택만 확인하며 모델 청크·대상 폴더·캐시를 준비하지 +않습니다. 백엔드를 여는 동작은 기존 CLI와 같아 빈 저장소를 초기화할 수 있습니다. + +대상 경로는 없어야 합니다. 형제 임시 디렉터리에서 청크 해시를 검증하고 전체 파일이 +완성된 뒤 rename으로 게시합니다. `<대상>.onggi-lock`으로 같은 대상을 준비하는 +프로세스를 막습니다. 일반 오류·취소에는 자신이 만든 임시 폴더·잠금만 정리하고 +캐시 청크는 남깁니다. 기존 대상·심볼릭 링크·다른 작업의 잠금은 거부합니다. +프로세스가 강제 종료되면 잠금·임시 파일이 남을 수 있으므로 소유 프로세스가 없는지 +확인한 뒤 정리하거나 새 경로를 사용하세요. 기존 디렉터리 자동 채택 및 크래시 후 +디스크 영속성 보장은 없습니다. 파일 권한은 원본을 유지하므로 엔진 UID/그룹을 맞추고, +CSI가 관리하는 루트와 준비 디렉터리를 공유하지 마세요. + +[init-container 예제](../../deploy/k8s/prepare-initcontainer.yaml)는 이 명령 성공 후 +엔진을 시작합니다. `ready:true`는 **검증된 파일 준비 완료**이며 NPU 서빙 준비와 +다릅니다. 엔진의 SDK·그래프 검증/적재·readiness는 이어서 수행해야 합니다. +클러스터 후보 탐색, CRD·컴파일 팜, 배치·확장 신호 연동과 NPU 예열은 후속 작업입니다. diff --git a/docs/superpowers/plans/2026-10-30-compatible-prepare.md b/docs/superpowers/plans/2026-10-30-compatible-prepare.md index 1eb28f8..e39b5f4 100644 --- a/docs/superpowers/plans/2026-10-30-compatible-prepare.md +++ b/docs/superpowers/plans/2026-10-30-compatible-prepare.md @@ -25,11 +25,11 @@ cmd/onggi/{store.go,prepare_test.go}. Interfaces: Profile.Validate/DecodeProfile, Store.Select(ctx, []digest.Digest, Profile) -> Selection/error; Manifest.Profile, PushOptions.Profile; push --profile. -- [ ] Verify clean Linux baseline on an owned CPU work directory. -- [ ] RED: exact identity matching, legacy exclusion, invalid profile/JSON, +- [x] Verify clean Linux baseline on an owned CPU work directory. +- [x] RED: exact identity matching, legacy exclusion, invalid profile/JSON, deterministic candidate choice, bad-manifest fail closed, profile roundtrip. -- [ ] Implement bounded metadata validation, optional profile and selection. -- [ ] GREEN focused tests, full suite, commit with validation ledger. +- [x] Implement bounded metadata validation, optional profile and selection. +- [x] GREEN focused tests, full suite, commit with validation ledger. ## Task 2: Atomic CLI preparation and operator documentation @@ -37,12 +37,12 @@ Files: cmd/onggi/{prepare.go,prepare_test.go,main.go}; README.md/README.ko.md; docs/{compatible-prepare.md,ko/compatible-prepare.md}; deploy/k8s example. Interface: prepare CLI per spec, always JSON report; request shares Profile v1. -- [ ] RED: candidate selection/immutable digest, no-match/no cache writes, +- [x] RED: candidate selection/immutable digest, no-match/no cache writes, plan-only, hash corruption/cleanup, existing/busy/symlink destinations, strict profile errors, byte-identical pull and warm node cache reuse. -- [ ] Implement prepare via existing Pull/Cache/Peers, sibling temp+mkdir lock, +- [x] Implement prepare via existing Pull/Cache/Peers, sibling temp+mkdir lock, store GC lease and atomic rename; never use shared CSI Materializer.Init. -- [ ] GREEN focused tests, full race/vet/build; bilingual docs and examples. +- [x] GREEN focused tests, full race/vet/build; bilingual docs and examples. - [ ] One independent branch review, fix important findings with RED->GREEN. - [ ] Commit/push draft PR, verify exact-head CI, record final gate in PR. @@ -54,3 +54,23 @@ Interface: prepare CLI per spec, always JSON report; request shares Profile v1. This avoids inventing cross-repo contracts or guessing vendor compatibility. - Ruling: strict exact B/S means callers must publish each supported profile; padding/bucketing is only safe when the runtime explicitly supports it. +- Baseline Linux `go test ./...` passed on clean 69033a1. Owned CPU-only + work directory and existing private Go1.27.3 toolchain, GOMAXPROCS4; no NPU, + shared SDK or driver operations. New GOPATH/GOCACHE isolate this work. +- Task1 RED at56f6865: missing Profile/Select/PushOptions fields. GREEN at + c46041a: every exact identity field, legacy ordinary pull, missing manifest, + deterministic/deduplicated matches, strict incomplete/unknown JSON and + push roundtrip pass. Profile validation precedes push data/lease operations. +- Task2 RED ateb1862f: missing prepare/prepareFiles. GREEN at1d050e5 + (gofmt only afterwards): real CLI publication/restoration byte equality, + second pull cache reads with zero store bytes, plan/no-match no target/cache, + existing/file/symlink/busy refusal, corruption cleanup and selected digest + preserved after actual ref CAS change. Profile errors precede backend open. +- Full Linux `go test -race -json ./...`:148 test/subtest pass events, no failed + tests/packages. `go vet ./...` and Linux CLI build passed. Live S3 conformance + is skipped locally without servers; final CI runs both actual S3 backends + plus the new profile/prepare/cache CLI workflow before publication completes. +- Ruling: preserve artifact file permissions, refuse existing destinations + rather than silently trusting them; process-crash temp/lock recovery remains + explicit and documented. This costs manual recovery/new destination on a + crash, and avoids treating an unverified existing directory as ready. diff --git a/pkg/store/profile.go b/pkg/store/profile.go index a9f765f..1a635f9 100644 --- a/pkg/store/profile.go +++ b/pkg/store/profile.go @@ -1,110 +1,151 @@ package store import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "sort" - "strings" - "unicode" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + "unicode" - "github.com/ai-computing/onggi/pkg/digest" + "github.com/ai-computing/onggi/pkg/digest" ) // Profile is a publisher's exact compatibility declaration, bound to artifact files by the manifest. // It does not attest vendor ABI compatibility or permit shape padding. type Profile struct { - Schema string `json:"schema"` - ModelSource string `json:"model_source"` - Options string `json:"options"` - Device string `json:"device"` - Runtime string `json:"runtime"` - SDK string `json:"sdk"` - Compiler string `json:"compiler"` - Firmware string `json:"firmware"` - Format string `json:"format"` - Layout string `json:"layout"` - Topology string `json:"topology"` - TP int `json:"tp"` - Batch int `json:"batch"` - Sequence int `json:"sequence"` + Schema string `json:"schema"` + ModelSource string `json:"model_source"` + Options string `json:"options"` + Device string `json:"device"` + Runtime string `json:"runtime"` + SDK string `json:"sdk"` + Compiler string `json:"compiler"` + Firmware string `json:"firmware"` + Format string `json:"format"` + Layout string `json:"layout"` + Topology string `json:"topology"` + TP int `json:"tp"` + Batch int `json:"batch"` + Sequence int `json:"sequence"` } func (p Profile) Validate() error { - if p.Schema!="onggi.profile.v1" {return fmt.Errorf("profile: unknown schema %q",p.Schema)} - for name,value:=range map[string]string{"model_source":p.ModelSource,"options":p.Options} { - d,err:=digest.Parse(value) - if err!=nil||d.IsZero()||d.String()!=value {return fmt.Errorf("profile %s: want canonical nonzero sha256 digest",name)} - } - for name,value:=range map[string]string{"device":p.Device,"runtime":p.Runtime,"sdk":p.SDK,"compiler":p.Compiler,"firmware":p.Firmware,"format":p.Format,"layout":p.Layout,"topology":p.Topology} { - if value==""||strings.TrimSpace(value)!=value||value=="*"||strings.EqualFold(value,"unknown")||strings.IndexFunc(value,unicode.IsControl)>=0 {return fmt.Errorf("profile %s: want explicit nonblank identity",name)} - } - if p.TP<1||p.Batch<1||p.Sequence<1 {return errors.New("profile: tp, batch and sequence must be positive integers")} - return nil + if p.Schema != "onggi.profile.v1" { + return fmt.Errorf("profile: unknown schema %q", p.Schema) + } + for name, value := range map[string]string{"model_source": p.ModelSource, "options": p.Options} { + d, err := digest.Parse(value) + if err != nil || d.IsZero() || d.String() != value { + return fmt.Errorf("profile %s: want canonical nonzero sha256 digest", name) + } + } + for name, value := range map[string]string{"device": p.Device, "runtime": p.Runtime, "sdk": p.SDK, "compiler": p.Compiler, "firmware": p.Firmware, "format": p.Format, "layout": p.Layout, "topology": p.Topology} { + if value == "" || strings.TrimSpace(value) != value || value == "*" || strings.EqualFold(value, "unknown") || strings.IndexFunc(value, unicode.IsControl) >= 0 { + return fmt.Errorf("profile %s: want explicit nonblank identity", name) + } + } + if p.TP < 1 || p.Batch < 1 || p.Sequence < 1 { + return errors.New("profile: tp, batch and sequence must be positive integers") + } + return nil } // UnmarshalJSON also validates profiles embedded in manifests, rejecting future/typo fields. func (p *Profile) UnmarshalJSON(data []byte) error { - type plain Profile - var v plain - dec:=json.NewDecoder(bytes.NewReader(data));dec.DisallowUnknownFields() - if err:=dec.Decode(&v);err!=nil {return fmt.Errorf("profile: %w",err)} - if err:=Profile(v).Validate();err!=nil {return err} - *p=Profile(v) - return nil + type plain Profile + var v plain + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return fmt.Errorf("profile: %w", err) + } + if err := Profile(v).Validate(); err != nil { + return err + } + *p = Profile(v) + return nil } // DecodeProfile reads one bounded JSON object with no trailing content. -func DecodeProfile(r io.Reader) (Profile,error) { - var p Profile - data,err:=io.ReadAll(io.LimitReader(r,(64<<10)+1)) - if err!=nil {return p,err} - if len(data)>64<<10 {return p,errors.New("profile exceeds 64 KiB")} - err=json.Unmarshal(data,&p) - return p,err +func DecodeProfile(r io.Reader) (Profile, error) { + var p Profile + data, err := io.ReadAll(io.LimitReader(r, (64<<10)+1)) + if err != nil { + return p, err + } + if len(data) > 64<<10 { + return p, errors.New("profile exceeds 64 KiB") + } + err = json.Unmarshal(data, &p) + return p, err } type Rejection struct { - Manifest string `json:"manifest"` - Reasons []string `json:"reasons"` + Manifest string `json:"manifest"` + Reasons []string `json:"reasons"` } type Selection struct { - Manifest string `json:"manifest,omitempty"` - Profile Profile `json:"profile"` - Compatible []string `json:"compatible"` - Rejected []Rejection `json:"rejected"` + Manifest string `json:"manifest,omitempty"` + Profile Profile `json:"profile"` + Compatible []string `json:"compatible"` + Rejected []Rejection `json:"rejected"` } -var ErrNoCompatible=errors.New("no compatible artifact") +var ErrNoCompatible = errors.New("no compatible artifact") // Select checks every supplied manifest and deterministically chooses the lowest matching digest. // Callers protect mutable ref resolution and subsequent use with a store GC lease. -func (s *Store) Select(ctx context.Context,candidates []digest.Digest,want Profile) (Selection,error) { - result:=Selection{Profile:want,Compatible:[]string{},Rejected:[]Rejection{}} - if err:=want.Validate();err!=nil {return result,err} - unique:=map[digest.Digest]bool{} - ds:=append([]digest.Digest(nil),candidates...) - sort.Slice(ds,func(i,j int)bool{return ds[i].String() Date: Fri, 30 Oct 2026 13:40:34 +0900 Subject: [PATCH 7/9] test: reproduce preparation staging and locator boundary findings --- cmd/onggi/prepare_test.go | 26 ++++++++++++++++++++++++ pkg/store/pull_boundary_test.go | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 pkg/store/pull_boundary_test.go diff --git a/cmd/onggi/prepare_test.go b/cmd/onggi/prepare_test.go index df21176..1ea372f 100644 --- a/cmd/onggi/prepare_test.go +++ b/cmd/onggi/prepare_test.go @@ -4,9 +4,12 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "io" "os" "path/filepath" "strings" + "sync/atomic" "testing" "github.com/ai-computing/onggi/pkg/backend" @@ -23,6 +26,29 @@ type prepareFixture struct { data []byte } +type inspectPrepareBackend struct { + backend.Backend + inspect func() error +} + +func (b inspectPrepareBackend) Get(ctx context.Context,k backend.Key,off,n int64)(io.ReadCloser,error) { + if k.Kind==backend.Pack {if err:=b.inspect();err!=nil {return nil,err}} + return b.Backend.Get(ctx,k,off,n) +} + +func TestPrepareTreePrivateDuringBackendRead(t *testing.T) { + f:=newPrepareFixture(t);ctx:=context.Background();b,err:=bfs.Open(f.storage,backend.DurabilityNone);if err!=nil {t.Fatal(err)} + var inspected atomic.Bool + wrapped:=inspectPrepareBackend{b,func()error { + inspected.Store(true);paths,err:=filepath.Glob(filepath.Join(f.root,".output.onggi-*"));if err!=nil||len(paths)!=1{return fmt.Errorf("missing staging root: %v",paths)} + info,err:=os.Stat(paths[0]);if err!=nil{return err};if info.Mode().Perm()&0077!=0{return fmt.Errorf("restricted files exposed through staging mode %o",info.Mode().Perm())};return nil + }} + s,err:=store.Open(ctx,wrapped,chunker.Default,0);if err!=nil{t.Fatal(err)};d,err:=store.Resolve(ctx,b,"model");if err!=nil{t.Fatal(err)} + dest:=filepath.Join(f.root,"output");if _,err=prepareFiles(ctx,s,d,dest,store.PullOptions{});err!=nil{t.Fatal(err)} + if !inspected.Load(){t.Fatal("backend read not inspected")} + got,err:=os.ReadFile(filepath.Join(dest,"graph.bin"));if err!=nil||!bytes.Equal(got,f.data){t.Fatal("output changed")} +} + func newPrepareFixture(t *testing.T) prepareFixture { t.Helper() root := t.TempDir() diff --git a/pkg/store/pull_boundary_test.go b/pkg/store/pull_boundary_test.go new file mode 100644 index 0000000..ea98f17 --- /dev/null +++ b/pkg/store/pull_boundary_test.go @@ -0,0 +1,35 @@ +package store + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "path/filepath" + "testing" + + "github.com/ai-computing/onggi/pkg/backend" + "github.com/ai-computing/onggi/pkg/digest" +) + +func TestPullPreservesPartialSuffixFilesInReverseManifestOrder(t *testing.T) { + s:=openStore(t,t.TempDir());src:=t.TempDir();ctx:=context.Background();p:=testProfile() + write(t,filepath.Join(src,"a"),[]byte("first file"));write(t,filepath.Join(src,"a.onggi-partial"),[]byte("different second file")) + d,_,err:=s.Push(ctx,src,PushOptions{Profile:&p});if err!=nil {t.Fatal(err)} + m,err:=s.ReadManifest(ctx,d);if err!=nil {t.Fatal(err)} + if len(m.Files)!=2 {t.Fatal("wrong fixture")};m.Files[0],m.Files[1]=m.Files[1],m.Files[0];m.Locator=nil + data,_:=json.Marshal(m);d=digest.Of(data) + if err=s.B.Put(ctx,backend.Key{Kind:backend.Manifest,Name:d.Hex()},bytes.NewReader(data),int64(len(data)));err!=nil{t.Fatal(err)} + out:=t.TempDir();if _,err=s.Pull(ctx,d,out,PullOptions{});err!=nil{t.Fatal(err)} + sameTree(t,src,out) +} + +func TestLocatorCountRefusesAllocationAfterInvalidCount(t *testing.T) { + data:=make([]byte,4);binary.LittleEndian.PutUint32(data,^uint32(0)) + for _,min:=range []int{4,32,41} { + r:=reader{p:data};if n:=r.count(min);n!=0||r.err==nil {t.Fatalf("unsafe allocation count=%d, min=%d, err=%v",n,min,r.err)} + } + // A bounded malformed locator exercises the decoder without a dangerous allocation. + malformed:=append([]byte("ONGGILC1"),100,0,0,0) + if _,_,err:=readLocator(malformed,&Manifest{});err==nil {t.Fatal("truncated locator accepted")} +} From c7e39104aca4547350624c681ab40e27a73d9635 Mon Sep 17 00:00:00 2001 From: gspark-etri Date: Fri, 30 Oct 2026 13:41:31 +0900 Subject: [PATCH 8/9] fix: protect preparation staging and validate locator counts --- cmd/onggi/prepare.go | 6 +++--- pkg/store/locator.go | 3 +++ pkg/store/pull.go | 35 ++++++++++++++++++++++++++++------- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/cmd/onggi/prepare.go b/cmd/onggi/prepare.go index 88ba3b8..3b69a33 100644 --- a/cmd/onggi/prepare.go +++ b/cmd/onggi/prepare.go @@ -141,9 +141,6 @@ func prepareFiles(ctx context.Context, s *store.Store, d digest.Digest, dest str return st, err } defer os.RemoveAll(tmp) - if err = os.Chmod(tmp, 0755); err != nil { - return st, err - } st, err = s.Pull(ctx, d, tmp, o) if err != nil { return st, err @@ -151,6 +148,9 @@ func prepareFiles(ctx context.Context, s *store.Store, d digest.Digest, dest str if err = ctx.Err(); err != nil { return st, err } + if err = os.Chmod(tmp, 0755); err != nil { + return st, err + } if _, err = os.Lstat(dest); !errors.Is(err, os.ErrNotExist) { if err == nil { err = errors.New("destination appeared during preparation") diff --git a/pkg/store/locator.go b/pkg/store/locator.go index 18c1179..e52b6e5 100644 --- a/pkg/store/locator.go +++ b/pkg/store/locator.go @@ -192,6 +192,9 @@ func (r *reader) count(min int) int { if r.err == nil && uint64(n)*uint64(min) > uint64(len(r.p)) { r.err = errors.New("locator: count exceeds the data") } + if r.err != nil { + return 0 + } return int(n) } diff --git a/pkg/store/pull.go b/pkg/store/pull.go index a90931d..f9df439 100644 --- a/pkg/store/pull.go +++ b/pkg/store/pull.go @@ -113,6 +113,21 @@ func (s *Store) Pull(ctx context.Context, manifest digest.Digest, dest string, o // open every file under a temporary name, sized up front handles := make([]*os.File, len(m.Files)) finals := make([]string, len(m.Files)) + reserved := map[string]bool{} + names := map[string]bool{} + for i, f := range m.Files { + p, err := safeJoin(dest, f.Path) + if err != nil { + return st, err + } + if names[p] { + return st, fmt.Errorf("duplicate file path %q in manifest", f.Path) + } + names[p], finals[i] = true, p + for q := p; q != filepath.Clean(dest); q = filepath.Dir(q) { + reserved[q] = true + } + } defer func() { for _, h := range handles { if h != nil { @@ -122,16 +137,22 @@ func (s *Store) Pull(ctx context.Context, manifest digest.Digest, dest string, o } }() for i, f := range m.Files { - p, err := safeJoin(dest, f.Path) - if err != nil { - return st, err - } + p := finals[i] if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return st, err } - h, err := os.Create(p + ".onggi-partial") - if err != nil { - return st, err + var h *os.File + for { + var err error + h, err = os.CreateTemp(filepath.Dir(p), ".onggi-partial-*") + if err != nil { + return st, err + } + if !reserved[h.Name()] { + break + } + h.Close() + os.Remove(h.Name()) } handles[i], finals[i] = h, p if err := h.Truncate(f.Size); err != nil { From db82b4c8f2dcec61c32eae531c6006fcf96cfbeb Mon Sep 17 00:00:00 2001 From: gspark-etri Date: Fri, 30 Oct 2026 13:45:33 +0900 Subject: [PATCH 9/9] docs: record review regressions and compatible preparation validation --- cmd/onggi/prepare_test.go | 57 ++++-- docs/compatible-prepare.md | 8 + docs/data/compatible-prepare-20261030.json | 164 ++++++++++++++++++ docs/ko/compatible-prepare.md | 6 + .../plans/2026-10-30-compatible-prepare.md | 35 +++- pkg/store/pull_boundary_test.go | 74 +++++--- 6 files changed, 307 insertions(+), 37 deletions(-) create mode 100644 docs/data/compatible-prepare-20261030.json diff --git a/cmd/onggi/prepare_test.go b/cmd/onggi/prepare_test.go index 1ea372f..de43765 100644 --- a/cmd/onggi/prepare_test.go +++ b/cmd/onggi/prepare_test.go @@ -31,22 +31,57 @@ type inspectPrepareBackend struct { inspect func() error } -func (b inspectPrepareBackend) Get(ctx context.Context,k backend.Key,off,n int64)(io.ReadCloser,error) { - if k.Kind==backend.Pack {if err:=b.inspect();err!=nil {return nil,err}} - return b.Backend.Get(ctx,k,off,n) +func (b inspectPrepareBackend) Get(ctx context.Context, k backend.Key, off, n int64) (io.ReadCloser, error) { + if k.Kind == backend.Pack { + if err := b.inspect(); err != nil { + return nil, err + } + } + return b.Backend.Get(ctx, k, off, n) } func TestPrepareTreePrivateDuringBackendRead(t *testing.T) { - f:=newPrepareFixture(t);ctx:=context.Background();b,err:=bfs.Open(f.storage,backend.DurabilityNone);if err!=nil {t.Fatal(err)} + f := newPrepareFixture(t) + ctx := context.Background() + b, err := bfs.Open(f.storage, backend.DurabilityNone) + if err != nil { + t.Fatal(err) + } var inspected atomic.Bool - wrapped:=inspectPrepareBackend{b,func()error { - inspected.Store(true);paths,err:=filepath.Glob(filepath.Join(f.root,".output.onggi-*"));if err!=nil||len(paths)!=1{return fmt.Errorf("missing staging root: %v",paths)} - info,err:=os.Stat(paths[0]);if err!=nil{return err};if info.Mode().Perm()&0077!=0{return fmt.Errorf("restricted files exposed through staging mode %o",info.Mode().Perm())};return nil + wrapped := inspectPrepareBackend{b, func() error { + inspected.Store(true) + paths, err := filepath.Glob(filepath.Join(f.root, ".output.onggi-*")) + if err != nil || len(paths) != 1 { + return fmt.Errorf("missing staging root: %v", paths) + } + info, err := os.Stat(paths[0]) + if err != nil { + return err + } + if info.Mode().Perm()&0077 != 0 { + return fmt.Errorf("restricted files exposed through staging mode %o", info.Mode().Perm()) + } + return nil }} - s,err:=store.Open(ctx,wrapped,chunker.Default,0);if err!=nil{t.Fatal(err)};d,err:=store.Resolve(ctx,b,"model");if err!=nil{t.Fatal(err)} - dest:=filepath.Join(f.root,"output");if _,err=prepareFiles(ctx,s,d,dest,store.PullOptions{});err!=nil{t.Fatal(err)} - if !inspected.Load(){t.Fatal("backend read not inspected")} - got,err:=os.ReadFile(filepath.Join(dest,"graph.bin"));if err!=nil||!bytes.Equal(got,f.data){t.Fatal("output changed")} + s, err := store.Open(ctx, wrapped, chunker.Default, 0) + if err != nil { + t.Fatal(err) + } + d, err := store.Resolve(ctx, b, "model") + if err != nil { + t.Fatal(err) + } + dest := filepath.Join(f.root, "output") + if _, err = prepareFiles(ctx, s, d, dest, store.PullOptions{}); err != nil { + t.Fatal(err) + } + if !inspected.Load() { + t.Fatal("backend read not inspected") + } + got, err := os.ReadFile(filepath.Join(dest, "graph.bin")) + if err != nil || !bytes.Equal(got, f.data) { + t.Fatal("output changed") + } } func newPrepareFixture(t *testing.T) prepareFixture { diff --git a/docs/compatible-prepare.md b/docs/compatible-prepare.md index d3cf568..f7d4eda 100644 --- a/docs/compatible-prepare.md +++ b/docs/compatible-prepare.md @@ -74,6 +74,10 @@ All readable candidates are reported; missing/corrupt manifests fail closed. For multiple matches, the lexically lowest manifest digest wins, independently of argument order. Refs resolve once under a GC lease; subsequent ref movement does not change the selected artifact. For reproducible jobs, supply digests. +This reuses the existing GC lease protocol (default TTL 24 hours), not a new +reader/writer lock. Strong exclusion of a sweep already in progress is not +established by this increment; retain immutable refs and coordinate GC during +critical preparation jobs. Stdout is a JSON report: `selection` contains the chosen manifest, compatible digests and rejection reasons; `ready` is true only after verified publication; @@ -102,3 +106,7 @@ RNGD and ATOM resident-update paths remain separate runtime integrations; this command does not implement live request draining, variant switching or device mutation. Cluster-wide discovery, CRDs/compile farm, placement-driven prefetch and native prewarming remain later P3/P4 work. + +[CPU validation and source hashes](data/compatible-prepare-20261030.json) record +the complete race suite, source-byte checks and three reproduced review fixes. +Final publication-head Go and live S3 CI links are recorded in the PR. diff --git a/docs/data/compatible-prepare-20261030.json b/docs/data/compatible-prepare-20261030.json new file mode 100644 index 0000000..3d55829 --- /dev/null +++ b/docs/data/compatible-prepare-20261030.json @@ -0,0 +1,164 @@ +{ + "schema": 1, + "scope": "Linux CPU file compatibility/preparation; synthetic fixture identities; no NPU or serving latency claim. Live S3 tested separately by final CI.", + "go": "go1.27.3 linux/amd64", + "GOMAXPROCS": 4, + "suite": { + "results": [ + { + "name": "race", + "exit": 0 + }, + { + "name": "vet", + "exit": 0 + }, + { + "name": "build", + "exit": 0 + } + ], + "all_passed": true, + "source_sha256": { + "cmd/onggi/csi.go": "9688f4fff880eda15c1a6d1ab994c3517027768a71b5e7df4086f31027157981", + "cmd/onggi/gc.go": "1ab034dc630233352ed80eca966989f9551f883e343d71241598a7dbe72592ad", + "cmd/onggi/main.go": "6103bb09c48e0f7a85b3d32e7b0f42ebe7901d78a74400ed96d62a33887c6bc6", + "cmd/onggi/main_test.go": "96323fa1efd223ab0f4ea0efbdc6a354c0ae1f3465408d3e9b4519b38b519c5c", + "cmd/onggi/memprof.go": "2e05bf5d01e3a358c9137498cd271fb30fe2d3102be65c95861fa17ea8d64dbf", + "cmd/onggi/peer.go": "8a9b9c7619d28a937baf1e7360bee6531d6dc2c08aeada4e9c591bd705c7c7ea", + "cmd/onggi/prepare.go": "2898c805c0f988e73136bf85750312f795224ff96f8ff8b941bb920fd1af64d7", + "cmd/onggi/prepare_test.go": "c9478d1eff5bed15687935c3652286c3f7f157a8fbf93bbf8de7e5899941a7a1", + "cmd/onggi/store.go": "e7a6b79535bc6ff0fa6da8ce3f80c197ac40c601ff98c84db8aa38e0191e0002", + "cmd/onggi/store_test.go": "fd3631a8b84acee849359774a600aabf180eeeb04030f8b7a8640d2df70339cf", + "go.mod": "f06368208efee087dddf223039cff81effec3f7f775adf68ef6f5519a980e6fb", + "go.sum": "eb8a42395d593bf8c28a4a93f81cddb67f42750faece7f36efe48fb7a36c6466", + "pkg/backend/backend.go": "b7cbb4be1fe432f9b12754bd50b4f1081e2dc2a23ba0080ff9580bc0872ad752", + "pkg/backend/backendtest/suite.go": "6f40cf54976be76aa7324394bd25dfbb8ddc4e0103113ba965c8d02281cc7bbd", + "pkg/backend/fs/fs.go": "a8c65dbb52417937ae92ce740bdb9b64ab9bb5a529970c2d717224d5b2ec7ee6", + "pkg/backend/fs/fs_test.go": "432f86961d43023ae4b493fd542b204b2f5c359d3c48ae55968bc133196c25c5", + "pkg/backend/s3/s3.go": "cd95a91dda3e12cb091ced6556c5c07dc7e00980edb195e511744fe9015c8c0f", + "pkg/backend/s3/s3_test.go": "8350a6d257a37fcf8905f0148548dd52d497071179e9f3b2b8f170ee5762a71a", + "pkg/chunker/chunker_test.go": "8c1e9e618e36ce0ac2e3f0ac0a2f1103d6c5733805acedd627a31d2261161340", + "pkg/chunker/fastcdc.go": "f4a71a924c115d0049a6c8f916e888f481d2296ff332d985dd6ac67f3a9e9d80", + "pkg/chunker/gear.go": "8c3a97f488e1b90bd8dc67a7a6e59de75c4ba479027d6cd4878b71fbd019da01", + "pkg/codec/codec.go": "b16f5c3f6cbd4d51822d129cbd244926aeda0650250bd83909f12d4f43fcd796", + "pkg/codec/codec_test.go": "d824f42cb113e6a48efed9b6842142571ca0e130ae333d84d6b82c87174ff32c", + "pkg/csi/acquire_failure_test.go": "790d2cb4cfaff75d384e5a80f3a4b718f30743041f639c59d4d1aa1d808410aa", + "pkg/csi/evict.go": "3d8719ab9e056e54b145d7b999197a09d05370f0ae7829a666f7204df31c5734", + "pkg/csi/evict_test.go": "bfd3539fcc117e2a1b104cfaab4cc0dafe06fdce86f0e47c49bb7005fc33b108", + "pkg/csi/materializer.go": "93c5d2835a321b8e02094a85775c371bafcd2e29b74dfcd8d78aad464f958f6e", + "pkg/csi/materializer_test.go": "84bd356a6c4422aaf64fae056f397bf86b9e2eac04023374b1075f19704f9409", + "pkg/csi/mount.go": "6a7df2e876a03577b49ec27d71b8592a73b7fd57087d7ad7f96723b7a54a7c17", + "pkg/csi/mount_integration_linux_test.go": "aef9cbaa5ef239ff822019a01f67b8286ba5e37ee0b6a953686d654e7dd3d297", + "pkg/csi/mount_linux.go": "48625654cd47a587f8ab731017f713264dade79954e4af8f5d7a41283619e50e", + "pkg/csi/mount_other.go": "073f9e964c326fa8be8d1403baebd728143669e686a2a0fc7be64144be6a9940", + "pkg/csi/mount_test.go": "629c2afdc46f1612b13888c3365333e3d1d157c5d69760c06ce37d8d1d892875", + "pkg/csi/pull_limit_test.go": "1af7f315462014b76adbc6db885029a4c99cbb1cd77a42645bd20feaced945df", + "pkg/csi/rebase_test.go": "3c3d9853f0f01cfa5b7c3c1f09e89053653a5b650f6590b384ba8221ce8422b6", + "pkg/csi/server.go": "9e6f096ee542c8d3d341673d39457d8f997ae525d38a9337f5acbea9a0737339", + "pkg/csi/server_test.go": "e46076f7cec0f80a9c2aff00349eb928f4ea21bf0242770289be22332c23f78d", + "pkg/dedup/report.go": "676a145bc4504b6e1c8b24ae244c447b0f3c2f61202d71e5889398a2414620db", + "pkg/dedup/report_test.go": "5b8f85886a14395a5173c5009c99b50ccf6d809a76e74437dd10ef61ccfb4c31", + "pkg/digest/digest.go": "6aa79c7bde9758ff165449d8718195b68041230256b0fb58776be76718037244", + "pkg/layout/layout.go": "84ad06189434722b8922f44b4372478500e89788aec30b4ffdc5d770ba0a05a9", + "pkg/layout/layout_test.go": "37eb17a2843e6591a4eca3b5024e8762e7c95812a77b5259da68409a2a808584", + "pkg/peer/discovery.go": "b2705b4b89c7acafc79647f72847cf3cfb73bf29cf31e44193f71ad5e2cabb62", + "pkg/peer/discovery_test.go": "8355f5254f07443800613818e200eb94d2a684e0195f35b3c1e3887aab600120", + "pkg/peer/peer.go": "2e75fbe429d43330ae47059918757cb12e216282db734e577599346ac1312690", + "pkg/peer/peer_test.go": "d167eef334ffa944b76125c7a2f9fd01b2331c7a7d3d15a31ee8f53c13e6556a", + "pkg/store/cache.go": "e47630e0becd8c91c61fe3cc3fbb78945a4b961014b30b9258bc514465b6875f", + "pkg/store/cache_test.go": "fc3da3957e3cc2c567a061b0a23b1d43505389e6a2fe4922e4fa5eec3a81c60e", + "pkg/store/codec_test.go": "99e00dbea1ba3a7c13b619b7b9b48854f7a8bab47bb40190182049629bb4d9c2", + "pkg/store/diff.go": "f17475422367a7dc844f72720c1dc8ed59192415ea41e17db3b1fcd69893552e", + "pkg/store/diff_test.go": "5d8c4a6cfbc973c378a847a3e10ccc8dc514ca46bba24365e64d183391427634", + "pkg/store/format.go": "6947db55457b8ca3e7dc98387408ca5b62f5af6c8f2e2b8503c3f161697bba7a", + "pkg/store/frame.go": "6044e4a404ed606eb6bd1c2e084b5acf98de8c0c941ada3c09be2246b3bed208", + "pkg/store/frame_test.go": "6f9a3eb6cd1d7cfb15b12e1bd4374cc9c34ed2eef2510e5373ca30a1378aedd7", + "pkg/store/gc.go": "23063c0552dfa3239c32a10c9506295d02d6ac8f56450b8f2fe8553f6c1c90bc", + "pkg/store/gc_test.go": "9e20e39db66fdba4c72bbca9653c83a8a693f004a06ad5fe754e4854809d8449", + "pkg/store/index.go": "dd418a1b5144347581ae8c9d63c931144c45694f60ec4ba638be5d8ca1fa1018", + "pkg/store/index_test.go": "464a52367346cc2148da72ccb2a99728043f037ec235e3aefc66944ddb3c9d19", + "pkg/store/locator.go": "5ca263586c372204059399e2cf8e7fcd90b482541b049c7322ae5c892e6a7469", + "pkg/store/locator_test.go": "73ed5207df2849e4c3ed25dd74684bb956a8096a3193d33c51b899f664d9c031", + "pkg/store/peers.go": "7b9f809c7fb2e17378233ec35080ed24d77fc0322ff696b7b9e35dfb2e593aa4", + "pkg/store/profile.go": "12698355a176296c46789e2ef8829ed34ab0db5060f9eb3860665c3b4cfb7f46", + "pkg/store/profile_test.go": "df7bcf41132153bb270d5a6481146d9192807d9032e8d78d292f0f070b89df63", + "pkg/store/pull.go": "95e783fa51f41201e2e816ce1793447fbe09dbc794215245a1cba5e678bbc026", + "pkg/store/pull_boundary_test.go": "23485635cb323d15494c7b79eeca8a6c84fbc7445cfb4a894920369f0eb94e0d", + "pkg/store/push.go": "6f045559d425c187d08b14f4e09771e666b5201c82f9ccb8804b9ba206f0981b", + "pkg/store/rebase.go": "673cc5f52f2ae2afae860a37464517380eb59b7cb452f31f1eb6032d5eccc4ac", + "pkg/store/rebase_test.go": "92f92cd4f84b8895550c9bb5a8cdb76a83c4644596621091c681d8f45a4560f4", + "pkg/store/source.go": "a61ba254991f28fc6c41cbb0b066091a326ba4ed3260f52a4b3f3b687cfb15a4", + "pkg/store/store.go": "d4936274c20ccb12abdd8ce801c0969e6db8d05b19f11ba16c8560acf83309e0", + "pkg/store/store_test.go": "89c7df2588448cfdb44b46c7feb050c82c9db17a8f799c9087fdbcdf5d31047c", + "pkg/store/sub_test.go": "c97d375544514bc4c3f4efb6ea22cec8c38d7f4dda9fdaf3f00399e49f4cba7d" + } + }, + "race_summary": { + "passed_tests_and_subtests": 151, + "skipped_tests": 10, + "failed_events": [] + }, + "regressions": { + "reverse_manifest_partial_suffix": "RED: a differs after pull; GREEN in final focused/race suite", + "private_staging_permissions": "RED: staging mode755 exposes restricted files; GREEN mode0700 throughout backend reads", + "locator_count": "RED: invalid count4294967295 returned after error; GREEN returns0 before allocation; no maximal allocation executed" + }, + "original_logs": [ + { + "path": "baseline.log", + "bytes": 1747, + "sha256": "be5ec9dd113dd4e8553947438655fee5c53cfbc02917e56da8b1f910130ea200" + }, + { + "path": "profile-red.log", + "bytes": 912, + "sha256": "11f1fed6357dd1e69e6f608792b4961150a61ad84be31ae13205fbe6780d3d8f" + }, + { + "path": "profile-green.log", + "bytes": 4299, + "sha256": "814835cbb49819955c8364a40aa08703225eddb987f70cc18aa6801f21c78a3e" + }, + { + "path": "prepare-red.log", + "bytes": 264, + "sha256": "eb89c62fa05ad15333a4b0002b5ffaff5bc1da5d0e8a2b52067b8a5e04b4fdca" + }, + { + "path": "focused-green.log", + "bytes": 6272, + "sha256": "5f41801b65752c14901e83955fdbe35b8b848e73bc8b57a2daee81de5586b44f" + }, + { + "path": "review-red.log", + "bytes": 727, + "sha256": "adce042067d9a406e3bb4c3133c29e484055e70717b2a95fb6799772c469afe3" + }, + { + "path": "race.log", + "bytes": 161570, + "sha256": "f510a583ea732ea17142e8792b66df0998b9fc817083a8b8466f7046e3b919fa" + }, + { + "path": "vet.log", + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "build.log", + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "suite-result.json", + "bytes": 6835, + "sha256": "d8840fca6b28cea14519f79ec42d97363592221c2f53a6084cad09c3b80de6b9" + } + ], + "cleanup": { + "owned_suite_runner_present": false, + "no_npu_allocation_or_sdk_changes": true, + "scope": "Persistent source/logs/cache retained in an isolated work directory; no owned running suite or benchmark job." + } +} diff --git a/docs/ko/compatible-prepare.md b/docs/ko/compatible-prepare.md index d8c6139..d42ed37 100644 --- a/docs/ko/compatible-prepare.md +++ b/docs/ko/compatible-prepare.md @@ -33,6 +33,9 @@ onggi prepare --store "$STORE" --request request.json \ 여러 후보가 일치하면 매니페스트 해시의 사전순으로 고릅니다. 후보 ref는 GC lease 안에서 한 번만 해석하며, 이후 ref가 바뀌어도 선택한 해시를 복원합니다. 없는 결과물이나 손상된 매니페스트를 조용히 건너뛰지는 않습니다. 재현 가능한 작업에는 해시를 넘기세요. +기존 GC lease 프로토콜(기본 TTL 24시간)을 재사용하며 새 읽기/쓰기 잠금은 아닙니다. +이미 시작된 GC 삭제와의 강한 상호 배제는 이번 구현에서 보장하지 않으므로 중요한 +준비 작업에는 불변 ref를 유지하고 GC 실행 시점을 조율하세요. 표준 출력은 JSON이며 `selection`에 선택·제외 이유, `ready`에 준비 결과가 담깁니다. 종료 코드 0은 성공, 1은 일치 후보 없음/운영 오류, 2는 잘못된 입력입니다. @@ -52,3 +55,6 @@ CSI가 관리하는 루트와 준비 디렉터리를 공유하지 마세요. 엔진을 시작합니다. `ready:true`는 **검증된 파일 준비 완료**이며 NPU 서빙 준비와 다릅니다. 엔진의 SDK·그래프 검증/적재·readiness는 이어서 수행해야 합니다. 클러스터 후보 탐색, CRD·컴파일 팜, 배치·확장 신호 연동과 NPU 예열은 후속 작업입니다. + +[CPU 검증·소스 해시](../data/compatible-prepare-20261030.json)에 전체 race 검사와 +리뷰 문제 세 건의 재현·수정 기록이 있습니다. 최종 커밋의 Go·실제 S3 CI는 PR에 기록합니다. diff --git a/docs/superpowers/plans/2026-10-30-compatible-prepare.md b/docs/superpowers/plans/2026-10-30-compatible-prepare.md index e39b5f4..174b2e3 100644 --- a/docs/superpowers/plans/2026-10-30-compatible-prepare.md +++ b/docs/superpowers/plans/2026-10-30-compatible-prepare.md @@ -43,8 +43,8 @@ Interface: prepare CLI per spec, always JSON report; request shares Profile v1. - [x] Implement prepare via existing Pull/Cache/Peers, sibling temp+mkdir lock, store GC lease and atomic rename; never use shared CSI Materializer.Init. - [x] GREEN focused tests, full race/vet/build; bilingual docs and examples. -- [ ] One independent branch review, fix important findings with RED->GREEN. -- [ ] Commit/push draft PR, verify exact-head CI, record final gate in PR. +- [x] One independent branch review, fix important findings with RED->GREEN. +- [x] Commit/push draft PR #71; final publication-head CI gate recorded in PR. ## Ledger @@ -74,3 +74,34 @@ Interface: prepare CLI per spec, always JSON report; request shares Profile v1. rather than silently trusting them; process-crash temp/lock recovery remains explicit and documented. This costs manual recovery/new destination on a crash, and avoids treating an unverified existing directory as ready. +- Final review at1f1ca21 found three Important issues, no Critical/Minor. + All reproduced together RED atee6e457: partial-suffix files overwritten in + reverse-order manifests; temporary tree mode0755; locator count4294967295 + returned despite error. Fixed c7e3910: private tree0700 until verification, + unique0600 staging filenames excluding all final/ancestor paths, duplicate + final-path refusal, and locator count0 on error before allocation. GREEN + focused regressions and full race/vet/build;151 test/subtest pass events, + 10 environment-dependent skips, no failed test/package. Maximal allocation + was never executed. Byte hashes for72 Go/source-dependency files independently + match local tested files. Evidence: docs/data/compatible-prepare-20261030.json. +- Final: Ruling on reviewer-declined publisher truth/omitted vendor dimensions, + ABI, native readiness and latency: metadata exact agreement only; publisher + records complete build identities/options and runtime validates artifacts. + No new native claim; the cost of inaccurate declarations is load rejection + or incorrect vendor compatibility, not something this selector can certify. +- Final: Ruling on hostile parent replacement/noncooperating writers: lock + coordinates cooperating preparers only; deployment uses owned directories. + Adversarial filesystem isolation requires a separate hardened filesystem API. +- Final: Ruling on crash durability/stale-lock recovery/existing adoption: + retain explicit documented operator recovery and refuse existing targets; + no crash-durable or automatic recovery claim. Cost: manual retry preparation. +- Final: Ruling on strong concurrent GC exclusion: reuse existing TTL/snapshot + lease protocol, not a reader/writer lock. Document24h default TTL and retain + immutable refs/coordinate critical GC; a future protocol change is separate. +- Final: Ruling on S3/Kubernetes/hardware review limits: both real S3 servers + passed CI41003744490 before fixes; final exact-head CI must pass again and + is recorded in the PR. Kubernetes example is syntax-checked, not deployed; + no NPU hardware operation, native warming or new performance claim. +- Owned CPU suite runner has exited; no NPU allocated. Keep isolated source/ + logs/cache for inspection. Final publication keeps the branch/worktree and + records CI in PR metadata rather than creating another CI-only commit. diff --git a/pkg/store/pull_boundary_test.go b/pkg/store/pull_boundary_test.go index ea98f17..6f7ff01 100644 --- a/pkg/store/pull_boundary_test.go +++ b/pkg/store/pull_boundary_test.go @@ -1,35 +1,61 @@ package store import ( - "bytes" - "context" - "encoding/binary" - "encoding/json" - "path/filepath" - "testing" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "path/filepath" + "testing" - "github.com/ai-computing/onggi/pkg/backend" - "github.com/ai-computing/onggi/pkg/digest" + "github.com/ai-computing/onggi/pkg/backend" + "github.com/ai-computing/onggi/pkg/digest" ) func TestPullPreservesPartialSuffixFilesInReverseManifestOrder(t *testing.T) { - s:=openStore(t,t.TempDir());src:=t.TempDir();ctx:=context.Background();p:=testProfile() - write(t,filepath.Join(src,"a"),[]byte("first file"));write(t,filepath.Join(src,"a.onggi-partial"),[]byte("different second file")) - d,_,err:=s.Push(ctx,src,PushOptions{Profile:&p});if err!=nil {t.Fatal(err)} - m,err:=s.ReadManifest(ctx,d);if err!=nil {t.Fatal(err)} - if len(m.Files)!=2 {t.Fatal("wrong fixture")};m.Files[0],m.Files[1]=m.Files[1],m.Files[0];m.Locator=nil - data,_:=json.Marshal(m);d=digest.Of(data) - if err=s.B.Put(ctx,backend.Key{Kind:backend.Manifest,Name:d.Hex()},bytes.NewReader(data),int64(len(data)));err!=nil{t.Fatal(err)} - out:=t.TempDir();if _,err=s.Pull(ctx,d,out,PullOptions{});err!=nil{t.Fatal(err)} - sameTree(t,src,out) + s := openStore(t, t.TempDir()) + src := t.TempDir() + ctx := context.Background() + p := testProfile() + write(t, filepath.Join(src, "a"), []byte("first file")) + write(t, filepath.Join(src, "a.onggi-partial"), []byte("different second file")) + d, _, err := s.Push(ctx, src, PushOptions{Profile: &p}) + if err != nil { + t.Fatal(err) + } + m, err := s.ReadManifest(ctx, d) + if err != nil { + t.Fatal(err) + } + if len(m.Files) != 2 { + t.Fatal("wrong fixture") + } + m.Files[0], m.Files[1] = m.Files[1], m.Files[0] + m.Locator = nil + data, _ := json.Marshal(m) + d = digest.Of(data) + if err = s.B.Put(ctx, backend.Key{Kind: backend.Manifest, Name: d.Hex()}, bytes.NewReader(data), int64(len(data))); err != nil { + t.Fatal(err) + } + out := t.TempDir() + if _, err = s.Pull(ctx, d, out, PullOptions{}); err != nil { + t.Fatal(err) + } + sameTree(t, src, out) } func TestLocatorCountRefusesAllocationAfterInvalidCount(t *testing.T) { - data:=make([]byte,4);binary.LittleEndian.PutUint32(data,^uint32(0)) - for _,min:=range []int{4,32,41} { - r:=reader{p:data};if n:=r.count(min);n!=0||r.err==nil {t.Fatalf("unsafe allocation count=%d, min=%d, err=%v",n,min,r.err)} - } - // A bounded malformed locator exercises the decoder without a dangerous allocation. - malformed:=append([]byte("ONGGILC1"),100,0,0,0) - if _,_,err:=readLocator(malformed,&Manifest{});err==nil {t.Fatal("truncated locator accepted")} + data := make([]byte, 4) + binary.LittleEndian.PutUint32(data, ^uint32(0)) + for _, min := range []int{4, 32, 41} { + r := reader{p: data} + if n := r.count(min); n != 0 || r.err == nil { + t.Fatalf("unsafe allocation count=%d, min=%d, err=%v", n, min, r.err) + } + } + // A bounded malformed locator exercises the decoder without a dangerous allocation. + malformed := append([]byte("ONGGILC1"), 100, 0, 0, 0) + if _, _, err := readLocator(malformed, &Manifest{}); err == nil { + t.Fatal("truncated locator accepted") + } }