helpers.go (483 lines of code) (raw):

package dalec import ( "bytes" "context" "encoding/json" "fmt" "path" "path/filepath" "slices" "sort" "sync/atomic" "github.com/containerd/platforms" "github.com/moby/buildkit/client/llb" "github.com/moby/buildkit/identity" "github.com/moby/buildkit/util/system" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" ) const ( // This is used as the source name for sources in specified in `SourceMount` // For any sources we need to mount we need to give the source a name. // We don't actually care about the name here *except* the way file-backed // sources work the name of the file becomes the source name. // So we at least need to track it. // Source names must also not contain path separators or it can screw up the logic. // // To note, the name of the source affects how the source is cached, so this // should just be a single specific name so we can get maximal cache re-use. internalMountSourceName = "src" ) var disableDiffMerge atomic.Bool // DisableDiffMerge allows disabling the use of [llb.Diff] and [llb.Merge] in favor of [llb.Copy]. // This is needed when the buildkit version does not support [llb.Diff] and [llb.Merge]. // // Mainly this would be to allow dockerd with the (current) // standard setup of dockerd which uses "graphdrivers" to work as these ops are not // supported by the graphdriver backend. // When this is false and the graphdriver backend is used, the build will fail when buildkit // checks the capabilities of the backend. func DisableDiffMerge(v bool) { disableDiffMerge.Store(v) } type copyOptionFunc func(*llb.CopyInfo) func (f copyOptionFunc) SetCopyOption(i *llb.CopyInfo) { f(i) } func WithIncludes(patterns []string) llb.CopyOption { return copyOptionFunc(func(i *llb.CopyInfo) { i.IncludePatterns = patterns }) } func WithExcludes(patterns []string) llb.CopyOption { return copyOptionFunc(func(i *llb.CopyInfo) { i.ExcludePatterns = patterns }) } func WithDirContentsOnly() llb.CopyOption { return copyOptionFunc(func(i *llb.CopyInfo) { i.CopyDirContentsOnly = true }) } type runOptionFunc func(*llb.ExecInfo) func (f runOptionFunc) SetRunOption(i *llb.ExecInfo) { f(i) } // WithMountedAptCache gives an [llb.RunOption] that mounts the apt cache directories. // It uses the given namePrefix as the prefix for the cache keys. // namePrefix should be distinct per distro version. func WithMountedAptCache(namePrefix string) llb.RunOption { return runOptionFunc(func(ei *llb.ExecInfo) { // This is in the "official" docker image for ubuntu/debian. // This file prevents us from actually caching anything. // To resolve that we delete the file. ei.State = ei.State.File( llb.Rm("/etc/apt/apt.conf.d/docker-clean", llb.WithAllowNotFound(true)), constraintsOptFunc(func(c *llb.Constraints) { *c = ei.Constraints }), ) llb.AddMount( "/var/cache/apt", llb.Scratch(), llb.AsPersistentCacheDir(namePrefix+"dalec-var-cache-apt", llb.CacheMountLocked), ).SetRunOption(ei) llb.AddMount( "/var/lib/apt", llb.Scratch(), llb.AsPersistentCacheDir(namePrefix+"dalec-var-lib-apt", llb.CacheMountLocked), ).SetRunOption(ei) }) } func WithRunOptions(opts ...llb.RunOption) llb.RunOption { return runOptionFunc(func(ei *llb.ExecInfo) { for _, opt := range opts { opt.SetRunOption(ei) } }) } type constraintsOptFunc func(*llb.Constraints) func (f constraintsOptFunc) SetConstraintsOption(c *llb.Constraints) { f(c) } func (f constraintsOptFunc) SetRunOption(ei *llb.ExecInfo) { f(&ei.Constraints) } func (f constraintsOptFunc) SetLocalOption(li *llb.LocalInfo) { f(&li.Constraints) } func (f constraintsOptFunc) SetOCILayoutOption(oi *llb.OCILayoutInfo) { f(&oi.Constraints) } func (f constraintsOptFunc) SetHTTPOption(hi *llb.HTTPInfo) { f(&hi.Constraints) } func (f constraintsOptFunc) SetImageOption(ii *llb.ImageInfo) { f(&ii.Constraints) } func (f constraintsOptFunc) SetGitOption(gi *llb.GitInfo) { f(&gi.Constraints) } func WithConstraints(ls ...llb.ConstraintsOpt) llb.ConstraintsOpt { return constraintsOptFunc(func(c *llb.Constraints) { for _, opt := range ls { opt.SetConstraintsOption(c) } }) } func WithConstraint(in *llb.Constraints) llb.ConstraintsOpt { return constraintsOptFunc(func(c *llb.Constraints) { *c = *in }) } func withConstraints(opts []llb.ConstraintsOpt) llb.ConstraintsOpt { return WithConstraints(opts...) } // SortMapKeys is a convenience generic function to sort the keys of a map[string]T func SortMapKeys[T any](m map[string]T) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } func DuplicateMap[K comparable, V any](m map[K]V) map[K]V { newM := make(map[K]V, len(m)) for k, v := range m { newM[k] = v } return newM } // MergeAtPath merges the given states into the given destination path in the given input state. func MergeAtPath(input llb.State, states []llb.State, dest string) llb.State { if disableDiffMerge.Load() { output := input for _, st := range states { output = output. File(llb.Copy(st, "/", dest, WithCreateDestPath())) } return output } diffs := make([]llb.State, 0, len(states)+1) diffs = append(diffs, input) for _, src := range states { st := src if dest != "" && dest != "/" { st = llb.Scratch(). File(llb.Copy(src, "/", dest, WithCreateDestPath())) } diffs = append(diffs, llb.Diff(input, st)) } return llb.Merge(diffs) } type localOptionFunc func(*llb.LocalInfo) func (f localOptionFunc) SetLocalOption(li *llb.LocalInfo) { f(li) } func localIncludeExcludeMerge(includes []string, excludes []string) localOptionFunc { return func(li *llb.LocalInfo) { if len(excludes) > 0 { if li.ExcludePatterns != "" { var ls []string if err := json.Unmarshal([]byte(li.ExcludePatterns), &ls); err != nil { panic(err) } excludes = append(excludes, ls...) } llb.ExcludePatterns(excludes).SetLocalOption(li) } if len(includes) > 0 { if li.IncludePatterns != "" { var ls []string if err := json.Unmarshal([]byte(li.IncludePatterns), &ls); err != nil { panic(err) } includes = append(includes, ls...) } llb.IncludePatterns(includes).SetLocalOption(li) } } } // CacheDirsToRunOpt converts the given cache directories into a RunOption. func CacheDirsToRunOpt(mounts map[string]CacheDirConfig, distroKey, archKey string) llb.RunOption { var opts []llb.RunOption for p, cfg := range mounts { mode, err := sharingMode(cfg.Mode) if err != nil { panic(err) } key := cfg.Key if cfg.IncludeDistroKey { key = path.Join(distroKey, key) } if cfg.IncludeArchKey { key = path.Join(archKey, key) } opts = append(opts, llb.AddMount(p, llb.Scratch(), llb.AsPersistentCacheDir(key, mode))) } return RunOptFunc(func(ei *llb.ExecInfo) { for _, opt := range opts { opt.SetRunOption(ei) } }) } type RunOptFunc func(*llb.ExecInfo) func (f RunOptFunc) SetRunOption(ei *llb.ExecInfo) { f(ei) } // ProgressGroup creates a progress group with the given name. // If a progress group is already set in the constraints the id is reused. // If no progress group is set a new id is generated. func ProgressGroup(name string) llb.ConstraintsOpt { return constraintsOptFunc(func(c *llb.Constraints) { if c.Metadata.ProgressGroup != nil { id := c.Metadata.ProgressGroup.Id llb.ProgressGroup(id, name, false).SetConstraintsOption(c) return } llb.ProgressGroup(identity.NewID(), name, false).SetConstraintsOption(c) }) } func (s *Spec) GetRuntimeDeps(targetKey string) []string { deps := s.GetPackageDeps(targetKey) if deps == nil { return nil } return SortMapKeys(deps.Runtime) } func (s *Spec) GetBuildDeps(targetKey string) map[string]PackageConstraints { deps := s.GetPackageDeps(targetKey) if deps == nil { return nil } return deps.Build } func (s *Spec) GetTestDeps(targetKey string) []string { deps := s.GetPackageDeps(targetKey) if deps == nil { return nil } out := slices.Clone(deps.Test) slices.Sort(out) return out } func (s *Spec) GetImagePost(target string) *PostInstall { img := s.Targets[target].Image if img != nil { if img.Post != nil { return img.Post } } if s.Image != nil { return s.Image.Post } return nil } func (s *Spec) GetArtifacts(targetKey string) Artifacts { if t, ok := s.Targets[targetKey]; ok { // If unset then we should use the global artifacts but if set or deliberately empty then we should use that. if t.Artifacts != nil { return *t.Artifacts } } return s.Artifacts } // ShArgs returns a RunOption that runs the given command in a shell. func ShArgs(args string) llb.RunOption { return llb.Args(append([]string{"sh", "-c"}, args)) } // ShArgsf is the same as [ShArgs] but tkes a format string func ShArgsf(format string, args ...interface{}) llb.RunOption { return ShArgs(fmt.Sprintf(format, args...)) } // InstallPostSymlinks returns a RunOption that adds symlinks defined in the [PostInstall] underneath the provided rootfs path. func InstallPostSymlinks(post *PostInstall, rootfsPath string) llb.RunOption { return runOptionFunc(func(ei *llb.ExecInfo) { if post == nil { return } if len(post.Symlinks) == 0 { return } llb.Dir(rootfsPath).SetRunOption(ei) buf := bytes.NewBuffer(nil) buf.WriteString("set -ex\n") sortedKeys := SortMapKeys(post.Symlinks) for _, oldpath := range sortedKeys { newpaths := post.Symlinks[oldpath].Paths sort.Strings(newpaths) for _, newpath := range newpaths { fmt.Fprintf(buf, "mkdir -p %q\n", filepath.Join(rootfsPath, filepath.Dir(newpath))) fmt.Fprintf(buf, "ln -s %q %q\n", oldpath, filepath.Join(rootfsPath, newpath)) } } const name = "tmp.dalec.symlink.sh" script := llb.Scratch().File(llb.Mkfile(name, 0o400, buf.Bytes())) llb.AddMount(name, script, llb.SourcePath(name)).SetRunOption(ei) llb.Args([]string{"/bin/sh", name}).SetRunOption(ei) ProgressGroup("Add post-install symlinks").SetRunOption(ei) }) } func (s *Spec) GetSigner(targetKey string) (*PackageSigner, bool) { if s.Targets != nil { targetOverridesRootSigningConfig := hasValidSigner(s.PackageConfig) if t, ok := s.Targets[targetKey]; ok && hasValidSigner(t.PackageConfig) { return t.PackageConfig.Signer, targetOverridesRootSigningConfig } } if hasValidSigner(s.PackageConfig) { return s.PackageConfig.Signer, false } return nil, false } func hasValidSigner(pc *PackageConfig) bool { return pc != nil && pc.Signer != nil && pc.Signer.Image != "" } // SortMapValues is like [maps.Values], but the list is sorted based on the map key func SortedMapValues[T any](m map[string]T) []T { keys := SortMapKeys(m) out := make([]T, 0, len(keys)) for _, k := range keys { out = append(out, m[k]) } return out } // MergeDependencies merges two sets of package dependencies, a base and a target. // If a dependency is set in both, the one from `target` is used, otherwise, the dependency from parent is used. // MergeDependencies(nil, child) = child, MergeDependencies(parent, nil) = parent func MergeDependencies(base, target *PackageDependencies) *PackageDependencies { var ( build map[string]PackageConstraints runtime map[string]PackageConstraints recommends map[string]PackageConstraints test []string extraRepos []PackageRepositoryConfig ) if base == nil { return target } if target == nil { return base } if len(target.Build) > 0 { build = target.Build } else { build = base.Build } if len(target.Runtime) > 0 { runtime = target.Runtime } else { runtime = base.Runtime } if len(target.Recommends) > 0 { recommends = target.Recommends } else { recommends = base.Recommends } if len(target.Test) > 0 { test = target.Test } else { test = base.Test } if len(target.ExtraRepos) > 0 { extraRepos = target.ExtraRepos } else { extraRepos = base.ExtraRepos } return &PackageDependencies{ Build: build, Runtime: runtime, Recommends: recommends, Test: test, ExtraRepos: extraRepos, } } // GetPackageDeps returns the package dependencies for the given target. // If the target does not have dependencies, the global dependencies are returned. func (s *Spec) GetPackageDeps(target string) *PackageDependencies { if _, ok := s.Targets[target]; !ok { return s.Dependencies } return MergeDependencies(s.Dependencies, s.Targets[target].Dependencies) } type gitOptionFunc func(*llb.GitInfo) func (f gitOptionFunc) SetGitOption(gi *llb.GitInfo) { f(gi) } type RepoPlatformConfig struct { ConfigRoot string GPGKeyRoot string ConfigExt string } // Returns a run option which mounts the data dirs for all specified repos func WithRepoData(repos []PackageRepositoryConfig, sOpts SourceOpts, opts ...llb.ConstraintsOpt) (llb.RunOption, error) { var repoMountsOpts []llb.RunOption for _, repo := range repos { rs, err := repoDataAsMount(repo, sOpts, opts...) if err != nil { return nil, err } repoMountsOpts = append(repoMountsOpts, rs) } return WithRunOptions(repoMountsOpts...), nil } // Returns a run option for mounting the state (i.e., packages/metadata) for a single repo func repoDataAsMount(config PackageRepositoryConfig, sOpts SourceOpts, opts ...llb.ConstraintsOpt) (llb.RunOption, error) { var mounts []llb.RunOption for _, data := range config.Data { repoState, err := data.Spec.AsMount(internalMountSourceName, sOpts, opts...) if err != nil { return nil, err } if SourceIsDir(data.Spec) { mounts = append(mounts, llb.AddMount(data.Dest, repoState)) } else { mounts = append(mounts, llb.AddMount(data.Dest, repoState, llb.SourcePath(internalMountSourceName))) } } return WithRunOptions(mounts...), nil } func repoConfigAsMount(config PackageRepositoryConfig, platformCfg *RepoPlatformConfig, sOpt SourceOpts, opts ...llb.ConstraintsOpt) ([]llb.RunOption, error) { repoConfigs := []llb.RunOption{} for name, repoConfig := range config.Config { // each of these sources represent a repo config file repoConfigSt, err := repoConfig.AsMount(name, sOpt, append(opts, ProgressGroup("Importing repo config: "+name))...) if err != nil { return nil, err } var normalized string = name if filepath.Ext(normalized) != platformCfg.ConfigExt { normalized += platformCfg.ConfigExt } repoConfigs = append(repoConfigs, llb.AddMount(filepath.Join(platformCfg.ConfigRoot, normalized), repoConfigSt, llb.SourcePath(name))) } return repoConfigs, nil } // Returns a run option for importing the config files for all repos func WithRepoConfigs(repos []PackageRepositoryConfig, cfg *RepoPlatformConfig, sOpt SourceOpts, opts ...llb.ConstraintsOpt) (llb.RunOption, error) { configStates := []llb.RunOption{} for _, repo := range repos { mnts, err := repoConfigAsMount(repo, cfg, sOpt, opts...) if err != nil { return nil, err } configStates = append(configStates, mnts...) } return WithRunOptions(configStates...), nil } func GetRepoKeys(configs []PackageRepositoryConfig, cfg *RepoPlatformConfig, sOpt SourceOpts, opts ...llb.ConstraintsOpt) (llb.RunOption, []string, error) { keys := []llb.RunOption{} names := []string{} for _, config := range configs { for name, repoKey := range config.Keys { gpgKey, err := repoKey.AsMount(name, sOpt, append(opts, ProgressGroup("Fetching repo key: "+name))...) if err != nil { return nil, nil, err } mountPath := filepath.Join(cfg.GPGKeyRoot, name) keys = append(keys, llb.AddMount(mountPath, gpgKey, llb.SourcePath(name))) names = append(names, name) } } return WithRunOptions(keys...), names, nil } const ( netModeNone = "none" netModeSandbox = "sandbox" ) // SetBuildNetworkMode returns an [llb.StateOption] that determines which func SetBuildNetworkMode(spec *Spec) llb.StateOption { switch spec.Build.NetworkMode { case "", netModeNone: return llb.Network(llb.NetModeNone) case netModeSandbox: return llb.Network(llb.NetModeSandbox) default: return func(in llb.State) llb.State { return in.Async(func(context.Context, llb.State, *llb.Constraints) (llb.State, error) { return in, fmt.Errorf("invalid build network mode %q", spec.Build.NetworkMode) }) } } } // BaseImageConfig provides a default image config that can be used for // producing images. // // This is taken from https://github.com/moby/buildkit/blob/0655923d7e2884a0d514313fd688178a6da57b43/frontend/dockerfile/dockerfile2llb/image.go#L26-L39 func BaseImageConfig(platform *ocispecs.Platform) *DockerImageSpec { img := &DockerImageSpec{} if platform == nil { p := platforms.DefaultSpec() platform = &p } img.Architecture = platform.Architecture img.OS = platform.OS img.OSVersion = platform.OSVersion if platform.OSFeatures != nil { img.OSFeatures = append([]string{}, platform.OSFeatures...) } img.Variant = platform.Variant img.RootFS.Type = "layers" img.Config.WorkingDir = "/" img.Config.Env = []string{"PATH=" + system.DefaultPathEnv(platform.OS)} return img } // Platform returns a [llb.ConstraintsOpt] that sets the platform to the provided platform // If the platform is nil, the [llb.ConstraintOpt] is a no-op. func Platform(platform *ocispecs.Platform) llb.ConstraintsOpt { if platform == nil { return constraintsOptFunc(func(c *llb.Constraints) {}) } return llb.Platform(*platform) }