Skip to content

Commit

Permalink
Merge pull request #21 from stefanprodan/api-version
Browse files Browse the repository at this point in the history
Add API versioning to Timoni's CUE definition
  • Loading branch information
stefanprodan authored Mar 1, 2023
2 parents d54203b + 25b4d8c commit 2f6b99d
Show file tree
Hide file tree
Showing 12 changed files with 266 additions and 134 deletions.
36 changes: 36 additions & 0 deletions api/v1alpha1/selectors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
Copyright 2023 Stefan Prodan
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v1alpha1

// Selector is an enumeration of the supported CUE paths known to Timoni.
type Selector string

// String returns the string representation of the Selector.
func (s Selector) String() string {
return string(s)
}

const (
// APIVersionSelector is the CUE path for the Timoni's API version.
APIVersionSelector Selector = "timoni.apiVersion"

// ValuesSelector is the CUE path for the Timoni's module values.
ValuesSelector Selector = "values"

// ApplySelector is the CUE path for the Timoni's apply resource sets.
ApplySelector Selector = "timoni.apply"
)
9 changes: 9 additions & 0 deletions cmd/timoni/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ func runApplyCmd(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to build instance, error: %w", err)
}

apiVer, err := builder.GetAPIVersion(buildResult)
if err != nil {
return err
}

if apiVer != apiv1.GroupVersion.Version {
return fmt.Errorf("API version %s not supported, must be %s", apiVer, apiv1.GroupVersion.Version)
}

