Skip to content

Commit 798ee22

Browse files
feat(oci): support digest-pinned imports
Accept sha256 manifest references in import and diff, prefer a digest when both tag and digest are supplied, and persist the resolved OCI identity on imported workspaces. Pin the CLI extraction pass to the manifest resolved during its initial check so a concurrent tag move cannot change the imported bundle.
1 parent 75dfa23 commit 798ee22

21 files changed

Lines changed: 743 additions & 67 deletions

cmd/nebi/bundle_api_e2e_test.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ func TestE2E_BundlePublishImportViaAPI_LocalMode(t *testing.T) {
165165
writeFile("notebook.ipynb", notebookBody)
166166

167167
reg := oci.Registry{Host: ociHost, Namespace: ociNS, PlainHTTP: true}
168-
if _, err := oci.Publish(ctx, srcDir, reg, repoName, bundleTag); err != nil {
168+
published, err := oci.Publish(ctx, srcDir, reg, repoName, bundleTag)
169+
if err != nil {
169170
t.Fatalf("seed oci.Publish: %v", err)
170171
}
171172

@@ -178,11 +179,15 @@ func TestE2E_BundlePublishImportViaAPI_LocalMode(t *testing.T) {
178179
})
179180

180181
// ---- Import via POST /registries/:id/import ----
181-
wsID := importViaAPI(t, serverURL, token, registryID, map[string]interface{}{
182+
imported := importViaAPI(t, serverURL, token, registryID, map[string]interface{}{
182183
"repository_path": ociNS + "/" + repoName,
183-
"tag": bundleTag,
184+
"digest": published.Digest,
184185
"name": "notebook-imported",
185186
})
187+
if imported.ImportRepository != published.Repository || imported.ImportTag != "" || imported.ImportDigest != published.Digest {
188+
t.Fatalf("unexpected import metadata: repository=%q tag=%q digest=%q", imported.ImportRepository, imported.ImportTag, imported.ImportDigest)
189+
}
190+
wsID := imported.ID
186191

187192
// ---- Poll workspace until ready (or fail after 30s) ----
188193
pollWorkspaceReady(t, serverURL, token, wsID, 30*time.Second)
@@ -337,8 +342,15 @@ func createRegistryViaAPI(t *testing.T, serverURL, token string, body map[string
337342
return result.ID
338343
}
339344

340-
// importViaAPI POSTs to /registries/:id/import and returns the created workspace ID.
341-
func importViaAPI(t *testing.T, serverURL, token, registryID string, body map[string]interface{}) string {
345+
type importedWorkspace struct {
346+
ID string `json:"id"`
347+
ImportRepository string `json:"import_repository"`
348+
ImportTag string `json:"import_tag"`
349+
ImportDigest string `json:"import_digest"`
350+
}
351+
352+
// importViaAPI POSTs to /registries/:id/import and returns the created workspace.
353+
func importViaAPI(t *testing.T, serverURL, token, registryID string, body map[string]interface{}) importedWorkspace {
342354
t.Helper()
343355
b, _ := json.Marshal(body)
344356
url := fmt.Sprintf("%s/api/v1/registries/%s/import", serverURL, registryID)
@@ -354,16 +366,14 @@ func importViaAPI(t *testing.T, serverURL, token, registryID string, body map[st
354366
if resp.StatusCode != http.StatusCreated {
355367
t.Fatalf("POST /registries/%s/import: status %d, body: %s", registryID, resp.StatusCode, raw)
356368
}
357-
var result struct {
358-
ID string `json:"id"`
359-
}
369+
var result importedWorkspace
360370
if err := json.Unmarshal(raw, &result); err != nil {
361371
t.Fatalf("decode import response: %v (body: %s)", err, raw)
362372
}
363373
if result.ID == "" {
364374
t.Fatalf("import response missing id: %s", raw)
365375
}
366-
return result.ID
376+
return result
367377
}
368378

369379
// pollWorkspaceReady polls GET /workspaces/:id until status == "ready" or times out.

cmd/nebi/bundle_e2e_test.go

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"testing"
1313

1414
"github.com/google/go-containerregistry/pkg/registry"
15+
"github.com/nebari-dev/nebi/internal/oci"
16+
"github.com/nebari-dev/nebi/internal/store"
1517
)
1618

1719
// startE2ERegistry spins up an in-memory OCI Distribution server. The
@@ -27,9 +29,9 @@ func startE2ERegistry(t *testing.T) string {
2729
return u.Host
2830
}
2931

30-
// publishedRefRE extracts the "<host>/<ns>/<repo>:<tag>" fragment from
32+
// publishedRefRE extracts the tag reference and resolved manifest digest from
3133
// publish stderr lines that look like `Published <ref> (digest: sha256:...)`.
32-
var publishedRefRE = regexp.MustCompile(`Published (\S+) \(digest:`)
34+
var publishedRefRE = regexp.MustCompile(`Published (\S+) \(digest: (sha256:[a-f0-9]{64})\)`)
3335

3436
// TestE2E_LocalBundlePublishImport runs a full publish --local → import
3537
// round trip against an in-memory OCI registry. Verifies that pixi.toml,
@@ -146,6 +148,84 @@ func TestE2E_LocalBundlePublishImport(t *testing.T) {
146148
if !strings.Contains(string(gotLock), "version: 6") {
147149
t.Fatalf("imported pixi.lock missing expected content:\n%s", gotLock)
148150
}
151+
152+
artifact, err := oci.ParseArtifactReference(ref)
153+
if err != nil {
154+
t.Fatalf("parse published reference: %v", err)
155+
}
156+
s, err := store.Open(dataDir)
157+
if err != nil {
158+
t.Fatalf("open local store: %v", err)
159+
}
160+
defer s.Close()
161+
ws, err := s.FindWorkspaceByPath(outDir)
162+
if err != nil {
163+
t.Fatalf("find imported workspace: %v", err)
164+
}
165+
if ws == nil {
166+
t.Fatal("imported workspace was not tracked")
167+
}
168+
if ws.ImportRepository != artifact.Repository || ws.ImportTag != artifact.Tag || ws.ImportDigest != m[2] {
169+
t.Fatalf("unexpected tag import metadata: repository=%q tag=%q digest=%q", ws.ImportRepository, ws.ImportTag, ws.ImportDigest)
170+
}
171+
}
172+
173+
func TestE2E_LocalBundleImportAndDiffByDigest(t *testing.T) {
174+
dataDir := t.TempDir()
175+
t.Setenv("NEBI_DATA_DIR", dataDir)
176+
177+
regHost := startE2ERegistry(t)
178+
srcDir := t.TempDir()
179+
writePixiFiles(t, srcDir,
180+
"[project]\nname = \"digest-e2e\"\nchannels = [\"conda-forge\"]\nplatforms = [\"linux-64\"]\n",
181+
"version: 6\n",
182+
)
183+
published, err := oci.Publish(
184+
t.Context(),
185+
srcDir,
186+
oci.Registry{Host: regHost, Namespace: "demo", PlainHTTP: true},
187+
"digest-e2e",
188+
"reviewed",
189+
)
190+
if err != nil {
191+
t.Fatalf("publish bundle: %v", err)
192+
}
193+
194+
digestRef := "http://" + published.Repository + "@" + published.Digest
195+
outParent := t.TempDir()
196+
outDir := filepath.Join(outParent, "restored")
197+
res := runCLI(t, outParent, "import", digestRef, "--output", outDir)
198+
if res.ExitCode != 0 {
199+
t.Fatalf("digest import failed:\nstdout: %s\nstderr: %s", res.Stdout, res.Stderr)
200+
}
201+
202+
s, err := store.Open(dataDir)
203+
if err != nil {
204+
t.Fatalf("open local store: %v", err)
205+
}
206+
defer s.Close()
207+
ws, err := s.FindWorkspaceByPath(outDir)
208+
if err != nil {
209+
t.Fatalf("find imported workspace: %v", err)
210+
}
211+
if ws == nil {
212+
t.Fatal("imported workspace was not tracked")
213+
}
214+
if ws.ImportRepository != published.Repository || ws.ImportTag != "" || ws.ImportDigest != published.Digest {
215+
t.Fatalf("unexpected import metadata: repository=%q tag=%q digest=%q", ws.ImportRepository, ws.ImportTag, ws.ImportDigest)
216+
}
217+
218+
modifiedToml := "[project]\nname = \"digest-e2e\"\nchannels = [\"conda-forge\"]\nplatforms = [\"linux-64\"]\n\n[dependencies]\nrequests = \"*\"\n"
219+
if err := os.WriteFile(filepath.Join(outDir, "pixi.toml"), []byte(modifiedToml), 0o644); err != nil {
220+
t.Fatalf("modify imported pixi.toml: %v", err)
221+
}
222+
res = runCLI(t, outDir, "diff", digestRef)
223+
if res.ExitCode != 0 {
224+
t.Fatalf("digest diff failed:\nstdout: %s\nstderr: %s", res.Stdout, res.Stderr)
225+
}
226+
if !strings.Contains(res.Stdout, digestRef) || !strings.Contains(res.Stdout, "requests") {
227+
t.Fatalf("digest diff did not compare against the OCI artifact: stdout=%q stderr=%q", res.Stdout, res.Stderr)
228+
}
149229
}
150230

151231
// TestE2E_LocalBundleRejectsNonEmptyDest verifies that importing a bundle

cmd/nebi/client.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,34 @@ func saveOrigin(remoteID, name, tag, action, tomlContent, lockContent string) er
224224
return s.SaveWorkspace(ws)
225225
}
226226

227+
// saveImportMetadata records the immutable OCI identity that seeded a local
228+
// workspace. The workspace must already have been registered by ensureInit.
229+
func saveImportMetadata(dir, repository, tag, manifestDigest string) error {
230+
absDir, err := filepath.Abs(dir)
231+
if err != nil {
232+
return fmt.Errorf("resolving import directory: %w", err)
233+
}
234+
235+
s, err := store.New()
236+
if err != nil {
237+
return err
238+
}
239+
defer s.Close()
240+
241+
ws, err := s.FindWorkspaceByPath(absDir)
242+
if err != nil {
243+
return err
244+
}
245+
if ws == nil {
246+
return fmt.Errorf("imported workspace at %s is not tracked", absDir)
247+
}
248+
249+
ws.ImportRepository = repository
250+
ws.ImportTag = tag
251+
ws.ImportDigest = manifestDigest
252+
return s.SaveWorkspace(ws)
253+
}
254+
227255
// parseWsRef parses a reference in the format workspace:tag.
228256
// Returns (workspace, tag) where tag may be empty if not specified.
229257
func parseWsRef(ref string) (string, string) {

cmd/nebi/diff.go

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/nebari-dev/nebi/internal/cliclient"
1111
"github.com/nebari-dev/nebi/internal/diff"
12+
"github.com/nebari-dev/nebi/internal/oci"
1213
"github.com/nebari-dev/nebi/internal/store"
1314
"github.com/spf13/cobra"
1415
)
@@ -23,6 +24,7 @@ Each reference can be:
2324
- A path (contains a slash): ./dir, /tmp/project, foo/bar
2425
- A tracked workspace name (bare word): data-science
2526
- A server ref (contains a colon): myworkspace:v1
27+
- An OCI ref: quay.io/nebari/my-env:v1 or quay.io/nebari/my-env@sha256:<digest>
2628
2729
If no refs are given, compares the current directory against the last
2830
pushed/pulled origin.
@@ -37,6 +39,7 @@ Examples:
3739
nebi diff myworkspace:v1 # server version vs cwd
3840
nebi diff myworkspace:v1 myworkspace:v2 # two server versions
3941
nebi diff myworkspace:v1 ./local-dir # server vs local dir
42+
nebi diff quay.io/nebari/my-env@sha256:<digest>
4043
4144
Use --lock to also compare pixi.lock files.`,
4245
Args: cobra.RangeArgs(0, 2),
@@ -140,12 +143,36 @@ func runDiff(cmd *cobra.Command, args []string) error {
140143

141144
// resolveSource resolves a ref (directory, workspace name, or workspace:tag) into a diffSource.
142145
func resolveSource(ref, defaultLabel string) (*diffSource, error) {
143-
// 1. Local directory path (must contain a slash, e.g. ./foo, /tmp/foo, foo/bar)
146+
// 1. Explicit local directory path.
147+
if isExplicitLocalPath(ref) {
148+
return resolveLocalSource(ref, defaultLabel)
149+
}
150+
151+
// Preserve the existing path semantics when a registry-looking relative
152+
// path exists on disk. Prefix a non-existent path with ./ to force it to
153+
// remain local.
154+
if isPath(ref) {
155+
if _, err := os.Stat(ref); err == nil || !os.IsNotExist(err) {
156+
return resolveLocalSource(ref, defaultLabel)
157+
}
158+
}
159+
160+
// 2. Fully-qualified OCI reference. Registry-like tag references and all
161+
// digest references are unambiguous; simple foo/bar remains a local path.
162+
if looksLikeOCIReference(ref) {
163+
artifact, err := oci.ParseArtifactReference(ref)
164+
if err != nil {
165+
return nil, err
166+
}
167+
return resolveOCISource(ref, artifact)
168+
}
169+
170+
// 3. Local directory path (must contain a slash, e.g. foo/bar).
144171
if isPath(ref) {
145172
return resolveLocalSource(ref, defaultLabel)
146173
}
147174

148-
// 2. Local workspace name (check store before assuming server ref)
175+
// 4. Local workspace name (check store before assuming server ref)
149176
if !strings.Contains(ref, ":") {
150177
s, err := store.New()
151178
if err == nil {
@@ -166,10 +193,49 @@ func resolveSource(ref, defaultLabel string) (*diffSource, error) {
166193
}
167194
}
168195

169-
// 3. Server ref (workspace:tag)
196+
// 5. Server ref (workspace:tag)
170197
return resolveServerSource(ref)
171198
}
172199

200+
func isExplicitLocalPath(ref string) bool {
201+
return ref == "." ||
202+
ref == ".." ||
203+
filepath.IsAbs(ref) ||
204+
strings.HasPrefix(ref, "./") ||
205+
strings.HasPrefix(ref, "../")
206+
}
207+
208+
func looksLikeOCIReference(ref string) bool {
209+
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") || strings.Contains(ref, "@") {
210+
return true
211+
}
212+
213+
slash := strings.IndexByte(ref, '/')
214+
if slash <= 0 {
215+
return false
216+
}
217+
registryHost := ref[:slash]
218+
return registryHost == "localhost" || strings.ContainsAny(registryHost, ".:")
219+
}
220+
221+
func resolveOCISource(label string, artifact oci.ArtifactReference) (*diffSource, error) {
222+
result, err := oci.PullBundle(context.Background(), artifact.Repository, artifact.Selector(), oci.PullOptions{
223+
PlainHTTP: artifact.PlainHTTP,
224+
})
225+
if err != nil {
226+
return nil, fmt.Errorf("pulling OCI bundle: %w", err)
227+
}
228+
if err := oci.VerifyManifestDigest(result.Digest, artifact.Digest); err != nil {
229+
return nil, err
230+
}
231+
232+
return &diffSource{
233+
label: label,
234+
toml: result.PixiToml,
235+
lock: result.PixiLock,
236+
}, nil
237+
}
238+
173239
func resolveLocalSource(dir, defaultLabel string) (*diffSource, error) {
174240
absDir, err := filepath.Abs(dir)
175241
if err != nil {

0 commit comments

Comments
 (0)