diff --git a/assets/src/components/cd/cluster/addon/ClusterAddOnReleases.tsx b/assets/src/components/cd/cluster/addon/ClusterAddOnReleases.tsx index 8b9e3723c1..28f2b81a88 100644 --- a/assets/src/components/cd/cluster/addon/ClusterAddOnReleases.tsx +++ b/assets/src/components/cd/cluster/addon/ClusterAddOnReleases.tsx @@ -7,10 +7,7 @@ import isEmpty from 'lodash/isEmpty' import { useOutletContext } from 'react-router-dom' import { InlineLink } from '../../../utils/typography/InlineLink' -import { - ClusterAddOnOutletContextT, - versionPlaceholder, -} from '../ClusterAddon.tsx' +import { ClusterAddOnOutletContextT } from '../ClusterAddon.tsx' type Release = { version: string @@ -44,7 +41,7 @@ export default function ClusterAddOnReleases() { return (addOn?.addon?.versions || []).map((addonVersion) => ({ version: addonVersion?.version ?? '', - url: template.replace(versionPlaceholder, addonVersion?.version ?? ''), + url: template.replace('{vsn}', addonVersion?.version ?? ''), })) }, [addOn]) diff --git a/assets/src/generated/graphql.ts b/assets/src/generated/graphql.ts index 83d897bd21..871b98ff90 100644 --- a/assets/src/generated/graphql.ts +++ b/assets/src/generated/graphql.ts @@ -1928,6 +1928,8 @@ export type Cluster = { cpuUtil?: Maybe; /** a custom credential to use when provisioning this cluster */ credential?: Maybe; + /** the current upgrade attempt for this cluster */ + currentUpgrade?: Maybe; /** current k8s version as told to us by the deployment operator */ currentVersion?: Maybe; /** when this cluster was scheduled for deletion */ @@ -2744,6 +2746,27 @@ export type ClusterUpdateAttributes = { writeBindings?: InputMaybe>>; }; +/** A representation of an agentic attempt to upgrade this cluster */ +export type ClusterUpgrade = { + __typename?: 'ClusterUpgrade'; + cluster?: Maybe; + id: Scalars['ID']['output']; + insertedAt?: Maybe; + runtime?: Maybe; + status: ClusterUpgradeStatus; + steps?: Maybe>>; + updatedAt?: Maybe; + user?: Maybe; + version?: Maybe; +}; + +export type ClusterUpgradeAttributes = { + /** the prompt for the upgrade */ + prompt?: InputMaybe; + /** the runtime to use for the upgrade */ + runtimeId?: InputMaybe; +}; + /** A consolidated checklist of tasks that need to be completed to upgrade this cluster */ export type ClusterUpgradePlan = { __typename?: 'ClusterUpgradePlan'; @@ -2757,6 +2780,39 @@ export type ClusterUpgradePlan = { kubeletSkew?: Maybe; }; +export enum ClusterUpgradeStatus { + Completed = 'COMPLETED', + Failed = 'FAILED', + InProgress = 'IN_PROGRESS', + Pending = 'PENDING' +} + +/** A step in an agentic attempt to upgrade a specific component or piece of infrastructure in this kubernetes cluster */ +export type ClusterUpgradeStep = { + __typename?: 'ClusterUpgradeStep'; + agentRun?: Maybe; + /** the error message for the step if present */ + error?: Maybe; + id: Scalars['ID']['output']; + insertedAt?: Maybe; + /** the name of the step */ + name: Scalars['String']['output']; + /** the prompt used to generate the step */ + prompt: Scalars['String']['output']; + /** the status of the step */ + status: ClusterUpgradeStatus; + /** the type of step */ + type: ClusterUpgradeStepType; + updatedAt?: Maybe; + upgrade?: Maybe; +}; + +export enum ClusterUpgradeStepType { + Addon = 'ADDON', + CloudAddon = 'CLOUD_ADDON', + Infrastructure = 'INFRASTRUCTURE' +} + export type ClusterUsage = { __typename?: 'ClusterUsage'; cluster?: Maybe; @@ -4352,6 +4408,8 @@ export type HelmConfigAttributes = { git?: InputMaybe; ignoreCrds?: InputMaybe; ignoreHooks?: InputMaybe; + /** a folder containing a kustomization to apply to the result of rendering this service's manifests */ + kustomizePostrender?: InputMaybe; luaFile?: InputMaybe; luaFolder?: InputMaybe; luaScript?: InputMaybe; @@ -4446,6 +4504,8 @@ export type HelmSpec = { git?: Maybe; ignoreCrds?: Maybe; ignoreHooks?: Maybe; + /** a folder containing a kustomization to apply to the result of rendering this service's manifests */ + kustomizePostrender?: Maybe; /** a lua file to use for helm applies */ luaFile?: Maybe; /** a folder of lua files to include in the final script used */ @@ -7856,6 +7916,7 @@ export type RootMutationType = { createClusterProvider?: Maybe; createClusterRegistration?: Maybe; createClusterRestore?: Maybe; + createClusterUpgrade?: Maybe; createCustomStackRun?: Maybe; createFederatedCredential?: Maybe; createGitRepository?: Maybe; @@ -8271,6 +8332,12 @@ export type RootMutationTypeCreateClusterRestoreArgs = { }; +export type RootMutationTypeCreateClusterUpgradeArgs = { + attributes?: InputMaybe; + id: Scalars['ID']['input']; +}; + + export type RootMutationTypeCreateCustomStackRunArgs = { attributes: CustomStackRunAttributes; }; @@ -9535,6 +9602,8 @@ export type RootQueryType = { stackDefinition?: Maybe; stackDefinitions?: Maybe; stackRun?: Maybe; + /** fetches the files from a stack's git tarball */ + stackTarball?: Maybe>>; statefulSet?: Maybe; /** adds the ability to search/filter through all tag name/value pairs */ tagPairs?: Maybe; @@ -10757,6 +10826,11 @@ export type RootQueryTypeStackRunArgs = { }; +export type RootQueryTypeStackTarballArgs = { + id: Scalars['ID']['input']; +}; + + export type RootQueryTypeStatefulSetArgs = { name: Scalars['String']['input']; namespace: Scalars['String']['input']; diff --git a/charts/console-rapid/charts/controller-0.0.166.tgz b/charts/console-rapid/charts/controller-0.0.166.tgz index dbf46881a4..9039391778 100644 Binary files a/charts/console-rapid/charts/controller-0.0.166.tgz and b/charts/console-rapid/charts/controller-0.0.166.tgz differ diff --git a/charts/console-rapid/charts/kas-0.3.1.tgz b/charts/console-rapid/charts/kas-0.3.1.tgz index d204ffb83a..9251799889 100644 Binary files a/charts/console-rapid/charts/kas-0.3.1.tgz and b/charts/console-rapid/charts/kas-0.3.1.tgz differ diff --git a/charts/console/charts/controller-0.0.166.tgz b/charts/console/charts/controller-0.0.166.tgz index cb8c74b885..9a2556813a 100644 Binary files a/charts/console/charts/controller-0.0.166.tgz and b/charts/console/charts/controller-0.0.166.tgz differ diff --git a/charts/console/charts/kas-0.3.1.tgz b/charts/console/charts/kas-0.3.1.tgz index 05e4271f9a..b104a875a0 100644 Binary files a/charts/console/charts/kas-0.3.1.tgz and b/charts/console/charts/kas-0.3.1.tgz differ diff --git a/charts/controller/crds/deployments.plural.sh_globalservices.yaml b/charts/controller/crds/deployments.plural.sh_globalservices.yaml index a5e273bd12..c6d8ebcf58 100644 --- a/charts/controller/crds/deployments.plural.sh_globalservices.yaml +++ b/charts/controller/crds/deployments.plural.sh_globalservices.yaml @@ -455,6 +455,11 @@ spec: description: IgnoreHooks indicates whether to completely ignore Helm hooks when actualizing this service. type: boolean + kustomizePostrender: + description: KustomizePostrender is a folder containing a + kustomization to apply to the result of rendering this service's + manifests. + type: string luaFile: description: |- LuaFile to use to generate Helm configuration. diff --git a/charts/controller/crds/deployments.plural.sh_managednamespaces.yaml b/charts/controller/crds/deployments.plural.sh_managednamespaces.yaml index 86a3ec0f2e..6ee370efbc 100644 --- a/charts/controller/crds/deployments.plural.sh_managednamespaces.yaml +++ b/charts/controller/crds/deployments.plural.sh_managednamespaces.yaml @@ -331,6 +331,11 @@ spec: description: IgnoreHooks indicates whether to completely ignore Helm hooks when actualizing this service. type: boolean + kustomizePostrender: + description: KustomizePostrender is a folder containing a + kustomization to apply to the result of rendering this service's + manifests. + type: string luaFile: description: |- LuaFile to use to generate Helm configuration. diff --git a/charts/controller/crds/deployments.plural.sh_previewenvironmenttemplates.yaml b/charts/controller/crds/deployments.plural.sh_previewenvironmenttemplates.yaml index db466adf77..7809ca6775 100644 --- a/charts/controller/crds/deployments.plural.sh_previewenvironmenttemplates.yaml +++ b/charts/controller/crds/deployments.plural.sh_previewenvironmenttemplates.yaml @@ -385,6 +385,11 @@ spec: description: IgnoreHooks indicates whether to completely ignore Helm hooks when actualizing this service. type: boolean + kustomizePostrender: + description: KustomizePostrender is a folder containing a + kustomization to apply to the result of rendering this service's + manifests. + type: string luaFile: description: |- LuaFile to use to generate Helm configuration. diff --git a/charts/controller/crds/deployments.plural.sh_servicedeployments.yaml b/charts/controller/crds/deployments.plural.sh_servicedeployments.yaml index 9dfcb50030..d526d79716 100644 --- a/charts/controller/crds/deployments.plural.sh_servicedeployments.yaml +++ b/charts/controller/crds/deployments.plural.sh_servicedeployments.yaml @@ -308,6 +308,10 @@ spec: description: IgnoreHooks indicates whether to completely ignore Helm hooks when actualizing this service. type: boolean + kustomizePostrender: + description: KustomizePostrender is a folder containing a kustomization + to apply to the result of rendering this service's manifests. + type: string luaFile: description: |- LuaFile to use to generate Helm configuration. diff --git a/go/client/models_gen.go b/go/client/models_gen.go index b89dabac80..26e92ae8c7 100644 --- a/go/client/models_gen.go +++ b/go/client/models_gen.go @@ -1644,6 +1644,8 @@ type Cluster struct { ParentCluster *Cluster `json:"parentCluster,omitempty"` // an ai insight generated about issues discovered which might impact the health of this cluster Insight *AiInsight `json:"insight,omitempty"` + // the current upgrade attempt for this cluster + CurrentUpgrade *ClusterUpgrade `json:"currentUpgrade,omitempty"` // a high level description of the setup of common resources in a cluster OperationalLayout *OperationalLayout `json:"operationalLayout,omitempty"` // a set of kubernetes resources used to generate the ai insight for this cluster @@ -2228,6 +2230,26 @@ type ClusterUpdateAttributes struct { WriteBindings []*PolicyBindingAttributes `json:"writeBindings,omitempty"` } +// A representation of an agentic attempt to upgrade this cluster +type ClusterUpgrade struct { + ID string `json:"id"` + Version *string `json:"version,omitempty"` + Status ClusterUpgradeStatus `json:"status"` + Steps []*ClusterUpgradeStep `json:"steps,omitempty"` + Runtime *AgentRuntime `json:"runtime,omitempty"` + Cluster *Cluster `json:"cluster,omitempty"` + User *User `json:"user,omitempty"` + InsertedAt *string `json:"insertedAt,omitempty"` + UpdatedAt *string `json:"updatedAt,omitempty"` +} + +type ClusterUpgradeAttributes struct { + // the prompt for the upgrade + Prompt *string `json:"prompt,omitempty"` + // the runtime to use for the upgrade + RuntimeID *string `json:"runtimeId,omitempty"` +} + // A consolidated checklist of tasks that need to be completed to upgrade this cluster type ClusterUpgradePlan struct { // whether api compatibilities with all addons and kubernetes are satisfied @@ -2240,6 +2262,25 @@ type ClusterUpgradePlan struct { KubeletSkew *bool `json:"kubeletSkew,omitempty"` } +// A step in an agentic attempt to upgrade a specific component or piece of infrastructure in this kubernetes cluster +type ClusterUpgradeStep struct { + ID string `json:"id"` + // the name of the step + Name string `json:"name"` + // the prompt used to generate the step + Prompt string `json:"prompt"` + // the status of the step + Status ClusterUpgradeStatus `json:"status"` + // the type of step + Type ClusterUpgradeStepType `json:"type"` + // the error message for the step if present + Error *string `json:"error,omitempty"` + AgentRun *AgentRun `json:"agentRun,omitempty"` + Upgrade *ClusterUpgrade `json:"upgrade,omitempty"` + InsertedAt *string `json:"insertedAt,omitempty"` + UpdatedAt *string `json:"updatedAt,omitempty"` +} + type ClusterUsage struct { ID string `json:"id"` CPU *float64 `json:"cpu,omitempty"` @@ -3553,6 +3594,8 @@ type HelmConfigAttributes struct { Set *HelmValueAttributes `json:"set,omitempty"` Repository *NamespacedName `json:"repository,omitempty"` Git *GitRefAttributes `json:"git,omitempty"` + // a folder containing a kustomization to apply to the result of rendering this service's manifests + KustomizePostrender *string `json:"kustomizePostrender,omitempty"` // pointer to a Plural GitRepository RepositoryID *string `json:"repositoryId,omitempty"` } @@ -3651,6 +3694,8 @@ type HelmSpec struct { LuaFile *string `json:"luaFile,omitempty"` // a folder of lua files to include in the final script used LuaFolder *string `json:"luaFolder,omitempty"` + // a folder containing a kustomization to apply to the result of rendering this service's manifests + KustomizePostrender *string `json:"kustomizePostrender,omitempty"` } // a (possibly nested) helm value pair @@ -9704,6 +9749,122 @@ func (e ClusterDistro) MarshalJSON() ([]byte, error) { return buf.Bytes(), nil } +type ClusterUpgradeStatus string + +const ( + ClusterUpgradeStatusPending ClusterUpgradeStatus = "PENDING" + ClusterUpgradeStatusInProgress ClusterUpgradeStatus = "IN_PROGRESS" + ClusterUpgradeStatusCompleted ClusterUpgradeStatus = "COMPLETED" + ClusterUpgradeStatusFailed ClusterUpgradeStatus = "FAILED" +) + +var AllClusterUpgradeStatus = []ClusterUpgradeStatus{ + ClusterUpgradeStatusPending, + ClusterUpgradeStatusInProgress, + ClusterUpgradeStatusCompleted, + ClusterUpgradeStatusFailed, +} + +func (e ClusterUpgradeStatus) IsValid() bool { + switch e { + case ClusterUpgradeStatusPending, ClusterUpgradeStatusInProgress, ClusterUpgradeStatusCompleted, ClusterUpgradeStatusFailed: + return true + } + return false +} + +func (e ClusterUpgradeStatus) String() string { + return string(e) +} + +func (e *ClusterUpgradeStatus) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ClusterUpgradeStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ClusterUpgradeStatus", str) + } + return nil +} + +func (e ClusterUpgradeStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *ClusterUpgradeStatus) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e ClusterUpgradeStatus) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type ClusterUpgradeStepType string + +const ( + ClusterUpgradeStepTypeAddon ClusterUpgradeStepType = "ADDON" + ClusterUpgradeStepTypeCloudAddon ClusterUpgradeStepType = "CLOUD_ADDON" + ClusterUpgradeStepTypeInfrastructure ClusterUpgradeStepType = "INFRASTRUCTURE" +) + +var AllClusterUpgradeStepType = []ClusterUpgradeStepType{ + ClusterUpgradeStepTypeAddon, + ClusterUpgradeStepTypeCloudAddon, + ClusterUpgradeStepTypeInfrastructure, +} + +func (e ClusterUpgradeStepType) IsValid() bool { + switch e { + case ClusterUpgradeStepTypeAddon, ClusterUpgradeStepTypeCloudAddon, ClusterUpgradeStepTypeInfrastructure: + return true + } + return false +} + +func (e ClusterUpgradeStepType) String() string { + return string(e) +} + +func (e *ClusterUpgradeStepType) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ClusterUpgradeStepType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ClusterUpgradeStepType", str) + } + return nil +} + +func (e ClusterUpgradeStepType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *ClusterUpgradeStepType) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e ClusterUpgradeStepType) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + type ComplianceReportFormat string const ( diff --git a/go/controller/api/v1alpha1/servicedeployment_types.go b/go/controller/api/v1alpha1/servicedeployment_types.go index 4af6c1efe1..934cc44f8e 100644 --- a/go/controller/api/v1alpha1/servicedeployment_types.go +++ b/go/controller/api/v1alpha1/servicedeployment_types.go @@ -108,6 +108,10 @@ type ServiceHelm struct { // a folder of lua files to include in the final script used // +kubebuilder:validation:Optional LuaFolder *string `json:"luaFolder,omitempty"` + + // KustomizePostrender is a folder containing a kustomization to apply to the result of rendering this service's manifests. + // +kubebuilder:validation:Optional + KustomizePostrender *string `json:"kustomizePostrender,omitempty"` } type ServiceDependency struct { diff --git a/go/controller/api/v1alpha1/zz_generated.deepcopy.go b/go/controller/api/v1alpha1/zz_generated.deepcopy.go index fab0ab34cf..ef7440e18b 100644 --- a/go/controller/api/v1alpha1/zz_generated.deepcopy.go +++ b/go/controller/api/v1alpha1/zz_generated.deepcopy.go @@ -7917,6 +7917,11 @@ func (in *ServiceHelm) DeepCopyInto(out *ServiceHelm) { *out = new(string) **out = **in } + if in.KustomizePostrender != nil { + in, out := &in.KustomizePostrender, &out.KustomizePostrender + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceHelm. diff --git a/go/controller/config/crd/bases/deployments.plural.sh_globalservices.yaml b/go/controller/config/crd/bases/deployments.plural.sh_globalservices.yaml index a5e273bd12..c6d8ebcf58 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_globalservices.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_globalservices.yaml @@ -455,6 +455,11 @@ spec: description: IgnoreHooks indicates whether to completely ignore Helm hooks when actualizing this service. type: boolean + kustomizePostrender: + description: KustomizePostrender is a folder containing a + kustomization to apply to the result of rendering this service's + manifests. + type: string luaFile: description: |- LuaFile to use to generate Helm configuration. diff --git a/go/controller/config/crd/bases/deployments.plural.sh_managednamespaces.yaml b/go/controller/config/crd/bases/deployments.plural.sh_managednamespaces.yaml index 86a3ec0f2e..6ee370efbc 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_managednamespaces.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_managednamespaces.yaml @@ -331,6 +331,11 @@ spec: description: IgnoreHooks indicates whether to completely ignore Helm hooks when actualizing this service. type: boolean + kustomizePostrender: + description: KustomizePostrender is a folder containing a + kustomization to apply to the result of rendering this service's + manifests. + type: string luaFile: description: |- LuaFile to use to generate Helm configuration. diff --git a/go/controller/config/crd/bases/deployments.plural.sh_previewenvironmenttemplates.yaml b/go/controller/config/crd/bases/deployments.plural.sh_previewenvironmenttemplates.yaml index db466adf77..7809ca6775 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_previewenvironmenttemplates.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_previewenvironmenttemplates.yaml @@ -385,6 +385,11 @@ spec: description: IgnoreHooks indicates whether to completely ignore Helm hooks when actualizing this service. type: boolean + kustomizePostrender: + description: KustomizePostrender is a folder containing a + kustomization to apply to the result of rendering this service's + manifests. + type: string luaFile: description: |- LuaFile to use to generate Helm configuration. diff --git a/go/controller/config/crd/bases/deployments.plural.sh_servicedeployments.yaml b/go/controller/config/crd/bases/deployments.plural.sh_servicedeployments.yaml index 9dfcb50030..d526d79716 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_servicedeployments.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_servicedeployments.yaml @@ -308,6 +308,10 @@ spec: description: IgnoreHooks indicates whether to completely ignore Helm hooks when actualizing this service. type: boolean + kustomizePostrender: + description: KustomizePostrender is a folder containing a kustomization + to apply to the result of rendering this service's manifests. + type: string luaFile: description: |- LuaFile to use to generate Helm configuration. diff --git a/go/controller/docs/api.md b/go/controller/docs/api.md index f4ecfb25c3..7849b05aca 100644 --- a/go/controller/docs/api.md +++ b/go/controller/docs/api.md @@ -4341,6 +4341,7 @@ _Appears in:_ | `luaScript` _string_ | LuaScript to use to generate Helm configuration.
This can ultimately return a lua table with keys "values" and "valuesFiles"
to supply overlays for either dynamically based on git state or other metadata. | | Optional: \{\}
| | `luaFile` _string_ | LuaFile to use to generate Helm configuration.
This can ultimately return a Lua table with keys "values" and "valuesFiles"
to supply overlays for either dynamically based on Git state or other metadata. | | Optional: \{\}
| | `luaFolder` _string_ | a folder of lua files to include in the final script used | | Optional: \{\}
| +| `kustomizePostrender` _string_ | KustomizePostrender is a folder containing a kustomization to apply to the result of rendering this service's manifests. | | Optional: \{\}
| #### ServiceImport diff --git a/go/controller/internal/common/service_template.go b/go/controller/internal/common/service_template.go index f80aaa69bb..ce434888af 100644 --- a/go/controller/internal/common/service_template.go +++ b/go/controller/internal/common/service_template.go @@ -65,15 +65,16 @@ func ServiceTemplateAttributes(ctx context.Context, c runtimeclient.Client, name if srv.Helm != nil { serviceTemplate.Helm = &console.HelmConfigAttributes{ - ValuesFiles: srv.Helm.ValuesFiles, - Version: srv.Helm.Version, - URL: srv.Helm.URL, - IgnoreHooks: srv.Helm.IgnoreHooks, - IgnoreCrds: srv.Helm.IgnoreCrds, - LuaScript: srv.Helm.LuaScript, - LuaFile: srv.Helm.LuaFile, - LuaFolder: srv.Helm.LuaFolder, - Git: srv.Helm.Git.Attributes(), + ValuesFiles: srv.Helm.ValuesFiles, + Version: srv.Helm.Version, + URL: srv.Helm.URL, + IgnoreHooks: srv.Helm.IgnoreHooks, + IgnoreCrds: srv.Helm.IgnoreCrds, + LuaScript: srv.Helm.LuaScript, + LuaFile: srv.Helm.LuaFile, + LuaFolder: srv.Helm.LuaFolder, + KustomizePostrender: srv.Helm.KustomizePostrender, + Git: srv.Helm.Git.Attributes(), } if srv.Helm.Repository != nil { diff --git a/go/controller/internal/controller/servicedeployment_controller.go b/go/controller/internal/controller/servicedeployment_controller.go index 2d087b844c..3fb68029ca 100644 --- a/go/controller/internal/controller/servicedeployment_controller.go +++ b/go/controller/internal/controller/servicedeployment_controller.go @@ -373,17 +373,18 @@ func (r *ServiceDeploymentReconciler) genServiceAttributes(ctx context.Context, func (r *ServiceDeploymentReconciler) getHelmAttr(ctx context.Context, service *v1alpha1.ServiceDeployment) (*console.HelmConfigAttributes, *ctrl.Result, error) { attr := &console.HelmConfigAttributes{ - Release: service.Spec.Helm.Release, - ValuesFiles: service.Spec.Helm.ValuesFiles, - Version: service.Spec.Helm.Version, - Chart: service.Spec.Helm.Chart, - URL: service.Spec.Helm.URL, - IgnoreHooks: service.Spec.Helm.IgnoreHooks, - IgnoreCrds: service.Spec.Helm.IgnoreCrds, - LuaScript: service.Spec.Helm.LuaScript, - LuaFile: service.Spec.Helm.LuaFile, - LuaFolder: service.Spec.Helm.LuaFolder, - Git: service.Spec.Helm.Git.Attributes(), + Release: service.Spec.Helm.Release, + ValuesFiles: service.Spec.Helm.ValuesFiles, + Version: service.Spec.Helm.Version, + Chart: service.Spec.Helm.Chart, + URL: service.Spec.Helm.URL, + IgnoreHooks: service.Spec.Helm.IgnoreHooks, + IgnoreCrds: service.Spec.Helm.IgnoreCrds, + LuaScript: service.Spec.Helm.LuaScript, + LuaFile: service.Spec.Helm.LuaFile, + LuaFolder: service.Spec.Helm.LuaFolder, + KustomizePostrender: service.Spec.Helm.KustomizePostrender, + Git: service.Spec.Helm.Git.Attributes(), } if service.Spec.Helm.Repository != nil { diff --git a/lib/console.ex b/lib/console.ex index 74eb21ef26..43897d1951 100644 --- a/lib/console.ex +++ b/lib/console.ex @@ -414,5 +414,8 @@ defmodule Console do def priv_file!(name), do: Path.join([:code.priv_dir(:console), name]) |> File.read!() + def priv_filename(name) when is_binary(name), do: Path.join([:code.priv_dir(:console), name]) + def priv_filename(path) when is_list(path), do: Path.join([:code.priv_dir(:console) | path]) + def storage, do: Console.Storage.Git end diff --git a/lib/console/ai/agents/upgrade.ex b/lib/console/ai/agents/upgrade.ex new file mode 100644 index 0000000000..d5b58af12a --- /dev/null +++ b/lib/console/ai/agents/upgrade.ex @@ -0,0 +1,75 @@ +defmodule Console.AI.Agents.Upgrade do + alias Console.Repo + alias Console.AI.Chat.MemoryEngine + alias Console.Schema.{ + ClusterUpgrade, + ClusterUpgradeStep, + User, + AgentRun + } + alias Console.AI.Tools.{ + Agent.ServiceComponent, + Agent.Stack, + Agent.Coding.ServiceFiles, + Agent.Coding.StackFiles, + Upgrade.AgentRun + } + require EEx + + @prompt "Attempt to launch the coding agent to generate the needed pr for this. If it's not clear enough, end the conversation with an explanation as to why" + + def exec(%ClusterUpgrade{} = upgrade) do + %{steps: steps, user: user} = Repo.preload(upgrade, [:steps, :cluster, :user, :runtime]) + user = Console.Services.Rbac.preload(user) + + Task.async_stream(steps, &exec_step(&1, upgrade, user), max_concurrency: 10, timeout: :infinity) + |> Enum.any?(&match?({:ok, %{status: :failed}}, &1)) + |> then(&ClusterUpgrade.changeset(upgrade, %{status: if(&1, do: :failed, else: :completed)})) + |> Repo.update() + end + + defp exec_step(%ClusterUpgradeStep{} = step, %ClusterUpgrade{} = upgrade, %User{} = user) do + Console.AI.Tool.context(%{ + user: user, + runtime: upgrade.runtime + }) + + tools(step) + |> MemoryEngine.new(20, system_prompt: prompt(step, upgrade), acc: %{}) + |> MemoryEngine.reduce([{:user, @prompt}], &reducer/2) + |> case do + {:ok, attrs} -> attrs + {:error, error} -> %{status: :failed, error: "error evaluating upgrade step: #{inspect(error)}"} + end + |> then(&ClusterUpgradeStep.changeset(step, &1)) + |> Repo.update() + end + + defp reducer(messages, _) do + case Enum.find(messages, &match?(%AgentRun{}, &1)) do + %AgentRun{} = run -> {:halt, %{status: :completed, agent_run_id: run.id}} + _ -> last_message(messages) + end + end + + defp last_message(messages) do + Enum.reverse(messages) + |> Enum.find(&match?({:assistant, content} when is_binary(content), &1)) + |> case do + {:assistant, content} when is_binary(content) -> %{status: :failed, error: content} + _ -> %{status: :failed, error: "no reason given for failure"} + end + end + + defp prompt(%ClusterUpgradeStep{type: :addon, prompt: prompt}, upgrade), do: addon_prompt(prompt: prompt, upgrade: upgrade) |> String.trim() + defp prompt(%ClusterUpgradeStep{type: :cloud_addon, prompt: prompt}, upgrade), do: cloud_addon_prompt(prompt: prompt, upgrade: upgrade) |> String.trim() + defp prompt(%ClusterUpgradeStep{type: :infrastructure, prompt: prompt}, upgrade), do: infrastructure_prompt(prompt: prompt, upgrade: upgrade) |> String.trim() + + defp tools(%ClusterUpgradeStep{type: :addon}), do: [ServiceComponent, ServiceFiles, AgentRun] + defp tools(%ClusterUpgradeStep{type: :cloud_addon}), do: [Stack, StackFiles, ServiceFiles, AgentRun] + defp tools(%ClusterUpgradeStep{type: :infrastructure}), do: [Stack, StackFiles, ServiceFiles, AgentRun] + + EEx.function_from_file(:defp, :addon_prompt, Console.priv_filename(["prompts", "upgrade", "addon.md.eex"]), [:assigns]) + EEx.function_from_file(:defp, :cloud_addon_prompt, Console.priv_filename(["prompts", "upgrade", "cloud_addon.md.eex"]), [:assigns]) + EEx.function_from_file(:defp, :infrastructure_prompt, Console.priv_filename(["prompts", "upgrade", "infrastructure.md.eex"]), [:assigns]) +end diff --git a/lib/console/ai/chat/memory_engine.ex b/lib/console/ai/chat/memory_engine.ex index 7900ef28d9..195aaf513c 100644 --- a/lib/console/ai/chat/memory_engine.ex +++ b/lib/console/ai/chat/memory_engine.ex @@ -19,6 +19,11 @@ defmodule Console.AI.Chat.MemoryEngine do |> loop() end + @doc """ + Reduces the engine by running a completion and then calling the reducer function with the result. + + The reducer function is a two-arity function of messages and accumulator. It should return a tuple of {:halt, result} if the reduction should halt, or the new accumulator if the reduction should continue. + """ def reduce(%__MODULE__{} = engine, [_ | _] = messages, reducer) when is_function(reducer, 2) do engine = %__MODULE__{engine | reducer: reducer} put_in(engine.messages, engine.messages ++ messages) diff --git a/lib/console/ai/fixer/service.ex b/lib/console/ai/fixer/service.ex index ca27be38e2..01d154e9ae 100644 --- a/lib/console/ai/fixer/service.ex +++ b/lib/console/ai/fixer/service.ex @@ -17,6 +17,7 @@ defmodule Console.AI.Fixer.Service do svc = Repo.preload(svc, [:cluster, :repository, :parent, owner: :parent, insight: :evidence]) %{ details: svc_details(svc), + plural_service_id: svc.id } |> add_git(svc) |> add_helm(svc, opts) diff --git a/lib/console/ai/tool.ex b/lib/console/ai/tool.ex index 81f6081065..7c46a4270b 100644 --- a/lib/console/ai/tool.ex +++ b/lib/console/ai/tool.ex @@ -9,15 +9,16 @@ defmodule Console.AI.Tool do AiInsight, AgentSession, ChatThread, - InfraResearch + InfraResearch, + AgentRuntime } alias Console.AI.Chat.Knowledge - alias Console.Deployments.{Git, Settings} + alias Console.Deployments.{Git, Settings, Agents} @type t :: %__MODULE__{} defmodule Context do - alias Console.Schema.{AgentSession, Flow, User, AiInsight, Stack, Cluster, Service, InfraResearch} + alias Console.Schema.{AgentSession, Flow, User, AiInsight, Stack, Cluster, Service, InfraResearch, AgentRuntime} @type t :: %__MODULE__{ flow: Flow.t, user: User.t, @@ -27,10 +28,11 @@ defmodule Console.AI.Tool do service: Service.t, session: AgentSession.t, thread: ChatThread.t, - research: InfraResearch.t + research: InfraResearch.t, + runtime: AgentRuntime.t } - defstruct [:flow, :user, :insight, :stack, :cluster, :service, :session, :thread, :research] + defstruct [:flow, :user, :insight, :stack, :cluster, :service, :session, :thread, :research, :runtime] def new(args), do: struct(__MODULE__, args) end @@ -120,4 +122,11 @@ defmodule Console.AI.Tool do _ -> Git.default_scm_connection() end end + + def agent_runtime() do + case Process.get(@ctx) do + %Context{runtime: %AgentRuntime{} = runtime} -> runtime + _ -> Agents.default_runtime() + end + end end diff --git a/lib/console/ai/tools/upgrade/agent_run.ex b/lib/console/ai/tools/upgrade/agent_run.ex new file mode 100644 index 0000000000..4569b75102 --- /dev/null +++ b/lib/console/ai/tools/upgrade/agent_run.ex @@ -0,0 +1,38 @@ +defmodule Console.AI.Tools.Upgrade.AgentRun do + use Console.AI.Tools.Agent.Base + import Console.AI.Tools.Utils + alias Console.Schema.{User, AgentRuntime} + alias Console.Deployments.Agents + + embedded_schema do + field :repository, :string + field :prompt, :string + end + + @valid ~w(repository prompt)a + + def changeset(model, attrs) do + model + |> cast(attrs, @valid) + end + + @json_schema Console.priv_file!("tools/upgrade/agent_run.json") |> Jason.decode!() + + def json_schema(), do: @json_schema + def name(), do: plrl_tool("coding_agent") + def description(), do: "Invokes a coding agent to make a code change with the given prompt and repository. Only use this once you've gathered enough information to craft an effective prompt" + + def implement(%__MODULE__{repository: repository, prompt: prompt}) do + with {:user, %User{} = user} <- {:user, Tool.actor()}, + {:runtime, %AgentRuntime{} = runtime} <- {:runtime, Tool.agent_runtime()} do + Agents.create_agent_run(%{ + repository: repository, + prompt: prompt, + }, runtime.id, user) + else + {:user, _} -> {:error, "no actor found for this session"} + {:runtime, _} -> {:error, "no runtime found"} + err -> err + end + end +end diff --git a/lib/console/deployments/agents.ex b/lib/console/deployments/agents.ex index 603f1b4130..51a2deb7e7 100644 --- a/lib/console/deployments/agents.ex +++ b/lib/console/deployments/agents.ex @@ -27,6 +27,8 @@ defmodule Console.Deployments.Agents do @type agent_msg_resp :: {:ok, AgentMessage.t} | error @type history_resp :: {:ok, AgentPromptHistory.t} | error + def default_runtime(), do: Repo.get_by(AgentRuntime, default: true) + def get_agent_runtime!(id), do: Repo.get!(AgentRuntime, id) def get_agent_runtime(cluster_id, name), diff --git a/lib/console/deployments/clusters.ex b/lib/console/deployments/clusters.ex index dcf4415c85..6a7db4d1a6 100644 --- a/lib/console/deployments/clusters.ex +++ b/lib/console/deployments/clusters.ex @@ -35,10 +35,12 @@ defmodule Console.Deployments.Clusters do DeploymentSettings, DeprecatedCustomResource, UpgradePlanCallout, - CustomCompatibilityMatrix + CustomCompatibilityMatrix, + ClusterUpgrade } alias Console.Deployments.Compatibilities require Logger + require EEx @cache_adapter Console.conf(:cache_adapter) @local_adapter Console.conf(:local_cache) @@ -55,6 +57,7 @@ defmodule Console.Deployments.Clusters do @type iso_resp :: {:ok, ClusterISOImage.t} | Console.error @type upgrade_plan_callout_resp :: {:ok, UpgradePlanCallout.t} | Console.error @type custom_compatibility_matrix_resp :: {:ok, CustomCompatibilityMatrix.t} | Console.error + @type cluster_upgrade_resp :: {:ok, ClusterUpgrade.t} | Console.error @spec count() :: integer def count(), do: Repo.aggregate(Cluster, :count) @@ -587,10 +590,67 @@ defmodule Console.Deployments.Clusters do end def kubelet_skew?(_), do: true + @doc """ + Creates a new cluster upgrade workflow for a given cluster. Will then enqueue and execute it agentically in the background. + """ + @spec create_cluster_upgrade(binary, User.t) :: cluster_upgrade_resp + @spec create_cluster_upgrade(map, binary, User.t) :: cluster_upgrade_resp + def create_cluster_upgrade(attrs \\ %{}, cluster_id, %User{id: user_id} = user) when is_map(attrs) do + start_transaction() + |> add_operation(:cluster, fn _ -> + get_cluster!(cluster_id) + |> allow(user, :write) + end) + |> add_operation(:version, fn %{cluster: cluster} -> Compatibilities.Utils.next_version(cluster.current_version) end) + |> add_operation(:plan, fn %{cluster: cluster} -> + plan = upgrade_plan(cluster) + Enum.concat(plan.blocking_addons, plan.blocking_cloud_addons) + |> Enum.filter(fn %{fix: fix} -> is_nil(fix) end) + |> case do + [_ | _] = blocking -> + {:error, "upgrade plan is not ready, the following components have no known fix: #{Enum.map(blocking, fn %{addon: addon} -> addon.name end) |> Enum.join(", ")}"} + _ -> {:ok, plan} + end + end) + |> add_operation(:upgrade, fn %{cluster: cluster, version: next_version, plan: plan} -> + addon_steps = Enum.map(plan.blocking_addons, fn %{current: curr, fix: fix, addon: addon} = blocker -> %{ + name: "Upgrade #{addon.name} from #{curr.version} to #{fix.version}", + prompt: addon_prompt(addon: addon, curr: curr, fix: fix, cluster: cluster, callout: blocker[:callout]) |> String.trim(), + type: :addon, + status: :pending + } end) + + cloud_addon_steps = Enum.map(plan.blocking_cloud_addons, fn %{current: curr, fix: fix, addon: addon} = blocker -> %{ + name: "Upgrade #{addon.name} from #{curr.version} to #{fix.version}", + prompt: cloud_addon_prompt(addon: addon, curr: curr, fix: fix, cluster: cluster, callout: blocker[:callout]) |> String.trim(), + type: :cloud_addon + } end) + + infrastructure_step = %{ + name: "Upgrade Kubernetes from #{cluster.current_version} to #{next_version} for cluster #{cluster.name}", + prompt: upgrade_prompt(cluster: cluster, next_version: next_version, failed_insights: plan.failed_insights) |> String.trim(), + type: :infrastructure + } + + %ClusterUpgrade{cluster_id: cluster.id, user_id: user_id} + |> ClusterUpgrade.changeset(Map.merge(attrs, %{ + version: next_version, + steps: addon_steps ++ cloud_addon_steps ++ [infrastructure_step] + })) + |> Repo.insert() + end) + |> execute(extract: :upgrade) + |> notify(:create, user) + end + + EEx.function_from_file(:defp, :upgrade_prompt, Console.priv_filename(["prompts", "cluster_upgrade.md.eex"]), [:assigns]) + EEx.function_from_file(:defp, :addon_prompt, Console.priv_filename(["prompts", "upgrade", "addon_inline.md.eex"]), [:assigns]) + EEx.function_from_file(:defp, :cloud_addon_prompt, Console.priv_filename(["prompts", "upgrade", "cloud_addon_inline.md.eex"]), [:assigns]) + @spec upgrade_plan(Cluster.t) :: %{ failed_insights: [UpgradeInsight.t], - blocking_addons: [%{current: Version.t, fix: Version.t | nil}], - blocking_cloud_addons: [%{current: CloudAddOnVersion.t, fix: CloudAddOnVersion.t | nil}] + blocking_addons: [%{current: Version.t, fix: Version.t | nil, addon: AddOn.t}], + blocking_cloud_addons: [%{current: CloudAddOnVersion.t, fix: CloudAddOnVersion.t | nil, addon: CloudAddon.t}] } def upgrade_plan(%Cluster{} = cluster) do cluster = Repo.preload(cluster, [:upgrade_insights]) @@ -1540,6 +1600,9 @@ defmodule Console.Deployments.Clusters do defp notify({:ok, %ProviderCredential{} = prov}, :delete, user), do: handle_notify(PubSub.ProviderCredentialDeleted, prov, actor: user) + defp notify({:ok, %ClusterUpgrade{} = upgrade}, :create, user), + do: handle_notify(PubSub.ClusterUpgradeCreated, upgrade, actor: user) + defp notify({:ok, %AgentMigration{} = migration}, :create, user), do: handle_notify(PubSub.AgentMigrationCreated, migration, actor: user) diff --git a/lib/console/deployments/compatibilities/utils.ex b/lib/console/deployments/compatibilities/utils.ex index 720a5eda50..6785150434 100644 --- a/lib/console/deployments/compatibilities/utils.ex +++ b/lib/console/deployments/compatibilities/utils.ex @@ -14,7 +14,7 @@ defmodule Console.Deployments.Compatibilities.Utils do |> case do {:ok, %Version{} = vsn} -> Enum.filter(versions, fn next -> - case Version.parse(next.version) do + case Version.parse(clean_version(next.version)) do {:ok, %Version{} = next} -> :lt == Version.compare(vsn, next) _ -> false end @@ -37,6 +37,16 @@ defmodule Console.Deployments.Compatibilities.Utils do end end + @spec next_version(binary) :: {:ok, binary} | Console.error + def next_version(current_version) when is_binary(current_version) do + with {:ok, %{major: maj, minor: min}} <- Version.parse(clean_version(current_version)) do + {:ok, "#{maj}.#{min + 1}"} + else + _ -> {:error, "invalid semver #{current_version}"} + end + end + def next_version(_), do: {:error, "no version provided"} + def blocking?(kube_vsns, kube_vsn, inc \\ 1) def blocking?(kube_vsns, kube_vsn, inc) when is_list(kube_vsns) do with {:ok, %{major: maj, minor: min}} <- Version.parse(clean_version(kube_vsn)) do diff --git a/lib/console/deployments/events.ex b/lib/console/deployments/events.ex index 05a730346f..81fed129ed 100644 --- a/lib/console/deployments/events.ex +++ b/lib/console/deployments/events.ex @@ -11,6 +11,8 @@ defmodule Console.PubSub.ClusterUpdated, do: use Piazza.PubSub.Event defmodule Console.PubSub.ClusterDeleted, do: use Piazza.PubSub.Event defmodule Console.PubSub.ClusterPinged, do: use Piazza.PubSub.Event +defmodule Console.PubSub.ClusterUpgradeCreated, do: use Piazza.PubSub.Event + defmodule Console.PubSub.ProviderCreated, do: use Piazza.PubSub.Event defmodule Console.PubSub.ProviderUpdated, do: use Piazza.PubSub.Event defmodule Console.PubSub.ProviderDeleted, do: use Piazza.PubSub.Event diff --git a/lib/console/deployments/pubsub/recurse.ex b/lib/console/deployments/pubsub/recurse.ex index 5bdbf9645e..8129879e7f 100644 --- a/lib/console/deployments/pubsub/recurse.ex +++ b/lib/console/deployments/pubsub/recurse.ex @@ -56,6 +56,13 @@ defimpl Console.PubSub.Recurse, for: Console.PubSub.ClusterPinged do def process(_), do: :ok end +defimpl Console.PubSub.Recurse, for: Console.PubSub.ClusterUpgradeCreated do + alias Console.Deployments.Clusters + + def process(%{item: upgrade}), + do: Task.Supervisor.async(Console.AI.TaskSupervisor, Console.AI.Agents.Upgrade, :exec, [upgrade]) +end + defimpl Console.PubSub.Recurse, for: [Console.PubSub.GlobalServiceCreated, Console.PubSub.GlobalServiceUpdated] do alias Console.Deployments.Global diff --git a/lib/console/graphql/deployments/cluster.ex b/lib/console/graphql/deployments/cluster.ex index e2fa5187f2..6b367e7872 100644 --- a/lib/console/graphql/deployments/cluster.ex +++ b/lib/console/graphql/deployments/cluster.ex @@ -10,6 +10,9 @@ defmodule Console.GraphQl.Deployments.Cluster do ecto_enum :service_mesh, Console.Schema.OperationalLayout.ServiceMesh ecto_enum :insight_component_priority, Console.Schema.ClusterInsightComponent.Priority ecto_enum :node_statistic_health, Console.Schema.NodeStatistic.Health + ecto_enum :cluster_upgrade_status, Console.Schema.ClusterUpgrade.Status + ecto_enum :cluster_upgrade_step_type, Console.Schema.ClusterUpgradeStep.Type + enum :conjunction do value :and value :or @@ -414,6 +417,11 @@ defmodule Console.GraphQl.Deployments.Cluster do field :breaking_changes, list_of(non_null(:string)), description: "the breaking changes for this version" end + input_object :cluster_upgrade_attributes do + field :prompt, :string, description: "the prompt for the upgrade" + field :runtime_id, :id, description: "the runtime to use for the upgrade" + end + @desc "a CAPI provider for a cluster, cloud is inferred from name if not provided manually" object :cluster_provider do field :id, non_null(:id), description: "the id of this provider" @@ -542,6 +550,7 @@ defmodule Console.GraphQl.Deployments.Cluster do field :object_store, :object_store, resolve: dataloader(Deployments), description: "the object store connection bound to this cluster for backup/restore" field :parent_cluster, :cluster, resolve: dataloader(Deployments), description: "the parent of this virtual cluster" field :insight, :ai_insight, resolve: dataloader(Deployments), description: "an ai insight generated about issues discovered which might impact the health of this cluster" + field :current_upgrade, :cluster_upgrade, resolve: dataloader(Deployments), description: "the current upgrade attempt for this cluster" field :operational_layout, :operational_layout, resolve: dataloader(Deployments), description: "a high level description of the setup of common resources in a cluster" field :insight_components, list_of(:cluster_insight_component), resolve: dataloader(Deployments), description: "a set of kubernetes resources used to generate the ai insight for this cluster" @@ -1001,6 +1010,35 @@ defmodule Console.GraphQl.Deployments.Cluster do field :last_request_at, :datetime end + @desc "A representation of an agentic attempt to upgrade this cluster" + object :cluster_upgrade do + field :id, non_null(:id) + field :version, :string + field :status, non_null(:cluster_upgrade_status) + field :steps, list_of(:cluster_upgrade_step), resolve: dataloader(Deployments) + field :runtime, :agent_runtime, resolve: dataloader(Deployments) + + field :cluster, :cluster, resolve: dataloader(Deployments) + field :user, :user, resolve: dataloader(User) + + timestamps() + end + + @desc "A step in an agentic attempt to upgrade a specific component or piece of infrastructure in this kubernetes cluster" + object :cluster_upgrade_step do + field :id, non_null(:id) + field :name, non_null(:string), description: "the name of the step" + field :prompt, non_null(:string), description: "the prompt used to generate the step" + field :status, non_null(:cluster_upgrade_status), description: "the status of the step" + field :type, non_null(:cluster_upgrade_step_type), description: "the type of step" + field :error, :string, description: "the error message for the step if present" + + field :agent_run, :agent_run, resolve: dataloader(Deployments) + field :upgrade, :cluster_upgrade, resolve: dataloader(Deployments) + + timestamps() + end + object :cluster_usage do field :id, non_null(:id) field :cpu, :float @@ -1620,6 +1658,17 @@ defmodule Console.GraphQl.Deployments.Cluster do resolve &Deployments.detach_cluster/2 end + field :create_cluster_upgrade, :cluster_upgrade do + middleware Authenticated + middleware Scope, + resource: :cluster, + action: :write + arg :id, non_null(:id) + arg :attributes, :cluster_upgrade_attributes + + safe_resolve &Deployments.create_cluster_upgrade/2 + end + field :create_cluster_provider, :cluster_provider do middleware Authenticated arg :attributes, non_null(:cluster_provider_attributes) diff --git a/lib/console/graphql/deployments/service.ex b/lib/console/graphql/deployments/service.ex index 10a403bce7..3c50d217b3 100644 --- a/lib/console/graphql/deployments/service.ex +++ b/lib/console/graphql/deployments/service.ex @@ -57,21 +57,22 @@ defmodule Console.GraphQl.Deployments.Service do end input_object :helm_config_attributes do - field :values, :string - field :values_files, list_of(:string) - field :chart, :string - field :version, :string - field :release, :string - field :url, :string - field :ignore_hooks, :boolean - field :ignore_crds, :boolean - field :lua_script, :string - field :lua_file, :string - field :lua_folder, :string - field :set, :helm_value_attributes - field :repository, :namespaced_name - field :git, :git_ref_attributes - field :repository_id, :id, description: "pointer to a Plural GitRepository" + field :values, :string + field :values_files, list_of(:string) + field :chart, :string + field :version, :string + field :release, :string + field :url, :string + field :ignore_hooks, :boolean + field :ignore_crds, :boolean + field :lua_script, :string + field :lua_file, :string + field :lua_folder, :string + field :set, :helm_value_attributes + field :repository, :namespaced_name + field :git, :git_ref_attributes + field :kustomize_postrender, :string, description: "a folder containing a kustomization to apply to the result of rendering this service's manifests" + field :repository_id, :id, description: "pointer to a Plural GitRepository" end input_object :metadata_attributes do @@ -353,23 +354,24 @@ defmodule Console.GraphQl.Deployments.Service do end object :helm_spec do - field :chart, :string, description: "the name of the chart this service is using" - field :url, :string, description: "the helm repository url to use" - field :values, :string, + field :chart, :string, description: "the name of the chart this service is using" + field :url, :string, description: "the helm repository url to use" + field :values, :string, description: "a helm values file to use with this service, requires auth and so is heavy to query", resolve: &Deployments.helm_values/3 - field :release, :string - field :ignore_hooks, :boolean - field :ignore_crds, :boolean - field :git, :git_ref, description: "spec of where to find the chart in git" - field :repository_id, :id, description: "a git repository in Plural to use as a source" - field :repository, :object_reference, description: "pointer to the flux helm repository resource used for this chart" - field :version, :string, description: "the chart version in use currently" - field :set, list_of(:helm_value), description: "a list of helm name/value pairs to precisely set individual values" - field :values_files, list_of(:string), description: "a list of relative paths to values files to use for helm applies" - field :lua_script, :string, description: "a lua script to use for helm applies" - field :lua_file, :string, description: "a lua file to use for helm applies" - field :lua_folder, :string, description: "a folder of lua files to include in the final script used" + field :release, :string + field :ignore_hooks, :boolean + field :ignore_crds, :boolean + field :git, :git_ref, description: "spec of where to find the chart in git" + field :repository_id, :id, description: "a git repository in Plural to use as a source" + field :repository, :object_reference, description: "pointer to the flux helm repository resource used for this chart" + field :version, :string, description: "the chart version in use currently" + field :set, list_of(:helm_value), description: "a list of helm name/value pairs to precisely set individual values" + field :values_files, list_of(:string), description: "a list of relative paths to values files to use for helm applies" + field :lua_script, :string, description: "a lua script to use for helm applies" + field :lua_file, :string, description: "a lua file to use for helm applies" + field :lua_folder, :string, description: "a folder of lua files to include in the final script used" + field :kustomize_postrender, :string, description: "a folder containing a kustomization to apply to the result of rendering this service's manifests" end @desc "a configuration item k/v pair" diff --git a/lib/console/graphql/resolvers/deployments.ex b/lib/console/graphql/resolvers/deployments.ex index 96a7d68f37..79d9730803 100644 --- a/lib/console/graphql/resolvers/deployments.ex +++ b/lib/console/graphql/resolvers/deployments.ex @@ -103,7 +103,9 @@ defmodule Console.GraphQl.Resolvers.Deployments do AgentMessage, SentinelRun, SentinelRunJob, - ChatConnection + ChatConnection, + ClusterUpgrade, + ClusterUpgradeStep } def query(Project, _), do: Project @@ -196,6 +198,8 @@ defmodule Console.GraphQl.Resolvers.Deployments do def query(AgentPrompt, _), do: AgentPrompt.ordered() def query(AgentMessage, _), do: AgentMessage.ordered() def query(ChatConnection, _), do: ChatConnection + def query(ClusterUpgrade, _), do: ClusterUpgrade + def query(ClusterUpgradeStep, _), do: ClusterUpgradeStep def query(_, _), do: Cluster delegates Console.GraphQl.Resolvers.Deployments.Git diff --git a/lib/console/graphql/resolvers/deployments/cluster.ex b/lib/console/graphql/resolvers/deployments/cluster.ex index 8a839567b1..013a012024 100644 --- a/lib/console/graphql/resolvers/deployments/cluster.ex +++ b/lib/console/graphql/resolvers/deployments/cluster.ex @@ -277,6 +277,9 @@ defmodule Console.GraphQl.Resolvers.Deployments.Cluster do def delete_virtual_cluster(%{id: id}, %{context: %{current_user: user}}), do: Clusters.delete_virtual_cluster(id, user) + def create_cluster_upgrade(%{id: id} = args, %{context: %{current_user: user}}), + do: Clusters.create_cluster_upgrade(args[:attributes] || %{}, id, user) + def create_provider(%{attributes: attrs}, %{context: %{current_user: user}}), do: Clusters.create_provider(attrs, user) diff --git a/lib/console/schema/cluster.ex b/lib/console/schema/cluster.ex index 7ae362ddbc..b126be93df 100644 --- a/lib/console/schema/cluster.ex +++ b/lib/console/schema/cluster.ex @@ -23,7 +23,8 @@ defmodule Console.Schema.Cluster do CloudAddon, OperationalLayout, DeprecatedCustomResource, - NodeStatistic + NodeStatistic, + ClusterUpgrade, } defenum Distro, generic: 0, eks: 1, aks: 2, gke: 3, rke: 4, k3s: 5, openshift: 6 @@ -135,13 +136,14 @@ defmodule Console.Schema.Cluster do embeds_one :kubeconfig, Kubeconfig, on_replace: :update embeds_one :cloud_settings, CloudSettings, on_replace: :update - belongs_to :provider, ClusterProvider - belongs_to :service, Service - belongs_to :credential, ProviderCredential - belongs_to :object_store, ObjectStore - belongs_to :restore, ClusterRestore - belongs_to :project, Project - belongs_to :insight, AiInsight, on_replace: :update + belongs_to :provider, ClusterProvider + belongs_to :service, Service + belongs_to :credential, ProviderCredential + belongs_to :object_store, ObjectStore + belongs_to :restore, ClusterRestore + belongs_to :project, Project + belongs_to :insight, AiInsight, on_replace: :update + belongs_to :current_upgrade, ClusterUpgrade belongs_to :parent_cluster, __MODULE__ has_one :operational_layout, OperationalLayout, on_replace: :update diff --git a/lib/console/schema/cluster_upgrade.ex b/lib/console/schema/cluster_upgrade.ex new file mode 100644 index 0000000000..4a4191f601 --- /dev/null +++ b/lib/console/schema/cluster_upgrade.ex @@ -0,0 +1,32 @@ +defmodule Console.Schema.ClusterUpgrade do + use Piazza.Ecto.Schema + alias Console.Schema.{Cluster, User, ClusterUpgradeStep, AgentRuntime} + + defenum Status, pending: 0, in_progress: 1, completed: 2, failed: 3 + + schema "cluster_upgrades" do + field :version, :string + field :prompt, :string + field :status, Status, default: :pending + + belongs_to :cluster, Cluster + belongs_to :user, User + belongs_to :runtime, AgentRuntime + + has_many :steps, ClusterUpgradeStep, foreign_key: :upgrade_id, on_replace: :delete + + timestamps() + end + + @valid ~w(version status cluster_id user_id runtime_id prompt)a + + def changeset(model, attrs \\ %{}) do + model + |> cast(attrs, @valid) + |> cast_assoc(:steps) + |> foreign_key_constraint(:cluster_id) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:runtime_id) + |> validate_required([:version, :status, :cluster_id, :user_id]) + end +end diff --git a/lib/console/schema/cluster_upgrade_steps.ex b/lib/console/schema/cluster_upgrade_steps.ex new file mode 100644 index 0000000000..d50d309f71 --- /dev/null +++ b/lib/console/schema/cluster_upgrade_steps.ex @@ -0,0 +1,29 @@ +defmodule Console.Schema.ClusterUpgradeStep do + use Piazza.Ecto.Schema + alias Console.Schema.{ClusterUpgrade, AgentRun} + + defenum Type, addon: 0, cloud_addon: 1, infrastructure: 2 + + schema "cluster_upgrade_steps" do + field :name, :string + field :prompt, :string + field :error, :binary + field :type, Type + field :status, ClusterUpgrade.Status, default: :pending + + belongs_to :upgrade, ClusterUpgrade + belongs_to :agent_run, AgentRun + + timestamps() + end + + @valid ~w(name prompt error type status upgrade_id agent_run_id)a + + def changeset(model, attrs \\ %{}) do + model + |> cast(attrs, @valid) + |> foreign_key_constraint(:upgrade_id) + |> foreign_key_constraint(:agent_run_id) + |> validate_required([:name, :prompt, :type, :status]) + end +end diff --git a/lib/console/schema/service.ex b/lib/console/schema/service.ex index c97ca9d4fd..5506f7e5fe 100644 --- a/lib/console/schema/service.ex +++ b/lib/console/schema/service.ex @@ -53,18 +53,19 @@ defmodule Console.Schema.Service do alias Console.Schema.{NamespacedName, Service.Git} embedded_schema do - field :values, Piazza.Ecto.EncryptedString - field :chart, :string - field :version, :string - field :release, :string - field :url, :string - field :values_files, {:array, :string} - field :repository_id, :binary_id - field :ignore_hooks, :boolean - field :ignore_crds, :boolean - field :lua_script, :string - field :lua_file, :string - field :lua_folder, :string + field :values, Piazza.Ecto.EncryptedString + field :chart, :string + field :version, :string + field :release, :string + field :url, :string + field :values_files, {:array, :string} + field :repository_id, :binary_id + field :ignore_hooks, :boolean + field :ignore_crds, :boolean + field :lua_script, :string + field :lua_file, :string + field :lua_folder, :string + field :kustomize_postrender, :string embeds_many :set, HelmValue, on_replace: :delete do field :name, :string @@ -77,7 +78,7 @@ defmodule Console.Schema.Service do def changeset(model, attrs \\ %{}) do model - |> cast(attrs, ~w(values ignore_hooks ignore_crds release url chart version repository_id values_files lua_script lua_folder lua_file)a) + |> cast(attrs, ~w(values ignore_hooks ignore_crds release url chart version repository_id kustomize_postrender values_files lua_script lua_folder lua_file)a) |> cast_embed(:repository) |> cast_embed(:set, with: &set_changeset/2) |> helm_url(:url) diff --git a/priv/prompts/cluster_upgrade.md.eex b/priv/prompts/cluster_upgrade.md.eex new file mode 100644 index 0000000000..6377b666fb --- /dev/null +++ b/priv/prompts/cluster_upgrade.md.eex @@ -0,0 +1,12 @@ +Upgrade the Kubernetes component from <%= @cluster.distro %> to <%= @next_version %> for the <%= @cluster.distro %> cluster <%= @cluster.name %>. + +<%= if !Enum.empty?(@failed_insights) do %> +In addition, there are a few EKS insights you'll also want to address as part of this upgrade process, here are the details: + +<%= Enum.map(@failed_insights, fn failed_insight -> %> +Name: <%= failed_insight.name %> +Description: <%= failed_insight.description %> +<% end) %> + +(You can ignore deprecated api usage insights, those are handled in a separate process entirely) +<% end %> diff --git a/priv/prompts/upgrade/addon.md.eex b/priv/prompts/upgrade/addon.md.eex new file mode 100644 index 0000000000..02295e9ac1 --- /dev/null +++ b/priv/prompts/upgrade/addon.md.eex @@ -0,0 +1,20 @@ +You're tasked with upgrading a common kubernetes addon. This will likely be managed via GitOps, usually via Plural as a GlobalService or as a standalone Plural Service. + +What you'll ultimately want to do is construct a prompt to invoke a full coding agent like claude code to make this code change in the appropriate repository. To do this, here is your workflow: + +1. Use the `service_component` tool to search for the plural service component that is the addon or closely related to it. +2. Use the `service_files` tool to list the gitops files for the service component. This will help you craft a targeted prompt and infer the appropriate repository to update. +3. Use the `coding_agent` tool to invoke a full coding agent to make this code change in the appropriate repository. + +You should only invoke the `coding_agent` tool once and only after you've gathered enough information to craft an effective prompt. + +Here is the prompt we've been given about the component to upgrade: + +<%= @prompt %> + +<%= if byte_size(@upgrade.prompt) > 0 do %> +We've also been given some high level guidance about this upgrade that could be helpful for you: + +<%= @upgrade.prompt %> + +<% end %> diff --git a/priv/prompts/upgrade/addon_inline.md.eex b/priv/prompts/upgrade/addon_inline.md.eex new file mode 100644 index 0000000000..3963500e4f --- /dev/null +++ b/priv/prompts/upgrade/addon_inline.md.eex @@ -0,0 +1,9 @@ +Upgrade the <%= @addon.name %> kubernetes component from <%= @curr.version %> to <%= @fix.version %>. + +This will likely be modeled as either a Plural Service or Plural GlobalService, and is currently deployed to the <%= @cluster.name %> <%= @cluster.distro %> cluster. + +<%= if is_binary(@callout) do %> +There's also some custom guidance about this specific addon: + +<%= @callout %> +<% end %> diff --git a/priv/prompts/upgrade/cloud_addon.md.eex b/priv/prompts/upgrade/cloud_addon.md.eex new file mode 100644 index 0000000000..b6fd74132f --- /dev/null +++ b/priv/prompts/upgrade/cloud_addon.md.eex @@ -0,0 +1,21 @@ +You're tasked with upgrading a common kubernetes cloud-managed addon, likely an EKS Addon. This will almost certainly be managed via Terraform. + +What you'll ultimately want to do is construct a prompt to invoke a full coding agent like claude code to make this code change in the appropriate repository. To do this, here is your workflow: + +1. Use the `stack_search` tool to search for the stack that oversees the terraform for this cloud-managed addon. +2. Use the `stack_files` tool to list the files for the stack. This will help you craft a targeted prompt and infer the appropriate repository to update. +3. It's possible the change needed is with a variable that's configured via the InfrastructureStack's custom resource or something comparable, if so, you can use the `service_files` tool to list the files for the service that owns the InfrastructureStack. +4. Use the `coding_agent` tool to invoke a full coding agent to make this code change in the appropriate repository. + +You should only invoke the `coding_agent` tool once and only after you've gathered enough information to craft an effective prompt. + +Here is the prompt we've been given about the component to upgrade: + +<%= @prompt %> + +<%= if byte_size(@upgrade.prompt) > 0 do %> +We've also been given some high level guidance about this upgrade that could be helpful for you: + +<%= @upgrade.prompt %> + +<% end %> diff --git a/priv/prompts/upgrade/cloud_addon_inline.md.eex b/priv/prompts/upgrade/cloud_addon_inline.md.eex new file mode 100644 index 0000000000..4d548003e0 --- /dev/null +++ b/priv/prompts/upgrade/cloud_addon_inline.md.eex @@ -0,0 +1,9 @@ +Upgrade the <%= @addon.name %> cloud managed addon from <%= @curr.version %> to <%= @fix.version %>. + +This will likely be modeled as a EKS Addon or similar, managed via terrafrom, and is currently deployed to the <%= @cluster.name %> <%= @cluster.distro %> cluster + +<%= if is_binary(@callout) do %> +There's also some custom guidance about this specific addon: + +<%= @callout %> +<% end %> diff --git a/priv/prompts/upgrade/infrastructure.md.eex b/priv/prompts/upgrade/infrastructure.md.eex new file mode 100644 index 0000000000..4ad1eb9faf --- /dev/null +++ b/priv/prompts/upgrade/infrastructure.md.eex @@ -0,0 +1,21 @@ +You're tasked with upgrading a kubernetes cluster and addressing any remaining upgrade insights for the cluster. This will almost certainly be managed via Terraform. + +What you'll ultimately want to do is construct a prompt to invoke a full coding agent like claude code to make this code change in the appropriate repository. To do this, here is your workflow: + +1. Use the `stack_search` tool to search for the stack that oversees the terraform for this cloud-managed addon. +2. Use the `stack_files` tool to list the files for the stack. This will help you craft a targeted prompt and infer the appropriate repository to update +3. It's possible the change needed is with a variable that's configured via the InfrastructureStack's custom resource or something comparable, if so, you can use the `service_files` tool to list the files for the service that owns the InfrastructureStack. +4. Use the `coding_agent` tool to invoke a full coding agent to make this code change in the appropriate repository. + +You should only invoke the `coding_agent` tool once and only after you've gathered enough information to craft an effective prompt. + +Here is the prompt we've been given about the component to upgrade: + +<%= @prompt %> + +<%= if byte_size(@upgrade.prompt) > 0 do %> +We've also been given some high level guidance about this upgrade that could be helpful for you: + +<%= @upgrade.prompt %> + +<% end %> diff --git a/priv/repo/migrations/20260129002329_add_cluster_upgrades.exs b/priv/repo/migrations/20260129002329_add_cluster_upgrades.exs new file mode 100644 index 0000000000..2e6c8c6bbb --- /dev/null +++ b/priv/repo/migrations/20260129002329_add_cluster_upgrades.exs @@ -0,0 +1,42 @@ +defmodule Console.Repo.Migrations.AddClusterUpgrades do + use Ecto.Migration + + def change do + create table(:cluster_upgrades, primary_key: false) do + add :id, :uuid, primary_key: true + add :cluster_id, references(:clusters, type: :uuid, on_delete: :delete_all) + add :user_id, references(:watchman_users, type: :uuid, on_delete: :delete_all) + add :runtime_id, references(:agent_runtimes, type: :uuid, on_delete: :delete_all) + add :prompt, :binary + add :version, :string + add :status, :integer + + timestamps() + end + + create index(:cluster_upgrades, [:cluster_id]) + create index(:cluster_upgrades, [:user_id]) + + create table(:cluster_upgrade_steps, primary_key: false) do + add :id, :uuid, primary_key: true + add :type, :integer + add :upgrade_id, references(:cluster_upgrades, type: :uuid, on_delete: :delete_all) + add :agent_run_id, references(:agent_runs, type: :uuid, on_delete: :nilify_all) + add :name, :string + add :prompt, :binary + add :status, :integer + add :error, :binary + + timestamps() + end + + create index(:cluster_upgrade_steps, [:upgrade_id]) + create index(:cluster_upgrade_steps, [:agent_run_id]) + + alter table(:clusters) do + add :current_upgrade_id, references(:cluster_upgrades, type: :uuid, on_delete: :nilify_all) + end + + create index(:clusters, [:current_upgrade_id]) + end +end diff --git a/priv/tools/upgrade/agent_run.json b/priv/tools/upgrade/agent_run.json new file mode 100644 index 0000000000..ee7c521846 --- /dev/null +++ b/priv/tools/upgrade/agent_run.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "The repository to update. This can be either a https or ssh url." + }, + "prompt": { + "type": "string", + "description": "The prompt to give to the agent. This should be a detailed description of the changes you want to make to the repository." + } + }, + "required": ["repository", "prompt"] +} \ No newline at end of file diff --git a/schema/schema.graphql b/schema/schema.graphql index b30e86a18f..6e3cd6b540 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -723,6 +723,8 @@ type RootMutationType { "soft deletes a cluster, by deregistering it in our system but not disturbing any kubernetes objects" detachCluster(id: ID!): Cluster + createClusterUpgrade(id: ID!, attributes: ClusterUpgradeAttributes): ClusterUpgrade + createClusterProvider(attributes: ClusterProviderAttributes!): ClusterProvider updateClusterProvider(id: ID!, attributes: ClusterProviderUpdateAttributes!): ClusterProvider @@ -5999,6 +6001,9 @@ input HelmConfigAttributes { git: GitRefAttributes + "a folder containing a kustomization to apply to the result of rendering this service's manifests" + kustomizePostrender: String + "pointer to a Plural GitRepository" repositoryId: ID } @@ -6434,6 +6439,9 @@ type HelmSpec { "a folder of lua files to include in the final script used" luaFolder: String + + "a folder containing a kustomization to apply to the result of rendering this service's manifests" + kustomizePostrender: String } "a configuration item k\/v pair" @@ -6799,6 +6807,19 @@ enum NodeStatisticHealth { FAILED } +enum ClusterUpgradeStatus { + PENDING + IN_PROGRESS + COMPLETED + FAILED +} + +enum ClusterUpgradeStepType { + ADDON + CLOUD_ADDON + INFRASTRUCTURE +} + enum Conjunction { AND OR @@ -7378,6 +7399,14 @@ input CompatibilityMatrixSummaryAttributes { breakingChanges: [String!] } +input ClusterUpgradeAttributes { + "the prompt for the upgrade" + prompt: String + + "the runtime to use for the upgrade" + runtimeId: ID +} + "a CAPI provider for a cluster, cloud is inferred from name if not provided manually" type ClusterProvider { "the id of this provider" @@ -7603,6 +7632,9 @@ type Cluster { "an ai insight generated about issues discovered which might impact the health of this cluster" insight: AiInsight + "the current upgrade attempt for this cluster" + currentUpgrade: ClusterUpgrade + "a high level description of the setup of common resources in a cluster" operationalLayout: OperationalLayout @@ -8159,6 +8191,47 @@ type InsightClientInfo { lastRequestAt: DateTime } +"A representation of an agentic attempt to upgrade this cluster" +type ClusterUpgrade { + id: ID! + version: String + status: ClusterUpgradeStatus! + steps: [ClusterUpgradeStep] + runtime: AgentRuntime + cluster: Cluster + user: User + insertedAt: DateTime + updatedAt: DateTime +} + +"A step in an agentic attempt to upgrade a specific component or piece of infrastructure in this kubernetes cluster" +type ClusterUpgradeStep { + id: ID! + + "the name of the step" + name: String! + + "the prompt used to generate the step" + prompt: String! + + "the status of the step" + status: ClusterUpgradeStatus! + + "the type of step" + type: ClusterUpgradeStepType! + + "the error message for the step if present" + error: String + + agentRun: AgentRun + + upgrade: ClusterUpgrade + + insertedAt: DateTime + + updatedAt: DateTime +} + type ClusterUsage { id: ID! diff --git a/test/console/deployments/clusters_test.exs b/test/console/deployments/clusters_test.exs index 24f692e0f2..b704a5d07e 100644 --- a/test/console/deployments/clusters_test.exs +++ b/test/console/deployments/clusters_test.exs @@ -1378,6 +1378,47 @@ defmodule Console.Deployments.ClustersTest do end end + describe "#create_cluster_upgrade/2" do + test "it can create an upgrade with addon and cloud addon steps" do + user = admin_user() + cluster = insert(:cluster, current_version: "1.31.1", write_bindings: [%{user_id: user.id}]) + insert(:runtime_service, cluster: cluster, name: "ingress-nginx", version: "1.11.0") + insert(:cloud_addon, cluster: cluster, name: "coredns", version: "v1.10.1-eksbuild.38", distro: :eks) + + {:ok, upgrade} = Clusters.create_cluster_upgrade(cluster.id, user) + + assert upgrade.cluster_id == cluster.id + assert upgrade.user_id == user.id + assert upgrade.version == "1.32" + + assert_receive {:event, %PubSub.ClusterUpgradeCreated{item: ^upgrade}} + + types = Enum.frequencies_by(upgrade.steps, & &1.type) + + assert types[:addon] == 1 + assert types[:cloud_addon] == 1 + assert types[:infrastructure] == 1 + end + + test "it does not allow creation of cluster upgrade if user cannot read the cluster" do + user = insert(:user) + cluster = insert(:cluster, current_version: "1.30.1") + + {:error, _} = Clusters.create_cluster_upgrade(cluster.id, user) + end + + test "it errors if the upgrade plan is not ready" do + user = admin_user() + cluster = insert(:cluster, current_version: "1.35.1", write_bindings: [%{user_id: user.id}]) + insert(:cloud_addon, cluster: cluster, name: "coredns", version: "v1.13.1-eksbuild.1", distro: :eks) + + {:error, msg} = Clusters.create_cluster_upgrade(cluster.id, user) + + assert msg =~ "upgrade plan is not ready" + assert msg =~ "coredns" + end + end + describe "#create_cluster_registration/2" do test "project writers can create registrations" do user = insert(:user) diff --git a/test/console/graphql/mutations/deployments/cluster_mutations_test.exs b/test/console/graphql/mutations/deployments/cluster_mutations_test.exs index 56e4e76607..d1c11377c0 100644 --- a/test/console/graphql/mutations/deployments/cluster_mutations_test.exs +++ b/test/console/graphql/mutations/deployments/cluster_mutations_test.exs @@ -492,6 +492,29 @@ defmodule Console.GraphQl.Deployments.ClusterMutationsTest do end end + describe "createClusterUpgrade" do + test "it can create a cluster upgrade" do + user = insert(:user) + cluster = insert(:cluster, current_version: "1.24.0", write_bindings: [%{user_id: user.id}]) + + {:ok, %{data: %{"createClusterUpgrade" => upgrade}}} = run_query(""" + mutation Upgrade($id: ID!) { + createClusterUpgrade(id: $id) { + id + version + status + steps { name status type } + } + } + """, %{"id" => cluster.id}, %{current_user: user}) + + assert upgrade["id"] + assert upgrade["version"] == "1.25" + assert upgrade["status"] == "PENDING" + assert length(upgrade["steps"]) > 0 + end + end + describe "addClusterAuditLog" do test "it enqueues an audit log" do cluster = insert(:cluster)