Skip to content

Commit 89b8f95

Browse files
committed
create manifest from store contents
1 parent 49a781c commit 89b8f95

3 files changed

Lines changed: 364 additions & 0 deletions

File tree

cmd/hauler/cli/store.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func addStore(parent *cobra.Command, ro *flags.CliRootOpts) {
3434
addStoreCopy(rso, ro),
3535
addStoreAdd(rso, ro),
3636
addStoreRemove(rso, ro),
37+
addStoreCreate(rso, ro),
3738
)
3839

3940
parent.AddCommand(cmd)
@@ -419,6 +420,50 @@ func addStoreAddChart(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Co
419420
return cmd
420421
}
421422

423+
func addStoreCreate(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Command {
424+
cmd := &cobra.Command{
425+
Use: "create",
426+
Short: "Create content derived from the store",
427+
RunE: func(cmd *cobra.Command, args []string) error {
428+
return cmd.Help()
429+
},
430+
}
431+
432+
cmd.AddCommand(
433+
addStoreCreateManifest(rso, ro),
434+
)
435+
436+
return cmd
437+
}
438+
439+
func addStoreCreateManifest(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Command {
440+
o := &flags.CreateManifestOpts{StoreRootOpts: rso}
441+
442+
cmd := &cobra.Command{
443+
Use: "manifest",
444+
Short: "Create a hauler content manifest from the store's metadata",
445+
Example: ` # generate a manifest for the default store into ./hauler-manifest.yaml
446+
hauler store create manifest
447+
448+
# generate a manifest for a specific store into a custom path
449+
hauler store create manifest --store /path/to/store --output my-manifest.yaml`,
450+
Args: cobra.ExactArgs(0),
451+
RunE: func(cmd *cobra.Command, args []string) error {
452+
ctx := cmd.Context()
453+
454+
s, err := o.Store(ctx, ro)
455+
if err != nil {
456+
return err
457+
}
458+
459+
return store.CreateManifestCmd(ctx, o, s)
460+
},
461+
}
462+
o.AddFlags(cmd)
463+
464+
return cmd
465+
}
466+
422467
func addStoreRemove(rso *flags.StoreRootOpts, ro *flags.CliRootOpts) *cobra.Command {
423468
o := &flags.RemoveOpts{}
424469
cmd := &cobra.Command{

cmd/hauler/cli/store/create.go

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
package store
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
gname "github.com/google/go-containerregistry/pkg/name"
12+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
13+
"gopkg.in/yaml.v3"
14+
15+
"hauler.dev/go/hauler/v2/internal/flags"
16+
"hauler.dev/go/hauler/v2/pkg/consts"
17+
"hauler.dev/go/hauler/v2/pkg/log"
18+
"hauler.dev/go/hauler/v2/pkg/store"
19+
)
20+
21+
// manifestImage, manifestChart, and manifestFile mirror the relevant fields of
22+
// v1.Image/v1.Chart/v1.File, but keep only what can be confidently recovered from the
23+
// store's metadata and use "omitempty" throughout (unlike the api types, which most
24+
// callers unmarshal rather than marshal) so the generated manifest stays readable
25+
// instead of listing every unset flag.
26+
type manifestImage struct {
27+
Name string `yaml:"name"`
28+
Platform string `yaml:"platform,omitempty"`
29+
Rewrite string `yaml:"rewrite,omitempty"`
30+
}
31+
32+
type manifestChart struct {
33+
Name string `yaml:"name"`
34+
RepoURL string `yaml:"repoURL,omitempty"`
35+
Version string `yaml:"version,omitempty"`
36+
Rewrite string `yaml:"rewrite,omitempty"`
37+
}
38+
39+
type manifestFile struct {
40+
Path string `yaml:"path"`
41+
Name string `yaml:"name,omitempty"`
42+
}
43+
44+
type manifestMetadata struct {
45+
Name string `yaml:"name"`
46+
}
47+
48+
type manifestDoc struct {
49+
APIVersion string `yaml:"apiVersion"`
50+
Kind string `yaml:"kind"`
51+
Metadata manifestMetadata `yaml:"metadata"`
52+
Spec interface{} `yaml:"spec"`
53+
}
54+
55+
// CreateManifestCmd walks the store's OCI index (and the manifests/configs it
56+
// references) to reconstruct a hauler content manifest capable of recreating the
57+
// store's contents via `hauler store sync`. It groups discovered content into
58+
// Images/Charts/Files documents and writes them to o.Output.
59+
func CreateManifestCmd(ctx context.Context, o *flags.CreateManifestOpts, s *store.Layout) error {
60+
l := log.FromContext(ctx)
61+
62+
var images []manifestImage
63+
var charts []manifestChart
64+
var files []manifestFile
65+
chartsMissingRepoURL := false
66+
67+
if err := s.Walk(func(_ string, desc ocispec.Descriptor) error {
68+
refName, ok := desc.Annotations[ocispec.AnnotationRefName]
69+
if !ok {
70+
return nil
71+
}
72+
73+
kind := desc.Annotations[consts.KindAnnotationName]
74+
switch {
75+
case kind == consts.KindAnnotationSigs, kind == consts.KindAnnotationAtts, kind == consts.KindAnnotationSboms:
76+
// cosign-related artifacts are rediscovered automatically when the
77+
// parent image is re-added, so they don't need their own entry.
78+
return nil
79+
case strings.HasPrefix(kind, consts.KindAnnotationReferrers):
80+
return nil
81+
}
82+
83+
// Container images (both single-platform and multi-arch indexes) carry the
84+
// full OCI reference under this annotation; charts and files never do.
85+
if fullRef, isImage := desc.Annotations[consts.ContainerdImageNameKey]; isImage {
86+
name := fullRef
87+
rewrite := ""
88+
if orig, ok := desc.Annotations[consts.OriginalRefAnnotation]; ok && orig != "" && orig != fullRef {
89+
// The current ref differs from what was captured at the initial add,
90+
// meaning --rewrite changed it since. Recover the original, pullable
91+
// name and reapply the same rewrite so a resync reproduces this exact
92+
// store layout. If there's no annotation at all (a store from before
93+
// this was tracked) or it matches fullRef (never rewritten), fullRef
94+
// is already the right, pullable name.
95+
rewrite = fullRef
96+
name = orig
97+
}
98+
99+
img := manifestImage{Name: name, Rewrite: rewrite}
100+
if kind == consts.KindAnnotationImage {
101+
// Only a single-platform manifest has an unambiguous platform to pin.
102+
// A stored multi-arch index is left unset so a future sync re-pulls
103+
// every platform, matching what's actually in the store.
104+
platform, err := imagePlatform(ctx, s, desc)
105+
if err != nil {
106+
l.Warnf("could not determine platform for image [%s]: %v", name, err)
107+
} else if platform != "" {
108+
img.Platform = platform
109+
}
110+
}
111+
images = append(images, img)
112+
return nil
113+
}
114+
115+
rc, err := s.Fetch(ctx, desc)
116+
if err != nil {
117+
return fmt.Errorf("fetching manifest for [%s]: %w", refName, err)
118+
}
119+
defer rc.Close()
120+
121+
var m ocispec.Manifest
122+
if err := json.NewDecoder(rc).Decode(&m); err != nil {
123+
return fmt.Errorf("decoding manifest for [%s]: %w", refName, err)
124+
}
125+
126+
ref, err := gname.ParseReference(refName)
127+
if err != nil {
128+
return fmt.Errorf("parsing reference [%s]: %w", refName, err)
129+
}
130+
name := strings.TrimPrefix(ref.Context().RepositoryStr(), consts.DefaultNamespace+"/")
131+
132+
switch m.Config.MediaType {
133+
case consts.ChartConfigMediaType:
134+
version := ref.Identifier()
135+
if tag, ok := ref.(gname.Tag); ok {
136+
version = tag.TagStr()
137+
}
138+
139+
repoURL := ""
140+
rewrite := ""
141+
if orig, ok := desc.Annotations[consts.OriginalRefAnnotation]; ok && orig != "" {
142+
origRepoURL, origTotal := decodeOriginalChartRef(orig)
143+
repoURL = origRepoURL
144+
if origTotal != "" && origTotal != refName {
145+
// The current ref differs from what was captured at the initial
146+
// add, meaning --rewrite changed it since. Recover the original,
147+
// pullable name/version and reapply the same rewrite so a resync
148+
// reproduces this exact store layout.
149+
rewrite = refName
150+
if origRef, err := gname.ParseReference(origTotal); err == nil {
151+
name = strings.TrimPrefix(origRef.Context().RepositoryStr(), consts.DefaultNamespace+"/")
152+
version = origRef.Identifier()
153+
if tag, ok := origRef.(gname.Tag); ok {
154+
version = tag.TagStr()
155+
}
156+
}
157+
}
158+
}
159+
160+
charts = append(charts, manifestChart{Name: name, RepoURL: repoURL, Version: version, Rewrite: rewrite})
161+
if repoURL == "" {
162+
chartsMissingRepoURL = true
163+
}
164+
165+
case consts.FileLocalConfigMediaType, consts.FileHttpConfigMediaType, consts.FileDirectoryConfigMediaType:
166+
path := name
167+
if orig, ok := desc.Annotations[consts.OriginalRefAnnotation]; ok && orig != "" {
168+
path = orig
169+
}
170+
files = append(files, manifestFile{Path: path, Name: name})
171+
172+
default:
173+
l.Warnf("skipping unrecognized artifact [%s] with config media type [%s]", refName, m.Config.MediaType)
174+
}
175+
176+
return nil
177+
}); err != nil {
178+
return err
179+
}
180+
181+
if len(images) == 0 && len(charts) == 0 && len(files) == 0 {
182+
return fmt.Errorf("store contains no content to build a manifest from")
183+
}
184+
185+
base := sanitizeName(filepath.Base(s.Root))
186+
187+
var out strings.Builder
188+
if len(images) > 0 {
189+
if err := writeDoc(&out, "", consts.ImagesContentKind, base+"-images", struct {
190+
Images []manifestImage `yaml:"images"`
191+
}{images}); err != nil {
192+
return err
193+
}
194+
}
195+
if len(charts) > 0 {
196+
header := ""
197+
if chartsMissingRepoURL {
198+
header = "# NOTE: repoURL could not be recovered from the store's metadata and must be filled in below.\n"
199+
}
200+
if err := writeDoc(&out, header, consts.ChartsContentKind, base+"-charts", struct {
201+
Charts []manifestChart `yaml:"charts"`
202+
}{charts}); err != nil {
203+
return err
204+
}
205+
}
206+
if len(files) > 0 {
207+
if err := writeDoc(&out, "", consts.FilesContentKind, base+"-files", struct {
208+
Files []manifestFile `yaml:"files"`
209+
}{files}); err != nil {
210+
return err
211+
}
212+
}
213+
214+
if err := os.WriteFile(o.Output, []byte(out.String()), 0o644); err != nil {
215+
return fmt.Errorf("writing manifest to [%s]: %w", o.Output, err)
216+
}
217+
218+
outPath := o.Output
219+
if abs, err := filepath.Abs(o.Output); err == nil {
220+
outPath = abs
221+
}
222+
l.Infof("wrote manifest with [%d] image(s), [%d] chart(s), [%d] file(s) to [%s]", len(images), len(charts), len(files), outPath)
223+
224+
return nil
225+
}
226+
227+
func writeDoc(out *strings.Builder, header string, kind string, name string, spec interface{}) error {
228+
doc := manifestDoc{
229+
APIVersion: consts.ContentGroup + "/v1",
230+
Kind: kind,
231+
Metadata: manifestMetadata{Name: name},
232+
Spec: spec,
233+
}
234+
data, err := yaml.Marshal(doc)
235+
if err != nil {
236+
return fmt.Errorf("marshaling [%s] manifest: %w", kind, err)
237+
}
238+
out.WriteString("---\n")
239+
out.WriteString(header)
240+
out.Write(data)
241+
return nil
242+
}
243+
244+
// imagePlatform returns the "os/arch" of a single-platform image manifest by
245+
// fetching its config blob, or "" if the platform can't be determined.
246+
func imagePlatform(ctx context.Context, s *store.Layout, desc ocispec.Descriptor) (string, error) {
247+
rc, err := s.Fetch(ctx, desc)
248+
if err != nil {
249+
return "", err
250+
}
251+
defer rc.Close()
252+
253+
var m ocispec.Manifest
254+
if err := json.NewDecoder(rc).Decode(&m); err != nil {
255+
return "", err
256+
}
257+
258+
cfgRc, err := s.FetchManifest(ctx, m)
259+
if err != nil {
260+
return "", err
261+
}
262+
defer cfgRc.Close()
263+
264+
var cfg ocispec.Image
265+
if err := json.NewDecoder(cfgRc).Decode(&cfg); err != nil {
266+
return "", err
267+
}
268+
if cfg.OS == "" || cfg.Architecture == "" {
269+
return "", nil
270+
}
271+
return cfg.OS + "/" + cfg.Architecture, nil
272+
}
273+
274+
// decodeOriginalChartRef splits a value produced by encodeOriginalChartRef (see
275+
// storeChart in add.go) back into its repoURL and "repo:tag" parts. Values with no
276+
// "|" (shouldn't occur once only encodeOriginalChartRef ever writes this annotation
277+
// for charts) are treated as a bare ref with an unknown repoURL.
278+
func decodeOriginalChartRef(v string) (repoURL string, total string) {
279+
repoURL, total, found := strings.Cut(v, "|")
280+
if !found {
281+
return "", v
282+
}
283+
return repoURL, total
284+
}
285+
286+
// sanitizeName lowercases s and replaces any character outside [a-z0-9-] with '-' so
287+
// the result is safe to use as a Kubernetes-style object name.
288+
func sanitizeName(s string) string {
289+
s = strings.ToLower(s)
290+
var b strings.Builder
291+
for _, r := range s {
292+
switch {
293+
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
294+
b.WriteRune(r)
295+
default:
296+
b.WriteRune('-')
297+
}
298+
}
299+
out := strings.Trim(b.String(), "-")
300+
if out == "" {
301+
return "store"
302+
}
303+
return out
304+
}

internal/flags/create.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package flags
2+
3+
import "github.com/spf13/cobra"
4+
5+
type CreateManifestOpts struct {
6+
*StoreRootOpts
7+
8+
Output string
9+
}
10+
11+
func (o *CreateManifestOpts) AddFlags(cmd *cobra.Command) {
12+
f := cmd.Flags()
13+
14+
f.StringVarP(&o.Output, "output", "o", "hauler-manifest.yaml", "(Optional) Path to write the generated manifest to")
15+
}

0 commit comments

Comments
 (0)