diff --git a/assets/src/generated/graphql-kubernetes.ts b/assets/src/generated/graphql-kubernetes.ts index 7ccd7e058a..d29b0e2876 100644 --- a/assets/src/generated/graphql-kubernetes.ts +++ b/assets/src/generated/graphql-kubernetes.ts @@ -11835,4 +11835,4 @@ export function useStatefulSetPodsSuspenseQuery(baseOptions?: Apollo.SuspenseQue export type StatefulSetPodsQueryHookResult = ReturnType; export type StatefulSetPodsLazyQueryHookResult = ReturnType; export type StatefulSetPodsSuspenseQueryHookResult = ReturnType; -export type StatefulSetPodsQueryResult = Apollo.QueryResult; +export type StatefulSetPodsQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/assets/src/generated/graphql-plural.ts b/assets/src/generated/graphql-plural.ts index 827a9bef4a..2a8b931afc 100644 --- a/assets/src/generated/graphql-plural.ts +++ b/assets/src/generated/graphql-plural.ts @@ -5349,4 +5349,4 @@ export const namedOperations = { Fragment: { ChatMessage: 'ChatMessage' } -} +} \ No newline at end of file diff --git a/assets/src/generated/graphql.ts b/assets/src/generated/graphql.ts index d68a0575be..392533bd73 100644 --- a/assets/src/generated/graphql.ts +++ b/assets/src/generated/graphql.ts @@ -635,6 +635,52 @@ export type CascadeAttributes = { detach?: InputMaybe; }; +/** A catalog is an organized collection of PR Automations used for permissioning and discovery */ +export type Catalog = { + __typename?: 'Catalog'; + /** the name of the author of this catalog */ + author?: Maybe; + /** short category name used for browsing catalogs */ + category?: Maybe; + /** longform description for the purpose of this catalog */ + description?: Maybe; + id: Scalars['ID']['output']; + insertedAt?: Maybe; + name: Scalars['String']['output']; + project?: Maybe; + /** read policy for this catalog */ + readBindings?: Maybe>>; + updatedAt?: Maybe; + /** write policy for this catalog */ + writeBindings?: Maybe>>; +}; + +export type CatalogAttributes = { + /** the name of the author of this catalog, used for attribution only */ + author: Scalars['String']['input']; + /** short category name for browsability */ + category?: InputMaybe; + description?: InputMaybe; + name: Scalars['String']['input']; + /** owning project of the catalog, permissions will propagate down */ + projectId?: InputMaybe; + readBindings?: InputMaybe>>; + tags?: InputMaybe>>; + writeBindings?: InputMaybe>>; +}; + +export type CatalogConnection = { + __typename?: 'CatalogConnection'; + edges?: Maybe>>; + pageInfo: PageInfo; +}; + +export type CatalogEdge = { + __typename?: 'CatalogEdge'; + cursor?: Maybe; + node?: Maybe; +}; + export type Certificate = { __typename?: 'Certificate'; events?: Maybe>>; @@ -4037,6 +4083,8 @@ export type PrAutomation = { __typename?: 'PrAutomation'; /** link to an add-on name if this can update it */ addon?: Maybe; + /** the catalog this pr automation belongs to */ + catalog?: Maybe; /** link to a cluster if this is to perform an upgrade */ cluster?: Maybe; configuration?: Maybe>>; @@ -4074,6 +4122,8 @@ export type PrAutomationAttributes = { /** link to an add-on name if this can update it */ addon?: InputMaybe; branch?: InputMaybe; + /** the catalog this automation will belong to */ + catalogId?: InputMaybe; /** link to a cluster if this is to perform an upgrade */ clusterId?: InputMaybe; configuration?: InputMaybe>>; @@ -4716,6 +4766,7 @@ export type RootMutationType = { createUpgradePolicy?: Maybe; createWebhook?: Maybe; deleteAccessToken?: Maybe; + deleteCatalog?: Maybe; deleteCertificate?: Maybe; deleteCluster?: Maybe; deleteClusterProvider?: Maybe; @@ -4843,6 +4894,7 @@ export type RootMutationType = { updateStackDefinition?: Maybe; updateStackRun?: Maybe; updateUser?: Maybe; + upsertCatalog?: Maybe; upsertHelmRepository?: Maybe; upsertNotificationRouter?: Maybe; upsertNotificationSink?: Maybe; @@ -5106,6 +5158,11 @@ export type RootMutationTypeDeleteAccessTokenArgs = { }; +export type RootMutationTypeDeleteCatalogArgs = { + id: Scalars['ID']['input']; +}; + + export type RootMutationTypeDeleteCertificateArgs = { name: Scalars['String']['input']; namespace: Scalars['String']['input']; @@ -5709,6 +5766,11 @@ export type RootMutationTypeUpdateUserArgs = { }; +export type RootMutationTypeUpsertCatalogArgs = { + attributes?: InputMaybe; +}; + + export type RootMutationTypeUpsertHelmRepositoryArgs = { attributes?: InputMaybe; url: Scalars['String']['input']; @@ -5762,6 +5824,8 @@ export type RootQueryType = { builds?: Maybe; cachedPods?: Maybe>>; canary?: Maybe; + catalog?: Maybe; + catalogs?: Maybe; certificate?: Maybe; /** fetches an individual cluster */ cluster?: Maybe; @@ -5999,6 +6063,21 @@ export type RootQueryTypeCanaryArgs = { }; +export type RootQueryTypeCatalogArgs = { + id?: InputMaybe; + name?: InputMaybe; +}; + + +export type RootQueryTypeCatalogsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + projectId?: InputMaybe; +}; + + export type RootQueryTypeCertificateArgs = { name: Scalars['String']['input']; namespace: Scalars['String']['input']; @@ -8915,7 +8994,7 @@ export type VClustersQueryVariables = Exact<{ }>; -export type VClustersQuery = { __typename?: 'RootQueryType', tags?: Array | null, clusters?: { __typename?: 'ClusterConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'ClusterEdge', node?: { __typename?: 'Cluster', currentVersion?: string | null, id: string, self?: boolean | null, protect?: boolean | null, name: string, handle?: string | null, distro?: ClusterDistro | null, installed?: boolean | null, pingedAt?: string | null, deletedAt?: string | null, version?: string | null, kubeletVersion?: string | null, virtual?: boolean | null, nodes?: Array<{ __typename?: 'Node', status: { __typename?: 'NodeStatus', capacity?: Record | null } } | null> | null, nodeMetrics?: Array<{ __typename?: 'NodeMetric', usage?: { __typename?: 'NodeUsage', cpu?: string | null, memory?: string | null } | null } | null> | null, provider?: { __typename?: 'ClusterProvider', id: string, cloud: string, name: string, namespace: string, supportedVersions?: Array | null } | null, prAutomations?: Array<{ __typename?: 'PrAutomation', id: string, name: string, documentation?: string | null, addon?: string | null, identifier: string, role?: PrRole | null, cluster?: { __typename?: 'Cluster', handle?: string | null, protect?: boolean | null, deletedAt?: string | null, version?: string | null, currentVersion?: string | null, id: string, name: string, self?: boolean | null, distro?: ClusterDistro | null, virtual?: boolean | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null } | null, service?: { __typename?: 'ServiceDeployment', id: string, name: string } | null, repository?: { __typename?: 'GitRepository', url: string, refs?: Array | null } | null, connection?: { __typename?: 'ScmConnection', id: string, name: string, insertedAt?: string | null, updatedAt?: string | null, type: ScmType, username?: string | null, baseUrl?: string | null, apiUrl?: string | null } | null, createBindings?: 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, writeBindings?: 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?: Array<{ __typename?: 'PrConfiguration', values?: Array | null, default?: string | null, documentation?: string | null, longform?: string | null, name: string, optional?: boolean | null, placeholder?: string | null, type: ConfigurationType, condition?: { __typename?: 'PrConfigurationCondition', field: string, operation: Operation, value?: string | null } | null } | null> | null } | null> | null, service?: { __typename?: 'ServiceDeployment', id: string, repository?: { __typename?: 'GitRepository', url: string } | null } | null, status?: { __typename?: 'ClusterStatus', conditions?: Array<{ __typename?: 'ClusterCondition', lastTransitionTime?: string | null, message?: string | null, reason?: string | null, severity?: string | null, status?: string | null, type?: string | null } | null> | null } | null, tags?: Array<{ __typename?: 'Tag', name: string, value: string } | null> | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null } | null } | null> | null } | null, clusterStatuses?: Array<{ __typename?: 'ClusterStatusInfo', count?: number | null, healthy?: boolean | null } | null> | null }; +export type VClustersQuery = { __typename?: 'RootQueryType', tags?: Array | null, clusters?: { __typename?: 'ClusterConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'ClusterEdge', node?: { __typename?: 'Cluster', currentVersion?: string | null, id: string, self?: boolean | null, protect?: boolean | null, name: string, handle?: string | null, distro?: ClusterDistro | null, installed?: boolean | null, pingedAt?: string | null, deletedAt?: string | null, version?: string | null, kubeletVersion?: string | null, virtual?: boolean | null, nodes?: Array<{ __typename?: 'Node', status: { __typename?: 'NodeStatus', capacity?: Record | null } } | null> | null, nodeMetrics?: Array<{ __typename?: 'NodeMetric', usage?: { __typename?: 'NodeUsage', cpu?: string | null, memory?: string | null } | null } | null> | null, provider?: { __typename?: 'ClusterProvider', id: string, cloud: string, name: string, namespace: string, supportedVersions?: Array | null } | null, prAutomations?: Array<{ __typename?: 'PrAutomation', id: string, name: string, documentation?: string | null, addon?: string | null, identifier: string, role?: PrRole | null, cluster?: { __typename?: 'Cluster', handle?: string | null, protect?: boolean | null, deletedAt?: string | null, version?: string | null, currentVersion?: string | null, id: string, name: string, self?: boolean | null, distro?: ClusterDistro | null, virtual?: boolean | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null } | null, service?: { __typename?: 'ServiceDeployment', id: string, name: string } | null, repository?: { __typename?: 'GitRepository', url: string, refs?: Array | null } | null, connection?: { __typename?: 'ScmConnection', id: string, name: string, insertedAt?: string | null, updatedAt?: string | null, type: ScmType, username?: string | null, baseUrl?: string | null, apiUrl?: string | null } | null, createBindings?: 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, writeBindings?: 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?: Array<{ __typename?: 'PrConfiguration', values?: Array | null, default?: string | null, documentation?: string | null, longform?: string | null, name: string, optional?: boolean | null, placeholder?: string | null, type: ConfigurationType, condition?: { __typename?: 'PrConfigurationCondition', field: string, operation: Operation, value?: string | null } | null } | null> | null } | null> | null, service?: { __typename?: 'ServiceDeployment', id: string, repository?: { __typename?: 'GitRepository', url: string } | null } | null, status?: { __typename?: 'ClusterStatus', conditions?: Array<{ __typename?: 'ClusterCondition', lastTransitionTime?: string | null, message?: string | null, reason?: string | null, severity?: string | null, status?: string | null, type?: string | null } | null> | null } | null, tags?: Array<{ __typename?: 'Tag', name: string, value: string } | null> | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null } | null } | null> | null } | null }; export type ClusterSelectorQueryVariables = Exact<{ first?: InputMaybe; @@ -14701,14 +14780,10 @@ export const VClustersDocument = gql` } } } - clusterStatuses { - ...ClusterStatusInfo - } tags } ${PageInfoFragmentDoc} -${ClustersRowFragmentDoc} -${ClusterStatusInfoFragmentDoc}`; +${ClustersRowFragmentDoc}`; /** * __useVClustersQuery__ @@ -22848,4 +22923,4 @@ export const namedOperations = { Manifest: 'Manifest', RefreshToken: 'RefreshToken' } -} +} \ No newline at end of file diff --git a/go/client/models_gen.go b/go/client/models_gen.go index a8c6d703f9..2265535b64 100644 --- a/go/client/models_gen.go +++ b/go/client/models_gen.go @@ -487,6 +487,49 @@ type CascadeAttributes struct { Detach *bool `json:"detach,omitempty"` } +// A catalog is an organized collection of PR Automations used for permissioning and discovery +type Catalog struct { + ID string `json:"id"` + Name string `json:"name"` + // longform description for the purpose of this catalog + Description *string `json:"description,omitempty"` + // short category name used for browsing catalogs + Category *string `json:"category,omitempty"` + // the name of the author of this catalog + Author *string `json:"author,omitempty"` + Project *Project `json:"project,omitempty"` + // read policy for this catalog + ReadBindings []*PolicyBinding `json:"readBindings,omitempty"` + // write policy for this catalog + WriteBindings []*PolicyBinding `json:"writeBindings,omitempty"` + InsertedAt *string `json:"insertedAt,omitempty"` + UpdatedAt *string `json:"updatedAt,omitempty"` +} + +type CatalogAttributes struct { + Name string `json:"name"` + // the name of the author of this catalog, used for attribution only + Author string `json:"author"` + Description *string `json:"description,omitempty"` + // short category name for browsability + Category *string `json:"category,omitempty"` + // owning project of the catalog, permissions will propagate down + ProjectID *string `json:"projectId,omitempty"` + Tags []*TagAttributes `json:"tags,omitempty"` + ReadBindings []*PolicyBindingAttributes `json:"readBindings,omitempty"` + WriteBindings []*PolicyBindingAttributes `json:"writeBindings,omitempty"` +} + +type CatalogConnection struct { + PageInfo PageInfo `json:"pageInfo"` + Edges []*CatalogEdge `json:"edges,omitempty"` +} + +type CatalogEdge struct { + Node *Catalog `json:"node,omitempty"` + Cursor *string `json:"cursor,omitempty"` +} + type Certificate struct { Metadata Metadata `json:"metadata"` Status CertificateStatus `json:"status"` @@ -3374,6 +3417,8 @@ type PrAutomation struct { Addon *string `json:"addon,omitempty"` // the git repository to use for sourcing external templates Repository *GitRepository `json:"repository,omitempty"` + // the catalog this pr automation belongs to + Catalog *Catalog `json:"catalog,omitempty"` // the project this automation lives w/in Project *Project `json:"project,omitempty"` // link to a cluster if this is to perform an upgrade @@ -3407,6 +3452,8 @@ type PrAutomationAttributes struct { ServiceID *string `json:"serviceId,omitempty"` // the scm connection to use for pr generation ConnectionID *string `json:"connectionId,omitempty"` + // the catalog this automation will belong to + CatalogID *string `json:"catalogId,omitempty"` // the project this automation lives in ProjectID *string `json:"projectId,omitempty"` // a git repository to use for create mode prs diff --git a/lib/console/deployments/git.ex b/lib/console/deployments/git.ex index 85061b6c4a..fb6f1ab890 100644 --- a/lib/console/deployments/git.ex +++ b/lib/console/deployments/git.ex @@ -16,7 +16,8 @@ defmodule Console.Deployments.Git do PullRequest, DependencyManagementService, HelmRepository, - Observer + Observer, + Catalog } require Logger @@ -31,6 +32,7 @@ defmodule Console.Deployments.Git do @type automation_resp :: {:ok, PrAutomation.t} | Console.error @type pull_request_resp :: {:ok, PullRequest.t} | Console.error @type observer_resp :: {:ok, Observer.t} | Console.error + @type catalog_resp :: {:ok, Catalog.t} | Console.error @decorate cacheable(cache: @cache, key: {:git_repo, id}, opts: [ttl: @ttl]) def cached!(id), do: Repo.get!(GitRepository, id) @@ -66,6 +68,12 @@ defmodule Console.Deployments.Git do def get_observer!(id), do: Repo.get!(Observer, id) + def get_catalog_by_name(name), do: Repo.get_by(Catalog, name: name) + + def get_catalog_by_name!(name), do: Repo.get_by!(Catalog, name: name) + + def get_catalog!(id), do: Repo.get!(Catalog, id) + def get_pr_automation_by_name(name), do: Repo.get_by(PrAutomation, name: name) def deploy_url(), do: "https://github.com/pluralsh/deployment-operator.git" @@ -403,6 +411,30 @@ defmodule Console.Deployments.Git do |> when_ok(&Repo.insert_or_update/1) end + @doc """ + Upserts a new catalog instance, requires at least project write permissions + """ + @spec upsert_catalog(map, User.t) :: catalog_resp + def upsert_catalog(%{name: name} = attrs, %User{} = user) do + case get_catalog_by_name(name) do + %Catalog{} = catalog -> Repo.preload(catalog, [:read_bindings, :write_bindings]) + nil -> %Catalog{project_id: attrs[:project_id] || Settings.default_project!().id} + end + |> allow(user, :write) + |> when_ok(&Catalog.changeset(&1, attrs)) + |> when_ok(&Repo.insert_or_update/1) + end + + @doc """ + Deletes the given catalog, works if user has write permissions on the catalog on up + """ + @spec delete_catalog(binary, User.t) :: catalog_resp + def delete_catalog(id, %User{} = user) do + get_catalog!(id) + |> allow(user, :write) + |> when_ok(:delete) + end + @doc """ Upserts a new observer which can poll external registries and perform configurable actions as a result. diff --git a/lib/console/deployments/policies/rbac.ex b/lib/console/deployments/policies/rbac.ex index 1ffcf0fe23..002279409c 100644 --- a/lib/console/deployments/policies/rbac.ex +++ b/lib/console/deployments/policies/rbac.ex @@ -27,7 +27,8 @@ defmodule Console.Deployments.Policies.Rbac do Project, User, SharedSecret, - Observer + Observer, + Catalog } def globally_readable(query, %User{roles: %{admin: true}}, _), do: query @@ -70,7 +71,7 @@ defmodule Console.Deployments.Policies.Rbac do def evaluate(%ProviderCredential{} = cred, %User{} = user, action), do: recurse(cred, user, action, fn _ -> Settings.fetch() end) def evaluate(%PrAutomation{} = pr, %User{} = user, action), - do: recurse(pr, user, action, & &1.project) + do: recurse(pr, user, action, & [&1.project, &1.catalog]) def evaluate(%Observer{} = obs, %User{} = user, action), do: recurse(obs, user, action, & &1.project) def evaluate(%GitRepository{}, %User{} = user, action), @@ -107,6 +108,8 @@ defmodule Console.Deployments.Policies.Rbac do _ -> Settings.fetch() end) end + def evaluate(%Catalog{} = catalog, %User{} = user, action), + do: recurse(catalog, user, action, & &1.project) def evaluate(%User{} = sa, %User{} = user, :assume), do: recurse(sa, user, :assume) def evaluate(%SharedSecret{} = share, %User{} = user, :consume), do: recurse(share, user, :notify) def evaluate(_, _, _), do: false @@ -133,7 +136,9 @@ defmodule Console.Deployments.Policies.Rbac do def preload(%DeploymentSettings{} = settings), do: Repo.preload(settings, [:read_bindings, :write_bindings, :git_bindings, :create_bindings]) def preload(%PrAutomation{} = pr), - do: Repo.preload(pr, [:write_bindings, :create_bindings, project: @bindings]) + do: Repo.preload(pr, [:write_bindings, :create_bindings, catalog: @top_preloads]) + def preload(%Catalog{} = pr), + do: Repo.preload(pr, @top_preloads) def preload(%Observer{} = obs), do: Repo.preload(obs, [project: @bindings]) def preload(%PolicyConstraint{} = pr), @@ -157,6 +162,8 @@ defmodule Console.Deployments.Policies.Rbac do def preload(pass), do: pass defp recurse(resource, user, action, func \\ fn _ -> nil end) + defp recurse(l, user, action, next) when is_list(l), + do: Enum.any?(l, &recurse(&1, user, action, next)) defp recurse(%{} = resource, user, action, next) do resource = preload(resource) diff --git a/lib/console/graphql/deployments/git.ex b/lib/console/graphql/deployments/git.ex index 0de6356d52..40dde4b1e7 100644 --- a/lib/console/graphql/deployments/git.ex +++ b/lib/console/graphql/deployments/git.ex @@ -27,6 +27,17 @@ defmodule Console.GraphQl.Deployments.Git do ecto_enum :observer_target_order, Observer.TargetOrder ecto_enum :observer_status, Observer.Status + input_object :catalog_attributes do + field :name, non_null(:string) + field :author, non_null(:string), description: "the name of the author of this catalog, used for attribution only" + field :description, :string + field :category, :string, description: "short category name for browsability" + field :project_id, :id, description: "owning project of the catalog, permissions will propagate down" + field :tags, list_of(:tag_attributes) + field :read_bindings, list_of(:policy_binding_attributes) + field :write_bindings, list_of(:policy_binding_attributes) + end + input_object :git_attributes do field :url, non_null(:string), description: "the url of this repository" field :private_key, :string, description: "an ssh private key to use with this repo if an ssh url was given" @@ -117,6 +128,7 @@ defmodule Console.GraphQl.Deployments.Git do field :connection_id, :id, description: "the scm connection to use for pr generation" + field :catalog_id, :id, description: "the catalog this automation will belong to" field :project_id, :id, description: "the project this automation lives in" field :repository_id, :id, description: "a git repository to use for create mode prs" @@ -403,6 +415,9 @@ defmodule Console.GraphQl.Deployments.Git do field :repository, :git_repository, description: "the git repository to use for sourcing external templates", resolve: dataloader(Deployments) + field :catalog, :catalog, + resolve: dataloader(Deployments), + description: "the catalog this pr automation belongs to" field :project, :project, description: "the project this automation lives w/in", resolve: dataloader(Deployments) @@ -600,6 +615,27 @@ defmodule Console.GraphQl.Deployments.Git do field :context, non_null(:map), description: "the context to apply, use $value to interject the observed value" end + + @desc "A catalog is an organized collection of PR Automations used for permissioning and discovery" + object :catalog do + field :id, non_null(:id) + field :name, non_null(:string) + field :description, :string, description: "longform description for the purpose of this catalog" + field :category, :string, description: "short category name used for browsing catalogs" + field :author, :string, description: "the name of the author of this catalog" + + field :project, :project, resolve: dataloader(Deployments) + + field :read_bindings, list_of(:policy_binding), + resolve: dataloader(Deployments), + description: "read policy for this catalog" + field :write_bindings, list_of(:policy_binding), + resolve: dataloader(Deployments), + description: "write policy for this catalog" + + timestamps() + end + connection node_type: :git_repository connection node_type: :helm_repository connection node_type: :scm_connection @@ -608,6 +644,7 @@ defmodule Console.GraphQl.Deployments.Git do connection node_type: :scm_webhook connection node_type: :dependency_management_service connection node_type: :observer + connection node_type: :catalog delta :git_repository @@ -717,6 +754,21 @@ defmodule Console.GraphQl.Deployments.Git do resolve &Deployments.list_observers/2 end + + field :catalog, :catalog do + middleware Authenticated + arg :id, :id + arg :name, :string + + resolve &Deployments.resolve_catalog/2 + end + + connection field :catalogs, node_type: :catalog do + middleware Authenticated + arg :project_id, :id + + resolve &Deployments.list_catalogs/2 + end end object :git_mutations do @@ -883,5 +935,19 @@ defmodule Console.GraphQl.Deployments.Git do resolve &Deployments.delete_observer/2 end + + field :upsert_catalog, :catalog do + middleware Authenticated + arg :attributes, :catalog_attributes + + resolve &Deployments.upsert_catalog/2 + end + + field :delete_catalog, :catalog do + middleware Authenticated + arg :id, non_null(:id) + + resolve &Deployments.delete_catalog/2 + end end end diff --git a/lib/console/graphql/resolvers/deployments.ex b/lib/console/graphql/resolvers/deployments.ex index 539fdc9c70..3b12f4ddfa 100644 --- a/lib/console/graphql/resolvers/deployments.ex +++ b/lib/console/graphql/resolvers/deployments.ex @@ -61,7 +61,8 @@ defmodule Console.GraphQl.Resolvers.Deployments do ObservableMetric, ObservabilityProvider, UpgradeInsight, - UpgradeInsightDetail + UpgradeInsightDetail, + Catalog } def query(Project, _), do: Project @@ -117,6 +118,7 @@ defmodule Console.GraphQl.Resolvers.Deployments do def query(ServiceImport, _), do: ServiceImport def query(StackDefinition, _), do: StackDefinition def query(StackCron, _), do: StackCron + def query(Catalog, _), do: Catalog def query(ObservableMetric, _), do: ObservableMetric def query(ObservabilityProvider, _), do: ObservabilityProvider def query(UpgradeInsight, _), do: UpgradeInsight diff --git a/lib/console/graphql/resolvers/deployments/git.ex b/lib/console/graphql/resolvers/deployments/git.ex index c99d54df9d..16a4aebdd8 100644 --- a/lib/console/graphql/resolvers/deployments/git.ex +++ b/lib/console/graphql/resolvers/deployments/git.ex @@ -10,6 +10,7 @@ defmodule Console.GraphQl.Resolvers.Deployments.Git do ScmWebhook, HelmRepository, Observer, + Catalog, DependencyManagementService } @@ -27,6 +28,9 @@ defmodule Console.GraphQl.Resolvers.Deployments.Git do def resolve_observer(%{id: id}, _) when is_binary(id), do: {:ok, Git.get_observer!(id)} def resolve_observer(%{name: name}, _), do: {:ok, Git.get_observer_by_name(name)} + def resolve_catalog(%{id: id}, _) when is_binary(id), do: {:ok, Git.get_catalog!(id)} + def resolve_catalog(%{name: name}, _), do: {:ok, Git.get_catalog_by_name(name)} + def list_git_repositories(args, _) do GitRepository.ordered() |> paginate(args) @@ -71,6 +75,12 @@ defmodule Console.GraphQl.Resolvers.Deployments.Git do |> paginate(args) end + def list_catalogs(args, _) do + Catalog.ordered() + |> filter_proj(Catalog, args) + |> paginate(args) + end + def get_flux_helm_repository(%{name: name, namespace: ns}, _), do: Kube.Client.get_helm_repository(ns, name) def list_flux_helm_repositories(_, _), do: Git.list_helm_repositories() @@ -144,6 +154,12 @@ defmodule Console.GraphQl.Resolvers.Deployments.Git do def delete_observer(%{id: id}, %{context: %{current_user: user}}), do: Git.delete_observer(id, user) + def upsert_catalog(%{attributes: attrs}, %{context: %{current_user: user}}), + do: Git.upsert_catalog(attrs, user) + + def delete_catalog(%{id: id}, %{context: %{current_user: user}}), + do: Git.delete_catalog(id, user) + defp pr_filters(query, args) do Enum.reduce(args, query, fn {:cluster_id, cid}, q -> PullRequest.for_cluster(q, cid) diff --git a/lib/console/schema/catalog.ex b/lib/console/schema/catalog.ex new file mode 100644 index 0000000000..69898d944f --- /dev/null +++ b/lib/console/schema/catalog.ex @@ -0,0 +1,49 @@ +defmodule Console.Schema.Catalog do + use Piazza.Ecto.Schema + alias Console.Schema.{Project, PolicyBinding, Tag} + + schema "catalogs" do + field :name, :string + field :description, :string + field :category, :string + field :author, :string + + field :write_policy_id, :binary_id + field :read_policy_id, :binary_id + + belongs_to :project, Project + + has_many :read_bindings, PolicyBinding, + on_replace: :delete, + foreign_key: :policy_id, + references: :read_policy_id + has_many :write_bindings, PolicyBinding, + on_replace: :delete, + foreign_key: :policy_id, + references: :write_policy_id + + has_many :tags, Tag + + timestamps() + end + + def for_project(query \\ __MODULE__, pid) do + from(c in query, where: c.project_id == ^pid) + end + + def ordered(query \\ __MODULE__, order \\ [asc: :name]) do + from(c in query, order_by: ^order) + end + + def changeset(model, attrs \\ %{}) do + model + |> cast(attrs, ~w(name author description category project_id)a) + |> cast_assoc(:tags) + |> cast_assoc(:read_bindings) + |> cast_assoc(:write_bindings) + |> foreign_key_constraint(:project_id) + |> validate_required([:name, :author, :project_id]) + |> put_new_change(:write_policy_id, &Ecto.UUID.generate/0) + |> put_new_change(:read_policy_id, &Ecto.UUID.generate/0) + end +end diff --git a/lib/console/schema/pr_automation.ex b/lib/console/schema/pr_automation.ex index 96bca791d3..a531ec3d69 100644 --- a/lib/console/schema/pr_automation.ex +++ b/lib/console/schema/pr_automation.ex @@ -7,7 +7,8 @@ defmodule Console.Schema.PrAutomation do PolicyBinding, Configuration, Project, - GitRepository + GitRepository, + Catalog } defenum MatchStrategy, any: 0, all: 1, recursive: 2 @@ -62,6 +63,7 @@ defmodule Console.Schema.PrAutomation do belongs_to :connection, ScmConnection belongs_to :repository, GitRepository belongs_to :project, Project + belongs_to :catalog, Catalog has_many :write_bindings, PolicyBinding, on_replace: :delete, @@ -84,7 +86,7 @@ defmodule Console.Schema.PrAutomation do from(p in query, order_by: ^order) end - @valid ~w(name project_id role identifier message title branch documentation addon repository_id cluster_id service_id connection_id)a + @valid ~w(name project_id role identifier message title branch documentation addon catalog_id repository_id cluster_id service_id connection_id)a def changeset(model, attrs \\ %{}) do model @@ -103,6 +105,7 @@ defmodule Console.Schema.PrAutomation do |> foreign_key_constraint(:service_id) |> foreign_key_constraint(:connection_id) |> foreign_key_constraint(:project_id) + |> foreign_key_constraint(:catalog_id) end defp update_changeset(model, attrs) do diff --git a/lib/console/schema/tag.ex b/lib/console/schema/tag.ex index fc6b476597..0a16e0ab4e 100644 --- a/lib/console/schema/tag.ex +++ b/lib/console/schema/tag.ex @@ -1,6 +1,6 @@ defmodule Console.Schema.Tag do use Piazza.Ecto.Schema - alias Console.Schema.{Cluster, Service, Stack} + alias Console.Schema.{Cluster, Service, Stack, Catalog} schema "tags" do field :name, :string @@ -9,6 +9,7 @@ defmodule Console.Schema.Tag do belongs_to :cluster, Cluster belongs_to :service, Service belongs_to :stack, Stack + belongs_to :catalog, Catalog timestamps() end @@ -17,6 +18,8 @@ defmodule Console.Schema.Tag do def stack(query \\ __MODULE__), do: from(t in query, where: not is_nil(t.stack_id)) + def catalog(query \\ __MODULE__), do: from(t in query, where: not is_nil(t.catalog_id)) + def for_name(query \\ __MODULE__, name), do: from(t in query, where: t.name == ^name) def for_query(query \\ __MODULE__, tq, column \\ :cluster_id) @@ -48,6 +51,10 @@ defmodule Console.Schema.Tag do from(t in q, select: %{stack_id: t.stack_id, count: count(t.id)}) end + defp do_select(q, :catalog_id) do + from(t in q, select: %{catalog_id: t.stack_id, count: count(t.id)}) + end + def search(query \\ __MODULE__, q) do sq = "%#{q}%" from(t in query, where: like(t.name, ^sq) or like(t.value, ^sq)) @@ -73,8 +80,10 @@ defmodule Console.Schema.Tag do |> foreign_key_constraint(:cluster_id) |> foreign_key_constraint(:service_id) |> foreign_key_constraint(:stack_id) + |> foreign_key_constraint(:catalog_id) |> unique_constraint([:cluster_id, :name]) |> unique_constraint([:service_id, :name]) + |> unique_constraint([:catalog_id, :name]) |> validate_required([:name, :value]) end end diff --git a/priv/repo/migrations/20241008165928_add_catalogs.exs b/priv/repo/migrations/20241008165928_add_catalogs.exs new file mode 100644 index 0000000000..4ae727d8b7 --- /dev/null +++ b/priv/repo/migrations/20241008165928_add_catalogs.exs @@ -0,0 +1,37 @@ +defmodule Console.Repo.Migrations.AddCatalogs do + use Ecto.Migration + + def change do + create table(:catalogs, primary_key: false) do + add :id, :uuid, primary_key: true + add :name, :string + add :description, :string + add :category, :string + add :author, :string + + add :read_policy_id, :uuid + add :write_policy_id, :uuid + + add :project_id, references(:projects, type: :uuid) + + timestamps() + end + + create unique_index(:catalogs, [:name]) + create index(:catalogs, [:category]) + create index(:catalogs, [:project_id]) + + alter table(:pr_automations) do + add :catalog_id, references(:catalogs, type: :uuid, on_delete: :nilify_all) + end + + create index(:pr_automations, [:catalog_id]) + + alter table(:tags) do + add :catalog_id, references(:catalogs, type: :uuid, on_delete: :delete_all) + end + + create unique_index(:tags, [:catalog_id, :name]) + create index(:tags, [:catalog_id]) + end +end diff --git a/schema/schema.graphql b/schema/schema.graphql index 5b5bdc2acc..cc5d9c7464 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -203,6 +203,10 @@ type RootQueryType { observers(after: String, first: Int, before: String, last: Int, projectId: ID): ObserverConnection + catalog(id: ID, name: String): Catalog + + catalogs(after: String, first: Int, before: String, last: Int, projectId: ID): CatalogConnection + "exchanges a kubeconfig token for user info" tokenExchange(token: String!): User @@ -581,6 +585,10 @@ type RootMutationType { deleteObserver(id: ID!): Observer + upsertCatalog(attributes: CatalogAttributes): Catalog + + deleteCatalog(id: ID!): Catalog + createCluster(attributes: ClusterAttributes!): Cluster updateCluster(id: ID!, attributes: ClusterUpdateAttributes!): Cluster @@ -4535,6 +4543,27 @@ enum ObserverStatus { FAILED } +input CatalogAttributes { + name: String! + + "the name of the author of this catalog, used for attribution only" + author: String! + + description: String + + "short category name for browsability" + category: String + + "owning project of the catalog, permissions will propagate down" + projectId: ID + + tags: [TagAttributes] + + readBindings: [PolicyBindingAttributes] + + writeBindings: [PolicyBindingAttributes] +} + input GitAttributes { "the url of this repository" url: String! @@ -4673,6 +4702,9 @@ input PrAutomationAttributes { "the scm connection to use for pr generation" connectionId: ID + "the catalog this automation will belong to" + catalogId: ID + "the project this automation lives in" projectId: ID @@ -5048,6 +5080,9 @@ type PrAutomation { "the git repository to use for sourcing external templates" repository: GitRepository + "the catalog this pr automation belongs to" + catalog: Catalog + "the project this automation lives w\/in" project: Project @@ -5280,6 +5315,34 @@ type ObserverPipelineAction { context: Map! } +"A catalog is an organized collection of PR Automations used for permissioning and discovery" +type Catalog { + id: ID! + + name: String! + + "longform description for the purpose of this catalog" + description: String + + "short category name used for browsing catalogs" + category: String + + "the name of the author of this catalog" + author: String + + project: Project + + "read policy for this catalog" + readBindings: [PolicyBinding] + + "write policy for this catalog" + writeBindings: [PolicyBinding] + + insertedAt: DateTime + + updatedAt: DateTime +} + type GitRepositoryConnection { pageInfo: PageInfo! edges: [GitRepositoryEdge] @@ -5320,6 +5383,11 @@ type ObserverConnection { edges: [ObserverEdge] } +type CatalogConnection { + pageInfo: PageInfo! + edges: [CatalogEdge] +} + input CloneAttributes { s3AccessKeyId: String s3SecretAccessKey: String @@ -7224,6 +7292,11 @@ type WebhookEdge { cursor: String } +type CatalogEdge { + node: Catalog + cursor: String +} + type ObserverEdge { node: Observer cursor: String diff --git a/test/console/deployments/git_test.exs b/test/console/deployments/git_test.exs index 28ee385586..9484b477c3 100644 --- a/test/console/deployments/git_test.exs +++ b/test/console/deployments/git_test.exs @@ -510,6 +510,73 @@ defmodule Console.Deployments.GitTest do end end + describe "#upsert_catalog/2" do + test "it can create a new catalog record" do + admin = admin_user() + + {:ok, catalog} = Git.upsert_catalog(%{ + name: "catalog", + author: "Plural", + }, admin) + + assert catalog.name == "catalog" + assert catalog.author == "Plural" + assert catalog.project_id == Settings.default_project!().id + end + + test "it can update an existing catalog record" do + admin = admin_user() + cat = insert(:catalog) + group = insert(:group) + + {:ok, updated} = Git.upsert_catalog(%{ + name: cat.name, + read_bindings: [%{group_id: group.id}] + }, admin) + + assert updated.id == cat.id + [read] = updated.read_bindings + assert read.group_id == group.id + end + + test "project writers can create" do + user = insert(:user) + project = insert(:project, write_bindings: [%{user_id: user.id}]) + + {:ok, catalog} = Git.upsert_catalog(%{ + name: "catalog", + author: "Plural", + project_id: project.id, + }, user) + + assert catalog.name == "catalog" + assert catalog.project_id == project.id + end + + test "randos cannot create" do + {:error, _} = Git.upsert_catalog(%{ + name: "catalog", + }, insert(:user)) + end + end + + describe "#delete_catalog/2" do + test "writers can delete catalogs" do + cat = insert(:catalog) + + {:ok, deleted} = Git.delete_catalog(cat.id, admin_user()) + + assert deleted.id == cat.id + refute refetch(cat) + end + + test "randos cannot delete" do + cat = insert(:catalog) + + {:error, _} = Git.delete_catalog(cat.id, insert(:user)) + end + end + describe "setupRenovate" do test "it can create a service for renovate" do git = insert(:git_repository, url: "https://github.com/pluralsh/scaffolds.git") diff --git a/test/console/graphql/mutations/deployments/git_mutations_test.exs b/test/console/graphql/mutations/deployments/git_mutations_test.exs index c14fe3f94a..e6976c7048 100644 --- a/test/console/graphql/mutations/deployments/git_mutations_test.exs +++ b/test/console/graphql/mutations/deployments/git_mutations_test.exs @@ -390,4 +390,41 @@ defmodule Console.GraphQl.Deployments.GitMutationsTest do refute refetch(observer) end end + + describe "upsertcatalog" do + test "admins can upsert catalogs" do + {:ok, %{data: %{"upsertCatalog" => cat}}} = run_query(""" + mutation Upsert($attrs: CatalogAttributes!) { + upsertCatalog(attributes: $attrs) { + id + name + author + } + } + """, %{ + "attrs" => %{ + "name" => "catalog", + "author" => "Plural", + } + }, %{current_user: admin_user()}) + + assert cat["name"] == "catalog" + assert cat["author"] == "Plural" + end + end + + describe "deletecatalog" do + test "it can delete an catalog" do + catalog = insert(:catalog) + + {:ok, %{data: %{"deleteCatalog" => deleted}}} = run_query(""" + mutation Delete($id: ID!) { + deleteCatalog(id: $id) { id } + } + """, %{"id" => catalog.id}, %{current_user: admin_user()}) + + assert deleted["id"] == catalog.id + refute refetch(catalog) + end + end end diff --git a/test/console/graphql/queries/deployments/git_queries_test.exs b/test/console/graphql/queries/deployments/git_queries_test.exs index 48f477d33f..e02a2c3b9a 100644 --- a/test/console/graphql/queries/deployments/git_queries_test.exs +++ b/test/console/graphql/queries/deployments/git_queries_test.exs @@ -195,4 +195,56 @@ defmodule Console.GraphQl.Deployments.GitQueriesTest do |> ids_equal(observers) end end + + describe "catalog" do + test "it can fetch an catalog by name" do + catalog = insert(:catalog) + + {:ok, %{data: %{"catalog" => found}}} = run_query(""" + query Catalog($name: String!) { + catalog(name: $name) { + id + name + } + } + """, %{"name" => catalog.name}, %{current_user: insert(:user)}) + + assert found["id"] == catalog.id + assert found["name"] == catalog.name + end + end + + describe "catalogs" do + test "it can fetch paginated catalogs" do + catalogs = insert_list(3, :catalog) + + {:ok, %{data: %{"catalogs" => found}}} = run_query(""" + query { + catalogs(first: 5) { + edges { node { id } } + } + } + """, %{}, %{current_user: insert(:user)}) + + assert from_connection(found) + |> ids_equal(catalogs) + end + + test "it can filter by project" do + project = insert(:project) + catalogs = insert_list(3, :catalog, project: project) + insert_list(3, :catalog) + + {:ok, %{data: %{"catalogs" => found}}} = run_query(""" + query Catalogs($id: ID!) { + catalogs(projectId: $id, first: 5) { + edges { node { id } } + } + } + """, %{"id" => project.id}, %{current_user: insert(:user)}) + + assert from_connection(found) + |> ids_equal(catalogs) + end + end end diff --git a/test/support/factory.ex b/test/support/factory.ex index eed0b0d6c6..c57680be83 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -666,6 +666,16 @@ defmodule Console.Factory do } end + def catalog_factory do + %Schema.Catalog{ + name: sequence(:catalog, & "catalog-#{&1}"), + author: "Plural", + read_policy_id: Ecto.UUID.generate(), + write_policy_id: Ecto.UUID.generate(), + project: Settings.default_project!() + } + end + def setup_rbac(user, repos \\ ["*"], perms) do role = insert(:role, repositories: repos, permissions: Map.new(perms)) insert(:role_binding, role: role, user: user)