Skip to content

Commit

Permalink
#53 Process helm metadata
Browse files Browse the repository at this point in the history
Extend CR with field mappedValues to process helm values which need metadata from their charts
  • Loading branch information
nhinze23 committed Jan 30, 2024
1 parent 1b828b7 commit 7f92fe5
Show file tree
Hide file tree
Showing 9 changed files with 163 additions and 8 deletions.
5 changes: 4 additions & 1 deletion config/samples/dogu-operator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ metadata:
name: k8s-dogu-operator
spec:
name: k8s-dogu-operator
namespace: k8s
namespace: k8s
version: 0.41.11-dev
mappedValues:
log-level: error
5 changes: 5 additions & 0 deletions k8s/helm-crd/templates/k8s.cloudogu.com_components.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ spec:
deployNamespace:
description: DeployNamespace is the namespace where the helm chart should be deployed in. This value is optional. If it is empty the operator deploys the helm chart in the namespace where the operator is deployed.
type: string
mappedValues:
additionalProperties:
type: string
description: MappedValues contains helm values which require a mapping. Components hold metadata information in a file `component-metadata-values.yaml` for these fields. The component-operator reads the file in install or upgrade process the sets the corresponding values in the chart spec. Possible values in this map are forbidden to set via the field ValuesYamlOverwrite.
type: object
name:
description: Name of the component (e.g. k8s-dogu-operator)
type: string
Expand Down
6 changes: 6 additions & 0 deletions pkg/api/v1/ces_component_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ type ComponentSpec struct {
// It can be used to overwrite specific configurations. Lists are overwritten, maps are merged.
// +optional
ValuesYamlOverwrite string `json:"valuesYamlOverwrite,omitempty"`
// MappedValues contains helm values which require a mapping.
// Components hold metadata information in a file `component-metadata-values.yaml` for these fields.
// The component-operator reads the file in install or upgrade process the sets the corresponding values in the chart spec.
// Possible values in this map are forbidden to set via the field ValuesYamlOverwrite.
// +optional
MappedValues map[string]string `json:"mappedValues,omitempty"`
}

type HealthStatus string
Expand Down
1 change: 1 addition & 0 deletions pkg/controllers/componentController.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ func (r *ComponentReconciler) isValuesChanged(deployedRelease *release.Release,
return false, fmt.Errorf("failed to get values.yaml from release %s: %w", deployedRelease.Name, err)
}

