From 23b0000e14c3fe21729841f9d708bc5b9b421f4d Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 14 Oct 2024 13:52:29 +0200 Subject: [PATCH] feat: cache runx readme and config locally So that we only need to perform a HEAD to get the digest of the reference from the user. This HEAD request and the fact we are using the digest as a key allow to invalidate cache each time the remote image change. Cache for a specific image with digest DIGEST contains these two items: - ~/.docker/runx/cache/sha256/sha256:DIGEST/README.md - ~/.docker/runx/cache/sha256/sha256:DIGEST/runx.yaml Fixes #1 Signed-off-by: Yves Brissaud --- internal/commands/root/root.go | 5 +- runkit/cache.go | 83 ++++++++++++++++++ runkit/read.go | 154 ++++++++++++++++++++------------- 3 files changed, 180 insertions(+), 62 deletions(-) create mode 100644 runkit/cache.go diff --git a/internal/commands/root/root.go b/internal/commands/root/root.go index eff2ba8..dc86a35 100644 --- a/internal/commands/root/root.go +++ b/internal/commands/root/root.go @@ -43,6 +43,7 @@ func NewCmd(dockerCli command.Cli, isPlugin bool) *cobra.Command { src string action string lc = runkit.GetLocalConfig() + cache = runkit.NewLocalCache(dockerCli) ) switch len(args) { @@ -83,14 +84,14 @@ func NewCmd(dockerCli command.Cli, isPlugin bool) *cobra.Command { Type(spinner.Globe). Title(" Fetching runx details..."). Action(func() { - rk, err = runkit.Get(cmd.Context(), src) + rk, err = runkit.Get(cmd.Context(), cache, src) if err != nil { _, _ = fmt.Fprintln(dockerCli.Err(), err) os.Exit(1) } }).Run() } else { - rk, err = runkit.Get(cmd.Context(), src) + rk, err = runkit.Get(cmd.Context(), cache, src) } if err != nil { return err diff --git a/runkit/cache.go b/runkit/cache.go new file mode 100644 index 0000000..7522f11 --- /dev/null +++ b/runkit/cache.go @@ -0,0 +1,83 @@ +package runkit + +import ( + "os" + "path/filepath" + + "github.com/docker/cli/cli/command" + "github.com/eunomie/docker-runx/internal/constants" +) + +const ( + runxConfigFile = "runx.yaml" + runxDocFile = "README.md" +) + +var subCacheDir = filepath.Join(constants.SubCommandName, "cache", "sha256") + +type LocalCache struct { + cacheDir string +} + +func NewLocalCache(cli command.Cli) *LocalCache { + rootDir := filepath.Dir(cli.ConfigFile().Filename) + cacheDir := filepath.Join(rootDir, subCacheDir) + + return &LocalCache{ + cacheDir: cacheDir, + } +} + +func (c *LocalCache) Get(digest string) (*RunKit, error) { + rk := &RunKit{ + Files: make(map[string]string), + } + found := false + + configFile := filepath.Join(c.cacheDir, digest, runxConfigFile) + if runxConfig, err := os.ReadFile(configFile); err != nil { + if !os.IsNotExist(err) { + return nil, err + } + } else { + if err = decodeConfig(rk, digest, runxConfig); err != nil { + return nil, err + } + found = true + } + + readmeFile := filepath.Join(c.cacheDir, digest, runxDocFile) + if runxDoc, err := os.ReadFile(readmeFile); err != nil { + if !os.IsNotExist(err) { + return nil, err + } + } else { + rk.Readme = string(runxDoc) + found = true + } + + if found { + return rk, nil + } + return nil, nil +} + +func (c *LocalCache) Set(digest string, runxConfig, runxDoc []byte) error { + digestDir := filepath.Join(c.cacheDir, digest) + if err := os.MkdirAll(digestDir, 0o755); err != nil { + return err + } + if len(runxConfig) > 0 { + configFile := filepath.Join(c.cacheDir, digest, runxConfigFile) + if err := os.WriteFile(configFile, runxConfig, 0o644); err != nil { + return err + } + } + if len(runxDoc) > 0 { + readmeFile := filepath.Join(c.cacheDir, digest, runxDocFile) + if err := os.WriteFile(readmeFile, runxDoc, 0o644); err != nil { + return err + } + } + return nil +} diff --git a/runkit/read.go b/runkit/read.go index d3a1d78..74fa5da 100644 --- a/runkit/read.go +++ b/runkit/read.go @@ -16,33 +16,40 @@ import ( "github.com/eunomie/docker-runx/internal/registry" ) -type Files struct { - Files []struct { - Name string `yaml:"name"` - Content string `yaml:"content"` - } `yaml:"files"` -} +type ( + Files struct { + Files []struct { + Name string `yaml:"name"` + Content string `yaml:"content"` + } `yaml:"files"` + } + + Cache interface { + Get(digest string) (*RunKit, error) + Set(digest string, runxConfig, runxDoc []byte) error + } +) -func Get(ctx context.Context, src string) (*RunKit, error) { +func Get(ctx context.Context, cache Cache, src string) (*RunKit, error) { var ( - err error - index v1.ImageIndex - desc *remote.Descriptor - manifest v1.Descriptor - runxImg v1.Image - layers []v1.Layer - runxConfig []byte - runxDoc []byte - files Files - config Config - rk = RunKit{ + err error + desc *v1.Descriptor + indexDigest string + cached *RunKit + index v1.ImageIndex + manifest v1.Descriptor + runxImg v1.Image + layers []v1.Layer + runxConfig []byte + runxDoc []byte + rk = &RunKit{ Files: make(map[string]string), } remoteOpts = registry.WithOptions(ctx, nil) ref, _ = name.ParseReference(src) ) - desc, err = remote.Get(ref, remoteOpts...) + desc, err = remote.Head(ref, remoteOpts...) if err != nil { return nil, fmt.Errorf("could not get image %s: %w", src, err) } @@ -51,6 +58,13 @@ func Get(ctx context.Context, src string) (*RunKit, error) { return nil, fmt.Errorf("image %s can't be read by 'docker runx': should be an index", src) } + indexDigest = desc.Digest.String() + + cached, err = cache.Get(indexDigest) + if err == nil && cached != nil { + return cached, nil + } + index, err = remote.Index(ref, remoteOpts...) if err != nil { return nil, fmt.Errorf("could not get index %s: %w", src, err) @@ -106,47 +120,10 @@ func Get(ctx context.Context, src string) (*RunKit, error) { } if len(runxConfig) != 0 { - dec := yaml.NewDecoder(bytes.NewReader(runxConfig)) - dec.KnownFields(true) - // first, the runx config itself - if err = dec.Decode(&config); err != nil { - return nil, fmt.Errorf("could not decode runx config %s: %w", src, err) - } - // then, the optional files - if err = dec.Decode(&files); err != nil && err != io.EOF { - return nil, fmt.Errorf("could not decode runx files %s: %w", src, err) - } else { - for _, f := range files.Files { - c, err := b64Decode(f.Content) - if err != nil { - return nil, fmt.Errorf("could not decode runx file %s: %w", f.Name, err) - } - rk.Files[f.Name] = string(c) - } - } - - if err = yaml.Unmarshal(runxConfig, &config); err != nil { - return nil, fmt.Errorf("could not unmarshal runx config %s: %w", src, err) - } - var actions []Action - for _, a := range config.Actions { - // TODO: fix reading of multiline YAML strings - a.Command = strings.ReplaceAll(a.Command, "\n", " ") - - if a.Dockerfile != "" { - if c, ok := rk.Files[a.Dockerfile]; ok { - a.DockerfileContent = c - } - } - - if config.Default == a.ID { - a.isDefault = true - } - - actions = append(actions, a) + err = decodeConfig(rk, src, runxConfig) + if err != nil { + return nil, err } - config.Actions = actions - rk.Config = config } if len(runxDoc) != 0 { @@ -155,9 +132,66 @@ func Get(ctx context.Context, src string) (*RunKit, error) { rk.src = src - return &rk, nil + err = cache.Set(indexDigest, runxConfig, runxDoc) + if err != nil { + // TODO: log error + return rk, nil + } + + return rk, nil } func b64Decode(content string) ([]byte, error) { return base64.StdEncoding.DecodeString(content) } + +func decodeConfig(rk *RunKit, src string, runxConfig []byte) error { + var ( + config Config + err error + files Files + ) + dec := yaml.NewDecoder(bytes.NewReader(runxConfig)) + dec.KnownFields(true) + // first, the runx config itself + if err = dec.Decode(&config); err != nil { + return fmt.Errorf("could not decode runx config %s: %w", src, err) + } + // then, the optional files + if err = dec.Decode(&files); err != nil && err != io.EOF { + return fmt.Errorf("could not decode runx files %s: %w", src, err) + } else { + for _, f := range files.Files { + c, err := b64Decode(f.Content) + if err != nil { + return fmt.Errorf("could not decode runx file %s: %w", f.Name, err) + } + rk.Files[f.Name] = string(c) + } + } + + if err = yaml.Unmarshal(runxConfig, &config); err != nil { + return fmt.Errorf("could not unmarshal runx config %s: %w", src, err) + } + var actions []Action + for _, a := range config.Actions { + // TODO: fix reading of multiline YAML strings + a.Command = strings.ReplaceAll(a.Command, "\n", " ") + + if a.Dockerfile != "" { + if c, ok := rk.Files[a.Dockerfile]; ok { + a.DockerfileContent = c + } + } + + if config.Default == a.ID { + a.isDefault = true + } + + actions = append(actions, a) + } + config.Actions = actions + rk.Config = config + + return nil +}