From 7ce3f007b06d6fe7f33085326c30851ddb7fe58c Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Tue, 15 Aug 2023 17:19:06 +0000 Subject: [PATCH] Add functions to (un)install webhooks for orgs and repos Signed-off-by: Gabriel Adrian Samfira --- apiserver/controllers/controllers.go | 2 +- apiserver/controllers/organizations.go | 93 ++++++++ apiserver/controllers/repositories.go | 93 ++++++++ apiserver/routers/routers.go | 14 ++ apiserver/swagger-models.yaml | 7 + apiserver/swagger.yaml | 101 +++++++++ cmd/garm-cli/cmd/controller_info.go | 18 +- cmd/garm-cli/cmd/enterprise.go | 19 +- cmd/garm-cli/cmd/organization.go | 19 +- cmd/garm-cli/cmd/repository.go | 23 +- params/params.go | 62 +++--- runner/common/mocks/GithubClient.go | 262 +++++++++++++++++++++++ runner/common/mocks/OrganizationHooks.go | 160 ++++++++++++++ runner/common/mocks/PoolManager.go | 30 +++ runner/common/mocks/RepositoryHooks.go | 160 ++++++++++++++ runner/common/pool.go | 4 +- runner/common/util.go | 17 ++ runner/organizations.go | 42 ++++ runner/pool/enterprise.go | 16 +- runner/pool/interfaces.go | 7 +- runner/pool/organization.go | 70 +++++- runner/pool/pool.go | 45 +++- runner/pool/repository.go | 64 +++++- runner/repositories.go | 42 ++++ runner/runner.go | 30 ++- testdata/config.toml | 2 + util/util.go | 45 +++- 27 files changed, 1366 insertions(+), 81 deletions(-) create mode 100644 runner/common/mocks/OrganizationHooks.go create mode 100644 runner/common/mocks/RepositoryHooks.go diff --git a/apiserver/controllers/controllers.go b/apiserver/controllers/controllers.go index d19b82b4..5ae3f571 100644 --- a/apiserver/controllers/controllers.go +++ b/apiserver/controllers/controllers.go @@ -48,7 +48,7 @@ func NewAPIController(r *runner.Runner, authenticator *auth.Authenticator, hub * ReadBufferSize: 1024, WriteBufferSize: 16384, }, - controllerID: controllerInfo.ShortControllerID(), + controllerID: controllerInfo.ControllerID.String(), }, nil } diff --git a/apiserver/controllers/organizations.go b/apiserver/controllers/organizations.go index d03d1de4..4b26ada3 100644 --- a/apiserver/controllers/organizations.go +++ b/apiserver/controllers/organizations.go @@ -479,3 +479,96 @@ func (a *APIController) UpdateOrgPoolHandler(w http.ResponseWriter, r *http.Requ log.Printf("failed to encode response: %q", err) } } + +// swagger:route POST /organizations/{orgID}/webhook organizations hooks InstallOrgWebhook +// +// Install the GARM webhook for an organization. The secret configured on the organization will +// be used to validate the requests. +// +// Parameters: +// + name: orgID +// description: Organization ID. +// type: string +// in: path +// required: true +// +// + name: Body +// description: Parameters used when creating the organization webhook. +// type: InstallWebhookParams +// in: body +// required: true +// +// Responses: +// 200: {} +// default: APIErrorResponse +func (a *APIController) InstallOrgWebhookHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + orgID, orgOk := vars["orgID"] + if !orgOk { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(params.APIErrorResponse{ + Error: "Bad Request", + Details: "No org ID specified", + }); err != nil { + log.Printf("failed to encode response: %q", err) + } + return + } + + var hookParam runnerParams.InstallWebhookParams + if err := json.NewDecoder(r.Body).Decode(&hookParam); err != nil { + log.Printf("failed to decode: %s", err) + handleError(w, gErrors.ErrBadRequest) + return + } + + if err := a.r.InstallOrgWebhook(ctx, orgID, hookParam); err != nil { + log.Printf("installing webhook: %s", err) + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} + +// swagger:route DELETE /organizations/{orgID}/webhook organizations hooks UninstallOrgWebhook +// +// Uninstall organization webhook. +// +// Parameters: +// + name: orgID +// description: Organization ID. +// type: string +// in: path +// required: true +// +// Responses: +// default: APIErrorResponse +func (a *APIController) UninstallOrgWebhookHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + orgID, orgOk := vars["orgID"] + if !orgOk { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(params.APIErrorResponse{ + Error: "Bad Request", + Details: "No org ID specified", + }); err != nil { + log.Printf("failed to encode response: %q", err) + } + return + } + + if err := a.r.UninstallOrgWebhook(ctx, orgID); err != nil { + log.Printf("removing webhook: %s", err) + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} diff --git a/apiserver/controllers/repositories.go b/apiserver/controllers/repositories.go index 9aae826f..cdbb4285 100644 --- a/apiserver/controllers/repositories.go +++ b/apiserver/controllers/repositories.go @@ -479,3 +479,96 @@ func (a *APIController) UpdateRepoPoolHandler(w http.ResponseWriter, r *http.Req log.Printf("failed to encode response: %q", err) } } + +// swagger:route POST /repositories/{repoID}/webhook repositories hooks InstallRepoWebhook +// +// Install the GARM webhook for an organization. The secret configured on the organization will +// be used to validate the requests. +// +// Parameters: +// + name: repoID +// description: Repository ID. +// type: string +// in: path +// required: true +// +// + name: Body +// description: Parameters used when creating the repository webhook. +// type: InstallWebhookParams +// in: body +// required: true +// +// Responses: +// 200: {} +// default: APIErrorResponse +func (a *APIController) InstallRepoWebhookHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + repoID, orgOk := vars["repoID"] + if !orgOk { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(params.APIErrorResponse{ + Error: "Bad Request", + Details: "No repository ID specified", + }); err != nil { + log.Printf("failed to encode response: %q", err) + } + return + } + + var hookParam runnerParams.InstallWebhookParams + if err := json.NewDecoder(r.Body).Decode(&hookParam); err != nil { + log.Printf("failed to decode: %s", err) + handleError(w, gErrors.ErrBadRequest) + return + } + + if err := a.r.InstallRepoWebhook(ctx, repoID, hookParam); err != nil { + log.Printf("installing webhook: %s", err) + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} + +// swagger:route DELETE /repositories/{repoID}/webhook repositories hooks UninstallRepoWebhook +// +// Uninstall organization webhook. +// +// Parameters: +// + name: repoID +// description: Repository ID. +// type: string +// in: path +// required: true +// +// Responses: +// default: APIErrorResponse +func (a *APIController) UninstallRepoWebhookHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + repoID, orgOk := vars["repoID"] + if !orgOk { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(params.APIErrorResponse{ + Error: "Bad Request", + Details: "No repository ID specified", + }); err != nil { + log.Printf("failed to encode response: %q", err) + } + return + } + + if err := a.r.UninstallRepoWebhook(ctx, repoID); err != nil { + log.Printf("removing webhook: %s", err) + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} diff --git a/apiserver/routers/routers.go b/apiserver/routers/routers.go index 2a93980c..8734e5f4 100644 --- a/apiserver/routers/routers.go +++ b/apiserver/routers/routers.go @@ -203,6 +203,13 @@ func NewAPIRouter(han *controllers.APIController, logWriter io.Writer, authMiddl apiRouter.Handle("/repositories/", http.HandlerFunc(han.CreateRepoHandler)).Methods("POST", "OPTIONS") apiRouter.Handle("/repositories", http.HandlerFunc(han.CreateRepoHandler)).Methods("POST", "OPTIONS") + // Install Webhook + apiRouter.Handle("/repositories/{repoID}/webhook/", http.HandlerFunc(han.InstallRepoWebhookHandler)).Methods("POST", "OPTIONS") + apiRouter.Handle("/repositories/{repoID}/webhook", http.HandlerFunc(han.InstallRepoWebhookHandler)).Methods("POST", "OPTIONS") + // Uninstall Webhook + apiRouter.Handle("/repositories/{repoID}/webhook/", http.HandlerFunc(han.UninstallRepoWebhookHandler)).Methods("DELETE", "OPTIONS") + apiRouter.Handle("/repositories/{repoID}/webhook", http.HandlerFunc(han.UninstallRepoWebhookHandler)).Methods("DELETE", "OPTIONS") + ///////////////////////////// // Organizations and pools // ///////////////////////////// @@ -242,6 +249,13 @@ func NewAPIRouter(han *controllers.APIController, logWriter io.Writer, authMiddl apiRouter.Handle("/organizations/", http.HandlerFunc(han.CreateOrgHandler)).Methods("POST", "OPTIONS") apiRouter.Handle("/organizations", http.HandlerFunc(han.CreateOrgHandler)).Methods("POST", "OPTIONS") + // Install Webhook + apiRouter.Handle("/organizations/{orgID}/webhook/", http.HandlerFunc(han.InstallOrgWebhookHandler)).Methods("POST", "OPTIONS") + apiRouter.Handle("/organizations/{orgID}/webhook", http.HandlerFunc(han.InstallOrgWebhookHandler)).Methods("POST", "OPTIONS") + // Uninstall Webhook + apiRouter.Handle("/organizations/{orgID}/webhook/", http.HandlerFunc(han.UninstallOrgWebhookHandler)).Methods("DELETE", "OPTIONS") + apiRouter.Handle("/organizations/{orgID}/webhook", http.HandlerFunc(han.UninstallOrgWebhookHandler)).Methods("DELETE", "OPTIONS") + ///////////////////////////// // Enterprises and pools // ///////////////////////////// diff --git a/apiserver/swagger-models.yaml b/apiserver/swagger-models.yaml index a2ad3474..333bdc70 100644 --- a/apiserver/swagger-models.yaml +++ b/apiserver/swagger-models.yaml @@ -15,6 +15,13 @@ definitions: import: package: github.com/cloudbase/garm/params alias: garm_params + InstallWebhookParams: + type: object + x-go-type: + type: InstallWebhookParams + import: + package: github.com/cloudbase/garm/params + alias: garm_params NewUserParams: type: object x-go-type: diff --git a/apiserver/swagger.yaml b/apiserver/swagger.yaml index 5851740c..441abe43 100644 --- a/apiserver/swagger.yaml +++ b/apiserver/swagger.yaml @@ -76,6 +76,13 @@ definitions: alias: garm_params package: github.com/cloudbase/garm/params type: GithubCredentials + InstallWebhookParams: + type: object + x-go-type: + import: + alias: garm_params + package: github.com/cloudbase/garm/params + type: InstallWebhookParams Instance: type: object x-go-type: @@ -900,6 +907,53 @@ paths: tags: - organizations - pools + /organizations/{orgID}/webhook: + delete: + operationId: UninstallOrgWebhook + parameters: + - description: Organization ID. + in: path + name: orgID + required: true + type: string + responses: + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Uninstall organization webhook. + tags: + - organizations + - hooks + post: + description: |- + Install the GARM webhook for an organization. The secret configured on the organization will + be used to validate the requests. + operationId: InstallOrgWebhook + parameters: + - description: Organization ID. + in: path + name: orgID + required: true + type: string + - description: Parameters used when creating the organization webhook. + in: body + name: Body + required: true + schema: + $ref: '#/definitions/InstallWebhookParams' + description: Parameters used when creating the organization webhook. + type: object + responses: + "200": + $ref: '#/responses/%7B%7D' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + tags: + - organizations + - hooks /pools: get: operationId: ListPools @@ -1275,6 +1329,53 @@ paths: tags: - repositories - pools + /repositories/{repoID}/webhook: + delete: + operationId: UninstallRepoWebhook + parameters: + - description: Repository ID. + in: path + name: repoID + required: true + type: string + responses: + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Uninstall organization webhook. + tags: + - repositories + - hooks + post: + description: |- + Install the GARM webhook for an organization. The secret configured on the organization will + be used to validate the requests. + operationId: InstallRepoWebhook + parameters: + - description: Repository ID. + in: path + name: repoID + required: true + type: string + - description: Parameters used when creating the repository webhook. + in: body + name: Body + required: true + schema: + $ref: '#/definitions/InstallWebhookParams' + description: Parameters used when creating the repository webhook. + type: object + responses: + "200": + $ref: '#/responses/%7B%7D' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + tags: + - repositories + - hooks produces: - application/json security: diff --git a/cmd/garm-cli/cmd/controller_info.go b/cmd/garm-cli/cmd/controller_info.go index 038fe8fe..8a417799 100644 --- a/cmd/garm-cli/cmd/controller_info.go +++ b/cmd/garm-cli/cmd/controller_info.go @@ -46,22 +46,32 @@ var infoShowCmd = &cobra.Command{ if err != nil { return err } - formatInfo(response.Payload) - return nil + return formatInfo(response.Payload) }, } -func formatInfo(info params.ControllerInfo) { +func formatInfo(info params.ControllerInfo) error { t := table.NewWriter() - header := table.Row{"Field", "Value"} + if info.WebhookURL == "" { + info.WebhookURL = "N/A" + } + + if info.ControllerWebhookURL == "" { + info.ControllerWebhookURL = "N/A" + } + t.AppendHeader(header) t.AppendRow(table.Row{"Controller ID", info.ControllerID}) t.AppendRow(table.Row{"Hostname", info.Hostname}) t.AppendRow(table.Row{"Metadata URL", info.MetadataURL}) t.AppendRow(table.Row{"Callback URL", info.CallbackURL}) + t.AppendRow(table.Row{"Webhook URL Base", info.WebhookURL}) + t.AppendRow(table.Row{"Controller Webhook URL", info.ControllerWebhookURL}) fmt.Println(t.Render()) + + return nil } func init() { diff --git a/cmd/garm-cli/cmd/enterprise.go b/cmd/garm-cli/cmd/enterprise.go index c63e0c44..b9ec7fe2 100644 --- a/cmd/garm-cli/cmd/enterprise.go +++ b/cmd/garm-cli/cmd/enterprise.go @@ -17,6 +17,7 @@ package cmd import ( "fmt" + "github.com/cloudbase/garm-provider-common/util" apiClientEnterprises "github.com/cloudbase/garm/client/enterprises" "github.com/cloudbase/garm/params" @@ -25,9 +26,10 @@ import ( ) var ( - enterpriseName string - enterpriseWebhookSecret string - enterpriseCreds string + enterpriseName string + enterpriseWebhookSecret string + enterpriseCreds string + enterpriseRandomWebhookSecret bool ) // enterpriseCmd represents the enterprise command @@ -55,6 +57,14 @@ var enterpriseAddCmd = &cobra.Command{ return errNeedsInitError } + if enterpriseRandomWebhookSecret { + secret, err := util.GetRandomString(32) + if err != nil { + return err + } + enterpriseWebhookSecret = secret + } + newEnterpriseReq := apiClientEnterprises.NewCreateEnterpriseParams() newEnterpriseReq.Body = params.CreateEnterpriseParams{ Name: enterpriseName, @@ -179,6 +189,9 @@ func init() { enterpriseAddCmd.Flags().StringVar(&enterpriseName, "name", "", "The name of the enterprise") enterpriseAddCmd.Flags().StringVar(&enterpriseWebhookSecret, "webhook-secret", "", "The webhook secret for this enterprise") enterpriseAddCmd.Flags().StringVar(&enterpriseCreds, "credentials", "", "Credentials name. See credentials list.") + enterpriseAddCmd.Flags().BoolVar(&enterpriseRandomWebhookSecret, "random-webhook-secret", false, "Generate a random webhook secret for this organization.") + enterpriseAddCmd.MarkFlagsMutuallyExclusive("webhook-secret", "random-webhook-secret") + enterpriseAddCmd.MarkFlagRequired("credentials") //nolint enterpriseAddCmd.MarkFlagRequired("name") //nolint enterpriseUpdateCmd.Flags().StringVar(&enterpriseWebhookSecret, "webhook-secret", "", "The webhook secret for this enterprise") diff --git a/cmd/garm-cli/cmd/organization.go b/cmd/garm-cli/cmd/organization.go index 56de989b..6d0ead6f 100644 --- a/cmd/garm-cli/cmd/organization.go +++ b/cmd/garm-cli/cmd/organization.go @@ -17,6 +17,7 @@ package cmd import ( "fmt" + "github.com/cloudbase/garm-provider-common/util" apiClientOrgs "github.com/cloudbase/garm/client/organizations" "github.com/cloudbase/garm/params" @@ -25,9 +26,10 @@ import ( ) var ( - orgName string - orgWebhookSecret string - orgCreds string + orgName string + orgWebhookSecret string + orgCreds string + orgRandomWebhookSecret bool ) // organizationCmd represents the organization command @@ -55,6 +57,14 @@ var orgAddCmd = &cobra.Command{ return errNeedsInitError } + if orgRandomWebhookSecret { + secret, err := util.GetRandomString(32) + if err != nil { + return err + } + orgWebhookSecret = secret + } + newOrgReq := apiClientOrgs.NewCreateOrgParams() newOrgReq.Body = params.CreateOrgParams{ Name: orgName, @@ -179,6 +189,9 @@ func init() { orgAddCmd.Flags().StringVar(&orgName, "name", "", "The name of the organization") orgAddCmd.Flags().StringVar(&orgWebhookSecret, "webhook-secret", "", "The webhook secret for this organization") orgAddCmd.Flags().StringVar(&orgCreds, "credentials", "", "Credentials name. See credentials list.") + orgAddCmd.Flags().BoolVar(&orgRandomWebhookSecret, "random-webhook-secret", false, "Generate a random webhook secret for this organization.") + orgAddCmd.MarkFlagsMutuallyExclusive("webhook-secret", "random-webhook-secret") + orgAddCmd.MarkFlagRequired("credentials") //nolint orgAddCmd.MarkFlagRequired("name") //nolint orgUpdateCmd.Flags().StringVar(&orgWebhookSecret, "webhook-secret", "", "The webhook secret for this organization") diff --git a/cmd/garm-cli/cmd/repository.go b/cmd/garm-cli/cmd/repository.go index 8466b318..ecad44f7 100644 --- a/cmd/garm-cli/cmd/repository.go +++ b/cmd/garm-cli/cmd/repository.go @@ -17,6 +17,7 @@ package cmd import ( "fmt" + "github.com/cloudbase/garm-provider-common/util" apiClientRepos "github.com/cloudbase/garm/client/repositories" "github.com/cloudbase/garm/params" @@ -25,10 +26,11 @@ import ( ) var ( - repoOwner string - repoName string - repoWebhookSecret string - repoCreds string + repoOwner string + repoName string + repoWebhookSecret string + repoCreds string + randomWebhookSecret bool ) // repositoryCmd represents the repository command @@ -56,6 +58,14 @@ var repoAddCmd = &cobra.Command{ return errNeedsInitError } + if randomWebhookSecret { + secret, err := util.GetRandomString(32) + if err != nil { + return err + } + repoWebhookSecret = secret + } + newRepoReq := apiClientRepos.NewCreateRepoParams() newRepoReq.Body = params.CreateRepoParams{ Owner: repoOwner, @@ -183,10 +193,13 @@ func init() { repoAddCmd.Flags().StringVar(&repoName, "name", "", "The name of the repository") repoAddCmd.Flags().StringVar(&repoWebhookSecret, "webhook-secret", "", "The webhook secret for this repository") repoAddCmd.Flags().StringVar(&repoCreds, "credentials", "", "Credentials name. See credentials list.") + repoAddCmd.Flags().BoolVar(&randomWebhookSecret, "random-webhook-secret", false, "Generate a random webhook secret for this repository.") + repoAddCmd.MarkFlagsMutuallyExclusive("webhook-secret", "random-webhook-secret") + repoAddCmd.MarkFlagRequired("credentials") //nolint repoAddCmd.MarkFlagRequired("owner") //nolint repoAddCmd.MarkFlagRequired("name") //nolint - repoUpdateCmd.Flags().StringVar(&repoWebhookSecret, "webhook-secret", "", "The webhook secret for this repository") + repoUpdateCmd.Flags().StringVar(&repoWebhookSecret, "webhook-secret", "", "The webhook secret for this repository. If you update this secret, you will have to manually update the secret in GitHub as well.") repoUpdateCmd.Flags().StringVar(&repoCreds, "credentials", "", "Credentials name. See credentials list.") repositoryCmd.AddCommand( diff --git a/params/params.go b/params/params.go index 5eb04e89..6deeb420 100644 --- a/params/params.go +++ b/params/params.go @@ -16,7 +16,6 @@ package params import ( "encoding/json" - "math/big" "time" commonParams "github.com/cloudbase/garm-provider-common/params" @@ -28,12 +27,13 @@ import ( ) type ( - PoolType string - EventType string - EventLevel string - ProviderType string - JobStatus string - RunnerStatus string + PoolType string + EventType string + EventLevel string + ProviderType string + JobStatus string + RunnerStatus string + WebhookEndpointType string ) const ( @@ -43,6 +43,16 @@ const ( ExternalProvider ProviderType = "external" ) +const ( + // WebhookEndpointDirect instructs garm that it should attempt to create a webhook + // in the target entity, using the callback URL defined in the config as a target. + WebhookEndpointDirect WebhookEndpointType = "direct" + // WebhookEndpointTunnel instructs garm that it should attempt to create a webhook + // in the target entity, using the tunnel URL as a base for the webhook URL. + // This is defined for future use. + WebhookEndpointTunnel WebhookEndpointType = "tunnel" +) + const ( JobStatusQueued JobStatus = "queued" JobStatusInProgress JobStatus = "in_progress" @@ -286,11 +296,14 @@ func (p *Pool) PoolType() PoolType { type Pools []Pool type Internal struct { - OAuth2Token string `json:"oauth2"` - ControllerID string `json:"controller_id"` - InstanceCallbackURL string `json:"instance_callback_url"` - InstanceMetadataURL string `json:"instance_metadata_url"` - JWTSecret string `json:"jwt_secret"` + OAuth2Token string `json:"oauth2"` + ControllerID string `json:"controller_id"` + InstanceCallbackURL string `json:"instance_callback_url"` + InstanceMetadataURL string `json:"instance_metadata_url"` + BaseWebhookURL string `json:"base_webhook_url"` + ControllerWebhookURL string `json:"controller_webhook_url"` + + JWTSecret string `json:"jwt_secret"` // GithubCredentialsDetails contains all info about the credentials, except the // token, which is added above. GithubCredentialsDetails GithubCredentials `json:"gh_creds_details"` @@ -380,20 +393,12 @@ type JWTResponse struct { } type ControllerInfo struct { - ControllerID uuid.UUID `json:"controller_id"` - Hostname string `json:"hostname"` - MetadataURL string `json:"metadata_url"` - CallbackURL string `json:"callback_url"` -} - -func (c ControllerInfo) ShortControllerID() string { - var i big.Int - i.SetBytes(c.ControllerID[:]) - id := i.Text(62) - if id == "0" { - return "" - } - return id + ControllerID uuid.UUID `json:"controller_id"` + Hostname string `json:"hostname"` + MetadataURL string `json:"metadata_url"` + CallbackURL string `json:"callback_url"` + WebhookURL string `json:"webhook_url"` + ControllerWebhookURL string `json:"controller_webhook_url"` } type GithubCredentials struct { @@ -495,3 +500,8 @@ type Job struct { // used by swagger client generated code type Jobs []Job + +type InstallWebhookParams struct { + WebhookEndpointType WebhookEndpointType `json:"webhook_endpoint_type"` + InsecureSSL bool `json:"insecure_ssl"` +} diff --git a/runner/common/mocks/GithubClient.go b/runner/common/mocks/GithubClient.go index fa65dcef..ebd442ba 100644 --- a/runner/common/mocks/GithubClient.go +++ b/runner/common/mocks/GithubClient.go @@ -14,6 +14,41 @@ type GithubClient struct { mock.Mock } +// CreateOrgHook provides a mock function with given fields: ctx, org, hook +func (_m *GithubClient) CreateOrgHook(ctx context.Context, org string, hook *github.Hook) (*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, org, hook) + + var r0 *github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, *github.Hook) (*github.Hook, *github.Response, error)); ok { + return rf(ctx, org, hook) + } + if rf, ok := ret.Get(0).(func(context.Context, string, *github.Hook) *github.Hook); ok { + r0 = rf(ctx, org, hook) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, *github.Hook) *github.Response); ok { + r1 = rf(ctx, org, hook) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, *github.Hook) error); ok { + r2 = rf(ctx, org, hook) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + // CreateOrganizationRegistrationToken provides a mock function with given fields: ctx, owner func (_m *GithubClient) CreateOrganizationRegistrationToken(ctx context.Context, owner string) (*github.RegistrationToken, *github.Response, error) { ret := _m.Called(ctx, owner) @@ -84,6 +119,163 @@ func (_m *GithubClient) CreateRegistrationToken(ctx context.Context, owner strin return r0, r1, r2 } +// CreateRepoHook provides a mock function with given fields: ctx, owner, repo, hook +func (_m *GithubClient) CreateRepoHook(ctx context.Context, owner string, repo string, hook *github.Hook) (*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, owner, repo, hook) + + var r0 *github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.Hook) (*github.Hook, *github.Response, error)); ok { + return rf(ctx, owner, repo, hook) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.Hook) *github.Hook); ok { + r0 = rf(ctx, owner, repo, hook) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, *github.Hook) *github.Response); ok { + r1 = rf(ctx, owner, repo, hook) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, string, *github.Hook) error); ok { + r2 = rf(ctx, owner, repo, hook) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// DeleteOrgHook provides a mock function with given fields: ctx, org, id +func (_m *GithubClient) DeleteOrgHook(ctx context.Context, org string, id int64) (*github.Response, error) { + ret := _m.Called(ctx, org, id) + + var r0 *github.Response + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, int64) (*github.Response, error)); ok { + return rf(ctx, org, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string, int64) *github.Response); ok { + r0 = rf(ctx, org, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Response) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, int64) error); ok { + r1 = rf(ctx, org, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DeleteRepoHook provides a mock function with given fields: ctx, owner, repo, id +func (_m *GithubClient) DeleteRepoHook(ctx context.Context, owner string, repo string, id int64) (*github.Response, error) { + ret := _m.Called(ctx, owner, repo, id) + + var r0 *github.Response + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) (*github.Response, error)); ok { + return rf(ctx, owner, repo, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) *github.Response); ok { + r0 = rf(ctx, owner, repo, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Response) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, int64) error); ok { + r1 = rf(ctx, owner, repo, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetOrgHook provides a mock function with given fields: ctx, org, id +func (_m *GithubClient) GetOrgHook(ctx context.Context, org string, id int64) (*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, org, id) + + var r0 *github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, int64) (*github.Hook, *github.Response, error)); ok { + return rf(ctx, org, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string, int64) *github.Hook); ok { + r0 = rf(ctx, org, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, int64) *github.Response); ok { + r1 = rf(ctx, org, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, int64) error); ok { + r2 = rf(ctx, org, id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// GetRepoHook provides a mock function with given fields: ctx, owner, repo, id +func (_m *GithubClient) GetRepoHook(ctx context.Context, owner string, repo string, id int64) (*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, owner, repo, id) + + var r0 *github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) (*github.Hook, *github.Response, error)); ok { + return rf(ctx, owner, repo, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) *github.Hook); ok { + r0 = rf(ctx, owner, repo, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, int64) *github.Response); ok { + r1 = rf(ctx, owner, repo, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, string, int64) error); ok { + r2 = rf(ctx, owner, repo, id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + // GetWorkflowJobByID provides a mock function with given fields: ctx, owner, repo, jobID func (_m *GithubClient) GetWorkflowJobByID(ctx context.Context, owner string, repo string, jobID int64) (*github.WorkflowJob, *github.Response, error) { ret := _m.Called(ctx, owner, repo, jobID) @@ -119,6 +311,41 @@ func (_m *GithubClient) GetWorkflowJobByID(ctx context.Context, owner string, re return r0, r1, r2 } +// ListOrgHooks provides a mock function with given fields: ctx, org, opts +func (_m *GithubClient) ListOrgHooks(ctx context.Context, org string, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, org, opts) + + var r0 []*github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, *github.ListOptions) ([]*github.Hook, *github.Response, error)); ok { + return rf(ctx, org, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, string, *github.ListOptions) []*github.Hook); ok { + r0 = rf(ctx, org, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, *github.ListOptions) *github.Response); ok { + r1 = rf(ctx, org, opts) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, *github.ListOptions) error); ok { + r2 = rf(ctx, org, opts) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + // ListOrganizationRunnerApplicationDownloads provides a mock function with given fields: ctx, owner func (_m *GithubClient) ListOrganizationRunnerApplicationDownloads(ctx context.Context, owner string) ([]*github.RunnerApplicationDownload, *github.Response, error) { ret := _m.Called(ctx, owner) @@ -189,6 +416,41 @@ func (_m *GithubClient) ListOrganizationRunners(ctx context.Context, owner strin return r0, r1, r2 } +// ListRepoHooks provides a mock function with given fields: ctx, owner, repo, opts +func (_m *GithubClient) ListRepoHooks(ctx context.Context, owner string, repo string, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, owner, repo, opts) + + var r0 []*github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.ListOptions) ([]*github.Hook, *github.Response, error)); ok { + return rf(ctx, owner, repo, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.ListOptions) []*github.Hook); ok { + r0 = rf(ctx, owner, repo, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, *github.ListOptions) *github.Response); ok { + r1 = rf(ctx, owner, repo, opts) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, string, *github.ListOptions) error); ok { + r2 = rf(ctx, owner, repo, opts) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + // ListRunnerApplicationDownloads provides a mock function with given fields: ctx, owner, repo func (_m *GithubClient) ListRunnerApplicationDownloads(ctx context.Context, owner string, repo string) ([]*github.RunnerApplicationDownload, *github.Response, error) { ret := _m.Called(ctx, owner, repo) diff --git a/runner/common/mocks/OrganizationHooks.go b/runner/common/mocks/OrganizationHooks.go new file mode 100644 index 00000000..4d891f0b --- /dev/null +++ b/runner/common/mocks/OrganizationHooks.go @@ -0,0 +1,160 @@ +// Code generated by mockery v0.0.0-dev. DO NOT EDIT. + +package mocks + +import ( + context "context" + + github "github.com/google/go-github/v53/github" + mock "github.com/stretchr/testify/mock" +) + +// OrganizationHooks is an autogenerated mock type for the OrganizationHooks type +type OrganizationHooks struct { + mock.Mock +} + +// CreateOrgHook provides a mock function with given fields: ctx, org, hook +func (_m *OrganizationHooks) CreateOrgHook(ctx context.Context, org string, hook *github.Hook) (*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, org, hook) + + var r0 *github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, *github.Hook) (*github.Hook, *github.Response, error)); ok { + return rf(ctx, org, hook) + } + if rf, ok := ret.Get(0).(func(context.Context, string, *github.Hook) *github.Hook); ok { + r0 = rf(ctx, org, hook) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, *github.Hook) *github.Response); ok { + r1 = rf(ctx, org, hook) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, *github.Hook) error); ok { + r2 = rf(ctx, org, hook) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// DeleteOrgHook provides a mock function with given fields: ctx, org, id +func (_m *OrganizationHooks) DeleteOrgHook(ctx context.Context, org string, id int64) (*github.Response, error) { + ret := _m.Called(ctx, org, id) + + var r0 *github.Response + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, int64) (*github.Response, error)); ok { + return rf(ctx, org, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string, int64) *github.Response); ok { + r0 = rf(ctx, org, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Response) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, int64) error); ok { + r1 = rf(ctx, org, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetOrgHook provides a mock function with given fields: ctx, org, id +func (_m *OrganizationHooks) GetOrgHook(ctx context.Context, org string, id int64) (*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, org, id) + + var r0 *github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, int64) (*github.Hook, *github.Response, error)); ok { + return rf(ctx, org, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string, int64) *github.Hook); ok { + r0 = rf(ctx, org, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, int64) *github.Response); ok { + r1 = rf(ctx, org, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, int64) error); ok { + r2 = rf(ctx, org, id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ListOrgHooks provides a mock function with given fields: ctx, org, opts +func (_m *OrganizationHooks) ListOrgHooks(ctx context.Context, org string, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, org, opts) + + var r0 []*github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, *github.ListOptions) ([]*github.Hook, *github.Response, error)); ok { + return rf(ctx, org, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, string, *github.ListOptions) []*github.Hook); ok { + r0 = rf(ctx, org, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, *github.ListOptions) *github.Response); ok { + r1 = rf(ctx, org, opts) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, *github.ListOptions) error); ok { + r2 = rf(ctx, org, opts) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewOrganizationHooks creates a new instance of OrganizationHooks. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOrganizationHooks(t interface { + mock.TestingT + Cleanup(func()) +}) *OrganizationHooks { + mock := &OrganizationHooks{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/runner/common/mocks/PoolManager.go b/runner/common/mocks/PoolManager.go index e817407c..f5a43868 100644 --- a/runner/common/mocks/PoolManager.go +++ b/runner/common/mocks/PoolManager.go @@ -3,6 +3,8 @@ package mocks import ( + context "context" + params "github.com/cloudbase/garm/params" mock "github.com/stretchr/testify/mock" ) @@ -78,6 +80,20 @@ func (_m *PoolManager) ID() string { return r0 } +// InstallWebhook provides a mock function with given fields: ctx, param +func (_m *PoolManager) InstallWebhook(ctx context.Context, param params.InstallWebhookParams) error { + ret := _m.Called(ctx, param) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, params.InstallWebhookParams) error); ok { + r0 = rf(ctx, param) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // RefreshState provides a mock function with given fields: param func (_m *PoolManager) RefreshState(param params.UpdatePoolStateParams) error { ret := _m.Called(param) @@ -134,6 +150,20 @@ func (_m *PoolManager) Stop() error { return r0 } +// UninstallWebhook provides a mock function with given fields: ctx +func (_m *PoolManager) UninstallWebhook(ctx context.Context) error { + ret := _m.Called(ctx) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Wait provides a mock function with given fields: func (_m *PoolManager) Wait() error { ret := _m.Called() diff --git a/runner/common/mocks/RepositoryHooks.go b/runner/common/mocks/RepositoryHooks.go new file mode 100644 index 00000000..ab9f1496 --- /dev/null +++ b/runner/common/mocks/RepositoryHooks.go @@ -0,0 +1,160 @@ +// Code generated by mockery v0.0.0-dev. DO NOT EDIT. + +package mocks + +import ( + context "context" + + github "github.com/google/go-github/v53/github" + mock "github.com/stretchr/testify/mock" +) + +// RepositoryHooks is an autogenerated mock type for the RepositoryHooks type +type RepositoryHooks struct { + mock.Mock +} + +// CreateRepoHook provides a mock function with given fields: ctx, owner, repo, hook +func (_m *RepositoryHooks) CreateRepoHook(ctx context.Context, owner string, repo string, hook *github.Hook) (*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, owner, repo, hook) + + var r0 *github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.Hook) (*github.Hook, *github.Response, error)); ok { + return rf(ctx, owner, repo, hook) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.Hook) *github.Hook); ok { + r0 = rf(ctx, owner, repo, hook) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, *github.Hook) *github.Response); ok { + r1 = rf(ctx, owner, repo, hook) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, string, *github.Hook) error); ok { + r2 = rf(ctx, owner, repo, hook) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// DeleteRepoHook provides a mock function with given fields: ctx, owner, repo, id +func (_m *RepositoryHooks) DeleteRepoHook(ctx context.Context, owner string, repo string, id int64) (*github.Response, error) { + ret := _m.Called(ctx, owner, repo, id) + + var r0 *github.Response + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) (*github.Response, error)); ok { + return rf(ctx, owner, repo, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) *github.Response); ok { + r0 = rf(ctx, owner, repo, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Response) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, int64) error); ok { + r1 = rf(ctx, owner, repo, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRepoHook provides a mock function with given fields: ctx, owner, repo, id +func (_m *RepositoryHooks) GetRepoHook(ctx context.Context, owner string, repo string, id int64) (*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, owner, repo, id) + + var r0 *github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) (*github.Hook, *github.Response, error)); ok { + return rf(ctx, owner, repo, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) *github.Hook); ok { + r0 = rf(ctx, owner, repo, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, int64) *github.Response); ok { + r1 = rf(ctx, owner, repo, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, string, int64) error); ok { + r2 = rf(ctx, owner, repo, id) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ListRepoHooks provides a mock function with given fields: ctx, owner, repo, opts +func (_m *RepositoryHooks) ListRepoHooks(ctx context.Context, owner string, repo string, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) { + ret := _m.Called(ctx, owner, repo, opts) + + var r0 []*github.Hook + var r1 *github.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.ListOptions) ([]*github.Hook, *github.Response, error)); ok { + return rf(ctx, owner, repo, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, *github.ListOptions) []*github.Hook); ok { + r0 = rf(ctx, owner, repo, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*github.Hook) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, *github.ListOptions) *github.Response); ok { + r1 = rf(ctx, owner, repo, opts) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*github.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, string, *github.ListOptions) error); ok { + r2 = rf(ctx, owner, repo, opts) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewRepositoryHooks creates a new instance of RepositoryHooks. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRepositoryHooks(t interface { + mock.TestingT + Cleanup(func()) +}) *RepositoryHooks { + mock := &RepositoryHooks{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/runner/common/pool.go b/runner/common/pool.go index 39d8d3fa..dd7049b0 100644 --- a/runner/common/pool.go +++ b/runner/common/pool.go @@ -15,6 +15,7 @@ package common import ( + "context" "time" "github.com/cloudbase/garm/params" @@ -43,7 +44,8 @@ type PoolManager interface { HandleWorkflowJob(job params.WorkflowJob) error RefreshState(param params.UpdatePoolStateParams) error ForceDeleteRunner(runner params.Instance) error - // AddPool(ctx context.Context, pool params.Pool) error + InstallWebhook(ctx context.Context, param params.InstallWebhookParams) error + UninstallWebhook(ctx context.Context) error // PoolManager lifecycle functions. Start/stop pool. Start() error diff --git a/runner/common/util.go b/runner/common/util.go index b23295e3..326b4c06 100644 --- a/runner/common/util.go +++ b/runner/common/util.go @@ -6,11 +6,28 @@ import ( "github.com/google/go-github/v53/github" ) +type OrganizationHooks interface { + ListOrgHooks(ctx context.Context, org string, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) + GetOrgHook(ctx context.Context, org string, id int64) (*github.Hook, *github.Response, error) + CreateOrgHook(ctx context.Context, org string, hook *github.Hook) (*github.Hook, *github.Response, error) + DeleteOrgHook(ctx context.Context, org string, id int64) (*github.Response, error) +} + +type RepositoryHooks interface { + ListRepoHooks(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) + GetRepoHook(ctx context.Context, owner, repo string, id int64) (*github.Hook, *github.Response, error) + CreateRepoHook(ctx context.Context, owner, repo string, hook *github.Hook) (*github.Hook, *github.Response, error) + DeleteRepoHook(ctx context.Context, owner, repo string, id int64) (*github.Response, error) +} + // GithubClient that describes the minimum list of functions we need to interact with github. // Allows for easier testing. // //go:generate mockery --all type GithubClient interface { + OrganizationHooks + RepositoryHooks + // GetWorkflowJobByID gets details about a single workflow job. GetWorkflowJobByID(ctx context.Context, owner, repo string, jobID int64) (*github.WorkflowJob, *github.Response, error) // ListRunners lists all runners within a repository. diff --git a/runner/organizations.go b/runner/organizations.go index 61ebcc6a..06fc5ae8 100644 --- a/runner/organizations.go +++ b/runner/organizations.go @@ -339,3 +339,45 @@ func (r *Runner) findOrgPoolManager(name string) (common.PoolManager, error) { } return poolManager, nil } + +func (r *Runner) InstallOrgWebhook(ctx context.Context, orgID string, param params.InstallWebhookParams) error { + if !auth.IsAdmin(ctx) { + return runnerErrors.ErrUnauthorized + } + + org, err := r.store.GetOrganizationByID(ctx, orgID) + if err != nil { + return errors.Wrap(err, "fetching org") + } + + poolMgr, err := r.poolManagerCtrl.GetOrgPoolManager(org) + if err != nil { + return errors.Wrap(err, "fetching pool manager for org") + } + + if err := poolMgr.InstallWebhook(ctx, param); err != nil { + return errors.Wrap(err, "installing webhook") + } + return nil +} + +func (r *Runner) UninstallOrgWebhook(ctx context.Context, orgID string) error { + if !auth.IsAdmin(ctx) { + return runnerErrors.ErrUnauthorized + } + + org, err := r.store.GetOrganizationByID(ctx, orgID) + if err != nil { + return errors.Wrap(err, "fetching org") + } + + poolMgr, err := r.poolManagerCtrl.GetOrgPoolManager(org) + if err != nil { + return errors.Wrap(err, "fetching pool manager for org") + } + + if err := poolMgr.UninstallWebhook(ctx); err != nil { + return errors.Wrap(err, "uninstalling webhook") + } + return nil +} diff --git a/runner/pool/enterprise.go b/runner/pool/enterprise.go index 025c3415..54f7fc89 100644 --- a/runner/pool/enterprise.go +++ b/runner/pool/enterprise.go @@ -197,14 +197,6 @@ func (r *enterprise) WebhookSecret() string { return r.cfg.WebhookSecret } -func (r *enterprise) GetCallbackURL() string { - return r.cfgInternal.InstanceCallbackURL -} - -func (r *enterprise) GetMetadataURL() string { - return r.cfgInternal.InstanceMetadataURL -} - func (r *enterprise) FindPoolByTags(labels []string) (params.Pool, error) { pool, err := r.store.FindEnterprisePoolByTags(r.ctx, r.id, labels) if err != nil { @@ -231,3 +223,11 @@ func (r *enterprise) ValidateOwner(job params.WorkflowJob) error { func (r *enterprise) ID() string { return r.id } + +func (r *enterprise) InstallHook(ctx context.Context, req *github.Hook) error { + return fmt.Errorf("not implemented") +} + +func (r *enterprise) UninstallHook(ctx context.Context, url string) error { + return fmt.Errorf("not implemented") +} diff --git a/runner/pool/interfaces.go b/runner/pool/interfaces.go index e707cdae..c589898a 100644 --- a/runner/pool/interfaces.go +++ b/runner/pool/interfaces.go @@ -15,6 +15,8 @@ package pool import ( + "context" + "github.com/cloudbase/garm/params" "github.com/cloudbase/garm/runner/common" @@ -29,6 +31,9 @@ type poolHelper interface { RemoveGithubRunner(runnerID int64) (*github.Response, error) FetchTools() ([]*github.RunnerApplicationDownload, error) + InstallHook(ctx context.Context, req *github.Hook) error + UninstallHook(ctx context.Context, url string) error + GithubCLI() common.GithubClient FetchDbInstances() ([]params.Instance, error) @@ -36,8 +41,6 @@ type poolHelper interface { GithubURL() string JwtToken() string String() string - GetCallbackURL() string - GetMetadataURL() string FindPoolByTags(labels []string) (params.Pool, error) GetPoolByID(poolID string) (params.Pool, error) ValidateOwner(job params.WorkflowJob) error diff --git a/runner/pool/organization.go b/runner/pool/organization.go index a8a6ed9d..16cf2d19 100644 --- a/runner/pool/organization.go +++ b/runner/pool/organization.go @@ -57,6 +57,12 @@ func NewOrganizationPoolManager(ctx context.Context, cfg params.Organization, cf store: store, providers: providers, controllerID: cfgInternal.ControllerID, + urls: urls{ + webhookURL: cfgInternal.BaseWebhookURL, + callbackURL: cfgInternal.InstanceCallbackURL, + metadataURL: cfgInternal.InstanceMetadataURL, + controllerWebhookURL: cfgInternal.ControllerWebhookURL, + }, quit: make(chan struct{}), helper: helper, credsDetails: cfgInternal.GithubCredentialsDetails, @@ -210,14 +216,6 @@ func (r *organization) WebhookSecret() string { return r.cfg.WebhookSecret } -func (r *organization) GetCallbackURL() string { - return r.cfgInternal.InstanceCallbackURL -} - -func (r *organization) GetMetadataURL() string { - return r.cfgInternal.InstanceMetadataURL -} - func (r *organization) FindPoolByTags(labels []string) (params.Pool, error) { pool, err := r.store.FindOrganizationPoolByTags(r.ctx, r.id, labels) if err != nil { @@ -244,3 +242,59 @@ func (r *organization) ValidateOwner(job params.WorkflowJob) error { func (r *organization) ID() string { return r.id } + +func (r *organization) listHooks(ctx context.Context) ([]*github.Hook, error) { + opts := github.ListOptions{ + PerPage: 100, + } + var allHooks []*github.Hook + for { + hooks, ghResp, err := r.ghcli.ListOrgHooks(ctx, r.cfg.Name, &opts) + if err != nil { + return nil, errors.Wrap(err, "fetching hooks") + } + allHooks = append(allHooks, hooks...) + if ghResp.NextPage == 0 { + break + } + opts.Page = ghResp.NextPage + } + return allHooks, nil +} + +func (r *organization) InstallHook(ctx context.Context, req *github.Hook) error { + allHooks, err := r.listHooks(ctx) + if err != nil { + return errors.Wrap(err, "listing hooks") + } + + for _, hook := range allHooks { + if hook.Config["url"] == req.Config["url"] { + return fmt.Errorf("hook already installed: %w", runnerErrors.ErrBadRequest) + } + } + + _, _, err = r.ghcli.CreateOrgHook(ctx, r.cfg.Name, req) + if err != nil { + return errors.Wrap(err, "creating organization hook") + } + return nil +} + +func (r *organization) UninstallHook(ctx context.Context, url string) error { + allHooks, err := r.listHooks(ctx) + if err != nil { + return errors.Wrap(err, "listing hooks") + } + + for _, hook := range allHooks { + if hook.Config["url"] == url { + _, err = r.ghcli.DeleteOrgHook(ctx, r.cfg.Name, hook.GetID()) + if err != nil { + return errors.Wrap(err, "deleting hook") + } + return nil + } + } + return nil +} diff --git a/runner/pool/pool.go b/runner/pool/pool.go index a2516605..41e224c5 100644 --- a/runner/pool/pool.go +++ b/runner/pool/pool.go @@ -85,6 +85,12 @@ func (k *keyMutex) Delete(key string) { k.muxes.Delete(key) } +type urls struct { + callbackURL string + metadataURL string + webhookURL string + controllerWebhookURL string +} type basePoolManager struct { ctx context.Context controllerID string @@ -101,6 +107,8 @@ type basePoolManager struct { managerIsRunning bool managerErrorReason string + urls urls + mux sync.Mutex wg *sync.WaitGroup keyMux *keyMutex @@ -686,8 +694,8 @@ func (r *basePoolManager) AddRunner(ctx context.Context, poolID string, aditiona RunnerStatus: params.RunnerPending, OSArch: pool.OSArch, OSType: pool.OSType, - CallbackURL: r.helper.GetCallbackURL(), - MetadataURL: r.helper.GetMetadataURL(), + CallbackURL: r.urls.callbackURL, + MetadataURL: r.urls.metadataURL, CreateAttempt: 1, GitHubRunnerGroup: pool.GitHubRunnerGroup, AditionalLabels: aditionalLabels, @@ -1598,3 +1606,36 @@ func (r *basePoolManager) consumeQueuedJobs() error { } return nil } + +func (r *basePoolManager) InstallWebhook(ctx context.Context, param params.InstallWebhookParams) error { + if r.urls.controllerWebhookURL == "" { + return errors.Wrap(runnerErrors.ErrUnprocessable, "controller webhook url is empty") + } + + insecureSSL := "0" + if param.InsecureSSL { + insecureSSL = "1" + } + req := &github.Hook{ + Active: github.Bool(true), + Config: map[string]interface{}{ + "url": r.urls.controllerWebhookURL, + "content_type": "json", + "insecure_ssl": insecureSSL, + "secret": r.WebhookSecret(), + }, + Events: []string{ + "workflow_job", + }, + } + + return r.helper.InstallHook(ctx, req) +} + +func (r *basePoolManager) UninstallWebhook(ctx context.Context) error { + if r.urls.controllerWebhookURL == "" { + return errors.Wrap(runnerErrors.ErrUnprocessable, "controller webhook url is empty") + } + + return r.helper.UninstallHook(ctx, r.urls.controllerWebhookURL) +} diff --git a/runner/pool/repository.go b/runner/pool/repository.go index 86dc5cec..4f60b577 100644 --- a/runner/pool/repository.go +++ b/runner/pool/repository.go @@ -211,14 +211,6 @@ func (r *repository) WebhookSecret() string { return r.cfg.WebhookSecret } -func (r *repository) GetCallbackURL() string { - return r.cfgInternal.InstanceCallbackURL -} - -func (r *repository) GetMetadataURL() string { - return r.cfgInternal.InstanceMetadataURL -} - func (r *repository) FindPoolByTags(labels []string) (params.Pool, error) { pool, err := r.store.FindRepositoryPoolByTags(r.ctx, r.id, labels) if err != nil { @@ -245,3 +237,59 @@ func (r *repository) ValidateOwner(job params.WorkflowJob) error { func (r *repository) ID() string { return r.id } + +func (r *repository) listHooks(ctx context.Context) ([]*github.Hook, error) { + opts := github.ListOptions{ + PerPage: 100, + } + var allHooks []*github.Hook + for { + hooks, ghResp, err := r.ghcli.ListRepoHooks(ctx, r.cfg.Owner, r.cfg.Name, &opts) + if err != nil { + return nil, errors.Wrap(err, "fetching hooks") + } + allHooks = append(allHooks, hooks...) + if ghResp.NextPage == 0 { + break + } + opts.Page = ghResp.NextPage + } + return allHooks, nil +} + +func (r *repository) InstallHook(ctx context.Context, req *github.Hook) error { + allHooks, err := r.listHooks(ctx) + if err != nil { + return errors.Wrap(err, "listing hooks") + } + + for _, hook := range allHooks { + if hook.Config["url"] == req.Config["url"] { + return fmt.Errorf("hook already installed: %w", runnerErrors.ErrBadRequest) + } + } + + _, _, err = r.ghcli.CreateRepoHook(ctx, r.cfg.Owner, r.cfg.Name, req) + if err != nil { + return errors.Wrap(err, "creating repository hook") + } + return nil +} + +func (r *repository) UninstallHook(ctx context.Context, url string) error { + allHooks, err := r.listHooks(ctx) + if err != nil { + return errors.Wrap(err, "listing hooks") + } + + for _, hook := range allHooks { + if hook.Config["url"] == url { + _, err = r.ghcli.DeleteRepoHook(ctx, r.cfg.Owner, r.cfg.Name, *hook.ID) + if err != nil { + return errors.Wrap(err, "deleting hook") + } + return nil + } + } + return nil +} diff --git a/runner/repositories.go b/runner/repositories.go index cf5191dd..3030bdce 100644 --- a/runner/repositories.go +++ b/runner/repositories.go @@ -349,3 +349,45 @@ func (r *Runner) findRepoPoolManager(owner, name string) (common.PoolManager, er } return poolManager, nil } + +func (r *Runner) InstallRepoWebhook(ctx context.Context, repoID string, param params.InstallWebhookParams) error { + if !auth.IsAdmin(ctx) { + return runnerErrors.ErrUnauthorized + } + + repo, err := r.store.GetRepositoryByID(ctx, repoID) + if err != nil { + return errors.Wrap(err, "fetching repo") + } + + poolManager, err := r.poolManagerCtrl.GetRepoPoolManager(repo) + if err != nil { + return errors.Wrap(err, "fetching pool manager for repo") + } + + if err := poolManager.InstallWebhook(ctx, param); err != nil { + return errors.Wrap(err, "installing webhook") + } + return nil +} + +func (r *Runner) UninstallRepoWebhook(ctx context.Context, repoID string) error { + if !auth.IsAdmin(ctx) { + return runnerErrors.ErrUnauthorized + } + + repo, err := r.store.GetRepositoryByID(ctx, repoID) + if err != nil { + return errors.Wrap(err, "fetching repo") + } + + poolManager, err := r.poolManagerCtrl.GetRepoPoolManager(repo) + if err != nil { + return errors.Wrap(err, "fetching pool manager for repo") + } + + if err := poolManager.UninstallWebhook(ctx); err != nil { + return errors.Wrap(err, "uninstalling webhook") + } + return nil +} diff --git a/runner/runner.go b/runner/runner.go index 9909ad58..76563c6d 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -311,12 +311,18 @@ func (p *poolManagerCtrl) getInternalConfig(credsName string) (params.Internal, return params.Internal{}, fmt.Errorf("fetching CA bundle for creds: %w", err) } + var controllerWebhookURL string + if p.config.Default.WebhookURL != "" { + controllerWebhookURL = fmt.Sprintf("%s/%s", p.config.Default.WebhookURL, p.controllerID) + } return params.Internal{ - OAuth2Token: creds.OAuth2Token, - ControllerID: p.controllerID, - InstanceCallbackURL: p.config.Default.CallbackURL, - InstanceMetadataURL: p.config.Default.MetadataURL, - JWTSecret: p.config.JWTAuth.Secret, + OAuth2Token: creds.OAuth2Token, + ControllerID: p.controllerID, + InstanceCallbackURL: p.config.Default.CallbackURL, + InstanceMetadataURL: p.config.Default.MetadataURL, + BaseWebhookURL: p.config.Default.WebhookURL, + ControllerWebhookURL: controllerWebhookURL, + JWTSecret: p.config.JWTAuth.Secret, GithubCredentialsDetails: params.GithubCredentials{ Name: creds.Name, Description: creds.Description, @@ -376,11 +382,17 @@ func (r *Runner) GetControllerInfo(ctx context.Context) (params.ControllerInfo, return params.ControllerInfo{}, errors.Wrap(err, "fetching hostname") } r.controllerInfo.Hostname = hostname + var controllerWebhook string + if r.controllerID != uuid.Nil && r.config.Default.WebhookURL != "" { + controllerWebhook = fmt.Sprintf("%s/%s", r.config.Default.WebhookURL, r.controllerID.String()) + } return params.ControllerInfo{ - ControllerID: r.controllerID, - Hostname: hostname, - MetadataURL: r.config.Default.MetadataURL, - CallbackURL: r.config.Default.CallbackURL, + ControllerID: r.controllerID, + Hostname: hostname, + MetadataURL: r.config.Default.MetadataURL, + CallbackURL: r.config.Default.CallbackURL, + WebhookURL: r.config.Default.WebhookURL, + ControllerWebhookURL: controllerWebhook, }, nil } diff --git a/testdata/config.toml b/testdata/config.toml index 24efc5b9..75fa1376 100644 --- a/testdata/config.toml +++ b/testdata/config.toml @@ -17,6 +17,8 @@ callback_url = "https://garm.example.com/api/v1/callbacks" # highly encouraged. metadata_url = "https://garm.example.com/api/v1/metadata" +webhook_url = "https://garm.example.com/webhooks" + # Uncomment this line if you'd like to log to a file instead of standard output. # log_file = "/tmp/runner-manager.log" diff --git a/util/util.go b/util/util.go index db2b86f0..82707570 100644 --- a/util/util.go +++ b/util/util.go @@ -29,6 +29,44 @@ import ( "golang.org/x/oauth2" ) +type githubClient struct { + *github.ActionsService + org *github.OrganizationsService + repo *github.RepositoriesService +} + +func (g *githubClient) ListOrgHooks(ctx context.Context, org string, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) { + return g.org.ListHooks(ctx, org, opts) +} + +func (g *githubClient) GetOrgHook(ctx context.Context, org string, id int64) (*github.Hook, *github.Response, error) { + return g.org.GetHook(ctx, org, id) +} + +func (g *githubClient) CreateOrgHook(ctx context.Context, org string, hook *github.Hook) (*github.Hook, *github.Response, error) { + return g.org.CreateHook(ctx, org, hook) +} + +func (g *githubClient) DeleteOrgHook(ctx context.Context, org string, id int64) (*github.Response, error) { + return g.org.DeleteHook(ctx, org, id) +} + +func (g *githubClient) ListRepoHooks(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) { + return g.repo.ListHooks(ctx, owner, repo, opts) +} + +func (g *githubClient) GetRepoHook(ctx context.Context, owner, repo string, id int64) (*github.Hook, *github.Response, error) { + return g.repo.GetHook(ctx, owner, repo, id) +} + +func (g *githubClient) CreateRepoHook(ctx context.Context, owner, repo string, hook *github.Hook) (*github.Hook, *github.Response, error) { + return g.repo.CreateHook(ctx, owner, repo, hook) +} + +func (g *githubClient) DeleteRepoHook(ctx context.Context, owner, repo string, id int64) (*github.Response, error) { + return g.repo.DeleteHook(ctx, owner, repo, id) +} + func GithubClient(ctx context.Context, token string, credsDetails params.GithubCredentials) (common.GithubClient, common.GithubEnterpriseClient, error) { var roots *x509.CertPool if credsDetails.CABundle != nil && len(credsDetails.CABundle) > 0 { @@ -56,5 +94,10 @@ func GithubClient(ctx context.Context, token string, credsDetails params.GithubC return nil, nil, errors.Wrap(err, "fetching github client") } - return ghClient.Actions, ghClient.Enterprise, nil + cli := &githubClient{ + ActionsService: ghClient.Actions, + org: ghClient.Organizations, + repo: ghClient.Repositories, + } + return cli, ghClient.Enterprise, nil }