Compare commits

..

4 Commits

Author SHA1 Message Date
Jeff McCune
c2d5c4ad36 (#27) Add cert-manager to the mesh collection
Straight-forward helm install with no customization.

This patch also adds a "Skip" output kind which allows intermediate cue
files in the tree to signal holos to skip over the instance.  This
enables constraints to be added at intermediate layers without build
errors.
2024-02-29 16:50:27 -08:00
Jeff McCune
ab03ef1052 (#27) Refactor top level schema
Remove content and contentType top level keys, deprecated in favor of
apiObjects.

Clarify toward the use of #CollectionName instead of project name.
2024-02-29 15:48:54 -08:00
Jeff McCune
8c76061b0d (#27) Add recommended labels and sort output
Add the recommended labels mapping to holos stage, project, and
component names.  Project will eventually be renamed to "collection" or
something.

Example:

    app.kubernetes.io/part-of: prod
    app.kubernetes.io/name: secrets
    app.kubernetes.io/component: validate
    app.kubernetes.io/instance: prod-secrets-validate

Also sort the api objects produced from cue so the output of the `holos
render` command is stable for git commits.
2024-02-29 15:12:19 -08:00
Jeff McCune
f60db8fa1f (#25) Show name of api object in errors
This patch changes the interface between CUE and Holos to remove the
content field and replace it with an api object map.  The map is a
`map[string]map[string]string` with the rendered yaml as the value of a
kind/name nesting.

This structure enables better error messages, cue disjunction errors
indicate the type and the name of the resource instead of just the list
index number.
2024-02-29 11:23:49 -08:00
18 changed files with 416 additions and 121 deletions

42
cmd/holos/testdata/constraints.txt vendored Normal file
View File

@@ -0,0 +1,42 @@
# Want support for intermediary constraints
exec holos build ./foo/... --log-level debug
stdout '^bf2bc7f9-9ba0-4f9e-9bd2-9a205627eb0b$'
stderr 'processing holos component kind Skip'
-- cue.mod --
package holos
-- foo/constraints.cue --
package holos
metadata: name: "jeff"
-- foo/bar/bar.cue --
package holos
#KubernetesObjects & {
apiObjectMap: foo: bar: "bf2bc7f9-9ba0-4f9e-9bd2-9a205627eb0b"
}
-- schema.cue --
package holos
cluster: string @tag(cluster, string)
// #OutputTypeMeta is shared among all output types
#OutputTypeMeta: {
apiVersion: "holos.run/v1alpha1"
kind: #KubernetesObjects.kind | #NoOutput.kind
metadata: name: string
}
#KubernetesObjects: {
#OutputTypeMeta
kind: "KubernetesObjects"
apiObjectMap: {...}
}
#NoOutput: {
#OutputTypeMeta
kind: string | *"Skip"
metadata: name: string | *"skipped"
}
#NoOutput & {}

View File

@@ -1,7 +1,7 @@
# Want cue errors to show files and lines
! exec holos build .
stderr 'could not decode: content: cannot convert non-concrete value string'
stderr '/component.cue:6:1$'
stderr '^apiObjectMap.foo.bar: cannot convert non-concrete value string'
stderr '/component.cue:7:20$'
-- cue.mod --
package holos
@@ -11,5 +11,6 @@ package holos
apiVersion: "holos.run/v1alpha1"
kind: "KubernetesObjects"
cluster: string @tag(cluster, string)
content: foo
foo: string
apiObjectMap: foo: bar: baz
baz: string

View File

