Compare commits

..

5 Commits

Author SHA1 Message Date
Jeff McCune
cde4380049 Add holos component HelmChart type
This patch implements rendering a holos component from an upstream helm
chart using a values.yaml file generated by CUE.  The resulting
kubernetes api objects are saved to the deploy directory in the same way
the KubernetesObject holos component type.

```
❯ holos render --cluster-name=core2 ./docs/examples/platforms/reference/projects/secrets/components/...
3:55PM INF render.go:39 rendered prod-secrets-eso version=0.41.0 status=ok action=rendered name=prod-secrets-eso
3:55PM INF render.go:39 rendered prod-secrets-namespaces version=0.41.0 status=ok action=rendered name=prod-secrets-namespaces
```

```
❯ tree deploy
deploy
└── clusters
    └── core2
        ├── components
        │   ├── prod-secrets-eso
        │   │   └── prod-secrets-eso.gen.yaml
        │   └── prod-secrets-namespaces
        │       └── prod-secrets-namespaces.gen.yaml
        └── holos
            └── components
                ├── prod-secrets-eso-kustomization.gen.yaml
                └── prod-secrets-namespaces-kustomization.gen.yaml

7 directories, 4 files
```
2024-02-12 15:56:06 -08:00
Jeff McCune
0d4f36333f Add platform and instance values to helm values 2024-02-12 10:16:40 -08:00
Jeff McCune
69916a13ab Decode cue values for use as helm values
In helm mode, cue is responsible for producing the values.yaml file.
Holos is responsible for taking the values produced by cue and providing
them to helm to produce rendered kubernetes api objects.

This patch adds intermediate data structures to hold the output from
cue: the helm values, the flux kustomization, and the helm charts to
provide the helm values to.

Holos takes this information and orchestrates running helm template to
render the api objects and write them to the file system for git ops.
2024-02-12 09:53:47 -08:00
Jeff McCune
9739fc6471 Initial structure for helm support
Stopping here to look into generating go types from the cue output type
definitions.
2024-02-10 17:14:42 -08:00
Jeff McCune
1d3b9340ab Fix log message 2024-02-09 15:54:02 -08:00
12 changed files with 239 additions and 38 deletions

View File

@@ -0,0 +1,3 @@
package holos
#InputKeys: component: "eso"

View File

@@ -0,0 +1,14 @@
package holos
#HelmChart & {
values: installCrds: true
namespace: #TargetNamespace
chart: {
name: "external-secrets"
version: "0.9.12"
repository: {
name: "external-secrets"
url: "https://charts.external-secrets.io"
}
}
}

View File

@@ -5,6 +5,6 @@ package holos
metadata: name: #InstanceName
objects: [
#Namespace & {
metadata: name: "external-secrets"
metadata: name: #TargetNamespace
}
]

View File

@@ -4,8 +4,3 @@ package holos
{} & #KubernetesObjects & {
ksObjects: [#Kustomization]
}
#InputKeys: {
project: "secrets"
service: "eso"
}

View File

@@ -0,0 +1,11 @@
package holos
#TargetNamespace: "external-secrets"
#InputKeys: {
project: "secrets"
service: "eso"
}
// Holos component name
metadata: name: #InstanceName

View File

@@ -25,6 +25,9 @@ _apiVersion: "holos.run/v1alpha1"
...
}
// #TargetNamespace is the target namespace for a holos component.
#TargetNamespace: string
// Kubernetes API Objects
#Namespace: corev1.#Namespace & #NamespaceMeta
#ConfigMap: corev1.#ConfigMap
@@ -92,7 +95,7 @@ _Platform: #Platform
// apiVersion is the output api version
apiVersion: _apiVersion
// kind is a discriminator of the type of output
kind: #PlatformSpec.kind | #KubernetesObjects.kind | #ChartValues.kind
kind: #PlatformSpec.kind | #KubernetesObjects.kind | #HelmChart.kind
// name holds a unique name suitable for a filename
metadata: name: string
// contentType is the standard MIME type indicating the content type of the content field
@@ -120,10 +123,36 @@ _Platform: #Platform
platform: _Platform
}
// #ChartValues is the output schema of a holos component which produces values for a helm chart.
#ChartValues: {
// #Chart defines an upstream helm chart
#Chart: {
name: string
version: string
repository: {
name: string
url: string
}
}
// #HelmChart is a holos component which produces kubernetes api objects from cue values provided to the helm template command.
#HelmChart: {
#OutputTypeMeta
kind: "ChartValues"
kind: "HelmChart"
// ksObjects holds the flux Kustomization objects for gitops.
ksObjects: [...#Kustomization] | *[#Kustomization]
// ksContent is the yaml representation of kustomization.
ksContent: yaml.MarshalStream(ksObjects)
// namespace defines the value passed to the helm --namespace flag
namespace: #TargetNamespace
// chart defines the upstream helm chart to process.
chart: #Chart
// values represents the helm values to provide to the chart.
values: {...}
// valuesContent holds the values yaml
valuesContent: yaml.Marshal(values)
// platform returns the platform data structure for visibility / troubleshooting.
platform: _Platform
// instance returns the key values of the holos component instance.
instance: #InputKeys
}
// #PlatformSpec is the output schema of a platform specification.
@@ -132,4 +161,4 @@ _Platform: #Platform
kind: "PlatformSpec"
}
#Output: #PlatformSpec | #KubernetesObjects | #ChartValues
#Output: #PlatformSpec | #KubernetesObjects | #HelmChart