// TODO Check changes for mappedValues. Merge them here. Maybe extend chartSpec and reuse them later.
chartSpecValues, err := r.helmClient.GetChartSpecValues(component.GetHelmChartSpec())
if err != nil {
return false, fmt.Errorf("failed to get values.yaml from component %s: %w", component.GetHelmChartSpec().ChartName, err)
Expand Down
2 changes: 1 addition & 1 deletion pkg/controllers/componentInstallManager.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func (cim *ComponentInstallManager) Install(ctx context.Context, component *k8sv
// create a new context that does not get canceled immediately on SIGTERM
helmCtx := context.Background()

if err := cim.helmClient.InstallOrUpgrade(helmCtx, component.GetHelmChartSpec()); err != nil {
if err := cim.helmClient.InstallOrUpgradeWithMappedValues(helmCtx, component.GetHelmChartSpec(), component.Spec.MappedValues); err != nil {
return &genericRequeueableError{"failed to install chart for component " + component.Spec.Name, err}
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/controllers/componentUpgradeManager.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (cum *ComponentUpgradeManager) Upgrade(ctx context.Context, component *k8sv
// this allows self-upgrades
helmCtx := context.WithoutCancel(ctx)

if err := cum.helmClient.InstallOrUpgrade(helmCtx, component.GetHelmChartSpec()); err != nil {
if err := cum.helmClient.InstallOrUpgradeWithMappedValues(helmCtx, component.GetHelmChartSpec(), component.Spec.MappedValues); err != nil {
return fmt.Errorf("failed to upgrade chart for component %s: %w", component.Spec.Name, err)
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/controllers/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ type healthManager interface {
type helmClient interface {
// InstallOrUpgrade takes a helmChart and applies it.
InstallOrUpgrade(ctx context.Context, chart *client.ChartSpec) error
// InstallOrUpgradeWithMappedValues takes a helmChart and applies it with custom values which should be in the metadata file of the helm chart.
InstallOrUpgradeWithMappedValues(ctx context.Context, chart *client.ChartSpec, mappedValues map[string]string) error
// Uninstall removes the helmRelease for the given name
Uninstall(releaseName string) error
// ListDeployedReleases returns all deployed helm releases
Expand Down
122 changes: 117 additions & 5 deletions pkg/helm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package helm
import (
"context"
"fmt"
"helm.sh/helm/v3/pkg/chartutil"
"k8s.io/apimachinery/pkg/util/yaml"
"sort"
"strings"

Expand All @@ -19,10 +21,11 @@ import (
)

const (
helmRepositoryCache = "/tmp/.helmcache"
helmRepositoryConfig = "/tmp/.helmrepo"
helmRegistryConfigFile = "/tmp/.helmregistry/config.json"
ociSchemePrefix = string(config.EndpointSchemaOCI + "://")
helmRepositoryCache = "/tmp/.helmcache"
helmRepositoryConfig = "/tmp/.helmrepo"
helmRegistryConfigFile = "/tmp/.helmregistry/config.json"
ociSchemePrefix = string(config.EndpointSchemaOCI + "://")
helmValuesMetadataFileName = "component-values-metadata.yaml"
)

// HelmClient embeds the client.Client interface for usage in this package.
Expand Down Expand Up @@ -67,6 +70,10 @@ func NewClient(namespace string, helmRepoData *config.HelmRepositoryData, debug

// InstallOrUpgrade takes a helmChart and applies it.
func (c *Client) InstallOrUpgrade(ctx context.Context, chart *client.ChartSpec) error {
return c.InstallOrUpgradeWithMappedValues(ctx, chart, nil)
}

func (c *Client) InstallOrUpgradeWithMappedValues(ctx context.Context, chart *client.ChartSpec, mappedValues map[string]string) error {
// This helm-client currently only works with OCI-Helm-Repositories.
// Therefore, the chartName has to include the FQDN of the repository (e.g. "oci://my.repo/...")
// If in the future non-oci-repositories need to be used, this should be done here...
Expand All @@ -76,6 +83,10 @@ func (c *Client) InstallOrUpgrade(ctx context.Context, chart *client.ChartSpec)
return fmt.Errorf("error patching chart-version for chart %s: %w", chart.ChartName, err)
}

if err := c.patchMappedValues(ctx, chart, mappedValues); err != nil {
return err
}

_, err := c.helmClient.InstallOrUpgradeChart(ctx, chart)
if err != nil {
return fmt.Errorf("error while installOrUpgrade chart %s: %w", chart.ChartName, err)
Expand Down Expand Up @@ -154,6 +165,107 @@ func (c *Client) GetChartSpecValues(spec *client.ChartSpec) (map[string]interfac
return c.helmClient.GetChartSpecValues(spec)
}

type helmValuesMetadata struct {
ApiVersion string `yaml:"apiVersion"`
Metadata map[string]helmValuesMetadataEntry `yaml:"metadata"`
}

type helmValuesMetadataEntry struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Keys []helmValuesMetadataKey `yaml:"keys"`
}

type helmValuesMetadataKey struct {
Path string `yaml:"path"`
}

func (c *Client) patchMappedValues(ctx context.Context, chartSpec *client.ChartSpec, mappedValues map[string]string) error {
logger := log.FromContext(ctx)
if mappedValues == nil {
logger.Info("return patching values because mapped values are nil")
return nil
}

helmChart, err := c.getChart(ctx, chartSpec)
if err != nil {
return err
}

files := helmChart.Files
var metadataFile *helmValuesMetadata
for _, file := range files {
logger.Info(file.Name)
if file.Name == helmValuesMetadataFileName {
err := yaml.Unmarshal(file.Data, &metadataFile)
if err != nil {
return fmt.Errorf("failed to unmarshal %s: %w", helmValuesMetadataFileName, err)
}
logger.Info(fmt.Sprintf("found metadata file %q for chart %q", helmValuesMetadataFileName, chartSpec.ChartName))
logger.Info(fmt.Sprintf("%+v", metadataFile))
break
}
}

if metadataFile == nil {
logger.Info(fmt.Sprintf("found no metadata file %q for chart %q", helmValuesMetadataFileName, chartSpec.ChartName))
return nil
}

values, err := chartutil.ReadValues([]byte(chartSpec.ValuesYaml))
if err != nil {
return fmt.Errorf("failed to read current values: %w", err)
}

// TODO Exclude this check because it has to be done if the mappedValues are nil too
for _, metadataEntry := range metadataFile.Metadata {
// Check if metadata values are already set in valuesYamlOverwrite
for _, key := range metadataEntry.Keys {
// key.Path is somethings like controllerManager.env.logLevel (dot-separated)
path := key.Path
if isMetadataPathInValuesMap(path, values) {
return fmt.Errorf("values contains path %s which should only be set in field mappedValues", path)
}

value, ok := mappedValues[metadataEntry.Name]
if !ok {
// Return nil because metadata is nil in mappedValue
logger.Info(fmt.Sprintf("found no value in mappedValues for metadata %q", metadataEntry.Name))
return nil
}

setValueInChartSpec(ctx, chartSpec, path, value)
}
}

return nil
}

func setValueInChartSpec(ctx context.Context, chartSpec *client.ChartSpec, path string, value interface{}) {
// TODO Mapping
// This is easier than reading the current values as string and merging them.
logger := log.FromContext(ctx)
option := fmt.Sprintf("%s=%s", path, value)
logger.Info(fmt.Sprintf("set option %q for chart %q", option, chartSpec.ChartName))
chartSpec.ValuesOptions.Values = append(chartSpec.ValuesOptions.Values, option)
}

func isMetadataPathInValuesMap(path string, values chartutil.Values) bool {
before, after, _ := strings.Cut(path, ".")

i, ok := values[before]
if !ok {
return false
}

ii, ok := i.(map[string]interface{})
if !ok && after == "" {
return true
}

return isMetadataPathInValuesMap(after, ii)
}

func (c *Client) patchOciEndpoint(chart *client.ChartSpec) {
if strings.HasPrefix(chart.ChartName, ociSchemePrefix) {
return
Expand All @@ -173,7 +285,7 @@ func (c *Client) patchChartVersion(chart *client.ChartSpec) error {
return fmt.Errorf("error resolving tags for chart %s: %w", chart.ChartName, err)
}

//sort tags by version
// sort tags by version
sortedTags := sortByVersionDescending(tags)

if len(sortedTags) <= 0 {
Expand Down
26 changes: 26 additions & 0 deletions pkg/helm/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"helm.sh/helm/v3/pkg/chartutil"
"strings"
"testing"

Expand Down Expand Up @@ -628,3 +629,28 @@ func TestClient_GetReleaseValues(t *testing.T) {
assert.Equal(t, "val", values["key"])
})
}

func Test_isMetadataPathInValuesMap(t *testing.T) {
type args struct {
path string
values chartutil.Values
}
tests := []struct {
name string
args args
want bool
}{
{"empty path should result in false with empty values", args{"", map[string]interface{}{}}, false},
{"empty path should result in false", args{"", map[string]interface{}{"controllerManager": "value"}}, false},
{"simple path is in values", args{"controllerManager", map[string]interface{}{"controllerManager": "value"}}, true},
{"path is in values", args{"controllerManager.env", map[string]interface{}{"controllerManager": map[string]interface{}{"env": "value"}}}, true},
{"path is not in values", args{"controllerManager.env", map[string]interface{}{"controllerManager": "value"}}, false},
{"path is not in values because of bool value", args{"controllerManager.env", map[string]interface{}{"controllerManager": false}}, false},
{"path is not in values because of slice value", args{"controllerManager.env", map[string]interface{}{"controllerManager": []byte{}}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, isMetadataPathInValuesMap(tt.args.path, tt.args.values), "isMetadataPathInValuesMap(%v, %v)", tt.args.path, tt.args.values)
})
}
}

0 comments on commit 7f92fe5

Please sign in to comment.