From d875d067e975c3d6bf2ddae9106b087c65769a68 Mon Sep 17 00:00:00 2001 From: michaeljguarino Date: Wed, 8 Jan 2025 17:28:58 -0500 Subject: [PATCH] Adds automated PRs to apply scaling recommendations (#1734) --- AGENT_VERSION | 2 +- Dockerfile | 2 +- .../details/ClusterScalingRecsTableCols.tsx | 57 ++++++++++++- .../CostManagementDetailsRecommendations.tsx | 3 +- assets/src/generated/graphql-kubernetes.ts | 2 +- assets/src/generated/graphql-plural.ts | 2 +- assets/src/generated/graphql.ts | 82 +++++++++++++++---- assets/src/graph/costManagement.graphql | 11 ++- lib/console/ai/fixer.ex | 14 ++-- lib/console/ai/tools/pr.ex | 20 +++++ lib/console/cost/dimensions.ex | 17 ++++ lib/console/cost/pr.ex | 57 +++++++++++++ lib/console/graphql/deployments/cluster.ex | 7 ++ .../graphql/resolvers/deployments/cluster.ex | 4 + lib/console/logs/provider/victoria.ex | 2 + lib/console/logs/query.ex | 1 - lib/console/schema/deployment_settings.ex | 2 +- priv/tools/pr.json | 13 +++ schema/schema.graphql | 2 + test/console/cost/pr_test.exs | 62 ++++++++++++++ 20 files changed, 331 insertions(+), 31 deletions(-) create mode 100644 lib/console/cost/dimensions.ex create mode 100644 lib/console/cost/pr.ex create mode 100644 test/console/cost/pr_test.exs diff --git a/AGENT_VERSION b/AGENT_VERSION index 4d5708f66..c70db8fec 100644 --- a/AGENT_VERSION +++ b/AGENT_VERSION @@ -1 +1 @@ -v0.5.5 \ No newline at end of file +v0.5.6 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8b9479178..a84dc81f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,7 +69,7 @@ ARG TARGETARCH=amd64 # ENV TERRAFORM_VERSION=v1.9.8 # renovate: datasource=github-releases depName=pluralsh/plural-cli -ENV CLI_VERSION=v0.11.1 +ENV CLI_VERSION=v0.11.2 # renovate: datasource=github-tags depName=kubernetes/kubernetes # ENV KUBECTL_VERSION=v1.31.3 diff --git a/assets/src/components/cost-management/details/ClusterScalingRecsTableCols.tsx b/assets/src/components/cost-management/details/ClusterScalingRecsTableCols.tsx index 68e343a41..e9394845a 100644 --- a/assets/src/components/cost-management/details/ClusterScalingRecsTableCols.tsx +++ b/assets/src/components/cost-management/details/ClusterScalingRecsTableCols.tsx @@ -1,7 +1,11 @@ +import { Button, LinkoutIcon, PrOpenIcon, Toast } from '@pluralsh/design-system' import { createColumnHelper } from '@tanstack/react-table' import { StackedText } from 'components/utils/table/StackedText' import { Body2P } from 'components/utils/typography/Text' -import { ClusterScalingRecommendationFragment } from 'generated/graphql' +import { + ClusterScalingRecommendationFragment, + useApplyScalingRecommendationMutation, +} from 'generated/graphql' import styled from 'styled-components' const columnHelper = createColumnHelper() @@ -53,6 +57,57 @@ export const ColMemoryChange = columnHelper.accessor((rec) => rec, { }, }) +export const ColScalingPr = columnHelper.accessor((rec) => rec, { + id: 'scalingPr', + header: 'Create PR', + cell: function Cell({ getValue }) { + const rec = getValue() + const [mutation, { data, loading, error }] = + useApplyScalingRecommendationMutation({ variables: { id: rec.id } }) + + if (!rec.service) { + return null + } + + return ( + + {error && ( + + {error.message} + + )} + {data?.applyScalingRecommendation?.id ? ( + + ) : ( + + )} + + ) + }, +}) + const BoldTextSC = styled.strong(({ theme }) => ({ color: theme.colors.text, })) diff --git a/assets/src/components/cost-management/details/CostManagementDetailsRecommendations.tsx b/assets/src/components/cost-management/details/CostManagementDetailsRecommendations.tsx index 2cd4d2754..0508d3bc0 100644 --- a/assets/src/components/cost-management/details/CostManagementDetailsRecommendations.tsx +++ b/assets/src/components/cost-management/details/CostManagementDetailsRecommendations.tsx @@ -19,6 +19,7 @@ import { ColCpuChange, ColMemoryChange, ColName, + ColScalingPr, } from './ClusterScalingRecsTableCols' import { CMContextType } from './CostManagementDetails' import { useMemo } from 'react' @@ -111,4 +112,4 @@ export function CostManagementDetailsRecommendations() { ) } -const cols = [ColName, ColCpuChange, ColMemoryChange] +const cols = [ColName, ColCpuChange, ColMemoryChange, ColScalingPr] diff --git a/assets/src/generated/graphql-kubernetes.ts b/assets/src/generated/graphql-kubernetes.ts index d29b0e287..d1042cfb9 100644 --- a/assets/src/generated/graphql-kubernetes.ts +++ b/assets/src/generated/graphql-kubernetes.ts @@ -1,4 +1,4 @@ - +/* eslint-disable */ /* prettier-ignore */ import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; diff --git a/assets/src/generated/graphql-plural.ts b/assets/src/generated/graphql-plural.ts index 2a8b931af..694f1a631 100644 --- a/assets/src/generated/graphql-plural.ts +++ b/assets/src/generated/graphql-plural.ts @@ -1,4 +1,4 @@ - +/* eslint-disable */ /* prettier-ignore */ import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; diff --git a/assets/src/generated/graphql.ts b/assets/src/generated/graphql.ts index 6bcd52edc..615cd95df 100644 --- a/assets/src/generated/graphql.ts +++ b/assets/src/generated/graphql.ts @@ -1,4 +1,4 @@ - +/* eslint-disable */ /* prettier-ignore */ import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; @@ -5238,6 +5238,7 @@ export type RootMutationType = { addClusterAuditLog?: Maybe; addRunLogs?: Maybe; aiFixPr?: Maybe; + applyScalingRecommendation?: Maybe; /** approves an approval pipeline gate */ approveGate?: Maybe; approveStackRun?: Maybe; @@ -5456,6 +5457,11 @@ export type RootMutationTypeAiFixPrArgs = { }; +export type RootMutationTypeApplyScalingRecommendationArgs = { + id: Scalars['ID']['input']; +}; + + export type RootMutationTypeApproveGateArgs = { id: Scalars['ID']['input']; }; @@ -10550,7 +10556,7 @@ export type ClusterUsageTinyFragment = { __typename?: 'ClusterUsage', id: string export type ClusterNamespaceUsageFragment = { __typename?: 'ClusterNamespaceUsage', id: string, namespace?: string | null, storage?: number | null, cpuCost?: number | null, cpuUtil?: number | null, cpu?: number | null, memoryCost?: number | null, memUtil?: number | null, memory?: number | null, ingressCost?: number | null, loadBalancerCost?: number | null, egressCost?: number | null }; -export type ClusterScalingRecommendationFragment = { __typename?: 'ClusterScalingRecommendation', id: string, namespace?: string | null, name?: string | null, container?: string | null, cpuCost?: number | null, cpuRequest?: number | null, cpuRecommendation?: number | null, memoryCost?: number | null, memoryRequest?: number | null, memoryRecommendation?: number | null, type?: ScalingRecommendationType | null }; +export type ClusterScalingRecommendationFragment = { __typename?: 'ClusterScalingRecommendation', id: string, namespace?: string | null, name?: string | null, type?: ScalingRecommendationType | null, container?: string | null, cpuCost?: number | null, cpuRequest?: number | null, cpuRecommendation?: number | null, memoryCost?: number | null, memoryRequest?: number | null, memoryRecommendation?: number | null, service?: { __typename?: 'ServiceDeployment', id: string, name: string, cluster?: { __typename?: 'Cluster', id: string, name: string, handle?: string | null } | null } | null }; export type ClusterUsagesQueryVariables = Exact<{ after?: InputMaybe; @@ -10588,7 +10594,14 @@ export type ClusterUsageScalingRecommendationsQueryVariables = Exact<{ }>; -export type ClusterUsageScalingRecommendationsQuery = { __typename?: 'RootQueryType', clusterUsage?: { __typename?: 'ClusterUsage', id: string, cluster?: { __typename?: 'Cluster', id: string, name: string } | null, recommendations?: { __typename?: 'ClusterScalingRecommendationConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'ClusterScalingRecommendationEdge', node?: { __typename?: 'ClusterScalingRecommendation', id: string, namespace?: string | null, name?: string | null, container?: string | null, cpuCost?: number | null, cpuRequest?: number | null, cpuRecommendation?: number | null, memoryCost?: number | null, memoryRequest?: number | null, memoryRecommendation?: number | null, type?: ScalingRecommendationType | null } | null } | null> | null } | null } | null }; +export type ClusterUsageScalingRecommendationsQuery = { __typename?: 'RootQueryType', clusterUsage?: { __typename?: 'ClusterUsage', id: string, cluster?: { __typename?: 'Cluster', id: string, name: string } | null, recommendations?: { __typename?: 'ClusterScalingRecommendationConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'ClusterScalingRecommendationEdge', node?: { __typename?: 'ClusterScalingRecommendation', id: string, namespace?: string | null, name?: string | null, type?: ScalingRecommendationType | null, container?: string | null, cpuCost?: number | null, cpuRequest?: number | null, cpuRecommendation?: number | null, memoryCost?: number | null, memoryRequest?: number | null, memoryRecommendation?: number | null, service?: { __typename?: 'ServiceDeployment', id: string, name: string, cluster?: { __typename?: 'Cluster', id: string, name: string, handle?: string | null } | null } | null } | null } | null> | null } | null } | null }; + +export type ApplyScalingRecommendationMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type ApplyScalingRecommendationMutation = { __typename?: 'RootMutationType', applyScalingRecommendation?: { __typename?: 'PullRequest', id: string, title?: string | null, url: string, labels?: Array | null, creator?: string | null, status?: PrStatus | null, insertedAt?: string | null, updatedAt?: string | null, service?: { __typename?: 'ServiceDeployment', id: string, name: string, protect?: boolean | null, deletedAt?: string | null } | null, cluster?: { __typename?: 'Cluster', handle?: string | null, protect?: boolean | null, deletedAt?: string | null, version?: string | null, currentVersion?: string | null, self?: boolean | null, virtual?: boolean | null, id: string, name: string, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null } | null }; export type GroupMemberFragment = { __typename?: 'GroupMember', user?: { __typename?: 'User', id: string, pluralId?: string | null, name: string, email: string, profile?: string | null, backgroundColor?: string | null, readTimestamp?: string | null, emailSettings?: { __typename?: 'EmailSettings', digest?: boolean | null } | null, roles?: { __typename?: 'UserRoles', admin?: boolean | null } | null, personas?: Array<{ __typename?: 'Persona', id: string, name: string, description?: string | null, bindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'PersonaConfiguration', all?: boolean | null, deployments?: { __typename?: 'PersonaDeployment', addOns?: boolean | null, clusters?: boolean | null, pipelines?: boolean | null, providers?: boolean | null, repositories?: boolean | null, services?: boolean | null } | null, home?: { __typename?: 'PersonaHome', manager?: boolean | null, security?: boolean | null } | null, sidebar?: { __typename?: 'PersonaSidebar', audits?: boolean | null, kubernetes?: boolean | null, pullRequests?: boolean | null, settings?: boolean | null, backups?: boolean | null, stacks?: boolean | null } | null } | null } | null> | null } | null, group?: { __typename?: 'Group', id: string, name: string, description?: string | null, insertedAt?: string | null, updatedAt?: string | null } | null }; @@ -13037,17 +13050,6 @@ export const ServiceDeploymentRevisionsFragmentDoc = gql` } } ${ServiceDeploymentRevisionFragmentDoc}`; -export const ServiceDeploymentTinyFragmentDoc = gql` - fragment ServiceDeploymentTiny on ServiceDeployment { - id - name - cluster { - id - name - handle - } -} - `; export const ApiDeprecationFragmentDoc = gql` fragment ApiDeprecation on ApiDeprecation { availableIn @@ -13263,11 +13265,23 @@ export const ClusterNamespaceUsageFragmentDoc = gql` egressCost } `; +export const ServiceDeploymentTinyFragmentDoc = gql` + fragment ServiceDeploymentTiny on ServiceDeployment { + id + name + cluster { + id + name + handle + } +} + `; export const ClusterScalingRecommendationFragmentDoc = gql` fragment ClusterScalingRecommendation on ClusterScalingRecommendation { id namespace name + type container cpuCost cpuRequest @@ -13275,9 +13289,11 @@ export const ClusterScalingRecommendationFragmentDoc = gql` memoryCost memoryRequest memoryRecommendation - type + service { + ...ServiceDeploymentTiny + } } - `; + ${ServiceDeploymentTinyFragmentDoc}`; export const GroupFragmentDoc = gql` fragment Group on Group { id @@ -20602,6 +20618,39 @@ export type ClusterUsageScalingRecommendationsQueryHookResult = ReturnType; export type ClusterUsageScalingRecommendationsSuspenseQueryHookResult = ReturnType; export type ClusterUsageScalingRecommendationsQueryResult = Apollo.QueryResult; +export const ApplyScalingRecommendationDocument = gql` + mutation ApplyScalingRecommendation($id: ID!) { + applyScalingRecommendation(id: $id) { + ...PullRequest + } +} + ${PullRequestFragmentDoc}`; +export type ApplyScalingRecommendationMutationFn = Apollo.MutationFunction; + +/** + * __useApplyScalingRecommendationMutation__ + * + * To run a mutation, you first call `useApplyScalingRecommendationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApplyScalingRecommendationMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [applyScalingRecommendationMutation, { data, loading, error }] = useApplyScalingRecommendationMutation({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useApplyScalingRecommendationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApplyScalingRecommendationDocument, options); + } +export type ApplyScalingRecommendationMutationHookResult = ReturnType; +export type ApplyScalingRecommendationMutationResult = Apollo.MutationResult; +export type ApplyScalingRecommendationMutationOptions = Apollo.BaseMutationOptions; export const GroupsDocument = gql` query Groups($q: String, $first: Int = 20, $after: String) { groups(q: $q, first: $first, after: $after) { @@ -24959,6 +25008,7 @@ export const namedOperations = { UpdateRbac: 'UpdateRbac', SelfManage: 'SelfManage', KickService: 'KickService', + ApplyScalingRecommendation: 'ApplyScalingRecommendation', CreateGroupMember: 'CreateGroupMember', DeleteGroupMember: 'DeleteGroupMember', CreateGroup: 'CreateGroup', diff --git a/assets/src/graph/costManagement.graphql b/assets/src/graph/costManagement.graphql index 4a49cd1df..a3f824196 100644 --- a/assets/src/graph/costManagement.graphql +++ b/assets/src/graph/costManagement.graphql @@ -41,6 +41,7 @@ fragment ClusterScalingRecommendation on ClusterScalingRecommendation { id namespace name + type container cpuCost cpuRequest @@ -48,7 +49,9 @@ fragment ClusterScalingRecommendation on ClusterScalingRecommendation { memoryCost memoryRequest memoryRecommendation - type + service { + ...ServiceDeploymentTiny + } } query ClusterUsages( @@ -147,3 +150,9 @@ query ClusterUsageScalingRecommendations( } } } + +mutation ApplyScalingRecommendation($id: ID!) { + applyScalingRecommendation(id: $id) { + ...PullRequest + } +} diff --git a/lib/console/ai/fixer.ex b/lib/console/ai/fixer.ex index 93ff3b417..8afa962ff 100644 --- a/lib/console/ai/fixer.ex +++ b/lib/console/ai/fixer.ex @@ -10,6 +10,8 @@ defmodule Console.AI.Fixer do alias Console.AI.Fixer.Stack, as: StackFixer alias Console.AI.{Provider, Tools.Pr} + @type pr_resp :: {:ok, PullRequest.t} | Console.error + @prompt """ Please provide the most straightforward code or configuration change available based on the information I've already provided above to fix this issue. @@ -44,7 +46,7 @@ defmodule Console.AI.Fixer do @doc """ Generate a fix recommendation from an ai insight struct """ - @spec pr(AiInsight.t, Provider.history) :: {:ok, PullRequest.t} | Console.error + @spec pr(AiInsight.t, Provider.history) :: pr_resp def pr(%AiInsight{service: svc, stack: stack} = insight, history) when is_map(svc) or is_map(stack) do with {:ok, prompt} <- pr_prompt(insight, history) do ask(prompt, @tool) @@ -58,7 +60,7 @@ defmodule Console.AI.Fixer do @doc """ Spawns a pr given a fix recommendation """ - @spec pr(binary, Provider.history, User.t) :: {:ok, PullRequest.t} | Console.error + @spec pr(binary, Provider.history, User.t) :: pr_resp def pr(id, history, %User{} = user) do Console.AI.Tool.set_actor(user) @@ -79,14 +81,14 @@ defmodule Console.AI.Fixer do |> when_ok(&fix/1) end - defp handle_tool_call({:ok, [%{create_pr: %{result: pr_attrs}} | _]}, additional) do + def handle_tool_call({:ok, [%{create_pr: %{result: pr_attrs}} | _]}, additional) do %PullRequest{} |> PullRequest.changeset(Map.merge(pr_attrs, additional)) |> Repo.insert() end - defp handle_tool_call({:ok, [%{create_pr: %{error: err}} | _]}, _), do: {:error, err} - defp handle_tool_call({:ok, msg}, _), do: {:error, msg} - defp handle_tool_call(err, _), do: err + def handle_tool_call({:ok, [%{create_pr: %{error: err}} | _]}, _), do: {:error, err} + def handle_tool_call({:ok, msg}, _), do: {:error, msg} + def handle_tool_call(err, _), do: err defp ask(prompt, task \\ @prompt), do: prompt ++ [{:user, task}] diff --git a/lib/console/ai/tools/pr.ex b/lib/console/ai/tools/pr.ex index 85a1e952a..7cf737bf0 100644 --- a/lib/console/ai/tools/pr.ex +++ b/lib/console/ai/tools/pr.ex @@ -17,6 +17,11 @@ defmodule Console.AI.Tools.Pr do field :file_name, :string field :content, :string end + + embeds_one :confidence, Confidence, on_replace: :update do + field :confident, :boolean + field :reason, :string + end end @valid ~w(repo_url branch_name commit_message pr_title pr_description)a @@ -25,7 +30,9 @@ defmodule Console.AI.Tools.Pr do model |> cast(attrs, @valid) |> cast_embed(:file_updates, with: &file_update_changeset/2) + |> cast_embed(:confidence, with: &confidence_changeset/2) |> validate_required(@valid) + |> ensure_confident() end @json_schema Console.priv_file!("tools/pr.json") |> Jason.decode!() @@ -73,4 +80,17 @@ defmodule Console.AI.Tools.Pr do |> cast(attrs, ~w(file_name content)a) |> validate_required(~w(file_name content)a) end + + defp confidence_changeset(model, attrs) do + model + |> cast(attrs, ~w(confident reason)a) + end + + defp ensure_confident(cs) do + case get_field(cs, :confidence) do + %__MODULE__.Confidence{confident: false, reason: reason} -> + add_error(cs, :confidence, "There's not sufficient confidence to apply this PR, the reason is: #{reason}") + _ -> cs + end + end end diff --git a/lib/console/cost/dimensions.ex b/lib/console/cost/dimensions.ex new file mode 100644 index 000000000..a623ef534 --- /dev/null +++ b/lib/console/cost/dimensions.ex @@ -0,0 +1,17 @@ +defmodule Console.Cost.Dimensions do + @kb 1024 + @mb @kb * @kb + + def memory(mem) when mem > @mb, do: "#{unit(mem, @mb)}Mi" + def memory(mem) when mem > @kb, do: "#{unit(mem, @kb)}Ki" + def memory(mem), do: mem + + def cpu(cpu) when cpu > 1, do: cpu + def cpu(cpu), do: "#{cpu * 1000}m" + + def maybe_quote(val) when is_binary(val), do: ~s("#{val}") + def maybe_quote(val), do: val + + defp round(v, mult), do: round(v / mult) * mult + defp unit(v, unit), do: round(round(v, unit), 10) +end diff --git a/lib/console/cost/pr.ex b/lib/console/cost/pr.ex new file mode 100644 index 000000000..9c79924b8 --- /dev/null +++ b/lib/console/cost/pr.ex @@ -0,0 +1,57 @@ +defmodule Console.Cost.Pr do + import Console.Deployments.Policies + import Console.AI.Evidence.Base, only: [prepend: 2, append: 2] + import Console.Cost.Dimensions + + alias Console.AI.{Provider, Fixer, Tools.Pr} + alias Console.Repo + alias Console.Schema.{ClusterScalingRecommendation, Service, User} + + @pr """ + The following is the description of how a Kubernetes service is configured using Plural as a CD engine. I want to reconfigure one + of its containers resource requests to match an optimal setup determined by our cost management solution. First, the manifests for + the service are listed below: + """ + + @doc """ + Generates a prompt to apply a scaling recommendation and then executes a PR tool call. Cannot be done w/o write access + to the given service. + """ + @spec create(binary | ClusterScalingRecommendation.t, User.t) :: Fixer.pr_resp + def create(%ClusterScalingRecommendation{} = rec, %User{} = user) do + Console.AI.Tool.set_actor(user) + with %{service: %Service{} = svc} = rec <- Repo.preload(rec, [:service]), + {:ok, svc} <- allow(svc, user, :write), + {:ok, [_ | prompt]} <- Fixer.Service.prompt(svc, "") do + prepend(prompt, {:user, @pr}) + |> append({:user, cost_prompt(rec)}) + |> Provider.tool_call([Pr]) + |> Fixer.handle_tool_call(%{service_id: svc.id}) + end + end + def create(id, %User{} = user) when is_binary(id) do + Repo.get!(ClusterScalingRecommendation, id) + |> create(user) + end + + defp cost_prompt(%ClusterScalingRecommendation{} = rec) do + """ + The cost management system has recommended this service have the following scaling recommendations applied, which I'll list in json format: + + ```json + { + "controller_type": "#{rec.type}", + "namespace": "#{rec.namespace}", + "name": "#{rec.name}", + "container": "#{rec.container}", + "requests": { + "memory": #{maybe_quote(memory(rec.memory_recommendation))}, + "cpu": #{maybe_quote(cpu(rec.cpu_recommendation))} + } + } + ``` + + Please generate a PR that applies those changes to the above service manifests. + """ + end +end diff --git a/lib/console/graphql/deployments/cluster.ex b/lib/console/graphql/deployments/cluster.ex index d9ce2e416..092396721 100644 --- a/lib/console/graphql/deployments/cluster.ex +++ b/lib/console/graphql/deployments/cluster.ex @@ -1172,5 +1172,12 @@ defmodule Console.GraphQl.Deployments.Cluster do resolve &Deployments.add_cluster_audit/2 end + + field :apply_scaling_recommendation, :pull_request do + middleware Authenticated + arg :id, non_null(:id) + + resolve &Deployments.scaling_pr/2 + end end end diff --git a/lib/console/graphql/resolvers/deployments/cluster.ex b/lib/console/graphql/resolvers/deployments/cluster.ex index 7613b8f3f..32a8193aa 100644 --- a/lib/console/graphql/resolvers/deployments/cluster.ex +++ b/lib/console/graphql/resolvers/deployments/cluster.ex @@ -1,6 +1,7 @@ defmodule Console.GraphQl.Resolvers.Deployments.Cluster do use Console.GraphQl.Resolvers.Deployments.Base alias Console.Deployments.Clusters + alias Console.Cost.Pr alias Console.Schema.{ User, Cluster, @@ -245,6 +246,9 @@ defmodule Console.GraphQl.Resolvers.Deployments.Cluster do def ping(%{attributes: attrs}, %{context: %{cluster: cluster}}), do: Clusters.ping(attrs, cluster) + def scaling_pr(%{id: id}, %{context: %{current_user: user}}), + do: Pr.create(id, user) + def add_cluster_audit(_, %{context: %{current_user: %User{email: "console@plural.sh"}}}), do: {:ok, false} def add_cluster_audit(%{audit: audit}, %{context: %{current_user: user}}) do Map.put(audit, :actor_id, user.id) diff --git a/lib/console/logs/provider/victoria.ex b/lib/console/logs/provider/victoria.ex index 9f1c867df..dbc3afe15 100644 --- a/lib/console/logs/provider/victoria.ex +++ b/lib/console/logs/provider/victoria.ex @@ -53,6 +53,7 @@ defmodule Console.Logs.Provider.Victoria do %{cluster: %Cluster{} = cluster} = Console.Repo.preload(svc, [:cluster]) [cluster_label(cluster), ~s("namespace":#{ns}) | io] end + defp add_resource(io, _), do: io defp cluster_label(%Cluster{handle: h}), do: ~s("cluster":"#{h}") @@ -60,6 +61,7 @@ defmodule Console.Logs.Provider.Victoria do do: [~s(_time:[#{a}, #{b}]) | io] defp add_time(io, %Query{time: %Time{duration: d}}) when is_binary(d), do: [~s(_time:#{d}) | io] + defp add_time(io, _), do: io defp maybe_reverse(io, %Query{time: %Time{reverse: true}}), do: io ++ ["| sort by (_time desc)"] defp maybe_reverse(io, _), do: io diff --git a/lib/console/logs/query.ex b/lib/console/logs/query.ex index 93c708ef8..7c20fe06f 100644 --- a/lib/console/logs/query.ex +++ b/lib/console/logs/query.ex @@ -23,7 +23,6 @@ defmodule Console.Logs.Query do def limit(%__MODULE__{limit: l}) when is_integer(l), do: l def limit(_), do: @default_limit - def accessible(q, %User{roles: %{admin: true}}), do: {:ok, q} def accessible(%__MODULE__{project_id: project_id} = q, %User{} = user) when is_binary(project_id), do: check_access(Project, project_id, user, q) diff --git a/lib/console/schema/deployment_settings.ex b/lib/console/schema/deployment_settings.ex index 3e7f36e83..cb4e50520 100644 --- a/lib/console/schema/deployment_settings.ex +++ b/lib/console/schema/deployment_settings.ex @@ -254,7 +254,7 @@ defmodule Console.Schema.DeploymentSettings do defp logging_changeset(model, attrs) do model - |> cast(attrs, [:enabled]) + |> cast(attrs, ~w(enabled driver)a) |> cast_embed(:victoria) end end diff --git a/priv/tools/pr.json b/priv/tools/pr.json index 0321ca6b2..218245344 100644 --- a/priv/tools/pr.json +++ b/priv/tools/pr.json @@ -21,6 +21,19 @@ "type": "string", "description": "A longer-form description body for this PR, should allow users to understand the context and implications of the change. The expected format should include a Summary section, a Changes Made section and a Rationale section to explain the PR to reviewers." }, + "confidence": { + "type": "object", + "properties": { + "confident": { + "type": "boolean", + "description": "Provide false as the value if you believe an experienced engineer would be less than 70% sure this is the correct fix for the issue" + }, + "reason": { + "type": "string", + "description": "The reason why you're not confident about this change. This will be exposed to users to explain why the PR is not created" + } + } + }, "file_updates": { "type": "array", "description": "A list of files to update in this PR", diff --git a/schema/schema.graphql b/schema/schema.graphql index 86e2171a1..24d7d0c47 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -585,6 +585,8 @@ type RootMutationType { addClusterAuditLog(audit: ClusterAuditAttributes!): Boolean + applyScalingRecommendation(id: ID!): PullRequest + createServiceDeployment( clusterId: ID diff --git a/test/console/cost/pr_test.exs b/test/console/cost/pr_test.exs new file mode 100644 index 000000000..4e0fc721a --- /dev/null +++ b/test/console/cost/pr_test.exs @@ -0,0 +1,62 @@ +defmodule Console.Cost.PrTest do + use Console.DataCase, async: false + use Mimic + alias Console.Cost.Pr + + describe "#create/2" do + test "it can spawn a scaling pr" do + insert(:scm_connection, token: "some-pat", default: true) + expect(Tentacat.Pulls, :create, fn _, "pluralsh", "console", %{head: "plrl/ai/pr-test" <> _} -> + {:ok, %{"html_url" => "https://github.com/pr/url"}, %HTTPoison.Response{}} + end) + expect(Console.Deployments.Pr.Git, :setup, fn conn, "https://github.com/pluralsh/console.git", "plrl/ai/pr-test" <> _ -> + {:ok, %{conn | dir: Briefly.create!(directory: true)}} + end) + expect(Console.Deployments.Pr.Git, :commit, fn _, _ -> {:ok, ""} end) + expect(Console.Deployments.Pr.Git, :push, fn _, "plrl/ai/pr-test" <> _ -> {:ok, ""} end) + expect(File, :write, fn _, "first" -> :ok end) + expect(File, :write, fn _, "second" -> :ok end) + expect(HTTPoison, :post, fn _, _, _, _ -> + {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(%{choices: [ + %{ + message: %{ + tool_calls: [%{ + function: %{ + name: "create_pr", + arguments: Jason.encode!(%{ + repo_url: "git@github.com:pluralsh/console.git", + branch_name: "pr-test", + pr_description: "some pr", + pr_title: "some pr", + commit_message: "a commit", + file_updates: [%{file_name: "file.yaml", content: "first"}, %{file_name: "file2.yaml", content: "second"}] + }) + } + }] + } + } + ]})}} + end) + + user = insert(:user) + git = insert(:git_repository, url: "https://github.com/pluralsh/deployment-operator.git") + parent = insert(:service, + repository: git, + git: %{ref: "main", folder: "charts/deployment-operator"} + ) + + svc = insert(:service, + repository: git, + git: %{ref: "main", folder: "charts/deployment-operator"}, + write_bindings: [%{user_id: user.id}], + parent: parent + ) + recommendation = insert(:cluster_scaling_recommendation, service: svc) + deployment_settings(ai: %{enabled: true, provider: :openai, openai: %{access_token: "secret"}}) + + {:ok, pr} = Pr.create(recommendation, user) + + assert pr.url == "https://github.com/pr/url" + end + end +end