@@ -0,0 +1,57 @@
# Want kube api objects in the apiObjects output.
exec holos build .
stdout '^kind: SecretStore$'
stdout '# Source: CUE apiObjects.SecretStore.default'
-- cue.mod --
package holos
-- component.cue --
package holos
apiVersion: "holos.run/v1alpha1"
kind: "KubernetesObjects"
cluster: string @tag(cluster, string)
#SecretStore: {
kind: string
metadata: name: string
}
#APIObjects & {
apiObjects: {
SecretStore: {
default: #SecretStore & { metadata: name: "default" }
}
}
}
-- schema.cue --
package holos
// #APIObjects is the output type for api objects produced by cue. A map is used to aid debugging and clarity.
import "encoding/yaml"
#APIObjects: {
// apiObjects holds each the api objects produced by cue.
apiObjects: {
[Kind=_]: {
[Name=_]: {
kind: Kind
metadata: name: Name
}
}
}
// apiObjectsContent holds the marshalled representation of apiObjects
apiObjectMap: {
for kind, v in apiObjects {
"\(kind)": {
for name, obj in v {
"\(name)": yaml.Marshal(obj)
}
}
}
}
}

View File

@@ -0,0 +1,58 @@
# Want kube api objects in the apiObjects output.
exec holos build .
stdout '^kind: SecretStore$'
stdout '# Source: CUE apiObjects.SecretStore.default'
stderr 'skipping helm: no chart name specified'
-- cue.mod --
package holos
-- component.cue --
package holos
apiVersion: "holos.run/v1alpha1"
kind: "HelmChart"
cluster: string @tag(cluster, string)
#SecretStore: {
kind: string
metadata: name: string
}
#APIObjects & {
apiObjects: {
SecretStore: {
default: #SecretStore & { metadata: name: "default" }
}
}
}
-- schema.cue --
package holos
// #APIObjects is the output type for api objects produced by cue. A map is used to aid debugging and clarity.
import "encoding/yaml"
#APIObjects: {
// apiObjects holds each the api objects produced by cue.
apiObjects: {
[Kind=_]: {
[Name=_]: {
kind: Kind
metadata: name: Name
}
}
}
// apiObjectsContent holds the marshalled representation of apiObjects
apiObjectMap: {
for kind, v in apiObjects {
"\(kind)": {
for name, obj in v {
"\(name)": yaml.Marshal(obj)
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
# Want api object kind and name in errors
! exec holos build .
stderr 'apiObjects.secretstore.default.foo: field not allowed'
-- cue.mod --
package holos
-- component.cue --
package holos
apiVersion: "holos.run/v1alpha1"
kind: "KubernetesObjects"
cluster: string @tag(cluster, string)
#SecretStore: {
metadata: name: string
}
apiObjects: {
secretstore: {
default: #SecretStore & { foo: "not allowed" }
}
}

View File

@@ -8,21 +8,24 @@ package holos
// - Namespace
// - ServiceAccount eso-reader, eso-writer
import "list"
// objects are kubernetes api objects to apply.
objects: list.FlattenN(_objects, 1)
_objects: [
#CredsRefresherIAM.role,
#CredsRefresherIAM.binding,
for ns in #PlatformNamespaces {(#PlatformNamespaceObjects & {_ns: ns}).objects},
]
// No flux kustomization
ksObjects: []
{} & #KubernetesObjects
#KubernetesObjects & {
apiObjects: {
let role = #CredsRefresherIAM.role
let binding = #CredsRefresherIAM.binding
ClusterRole: "\(role.metadata.name)": role
ClusterRoleBinding: "\(binding.metadata.name)": binding
for ns in #PlatformNamespaces {
for obj in (#PlatformNamespaceObjects & {_ns: ns}).objects {
let Kind = obj.kind
let Name = obj.metadata.name
"\(Kind)": "\(ns.name)/\(Name)": obj
}
}
}
}
#InputKeys: {
cluster: "provisioner"

View File

@@ -1,7 +1,5 @@
package holos
import "list"
#TargetNamespace: "default"
#InputKeys: {
@@ -20,12 +18,14 @@ import "list"
]
}
objects: list.FlattenN(_objects, 1)
_objects: [
for ns in #PlatformNamespaces {
(#PlatformNamespaceObjects & {_ns: ns}).objects
},
]
{} & #KubernetesObjects
#KubernetesObjects & {
apiObjects: {
for ns in #PlatformNamespaces {
for obj in (#PlatformNamespaceObjects & {_ns: ns}).objects {
let Kind = obj.kind
let Name = obj.metadata.name
"\(Kind)": "\(Name)": obj
}
}
}
}

View File

