diff --git a/assets/src/generated/graphql.ts b/assets/src/generated/graphql.ts index 14a0395133..968615d1ac 100644 --- a/assets/src/generated/graphql.ts +++ b/assets/src/generated/graphql.ts @@ -1441,6 +1441,19 @@ export type BindingAttributes = { userId?: InputMaybe; }; +/** Requirements for Bitbucket Data Center / Server authentication */ +export type BitbucketDatacenterAttributes = { + /** the user slug for Bitbucket Data Center / Server */ + userSlug: Scalars['String']['input']; +}; + +/** Bitbucket Data Center / Server connection configuration */ +export type BitbucketDatacenterConfiguration = { + __typename?: 'BitbucketDatacenterConfiguration'; + /** the user slug for Bitbucket Data Center / Server */ + userSlug: Scalars['String']['output']; +}; + /** A restricted token meant only for use in registering clusters, esp for edge devices */ export type BootstrapToken = { __typename?: 'BootstrapToken'; @@ -4308,6 +4321,33 @@ export type GlobalServiceEdge = { node?: Maybe; }; +/** ServiceNow configuration for a pr governance controller */ +export type GovernanceServiceNow = { + __typename?: 'GovernanceServiceNow'; + /** additional attributes sent with change requests */ + attributes?: Maybe; + /** the change request model/type */ + changeModel?: Maybe; + /** the ServiceNow instance URL */ + url: Scalars['String']['output']; + /** ServiceNow API username */ + username: Scalars['String']['output']; +}; + +/** ServiceNow configuration for a pr governance controller */ +export type GovernanceServiceNowAttributes = { + /** additional attributes to send with change requests */ + attributes?: InputMaybe; + /** the change request model/type */ + changeModel?: InputMaybe; + /** ServiceNow API password */ + password: Scalars['String']['input']; + /** the ServiceNow instance URL */ + url: Scalars['String']['input']; + /** ServiceNow API username */ + username: Scalars['String']['input']; +}; + /** The webhook configuration for a pr governance controller */ export type GovernanceWebhook = { __typename?: 'GovernanceWebhook'; @@ -7317,6 +7357,7 @@ export type PrGovernance = { id: Scalars['ID']['output']; insertedAt?: Maybe; name: Scalars['String']['output']; + type: PrGovernanceType; updatedAt?: Maybe; }; @@ -7326,19 +7367,28 @@ export type PrGovernanceAttributes = { /** the scm connection to use for pr generation */ connectionId: Scalars['ID']['input']; name: Scalars['String']['input']; + /** the type of pr governance controller to use */ + type: PrGovernanceType; }; /** The configuration for a pr governance controller */ export type PrGovernanceConfiguration = { __typename?: 'PrGovernanceConfiguration'; + serviceNow?: Maybe; webhook?: Maybe; }; /** The settings for configuring a pr governance controller */ export type PrGovernanceConfigurationAttributes = { + serviceNow?: InputMaybe; webhook?: InputMaybe; }; +export enum PrGovernanceType { + ServiceNow = 'SERVICE_NOW', + Webhook = 'WEBHOOK' +} + export type PrHelmVendorSpec = { __typename?: 'PrHelmVendorSpec'; /** the name of the chart to use */ @@ -11360,6 +11410,8 @@ export type ScmConnection = { azure?: Maybe; /** base url for git clones for self-hosted versions */ baseUrl?: Maybe; + /** the Bitbucket Data Center / Server attributes for this connection */ + bitbucketDatacenter?: Maybe; default?: Maybe; id: Scalars['ID']['output']; insertedAt?: Maybe; @@ -11376,6 +11428,7 @@ export type ScmConnectionAttributes = { apiUrl?: InputMaybe; azure?: InputMaybe; baseUrl?: InputMaybe; + bitbucketDatacenter?: InputMaybe; default?: InputMaybe; github?: InputMaybe; name: Scalars['String']['input']; diff --git a/charts/console-rapid/charts/controller-0.0.169.tgz b/charts/console-rapid/charts/controller-0.0.169.tgz index 5e8ea474e5..570ba0e9b2 100644 Binary files a/charts/console-rapid/charts/controller-0.0.169.tgz and b/charts/console-rapid/charts/controller-0.0.169.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 ba53757dd1..873d013e01 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.169.tgz b/charts/console/charts/controller-0.0.169.tgz index 29ad3f870b..f5cdb85496 100644 Binary files a/charts/console/charts/controller-0.0.169.tgz and b/charts/console/charts/controller-0.0.169.tgz differ diff --git a/charts/console/charts/kas-0.3.1.tgz b/charts/console/charts/kas-0.3.1.tgz index 9bcfacc889..49e73f9035 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_prgovernances.yaml b/charts/controller/crds/deployments.plural.sh_prgovernances.yaml index 63bb47fb1a..aaac251281 100644 --- a/charts/controller/crds/deployments.plural.sh_prgovernances.yaml +++ b/charts/controller/crds/deployments.plural.sh_prgovernances.yaml @@ -55,9 +55,67 @@ spec: This includes webhook configurations, approval requirements, and other policy enforcement mechanisms that control how pull requests are managed and processed. properties: + serviceNow: + description: |- + ServiceNow defines ServiceNow change request integration for PR governance. + When set, PRs will require a ServiceNow change request to be opened and approved + before merge. The password is read from the referenced Secret. + properties: + attributes: + description: Attributes is optional JSON passed as additional + attributes when creating change requests. + type: object + x-kubernetes-preserve-unknown-fields: true + changeModel: + description: |- + ChangeModel is the change request model/type (e.g. "Standard"). If empty, "Standard" is used. + We currently support the built-in ILI4 models, such as Standard, Normal, and Emergency. + type: string + passwordSecretKeyRef: + description: |- + PasswordSecretKeyRef references a key in a Secret containing the ServiceNow API password. + For namespaced PrGovernance the secret is read from the same namespace; for cluster-scoped + PrGovernance set SecretNamespace to the namespace where the secret lives. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + secretNamespace: + description: SecretNamespace is the namespace of the secret + referenced by PasswordSecretKeyRef. + type: string + url: + description: Url is the ServiceNow instance URL (e.g. https://instance.service-now.com). + type: string + username: + description: Username is the ServiceNow API username for authentication. + type: string + required: + - passwordSecretKeyRef + - url + - username + type: object webhook: description: |- - Webhooks defines webhook integration settings for governance enforcement. + Webhook defines webhook integration settings for governance enforcement. This enables the governance controller to receive notifications about pull request events and respond with appropriate policy enforcement actions such as requiring additional approvals, running compliance checks, or blocking merges. @@ -73,8 +131,6 @@ spec: required: - url type: object - required: - - webhook type: object connectionRef: description: ConnectionRef references an ScmConnection to reuse its @@ -148,8 +204,16 @@ spec: example: 5m30s type: string type: object + type: + description: Type specifies the type of PR governance controller to + use. + enum: + - WEBHOOK + - SERVICE_NOW + type: string required: - connectionRef + - type type: object status: description: |- diff --git a/charts/controller/crds/deployments.plural.sh_scmconnections.yaml b/charts/controller/crds/deployments.plural.sh_scmconnections.yaml index 1511ca3f30..92c556cda9 100644 --- a/charts/controller/crds/deployments.plural.sh_scmconnections.yaml +++ b/charts/controller/crds/deployments.plural.sh_scmconnections.yaml @@ -75,6 +75,16 @@ spec: description: BaseUrl is a base URL for Git clones for self-hosted versions. type: string + bitbucketDatacenter: + description: Settings for configuring Bitbucket Data Center / Server + authentication + properties: + userSlug: + description: The user slug for Bitbucket Data Center / Server + type: string + required: + - userSlug + type: object default: type: boolean github: diff --git a/charts/controller/crds/deployments.plural.sh_sentinels.yaml b/charts/controller/crds/deployments.plural.sh_sentinels.yaml index ee05700957..fe9b772acd 100644 --- a/charts/controller/crds/deployments.plural.sh_sentinels.yaml +++ b/charts/controller/crds/deployments.plural.sh_sentinels.yaml @@ -56,6 +56,10 @@ spec: this check properties: cases: + description: A list of custom test cases to run for + this check. These can provide yaml-configurable targeted + cases of things like coredns, load balancers, pvcs, + etc. items: properties: coredns: diff --git a/config/prod.exs b/config/prod.exs index 7854180d03..711a347822 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -70,7 +70,6 @@ config :console, Console.Cron.Scheduler, {"15 1 * * *", {Console.Deployments.Cron, :prune_cluster_audit_logs, []}}, {"0 * * * *", {Console.Deployments.Cron, :prune_policy, []}}, {"15 * * * *", {Console.Deployments.Cron, :prune_vuln_reports, []}}, - {"*/15 * * * *", {Console.Deployments.Cron, :pr_governance, []}}, {"15 3 * * *", {Console.Deployments.Cron, :prune_dangling_templates, []}}, {"30 3 * * *", {Console.Deployments.Cron, :prune_insight_components, []}}, {"0 4 * * *", {Console.Deployments.Cron, :prune_helm_repositories, []}}, diff --git a/go/client/models_gen.go b/go/client/models_gen.go index 79e9113a7d..fa747ba229 100644 --- a/go/client/models_gen.go +++ b/go/client/models_gen.go @@ -1143,6 +1143,18 @@ type BindingAttributes struct { GroupID *string `json:"groupId,omitempty"` } +// Requirements for Bitbucket Data Center / Server authentication +type BitbucketDatacenterAttributes struct { + // the user slug for Bitbucket Data Center / Server + UserSlug string `json:"userSlug"` +} + +// Bitbucket Data Center / Server connection configuration +type BitbucketDatacenterConfiguration struct { + // the user slug for Bitbucket Data Center / Server + UserSlug string `json:"userSlug"` +} + // A restricted token meant only for use in registering clusters, esp for edge devices type BootstrapToken struct { ID string `json:"id"` @@ -3504,6 +3516,32 @@ type GlobalServiceEdge struct { Cursor *string `json:"cursor,omitempty"` } +// ServiceNow configuration for a pr governance controller +type GovernanceServiceNow struct { + // the ServiceNow instance URL + URL string `json:"url"` + // the change request model/type + ChangeModel *string `json:"changeModel,omitempty"` + // ServiceNow API username + Username string `json:"username"` + // additional attributes sent with change requests + Attributes map[string]any `json:"attributes,omitempty"` +} + +// ServiceNow configuration for a pr governance controller +type GovernanceServiceNowAttributes struct { + // the ServiceNow instance URL + URL string `json:"url"` + // the change request model/type + ChangeModel *string `json:"changeModel,omitempty"` + // ServiceNow API username + Username string `json:"username"` + // ServiceNow API password + Password string `json:"password"` + // additional attributes to send with change requests + Attributes *string `json:"attributes,omitempty"` +} + // The webhook configuration for a pr governance controller type GovernanceWebhook struct { URL string `json:"url"` @@ -6069,6 +6107,7 @@ type PrDeleteSpec struct { type PrGovernance struct { ID string `json:"id"` Name string `json:"name"` + Type PrGovernanceType `json:"type"` Connection *ScmConnection `json:"connection,omitempty"` Configuration *PrGovernanceConfiguration `json:"configuration,omitempty"` InsertedAt *string `json:"insertedAt,omitempty"` @@ -6077,7 +6116,9 @@ type PrGovernance struct { // The settings for configuring a pr governance controller type PrGovernanceAttributes struct { - Name string `json:"name"` + // the type of pr governance controller to use + Type PrGovernanceType `json:"type"` + Name string `json:"name"` // the scm connection to use for pr generation ConnectionID string `json:"connectionId"` Configuration *PrGovernanceConfigurationAttributes `json:"configuration,omitempty"` @@ -6085,12 +6126,14 @@ type PrGovernanceAttributes struct { // The configuration for a pr governance controller type PrGovernanceConfiguration struct { - Webhook *GovernanceWebhook `json:"webhook,omitempty"` + Webhook *GovernanceWebhook `json:"webhook,omitempty"` + ServiceNow *GovernanceServiceNow `json:"serviceNow,omitempty"` } // The settings for configuring a pr governance controller type PrGovernanceConfigurationAttributes struct { - Webhook *GovernanceWebhookAttributes `json:"webhook,omitempty"` + Webhook *GovernanceWebhookAttributes `json:"webhook,omitempty"` + ServiceNow *GovernanceServiceNowAttributes `json:"serviceNow,omitempty"` } type PrHelmVendorSpec struct { @@ -6775,6 +6818,8 @@ type ScmConnection struct { Proxy *HTTPProxyConfiguration `json:"proxy,omitempty"` // the azure devops attributes for this connection Azure *AzureDevopsConfiguration `json:"azure,omitempty"` + // the Bitbucket Data Center / Server attributes for this connection + BitbucketDatacenter *BitbucketDatacenterConfiguration `json:"bitbucketDatacenter,omitempty"` // base url for git clones for self-hosted versions BaseURL *string `json:"baseUrl,omitempty"` // base url for HTTP apis for self-hosted versions if different from base url @@ -6788,15 +6833,16 @@ type ScmConnectionAttributes struct { Name string `json:"name"` Type ScmType `json:"type"` // the owning entity in this scm provider, eg a github organization - Owner *string `json:"owner,omitempty"` - Username *string `json:"username,omitempty"` - Token *string `json:"token,omitempty"` - BaseURL *string `json:"baseUrl,omitempty"` - APIURL *string `json:"apiUrl,omitempty"` - Github *GithubAppAttributes `json:"github,omitempty"` - Azure *AzureDevopsAttributes `json:"azure,omitempty"` - Default *bool `json:"default,omitempty"` - Proxy *HTTPProxyAttributes `json:"proxy,omitempty"` + Owner *string `json:"owner,omitempty"` + Username *string `json:"username,omitempty"` + Token *string `json:"token,omitempty"` + BaseURL *string `json:"baseUrl,omitempty"` + APIURL *string `json:"apiUrl,omitempty"` + Github *GithubAppAttributes `json:"github,omitempty"` + Azure *AzureDevopsAttributes `json:"azure,omitempty"` + BitbucketDatacenter *BitbucketDatacenterAttributes `json:"bitbucketDatacenter,omitempty"` + Default *bool `json:"default,omitempty"` + Proxy *HTTPProxyAttributes `json:"proxy,omitempty"` // a ssh private key to be used for commit signing SigningPrivateKey *string `json:"signingPrivateKey,omitempty"` } @@ -12663,6 +12709,61 @@ func (e PolicyEngineType) MarshalJSON() ([]byte, error) { return buf.Bytes(), nil } +type PrGovernanceType string + +const ( + PrGovernanceTypeServiceNow PrGovernanceType = "SERVICE_NOW" + PrGovernanceTypeWebhook PrGovernanceType = "WEBHOOK" +) + +var AllPrGovernanceType = []PrGovernanceType{ + PrGovernanceTypeServiceNow, + PrGovernanceTypeWebhook, +} + +func (e PrGovernanceType) IsValid() bool { + switch e { + case PrGovernanceTypeServiceNow, PrGovernanceTypeWebhook: + return true + } + return false +} + +func (e PrGovernanceType) String() string { + return string(e) +} + +func (e *PrGovernanceType) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = PrGovernanceType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid PrGovernanceType", str) + } + return nil +} + +func (e PrGovernanceType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *PrGovernanceType) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e PrGovernanceType) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + type PrRole string const ( diff --git a/go/controller/api/v1alpha1/prgovernance_types.go b/go/controller/api/v1alpha1/prgovernance_types.go index 00025a8a49..087e129378 100644 --- a/go/controller/api/v1alpha1/prgovernance_types.go +++ b/go/controller/api/v1alpha1/prgovernance_types.go @@ -20,6 +20,9 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + console "github.com/pluralsh/console/go/client" ) func init() { @@ -88,6 +91,11 @@ func (in *PrGovernance) ConsoleName() string { // It specifies governance rules, approval workflows, and integration settings // for managing pull requests created through Plural Console automations. type PrGovernanceSpec struct { + // Type specifies the type of PR governance controller to use. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum:=WEBHOOK;SERVICE_NOW + Type console.PrGovernanceType `json:"type"` + // Name specifies the name for this PR governance controller. // If not provided, the name from the resource metadata will be used. // +kubebuilder:validation:Optional @@ -113,12 +121,18 @@ type PrGovernanceSpec struct { // It specifies the mechanisms and integrations used to implement governance policies // for pull requests managed through Plural Console automations. type PrGovernanceConfiguration struct { - // Webhooks defines webhook integration settings for governance enforcement. + // Webhook defines webhook integration settings for governance enforcement. // This enables the governance controller to receive notifications about pull request // events and respond with appropriate policy enforcement actions such as requiring // additional approvals, running compliance checks, or blocking merges. - // +kubebuilder:validation:Required - Webhooks PrGovernanceWebhook `json:"webhook"` + // +kubebuilder:validation:Optional + Webhook *PrGovernanceWebhook `json:"webhook,omitempty"` + + // ServiceNow defines ServiceNow change request integration for PR governance. + // When set, PRs will require a ServiceNow change request to be opened and approved + // before merge. The password is read from the referenced Secret. + // +kubebuilder:validation:Optional + ServiceNow *PrGovernanceServiceNow `json:"serviceNow,omitempty"` } // PrGovernanceWebhook defines webhook configuration for external governance system integration. @@ -134,3 +148,35 @@ type PrGovernanceWebhook struct { // +kubebuilder:validation:Required Url string `json:"url"` } + +// PrGovernanceServiceNow defines ServiceNow integration for PR governance. +// PRs governed by this configuration will create and manage ServiceNow change requests. +type PrGovernanceServiceNow struct { + // Url is the ServiceNow instance URL (e.g. https://instance.service-now.com). + // +kubebuilder:validation:Required + Url string `json:"url"` + + // ChangeModel is the change request model/type (e.g. "Standard"). If empty, "Standard" is used. + // We currently support the built-in ILI4 models, such as Standard, Normal, and Emergency. + // +kubebuilder:validation:Optional + ChangeModel *string `json:"changeModel,omitempty"` + + // Username is the ServiceNow API username for authentication. + // +kubebuilder:validation:Required + Username string `json:"username"` + + // PasswordSecretKeyRef references a key in a Secret containing the ServiceNow API password. + // For namespaced PrGovernance the secret is read from the same namespace; for cluster-scoped + // PrGovernance set SecretNamespace to the namespace where the secret lives. + // +kubebuilder:validation:Required + PasswordSecretKeyRef corev1.SecretKeySelector `json:"passwordSecretKeyRef"` + + // SecretNamespace is the namespace of the secret referenced by PasswordSecretKeyRef. + // +kubebuilder:validation:Optional + SecretNamespace *string `json:"secretNamespace,omitempty"` + + // Attributes is optional JSON passed as additional attributes when creating change requests. + // Not all change attributes need to be provided, we will auto-fill basics like description, implementation plan, backout plan, test plan, etc using AI if not provided. + // +kubebuilder:validation:Optional + Attributes *runtime.RawExtension `json:"attributes,omitempty"` +} diff --git a/go/controller/api/v1alpha1/scmconnection_types.go b/go/controller/api/v1alpha1/scmconnection_types.go index 196cbb2d24..dc0238a7c9 100644 --- a/go/controller/api/v1alpha1/scmconnection_types.go +++ b/go/controller/api/v1alpha1/scmconnection_types.go @@ -94,6 +94,12 @@ func (s *ScmConnection) Attributes(ctx context.Context, kubeClient client.Client } } + if s.Spec.BitbucketDatacenter != nil { + attr.BitbucketDatacenter = &console.BitbucketDatacenterAttributes{ + UserSlug: s.Spec.BitbucketDatacenter.UserSlug, + } + } + return attr, nil } @@ -142,6 +148,10 @@ type ScmConnectionSpec struct { // +kubebuilder:validation:Optional Azure *AzureDevopsSettings `json:"azure,omitempty"` + // Settings for configuring Bitbucket Data Center / Server authentication + // +kubebuilder:validation:Optional + BitbucketDatacenter *BitbucketDatacenterSettings `json:"bitbucketDatacenter,omitempty"` + // Configures usage of an HTTP proxy for all requests involving this SCM connection. // +kubebuilder:validation:Optional Proxy *HttpProxyConfiguration `json:"proxy,omitempty"` @@ -173,6 +183,12 @@ type AzureDevopsSettings struct { Project string `json:"project"` } +// BitbucketDatacenterSettings holds configuration for Bitbucket Data Center / Server authentication. +type BitbucketDatacenterSettings struct { + // The user slug for Bitbucket Data Center / Server + UserSlug string `json:"userSlug"` +} + type HttpProxyConfiguration struct { // The url of your HTTP proxy. // +kubebuilder:validation:Required diff --git a/go/controller/api/v1alpha1/zz_generated.deepcopy.go b/go/controller/api/v1alpha1/zz_generated.deepcopy.go index e3deb2ebf2..303b02695d 100644 --- a/go/controller/api/v1alpha1/zz_generated.deepcopy.go +++ b/go/controller/api/v1alpha1/zz_generated.deepcopy.go @@ -488,6 +488,21 @@ func (in *BindingsTemplate) DeepCopy() *BindingsTemplate { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BitbucketDatacenterSettings) DeepCopyInto(out *BitbucketDatacenterSettings) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BitbucketDatacenterSettings. +func (in *BitbucketDatacenterSettings) DeepCopy() *BitbucketDatacenterSettings { + if in == nil { + return nil + } + out := new(BitbucketDatacenterSettings) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BootstrapToken) DeepCopyInto(out *BootstrapToken) { *out = *in @@ -6700,7 +6715,16 @@ func (in *PrGovernance) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrGovernanceConfiguration) DeepCopyInto(out *PrGovernanceConfiguration) { *out = *in - out.Webhooks = in.Webhooks + if in.Webhook != nil { + in, out := &in.Webhook, &out.Webhook + *out = new(PrGovernanceWebhook) + **out = **in + } + if in.ServiceNow != nil { + in, out := &in.ServiceNow, &out.ServiceNow + *out = new(PrGovernanceServiceNow) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrGovernanceConfiguration. @@ -6745,6 +6769,37 @@ func (in *PrGovernanceList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PrGovernanceServiceNow) DeepCopyInto(out *PrGovernanceServiceNow) { + *out = *in + if in.ChangeModel != nil { + in, out := &in.ChangeModel, &out.ChangeModel + *out = new(string) + **out = **in + } + in.PasswordSecretKeyRef.DeepCopyInto(&out.PasswordSecretKeyRef) + if in.SecretNamespace != nil { + in, out := &in.SecretNamespace, &out.SecretNamespace + *out = new(string) + **out = **in + } + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrGovernanceServiceNow. +func (in *PrGovernanceServiceNow) DeepCopy() *PrGovernanceServiceNow { + if in == nil { + return nil + } + out := new(PrGovernanceServiceNow) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrGovernanceSpec) DeepCopyInto(out *PrGovernanceSpec) { *out = *in @@ -6757,7 +6812,7 @@ func (in *PrGovernanceSpec) DeepCopyInto(out *PrGovernanceSpec) { if in.Configuration != nil { in, out := &in.Configuration, &out.Configuration *out = new(PrGovernanceConfiguration) - **out = **in + (*in).DeepCopyInto(*out) } if in.Reconciliation != nil { in, out := &in.Reconciliation, &out.Reconciliation @@ -7174,6 +7229,11 @@ func (in *ScmConnectionSpec) DeepCopyInto(out *ScmConnectionSpec) { *out = new(AzureDevopsSettings) **out = **in } + if in.BitbucketDatacenter != nil { + in, out := &in.BitbucketDatacenter, &out.BitbucketDatacenter + *out = new(BitbucketDatacenterSettings) + **out = **in + } if in.Proxy != nil { in, out := &in.Proxy, &out.Proxy *out = new(HttpProxyConfiguration) diff --git a/go/controller/config/crd/bases/deployments.plural.sh_prgovernances.yaml b/go/controller/config/crd/bases/deployments.plural.sh_prgovernances.yaml index 63bb47fb1a..aaac251281 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_prgovernances.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_prgovernances.yaml @@ -55,9 +55,67 @@ spec: This includes webhook configurations, approval requirements, and other policy enforcement mechanisms that control how pull requests are managed and processed. properties: + serviceNow: + description: |- + ServiceNow defines ServiceNow change request integration for PR governance. + When set, PRs will require a ServiceNow change request to be opened and approved + before merge. The password is read from the referenced Secret. + properties: + attributes: + description: Attributes is optional JSON passed as additional + attributes when creating change requests. + type: object + x-kubernetes-preserve-unknown-fields: true + changeModel: + description: |- + ChangeModel is the change request model/type (e.g. "Standard"). If empty, "Standard" is used. + We currently support the built-in ILI4 models, such as Standard, Normal, and Emergency. + type: string + passwordSecretKeyRef: + description: |- + PasswordSecretKeyRef references a key in a Secret containing the ServiceNow API password. + For namespaced PrGovernance the secret is read from the same namespace; for cluster-scoped + PrGovernance set SecretNamespace to the namespace where the secret lives. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + secretNamespace: + description: SecretNamespace is the namespace of the secret + referenced by PasswordSecretKeyRef. + type: string + url: + description: Url is the ServiceNow instance URL (e.g. https://instance.service-now.com). + type: string + username: + description: Username is the ServiceNow API username for authentication. + type: string + required: + - passwordSecretKeyRef + - url + - username + type: object webhook: description: |- - Webhooks defines webhook integration settings for governance enforcement. + Webhook defines webhook integration settings for governance enforcement. This enables the governance controller to receive notifications about pull request events and respond with appropriate policy enforcement actions such as requiring additional approvals, running compliance checks, or blocking merges. @@ -73,8 +131,6 @@ spec: required: - url type: object - required: - - webhook type: object connectionRef: description: ConnectionRef references an ScmConnection to reuse its @@ -148,8 +204,16 @@ spec: example: 5m30s type: string type: object + type: + description: Type specifies the type of PR governance controller to + use. + enum: + - WEBHOOK + - SERVICE_NOW + type: string required: - connectionRef + - type type: object status: description: |- diff --git a/go/controller/config/crd/bases/deployments.plural.sh_scmconnections.yaml b/go/controller/config/crd/bases/deployments.plural.sh_scmconnections.yaml index 1511ca3f30..92c556cda9 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_scmconnections.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_scmconnections.yaml @@ -75,6 +75,16 @@ spec: description: BaseUrl is a base URL for Git clones for self-hosted versions. type: string + bitbucketDatacenter: + description: Settings for configuring Bitbucket Data Center / Server + authentication + properties: + userSlug: + description: The user slug for Bitbucket Data Center / Server + type: string + required: + - userSlug + type: object default: type: boolean github: diff --git a/go/controller/config/crd/bases/deployments.plural.sh_sentinels.yaml b/go/controller/config/crd/bases/deployments.plural.sh_sentinels.yaml index ee05700957..fe9b772acd 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_sentinels.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_sentinels.yaml @@ -56,6 +56,10 @@ spec: this check properties: cases: + description: A list of custom test cases to run for + this check. These can provide yaml-configurable targeted + cases of things like coredns, load balancers, pvcs, + etc. items: properties: coredns: diff --git a/go/controller/docs/api.md b/go/controller/docs/api.md index 9de6304b3e..55e417d28e 100644 --- a/go/controller/docs/api.md +++ b/go/controller/docs/api.md @@ -347,6 +347,22 @@ _Appears in:_ | `write` _string_ | Write bindings. | | Optional: \{\}
| +#### BitbucketDatacenterSettings + + + +BitbucketDatacenterSettings holds configuration for Bitbucket Data Center / Server authentication. + + + +_Appears in:_ +- [ScmConnectionSpec](#scmconnectionspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `userSlug` _string_ | The user slug for Bitbucket Data Center / Server | | | + + #### BootstrapToken @@ -3689,7 +3705,30 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `webhook` _[PrGovernanceWebhook](#prgovernancewebhook)_ | Webhooks defines webhook integration settings for governance enforcement.
This enables the governance controller to receive notifications about pull request
events and respond with appropriate policy enforcement actions such as requiring
additional approvals, running compliance checks, or blocking merges. | | Required: \{\}
| +| `webhook` _[PrGovernanceWebhook](#prgovernancewebhook)_ | Webhook defines webhook integration settings for governance enforcement.
This enables the governance controller to receive notifications about pull request
events and respond with appropriate policy enforcement actions such as requiring
additional approvals, running compliance checks, or blocking merges. | | Optional: \{\}
| +| `serviceNow` _[PrGovernanceServiceNow](#prgovernanceservicenow)_ | ServiceNow defines ServiceNow change request integration for PR governance.
When set, PRs will require a ServiceNow change request to be opened and approved
before merge. The password is read from the referenced Secret. | | Optional: \{\}
| + + +#### PrGovernanceServiceNow + + + +PrGovernanceServiceNow defines ServiceNow integration for PR governance. +PRs governed by this configuration will create and manage ServiceNow change requests. + + + +_Appears in:_ +- [PrGovernanceConfiguration](#prgovernanceconfiguration) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `url` _string_ | Url is the ServiceNow instance URL (e.g. https://instance.service-now.com). | | Required: \{\}
| +| `changeModel` _string_ | ChangeModel is the change request model/type (e.g. "Standard"). If empty, "Standard" is used.
We currently support the built-in ILI4 models, such as Standard, Normal, and Emergency. | | Optional: \{\}
| +| `username` _string_ | Username is the ServiceNow API username for authentication. | | Required: \{\}
| +| `passwordSecretKeyRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#secretkeyselector-v1-core)_ | PasswordSecretKeyRef references a key in a Secret containing the ServiceNow API password.
For namespaced PrGovernance the secret is read from the same namespace; for cluster-scoped
PrGovernance set SecretNamespace to the namespace where the secret lives. | | Required: \{\}
| +| `secretNamespace` _string_ | SecretNamespace is the namespace of the secret referenced by PasswordSecretKeyRef. | | Optional: \{\}
| +| `attributes` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#rawextension-runtime-pkg)_ | Attributes is optional JSON passed as additional attributes when creating change requests.
Not all change attributes need to be provided, we will auto-fill basics like description, implementation plan, backout plan, test plan, etc using AI if not provided. | | Optional: \{\}
| #### PrGovernanceSpec @@ -3707,6 +3746,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | +| `type` _[PrGovernanceType](#prgovernancetype)_ | Type specifies the type of PR governance controller to use. | | Enum: [WEBHOOK SERVICE_NOW]
Required: \{\}
| | `name` _string_ | Name specifies the name for this PR governance controller.
If not provided, the name from the resource metadata will be used. | | Optional: \{\}
| | `connectionRef` _[ObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectreference-v1-core)_ | ConnectionRef references an ScmConnection to reuse its credentials for this governance controller's authentication. | | Required: \{\}
| | `configuration` _[PrGovernanceConfiguration](#prgovernanceconfiguration)_ | Configuration contains the specific governance settings and rules to enforce on pull requests.
This includes webhook configurations, approval requirements, and other policy enforcement
mechanisms that control how pull requests are managed and processed. | | Optional: \{\}
| @@ -3977,6 +4017,7 @@ _Appears in:_ | `apiUrl` _string_ | APIUrl is a base URL for HTTP apis for shel-hosted versions if different from BaseUrl. | | Optional: \{\}
| | `github` _[ScmGithubConnection](#scmgithubconnection)_ | Settings for configuring Github App authentication | | Optional: \{\}
| | `azure` _[AzureDevopsSettings](#azuredevopssettings)_ | Settings for configuring Azure DevOps authentication | | Optional: \{\}
| +| `bitbucketDatacenter` _[BitbucketDatacenterSettings](#bitbucketdatacentersettings)_ | Settings for configuring Bitbucket Data Center / Server authentication | | Optional: \{\}
| | `proxy` _[HttpProxyConfiguration](#httpproxyconfiguration)_ | Configures usage of an HTTP proxy for all requests involving this SCM connection. | | Optional: \{\}
| | `default` _boolean_ | | | Optional: \{\}
| | `reconciliation` _[Reconciliation](#reconciliation)_ | Reconciliation settings for this resource.
Controls drift detection and reconciliation intervals. | | Optional: \{\}
| diff --git a/go/controller/internal/controller/prgovernance_controller.go b/go/controller/internal/controller/prgovernance_controller.go index 05406ed139..ca33dfb285 100644 --- a/go/controller/internal/controller/prgovernance_controller.go +++ b/go/controller/internal/controller/prgovernance_controller.go @@ -8,6 +8,7 @@ import ( consoleclient "github.com/pluralsh/console/go/controller/internal/client" "github.com/pluralsh/console/go/controller/internal/common" "github.com/pluralsh/console/go/controller/internal/utils" + "github.com/pluralsh/console/go/controller/internal/utils/safe" "github.com/samber/lo" "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -36,6 +37,7 @@ type PrGovernanceReconciler struct { //+kubebuilder:rbac:groups=deployments.plural.sh,resources=prgovernances,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=deployments.plural.sh,resources=prgovernances/status,verbs=get;update;patch //+kubebuilder:rbac:groups=deployments.plural.sh,resources=prgovernances/finalizers,verbs=update +//+kubebuilder:rbac:groups=core,resources=secrets,verbs=get // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -96,7 +98,7 @@ func (r *PrGovernanceReconciler) Reconcile(ctx context.Context, req ctrl.Request func (r *PrGovernanceReconciler) addOrRemoveFinalizer(ctx context.Context, prGovernance *v1alpha1.PrGovernance) *ctrl.Result { // If object is not being deleted and if it does not have our finalizer, // then lets add the finalizer. This is equivalent to registering our finalizer. - if prGovernance.GetDeletionTimestamp().IsZero() && !controllerutil.ContainsFinalizer(prGovernance, PreviewEnvironmentTemplateFinalizerName) { + if prGovernance.GetDeletionTimestamp().IsZero() && !controllerutil.ContainsFinalizer(prGovernance, PrGovernanceFinalizerName) { controllerutil.AddFinalizer(prGovernance, PrGovernanceFinalizerName) } @@ -139,6 +141,7 @@ func (r *PrGovernanceReconciler) addOrRemoveFinalizer(ctx context.Context, prGov func (r *PrGovernanceReconciler) attributes(ctx context.Context, prGovernance *v1alpha1.PrGovernance) (*console.PrGovernanceAttributes, *ctrl.Result, error) { attributes := &console.PrGovernanceAttributes{ Name: prGovernance.ConsoleName(), + Type: prGovernance.Spec.Type, } connection := &v1alpha1.ScmConnection{} @@ -155,16 +158,55 @@ func (r *PrGovernanceReconciler) attributes(ctx context.Context, prGovernance *v attributes.ConnectionID = *connection.Status.ID if prGovernance.Spec.Configuration != nil { - attributes.Configuration = &console.PrGovernanceConfigurationAttributes{ - Webhook: &console.GovernanceWebhookAttributes{ - URL: prGovernance.Spec.Configuration.Webhooks.Url, - }, + cfg := prGovernance.Spec.Configuration + attributes.Configuration = &console.PrGovernanceConfigurationAttributes{} + + if cfg.Webhook != nil { + attributes.Configuration.Webhook = &console.GovernanceWebhookAttributes{ + URL: cfg.Webhook.Url, + } + } + + if cfg.ServiceNow != nil { + snAttrs, err := r.serviceNowAttributes(ctx, prGovernance.Namespace, cfg.ServiceNow) + if err != nil { + return nil, lo.ToPtr(common.Wait()), err + } + attributes.Configuration.ServiceNow = snAttrs } } return attributes, nil, nil } +// serviceNowAttributes resolves the ServiceNow password from the secret and builds API attributes. +func (r *PrGovernanceReconciler) serviceNowAttributes(ctx context.Context, defaultNamespace string, sn *v1alpha1.PrGovernanceServiceNow) (*console.GovernanceServiceNowAttributes, error) { + namespace := defaultNamespace + if sn.SecretNamespace != nil && *sn.SecretNamespace != "" { + namespace = *sn.SecretNamespace + } + if namespace == "" { + return nil, fmt.Errorf("secret namespace is required for ServiceNow password (PrGovernance is cluster-scoped or namespace not set)") + } + + password, err := safe.GetSecretKey(ctx, r.Client, &sn.PasswordSecretKeyRef, namespace) + if err != nil { + return nil, err + } + + attrs := &console.GovernanceServiceNowAttributes{ + URL: sn.Url, + Username: sn.Username, + Password: password, + ChangeModel: sn.ChangeModel, + } + if sn.Attributes != nil && len(sn.Attributes.Raw) > 0 { + s := string(sn.Attributes.Raw) + attrs.Attributes = &s + } + return attrs, nil +} + // SetupWithManager sets up the controller with the Manager. func (r *PrGovernanceReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/go/controller/internal/controller/prgovernance_controller_test.go b/go/controller/internal/controller/prgovernance_controller_test.go index af81c0c2d8..2796fb617e 100644 --- a/go/controller/internal/controller/prgovernance_controller_test.go +++ b/go/controller/internal/controller/prgovernance_controller_test.go @@ -48,12 +48,13 @@ var _ = Describe("PrGovernanceReconciler Controller", Ordered, func() { }, Spec: v1alpha1.PrGovernanceSpec{ Name: lo.ToPtr(name), + Type: gqlclient.PrGovernanceTypeWebhook, ConnectionRef: v1.ObjectReference{ Name: name, Namespace: namespace, }, Configuration: &v1alpha1.PrGovernanceConfiguration{ - Webhooks: v1alpha1.PrGovernanceWebhook{ + Webhook: &v1alpha1.PrGovernanceWebhook{ Url: "test", }, }, @@ -101,7 +102,7 @@ var _ = Describe("PrGovernanceReconciler Controller", Ordered, func() { }{ expectedStatus: v1alpha1.Status{ ID: lo.ToPtr("123"), - SHA: lo.ToPtr("WDUVGRJ367GZ26DHXADTE3AYVXFWIQZWQRL7H2RESA6AZOD2XARA===="), + SHA: lo.ToPtr("QEJYO5KBUE6SMTNTVLE37GTQQMKA4XCB3MRDWPVVC5YIOQTSRM4A===="), Conditions: []metav1.Condition{ { Type: v1alpha1.ReadyConditionType.String(), diff --git a/lib/alertmanager/alert.ex b/lib/alertmanager/alert.ex deleted file mode 100644 index ef9f57d181..0000000000 --- a/lib/alertmanager/alert.ex +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Alertmanager.Alert do - defstruct [:name, :summary, :description, :annotations, :labels, :starts_at, :status, :fingerprint] - - def build(%{"annotations" => annots, "labels" => labs} = blob) do - %__MODULE__{ - name: labs["alertname"], - summary: annots["summary"], - description: annots["description"], - labels: labs, - annotations: annots, - status: status(blob), - starts_at: blob["startsAt"], - fingerprint: blob["fingerprint"] - } - end - - defp status(%{"status" => "resolved"}), do: :resolved - defp status(_), do: :firing -end diff --git a/lib/console/ai/tools/service_now.ex b/lib/console/ai/tools/service_now.ex new file mode 100644 index 0000000000..2ddd582f7e --- /dev/null +++ b/lib/console/ai/tools/service_now.ex @@ -0,0 +1,32 @@ +defmodule Console.AI.Tools.ServiceNow do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :short_description, :string + field :description, :string + field :implementation_plan, :string + field :backout_plan, :string + field :test_plan, :string + end + + @valid ~w(short_description description implementation_plan backout_plan test_plan)a + + def changeset(model, attrs) do + model + |> cast(attrs, @valid) + |> validate_required(@valid) + end + + @json_schema Console.priv_file!("tools/snow.json") |> Jason.decode!() + + def json_schema(), do: @json_schema + def name(), do: "service_now_change" + def description() do + """ + Creates a change in ServiceNow according to the given schema + """ + end + + def implement(%__MODULE__{} = change), do: {:ok, change} +end diff --git a/lib/helm/client.ex b/lib/console/clients/helm/client.ex similarity index 100% rename from lib/helm/client.ex rename to lib/console/clients/helm/client.ex diff --git a/lib/helm/interface.ex b/lib/console/clients/helm/interface.ex similarity index 100% rename from lib/helm/interface.ex rename to lib/console/clients/helm/interface.ex diff --git a/lib/helm/interface/http.ex b/lib/console/clients/helm/interface/http.ex similarity index 100% rename from lib/helm/interface/http.ex rename to lib/console/clients/helm/interface/http.ex diff --git a/lib/helm/interface/oci.ex b/lib/console/clients/helm/interface/oci.ex similarity index 100% rename from lib/helm/interface/oci.ex rename to lib/console/clients/helm/interface/oci.ex diff --git a/lib/helm/schema.ex b/lib/console/clients/helm/schema.ex similarity index 100% rename from lib/helm/schema.ex rename to lib/console/clients/helm/schema.ex diff --git a/lib/helm/utils.ex b/lib/console/clients/helm/utils.ex similarity index 100% rename from lib/helm/utils.ex rename to lib/console/clients/helm/utils.ex diff --git a/lib/hydra/client.ex b/lib/console/clients/hydra/client.ex similarity index 100% rename from lib/hydra/client.ex rename to lib/console/clients/hydra/client.ex diff --git a/lib/loki/client.ex b/lib/console/clients/loki/client.ex similarity index 100% rename from lib/loki/client.ex rename to lib/console/clients/loki/client.ex diff --git a/lib/loki/schema.ex b/lib/console/clients/loki/schema.ex similarity index 100% rename from lib/loki/schema.ex rename to lib/console/clients/loki/schema.ex diff --git a/lib/loki/stream.ex b/lib/console/clients/loki/stream.ex similarity index 100% rename from lib/loki/stream.ex rename to lib/console/clients/loki/stream.ex diff --git a/lib/oci/auth.ex b/lib/console/clients/oci/auth.ex similarity index 100% rename from lib/oci/auth.ex rename to lib/console/clients/oci/auth.ex diff --git a/lib/oci/client.ex b/lib/console/clients/oci/client.ex similarity index 100% rename from lib/oci/client.ex rename to lib/console/clients/oci/client.ex diff --git a/lib/oci/schema.ex b/lib/console/clients/oci/schema.ex similarity index 100% rename from lib/oci/schema.ex rename to lib/console/clients/oci/schema.ex diff --git a/lib/prometheus/client.ex b/lib/console/clients/prometheus/client.ex similarity index 100% rename from lib/prometheus/client.ex rename to lib/console/clients/prometheus/client.ex diff --git a/lib/prometheus/schema.ex b/lib/console/clients/prometheus/schema.ex similarity index 100% rename from lib/prometheus/schema.ex rename to lib/console/clients/prometheus/schema.ex diff --git a/lib/console/clients/service_now/change.ex b/lib/console/clients/service_now/change.ex new file mode 100644 index 0000000000..2492b6ff6c --- /dev/null +++ b/lib/console/clients/service_now/change.ex @@ -0,0 +1,150 @@ +defmodule Console.ServiceNow.Change do + @moduledoc """ + Typed struct for ServiceNow Change Management API change records. + + The API returns each field as `{ "display_value": "...", "value": ... }`. + This struct stores the `.value` for each field (string, number, or boolean). + """ + + # Full schema from Change Management API result - all fields as atoms + @fields [ + :reason, + :parent, + :watch_list, + :proposed_change, + :upon_reject, + :sys_updated_on, + :type, + :approval_history, + :skills, + :test_plan, + :number, + :is_bulk, + :cab_delegate, + :requested_by_date, + :ci_class, + :state, + :sys_created_by, + :knowledge, + :order, + :phase, + :cmdb_ci, + :delivery_plan, + :impact, + :contract, + :active, + :work_notes_list, + :priority, + :sys_domain_path, + :cab_recommendation, + :production_system, + :rejection_goto, + :review_date, + :requested_by, + :business_duration, + :group_list, + :change_plan, + :approval_set, + :wf_activity, + :implementation_plan, + :universal_request, + :end_date, + :short_description, + :correlation_display, + :work_start, + :delivery_task, + :outside_maintenance_schedule, + :additional_assignee_list, + :std_change_producer_version, + :sys_class_name, + :service_offering, + :closed_by, + :follow_up, + :review_status, + :reassignment_count, + :start_date, + :assigned_to, + :variables, + :sla_due, + :comments_and_work_notes, + :escalation, + :upon_approval, + :correlation_id, + :made_sla, + :backout_plan, + :conflict_status, + :task_effective_number, + :sys_updated_by, + :opened_by, + :user_input, + :sys_created_on, + :on_hold_task, + :sys_domain, + :route_reason, + :closed_at, + :review_comments, + :business_service, + :time_worked, + :chg_model, + :expected_start, + :opened_at, + :work_end, + :phase_state, + :cab_date, + :work_notes, + :close_code, + :assignment_group, + :description, + :on_hold_reason, + :calendar_duration, + :close_notes, + :sys_id, + :contact_type, + :cab_required, + :urgency, + :scope, + :company, + :justification, + :activity_due, + :comments, + :approval, + :due_date, + :sys_mod_count, + :on_hold, + :sys_tags, + :conflict_last_run, + :risk_value, + :unauthorized, + :risk, + :location, + :category, + :risk_impact_analysis + ] + + defstruct @fields + + @type t :: %__MODULE__{} + + @doc """ + Builds a Change struct from a Change Management API result. + + The API returns `result` as a map where each key is a field name and each value is + `%{"display_value" => ..., "value" => ...}`. This function extracts the `"value"` + for each known field. + """ + @spec from_result(map() | nil) :: t() | nil + def from_result(nil), do: nil + + def from_result(%{} = result) do + Map.new(@fields, &{&1, extract_value(result["#{&1}"])}) + |> then(&struct(__MODULE__, &1)) + end + + # API returns { "display_value": "...", "value": ... } per field + defp extract_value(%{"value" => v}), do: normalize_value(v) + defp extract_value(v) when is_number(v) or is_boolean(v) or is_binary(v), do: v + defp extract_value(_), do: nil + + defp normalize_value(""), do: nil + defp normalize_value(v), do: v +end diff --git a/lib/console/clients/service_now/client.ex b/lib/console/clients/service_now/client.ex new file mode 100644 index 0000000000..ded50d6908 --- /dev/null +++ b/lib/console/clients/service_now/client.ex @@ -0,0 +1,96 @@ +defmodule Console.ServiceNow.Client do + @moduledoc """ + Client for the ServiceNow [Change Management API](https://www.servicenow.com/docs/r/api-reference/rest-apis/change-management-api.html). + + Uses `/api/sn_chg_rest/v1/change`. Responses use the full change schema where each + field is `{ "display_value": "...", "value": ... }`; the client returns a typed + `Change` struct with the `.value` extracted for each field. + + Authentication is HTTP Basic (username/password). Base URL is the instance URL + (e.g. `https://instance.service-now.com`) without a trailing slash. + """ + alias Console.ServiceNow.Change + + @change_base_path "/api/sn_chg_rest/v1/change" + @accept "application/json" + @content_type "application/json" + + defstruct [:url, :username, :password] + + @type t :: %__MODULE__{ + url: String.t(), + username: String.t(), + password: String.t() + } + + @doc """ + Builds a new client from url, username, and password (basic auth). + """ + @spec new(String.t(), String.t(), String.t()) :: t() + def new(url, username, password) when is_binary(url) and is_binary(username) and is_binary(password) do + %__MODULE__{ + url: String.trim_trailing(url, "/"), + username: username, + password: password + } + end + + @doc """ + Fetches a single change by sys_id. `GET /api/sn_chg_rest/v1/change/{sys_id}` + """ + @spec get_change(t(), String.t()) :: {:ok, Change.t()} | {:error, term()} + def get_change(%__MODULE__{} = client, sys_id) when is_binary(sys_id) do + client + |> req_client() + |> Req.get(url: "#{@change_base_path}/#{URI.encode(sys_id)}") + |> handle_result() + end + + @doc """ + Creates a change. `POST /api/sn_chg_rest/v1/change` + Options: `:chg_model`, `:type` (query params). + """ + @spec create_change(t(), map(), keyword()) :: {:ok, Change.t()} | {:error, term()} + def create_change(%__MODULE__{} = client, attrs, opts \\ []) when is_map(attrs) do + path = if length(opts) > 0, + do: "#{@change_base_path}?#{URI.encode_query(opts)}", + else: @change_base_path + + client + |> req_client() + |> Req.post(url: path, json: attrs) + |> handle_result() + end + + @doc """ + Updates an existing change by sys_id. `PUT /api/sn_chg_rest/v1/change/{sys_id}` + """ + @spec update_change(t(), String.t(), map()) :: {:ok, Change.t()} | {:error, term()} + def update_change(%__MODULE__{} = client, sys_id, attrs) + when is_binary(sys_id) and is_map(attrs) do + client + |> req_client() + |> Req.patch(url: "#{@change_base_path}/#{URI.encode(sys_id)}", json: attrs) + |> handle_result() + end + + defp req_client(%__MODULE__{username: user, password: pass, url: base_url}) do + Req.new(base_url: base_url) + |> Req.Request.put_header("accept", @accept) + |> Req.Request.put_header("content-type", @content_type) + |> Req.merge(auth: {:basic, "#{user}:#{pass}"}) + end + + defp handle_result({:ok, %Req.Response{status: status, body: body}}) + when status in 200..299, do: parse_result(body) + defp handle_result({:ok, %Req.Response{status: 404, body: body}}), + do: {:error, {:not_found, body}} + defp handle_result({:ok, %Req.Response{status: status, body: body}}), + do: {:error, {:servicenow, status, body}} + defp handle_result({:error, _} = err), do: err + + defp parse_result(%{"result" => %{} = result}), do: {:ok, Change.from_result(result)} + defp parse_result(%{"result" => [first | _]}), do: {:ok, Change.from_result(first)} + defp parse_result(%{"result" => nil}), do: {:error, {:invalid_response, :empty_result}} + defp parse_result(other), do: {:error, {:invalid_response, other}} +end diff --git a/lib/console/deployments/git.ex b/lib/console/deployments/git.ex index 236181c946..4ec7cceac4 100644 --- a/lib/console/deployments/git.ex +++ b/lib/console/deployments/git.ex @@ -447,19 +447,27 @@ defmodule Console.Deployments.Git do create_pr(attrs, url) {%{flow_id: flow_id} = attrs, nil} when is_binary(flow_id) -> create_pr(attrs, url) + {%{governance_id: governance_id} = attrs, nil} when is_binary(governance_id) -> + create_pr(attrs, url) _ -> {:error, :not_found} end end + @spec governance_poll(PullRequest.t) :: pull_request_resp + def governance_poll(%PullRequest{} = pr) do + PullRequest.governance_poll_changeset(pr) + |> Repo.update() + end + @doc """ Confirms a pull request with the governance controller then approves it directly """ @spec confirm_pull_request(PullRequest.t) :: pull_request_resp def confirm_pull_request(%PullRequest{} = pr) do with %PullRequest{} = pr = Repo.preload(pr, [governance: :connection]), - {:ok, _} <- GovernanceProvider.confirm(pr), + {:ok, state} <- GovernanceProvider.confirm(pr), {:ok, _} <- Dispatcher.approve(pr.governance.connection, pr, "Approved by Plural PR Governance") do - PullRequest.changeset(pr, %{approved: true}) + PullRequest.changeset(pr, %{approved: true, governance_state: state}) |> Repo.update() end end diff --git a/lib/console/deployments/pr/governance/impl/service_now.ex b/lib/console/deployments/pr/governance/impl/service_now.ex new file mode 100644 index 0000000000..1fb0911bb4 --- /dev/null +++ b/lib/console/deployments/pr/governance/impl/service_now.ex @@ -0,0 +1,157 @@ +defmodule Console.Deployments.Pr.Governance.Impl.ServiceNow do + @moduledoc """ + Implements the PR governance controller hooks to implement a ServiceNow <> SCM sync during a pr lifecycle. + + The overall flow is: + + 1. pr create -> service now change created + 2. service now change approved -> pr approved and change moved to implement state + 3. pr merge -> service now change closed with success (moving all intermediate states) + 4. pr close -> service now change cancelled + + We also write a PR comment to record the changes in ServiceNow and make it easier to track the generated change. + """ + @behaviour Console.Deployments.Pr.Governance.Provider + alias Console.Repo + alias Console.Schema.{PrGovernance, PullRequest} + alias Console.Deployments.Pr.Dispatcher + alias Console.ServiceNow.{Client, Change} + alias Console.AI.{Provider, Tools.ServiceNow} + require EEx + require Logger + + def open(%PrGovernance{} = gov, pr) do + client = snow(gov) + model = change_model(gov) + + with {:ok, attributes} <- change_attributes(gov, pr), + {:ok, %Change{sys_id: sys_id, number: number} = change} <- Client.create_change(client, attributes, chg_model: model) do + state = %{"id" => sys_id, "state" => floor(change.state), "number" => number} + github_comment({:ok, change}, gov, pr, state["state"], state) + end + end + + def close(%PrGovernance{} = gov, %PullRequest{status: :merged, governance_state: %{"id" => sys_id, "state" => state} = prev} = pr) do + client = snow(gov) + walk_close(client, sys_id, state, 3) + |> github_comment(gov, pr, 3, Map.merge(prev, %{"state" => 3})) + end + def close(%PrGovernance{} = gov, %PullRequest{status: :closed, governance_state: %{"id" => sys_id} = prev} = pr) do + client = snow(gov) + Client.update_change(client, sys_id, %{state: 4}) + |> github_comment(gov, pr, 4, Map.merge(prev, %{"state" => 4})) + end + def close(_, _), do: {:error, :not_implemented} + + def confirm(%PrGovernance{} = gov, %PullRequest{governance_state: %{"id" => sys_id} = state} = pr) do + client = snow(gov) + + case Client.get_change(client, sys_id) do + {:ok, %Change{state: s, sys_id: sys_id}} when s > -3 and s != 4 -> + walk_close(client, sys_id, round(s), -1) + |> github_comment(gov, pr, max(round(s), -1), Map.merge(state, %{"id" => sys_id, "state" => max(round(s), -1)})) + {:error, _} -> {:error, :not_approved} + end + end + def confirm(_, _), do: {:error, :not_implemented} + + defp github_comment({:ok, _}, %PrGovernance{} = gov, %PullRequest{} = pr, state, %{"number" => number} = prev) do + %{connection: conn} = Repo.preload(gov, :connection) + case Dispatcher.review(conn, %{pr | comment_id: prev["comment_id"]}, pr_message( + approval_emoji: approval_emoji(state), + url: snow_change_url(gov, number), + state: state_name(state), + pending: state <= -3 + )) do + {:ok, id} -> {:ok, Map.merge(prev, %{"comment_id" => id, "state" => state})} + _ -> {:ok, Map.put(prev, "state", state)} + end + end + + defp github_comment(err, _, _, _, _) do + Logger.error("Failed to sync servicenow governance state: #{inspect(err)}") + err + end + + defp snow(%PrGovernance{configuration: %{service_now: %{url: url, username: username, password: password}}}) do + Client.new(url, username, password) + end + + defp walk_close(client, id, current_state, target_state) when current_state < target_state do + with {next_state, attributes} when is_integer(next_state) and is_map(attributes) <- state_transition(current_state), + {:ok, _} <- Console.Retrier.retry(fn -> + Client.update_change(client, id, Map.put(attributes, :state, next_state)) end, + pause: 50, max: 2) do + :timer.sleep(200) # give snow it a moment to update + walk_close(client, id, next_state, target_state) + end + end + defp walk_close(_, _, current_state, _), do: {:ok, %{state: current_state}} + + # this is obviously retarded, but that's because it's ServiceNow's fault + defp state_transition(-3), do: {-2, %{}} + defp state_transition(-2), do: {-1, %{}} + defp state_transition(-1), do: {0, %{}} + defp state_transition(0), do: {3, %{close_code: "successful", close_notes: "Pull request merged"}} + defp state_transition(_), do: {:error, :invalid_state} + + @required ~w(short_description description implementation_plan backout_plan test_plan) + + defp change_attributes(%PrGovernance{configuration: %{service_now: %{attributes: %{} = attributes}}}, pr) do + case Enum.all?(@required, &Map.has_key?(attributes, &1)) do + true -> {:ok, attributes} + false -> ai_attributes(attributes, pr) + end + end + defp change_attributes(_, pr), do: ai_attributes(%{}, pr) + + @preface """ + You are a devops engineer recording a change you're making to a Github/Gitlab/Bitbucket pull request and translating it into ServiceNow. + + You need to provide all the necessary form attributes, and from there the ServiceNow change will be created for you automatically, be sure + to be thorough and provide whatever is necessary from an auditing perspective. + + For things like test plan, you can usually say testing performed prior if not elucidated, since this is typically a gitops change. + + For backout plan, if no plan is specified, you can simply say revert the PR. + + Be sure to also provide links and details about the PR to the change information. + """ + + defp ai_attributes(attributes, %PullRequest{} =pr) do + [{:user, snow_prompt(pr: pr, attributes: attributes)}] + |> Provider.simple_tool_call(ServiceNow, preface: @preface) + |> case do + {:ok, %ServiceNow{} = now} -> + Console.mapify(now) + |> Console.string_map() + |> then(&{:ok, Map.merge(attributes, &1)}) + {:error, error} -> {:error, error} + end + end + + defp snow_change_url(%PrGovernance{configuration: %{service_now: %{url: url}}}, number), + do: "#{url}/change_request.do?sysparm_query=number=#{number}" + defp snow_change_url(_, _), do: nil + + defp state_name(-5), do: "New" + defp state_name(-4), do: "Assess" + defp state_name(-3), do: "Authorize" + defp state_name(-2), do: "Scheduled" + defp state_name(-1), do: "Implement" + defp state_name(0), do: "Review" + defp state_name(3), do: "Closed" + defp state_name(4), do: "Cancelled" + defp state_name(_), do: "Unknown" + + defp approval_emoji(s) when s > -3 and s != 4, do: "👍" + defp approval_emoji(s) when s <= -3, do: "⏳" + defp approval_emoji(_), do: "❌" + + defp change_model(%PrGovernance{configuration: %{service_now: %{change_model: model}}}), + do: model + defp change_model(_), do: "Standard" + + EEx.function_from_file(:defp, :snow_prompt, Path.join([:code.priv_dir(:console), "prompts", "governance", "snow.md.eex"]), [:assigns]) + EEx.function_from_file(:defp, :pr_message, Path.join([:code.priv_dir(:console), "pr", "governance.md.eex"]), [:assigns]) +end diff --git a/lib/console/deployments/pr/governance/provider.ex b/lib/console/deployments/pr/governance/provider.ex index c5b8972fe8..d6647d9a29 100644 --- a/lib/console/deployments/pr/governance/provider.ex +++ b/lib/console/deployments/pr/governance/provider.ex @@ -2,7 +2,7 @@ defmodule Console.Deployments.Pr.Governance.Provider do @moduledoc """ A provider for the pr governance controller """ - alias Console.Deployments.Pr.Governance.Impl.Webhook + alias Console.Deployments.Pr.Governance.Impl.{Webhook, ServiceNow} alias Console.Schema.{PrGovernance, PullRequest} @callback open(PrGovernance.t, PullRequest.t) :: {:ok, map} | {:error, any} @@ -10,17 +10,23 @@ defmodule Console.Deployments.Pr.Governance.Provider do @callback confirm(PrGovernance.t, PullRequest.t) :: {:ok, map} | {:error, any} def open(%PullRequest{governance: %PrGovernance{} = gov} = pr) do - provider(gov).open(gov, pr) + with {:ok, provider} <- provider(gov), + do: provider.open(gov, pr) end def close(%PullRequest{governance: %PrGovernance{} = gov} = pr) do - provider(gov).close(gov, pr) + with {:ok, provider} <- provider(gov), + do: provider.close(gov, pr) end def confirm(%PullRequest{governance: %PrGovernance{} = gov} = pr) do - provider(gov).confirm(gov, pr) + with {:ok, provider} <- provider(gov), + do: provider.confirm(gov, pr) end + defp provider(%PrGovernance{type: :service_now, configuration: %{service_now: %{url: url}}}) when is_binary(url), + do: {:ok, ServiceNow} defp provider(%PrGovernance{configuration: %{webhook: %{url: url}}}) when is_binary(url), - do: Webhook + do: {:ok, Webhook} + defp provider(_), do: {:error, :not_implemented} end diff --git a/lib/console/deployments/pr/impl/azure.ex b/lib/console/deployments/pr/impl/azure.ex index 9914a8253a..65fa66b418 100644 --- a/lib/console/deployments/pr/impl/azure.ex +++ b/lib/console/deployments/pr/impl/azure.ex @@ -82,7 +82,22 @@ defmodule Console.Deployments.Pr.Impl.Azure do end end - def approve(_, _, _), do: {:error, "not implemented"} + def approve(conn, %PullRequest{url: url}, _) do + with {:ok, name, number} <- get_pull_id(url), + {:ok, conn} <- connection(conn), + {:ok, repo_id} <- get_repo_id(conn, name) do + Path.join(["/git/repositories", repo_id, "pullRequests", number, "reviewers"]) + |> then(&post(conn, &1, %{ + "descriptor" => "Plural Governance", + "displayName" => "Plural Governance", + "vote" => 10, + })) + |> case do + {:ok, %{"id" => id}} -> {:ok, "#{id}"} + err -> err + end + end + end def files(_, _), do: {:ok, []} diff --git a/lib/console/deployments/pr/impl/bitbucket.ex b/lib/console/deployments/pr/impl/bitbucket.ex index 0ad77155b4..4539d60618 100644 --- a/lib/console/deployments/pr/impl/bitbucket.ex +++ b/lib/console/deployments/pr/impl/bitbucket.ex @@ -54,15 +54,26 @@ defmodule Console.Deployments.Pr.Impl.BitBucket do defp pr_content(pr), do: "#{pr["source"]["branch"]["name"]}\n#{pr["title"]}\n#{pr["summary"]["raw"]}" - def review(conn, %PullRequest{url: url}, body) do + def review(conn, %PullRequest{url: url} = pr, body) do with {:ok, org, repo, number} <- get_pull_id(url), {:ok, conn} <- connection(conn) do - case post(conn, Path.join(["/repositories", "#{URI.encode("#{org}/#{repo}")}", "pullrequests", number, "comments"]), %{ - content: %{ - raw: filter_ansi(body), - markup: "markdown" - } - }) do + case pr do + %PullRequest{comment_id: id} when is_binary(id) -> + put(conn, Path.join(["/repositories", "#{URI.encode("#{org}/#{repo}")}", "pullrequests", number, "comments", id]), %{ + content: %{ + raw: filter_ansi(body), + markup: "markdown" + } + }) + _ -> + post(conn, Path.join(["/repositories", "#{URI.encode("#{org}/#{repo}")}", "pullrequests", number, "comments"]), %{ + content: %{ + raw: filter_ansi(body), + markup: "markdown" + } + }) + end + |> case do {:ok, %{"id" => id}} -> {:ok, "#{id}"} err -> err end @@ -99,7 +110,17 @@ defmodule Console.Deployments.Pr.Impl.BitBucket do end end - def approve(_, _, _), do: {:error, "not implemented"} + def approve(conn, %PullRequest{url: url}, _) do + with {:ok, workspace, repo, number} <- get_pull_id(url), + {:ok, conn} <- connection(conn) do + Path.join(["/repositories", "#{URI.encode("#{workspace}/#{repo}")}", "pullrequests", number, "approve"]) + |> then(&post(conn, &1, %{})) + |> case do + {:ok, %{"id" => id}} -> {:ok, "#{id}"} + err -> err + end + end + end def pr_info(url) do with {:ok, workspace, repo, number} <- get_pull_id(url) do @@ -125,6 +146,11 @@ defmodule Console.Deployments.Pr.Impl.BitBucket do |> handle_response() end + defp put(conn, url, body) do + HTTPoison.put("#{conn.host}#{url}", Jason.encode!(body), Connection.headers(conn)) + |> handle_response() + end + defp get(%Connection{} = conn, url) do HTTPoison.get("#{conn.host}#{url}", Connection.headers(conn)) |> handle_response() diff --git a/lib/console/deployments/pr/impl/bitbucket_datacenter.ex b/lib/console/deployments/pr/impl/bitbucket_datacenter.ex index 91de170b9a..04b7a8cecf 100644 --- a/lib/console/deployments/pr/impl/bitbucket_datacenter.ex +++ b/lib/console/deployments/pr/impl/bitbucket_datacenter.ex @@ -70,14 +70,22 @@ defmodule Console.Deployments.Pr.Impl.BitBucketDatacenter do defp pr_content(pr), do: "#{pr["fromRef"]["displayId"]}\n#{pr["title"]}\n#{pr["description"]}" - def review(conn, %PullRequest{url: url}, body) do + def review(conn, %PullRequest{url: url} = pr, body) do with {:ok, project, slug, number} <- get_pull_id(url), {:ok, conn} <- connection(conn) do - case post(conn, Path.join(["/projects", project, "repos", slug, "pull-requests", number, "comments"]), %{ - severity: "NORMAL", - state: "OPEN", - text: filter_ansi(body), - }) do + case pr do + %PullRequest{comment_id: id} when is_binary(id) -> + put(conn, Path.join(["/projects", project, "repos", slug, "pull-requests", number, "comments", id]), %{ + text: filter_ansi(body), + }) + _ -> + post(conn, Path.join(["/projects", project, "repos", slug, "pull-requests", number, "comments"]), %{ + severity: "NORMAL", + state: "OPEN", + text: filter_ansi(body), + }) + end + |> case do {:ok, %{"id" => id}} -> {:ok, "#{id}"} err -> err end @@ -86,7 +94,18 @@ defmodule Console.Deployments.Pr.Impl.BitBucketDatacenter do def files(_, _), do: {:ok, []} - def approve(_, _, _), do: {:error, "not implemented"} + def approve(conn, %PullRequest{url: url}, _) do + with {:ok, project, slug, number} <- get_pull_id(url), + {:ok, conn} <- connection(conn), + {:ok, user_slug} <- user_slug(conn) do + Path.join(["/projects", project, "repos", slug, "pull-requests", number, "participants", user_slug]) + |> then(&put(conn, &1, %{"status" => "APPROVED"})) + |> case do + {:ok, %{"id" => id}} -> {:ok, "#{id}"} + err -> err + end + end + end def slug(url) do with %URI{path: "/scm/" <> path} <- URI.parse(url), @@ -99,7 +118,17 @@ defmodule Console.Deployments.Pr.Impl.BitBucketDatacenter do def commit_status(_, _, _, _, _), do: :ok - def merge(_, _), do: :ok + def merge(conn, %PullRequest{url: url}) do + with {:ok, project, slug, number} <- get_pull_id(url), + {:ok, conn} <- connection(conn) do + Path.join(["/projects", project, "repos", slug, "pull-requests", number, "merge"]) + |> then(&post(conn, &1, %{})) + |> case do + {:ok, %{"id" => id}} -> {:ok, "#{id}"} + err -> err + end + end + end def pr_info(url) do with {:ok, project, repo, number} <- get_pull_id(url), @@ -112,6 +141,12 @@ defmodule Console.Deployments.Pr.Impl.BitBucketDatacenter do |> handle_response() end + defp put(conn, path, body) do + url(conn, path) + |> HTTPoison.put(Jason.encode!(body), Connection.headers(conn)) + |> handle_response() + end + defp handle_response({:ok, %HTTPoison.Response{status_code: code, body: body}}) when code >= 200 and code < 300, do: Jason.decode(body) defp handle_response({:ok, %HTTPoison.Response{body: body}}), do: {:error, "bitbucket request failed: #{body}"} @@ -159,4 +194,8 @@ defmodule Console.Deployments.Pr.Impl.BitBucketDatacenter do host = String.trim_trailing(host, "/rest/api/latest") Path.join([host, "projects", project, "repos", slug, "pull-requests", "#{id}"]) end + + defp user_slug(%ScmConnection{bitbucket_datacenter: %{user_slug: user_slug}}) + when is_binary(user_slug), do: {:ok, user_slug} + defp user_slug(_), do: {:error, "could not determine user slug"} end diff --git a/lib/console/deployments/pr/impl/gitlab.ex b/lib/console/deployments/pr/impl/gitlab.ex index cf79355180..bb7a7aaad4 100644 --- a/lib/console/deployments/pr/impl/gitlab.ex +++ b/lib/console/deployments/pr/impl/gitlab.ex @@ -57,18 +57,27 @@ defmodule Console.Deployments.Pr.Impl.Gitlab do title: mr["title"], body: mr["description"] }, pr_associations(mr_content(mr))) + |> add_approver(mr) |> Console.drop_nils() {:ok, url, attrs} end def pr(_), do: :ignore - def review(conn, %PullRequest{url: url}, body) do + def review(conn, %PullRequest{url: url} = pr, body) do with {:ok, owner, repo, number} <- get_pull_id(url), {:ok, conn} <- connection(conn) do - case post(conn, Path.join(["/api/v4/projects", "#{uri_encode("#{owner}/#{repo}")}", "merge_requests", number]), %{ - body: filter_ansi(body) - }) do + case pr do + %PullRequest{comment_id: id} when is_binary(id) -> + put(conn, Path.join(["/api/v4/projects", "#{uri_encode("#{owner}/#{repo}")}", "merge_requests", number, "notes", id]), %{ + body: filter_ansi(body) + }) + _ -> + post(conn, Path.join(["/api/v4/projects", "#{uri_encode("#{owner}/#{repo}")}", "merge_requests", number, "notes"]), %{ + body: filter_ansi(body) + }) + end + |> case do {:ok, %{"id" => id}} -> {:ok, "#{id}"} err -> err end @@ -105,7 +114,17 @@ defmodule Console.Deployments.Pr.Impl.Gitlab do end end - def approve(_, _, _), do: {:error, "not implemented"} + def approve(conn, %PullRequest{url: url}, _) do + with {:ok, owner, repo, number} <- get_pull_id(url), + {:ok, conn} <- connection(conn) do + Path.join(["/api/v4/projects", "#{uri_encode("#{owner}/#{repo}")}", "merge_requests", number, "approve"]) + |> then(&post(conn, &1, %{})) + |> case do + {:ok, %{"id" => id}} -> {:ok, "#{id}"} + err -> err + end + end + end def commit_status(_, _, _, _, _), do: :ok @@ -124,16 +143,36 @@ defmodule Console.Deployments.Pr.Impl.Gitlab do end end - def merge(_, _), do: :ok + def merge(conn, %PullRequest{url: url}) do + with {:ok, owner, repo, number} <- get_pull_id(url), + {:ok, conn} <- connection(conn) do + Path.join(["/api/v4/projects", "#{uri_encode("#{owner}/#{repo}")}", "merge_requests", number, "merge"]) + |> then(&put(conn, &1, %{squash: true, auto_merge: true})) + |> case do + {:ok, %{"id" => id}} -> {:ok, "#{id}"} + err -> err + end + end + end defp mr_content(mr), do: "#{mr["branch"]}\n#{mr["title"]}\n#{mr["description"]}" + defp add_approver(attrs, %{"approvers" => [%{"username" => username}]}), + do: Map.put(attrs, :approver, username) + defp add_approver(attrs, _), do: attrs + defp post(conn, url, body) do api_url(conn, url) |> HTTPoison.post(Jason.encode!(body), Connection.headers(conn)) |> handle_response() end + defp put(conn, url, body) do + api_url(conn, url) + |> HTTPoison.put(Jason.encode!(body), Connection.headers(conn)) + |> handle_response() + end + defp get(conn, url) do api_url(conn, url) |> HTTPoison.get(Connection.headers(conn)) diff --git a/lib/console/deployments/pr/utils.ex b/lib/console/deployments/pr/utils.ex index c083a9d62c..ddad1fa2c7 100644 --- a/lib/console/deployments/pr/utils.ex +++ b/lib/console/deployments/pr/utils.ex @@ -1,6 +1,6 @@ defmodule Console.Deployments.Pr.Utils do use Nebulex.Caching - alias Console.Deployments.{Stacks, Clusters, Services, Flows} + alias Console.Deployments.{Stacks, Clusters, Services, Flows, Git} alias Console.Schema.{PrAutomation, ScmConnection} @ttl :timer.hours(1) @@ -15,12 +15,13 @@ defmodule Console.Deployments.Pr.Utils do @flow_regex [~r/plrl\/flow\/([[:alnum:]_\-]+)\/?/, ~r/plrl\(flow:([[:alnum:]_\-]*)\)/, ~r/Plural [fF]low:\s+([[:alnum:]_\-]+)/] @preview_regex [~r/plrl\/preview\/([[:alnum:]_\-]+)\/?/, ~r/plrl\(preview:([[:alnum:]_\-]*)\)/, ~r/Plural [pP]review:\s+([[:alnum:]_\-]+)/] @merge_cron_regex [~r/[Pp]lural [Mm]erge [Cc]ron:\s+([0-9,\-*\/]+\s[0-9,\-*\/]+\s[0-9,\-*\/]+\s[0-9,\-*\/]+\s[0-9,\-*\/]+)/] + @governance_regex [~r/[Pp]lural [Gg]overnance:\s+([[:alnum:]_\-]+)/] @solid_opts [strict_variables: true, strict_filters: true] def filter_ansi(text), do: String.replace(text, @ansi_code, "") - def pr_associations(content, scopes \\ ~w(stack cluster service flow)a) do + def pr_associations(content, scopes \\ ~w(stack cluster service flow governance)a) do Enum.reduce(scopes, %{}, &maybe_add(&2, :"#{&1}_id", scrape(&1, content))) |> Map.put(:preview, scrape(:preview, content)) |> Map.put(:merge_cron, scrape(:merge_cron, content)) @@ -48,6 +49,7 @@ defmodule Console.Deployments.Pr.Utils do defp regexes(:flow), do: @flow_regex defp regexes(:preview), do: @preview_regex defp regexes(:merge_cron), do: @merge_cron_regex + defp regexes(:governance), do: @governance_regex @decorate cacheable(cache: @adapter, key: {:pr_fetch, scope, id}, opts: [ttl: @ttl]) def fetch(scope, id), do: do_fetch(scope, id) @@ -61,6 +63,7 @@ defmodule Console.Deployments.Pr.Utils do _ -> nil end end + defp do_fetch(:governance, name), do: Git.get_governance_by_name(name) defp do_fetch(:preview, name), do: name defp do_fetch(:merge_cron, cron), do: cron diff --git a/lib/console/deployments/pubsub/protocols/governable.ex b/lib/console/deployments/pubsub/protocols/governable.ex index 5d1531647f..35204bd978 100644 --- a/lib/console/deployments/pubsub/protocols/governable.ex +++ b/lib/console/deployments/pubsub/protocols/governable.ex @@ -39,6 +39,14 @@ defimpl Console.Deployments.PubSub.Governable, for: Console.PubSub.PullRequestUp alias Console.Schema.PullRequest alias Console.Deployments.Pr.Governance.Provider + def reconcile(%@for{item: %PullRequest{governance_changed: true, governance_id: id} = pr}) when is_binary(id) do + with %PullRequest{} = pr = Repo.preload(pr, [:governance]), + {:ok, result} <- Provider.open(pr), + {:ok, _} <- add_state(pr, result) do + :ok + end + end + def reconcile(%@for{item: %PullRequest{governance_id: id, status: status} = pr}) when is_binary(id) and status in [:merged, :closed] do with %PullRequest{} = pr = Repo.preload(pr, [:governance]), {:ok, _} <- Provider.close(pr) do @@ -46,4 +54,10 @@ defimpl Console.Deployments.PubSub.Governable, for: Console.PubSub.PullRequestUp end end def reconcile(_), do: :ok + + def add_state(%PullRequest{} = pr, state) do + pr + |> PullRequest.changeset(%{governance_state: state}) + |> Repo.update() + end end diff --git a/lib/console/graphql/deployments/git.ex b/lib/console/graphql/deployments/git.ex index 44ded8b9f0..a25be7ffa8 100644 --- a/lib/console/graphql/deployments/git.ex +++ b/lib/console/graphql/deployments/git.ex @@ -9,7 +9,8 @@ defmodule Console.GraphQl.Deployments.Git do ScmWebhook, PrAutomation, Configuration, - Observer + Observer, + PrGovernance } ecto_enum :auth_method, GitRepository.AuthMethod @@ -28,6 +29,7 @@ defmodule Console.GraphQl.Deployments.Git do ecto_enum :observer_git_target_type, Observer.GitTargetType ecto_enum :observer_target_order, Observer.TargetOrder ecto_enum :observer_status, Observer.Status + ecto_enum :pr_governance_type, PrGovernance.Type input_object :catalog_attributes do field :name, non_null(:string) @@ -106,6 +108,7 @@ defmodule Console.GraphQl.Deployments.Git do field :api_url, :string field :github, :github_app_attributes field :azure, :azure_devops_attributes + field :bitbucket_datacenter, :bitbucket_datacenter_attributes field :default, :boolean field :proxy, :http_proxy_attributes field :signing_private_key, :string, description: "a ssh private key to be used for commit signing" @@ -131,6 +134,11 @@ defmodule Console.GraphQl.Deployments.Git do field :project, non_null(:string), description: "the project to use for azure devops" end + @desc "Requirements for Bitbucket Data Center / Server authentication" + input_object :bitbucket_datacenter_attributes do + field :user_slug, non_null(:string), description: "the user slug for Bitbucket Data Center / Server" + end + @desc "A way to create a self-service means of generating PRs against an IaC repo" input_object :pr_automation_attributes do field :name, :string @@ -434,6 +442,7 @@ defmodule Console.GraphQl.Deployments.Git do @desc "The settings for configuring a pr governance controller" input_object :pr_governance_attributes do + field :type, non_null(:pr_governance_type), description: "the type of pr governance controller to use" field :name, non_null(:string) field :connection_id, non_null(:id), description: "the scm connection to use for pr generation" field :configuration, :pr_governance_configuration_attributes @@ -442,6 +451,7 @@ defmodule Console.GraphQl.Deployments.Git do @desc "The settings for configuring a pr governance controller" input_object :pr_governance_configuration_attributes do field :webhook, :governance_webhook_attributes + field :service_now, :governance_service_now_attributes end @desc "The settings for configuring a pr governance controller" @@ -449,6 +459,15 @@ defmodule Console.GraphQl.Deployments.Git do field :url, non_null(:string), description: "the url to send webhooks to" end + @desc "ServiceNow configuration for a pr governance controller" + input_object :governance_service_now_attributes do + field :url, non_null(:string), description: "the ServiceNow instance URL" + field :change_model, :string, description: "the change request model/type" + field :username, non_null(:string), description: "ServiceNow API username" + field :password, non_null(:string), description: "ServiceNow API password" + field :attributes, :json, description: "additional attributes to send with change requests" + end + @desc "a git repository available for deployments" object :git_repository do field :id, non_null(:id), description: "internal id of this repository" @@ -539,6 +558,8 @@ defmodule Console.GraphQl.Deployments.Git do field :username, :string field :proxy, :http_proxy_configuration, description: "a proxy to use for git requests" field :azure, :azure_devops_configuration, description: "the azure devops attributes for this connection" + field :bitbucket_datacenter, :bitbucket_datacenter_configuration, + description: "the Bitbucket Data Center / Server attributes for this connection" field :base_url, :string, description: "base url for git clones for self-hosted versions" field :api_url, :string, description: "base url for HTTP apis for self-hosted versions if different from base url" @@ -722,6 +743,11 @@ defmodule Console.GraphQl.Deployments.Git do field :project, non_null(:string), description: "the project to use for azure devops" end + @desc "Bitbucket Data Center / Server connection configuration" + object :bitbucket_datacenter_configuration do + field :user_slug, non_null(:string), description: "the user slug for Bitbucket Data Center / Server" + end + @desc "A reference to a pull request for your kubernetes related IaC" object :pull_request do field :id, non_null(:id) @@ -925,6 +951,7 @@ defmodule Console.GraphQl.Deployments.Git do object :pr_governance do field :id, non_null(:id) field :name, non_null(:string) + field :type, non_null(:pr_governance_type) field :connection, :scm_connection, resolve: dataloader(Deployments) field :configuration, :pr_governance_configuration @@ -934,6 +961,7 @@ defmodule Console.GraphQl.Deployments.Git do @desc "The configuration for a pr governance controller" object :pr_governance_configuration do field :webhook, :governance_webhook + field :service_now, :governance_service_now end @desc "The webhook configuration for a pr governance controller" @@ -941,6 +969,14 @@ defmodule Console.GraphQl.Deployments.Git do field :url, non_null(:string) end + @desc "ServiceNow configuration for a pr governance controller" + object :governance_service_now do + field :url, non_null(:string), description: "the ServiceNow instance URL" + field :change_model, :string, description: "the change request model/type" + field :username, non_null(:string), description: "ServiceNow API username" + field :attributes, :map, description: "additional attributes sent with change requests" + end + connection node_type: :git_repository connection node_type: :helm_repository connection node_type: :scm_connection diff --git a/lib/console/pipelines/pr_governance/pipeline.ex b/lib/console/pipelines/pr_governance/pipeline.ex new file mode 100644 index 0000000000..7d83789cf2 --- /dev/null +++ b/lib/console/pipelines/pr_governance/pipeline.ex @@ -0,0 +1,21 @@ +defmodule Console.Pipelines.PrGovernance.Pipeline do + use Console.Pipelines.Consumer + require Logger + alias Console.Deployments.Git + + def handle_event(pr) do + Logger.info "Attempting pr auto merge for #{pr.url}" + case Git.confirm_pull_request(pr) do + :ok -> :ok + {:ok, _} -> :ok + {:error, err} -> Logger.info "Failed to auto merge pr #{pr.url}: #{inspect(err)}" + _ -> Logger.info "scm integration not set up to perform auto merge" + end + |> mark_polled(pr) + end + + defp mark_polled(result, pr) do + Git.governance_poll(pr) + result + end +end diff --git a/lib/console/pipelines/pr_governance/producer.ex b/lib/console/pipelines/pr_governance/producer.ex new file mode 100644 index 0000000000..122dbd8fbe --- /dev/null +++ b/lib/console/pipelines/pr_governance/producer.ex @@ -0,0 +1,11 @@ +defmodule Console.Pipelines.PrGovernance.Producer do + use Console.Pipelines.PollProducer + alias Console.Schema.PullRequest + + def poll(demand) do + PullRequest.stream() + |> PullRequest.pending_governance() + |> PullRequest.with_limit(limit(demand)) + |> Repo.all() + end +end diff --git a/lib/console/schema/pr_governance.ex b/lib/console/schema/pr_governance.ex index 3d42a7e30d..026172222e 100644 --- a/lib/console/schema/pr_governance.ex +++ b/lib/console/schema/pr_governance.ex @@ -1,14 +1,26 @@ defmodule Console.Schema.PrGovernance do use Piazza.Ecto.Schema alias Console.Schema.{ScmConnection} + alias Piazza.Ecto.EncryptedString + + defenum Type, service_now: 1, webhook: 0 schema "pr_governance" do + field :type, Type, default: :webhook field :name, :string embeds_one :configuration, PrGovernanceConfiguration, on_replace: :update do embeds_one :webhook, Webhook, on_replace: :update do field :url, :string end + + embeds_one :service_now, ServiceNow, on_replace: :update do + field :url, :string + field :change_model, :string + field :username, :string + field :password, EncryptedString + field :attributes, :map + end end belongs_to :connection, ScmConnection @@ -16,7 +28,7 @@ defmodule Console.Schema.PrGovernance do timestamps() end - @valid ~w(name connection_id)a + @valid ~w(name connection_id type)a def changeset(model, attrs) do model @@ -31,6 +43,7 @@ defmodule Console.Schema.PrGovernance do model |> cast(attrs, []) |> cast_embed(:webhook, with: &webhook_changeset/2) + |> cast_embed(:service_now, with: &service_now_changeset/2) end defp webhook_changeset(model, attrs) do @@ -38,4 +51,10 @@ defmodule Console.Schema.PrGovernance do |> cast(attrs, [:url]) |> validate_required([:url]) end + + defp service_now_changeset(model, attrs) do + model + |> cast(attrs, [:url, :change_model, :username, :password, :attributes]) + |> validate_required([:url, :username, :password]) + end end diff --git a/lib/console/schema/pull_request.ex b/lib/console/schema/pull_request.ex index d728399a88..73f4e12dcf 100644 --- a/lib/console/schema/pull_request.ex +++ b/lib/console/schema/pull_request.ex @@ -15,31 +15,33 @@ defmodule Console.Schema.PullRequest do defenum Status, open: 0, merged: 1, closed: 2 schema "pull_requests" do - field :url, :string - field :status, Status, default: :open - field :title, :string - field :body, :string - field :creator, :string - field :labels, {:array, :string} - field :ref, :string - field :sha, :string - field :polled_sha, :string - field :commit_sha, :string - field :approver, :string - field :preview, :string - field :attributes, :map - field :patch, :binary - field :agent_id, :string - field :approved, :boolean, default: false - field :governance_state, :map - field :next_poll_at, :utc_datetime_usec - field :merge_cron, :string - field :merge_attempt_at, :utc_datetime_usec + field :url, :string + field :status, Status, default: :open + field :title, :string + field :body, :string + field :creator, :string + field :labels, {:array, :string} + field :ref, :string + field :sha, :string + field :polled_sha, :string + field :commit_sha, :string + field :approver, :string + field :preview, :string + field :attributes, :map + field :patch, :binary + field :agent_id, :string + field :approved, :boolean, default: false + field :governance_state, :map + field :governance_poll_at, :utc_datetime_usec + field :next_poll_at, :utc_datetime_usec + field :merge_cron, :string + field :merge_attempt_at, :utc_datetime_usec field :notifications_policy_id, :binary_id - field :comment_id, :string, virtual: true - field :fresh, :boolean, virtual: true, default: false + field :comment_id, :string, virtual: true + field :fresh, :boolean, virtual: true, default: false + field :governance_changed, :boolean, virtual: true, default: false belongs_to :cluster, Cluster belongs_to :service, Service @@ -112,7 +114,10 @@ defmodule Console.Schema.PullRequest do end def pending_governance(query \\ __MODULE__) do - from(pr in query, where: not is_nil(pr.governance_id) and not pr.approved and pr.status == ^:open) + from(pr in query, where: + not is_nil(pr.governance_id) and not pr.approved and pr.status == ^:open and + (is_nil(pr.governance_poll_at) or pr.governance_poll_at < ^DateTime.utc_now()) + ) end def stack(query \\ __MODULE__) do @@ -167,6 +172,7 @@ defmodule Console.Schema.PullRequest do |> foreign_key_constraint(:flow_id) |> put_new_change(:notifications_policy_id, &Ecto.UUID.generate/0) |> unique_constraint(:url) + |> change_markers(governance_id: :governance_changed) |> next_merge_attempt() |> validate_required(~w(url title)a) end @@ -180,6 +186,13 @@ defmodule Console.Schema.PullRequest do }) end + def governance_poll_changeset(model) do + duration = Duration.new!(minute: 15) + jittered = Duration.add(duration, Duration.new!(second: jitter(duration))) + + Ecto.Changeset.change(model, %{governance_poll_at: DateTime.shift(DateTime.utc_now(), jittered)}) + end + def poll_duration(interval) when is_binary(interval) do case parse_duration(interval) do {:ok, duration} -> duration diff --git a/lib/console/schema/scm_connection.ex b/lib/console/schema/scm_connection.ex index 7c31a1f94e..18e823febf 100644 --- a/lib/console/schema/scm_connection.ex +++ b/lib/console/schema/scm_connection.ex @@ -35,6 +35,10 @@ defmodule Console.Schema.ScmConnection do field :project, :string end + embeds_one :bitbucket_datacenter, BitbucketDatacenter, on_replace: :update do + field :user_slug, :string + end + embeds_one :proxy, Proxy, on_replace: :update do field :url, :string field :noproxy, :string @@ -60,6 +64,7 @@ defmodule Console.Schema.ScmConnection do |> cast_embed(:github, with: &github_changeset/2) |> cast_embed(:proxy, with: &proxy_changeset/2) |> cast_embed(:azure, with: &azure_changeset/2) + |> cast_embed(:bitbucket_datacenter, with: &bitbucket_datacenter_changeset/2) |> unique_constraint(:name) |> unique_constraint(:default, message: "only one scm connection can be marked default at once") |> validate_required([:name, :type]) @@ -80,6 +85,12 @@ defmodule Console.Schema.ScmConnection do |> validate_required(~w(username organization project)a) end + defp bitbucket_datacenter_changeset(model, attrs) do + model + |> cast(attrs, ~w(user_slug)a) + |> validate_required(~w(user_slug)a) + end + def proxy_changeset(model, attrs) do model |> cast(attrs, ~w(url noproxy)a) diff --git a/lib/kubecost/client.ex b/lib/kubecost/client.ex deleted file mode 100644 index 617c9c9233..0000000000 --- a/lib/kubecost/client.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Kubecost.Client do - import Console.Services.Base, only: [ok: 1] - require Logger - alias Kubecost.Entry - - defmodule Response, do: defstruct [:code, :status, :data, :message] - - def hostname(), do: "http://kubecost-cost-analyzer.kubecost:9090" - - def url(path, params \\ []) do - params = URI.encode_query(params) - "#{hostname()}#{path}?#{params}" - end - - def get_aggregated_cost() do - url("/model/allocation", window: "month", aggregate: "namespace", accumulate: true) - |> HTTPoison.get() - |> case do - {:ok, %{body: body, status_code: 200}} -> - Poison.decode(body, as: %Response{}) - |> convert() - error -> error - end - end - - defp convert({:ok, %Response{code: 200, data: [data | _]}}) do - Enum.into(data, %{}, fn {namespace, result} -> - {namespace, Entry.build(result)} - end) - |> ok() - end - defp convert({:ok, %Response{} = resp}) do - Logger.info "Failed to query kubecost: #{inspect(resp)}" - {:error, "kubecost failure"} - end - defp convert({:error, _} = error), do: error -end diff --git a/lib/kubecost/schema.ex b/lib/kubecost/schema.ex deleted file mode 100644 index 8824a5dbbd..0000000000 --- a/lib/kubecost/schema.ex +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Kubecost.Entry do - defstruct [ - :minutes, - :cpu_cost, - :cpu_efficiency, - :efficiency, - :gpu_cost, - :network_cost, - :pv_cost, - :ram_cost, - :ram_efficiency, - :total_cost, - :shared_cost - ] - - def build(attrs) do - %__MODULE__{ - minutes: attrs["minutes"], - cpu_cost: attrs["cpuCost"], - cpu_efficiency: attrs["cpuEfficiency"], - efficiency: attrs["efficiency"], - gpu_cost: attrs["gpuCost"], - network_cost: attrs["networkCost"], - pv_cost: attrs["pvCost"], - ram_cost: attrs["ramCost"], - ram_efficiency: attrs["ramEfficiency"], - total_cost: attrs["totalCost"], - shared_cost: attrs["sharedCost"] - } - end -end diff --git a/priv/pr/governance.md.eex b/priv/pr/governance.md.eex new file mode 100644 index 0000000000..c3e83f2f52 --- /dev/null +++ b/priv/pr/governance.md.eex @@ -0,0 +1,11 @@ +Plural has created a change in ServiceNow for the following PR: + +| Attribute | Value | +|-----------|-------| +| URL | <%= @url %> | +| State | <%= @state %> | +| Approved | <%= @approval_emoji %> | + +<%= if @pending do %> +Plural will approve this PR once the change is approved in ServiceNow, and will manage the change based on the PR's lifecycle from there. +<% end %> diff --git a/priv/prompts/governance/snow.md.eex b/priv/prompts/governance/snow.md.eex new file mode 100644 index 0000000000..ad03c3ad15 --- /dev/null +++ b/priv/prompts/governance/snow.md.eex @@ -0,0 +1,14 @@ +Here is the Pull Request we need to make a change based on: + +Url: <%= @pr.url %> +Title: <%= @pr.title %> + +# Pull Request Body + +<%= @pr.body %> + +Here are the current attributes, possibly empty, provided for the change as well in JSON format (you can preserve any of these if adequate): + +```JSON +<%= Jason.encode!(@attributes, pretty: true) %> +``` diff --git a/priv/repo/migrations/20260221174841_add_service_now_pr_governance_columns.exs b/priv/repo/migrations/20260221174841_add_service_now_pr_governance_columns.exs new file mode 100644 index 0000000000..b59cc13c4f --- /dev/null +++ b/priv/repo/migrations/20260221174841_add_service_now_pr_governance_columns.exs @@ -0,0 +1,21 @@ +defmodule Console.Repo.Migrations.AddServiceNowPrGovernanceColumns do + use Ecto.Migration + + def change do + alter table(:pr_governance) do + add :type, :integer, default: 0 + end + + alter table(:scm_connections) do + add :bitbucket_datacenter, :map + end + + alter table(:pull_requests) do + add :governance_poll_at, :utc_datetime_usec + end + + create index(:pull_requests, [:governance_id]) + create index(:pull_requests, [:governance_poll_at]) + create index(:pull_requests, [:status]) + end +end diff --git a/priv/tools/snow.json b/priv/tools/snow.json new file mode 100644 index 0000000000..49d5210fab --- /dev/null +++ b/priv/tools/snow.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "properties": { + "short_description": { + "type": "string", + "description": "The short description of the change" + }, + "description": { + "type": "string", + "description": "The description of the change" + }, + "implementation_plan": { + "type": "string", + "description": "The implementation plan for the change" + }, + "backout_plan": { + "type": "string", + "description": "The backout plan for the change" + }, + "test_plan": { + "type": "string", + "description": "The test plan for the change" + } + }, + "required": ["short_description", "description", "implementation_plan", "backout_plan", "test_plan"] +} \ No newline at end of file diff --git a/schema/schema.graphql b/schema/schema.graphql index 66ec937837..d2ba2aeee2 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -9661,6 +9661,11 @@ enum ObserverStatus { FAILED } +enum PrGovernanceType { + SERVICE_NOW + WEBHOOK +} + input CatalogAttributes { name: String! @@ -9779,6 +9784,8 @@ input ScmConnectionAttributes { azure: AzureDevopsAttributes + bitbucketDatacenter: BitbucketDatacenterAttributes + default: Boolean proxy: HttpProxyAttributes @@ -9817,6 +9824,12 @@ input AzureDevopsAttributes { project: String! } +"Requirements for Bitbucket Data Center \/ Server authentication" +input BitbucketDatacenterAttributes { + "the user slug for Bitbucket Data Center \/ Server" + userSlug: String! +} + "A way to create a self-service means of generating PRs against an IaC repo" input PrAutomationAttributes { name: String @@ -10259,6 +10272,9 @@ input ObserverAddonAttributes { "The settings for configuring a pr governance controller" input PrGovernanceAttributes { + "the type of pr governance controller to use" + type: PrGovernanceType! + name: String! "the scm connection to use for pr generation" @@ -10270,6 +10286,7 @@ input PrGovernanceAttributes { "The settings for configuring a pr governance controller" input PrGovernanceConfigurationAttributes { webhook: GovernanceWebhookAttributes + serviceNow: GovernanceServiceNowAttributes } "The settings for configuring a pr governance controller" @@ -10278,6 +10295,24 @@ input GovernanceWebhookAttributes { url: String! } +"ServiceNow configuration for a pr governance controller" +input GovernanceServiceNowAttributes { + "the ServiceNow instance URL" + url: String! + + "the change request model\/type" + changeModel: String + + "ServiceNow API username" + username: String! + + "ServiceNow API password" + password: String! + + "additional attributes to send with change requests" + attributes: Json +} + "a git repository available for deployments" type GitRepository { "internal id of this repository" @@ -10406,6 +10441,9 @@ type ScmConnection { "the azure devops attributes for this connection" azure: AzureDevopsConfiguration + "the Bitbucket Data Center \/ Server attributes for this connection" + bitbucketDatacenter: BitbucketDatacenterConfiguration + "base url for git clones for self-hosted versions" baseUrl: String @@ -10686,6 +10724,12 @@ type AzureDevopsConfiguration { project: String! } +"Bitbucket Data Center \/ Server connection configuration" +type BitbucketDatacenterConfiguration { + "the user slug for Bitbucket Data Center \/ Server" + userSlug: String! +} + "A reference to a pull request for your kubernetes related IaC" type PullRequest { id: ID! @@ -10950,6 +10994,7 @@ type CatalogSearchItem { type PrGovernance { id: ID! name: String! + type: PrGovernanceType! connection: ScmConnection configuration: PrGovernanceConfiguration insertedAt: DateTime @@ -10959,6 +11004,7 @@ type PrGovernance { "The configuration for a pr governance controller" type PrGovernanceConfiguration { webhook: GovernanceWebhook + serviceNow: GovernanceServiceNow } "The webhook configuration for a pr governance controller" @@ -10966,6 +11012,21 @@ type GovernanceWebhook { url: String! } +"ServiceNow configuration for a pr governance controller" +type GovernanceServiceNow { + "the ServiceNow instance URL" + url: String! + + "the change request model\/type" + changeModel: String + + "ServiceNow API username" + username: String! + + "additional attributes sent with change requests" + attributes: Map +} + type GitRepositoryConnection { pageInfo: PageInfo! edges: [GitRepositoryEdge] diff --git a/test/console/deployments/pr/governance/service_now_test.exs b/test/console/deployments/pr/governance/service_now_test.exs new file mode 100644 index 0000000000..d81a838b4a --- /dev/null +++ b/test/console/deployments/pr/governance/service_now_test.exs @@ -0,0 +1,137 @@ +defmodule Console.Deployments.Pr.Governance.ServiceNowTest do + use Console.DataCase, async: true + use Mimic + alias Console.Deployments.Pr.Governance.Impl.ServiceNow + alias Console.Schema.{ScmConnection, PullRequest} + alias Console.AI.Provider + + describe "open/2" do + test "it can create a new service now change and write a comment to the pr" do + gov = governance() + pr = insert(:pull_request, governance: gov, status: :open) + + expect(Console.ServiceNow.Client, :create_change, fn _, _, [chg_model: "Standard"] -> + {:ok, %Console.ServiceNow.Change{sys_id: "1234567890", number: "CHG1234567890", state: -4}} + end) + + expect(Console.Deployments.Pr.Dispatcher, :review, fn %ScmConnection{}, %PullRequest{}, _ -> + {:ok, "1234567890"} + end) + + expect(Provider, :simple_tool_call, fn _, Console.AI.Tools.ServiceNow, _ -> + {:ok, %Console.AI.Tools.ServiceNow{ + short_description: "test", + description: "test", + implementation_plan: "test", + backout_plan: "test", + test_plan: "test" + }} + end) + + assert ServiceNow.open(gov, pr) == {:ok, %{ + "id" => "1234567890", + "number" => "CHG1234567890", + "state" => -4, + "comment_id" => "1234567890" + }} + end + end + + describe "#confirm/2" do + test "it will query service now and progress the change to the implement state" do + gov = governance() + pr = insert(:pull_request, + governance: gov, + status: :open, + governance_state: %{"id" => "1234567890", "state" => -2, "number" => "CHG1234567890", "comment_id" => "1234567890"} + ) + + expect(Console.ServiceNow.Client, :get_change, fn _, "1234567890" -> + {:ok, %Console.ServiceNow.Change{state: -2, sys_id: "1234567890", number: "CHG1234567890"}} + end) + + expect(Console.ServiceNow.Client, :update_change, fn _, "1234567890", %{state: -1} -> + {:ok, %Console.ServiceNow.Change{state: -1, sys_id: "1234567890", number: "CHG1234567890"}} + end) + + expect(Console.Deployments.Pr.Dispatcher, :review, fn %ScmConnection{}, %PullRequest{comment_id: "1234567890"}, _ -> + {:ok, "1234567890"} + end) + + assert ServiceNow.confirm(gov, pr) == {:ok, %{ + "id" => "1234567890", + "number" => "CHG1234567890", + "state" => -1, + "comment_id" => "1234567890" + }} + end + end + + describe "#close/2" do + test "it will close the change with success if the pr is merged" do + gov = governance() + pr = insert(:pull_request, + governance: gov, + status: :merged, + governance_state: %{"id" => "1234567890", "state" => -1, "number" => "CHG1234567890", "comment_id" => "1234567890"} + ) + + expect(Console.ServiceNow.Client, :update_change, fn _, "1234567890", %{state: 0} -> + {:ok, %Console.ServiceNow.Change{state: 0, sys_id: "1234567890", number: "CHG1234567890"}} + end) + + expect(Console.ServiceNow.Client, :update_change, fn _, "1234567890", %{state: 3, close_code: "successful", close_notes: "Pull request merged"} -> + {:ok, %Console.ServiceNow.Change{state: 3, sys_id: "1234567890", number: "CHG1234567890"}} + end) + + expect(Console.Deployments.Pr.Dispatcher, :review, fn %ScmConnection{}, %PullRequest{comment_id: "1234567890"}, _ -> + {:ok, "1234567890"} + end) + + assert ServiceNow.close(gov, pr) == {:ok, %{ + "id" => "1234567890", + "number" => "CHG1234567890", + "state" => 3, + "comment_id" => "1234567890" + }} + end + + test "if the pr is closed, it will cancel the change" do + gov = governance() + pr = insert(:pull_request, + governance: gov, + status: :closed, + governance_state: %{"id" => "1234567890", "state" => -1, "number" => "CHG1234567890", "comment_id" => "1234567890"} + ) + + expect(Console.ServiceNow.Client, :update_change, fn _, "1234567890", %{state: 4} -> + {:ok, %Console.ServiceNow.Change{state: 4, sys_id: "1234567890", number: "CHG1234567890"}} + end) + + expect(Console.Deployments.Pr.Dispatcher, :review, fn %ScmConnection{}, %PullRequest{comment_id: "1234567890"}, _ -> + {:ok, "1234567890"} + end) + + assert ServiceNow.close(gov, pr) == {:ok, %{ + "id" => "1234567890", + "number" => "CHG1234567890", + "state" => 4, + "comment_id" => "1234567890" + }} + end + end + + defp governance() do + insert(:pr_governance, + connection: insert(:scm_connection, type: :github), + configuration: %{ + service_now: %{ + url: "https://service-now.url", + username: "username", + password: "password", + change_model: "Standard" + } + } + ) + end +end diff --git a/test/console/deployments/pubsub/governance_test.exs b/test/console/deployments/pubsub/governance_test.exs index 2d2d176722..944809b053 100644 --- a/test/console/deployments/pubsub/governance_test.exs +++ b/test/console/deployments/pubsub/governance_test.exs @@ -22,6 +22,21 @@ defmodule Console.Deployments.PubSub.GovernanceTest do end describe "PullRequestUpdated" do + test "it will call the open callback if the governance has changed" do + governance = insert(:pr_governance, configuration: %{webhook: %{url: "https://webhook.url"}}) + pr = insert(:pull_request, governance: governance, status: :open, governance_changed: true) + + expect(HTTPoison, :post, fn "https://webhook.url/v1/open", _, _ -> + state = Jason.encode!(%{service_now_id: "1234567890"}) + {:ok, %HTTPoison.Response{status_code: 200, body: state}} + end) + + event = %PubSub.PullRequestUpdated{item: pr} + :ok = Governance.handle_event(event) + + assert refetch(pr).governance_state == %{"service_now_id" => "1234567890"} + end + test "it can handle a pull request updated event" do governance = insert(:pr_governance, configuration: %{webhook: %{url: "https://webhook.url"}}) pr = insert(:pull_request, governance: governance, status: :merged) diff --git a/test/console/graphql/mutations/deployments/git_mutations_test.exs b/test/console/graphql/mutations/deployments/git_mutations_test.exs index 4354cef8b3..b5dc767b57 100644 --- a/test/console/graphql/mutations/deployments/git_mutations_test.exs +++ b/test/console/graphql/mutations/deployments/git_mutations_test.exs @@ -501,6 +501,7 @@ defmodule Console.GraphQl.Deployments.GitMutationsTest do } """, %{ "attrs" => %{ + "type" => "WEBHOOK", "name" => "governance", "connectionId" => conn.id, "configuration" => %{ diff --git a/test/test_helper.exs b/test/test_helper.exs index be2f165d58..34a624277b 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -53,6 +53,7 @@ Mimic.copy(Console.AI.Workbench.Subagents.Plan) Mimic.copy(Console.AI.Workbench.Subagents.Infrastructure) Mimic.copy(Console.AI.Workbench.Subagents.Integration) Mimic.copy(Console.AI.Tools.Workbench.Http) +Mimic.copy(Console.ServiceNow.Client) ExUnit.start() Ecto.Adapters.SQL.Sandbox.mode(Console.Repo, :manual)