// Package pack contains functionality for building Edge Pallet packages from // files on disk and Pallet package definitions (pallet.yaml). // TODO: move docstring package pack import ( "fmt" "path/filepath" "regexp" "sort" "slices" "sigs.k8s.io/kustomize/api/filters/annotations" "sigs.k8s.io/kustomize/api/hasher" "sigs.k8s.io/kustomize/api/resmap" "sigs.k8s.io/kustomize/api/resource" "sigs.k8s.io/kustomize/kyaml/kio" "sigs.k8s.io/kustomize/kyaml/yaml" "edge-infra.dev/pkg/f8n/warehouse/capability" "edge-infra.dev/pkg/f8n/warehouse/lift" "edge-infra.dev/pkg/f8n/warehouse/lift/pack/filters/transformers/ambiguous" "edge-infra.dev/pkg/f8n/warehouse/lift/pack/filters/transformers/labels" "edge-infra.dev/pkg/f8n/warehouse/lift/pack/filters/transformers/palletmetadata" "edge-infra.dev/pkg/f8n/warehouse/lift/pack/internal" "edge-infra.dev/pkg/f8n/warehouse/lift/pack/types" "edge-infra.dev/pkg/f8n/warehouse/oci/layer" "edge-infra.dev/pkg/f8n/warehouse/pallet" "edge-infra.dev/pkg/k8s/eyaml/fieldspecs" ) // Packer produces Warehouse OCI artifacts from source manifests. type Packer struct { Context // execution context used for package lookups and accessing package sources buildInfo pallet.BuildInfo // build information added to all built packages capabilities map[capability.Capability]internal.Capability cache map[string]pallet.Pallet // cache of build results pkgCache map[string]*Package // cache of loaded package definition files cfgCache map[string]lift.Config // cache of package-specific warehouse configs } func New(c Context, build pallet.BuildInfo) (*Packer, error) { c, err := c.Default() if err != nil { return nil, fmt.Errorf("failed to instantiate packing context: %w", err) } b := &Packer{ Context: c, buildInfo: build, capabilities: make(map[capability.Capability]internal.Capability, len(c.Capabilities())), cache: make(map[string]pallet.Pallet), pkgCache: make(map[string]*Package), cfgCache: make(map[string]lift.Config), } for _, c := range b.Context.Capabilities() { var name capability.Capability switch { // infrastructure is only capability not named by package name case c.Package == b.Infrastructure.Package: name = capability.CapabilityInfra default: pkg, err := b.loadPkg(c.Package) if err != nil { return nil, err } name = capability.Capability(pkg.Config.Name) } var err error b.capabilities[name], err = internal.NewCapability(name, c) if err != nil { return nil, err } } return b, nil } // Pack compiles the sources at the input dir as well as any dependencies, // returning the packaged Pallet and its dependencies, if any, as an ordered // map. The provided build metadata is applied to all Pallets that are packed // from source. // TODO: honor build information from pallet.yaml if it exists // TODO: check each path as we parse Pallets and CapabilityProviders dependencies/capabilities so that we can fail early with a clear error message if its invalid func (b *Packer) Pack(dir string) (pallet.Pallet, error) { if filepath.IsAbs(dir) { // if path is absolute, it is invalid return nil, fmt.Errorf( "%s is not a valid warehouse package path, it should be relative to "+ "WAREHOUSE_PATH", dir, ) } return b.packPkg(dir) } func (b *Packer) packPkg(path string) (pallet.Pallet, error) { if plt, ok := b.cache[path]; ok { return plt, nil } if !b.FS.Exists(path) { return nil, fmt.Errorf("failed to find package %s", path) } pkg, err := b.loadPkg(path) if err != nil { return nil, err } if err := b.Compile(pkg); err != nil { return nil, err } // a single pallet with no dependencies means we have reached leaf node if len(pkg.Artifacts) == 1 && pkg.Config.Dependencies.IsEmpty() { // we can safely cast because all artifacts we are building are Pallets plt := pkg.Artifacts[0].(pallet.Pallet) // add result to our pkg cache b.cache[path] = plt b.pkgCache[path] = pkg return plt, nil } // now we know we are dealing with an image index // if providers is still empty, this is a composite/umbrella pallet. // if it doesn't explicitly set providers, assume it supports all providers // that are configured globally if len(pkg.Providers) == 0 && len(pkg.Config.Providers) == 0 { pkg.Providers = b.ClusterProviders } // build dependencies for _, d := range pkg.Config.Dependencies.Paths { dep, err := b.packPkg(d) if err != nil { return nil, err } pkg.Artifacts = append(pkg.Artifacts, dep) // aggregate dependency capabilities for final result for _, c := range dep.Capabilities() { if !slices.Contains(pkg.Capabilities, c) { pkg.Capabilities = append(pkg.Capabilities, c) } } // aggregate dependency parameters for _, depParam := range dep.Parameters() { if !slices.Contains(pkg.Parameters, depParam) { pkg.Parameters = append(pkg.Parameters, depParam) } } } // finally assemble final result now opts := pallet.Options{ Metadata: pkg.Metadata, ClusterProviders: pkg.Providers, Capabilities: pkg.Capabilities, DisableRendering: pkg.Config.DisableRendering, Parameters: pkg.Parameters, } plt, err := pallet.ImageIndex(opts, pkg.Artifacts...) if err != nil { return nil, err } // add result to our cache b.cache[path] = plt return plt, nil } // Compile renders the kustomize targets defined by the package, sorting manifests // into the appropriate layers and then packing the layers into OCI Images func (b *Packer) Compile(p *Package) error { if p.Config.ManifestTarget != nil { for _, m := range p.Config.ManifestTarget { layers, err := b.packRawLayers(p, m.Path) if err != nil { return err } p.Providers = append(p.Providers, m.Providers...) opts := pallet.Options{ ClusterProviders: m.Providers, Capabilities: p.Capabilities, Metadata: p.Metadata, DisableRendering: p.Config.DisableRendering, Parameters: p.Parameters, } img, err := pallet.Image(opts, layers...) if err != nil { return err } p.Artifacts = append(p.Artifacts, img) } } for _, t := range p.Config.Kustomize { layers, err := b.packKustomizeLayers(p, filepath.Join(p.Dir, t.Target)) if err != nil { return err } // track provider in overall result p.Providers = append(p.Providers, t.Providers...) opts := pallet.Options{ ClusterProviders: t.Providers, Capabilities: p.Capabilities, Metadata: p.Metadata, DisableRendering: p.Config.DisableRendering, Parameters: p.Parameters, } img, err := pallet.Image(opts, layers...) if err != nil { return err } p.Artifacts = append(p.Artifacts, img) } return nil } // packLayers executes kustomize against the target and packs the appropriate // layers based on the package definition and rendered contents. It returns // two arrays, the layers built and the total list of capabilities (by name) // that were found in the manifests. func (b *Packer) packLayers(p *Package, kres resmap.ResMap) ([]layer.Layer, error) { // TODO: better error context (package name, etc) for all errors throughout // build up list of filters we are going to apply against the result filters := append(b.Filters, &palletmetadata.Filter{ Annotations: annotations.Filter{ Annotations: p.Metadata.K8sAnnotations(), FsSlice: fieldspecs.Annotations, }, }, // TODO: allow configuring the fieldspecs here &ambiguous.Filter{}, ) // the labels filter adds variables to manifests, so can only be ran if // rendering is not disabled if !p.Config.DisableRendering { filters = append(filters, &labels.Filter{}) } for _, f := range filters { if err := kres.ApplyFilter(f); err != nil { return nil, fmt.Errorf("failed to execute filter %s: %w", f.Name(), err) } } // collect capabilities defined for this package to filter rendered objects // into the appropriate layers // +1 for built-in Infrastructure capabilities that any package can integrate // with caps := make([]internal.Capability, len(p.Config.Capabilities)+1) // add infra provider // TODO: support per-package infra provider config caps[0] = b.capabilities[capability.CapabilityInfra] for i, name := range p.Config.Capabilities { if c, ok := b.capabilities[name]; ok { // offset for global infra provider caps[i+1] = c continue } return nil, fmt.Errorf( "%s references unknown capability %s", p.Config.Name, name, ) } // sort resources into individual arrays based on which capability matches // resources that match none end up in the generic runtime layer // // NOTE: this code leverages the fact that pallet.LayerInfra and pallet.CapabilityInfra // are the same string constant. probably not worth addressing until // multiple infra providers are supported var ( resources = map[string][]*yaml.RNode{} yy = kres.ToRNodeSlice() ) for _, y := range yy { matched := false // whether or not current resource has been matched var match string // capability that matched, used for error messages // loop over capabilities, see if any match on this object for _, c := range caps { switch { // each resource can only match once case c.Matches(y) && matched: return nil, fmt.Errorf( "%s matched for both %s and %s. manifests can't be duplicated "+ "across layers. check your capability configuration to ensure that "+ "they do not cover overlapping resources.", fmtRNodeMetadata(y), c.Name, match, ) case c.Matches(y): matched = true match = string(c.Name) if resources[match] == nil { resources[match] = make([]*yaml.RNode, 0) } resources[match] = append(resources[match], y) } } // if not matched, drop into the base runtime layer if !matched { match = string(layer.Runtime) if resources[match] == nil { resources[match] = make([]*yaml.RNode, 0) } resources[match] = append(resources[match], y) } // reset match for next iteration matched = false match = "" } if resources[layer.Infra.String()] != nil { filt := labels.Filter{} filtered, err := filt.Filter(resources[layer.Infra.String()]) if err != nil { return nil, err } resources[layer.Infra.String()] = filtered } // order resources prior to layer packing to maintain order of capabilities resourceKeys := make([]string, 0, len(resources)) for k := range resources { resourceKeys = append(resourceKeys, k) } sort.Strings(resourceKeys) // finally pack layers with the appropriate objects var layers []layer.Layer for _, key := range resourceKeys { objs := resources[key] switch { // if there are no resources and it is a capability that was explicitly // set by the package definition, issa error case len(objs) == 0 && !layer.IsType(key): return nil, fmt.Errorf( "capability %s was specified but package produced no matching manifests", key, ) case len(objs) == 0: continue } // convert this layers documents into single string representation so we can // pack it ydata, err := kio.StringAll(objs) if err != nil { return nil, err } if !p.Config.DisableRendering { err := b.handleParams(p, ydata) if err != nil { return nil, err } } // pack the data based on the kind of layer it is switch key { // if its the base runtime layer or the infra layer, create it as normal case layer.Infra.String(): l, err := layer.New(layer.Infra, []byte(ydata)) if err != nil { return nil, err } layers = append(layers, l) // Reflect integration with capability in built package p.Capabilities = addCapability(p.Capabilities, capability.CapabilityInfra) case layer.Runtime.String(): l, err := layer.New(layer.Runtime, []byte(ydata)) if err != nil { return nil, err } layers = append(layers, l) // if its not base runtime or infra layer, it must be a runtime capability // so we add an additional annotation for the runtime capability name default: l, err := layer.New( layer.Runtime, []byte(ydata), layer.ForCapability(capability.Capability(key)), ) if err != nil { return nil, err } layers = append(layers, l) // Reflect integration with capability in built package p.Capabilities = addCapability(p.Capabilities, capability.Capability(key)) } } // sort layers, ordering of layers in images needs to be deterministic or else // digest of package with same contents will churn layer.Sort(layers) return layers, nil } func (b *Packer) packRawLayers(p *Package, t string) ([]layer.Layer, error) { file, err := b.FS.ReadFile(t) if err != nil { return nil, err } resourceFac := resource.NewFactory(&hasher.Hasher{}) resmapFac := resmap.NewFactory(resourceFac) kres, err := resmapFac.NewResMapFromBytes(file) if err != nil { return nil, err } return b.packLayers(p, kres) } func (b *Packer) packKustomizeLayers(p *Package, t string) ([]layer.Layer, error) { kres, err := b.Kustomizer.Run(b.FS, t) if err != nil { return nil, fmt.Errorf("failed to kustomize %s: %w", t, err) } return b.packLayers(p, kres) } // Artifacts returns all of the built Pallets from its internal cache func (b *Packer) Artifacts() []pallet.Pallet { r := make([]pallet.Pallet, 0, len(b.cache)) for _, v := range b.cache { r = append(r, v) } return r } // loadPkg loads a package definition file from the filesystem or the cache of // loaded package defs if present func (b *Packer) loadPkg(path string) (*Package, error) { if pkg, ok := b.pkgCache[path]; ok { return pkg, nil } pkgCfg, err := b.LoadPkg(path) if err != nil { return nil, err } pkg := &Package{ Config: pkgCfg, Warehouse: *b.Config, Dir: path, Metadata: b.pkgMeta(pkgCfg), } pkg.Warehouse, err = b.Config.WithParameters(pkg.Config.Parameters) if err != nil { return nil, err } for _, cfgpath := range pkg.Config.Configurations { cfg, err := b.loadCfg(cfgpath) if err != nil { return nil, err } pkg.Warehouse, err = mergeCfg(pkg.Warehouse, cfg) if err != nil { return nil, err } } return pkg, nil } // loadCfg loads a warehouse config file from the file system or the cache // of loaded configs if present func (b *Packer) loadCfg(path string) (lift.Config, error) { path = b.ResolveConfigPath(path) if cfg, ok := b.cfgCache[path]; ok { return cfg, nil } return b.LoadConfig(path) } func (b *Packer) pkgMeta(pkg types.Pallet) pallet.Metadata { version := b.buildInfo.Version // If version is set in pallet.yaml, honor it if it passes validation if err := pkg.PalletSpec.Version.Validate(); err == nil { version = pkg.PalletSpec.Version.String() } return pallet.Metadata{ Name: pkg.Name, Description: pkg.Description, Vendor: pkg.Vendor, Team: pkg.Team, BuildInfo: pallet.BuildInfo{ Source: b.buildInfo.Source, Version: version, Revision: b.buildInfo.Revision, Created: b.buildInfo.Created, }, // TODO: update build info based on package specs // TODO: implement readme support } } func fmtRNodeMetadata(y *yaml.RNode) string { return fmt.Sprintf("%s/%s/%s", y.GetKind(), y.GetNamespace(), y.GetName()) } func getParameters(data string) []string { var parameters []string re := regexp.MustCompile(`[^\$]\${1}{[[:word:]]*?}`) results := re.FindAllString(data, -1) // Exit early if no parameters are found if len(results) == 0 { return nil } trimmer := regexp.MustCompile(`[^[:word:]]+`) for _, result := range results { // trim result down such that only the rendering parameter name remains result = trimmer.ReplaceAllString(result, "") if !slices.Contains(parameters, result) { parameters = append(parameters, result) } } return parameters } func addCapability(cc capability.Capabilities, c capability.Capability) capability.Capabilities { if !slices.Contains(cc, c) { cc = append(cc, c) } return cc } func (b *Packer) handleParams(p *Package, ydata string) error { keys := make([]string, len(b.Config.Parameters)) for i, p := range b.Config.Parameters { keys[i] = p.Key } parameters := getParameters(ydata) for _, manifestP := range parameters { valid := false for _, p := range keys { if p == manifestP { // found valid parameter, exit early valid = true continue } } if !valid { return fmt.Errorf("rendering parameter %s is invalid: expected one of %v", manifestP, keys) } if !slices.Contains(p.Parameters, manifestP) { p.Parameters = append(p.Parameters, manifestP) } if manifestP == lift.ClusterHashRenderingParameter && !slices.Contains(p.Parameters, lift.ClusterUUIDRenderingParameter) { p.Parameters = append(p.Parameters, lift.ClusterUUIDRenderingParameter) } } return nil }