@@ -0,0 +1,24 @@
package holos
// https://cert-manager.io/docs/
#TargetNamespace: "cert-manager"
#InputKeys: {
project: "mesh"
component: "certmanager"
service: "cert-manager"
}
#HelmChart & {
values: installCrds: true
namespace: #TargetNamespace
chart: {
name: "cert-manager"
version: "1.14.3"
repository: {
name: "jetstack"
url: "https://charts.jetstack.io"
}
}
}

View File

@@ -0,0 +1,10 @@
package holos
// All components are share this collection
#InputKeys: project: "mesh"
// Shared dependencies for all components in this collection.
#Kustomization: spec: {
dependsOn: [{name: "\(#StageName)-secrets-namespaces"}, ...]
targetNamespace: #TargetNamespace
}

View File

@@ -2,11 +2,16 @@ package holos
import "encoding/json"
// objects are kubernetes api objects to apply
objects: #CredsRefresherService.objects
// output kubernetes api objects for holos
{} & #KubernetesObjects
#KubernetesObjects & {
apiObjects: {
for obj in #CredsRefresherService.objects {
let Kind = obj.kind
let Name = obj.metadata.name
"\(Kind)": "\(Name)": obj
}
}
}
#InputKeys: {
project: "secrets"

View File

@@ -1,7 +1,5 @@
package holos
import "list"
#TargetNamespace: "default"
#InputKeys: {
@@ -18,12 +16,14 @@ import "list"
]
}
objects: list.FlattenN(_objects, 1)
_objects: [
for ns in #PlatformNamespaces {
(#PlatformNamespaceObjects & {_ns: ns}).objects
},
]
{} & #KubernetesObjects
#KubernetesObjects & {
apiObjects: {
for ns in #PlatformNamespaces {
for obj in (#PlatformNamespaceObjects & {_ns: ns}).objects {
let Kind = obj.kind
let Name = obj.metadata.name
"\(Kind)": "\(Name)": obj
}
}
}
}

View File

@@ -11,9 +11,13 @@ package holos
#Kustomization: spec: dependsOn: [{name: #InstancePrefix + "-eso"}]
objects: [
#SecretStore,
#ExternalSecret & {_name: "validate"},
]
{} & #KubernetesObjects
#KubernetesObjects & {
apiObjects: {
SecretStore: default: #SecretStore
ExternalSecret: validate: #ExternalSecret & {
_name: "validate"
}
}
}

View File

@@ -27,8 +27,11 @@ package holos
url: "https://ceph.github.io/csi-charts"
}
}
objects: [
#SecretStore,
#ExternalSecret & {_name: #SecretName},
]
apiObjects: {
SecretStore: default: #SecretStore
ExternalSecret: "\(#SecretName)": #ExternalSecret & {
_name: #SecretName
}
}
}

View File

