From 4dd739cc08a883e39b0bdb295c1134583bbf0dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Sat, 19 Sep 2026 15:43:15 +0200 Subject: [PATCH] feat(registry): preserve release manifest --- apps/druid/adapters/cli/pull.go | 4 +- apps/druid/adapters/cli/push.go | 4 +- internal/core/services/registry/oci.go | 37 +++++++++++++- internal/core/services/registry/oci_test.go | 49 +++++++++++++++++++ .../services/registry/transfer_options.go | 8 +++ 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 internal/core/services/registry/transfer_options.go diff --git a/apps/druid/adapters/cli/pull.go b/apps/druid/adapters/cli/pull.go index 16c8e3df..6349731a 100644 --- a/apps/druid/adapters/cli/pull.go +++ b/apps/druid/adapters/cli/pull.go @@ -7,6 +7,7 @@ import ( ) var pullNoData bool +var pullPreserveReleaseManifest bool var PullCommand = &cobra.Command{ Use: "pull [dir]", @@ -21,7 +22,7 @@ var PullCommand = &cobra.Command{ registryClient := registry.NewOciClient(loadRegistryStore()) - err := registryClient.PullSelective(dir, artifact, !pullNoData, nil) + err := registryClient.PullSelectiveWithOptions(dir, artifact, !pullNoData, nil, registry.TransferOptions{PreserveReleaseManifest: pullPreserveReleaseManifest}) if err != nil { logger.Log().Error("Failed to pull from registry") return err @@ -35,4 +36,5 @@ var PullCommand = &cobra.Command{ func init() { RootCmd.AddCommand(PullCommand) PullCommand.Flags().BoolVar(&pullNoData, "no-data", false, "Skip scroll data files") + PullCommand.Flags().BoolVar(&pullPreserveReleaseManifest, "preserve-release-manifest", false, "Keep the release manifest.json stored in a backup artifact.") } diff --git a/apps/druid/adapters/cli/push.go b/apps/druid/adapters/cli/push.go index 43fb4d77..ba193651 100644 --- a/apps/druid/adapters/cli/push.go +++ b/apps/druid/adapters/cli/push.go @@ -22,6 +22,7 @@ var pushScrollPorts []string var pushPackMeta bool var pushSmart bool var pushCategory string +var pushPreserveReleaseManifest bool var pushDisableTarReproducible bool var PushCommand = &cobra.Command{ @@ -96,7 +97,7 @@ var PushCommand = &cobra.Command{ overrides[fmt.Sprintf("gg.druid.scroll.port.%s", name)] = port } - _, err = ociClient.Push(fullPath, repo, tag, overrides, pushPackMeta, &scroll.File) + _, err = ociClient.PushWithOptions(fullPath, repo, tag, overrides, pushPackMeta, &scroll.File, registry.TransferOptions{PreserveReleaseManifest: pushPreserveReleaseManifest}) if err != nil { return err } @@ -116,5 +117,6 @@ func init() { PushCommand.Flags().StringVarP(&pushImage, "image", "i", pushImage, "Image to use for the scroll. (Will be added as a manifest annotation gg.druid.scroll.image)") PushCommand.Flags().StringSliceVarP(&pushScrollPorts, "port", "p", pushScrollPorts, "Ports to expose. Format webserver=80, dns=53/udp or just minecraft (Will be added as a manifest annotation gg.druid.scroll.ports.)") PushCommand.Flags().BoolVarP(&pushPackMeta, "pack-meta", "m", pushPackMeta, "Pack the meta folder into the scroll.") + PushCommand.Flags().BoolVar(&pushPreserveReleaseManifest, "preserve-release-manifest", false, "Include the installed release manifest.json in this snapshot.") PushCommand.PersistentFlags().BoolVar(&pushDisableTarReproducible, "no-tar-reproducible", false, "Preserve file timestamps in pushed tar layers.") } diff --git a/internal/core/services/registry/oci.go b/internal/core/services/registry/oci.go index 9d9dbdc6..965dac64 100644 --- a/internal/core/services/registry/oci.go +++ b/internal/core/services/registry/oci.go @@ -162,6 +162,10 @@ func (c *OciClient) Pull(dir string, artifact string) error { } func (c *OciClient) PullSelective(dir string, artifact string, includeData bool, progress *domain.SnapshotProgress) error { + return c.PullSelectiveWithOptions(dir, artifact, includeData, progress, TransferOptions{}) +} + +func (c *OciClient) PullSelectiveWithOptions(dir string, artifact string, includeData bool, progress *domain.SnapshotProgress, options TransferOptions) error { repo, ref, _ := utils.ParseArtifactRef(artifact) if repo == "" || ref == "" { @@ -334,8 +338,19 @@ func (c *OciClient) PullSelective(dir string, artifact string, includeData bool, annotations := fullDesc.Annotations fileName := filepath.Join(dir, "manifest.json") - err = os.WriteFile(fileName, jsonData, 0644) - if err != nil { + if options.PreserveReleaseManifest { + preserved, readErr := os.ReadFile(fileName) + if readErr != nil { + return fmt.Errorf("preserve-release-manifest requires manifest.json in artifact: %w", readErr) + } + var descriptor v1.Descriptor + if err := json.Unmarshal(preserved, &descriptor); err != nil || descriptor.Digest.String() == "" { + if err == nil { + err = fmt.Errorf("descriptor digest is empty") + } + return fmt.Errorf("preserve-release-manifest requires a valid manifest.json in artifact: %w", err) + } + } else if err = os.WriteFile(fileName, jsonData, 0644); err != nil { return fmt.Errorf("failed to write manifest descriptor: %w", err) } @@ -746,6 +761,10 @@ func copyToRegistry(ctx context.Context, fs *file.Store, repo *remote.Repository } func (c *OciClient) Push(folder string, repo string, tag string, overrides map[string]string, packMeta bool, scrollFile *domain.File) (v1.Descriptor, error) { + return c.PushWithOptions(folder, repo, tag, overrides, packMeta, scrollFile, TransferOptions{}) +} + +func (c *OciClient) PushWithOptions(folder string, repo string, tag string, overrides map[string]string, packMeta bool, scrollFile *domain.File, options TransferOptions) (v1.Descriptor, error) { ctx := context.Background() // Authenticate before doing any expensive local work. @@ -777,6 +796,20 @@ func (c *OciClient) Push(folder string, repo string, tag string, overrides map[s if len(fsFileNames) == 0 { return v1.Descriptor{}, fmt.Errorf("no files found to push") } + if options.PreserveReleaseManifest { + manifest, err := os.ReadFile(filepath.Join(folder, "manifest.json")) + if err != nil { + return v1.Descriptor{}, fmt.Errorf("preserve-release-manifest requires manifest.json: %w", err) + } + var descriptor v1.Descriptor + if err := json.Unmarshal(manifest, &descriptor); err != nil || descriptor.Digest.String() == "" { + if err == nil { + err = fmt.Errorf("descriptor digest is empty") + } + return v1.Descriptor{}, fmt.Errorf("preserve-release-manifest requires a valid manifest.json: %w", err) + } + fsFileNames = append(fsFileNames, "manifest.json") + } fs, err := c.newFileStore(folder) if err != nil { diff --git a/internal/core/services/registry/oci_test.go b/internal/core/services/registry/oci_test.go index 2179b3d0..8efc65c9 100644 --- a/internal/core/services/registry/oci_test.go +++ b/internal/core/services/registry/oci_test.go @@ -294,6 +294,55 @@ func TestPushPullExecutableDataChunkPreservesMode(t *testing.T) { } } +func TestPreserveReleaseManifestRoundTrip(t *testing.T) { + t.Setenv("DRUID_REGISTRY_PLAIN_HTTP", "true") + srv := fakeRegistry(t) + registryHost := strings.TrimPrefix(srv.URL, "http://") + folder := t.TempDir() + if err := os.WriteFile(filepath.Join(folder, "scroll.yaml"), []byte("name: test\nversion: 0.1.0\napp_version: test\n"), 0644); err != nil { + t.Fatal(err) + } + want := []byte(`{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":123}`) + if err := os.WriteFile(filepath.Join(folder, "manifest.json"), want, 0644); err != nil { + t.Fatal(err) + } + client := &OciClient{credentialStore: NewCredentialStore(nil), plainHTTP: true} + repo := registryHost + "/test/preserve-release" + if _, err := client.PushWithOptions(folder, repo, "backup", nil, false, nil, TransferOptions{PreserveReleaseManifest: true}); err != nil { + t.Fatalf("push backup: %v", err) + } + pullDir := t.TempDir() + if err := client.PullSelectiveWithOptions(pullDir, repo+":backup", true, nil, TransferOptions{PreserveReleaseManifest: true}); err != nil { + t.Fatalf("restore backup: %v", err) + } + got, err := os.ReadFile(filepath.Join(pullDir, "manifest.json")) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("restored release manifest = %s, want %s", got, want) + } +} + +func TestPreserveReleaseManifestRequiresSnapshotDescriptor(t *testing.T) { + t.Setenv("DRUID_REGISTRY_PLAIN_HTTP", "true") + srv := fakeRegistry(t) + registryHost := strings.TrimPrefix(srv.URL, "http://") + folder := t.TempDir() + if err := os.WriteFile(filepath.Join(folder, "scroll.yaml"), []byte("name: test\nversion: 0.1.0\napp_version: test\n"), 0644); err != nil { + t.Fatal(err) + } + client := &OciClient{credentialStore: NewCredentialStore(nil), plainHTTP: true} + repo := registryHost + "/test/missing-release" + if _, err := client.Push(folder, repo, "release", nil, false, nil); err != nil { + t.Fatalf("push normal release: %v", err) + } + err := client.PullSelectiveWithOptions(t.TempDir(), repo+":release", true, nil, TransferOptions{PreserveReleaseManifest: true}) + if err == nil || !strings.Contains(err.Error(), "preserve-release-manifest requires manifest.json") { + t.Fatalf("preserving missing descriptor error = %v", err) + } +} + func TestFetchFileReadsScrollYAMLDescriptor(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) diff --git a/internal/core/services/registry/transfer_options.go b/internal/core/services/registry/transfer_options.go new file mode 100644 index 00000000..3bb7731e --- /dev/null +++ b/internal/core/services/registry/transfer_options.go @@ -0,0 +1,8 @@ +package registry + +// TransferOptions controls whether a runtime release descriptor travels with a +// snapshot. Normal Scroll publication excludes manifest.json because it +// describes a local installation, not the artifact being published. +type TransferOptions struct { + PreserveReleaseManifest bool +}