Skip to content

Commit 82bcda5

Browse files
committed
cmd/cue: add dry run flag to cue mod publish
We add a `--dryrun` flag to the publish command to enable the user to see what would be published. We also include a `--json` flag to print all available information in JSON format, including the list of files that's incorporated into the module. We also include `--out` flag to cause the command to write the module information into an image directory in a standard format (see https://github.com/opencontainers/image-spec/blob/8f3820ccf8f65db8744e626df17fe8a64462aece/image-layout.md). We also change the publish command to print a well-formed OCI reference that refers to the pushed image. Fixes #3046 Signed-off-by: Roger Peppe <[email protected]> Change-Id: I9e88aa75300d00882a68adaf14effedb37744069 Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1194090 TryBot-Result: CUEcueckoo <[email protected]> Reviewed-by: Daniel Martí <[email protected]> Unity-Result: CUE porcuepine <[email protected]>
1 parent b6a2637 commit 82bcda5

8 files changed

+401
-15
lines changed

cmd/cue/cmd/flags.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const (
3535
flagInject flagName = "inject"
3636
flagInjectVars flagName = "inject-vars"
3737
flagInlineImports flagName = "inline-imports"
38+
flagJSON flagName = "json"
3839
flagList flagName = "list"
3940
flagMerge flagName = "merge"
4041
flagOut flagName = "out"

cmd/cue/cmd/modpublish.go

Lines changed: 329 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,26 @@
1515
package cmd
1616

1717
import (
18+
"archive/zip"
19+
"bytes"
20+
"context"
21+
"encoding/json"
1822
"fmt"
1923
"io"
2024
"io/fs"
2125
"os"
2226
"path/filepath"
27+
"sync"
2328

29+
"cuelabs.dev/go/oci/ociregistry"
30+
"cuelabs.dev/go/oci/ociregistry/ociref"
31+
"github.com/opencontainers/go-digest"
32+
ocispecroot "github.com/opencontainers/image-spec/specs-go"
33+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
2434
"github.com/spf13/cobra"
2535

2636
"cuelang.org/go/internal/vcs"
37+
"cuelang.org/go/mod/modconfig"
2738
"cuelang.org/go/mod/modfile"
2839
"cuelang.org/go/mod/modregistry"
2940
"cuelang.org/go/mod/module"
@@ -44,23 +55,58 @@ no dependency or other checks at the moment.
4455
Note: you must enable the modules experiment with:
4556
export CUE_EXPERIMENT=modules
4657
for this command to work.
58+
59+
When the --dryrun flag is specified, nothing will actually be written
60+
to a registry, but all other checks will take place.
61+
62+
The --json flag can be used to find out more information about the upload.
63+
64+
The --out flag can be used to write the module's contents to a directory
65+
in OCI Image Layout format. See this link for more details on the format:
66+
https://github.com/opencontainers/image-spec/blob/8f3820ccf8f65db8744e626df17fe8a64462aece/image-layout.md
4767
`,
4868
RunE: mkRunE(c, runModUpload),
4969
Args: cobra.ExactArgs(1),
5070
}
71+
cmd.Flags().BoolP(string(flagDryrun), "n", false, "only run simulation")
72+
cmd.Flags().Bool(string(flagJSON), false, "print verbose information in JSON format (implies --dryrun)")
73+
cmd.Flags().String(string(flagOut), "", "write module contents to specified directory in OCI Image Layout format (implies --dryrun)")
5174

5275
return cmd
5376
}
5477

78+
// publishInfo defines the format of the JSON printed by `cue mod publish --json`.
79+
type publishInfo struct {
80+
Version string `json:"version"`
81+
Ref string `json:"ref"`
82+
Insecure bool `json:"insecure,omitempty"`
83+
Files []string `json:"files"`
84+
// TODO include metadata too.
85+
}
86+
5587
func runModUpload(cmd *Command, args []string) error {
5688
ctx := cmd.Context()
57-
resolver, err := getRegistryResolver()
89+
resolver0, err := getRegistryResolver()
5890
if err != nil {
5991
return err
6092
}
61-
if resolver == nil {
93+
if resolver0 == nil {
6294
return fmt.Errorf("modules experiment not enabled (enable with CUE_EXPERIMENT=modules)")
6395
}
96+
dryRun := flagDryrun.Bool(cmd)
97+
outDir := flagOut.String(cmd)
98+
useJSON := flagJSON.Bool(cmd)
99+
if outDir != "" || useJSON {
100+
dryRun = true
101+
}
102+
resolver := &publishRegistryResolverShim{
103+
resolver: resolver0,
104+
outDir: flagOut.String(cmd),
105+
dryRun: dryRun,
106+
// recording the files is somewhat heavyweight, so only do it
107+
// if we're going to need them.
108+
recordFiles: useJSON,
109+
}
64110
modRoot, err := findModuleRoot()
65111
if err != nil {
66112
return err
@@ -136,7 +182,38 @@ func runModUpload(cmd *Command, args []string) error {
136182
if err := rclient.PutModuleWithMetadata(backgroundContext(), mv, zf, info.Size(), meta); err != nil {
137183
return fmt.Errorf("cannot put module: %v", err)
138184
}
139-
fmt.Printf("published %s\n", mv)
185+
ref := ociref.Reference{
186+
Host: resolver.registryName,
187+
Repository: resolver.repository,
188+
Tag: resolver.tag,
189+
Digest: resolver.manifestDigest,
190+
}
191+
if outDir != "" {
192+
if err := resolver.writeIndex(); err != nil {
193+
return err
194+
}
195+
}
196+
switch {
197+
case useJSON:
198+
info := publishInfo{
199+
Version: mv.String(),
200+
Ref: ref.String(),
201+
Insecure: resolver.insecure,
202+
Files: resolver.files,
203+
}
204+
data, err := json.MarshalIndent(info, "", "\t")
205+
if err != nil {
206+
return err
207+
}
208+
data = append(data, '\n')
209+
os.Stdout.Write(data)
210+
case outDir != "":
211+
fmt.Printf("wrote image for %s to %s\n", mv, outDir)
212+
case dryRun:
213+
fmt.Printf("dry-run published %s to %v\n", mv, ref)
214+
default:
215+
fmt.Printf("published %s to %v\n", mv, ref)
216+
}
140217
return nil
141218
}
142219

@@ -161,3 +238,252 @@ func (fio osFileIO) Open(f string) (io.ReadCloser, error) {
161238
func (fio osFileIO) absPath(f string) string {
162239
return filepath.Join(fio.modRoot, f)
163240
}
241+
242+
// publishRegistryResolverShim implements a wrapper around
243+
// modregistry.Resolver that records information about the module being
244+
// published.
245+
//
246+
// If dryRun is true, it does not actually write to the underlying
247+
// registry.
248+
//
249+
// If outDir is non-empty, it also writes the contents of the module to
250+
// that directory.
251+
//
252+
// If recordFiles is true, it records which files are present in the
253+
// module's zip file.
254+
type publishRegistryResolverShim struct {
255+
resolver *modconfig.Resolver
256+
registry ociregistry.Interface
257+
dryRun bool
258+
recordFiles bool
259+
outDir string
260+
261+
initDirOnce sync.Once
262+
initDirError error
263+
264+
// mu protects the fields below it.
265+
mu sync.Mutex
266+
registryName string
267+
insecure bool
268+
repository string
269+
tag string
270+
manifestDigest digest.Digest
271+
files []string
272+
descriptors []ociregistry.Descriptor
273+
}
274+
275+
// ResolveToRegistry implements [modregistry.RegistryResolver].
276+
func (r *publishRegistryResolverShim) ResolveToRegistry(mpath, vers string) (modregistry.RegistryLocation, error) {
277+
// Make sure that we can acquire the underlying registry even
278+
// though we are not going to use it, so we're dry-running as
279+
// much as possible
280+
regLoc, err := r.resolver.ResolveToRegistry(mpath, vers)
281+
if err != nil {
282+
return modregistry.RegistryLocation{}, err
283+
}
284+
loc, ok := r.resolver.ResolveToLocation(mpath, vers)
285+
if !ok {
286+
panic("unreachable: ResolveToLocation failed when ResolveToRegistry succeeded")
287+
}
288+
r.mu.Lock()
289+
defer r.mu.Unlock()
290+
r.registryName = loc.Host
291+
r.insecure = loc.Insecure
292+
return modregistry.RegistryLocation{
293+
Registry: &publishRegistryShim{
294+
Funcs: &ociregistry.Funcs{
295+
NewError: func(ctx context.Context, methodName, repo string) error {
296+
return fmt.Errorf("unexpected OCI method %q invoked when publishing module", methodName)
297+
},
298+
},
299+
resolver: r,
300+
registry: regLoc.Registry,
301+
},
302+
Repository: loc.Repository,
303+
Tag: loc.Tag,
304+
}, nil
305+
}
306+
307+
func (r *publishRegistryResolverShim) writeIndex() error {
308+
if r.outDir == "" {
309+
return nil
310+
}
311+
index := ocispec.Index{
312+
Versioned: ocispecroot.Versioned{
313+
SchemaVersion: 2,
314+
},
315+
MediaType: "application/vnd.oci.image.index.v1+json",
316+
Manifests: r.descriptors,
317+
}
318+
data, err := json.MarshalIndent(index, "", "\t")
319+
if err != nil {
320+
return err
321+
}
322+
data = append(data, '\n')
323+
if os.WriteFile(filepath.Join(r.outDir, "index.json"), data, 0o666); err != nil {
324+
return err
325+
}
326+
return nil
327+
}
328+
329+
func (r *publishRegistryResolverShim) setRepository(repo string) error {
330+
r.mu.Lock()
331+
defer r.mu.Unlock()
332+
if r.repository != "" && repo != r.repository {
333+
return fmt.Errorf("internal error: publish wrote to more than one OCI repository")
334+
}
335+
r.repository = repo
336+
return nil
337+
}
338+
339+
func (r *publishRegistryResolverShim) setTag(tag string, dig digest.Digest) error {
340+
r.mu.Lock()
341+
defer r.mu.Unlock()
342+
if r.tag != "" && tag != r.tag {
343+
return fmt.Errorf("internal error: publish wrote to more than one OCI tag")
344+
}
345+
r.tag = tag
346+
r.manifestDigest = dig
347+
return nil
348+
}
349+
350+
func (r *publishRegistryResolverShim) setFiles(files []string) {
351+
r.mu.Lock()
352+
defer r.mu.Unlock()
353+
r.files = files
354+
}
355+
356+
func (r *publishRegistryResolverShim) addManifest(tag string, data []byte, mediaType string) {
357+
r.mu.Lock()
358+
defer r.mu.Unlock()
359+
r.descriptors = append(r.descriptors, ociregistry.Descriptor{
360+
MediaType: mediaType,
361+
Size: int64(len(data)),
362+
Digest: digest.FromBytes(data),
363+
Annotations: map[string]string{
364+
"org.opencontainers.image.ref.name": tag,
365+
},
366+
})
367+
}
368+
369+
func (r *publishRegistryResolverShim) initDir() error {
370+
// Lazily create the directory and the oci-layout file
371+
// so we don't create anything if no operations take place
372+
// on the registry.
373+
r.initDirOnce.Do(func() {
374+
if r.outDir == "" {
375+
return
376+
}
377+
if err := os.Mkdir(r.outDir, 0o777); err != nil {
378+
r.initDirError = err
379+
return
380+
}
381+
// Create oci-layout file.
382+
// See https://github.com/opencontainers/image-spec/blob/8f3820ccf8f65db8744e626df17fe8a64462aece/image-layout.md#oci-layout-file
383+
r.initDirError = os.WriteFile(filepath.Join(r.outDir, "oci-layout"), []byte(`
384+
{
385+
"imageLayoutVersion": "1.0.0"
386+
}
387+
`[1:]), 0o666)
388+
})
389+
return r.initDirError
390+
}
391+
392+
// publishRegistryShim implements [ociregistry.Interface] by recording
393+
// what is written. It returns an error for methods not expected to be
394+
// invoked as part of the publishing process.
395+
type publishRegistryShim struct {
396+
*ociregistry.Funcs
397+
resolver *publishRegistryResolverShim
398+
// registry is the real underlying registry. It is only used if
399+
// resolver.dryRun is false.
400+
registry ociregistry.Interface
401+
}
402+
403+
func filesFromZip(content0 io.Reader, size int64) ([]string, error) {
404+
// The modregistry code (our only caller) always invokes PushBlob
405+
// with a ReaderAt, so we can avoid copying all the data by type-asserting
406+
// to that.
407+
content, ok := content0.(io.ReaderAt)
408+
if !ok {
409+
return nil, fmt.Errorf("internal error: PushBlob invoked without ReaderAt")
410+
}
411+
z, err := zip.NewReader(content, size)
412+
if err != nil {
413+
return nil, err
414+
}
415+
files := make([]string, len(z.File))
416+
for i, f := range z.File {
417+
files[i] = f.Name
418+
}
419+
return files, nil
420+
}
421+
422+
func (r *publishRegistryShim) PushBlob(ctx context.Context, repoName string, desc ociregistry.Descriptor, content io.Reader) (ociregistry.Descriptor, error) {
423+
if err := r.resolver.setRepository(repoName); err != nil {
424+
return ociregistry.Descriptor{}, err
425+
}
426+
if r.resolver.recordFiles && desc.MediaType == "application/zip" {
427+
files, err := filesFromZip(content, desc.Size)
428+
if err != nil {
429+
return ociregistry.Descriptor{}, err
430+
}
431+
r.resolver.setFiles(files)
432+
}
433+
434+
switch {
435+
case r.resolver.outDir != "":
436+
if err := r.resolver.initDir(); err != nil {
437+
return ociregistry.Descriptor{}, err
438+
}
439+
outFile := filepath.Join(r.resolver.outDir, "blobs", string(desc.Digest.Algorithm()), desc.Digest.Encoded())
440+
if err := os.MkdirAll(filepath.Dir(outFile), 0o777); err != nil {
441+
return ociregistry.Descriptor{}, err
442+
}
443+
// TODO create as temp and atomically rename?
444+
f, err := os.Create(outFile)
445+
if err != nil {
446+
return ociregistry.Descriptor{}, err
447+
}
448+
defer f.Close()
449+
if _, err := io.Copy(f, content); err != nil {
450+
return ociregistry.Descriptor{}, fmt.Errorf("cannot copy blob to %q: %v", outFile, err)
451+
}
452+
if err := f.Close(); err != nil {
453+
return ociregistry.Descriptor{}, err
454+
}
455+
case r.resolver.dryRun:
456+
// Sanity check we can read the content.
457+
if _, err := io.Copy(io.Discard, content); err != nil {
458+
return ociregistry.Descriptor{}, fmt.Errorf("error reading blob: %v", err)
459+
}
460+
default:
461+
return r.registry.PushBlob(ctx, repoName, desc, content)
462+
}
463+
return desc, nil
464+
}
465+
466+
func (r *publishRegistryShim) PushManifest(ctx context.Context, repoName string, tag string, data []byte, mediaType string) (ociregistry.Descriptor, error) {
467+
if err := r.resolver.setRepository(repoName); err != nil {
468+
return ociregistry.Descriptor{}, err
469+
}
470+
desc := ociregistry.Descriptor{
471+
Digest: digest.FromBytes(data),
472+
Size: int64(len(data)),
473+
}
474+
if err := r.resolver.setTag(tag, desc.Digest); err != nil {
475+
return ociregistry.Descriptor{}, err
476+
}
477+
r.resolver.addManifest(tag, data, mediaType)
478+
switch {
479+
case r.resolver.outDir != "":
480+
// The OCI image layout does not distinguish between data blobs and
481+
// manifest blobs, unlike the OCI registry API, so use PushBlob
482+
// to create the blob.
483+
return r.PushBlob(ctx, repoName, desc, bytes.NewReader(data))
484+
case r.resolver.dryRun:
485+
return desc, nil
486+
default:
487+
return r.registry.PushManifest(ctx, repoName, tag, data, mediaType)
488+
}
489+
}

0 commit comments

Comments
 (0)