Files
holos/internal/builder/instance.go
Jeff McCune 2c79982bd3 cue: enable @embed for loading yaml (#385)
mpvl suggests @embed is a more ideal solution than our implementation of
core.Component.Instances for the use case of unifying YAML data updated
by Kargo Stage resources.

See the issue for a link to the discussion.
2024-12-20 07:14:01 -08:00

193 lines
4.5 KiB
Go

package builder
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
"cuelang.org/go/cue/interpreter/embed"
"cuelang.org/go/cue/load"
"cuelang.org/go/encoding/yaml"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/holos"
"github.com/holos-run/holos/internal/util"
)
// ExtractYAML extracts yaml encoded data from file paths. The data is unified
// into one [cue.Value]. If a path element is a directory, all files in the
// directory are loaded non-recursively.
//
// Attribution: https://github.com/cue-lang/cue/issues/3504
func ExtractYAML(ctxt *cue.Context, filepaths []string) (cue.Value, error) {
value := ctxt.CompileString("")
files := make([]string, 0, 10*len(filepaths))
for _, path := range filepaths {
info, err := os.Stat(path)
if err != nil {
return value, errors.Wrap(err)
}
if !info.IsDir() {
files = append(files, path)
continue
}
entries, err := os.ReadDir(path)
if err != nil {
return value, errors.Wrap(err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
files = append(files, filepath.Join(path, entry.Name()))
}
}
for _, file := range files {
f, err := yaml.Extract(file, nil)
if err != nil {
return value, errors.Wrap(err)
}
value = value.Unify(ctxt.BuildFile(f))
}
return value, nil
}
// LoadInstance loads the cue configuration instance at path. External data
// file paths are loaded by calling [ExtractYAML] providing filepaths. The
// extracted data values are unified with the platform configuration [cue.Value]
// in the returned [Instance].
func LoadInstance(path string, filepaths []string, tags []string) (*Instance, error) {
root, leaf, err := util.FindRootLeaf(path)
if err != nil {
return nil, errors.Wrap(err)
}
cfg := &load.Config{
Dir: root,
ModuleRoot: root,
Tags: tags,
}
ctxt := cuecontext.New(cuecontext.Interpreter(embed.New()))
bis := load.Instances([]string{path}, cfg)
values, err := ctxt.BuildInstances(bis)
if err != nil {
return nil, errors.Wrap(err)
}
value, err := ExtractYAML(ctxt, filepaths)
if err != nil {
return nil, errors.Wrap(err)
}
// TODO: https://cuelang.org/docs/howto/place-data-go-api/
value = value.Unify(values[0])
inst := &Instance{
path: leaf,
ctx: ctxt,
cfg: cfg,
value: value,
}
return inst, nil
}
// Instance represents a cue instance to build. Use LoadInstance to create a
// new Instance.
type Instance struct {
path string
ctx *cue.Context
cfg *load.Config
value cue.Value
}
// HolosValue returns the value of the holos field of the exported CUE instance.
func (i *Instance) HolosValue() (v cue.Value, err error) {
v = i.value.LookupPath(cue.ParsePath("holos"))
if err = v.Err(); err != nil {
if strings.HasPrefix(err.Error(), "field not found") {
slog.Warn(fmt.Sprintf("%s: deprecated usage: nest output under holos: %s", err, i.path), "err", err)
// Return the deprecated value at the root
return i.value, nil
}
err = errors.Wrap(err)
}
return
}
// Discriminate calls the discriminate func for side effects. Useful to switch
// over the instance kind and apiVersion.
func (i *Instance) Discriminate(discriminate func(tm holos.TypeMeta) error) error {
v, err := i.HolosValue()
if err != nil {
return errors.Wrap(err)
}
var tm holos.TypeMeta
kind := v.LookupPath(cue.ParsePath("kind"))
if err := kind.Err(); err != nil {
return errors.Wrap(err)
}
if tm.Kind, err = kind.String(); err != nil {
return errors.Wrap(err)
}
version := v.LookupPath(cue.ParsePath("apiVersion"))
if err := version.Err(); err != nil {
return errors.Wrap(err)
}
if tm.APIVersion, err = version.String(); err != nil {
return errors.Wrap(err)
}
if err := discriminate(tm); err != nil {
return errors.Wrap(err)
}
return nil
}
func (i *Instance) Decoder() (*json.Decoder, error) {
v, err := i.HolosValue()
if err != nil {
return nil, errors.Wrap(err)
}
jsonBytes, err := v.MarshalJSON()
if err != nil {
return nil, errors.Wrap(err)
}
decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
decoder.DisallowUnknownFields()
return decoder, nil
}
func (i *Instance) Export(enc holos.Encoder) error {
v, err := i.HolosValue()
if err != nil {
return errors.Wrap(err)
}
var data interface{}
if err := v.Decode(&data); err != nil {
return errors.Wrap(err)
}
if err := enc.Encode(&data); err != nil {
return errors.Wrap(err)
}
return nil
}