6
holos.go Normal file
View File

@@ -0,0 +1,6 @@
// Package holos defines types for the rest of the system.
package holos
// A PathCueMod is a string representing the filesystem path of a cue module.
// It is given a unique type so the API is clear.
type PathCueMod string

View File

@@ -36,7 +36,7 @@ func makeRenderRunFunc(cfg *config.Config) runFunc {
if err := result.Save(ctx, path, result.KsContent); err != nil {
return wrapper.Wrap(err)
}
log.InfoContext(ctx, "rendered "+result.Name(), "status", "ok", "action", "save", "path", path, "name", result.Name())
log.InfoContext(ctx, "rendered "+result.Name(), "status", "ok", "action", "rendered", "name", result.Name())
}
return nil
}

View File

@@ -9,7 +9,8 @@ import (
"os"
)
// An Option configures a Config
// An Option configures a Config using [functional
// options](https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html).
type Option func(o *options)
type options struct {
@@ -17,12 +18,12 @@ type options struct {
stderr io.Writer
}
// Stdout redirects standard output to w
// Stdout redirects standard output to w, useful for test capture.
func Stdout(w io.Writer) Option {
return func(o *options) { o.stdout = w }
}
// Stderr redirects standard error to w
// Stderr redirects standard error to w, useful for test capture.
func Stderr(w io.Writer) Option {
return func(o *options) { o.stderr = w }
}
@@ -51,7 +52,9 @@ func New(opts ...Option) *Config {
return cfg
}
// Config holds configuration for the whole program, used by main()
// Config holds configuration for the whole program, used by main(). The config
// should be initialized early at a well known location in the program lifecycle
// then remain immutable.
type Config struct {
logConfig *logger.Config
writeTo string
@@ -63,7 +66,7 @@ type Config struct {
clusterFlagSet *flag.FlagSet
}
// LogFlagSet returns the logging *flag.FlagSet
// LogFlagSet returns the logging *flag.FlagSet for use by the command handler.
func (c *Config) LogFlagSet() *flag.FlagSet {
return c.logConfig.FlagSet()
}
@@ -93,12 +96,12 @@ func (c *Config) Finalize() error {
return nil
}
// Vet validates the config
// Vet validates the config.
func (c *Config) Vet() error {
return c.logConfig.Vet()
}
// Logger returns a *slog.Logger configured by the user
// Logger returns a *slog.Logger configured by the user.
func (c *Config) Logger() *slog.Logger {
if c.logger != nil {
return c.logger
@@ -127,12 +130,12 @@ func (c *Config) WriteTo() string {
return c.writeTo
}
// ClusterName returns the configured cluster name
// ClusterName returns the cluster name configured by flags.
func (c *Config) ClusterName() string {
return c.clusterName
}
// getenv is equivalent to os.Getenv() with a default value
// getenv is equivalent to os.LookupEnv with a default value.
func getenv(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value

View File

@@ -4,17 +4,30 @@
package builder
import (
"bytes"
"context"
"cuelang.org/go/cue/build"
"fmt"
"github.com/holos-run/holos"
"github.com/holos-run/holos/pkg/logger"
"github.com/holos-run/holos/pkg/wrapper"
"os"
"os/exec"
"path/filepath"
"cuelang.org/go/cue/cuecontext"
"cuelang.org/go/cue/load"
)
const (
// Kube is the value of the kind field of holos build output indicating
// kubernetes api objects.
Kube = "KubernetesObjects"
// Helm is the value of the kind field of holos build output indicating helm
// values and helm command information.
Helm = "HelmChart"
)
// An Option configures a Builder
type Option func(*config)
@@ -64,6 +77,28 @@ type Result struct {
KsContent string `json:"ksContent,omitempty"`
}
type Repository struct {
Name string `json:"name"`
URL string `json:"url"`
}
type Chart struct {
Name string `json:"name"`
Version string `json:"version"`
Repository Repository `json:"repository"`
}
// A HelmChart represents a helm command to provide chart values in order to render kubernetes api objects.
type HelmChart struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata Metadata `json:"metadata"`
KsContent string `json:"ksContent"`
Namespace string `json:"namespace"`
Chart Chart `json:"chart"`
ValuesContent string `json:"valuesContent"`
}
// Name returns the metadata name of the result. Equivalent to the
// OrderedComponent name specified in platform.yaml in the holos prototype.
func (r *Result) Name() string {
@@ -100,15 +135,15 @@ func (b *Builder) Cluster() string {
return b.cfg.cluster
}
func (b *Builder) Run(ctx context.Context) ([]*Result, error) {
// Instances returns the cue build instances being built.
func (b *Builder) Instances(ctx context.Context) ([]*build.Instance, error) {
log := logger.FromContext(ctx)
cueCtx := cuecontext.New()
results := make([]*Result, 0, len(b.cfg.args))
dir, err := b.findCueMod()
mod, err := b.findCueMod()
if err != nil {
return nil, wrapper.Wrap(err)
}
dir := string(mod)
cfg := load.Config{Dir: dir}
@@ -126,39 +161,68 @@ func (b *Builder) Run(ctx context.Context) ([]*Result, error) {
relPath = "./" + relPath
args[idx] = relPath
equiv := fmt.Sprintf("cue export --out yaml -t cluster=%v %v", b.Cluster(), relPath)
log.Debug(equiv)
log.Debug("cue: equivalent command: " + equiv)
}
// Refer to https://github.com/cue-lang/cue/blob/v0.7.0/cmd/cue/cmd/common.go#L429
cfg.Tags = append(cfg.Tags, "cluster="+b.Cluster())
log.DebugContext(ctx, fmt.Sprintf("configured cue tags: %v", cfg.Tags))
log.DebugContext(ctx, fmt.Sprintf("cue: tags %v", cfg.Tags))
instances := load.Instances(args, &cfg)
return load.Instances(args, &cfg), nil
}
func (b *Builder) Run(ctx context.Context) (results []*Result, err error) {
results = make([]*Result, 0, len(b.cfg.args))
cueCtx := cuecontext.New()
logger.FromContext(ctx).DebugContext(ctx, "cue: building instances")
instances, err := b.Instances(ctx)
if err != nil {
return results, err
}
for _, instance := range instances {
var info buildInfo
var result Result
log := logger.FromContext(ctx).With("dir", instance.Dir)
results = append(results, &result)
if err := instance.Err; err != nil {
return nil, wrapper.Wrap(fmt.Errorf("could not load: %w", err))
}
log.DebugContext(ctx, "cue: building instance")
value := cueCtx.BuildInstance(instance)
if err := value.Err(); err != nil {
return nil, wrapper.Wrap(fmt.Errorf("could not build: %w", err))
}
log.DebugContext(ctx, "cue: validating instance")
if err := value.Validate(); err != nil {
return nil, wrapper.Wrap(fmt.Errorf("could not validate: %w", err))
}
log.DebugContext(ctx, "cue: decoding holos component build info")
if err := value.Decode(&info); err != nil {
return nil, wrapper.Wrap(fmt.Errorf("could not decode: %w", err))
}
log.DebugContext(ctx, "cue: processing holos component kind "+info.Kind)
switch kind := info.Kind; kind {
case "KubernetesObjects":
case Kube:
// CUE directly provides the kubernetes api objects in result.Content
if err := value.Decode(&result); err != nil {
return nil, wrapper.Wrap(fmt.Errorf("could not decode: %w", err))
}
case Helm:
var helmChart HelmChart
// First decode into the result. Helm will populate the api objects later.
if err := value.Decode(&result); err != nil {
return nil, wrapper.Wrap(fmt.Errorf("could not decode: %w", err))
}
// Decode again into the helm chart struct to get the values content to provide to helm.
if err := value.Decode(&helmChart); err != nil {
return nil, wrapper.Wrap(fmt.Errorf("could not decode: %w", err))
}
// runHelm populates result.Content from helm template output.
if err := runHelm(ctx, &helmChart, &result); err != nil {
return nil, err
}
default:
return nil, wrapper.Wrap(fmt.Errorf("build kind not implemented: %v", kind))
}
@@ -170,14 +234,15 @@ func (b *Builder) Run(ctx context.Context) ([]*Result, error) {
// findCueMod returns the root module location containing the cue.mod file or
// directory or an error if the builder arguments do not share a common root
// module.
func (b *Builder) findCueMod() (dir string, err error) {
func (b *Builder) findCueMod() (dir holos.PathCueMod, err error) {
for _, origPath := range b.cfg.args {
var path string
if path, err = filepath.Abs(origPath); err != nil {
return
absPath, err := filepath.Abs(origPath)
if err != nil {
return "", err
}
path := holos.PathCueMod(absPath)
for {
if _, err := os.Stat(filepath.Join(path, "cue.mod")); err == nil {
if _, err := os.Stat(filepath.Join(string(path), "cue.mod")); err == nil {
if dir != "" && dir != path {
return "", fmt.Errorf("multiple modules not supported: %v is not %v", dir, path)
}
@@ -186,7 +251,7 @@ func (b *Builder) findCueMod() (dir string, err error) {
} else if !os.IsNotExist(err) {
return "", err
}
parentPath := filepath.Dir(path)
parentPath := holos.PathCueMod(filepath.Dir(string(path)))
if parentPath == path {
return "", fmt.Errorf("no cue.mod from root to leaf: %v", origPath)
}
@@ -195,3 +260,78 @@ func (b *Builder) findCueMod() (dir string, err error) {
}
return dir, nil
}
type runResult struct {
stdout *bytes.Buffer
stderr *bytes.Buffer
}
func runCmd(ctx context.Context, name string, args ...string) (result runResult, err error) {
result = runResult{
stdout: new(bytes.Buffer),
stderr: new(bytes.Buffer),
}
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = result.stdout
cmd.Stderr = result.stderr
log := logger.FromContext(ctx)
log.DebugContext(ctx, "running: "+name, "name", name, "args", args)
err = cmd.Run()
return
}
// runHelm provides the values produced by CUE to helm template and returns
// the rendered kubernetes api objects in the result.
func runHelm(ctx context.Context, hc *HelmChart, r *Result) error {
log := logger.FromContext(ctx).With("chart", hc.Chart.Name)
// Add repositories
repo := hc.Chart.Repository
out, err := runCmd(ctx, "helm", "repo", "add", repo.Name, repo.URL)
if err != nil {
log.ErrorContext(ctx, "could not run helm", "stderr", out.stderr.String(), "stdout", out.stdout.String())
return wrapper.Wrap(fmt.Errorf("could not run helm repo add: %w", err))
}
out, err = runCmd(ctx, "helm", "repo", "update", repo.Name)
if err != nil {
log.ErrorContext(ctx, "could not run helm", "stderr", out.stderr.String(), "stdout", out.stdout.String())
return wrapper.Wrap(fmt.Errorf("could not run helm repo update: %w", err))
}
// Write values file
tempDir, err := os.MkdirTemp("", "holos")
if err != nil {
return wrapper.Wrap(fmt.Errorf("could not make temp dir: %w", err))
}
defer remove(ctx, tempDir)
// Write values file
valuesPath := filepath.Join(tempDir, "values.yaml")
if err := os.WriteFile(valuesPath, []byte(hc.ValuesContent), 0644); err != nil {
return wrapper.Wrap(fmt.Errorf("could not write values: %w", err))
}
log.DebugContext(ctx, "wrote values", "path", valuesPath)
// TODO: Cache the chart
// Run charts
chart := hc.Chart
chartPath := fmt.Sprintf("%s/%s", chart.Repository.Name, chart.Name)
helmOut, err := runCmd(ctx, "helm", "template", "--values", valuesPath, "--namespace", hc.Namespace, "--kubeconfig", "/dev/null", "--version", chart.Version, chart.Name, chartPath)
if err != nil {
return wrapper.Wrap(fmt.Errorf("could not run helm template: %w", err))
}
r.Content = helmOut.stdout.String()
return nil
}
// remove cleans up path, useful for temporary directories.
func remove(ctx context.Context, path string) {
log := logger.FromContext(ctx)
if err := os.RemoveAll(path); err != nil {
log.WarnContext(ctx, "could not remove", "err", err, "path", path)
} else {
log.DebugContext(ctx, "removed", "path", path)
}
}

View File

@@ -1 +1 @@
40
41

View File

@@ -1 +1 @@
4
0