finalValues, err := builder.GetValues(buildResult)
if err != nil {
return fmt.Errorf("failed to extract values, error: %w", err)
Expand Down
9 changes: 9 additions & 0 deletions cmd/timoni/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ func runBuildCmd(cmd *cobra.Command, args []string) error {
return fmt.Errorf("build failed, error: %w", err)
}

apiVer, err := builder.GetAPIVersion(buildResult)
if err != nil {
return err
}

if apiVer != apiv1.GroupVersion.Version {
return fmt.Errorf("API version %s not supported, must be %s", apiVer, apiv1.GroupVersion.Version)
}

applySets, err := builder.GetApplySets(buildResult)
if err != nil {
return fmt.Errorf("failed to extract objects, error: %w", err)
Expand Down
40 changes: 22 additions & 18 deletions cmd/timoni/testdata/module/timoni.cue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Code generated by timoni. DO NOT EDIT.
// Note that this file is required and should export
// a stream of Kubernetes objects under the timoni.apply field.
// Note that this file is required and should contain
// the values schema and the timoni workflow.

package main

Expand All @@ -13,23 +13,27 @@ import (
// and validates them according to the Config schema.
values: templates.#Config

// Define the instance that outputs the Kubernetes resources.
// At runtime, Timoni builds the instance and validates
// the resulting resources according to their Kubernetes schema.
instance: templates.#Instance & {
// The user-supplied values are merged with the
// default values at runtime by Timoni.
config: values
// The instance name and namespace tag values
// are injected at runtime by Timoni.
config: metadata: {
name: string @tag(name)
namespace: string @tag(namespace)
// Define how Timoni should build, validate and
// apply the Kubernetes resources.
timoni: {
apiVersion: "v1alpha1"

// Define the instance that outputs the Kubernetes resources.
// At runtime, Timoni builds the instance and validates
// the resulting resources according to their Kubernetes schema.
instance: templates.#Instance & {
// The user-supplied values are merged with the
// default values at runtime by Timoni.
config: values
// The instance name and namespace tag values
// are injected at runtime by Timoni.
config: metadata: {
name: string @tag(name)
namespace: string @tag(namespace)
}
}
}

// Pass Kubernetes resources outputted by the instance
// to Timoni's multi-step apply.
timoni: {
// Pass Kubernetes resources outputted by the instance
// to Timoni's multi-step apply.
apply: all: [ for obj in instance.objects {obj}]
}
40 changes: 22 additions & 18 deletions examples/podinfo/timoni.cue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Code generated by timoni. DO NOT EDIT.
// Note that this file is required and should export
// a stream of Kubernetes objects under the timoni.apply field.
// Note that this file is required and should contain
// the values schema and the timoni workflow.

package main

Expand All @@ -13,23 +13,27 @@ import (
// and validates them according to the Config schema.
values: templates.#Config

// Define the instance that outputs the Kubernetes resources.
// At runtime, Timoni builds the instance and validates
// the resulting resources according to their Kubernetes schema.
instance: templates.#Instance & {
// The user-supplied values are merged with the
// default values at runtime by Timoni.
config: values
// The instance name and namespace tag values
// are injected at runtime by Timoni.
config: metadata: {
name: string @tag(name)
namespace: string @tag(namespace)
// Define how Timoni should build, validate and
// apply the Kubernetes resources.
timoni: {
apiVersion: "v1alpha1"

// Define the instance that outputs the Kubernetes resources.
// At runtime, Timoni builds the instance and validates
// the resulting resources according to their Kubernetes schema.
instance: templates.#Instance & {
// The user-supplied values are merged with the
// default values at runtime by Timoni.
config: values
// The instance name and namespace tag values
// are injected at runtime by Timoni.
config: metadata: {
name: string @tag(name)
namespace: string @tag(namespace)
}
}
}

// Pass Kubernetes resources outputted by the instance
// to Timoni's multi-step apply.
timoni: {
// Pass Kubernetes resources outputted by the instance
// to Timoni's multi-step apply.
apply: all: [ for obj in instance.objects {obj}]
}
76 changes: 17 additions & 59 deletions internal/engine/module_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,37 +17,21 @@ limitations under the License.
package engine

import (
"bytes"
"fmt"
"os"
"path/filepath"

"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
"cuelang.org/go/cue/load"
"cuelang.org/go/encoding/yaml"
"github.com/fluxcd/pkg/ssa"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
apiv1 "github.com/stefanprodan/timoni/api/v1alpha1"
)

const (
defaultPackage = "main"
defaultValuesName = "values"
defaultValuesFile = "values.cue"
defaultOutputExp = "timoni.apply"
)

// ResourceSet is a named list of Kubernetes resource objects.
type ResourceSet struct {

// Name of the object list.
Name string `json:"name"`

// Objects holds the list of Kubernetes objects.
// +optional
Objects []*unstructured.Unstructured `json:"objects,omitempty"`
}

// ModuleBuilder compiles CUE definitions to Kubernetes objects.
type ModuleBuilder struct {
ctx *cue.Context
Expand Down Expand Up @@ -87,7 +71,7 @@ func (b *ModuleBuilder) MergeValuesFile(overlays []string) error {
return err
}

cueGen := fmt.Sprintf("package %s\n%s: %v", b.pkgName, defaultValuesName, finalVal)
cueGen := fmt.Sprintf("package %s\n%s: %v", b.pkgName, apiv1.ValuesSelector, finalVal)

// overwrite the values.cue file with the merged values
if err := os.MkdirAll(b.moduleRoot, os.ModePerm); err != nil {
Expand Down Expand Up @@ -129,50 +113,24 @@ func (b *ModuleBuilder) Build() (cue.Value, error) {
return v, nil
}

// GetAPIVersion returns the list of API version of the Timoni's CUE definition.
func (b *ModuleBuilder) GetAPIVersion(value cue.Value) (string, error) {
ver := value.LookupPath(cue.ParsePath(apiv1.APIVersionSelector.String()))
if ver.Err() != nil {
return "", fmt.Errorf("lookup %s failed, error: %w", apiv1.APIVersionSelector, ver.Err())
}
return ver.String()
}

// GetApplySets returns the list of Kubernetes unstructured objects to be applied in steps.
func (b *ModuleBuilder) GetApplySets(value cue.Value) ([]ResourceSet, error) {
steps := value.LookupPath(cue.ParsePath(defaultOutputExp))
steps := value.LookupPath(cue.ParsePath(apiv1.ApplySelector.String()))
if steps.Err() != nil {
return nil, fmt.Errorf("lookup %s failed, error: %w", defaultOutputExp, steps.Err())
return nil, fmt.Errorf("lookup %s failed, error: %w", apiv1.ApplySelector, steps.Err())
}
return GetResources(steps)
}

// GetResources converts the CUE value to a list of ResourceSets.
func GetResources(value cue.Value) ([]ResourceSet, error) {
var sets []ResourceSet
iter, _ := value.Fields(cue.Concrete(true))
for iter.Next() {
name := iter.Selector().String()
expr := iter.Value()
switch expr.Kind() {
case cue.ListKind:
items, err := expr.List()
if err != nil {
return nil, fmt.Errorf("listing objects for %s failed, error: %w", name, err)
}

data, err := yaml.EncodeStream(items)
if err != nil {
return nil, fmt.Errorf("encoding objects for %s failed, error: %w", name, err)
}

objects, err := ssa.ReadObjects(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decoding objects for %s failed, error: %w", name, err)
}

sets = append(sets, ResourceSet{
Name: name,
Objects: objects,
})
default:
return nil, fmt.Errorf("objects in %s are not of type cue.ListKind, got %v", name, value.Kind())
}
}
return sets, nil
}

// GetDefaultValues extracts the default values from the module.
func (b *ModuleBuilder) GetDefaultValues() (string, error) {
filePath := filepath.Join(b.pkgPath, defaultValuesFile)
Expand All @@ -187,9 +145,9 @@ func (b *ModuleBuilder) GetDefaultValues() (string, error) {
return "", value.Err()
}

expr := value.LookupPath(cue.ParsePath(defaultValuesName))
expr := value.LookupPath(cue.ParsePath(apiv1.ValuesSelector.String()))
if expr.Err() != nil {
return "", fmt.Errorf("lookup %s failed, error: %w", defaultValuesName, expr.Err())
return "", fmt.Errorf("lookup %s failed, error: %w", apiv1.ValuesSelector, expr.Err())
}

return fmt.Sprintf("%v", expr.Eval()), nil
Expand Down Expand Up @@ -224,9 +182,9 @@ func (b *ModuleBuilder) GetModuleName() (string, error) {

// GetValues extracts the values from the build result.
func (b *ModuleBuilder) GetValues(value cue.Value) (string, error) {
expr := value.LookupPath(cue.ParsePath(defaultValuesName))
expr := value.LookupPath(cue.ParsePath(apiv1.ValuesSelector.String()))
if expr.Err() != nil {
return "", fmt.Errorf("lookup %s failed, error: %w", defaultValuesName, expr.Err())
return "", fmt.Errorf("lookup %s failed, error: %w", apiv1.ValuesSelector, expr.Err())
}

return fmt.Sprintf("%v", expr.Eval()), nil
Expand Down
25 changes: 7 additions & 18 deletions internal/engine/module_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
. "github.com/onsi/gomega"

apiv1 "github.com/stefanprodan/timoni/api/v1alpha1"
)

func TestModuleBuilder(t *testing.T) {
Expand All @@ -47,28 +49,15 @@ func TestModuleBuilder(t *testing.T) {
val, err := mb.Build()
g.Expect(err).ToNot(HaveOccurred())

objects := val.LookupPath(cue.ParsePath("timoni.apply.all"))
apiVer, err := mb.GetAPIVersion(val)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(apiVer).To(BeEquivalentTo(apiv1.GroupVersion.Version))

objects := val.LookupPath(cue.ParsePath(apiv1.ApplySelector.String() + ".all"))
g.Expect(objects.Err()).ToNot(HaveOccurred())

gold, err := ExtractValueFromFile(ctx, "testdata/module-golden/overlay.cue", "objects")
g.Expect(err).ToNot(HaveOccurred())

g.Expect(fmt.Sprintf("%v", objects)).To(BeEquivalentTo(fmt.Sprintf("%v", gold)))
}

func TestExtractBuildResult(t *testing.T) {
g := NewWithT(t)
ctx := cuecontext.New()

steps, err := ExtractValueFromFile(ctx, "testdata/api/apply-steps.cue", defaultOutputExp)
g.Expect(err).ToNot(HaveOccurred())

sets, err := GetResources(steps)
g.Expect(err).ToNot(HaveOccurred())

expectedNames := []string{"app", "addons", "tests"}
for s, set := range sets {
g.Expect(sets[s].Name).To(BeEquivalentTo(expectedNames[s]))
g.Expect(len(set.Objects)).To(BeEquivalentTo(2))
}
}
Loading

0 comments on commit 2f6b99d

Please sign in to comment.