@@ -11,31 +11,34 @@ import (
"encoding/yaml"
)
// #ClusterName is the cluster name for cluster scoped resources.
#ClusterName: #InputKeys.cluster
// _apiVersion is the version of this schema. Defines the interface between CUE output and the holos cli.
_apiVersion: "holos.run/v1alpha1"
// #Name defines the name: string key value pair used all over the place.
#Name: name: string
// #ClusterName is the cluster name for cluster scoped resources.
#ClusterName: #InputKeys.cluster
// #StageName is prod, dev, stage, etc... Usually prod for platform components.
#StageName: #InputKeys.stage
// #CollectionName is the preferred handle to the collection element of the instance name. A collection name mapes to an "application name" as described in the kubernetes recommended labels documentation. Refer to https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
#CollectionName: #InputKeys.project
// #ComponentName is the name of the holos component.
#ComponentName: #InputKeys.component
// #InstanceName is the name of the holos component instance being managed varying by stage, project, and component names.
#InstanceName: "\(#StageName)-\(#CollectionName)-\(#ComponentName)"
// #InstancePrefix is the stage and project without the component name. Useful for dependency management among multiple components for a project stage.
#InstancePrefix: "\(#StageName)-\(#CollectionName)"
// #TargetNamespace is the target namespace for a holos component.
#TargetNamespace: string
// #InstanceName is the name of the holos component instance being managed varying by stage, project, and component names.
#InstanceName: "\(#InputKeys.stage)-\(#InputKeys.project)-\(#InputKeys.component)"
// #InstancePrefix is the stage and project without the component name. Useful for dependency management among multiple components for a project stage.
#InstancePrefix: "\(#InputKeys.stage)-\(#InputKeys.project)"
// TypeMeta indicates a kubernetes api object
#TypeMeta: metav1.#TypeMeta
// #CommonLabels are mixed into every kubernetes api object.
#CommonLabels: {
"holos.run/stage.name": #InputKeys.stage
"holos.run/project.name": #InputKeys.project
"holos.run/component.name": #InputKeys.component
"holos.run/stage.name": #StageName
"holos.run/project.name": #CollectionName
"holos.run/component.name": #ComponentName
"app.kubernetes.io/part-of": #StageName
"app.kubernetes.io/name": #CollectionName
"app.kubernetes.io/component": #ComponentName
"app.kubernetes.io/instance": #InstanceName
...
}
@@ -138,8 +141,6 @@ _apiVersion: "holos.run/v1alpha1"
cluster: string @tag(cluster, type=string)
// stage is usually set by the platform or project.
stage: *"prod" | string @tag(stage, type=string)
// project is usually set by the platform or project.
project: string @tag(project, type=string)
// service is usually set by the component.
service: string @tag(service, type=string)
// component is the name of the component
@@ -168,7 +169,7 @@ _apiVersion: "holos.run/v1alpha1"
}
stages: [ID=_]: {
name: string & ID
environments: [...#Name]
environments: [...{name: string}]
}
projects: [ID=_]: {
name: string & ID
@@ -178,33 +179,53 @@ _apiVersion: "holos.run/v1alpha1"
}
}
// #APIObjects is the output type for api objects produced by cue. A map is used to aid debugging and clarity.
#APIObjects: {
// apiObjects holds each the api objects produced by cue.
apiObjects: {
[Kind=_]: {
[Name=_]: metav1.#TypeMeta & {
kind: Kind
}
}
}
// apiObjectsContent holds the marshalled representation of apiObjects
apiObjectMap: {
for kind, v in apiObjects {
"\(kind)": {
for name, obj in v {
"\(name)": yaml.Marshal(obj)
}
}
}
}
}
// #OutputTypeMeta is shared among all output types
#OutputTypeMeta: {
// apiVersion is the output api version
apiVersion: _apiVersion
// kind is a discriminator of the type of output
kind: #PlatformSpec.kind | #KubernetesObjects.kind | #HelmChart.kind
kind: #PlatformSpec.kind | #KubernetesObjects.kind | #HelmChart.kind | #NoOutput.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
contentType: *"application/yaml" | "application/json"
// content holds the content text output
content: string | *""
// debug returns arbitrary debug output.
debug?: _
}
#NoOutput: {
#OutputTypeMeta
kind: string | *"Skip"
metadata: name: string | *"skipped"
}
// #KubernetesObjectOutput is the output schema of a single component.
#KubernetesObjects: {
#OutputTypeMeta
// kind KubernetesObjects provides a yaml text stream of kubernetes api objects in the out field.
#APIObjects
kind: "KubernetesObjects"
// objects holds a list of the kubernetes api objects to configure.
objects: [...metav1.#TypeMeta] | *[]
// content holds the rendered yaml text stream of kubernetes api objects.
content: yaml.MarshalStream(objects)
contentType: "application/yaml"
metadata: name: #InstanceName
// ksObjects holds the flux Kustomization objects for gitops
ksObjects: [...#Kustomization] | *[#Kustomization]
// ksContent is the yaml representation of kustomization
@@ -229,7 +250,9 @@ _apiVersion: "holos.run/v1alpha1"
// #HelmChart is a holos component which produces kubernetes api objects from cue values provided to the helm template command.
#HelmChart: {
#OutputTypeMeta
#APIObjects
kind: "HelmChart"
metadata: name: #InstanceName
// ksObjects holds the flux Kustomization objects for gitops.
ksObjects: [...#Kustomization] | *[#Kustomization]
// ksContent is the yaml representation of kustomization.
@@ -246,11 +269,6 @@ _apiVersion: "holos.run/v1alpha1"
platform: #Platform
// instance returns the key values of the holos component instance.
instance: #InputKeys
// objects holds a list of the kubernetes api objects to configure.
objects: [...metav1.#TypeMeta] | *[]
// content holds the rendered yaml text stream of kubernetes api objects.
content: yaml.MarshalStream(objects)
contentType: "application/yaml"
}
// #PlatformSpec is the output schema of a platform specification.
@@ -259,10 +277,10 @@ _apiVersion: "holos.run/v1alpha1"
kind: "PlatformSpec"
}
#Output: #PlatformSpec | #KubernetesObjects | #HelmChart
// Holos component name
metadata: name: #InstanceName
// #SecretName is the name of a Secret, ususally coupling a Deployment to an ExternalSecret
#SecretName: string
// By default, render kind: Skipped so holos knows to skip over intermediate cue files.
// This enables the use of holos render ./foo/bar/baz/... when bar contains intermediary constraints which are not complete components.
// Holos skips over these intermediary cue instances.
{} & #NoOutput

View File

@@ -20,7 +20,10 @@ func makeBuildRunFunc(cfg *holos.Config) command.RunFunc {
}
outs := make([]string, 0, len(results))
for _, result := range results {
outs = append(outs, result.Content)
if result.Skip {
continue
}
outs = append(outs, result.FinalOutput())
}
out := strings.Join(outs, "---\n")
if _, err := fmt.Fprintln(cmd.OutOrStdout(), out); err != nil {

View File

@@ -27,9 +27,12 @@ func makeRenderRunFunc(cfg *holos.Config) command.RunFunc {
// the same file path. Write files into a blank temporary directory, error if a
// file exists, then move the directory into place.
for _, result := range results {
if result.Skip {
continue
}
// API Objects
path := result.Filename(cfg.WriteTo(), cfg.ClusterName())
if err := result.Save(ctx, path, result.Content); err != nil {
if err := result.Save(ctx, path, result.FinalOutput()); err != nil {
return wrapper.Wrap(err)
}
// Kustomization

View File

@@ -12,9 +12,11 @@ import (
"github.com/holos-run/holos/pkg/logger"
"github.com/holos-run/holos/pkg/util"
"github.com/holos-run/holos/pkg/wrapper"
"log/slog"
"os"
"os/exec"
"path/filepath"
"slices"
"cuelang.org/go/cue/cuecontext"
"cuelang.org/go/cue/load"
@@ -27,6 +29,8 @@ const (
// Helm is the value of the kind field of holos build output indicating helm
// values and helm command information.
Helm = "HelmChart"
// Skip is the value when the instance should be skipped
Skip = "Skip"
// ChartDir is the chart cache directory name.
ChartDir = "vendor"
)
@@ -73,12 +77,17 @@ type Metadata struct {
Name string `json:"name,omitempty"`
}
// apiObjectMap is the shape of marshalled api objects returned from cue to the
// holos cli. A map is used to improve the clarity of error messages from cue.
type apiObjectMap map[string]map[string]string
// Result is the build result for display or writing.
type Result struct {
Metadata Metadata `json:"metadata,omitempty"`
Content string `json:"content,omitempty"`
ContentType string `json:"contentType"`
KsContent string `json:"ksContent,omitempty"`
Metadata Metadata `json:"metadata,omitempty"`
KsContent string `json:"ksContent,omitempty"`
APIObjectMap apiObjectMap `json:"apiObjectMap,omitempty"`
finalOutput string
Skip bool
}
type Repository struct {
@@ -94,15 +103,14 @@ type Chart struct {
// 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"`
ContentType string `json:"contentType"`
Content string `json:"content"`
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"`
APIObjectMap apiObjectMap `json:"APIObjectMap"`
}
// Name returns the metadata name of the result. Equivalent to the
@@ -119,6 +127,42 @@ func (r *Result) KustomizationFilename(writeTo string, cluster string) string {
return filepath.Join(writeTo, "clusters", cluster, "holos", "components", r.Name()+"-kustomization.gen.yaml")
}
// FinalOutput returns the final rendered output.
func (r *Result) FinalOutput() string {
return r.finalOutput
}
// addAPIObjects adds the overlay api objects to finalOutput.
func (r *Result) addOverlayObjects(log *slog.Logger) {
b := []byte(r.FinalOutput())
kinds := make([]string, 0, len(r.APIObjectMap))
// Sort the keys
for kind := range r.APIObjectMap {
kinds = append(kinds, kind)
}
slices.Sort(kinds)
for _, kind := range kinds {
v := r.APIObjectMap[kind]
// Sort the keys
names := make([]string, 0, len(v))
for name := range v {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
yamlString := v[name]
log.Debug(fmt.Sprintf("%s/%s", kind, name), "kind", kind, "name", name)
util.EnsureNewline(b)
header := fmt.Sprintf("---\n# Source: CUE apiObjects.%s.%s\n", kind, name)
b = append(b, []byte(header+yamlString)...)
util.EnsureNewline(b)
}
}
r.finalOutput = string(b)
}
// Save writes the content to the filesystem for git ops.
func (r *Result) Save(ctx context.Context, path string, content string) error {
log := logger.FromContext(ctx)
@@ -210,11 +254,14 @@ func (b *Builder) Run(ctx context.Context) (results []*Result, err error) {
log.DebugContext(ctx, "cue: processing holos component kind "+info.Kind)
switch kind := info.Kind; kind {
case Skip:
result.Skip = true
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))
}
result.addOverlayObjects(log)
case Helm:
var helmChart HelmChart
// First decode into the result. Helm will populate the api objects later.
@@ -229,16 +276,7 @@ func (b *Builder) Run(ctx context.Context) (results []*Result, err error) {
if err := runHelm(ctx, &helmChart, &result, holos.PathComponent(instance.Dir)); err != nil {
return nil, err
}
// Append any cue api objects defined alongside the helm holos component.
if helmChart.Content != "" && helmChart.ContentType == "application/yaml" {
buf := []byte(result.Content)
util.EnsureNewline(buf)
buf = append(buf, []byte("---\n# Source: holos component overlay objects\n")...)
buf = append(buf, []byte(helmChart.Content)...)
log.DebugContext(ctx, "added additional api objects", "bytes", len(buf))
result.Content = string(buf)
}
result.addOverlayObjects(log)
default:
return nil, wrapper.Wrap(fmt.Errorf("build kind not implemented: %v", kind))
}
@@ -300,6 +338,10 @@ func runCmd(ctx context.Context, name string, args ...string) (result runResult,
// the rendered kubernetes api objects in the result.
func runHelm(ctx context.Context, hc *HelmChart, r *Result, path holos.PathComponent) error {
log := logger.FromContext(ctx).With("chart", hc.Chart.Name)
if hc.Chart.Name == "" {
log.WarnContext(ctx, "skipping helm: no chart name specified, use a different component type")
return nil
}
cachedChartPath := filepath.Join(string(path), ChartDir, hc.Chart.Name)
if isNotExist(cachedChartPath) {
@@ -342,7 +384,7 @@ func runHelm(ctx context.Context, hc *HelmChart, r *Result, path holos.PathCompo
return wrapper.Wrap(fmt.Errorf("could not run helm template: %w", err))
}
r.Content = helmOut.stdout.String()
r.finalOutput = helmOut.stdout.String()
return nil
}
@@ -362,7 +404,7 @@ func isNotExist(path string) bool {
return os.IsNotExist(err)
}
// cacheChart stores a cached copy of Chart in the chart sub-directory of path.
// cacheChart stores a cached copy of Chart in the chart subdirectory of path.
func cacheChart(ctx context.Context, path holos.PathComponent, chartDir string, chart Chart) error {
log := logger.FromContext(ctx)

View File

@@ -1 +1 @@
47
48