Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow reading k0s config from a separate YAML document #814

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions cmd/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/adrg/xdg"
"github.com/k0sproject/k0sctl/phase"
"github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1"
"github.com/k0sproject/k0sctl/pkg/manifest"
"github.com/k0sproject/k0sctl/pkg/retry"
k0sctl "github.com/k0sproject/k0sctl/version"
"github.com/k0sproject/rig"
Expand Down Expand Up @@ -138,16 +139,41 @@ func initConfig(ctx *cli.Context) error {

log.Debugf("Loaded configuration:\n%s", subst)

var manifestReader manifest.Reader
if err := manifestReader.ParseBytes(subst); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}

cfgResources, err := manifestReader.GetResources("k0sctl.k0sproject.io/v1beta1", "Cluster")
if err != nil {
return fmt.Errorf("failed to find resource in config: %w", err)
}

if len(cfgResources) > 1 {
return fmt.Errorf("multiple Cluster configuration resources found in config")
}

c := &v1beta1.Cluster{}
if err := yaml.UnmarshalStrict(subst, c); err != nil {
return err
if err := cfgResources[0].Unmarshal(c); err != nil {
return fmt.Errorf("failed to decode config: %w", err)
}

m, err := yaml.Marshal(c)
if err == nil {
log.Tracef("unmarshaled configuration:\n%s", m)
}

k0sConfigs, err := manifestReader.GetResources("k0s.k0sproject.io/v1beta1", "ClusterConfig")
if err == nil {
if len(k0sConfigs) > 1 {
return fmt.Errorf("multiple k0s ClusterConfig resources found in config")
}
k0sCfg := k0sConfigs[0]
if err := k0sCfg.Unmarshal(&c.Spec.K0s.Config); err != nil {
return fmt.Errorf("failed to decode k0s config %s: %w", k0sCfg.Origin, err)
}
}

if err := c.Validate(); err != nil {
return fmt.Errorf("configuration validation failed: %w", err)
}
Expand Down
141 changes: 141 additions & 0 deletions pkg/manifest/reader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package manifest

import (
"bytes"
"fmt"
"io"
"os"
"path"
"regexp"
"strings"
"time"

"gopkg.in/yaml.v2"
)

// ResourceDefinition represents a single Kubernetes resource definition.
type ResourceDefinition struct {
APIVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
Metadata struct {
Name string `yaml:"name"`
} `yaml:"metadata"`
Origin string `yaml:"-"`
Raw []byte `yaml:"-"`
}

var fnRe = regexp.MustCompile(`[^\w\-\.]`)

func safeFn(input string) string {
safe := fnRe.ReplaceAllString(input, "_")
safe = strings.Trim(safe, "._")
return safe
}

// Filename returns a filename compatible name of the resource definition.
func (rd *ResourceDefinition) Filename() string {
if strings.HasSuffix(rd.Origin, ".yaml") || strings.HasSuffix(rd.Origin, ".yml") {
return path.Base(rd.Origin)
}

if rd.Metadata.Name != "" {
return fmt.Sprintf("%s-%s.yaml", safeFn(rd.Kind), safeFn(rd.Metadata.Name))
}

return fmt.Sprintf("%s-%s-%d.yaml", safeFn(rd.APIVersion), safeFn(rd.Kind), time.Now().UnixNano())
}

// returns a Reader that reads the raw resource definition
func (rd *ResourceDefinition) Reader() *bytes.Reader {
return bytes.NewReader(rd.Raw)
}

// Bytes returns the raw resource definition.
func (rd *ResourceDefinition) Bytes() []byte {
return rd.Raw
}

// Unmarshal unmarshals the raw resource definition into the provided object.
func (rd *ResourceDefinition) Unmarshal(obj any) error {
if err := yaml.UnmarshalStrict(rd.Bytes(), obj); err != nil {
return fmt.Errorf("failed to unmarshal %s: %w", rd.Origin, err)
}
return nil
}

// Reader reads Kubernetes resource definitions from input streams.
type Reader struct {
IgnoreErrors bool
manifests []*ResourceDefinition
}

func name(r io.Reader) string {
if n, ok := r.(*os.File); ok {
return n.Name()
}
return "manifest"
}

// Parse parses Kubernetes resource definitions from the provided input stream. They are then available via the Resources() or GetResources(apiVersion, kind) methods.
func (r *Reader) Parse(input io.Reader) error {
var buf bytes.Buffer
tee := io.TeeReader(input, &buf)
decoder := yaml.NewDecoder(tee)
for {
buf.Reset()
rd := &ResourceDefinition{}
if err := decoder.Decode(rd); err != nil {
if err == io.EOF {
break
}
if r.IgnoreErrors {
continue
}
return fmt.Errorf("encountered an error while parsing %s: %w", name(input), err)
}
if rd.APIVersion == "" || rd.Kind == "" {
if r.IgnoreErrors {
continue
}
return fmt.Errorf("missing apiVersion or kind in %s", name(input))
}
rd.Raw = buf.Bytes()
rd.Origin = name(input)
r.manifests = append(r.manifests, rd)
}
return nil
}

// ParseString parses Kubernetes resource definitions from the provided string.
func (r *Reader) ParseString(input string) error {
return r.Parse(strings.NewReader(input))
}

// ParseBytes parses Kubernetes resource definitions from the provided byte slice.
func (r *Reader) ParseBytes(input []byte) error {
return r.Parse(bytes.NewReader(input))
}

// Resources returns all parsed Kubernetes resource definitions.
func (r *Reader) Resources() []*ResourceDefinition {
return r.manifests
}

// Len returns the number of parsed Kubernetes resource definitions.
func (r *Reader) Len() int {
return len(r.manifests)
}

// GetResources returns all parsed Kubernetes resource definitions that match the provided apiVersion and kind. The matching is case-insensitive.
func (r *Reader) GetResources(apiVersion, kind string) ([]*ResourceDefinition, error) {
var resources []*ResourceDefinition
for _, rd := range r.manifests {
if strings.EqualFold(rd.APIVersion, apiVersion) && strings.EqualFold(rd.Kind, kind) {
resources = append(resources, rd)
}
}
if len(resources) == 0 {
return nil, fmt.Errorf("no resources found for apiVersion=%s, kind=%s", apiVersion, kind)
}
return resources, nil
}
116 changes: 116 additions & 0 deletions pkg/manifest/reader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package manifest_test

import (
"strings"
"testing"

"github.com/k0sproject/k0sctl/pkg/manifest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestReader_ParseIgnoreErrors(t *testing.T) {
input := `
apiVersion: v1
kind: Pod
metadata:
name: pod1
---
invalid_yaml
---
apiVersion: v1
kind: Service
metadata:
name: service1
`
reader := strings.NewReader(input)
r := &manifest.Reader{IgnoreErrors: true}

err := r.Parse(reader)

// Ensure no critical errors even with invalid YAML
require.NoError(t, err, "Parse should not return an error with IgnoreErrors=true")

// Assert that only valid manifests are parsed
require.Equal(t, 2, r.Len(), "Expected 2 valid manifests to be parsed")

// Validate the parsed manifests
assert.Equal(t, "v1", r.Resources()[0].APIVersion, "Unexpected apiVersion for Pod")
assert.Equal(t, "Pod", r.Resources()[0].Kind, "Unexpected kind for Pod")
assert.Equal(t, "v1", r.Resources()[1].APIVersion, "Unexpected apiVersion for Service")
assert.Equal(t, "Service", r.Resources()[1].Kind, "Unexpected kind for Service")
}

func TestReader_ParseMultipleReaders(t *testing.T) {
input1 := `
apiVersion: v1
kind: Pod
metadata:
name: pod1
`
input2 := `
apiVersion: v1
kind: Service
metadata:
name: service1
`
r := &manifest.Reader{}

// Parse first reader
err := r.Parse(strings.NewReader(input1))
require.NoError(t, err, "Parse should not return an error for input1")

// Parse second reader
err = r.Parse(strings.NewReader(input2))
require.NoError(t, err, "Parse should not return an error for input2")

// Assert that both manifests are parsed
require.Equal(t, 2, r.Len(), "Expected 2 manifests to be parsed")

// Validate the parsed manifests
pod := r.Resources()[0]
assert.Equal(t, "v1", pod.APIVersion, "Unexpected apiVersion for Pod")
assert.Equal(t, "Pod", pod.Kind, "Unexpected kind for Pod")

service := r.Resources()[1]
assert.Equal(t, "v1", service.APIVersion, "Unexpected apiVersion for Service")
assert.Equal(t, "Service", service.Kind, "Unexpected kind for Service")
}

func TestReader_GetResources(t *testing.T) {
input := `
apiVersion: v1
kind: Pod
metadata:
name: pod1
---
apiVersion: v1
kind: Service
metadata:
name: service1
---
apiVersion: v1
kind: Pod
metadata:
name: pod2
`
reader := strings.NewReader(input)
r := &manifest.Reader{}

err := r.Parse(reader)
require.NoError(t, err, "Parse should not return an error")

// Query for Pods
pods, err := r.GetResources("v1", "Pod")
require.NoError(t, err, "GetResources should not return an error for Pods")
assert.Len(t, pods, 2, "Expected 2 Pods to be returned")

// Validate Pods
assert.Equal(t, "Pod", pods[0].Kind, "Unexpected kind for the first Pod")
assert.Equal(t, "Pod", pods[1].Kind, "Unexpected kind for the second Pod")

// Query for Services
services, err := r.GetResources("v1", "Service")
require.NoError(t, err, "GetResources should not return an error for Services")
assert.Len(t, services, 1, "Expected 1 Service to be returned")
}
Loading