diff --git a/go.mod b/go.mod index 84691bd2..154acd3e 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c github.com/olekukonko/tablewriter v1.1.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/pluralsh/console/go/client v1.76.4 + github.com/pluralsh/console/go/client v1.76.5 github.com/pluralsh/console/go/polly v1.0.0 github.com/pluralsh/gqlclient v1.12.2 github.com/pluralsh/plural-operator v0.6.0 @@ -349,5 +349,3 @@ require ( sigs.k8s.io/kustomize/api v0.21.1 // indirect sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect ) - -replace github.com/pluralsh/console/go/client => github.com/pluralsh/console/go/client v0.0.0-20260706120905-5f6ff115eb71 diff --git a/go.sum b/go.sum index d5229173..f2ed60d2 100644 --- a/go.sum +++ b/go.sum @@ -608,8 +608,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pluralsh/console/go/client v0.0.0-20260706120905-5f6ff115eb71 h1:ZDQvTGLJEAqLmiZTffaLNp3Yc4GHRYrpxbKMppz+Z0s= -github.com/pluralsh/console/go/client v0.0.0-20260706120905-5f6ff115eb71/go.mod h1:+dJuFu2ruNb8u4Aaor8NPneOiK2ndrwGBsbsFxbQfDU= +github.com/pluralsh/console/go/client v1.76.5 h1:0DjfQTphB3pDLXVdK4Ty9/EsJvrm8kC12pqB9unvePM= +github.com/pluralsh/console/go/client v1.76.5/go.mod h1:+dJuFu2ruNb8u4Aaor8NPneOiK2ndrwGBsbsFxbQfDU= github.com/pluralsh/console/go/controller v0.0.0-20260706120905-5f6ff115eb71 h1:d00SB5O8he1ls7YmUpI10J9FiqXKU+k8EgI0gKqRdSo= github.com/pluralsh/console/go/controller v0.0.0-20260706120905-5f6ff115eb71/go.mod h1:7eM3lngWsj4OUrQsuN2ZTT5UgV7c5yi+PZF8Cer3K+M= github.com/pluralsh/console/go/polly v1.0.0 h1:NRg8CMITGllEJ08oYidNuuG/gJGDdDuga8TM+gdIcFA= diff --git a/pkg/client/plural.go b/pkg/client/plural.go index acc8dcb9..b244a1e8 100644 --- a/pkg/client/plural.go +++ b/pkg/client/plural.go @@ -7,6 +7,7 @@ import ( "os/exec" "strings" + consoleclient "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/polly/algorithms" "github.com/samber/lo" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -15,18 +16,15 @@ import ( "github.com/urfave/cli" - "github.com/pluralsh/plural-cli/pkg/common" - "github.com/pluralsh/plural-cli/pkg/scm" - "github.com/pluralsh/plural-cli/pkg/wkspace" - - "sigs.k8s.io/yaml" - "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/common" "github.com/pluralsh/plural-cli/pkg/config" "github.com/pluralsh/plural-cli/pkg/console" "github.com/pluralsh/plural-cli/pkg/kubernetes" "github.com/pluralsh/plural-cli/pkg/manifest" + "github.com/pluralsh/plural-cli/pkg/scm" "github.com/pluralsh/plural-cli/pkg/utils" + "github.com/pluralsh/plural-cli/pkg/wkspace" ) type Plural struct { @@ -243,7 +241,13 @@ func (p *Plural) DoInstallOperator(url, token, values, chart_loc, clusterId stri utils.Highlight("Using Agent v%s\n", version) if settings.AgentHelmValues != nil { - if err := yaml.Unmarshal([]byte(*settings.AgentHelmValues), &globalVals); err != nil { + cluster, err := p.fetchClusterForAgentHelmValues(settings, clusterId) + if err != nil { + return fmt.Errorf("fetching cluster for agent helm values templating: %w", err) + } + + globalVals, err = console.ResolveAgentHelmValues(settings, cluster) + if err != nil { return err } } else { @@ -285,6 +289,20 @@ func (p *Plural) ReinstallOperator(c *cli.Context, id, handle *string, chart_loc return p.DoInstallOperator(url, deployToken, c.String("values"), chart_loc, clusterId) } +func (p *Plural) fetchClusterForAgentHelmValues( + settings *consoleclient.DeploymentSettingsFragment, + clusterId string, +) (*consoleclient.ClusterFragment, error) { + if settings == nil || !lo.FromPtr(settings.AgentHelmValuesTemplateable) { + return nil, nil + } + if clusterId == "" { + return nil, fmt.Errorf("cluster id is required to render agent helm values") + } + + return p.ConsoleClient.GetCluster(lo.ToPtr(clusterId), nil) +} + func displayWarning(err error) { var outMsg string var ee *exec.ExitError diff --git a/pkg/console/agent_helm_values.go b/pkg/console/agent_helm_values.go new file mode 100644 index 00000000..c50c3032 --- /dev/null +++ b/pkg/console/agent_helm_values.go @@ -0,0 +1,89 @@ +package console + +import ( + "fmt" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/polly/template" + "github.com/samber/lo" + "sigs.k8s.io/yaml" +) + +func ResolveAgentHelmValues( + settings *gqlclient.DeploymentSettingsFragment, + cluster *gqlclient.ClusterFragment, +) (map[string]interface{}, error) { + if settings == nil || settings.AgentHelmValues == nil { + return map[string]interface{}{}, nil + } + + raw := *settings.AgentHelmValues + rendered := raw + if lo.FromPtr(settings.AgentHelmValuesTemplateable) { + if cluster == nil { + return nil, fmt.Errorf("cluster context is required to render agent helm values") + } + bindings := agentHelmValuesBindings(cluster) + out, err := template.RenderLiquid([]byte(raw), bindings) + if err != nil { + return nil, fmt.Errorf("rendering agent helm values: %w", err) + } + rendered = string(out) + } + + globalVals := map[string]interface{}{} + if err := yaml.Unmarshal([]byte(rendered), &globalVals); err != nil { + return nil, err + } + + return globalVals, nil +} + +func agentHelmValuesBindings(cluster *gqlclient.ClusterFragment) map[string]interface{} { + return map[string]interface{}{ + "cluster": clusterBindings(cluster), + } +} + +func clusterBindings(cluster *gqlclient.ClusterFragment) map[string]interface{} { + if cluster == nil { + return map[string]interface{}{} + } + + res := map[string]interface{}{ + "ID": cluster.ID, + "Self": cluster.Self, + "Handle": cluster.Handle, + "Name": cluster.Name, + "Version": cluster.Version, + "CurrentVersion": cluster.CurrentVersion, + "KasUrl": cluster.KasURL, + "Metadata": cluster.Metadata, + "Distro": cluster.Distro, + "Tags": clusterTagsMap(cluster.Tags), + } + + lowercase := make(map[string]interface{}, len(res)) + for k, v := range res { + lowercase[strings.ToLower(k)] = v + } + for k, v := range lowercase { + res[k] = v + } + res["kasUrl"] = cluster.KasURL + res["currentVersion"] = cluster.CurrentVersion + + return res +} + +func clusterTagsMap(tags []*gqlclient.ClusterTags) map[string]string { + res := map[string]string{} + for _, tag := range tags { + if tag == nil { + continue + } + res[tag.Name] = tag.Value + } + return res +} diff --git a/pkg/console/agent_helm_values_test.go b/pkg/console/agent_helm_values_test.go new file mode 100644 index 00000000..93748286 --- /dev/null +++ b/pkg/console/agent_helm_values_test.go @@ -0,0 +1,97 @@ +package console_test + +import ( + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/pluralsh/plural-cli/pkg/console" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveAgentHelmValuesWithoutTemplating(t *testing.T) { + t.Parallel() + + settings := &gqlclient.DeploymentSettingsFragment{ + AgentHelmValues: lo.ToPtr("foo: bar\n"), + } + + vals, err := console.ResolveAgentHelmValues(settings, nil) + require.NoError(t, err) + assert.Equal(t, "bar", vals["foo"]) +} + +func TestResolveAgentHelmValuesWithTemplating(t *testing.T) { + t.Parallel() + + settings := &gqlclient.DeploymentSettingsFragment{ + AgentHelmValues: lo.ToPtr("cluster:\n name: {{ cluster.name }}\n"), + AgentHelmValuesTemplateable: lo.ToPtr(true), + } + cluster := &gqlclient.ClusterFragment{ + ID: "cluster-id", + Name: "test", + } + + vals, err := console.ResolveAgentHelmValues(settings, cluster) + require.NoError(t, err) + + clusterVals, ok := vals["cluster"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "test", clusterVals["name"]) +} + +func TestResolveAgentHelmValuesRequiresClusterWhenTemplating(t *testing.T) { + t.Parallel() + + settings := &gqlclient.DeploymentSettingsFragment{ + AgentHelmValues: lo.ToPtr("cluster:\n id: {{ cluster.id }}\n"), + AgentHelmValuesTemplateable: lo.ToPtr(true), + } + + _, err := console.ResolveAgentHelmValues(settings, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cluster context is required") +} + +func TestResolveAgentHelmValuesRendersClusterMetadataAndTags(t *testing.T) { + t.Parallel() + + settings := &gqlclient.DeploymentSettingsFragment{ + AgentHelmValues: lo.ToPtr(`name: {{ cluster.name }} +region: {{ cluster.metadata.region }} +env: {{ cluster.tags.env }} +id: {{ cluster.id }} +kas: {{ cluster.kasUrl }} +`), + AgentHelmValuesTemplateable: lo.ToPtr(true), + } + cluster := &gqlclient.ClusterFragment{ + ID: "cluster-1", + Name: "prod-cluster", + KasURL: lo.ToPtr("https://kas.example.com"), + Metadata: map[string]any{ + "region": "eu-central-1", + }, + Tags: []*gqlclient.ClusterTags{ + {Name: "env", Value: "production"}, + }, + } + + vals, err := console.ResolveAgentHelmValues(settings, cluster) + require.NoError(t, err) + assert.Equal(t, "prod-cluster", vals["name"]) + assert.Equal(t, "eu-central-1", vals["region"]) + assert.Equal(t, "production", vals["env"]) + assert.Equal(t, "cluster-1", vals["id"]) + assert.Equal(t, "https://kas.example.com", vals["kas"]) +} + +func TestResolveAgentHelmValuesNilSettings(t *testing.T) { + t.Parallel() + + vals, err := console.ResolveAgentHelmValues(nil, nil) + require.NoError(t, err) + assert.Empty(t, vals) +} diff --git a/pkg/console/console.go b/pkg/console/console.go index 2b22a81f..7a122219 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -55,6 +55,7 @@ type ConsoleClient interface { CreateNotificationSinks(attr consoleclient.NotificationSinkAttributes) (*consoleclient.NotificationSinkFragment, error) UpdateDeploymentSettings(attr consoleclient.DeploymentSettingsAttributes) (*consoleclient.UpdateDeploymentSettings, error) GetGlobalSettings() (*consoleclient.DeploymentSettingsFragment, error) + GetGlobalSettingsMinimal() (*consoleclient.DeploymentSettingsFragment, error) GetAgentRun(id string) (*consoleclient.AgentRunMinimalFragment, error) ListAgentRuns(first int64) ([]*consoleclient.AgentRunMinimalFragment, error) ListStackRuns(stackID string) (*consoleclient.ListStackRuns, error) diff --git a/pkg/console/settings.go b/pkg/console/settings.go index 3a501121..8ae3b6b0 100644 --- a/pkg/console/settings.go +++ b/pkg/console/settings.go @@ -22,10 +22,32 @@ func (c *consoleClient) UpdateDeploymentSettings(attr gqlclient.DeploymentSettin func (c *consoleClient) GetGlobalSettings() (*gqlclient.DeploymentSettingsFragment, error) { resp, err := c.client.GetDeploymentSettings(c.ctx) if err != nil { - return nil, api.GetErrorResponse(err, "GetDeploymentSettings") + return c.GetGlobalSettingsMinimal() } - if resp == nil { - return nil, fmt.Errorf("returned GetDeploymentSettings object is nil") + if resp == nil || resp.DeploymentSettings == nil { + return c.GetGlobalSettingsMinimal() } return resp.DeploymentSettings, nil } + +func (c *consoleClient) GetGlobalSettingsMinimal() (*gqlclient.DeploymentSettingsFragment, error) { + resp, err := c.client.GetDeploymentSettingsMinimal(c.ctx) + if err != nil { + return nil, api.GetErrorResponse(err, "GetDeploymentSettingsMinimal") + } + if resp == nil { + return nil, fmt.Errorf("returned GetDeploymentSettingsMinimal object is nil") + } + return toDeploymentSettingsFragment(resp.DeploymentSettings), nil +} + +func toDeploymentSettingsFragment(minimal *gqlclient.DeploymentSettingsMinimalFragment) *gqlclient.DeploymentSettingsFragment { + if minimal == nil { + return nil + } + + return &gqlclient.DeploymentSettingsFragment{ + AgentHelmValues: minimal.AgentHelmValues, + AgentVsn: minimal.AgentVsn, + } +} diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index 0474a856..3ba2c578 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -631,6 +631,36 @@ func (_m *ConsoleClient) GetGlobalSettings() (*client.DeploymentSettingsFragment return r0, r1 } +// GetGlobalSettingsMinimal provides a mock function with no fields +func (_m *ConsoleClient) GetGlobalSettingsMinimal() (*client.DeploymentSettingsFragment, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetGlobalSettingsMinimal") + } + + var r0 *client.DeploymentSettingsFragment + var r1 error + if rf, ok := ret.Get(0).(func() (*client.DeploymentSettingsFragment, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() *client.DeploymentSettingsFragment); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.DeploymentSettingsFragment) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetPipelineContext provides a mock function with given fields: id func (_m *ConsoleClient) GetPipelineContext(id string) (*client.PipelineContextFragment, error) { ret := _m.Called(id) diff --git a/pkg/test/mocks/Kube.go b/pkg/test/mocks/Kube.go index 646fdd35..aaa40637 100644 --- a/pkg/test/mocks/Kube.go +++ b/pkg/test/mocks/Kube.go @@ -5,10 +5,13 @@ package mocks import ( client_gokubernetes "k8s.io/client-go/kubernetes" - mock "github.com/stretchr/testify/mock" - v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mock "github.com/stretchr/testify/mock" + rest "k8s.io/client-go/rest" + + v1 "k8s.io/api/core/v1" ) // Kube is an autogenerated mock type for the Kube type