From 9748aa47affc4e6eb84cb38fd67cb0daf660b4a1 Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Wed, 5 Jun 2024 06:41:16 +0000 Subject: [PATCH] Move URLs from default section of config to DB This change moves the callback_url, metadata_url and webhooks_url from the config to the database. The goal is to move as much as possible from the config to the DB, in preparation for a potential refactor that will allow GARM to scale out. This would allow multiple nodes to share a single source of truth. Signed-off-by: Gabriel Adrian Samfira --- apiserver/controllers/controllers.go | 39 ++++ apiserver/params/params.go | 5 + apiserver/routers/routers.go | 33 +++- apiserver/swagger-models.yaml | 7 + apiserver/swagger.yaml | 31 +++ auth/init_required.go | 27 +++ client/controller/controller_client.go | 106 +++++++++++ .../update_controller_parameters.go | 151 +++++++++++++++ .../controller/update_controller_responses.go | 179 ++++++++++++++++++ client/garm_api_client.go | 5 + cmd/garm-cli/cmd/controller.go | 173 +++++++++++++++++ cmd/garm-cli/cmd/controller_info.go | 84 -------- cmd/garm-cli/cmd/init.go | 107 ++++++++++- cmd/garm-cli/common/common.go | 5 +- cmd/garm/main.go | 44 ++++- database/common/common.go | 10 +- database/common/mocks/Store.go | 28 +++ database/sql/controller.go | 65 ++++++- database/sql/models.go | 4 + params/requests.go | 31 +++ runner/runner.go | 65 ++++--- testdata/config.toml | 45 ----- 22 files changed, 1067 insertions(+), 177 deletions(-) create mode 100644 client/controller/controller_client.go create mode 100644 client/controller/update_controller_parameters.go create mode 100644 client/controller/update_controller_responses.go create mode 100644 cmd/garm-cli/cmd/controller.go delete mode 100644 cmd/garm-cli/cmd/controller_info.go diff --git a/apiserver/controllers/controllers.go b/apiserver/controllers/controllers.go index 56745b82..556892f3 100644 --- a/apiserver/controllers/controllers.go +++ b/apiserver/controllers/controllers.go @@ -391,3 +391,42 @@ func (a *APIController) ControllerInfoHandler(w http.ResponseWriter, r *http.Req slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response") } } + +// swagger:route PUT /controller controller UpdateController +// +// Update controller. +// +// Parameters: +// + name: Body +// description: Parameters used when updating the controller. +// type: UpdateControllerParams +// in: body +// required: true +// +// Responses: +// 200: ControllerInfo +// 400: APIErrorResponse +func (a *APIController) UpdateControllerHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var updateParams runnerParams.UpdateControllerParams + if err := json.NewDecoder(r.Body).Decode(&updateParams); err != nil { + handleError(ctx, w, gErrors.ErrBadRequest) + return + } + + if err := updateParams.Validate(); err != nil { + handleError(ctx, w, err) + return + } + + info, err := a.r.UpdateController(ctx, updateParams) + if err != nil { + handleError(ctx, w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(info); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response") + } +} diff --git a/apiserver/params/params.go b/apiserver/params/params.go index 23283a07..6e46190e 100644 --- a/apiserver/params/params.go +++ b/apiserver/params/params.go @@ -36,4 +36,9 @@ var ( Error: "init_required", Details: "Missing superuser", } + // URLsRequired is returned if the controller does not have the required URLs + URLsRequired = APIErrorResponse{ + Error: "urls_required", + Details: "Missing required URLs. Make sure you update the metadata, callback and webhook URLs", + } ) diff --git a/apiserver/routers/routers.go b/apiserver/routers/routers.go index 5683a8c2..95d31518 100644 --- a/apiserver/routers/routers.go +++ b/apiserver/routers/routers.go @@ -100,7 +100,7 @@ func requestLogger(h http.Handler) http.Handler { }) } -func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware, instanceMiddleware auth.Middleware, manageWebhooks bool) *mux.Router { +func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware, urlsRequiredMiddleware, instanceMiddleware auth.Middleware, manageWebhooks bool) *mux.Router { router := mux.NewRouter() router.Use(requestLogger) @@ -152,11 +152,38 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware authRouter.Handle("/{login:login\\/?}", http.HandlerFunc(han.LoginHandler)).Methods("POST", "OPTIONS") authRouter.Use(initMiddleware.Middleware) + ////////////////////////// + // Controller endpoints // + ////////////////////////// + controllerRouter := apiSubRouter.PathPrefix("/controller").Subrouter() + // The controller endpoints allow us to get information about the controller and update the URL endpoints. + // This endpoint must not be guarded by the urlsRequiredMiddleware as that would prevent the user from + // updating the URLs. + controllerRouter.Use(initMiddleware.Middleware) + controllerRouter.Use(authMiddleware.Middleware) + controllerRouter.Use(auth.AdminRequiredMiddleware) + // Get controller info + controllerRouter.Handle("/", http.HandlerFunc(han.ControllerInfoHandler)).Methods("GET", "OPTIONS") + controllerRouter.Handle("", http.HandlerFunc(han.ControllerInfoHandler)).Methods("GET", "OPTIONS") + // Update controller + controllerRouter.Handle("/", http.HandlerFunc(han.UpdateControllerHandler)).Methods("PUT", "OPTIONS") + controllerRouter.Handle("", http.HandlerFunc(han.UpdateControllerHandler)).Methods("PUT", "OPTIONS") + + //////////////////////////////////// + // API router for everything else // + //////////////////////////////////// apiRouter := apiSubRouter.PathPrefix("").Subrouter() apiRouter.Use(initMiddleware.Middleware) + // all endpoints except the controller endpoint should return an error + // if the required metadata, callback and webhook URLs are not set. + apiRouter.Use(urlsRequiredMiddleware.Middleware) apiRouter.Use(authMiddleware.Middleware) apiRouter.Use(auth.AdminRequiredMiddleware) + // Legacy controller path + apiRouter.Handle("/controller-info/", http.HandlerFunc(han.ControllerInfoHandler)).Methods("GET", "OPTIONS") + apiRouter.Handle("/controller-info", http.HandlerFunc(han.ControllerInfoHandler)).Methods("GET", "OPTIONS") + // Metrics Token apiRouter.Handle("/metrics-token/", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS") apiRouter.Handle("/metrics-token", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS") @@ -343,10 +370,6 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware apiRouter.Handle("/providers/", http.HandlerFunc(han.ListProviders)).Methods("GET", "OPTIONS") apiRouter.Handle("/providers", http.HandlerFunc(han.ListProviders)).Methods("GET", "OPTIONS") - // Controller info - apiRouter.Handle("/controller-info/", http.HandlerFunc(han.ControllerInfoHandler)).Methods("GET", "OPTIONS") - apiRouter.Handle("/controller-info", http.HandlerFunc(han.ControllerInfoHandler)).Methods("GET", "OPTIONS") - ////////////////////// // Github Endpoints // ////////////////////// diff --git a/apiserver/swagger-models.yaml b/apiserver/swagger-models.yaml index b9ab5670..88c6bd8d 100644 --- a/apiserver/swagger-models.yaml +++ b/apiserver/swagger-models.yaml @@ -278,3 +278,10 @@ definitions: import: package: github.com/cloudbase/garm/params alias: garm_params + UpdateControllerParams: + type: object + x-go-type: + type: UpdateControllerParams + import: + package: github.com/cloudbase/garm/params + alias: garm_params diff --git a/apiserver/swagger.yaml b/apiserver/swagger.yaml index afed3747..42c573f0 100644 --- a/apiserver/swagger.yaml +++ b/apiserver/swagger.yaml @@ -244,6 +244,13 @@ definitions: alias: garm_params package: github.com/cloudbase/garm/params type: Repository + UpdateControllerParams: + type: object + x-go-type: + import: + alias: garm_params + package: github.com/cloudbase/garm/params + type: UpdateControllerParams UpdateEntityParams: type: object x-go-type: @@ -311,6 +318,30 @@ paths: summary: Logs in a user and returns a JWT token. tags: - login + /controller: + put: + operationId: UpdateController + parameters: + - description: Parameters used when updating the controller. + in: body + name: Body + required: true + schema: + $ref: '#/definitions/UpdateControllerParams' + description: Parameters used when updating the controller. + type: object + responses: + "200": + description: ControllerInfo + schema: + $ref: '#/definitions/ControllerInfo' + "400": + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Update controller. + tags: + - controller /controller-info: get: operationId: ControllerInfo diff --git a/auth/init_required.go b/auth/init_required.go index 7dcc655b..6b369a6c 100644 --- a/auth/init_required.go +++ b/auth/init_required.go @@ -51,3 +51,30 @@ func (i *initRequired) Middleware(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(ctx)) }) } + +func NewUrlsRequiredMiddleware(store common.Store) (Middleware, error) { + return &urlsRequired{ + store: store, + }, nil +} + +type urlsRequired struct { + store common.Store +} + +func (u *urlsRequired) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctrlInfo, err := u.store.ControllerInfo() + if err != nil || ctrlInfo.WebhookURL == "" || ctrlInfo.MetadataURL == "" || ctrlInfo.CallbackURL == "" { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + if err := json.NewEncoder(w).Encode(params.URLsRequired); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response") + } + return + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/client/controller/controller_client.go b/client/controller/controller_client.go new file mode 100644 index 00000000..cf6cde1a --- /dev/null +++ b/client/controller/controller_client.go @@ -0,0 +1,106 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package controller + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "fmt" + + "github.com/go-openapi/runtime" + httptransport "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// New creates a new controller API client. +func New(transport runtime.ClientTransport, formats strfmt.Registry) ClientService { + return &Client{transport: transport, formats: formats} +} + +// New creates a new controller API client with basic auth credentials. +// It takes the following parameters: +// - host: http host (github.com). +// - basePath: any base path for the API client ("/v1", "/v3"). +// - scheme: http scheme ("http", "https"). +// - user: user for basic authentication header. +// - password: password for basic authentication header. +func NewClientWithBasicAuth(host, basePath, scheme, user, password string) ClientService { + transport := httptransport.New(host, basePath, []string{scheme}) + transport.DefaultAuthentication = httptransport.BasicAuth(user, password) + return &Client{transport: transport, formats: strfmt.Default} +} + +// New creates a new controller API client with a bearer token for authentication. +// It takes the following parameters: +// - host: http host (github.com). +// - basePath: any base path for the API client ("/v1", "/v3"). +// - scheme: http scheme ("http", "https"). +// - bearerToken: bearer token for Bearer authentication header. +func NewClientWithBearerToken(host, basePath, scheme, bearerToken string) ClientService { + transport := httptransport.New(host, basePath, []string{scheme}) + transport.DefaultAuthentication = httptransport.BearerToken(bearerToken) + return &Client{transport: transport, formats: strfmt.Default} +} + +/* +Client for controller API +*/ +type Client struct { + transport runtime.ClientTransport + formats strfmt.Registry +} + +// ClientOption may be used to customize the behavior of Client methods. +type ClientOption func(*runtime.ClientOperation) + +// ClientService is the interface for Client methods +type ClientService interface { + UpdateController(params *UpdateControllerParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*UpdateControllerOK, error) + + SetTransport(transport runtime.ClientTransport) +} + +/* +UpdateController updates controller +*/ +func (a *Client) UpdateController(params *UpdateControllerParams, authInfo runtime.ClientAuthInfoWriter, opts ...ClientOption) (*UpdateControllerOK, error) { + // TODO: Validate the params before sending + if params == nil { + params = NewUpdateControllerParams() + } + op := &runtime.ClientOperation{ + ID: "UpdateController", + Method: "PUT", + PathPattern: "/controller", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &UpdateControllerReader{formats: a.formats}, + AuthInfo: authInfo, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + success, ok := result.(*UpdateControllerOK) + if ok { + return success, nil + } + // unexpected success response + // safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue + msg := fmt.Sprintf("unexpected success response for UpdateController: API contract not enforced by server. Client expected to get an error, but got: %T", result) + panic(msg) +} + +// SetTransport changes the transport on the client +func (a *Client) SetTransport(transport runtime.ClientTransport) { + a.transport = transport +} diff --git a/client/controller/update_controller_parameters.go b/client/controller/update_controller_parameters.go new file mode 100644 index 00000000..a0705d60 --- /dev/null +++ b/client/controller/update_controller_parameters.go @@ -0,0 +1,151 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package controller + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + + garm_params "github.com/cloudbase/garm/params" +) + +// NewUpdateControllerParams creates a new UpdateControllerParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewUpdateControllerParams() *UpdateControllerParams { + return &UpdateControllerParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewUpdateControllerParamsWithTimeout creates a new UpdateControllerParams object +// with the ability to set a timeout on a request. +func NewUpdateControllerParamsWithTimeout(timeout time.Duration) *UpdateControllerParams { + return &UpdateControllerParams{ + timeout: timeout, + } +} + +// NewUpdateControllerParamsWithContext creates a new UpdateControllerParams object +// with the ability to set a context for a request. +func NewUpdateControllerParamsWithContext(ctx context.Context) *UpdateControllerParams { + return &UpdateControllerParams{ + Context: ctx, + } +} + +// NewUpdateControllerParamsWithHTTPClient creates a new UpdateControllerParams object +// with the ability to set a custom HTTPClient for a request. +func NewUpdateControllerParamsWithHTTPClient(client *http.Client) *UpdateControllerParams { + return &UpdateControllerParams{ + HTTPClient: client, + } +} + +/* +UpdateControllerParams contains all the parameters to send to the API endpoint + + for the update controller operation. + + Typically these are written to a http.Request. +*/ +type UpdateControllerParams struct { + + /* Body. + + Parameters used when updating the controller. + */ + Body garm_params.UpdateControllerParams + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the update controller params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *UpdateControllerParams) WithDefaults() *UpdateControllerParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the update controller params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *UpdateControllerParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the update controller params +func (o *UpdateControllerParams) WithTimeout(timeout time.Duration) *UpdateControllerParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the update controller params +func (o *UpdateControllerParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the update controller params +func (o *UpdateControllerParams) WithContext(ctx context.Context) *UpdateControllerParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the update controller params +func (o *UpdateControllerParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the update controller params +func (o *UpdateControllerParams) WithHTTPClient(client *http.Client) *UpdateControllerParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the update controller params +func (o *UpdateControllerParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithBody adds the body to the update controller params +func (o *UpdateControllerParams) WithBody(body garm_params.UpdateControllerParams) *UpdateControllerParams { + o.SetBody(body) + return o +} + +// SetBody adds the body to the update controller params +func (o *UpdateControllerParams) SetBody(body garm_params.UpdateControllerParams) { + o.Body = body +} + +// WriteToRequest writes these params to a swagger request +func (o *UpdateControllerParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + if err := r.SetBodyParam(o.Body); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/client/controller/update_controller_responses.go b/client/controller/update_controller_responses.go new file mode 100644 index 00000000..f555a78e --- /dev/null +++ b/client/controller/update_controller_responses.go @@ -0,0 +1,179 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package controller + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + apiserver_params "github.com/cloudbase/garm/apiserver/params" + garm_params "github.com/cloudbase/garm/params" +) + +// UpdateControllerReader is a Reader for the UpdateController structure. +type UpdateControllerReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *UpdateControllerReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 200: + result := NewUpdateControllerOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + case 400: + result := NewUpdateControllerBadRequest() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + default: + return nil, runtime.NewAPIError("[PUT /controller] UpdateController", response, response.Code()) + } +} + +// NewUpdateControllerOK creates a UpdateControllerOK with default headers values +func NewUpdateControllerOK() *UpdateControllerOK { + return &UpdateControllerOK{} +} + +/* +UpdateControllerOK describes a response with status code 200, with default header values. + +ControllerInfo +*/ +type UpdateControllerOK struct { + Payload garm_params.ControllerInfo +} + +// IsSuccess returns true when this update controller o k response has a 2xx status code +func (o *UpdateControllerOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this update controller o k response has a 3xx status code +func (o *UpdateControllerOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this update controller o k response has a 4xx status code +func (o *UpdateControllerOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this update controller o k response has a 5xx status code +func (o *UpdateControllerOK) IsServerError() bool { + return false +} + +// IsCode returns true when this update controller o k response a status code equal to that given +func (o *UpdateControllerOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the update controller o k response +func (o *UpdateControllerOK) Code() int { + return 200 +} + +func (o *UpdateControllerOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /controller][%d] updateControllerOK %s", 200, payload) +} + +func (o *UpdateControllerOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /controller][%d] updateControllerOK %s", 200, payload) +} + +func (o *UpdateControllerOK) GetPayload() garm_params.ControllerInfo { + return o.Payload +} + +func (o *UpdateControllerOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewUpdateControllerBadRequest creates a UpdateControllerBadRequest with default headers values +func NewUpdateControllerBadRequest() *UpdateControllerBadRequest { + return &UpdateControllerBadRequest{} +} + +/* +UpdateControllerBadRequest describes a response with status code 400, with default header values. + +APIErrorResponse +*/ +type UpdateControllerBadRequest struct { + Payload apiserver_params.APIErrorResponse +} + +// IsSuccess returns true when this update controller bad request response has a 2xx status code +func (o *UpdateControllerBadRequest) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this update controller bad request response has a 3xx status code +func (o *UpdateControllerBadRequest) IsRedirect() bool { + return false +} + +// IsClientError returns true when this update controller bad request response has a 4xx status code +func (o *UpdateControllerBadRequest) IsClientError() bool { + return true +} + +// IsServerError returns true when this update controller bad request response has a 5xx status code +func (o *UpdateControllerBadRequest) IsServerError() bool { + return false +} + +// IsCode returns true when this update controller bad request response a status code equal to that given +func (o *UpdateControllerBadRequest) IsCode(code int) bool { + return code == 400 +} + +// Code gets the status code for the update controller bad request response +func (o *UpdateControllerBadRequest) Code() int { + return 400 +} + +func (o *UpdateControllerBadRequest) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /controller][%d] updateControllerBadRequest %s", 400, payload) +} + +func (o *UpdateControllerBadRequest) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PUT /controller][%d] updateControllerBadRequest %s", 400, payload) +} + +func (o *UpdateControllerBadRequest) GetPayload() apiserver_params.APIErrorResponse { + return o.Payload +} + +func (o *UpdateControllerBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + // response payload + if err := consumer.Consume(response.Body(), &o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} diff --git a/client/garm_api_client.go b/client/garm_api_client.go index 597eab26..cbc65dfc 100644 --- a/client/garm_api_client.go +++ b/client/garm_api_client.go @@ -10,6 +10,7 @@ import ( httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" + "github.com/cloudbase/garm/client/controller" "github.com/cloudbase/garm/client/controller_info" "github.com/cloudbase/garm/client/credentials" "github.com/cloudbase/garm/client/endpoints" @@ -67,6 +68,7 @@ func New(transport runtime.ClientTransport, formats strfmt.Registry) *GarmAPI { cli := new(GarmAPI) cli.Transport = transport + cli.Controller = controller.New(transport, formats) cli.ControllerInfo = controller_info.New(transport, formats) cli.Credentials = credentials.New(transport, formats) cli.Endpoints = endpoints.New(transport, formats) @@ -124,6 +126,8 @@ func (cfg *TransportConfig) WithSchemes(schemes []string) *TransportConfig { // GarmAPI is a client for garm API type GarmAPI struct { + Controller controller.ClientService + ControllerInfo controller_info.ClientService Credentials credentials.ClientService @@ -156,6 +160,7 @@ type GarmAPI struct { // SetTransport changes the transport on the client and all its subresources func (c *GarmAPI) SetTransport(transport runtime.ClientTransport) { c.Transport = transport + c.Controller.SetTransport(transport) c.ControllerInfo.SetTransport(transport) c.Credentials.SetTransport(transport) c.Endpoints.SetTransport(transport) diff --git a/cmd/garm-cli/cmd/controller.go b/cmd/garm-cli/cmd/controller.go new file mode 100644 index 00000000..2141121b --- /dev/null +++ b/cmd/garm-cli/cmd/controller.go @@ -0,0 +1,173 @@ +// Copyright 2023 Cloudbase Solutions SRL +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package cmd + +import ( + "fmt" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + + apiClientController "github.com/cloudbase/garm/client/controller" + apiClientControllerInfo "github.com/cloudbase/garm/client/controller_info" + "github.com/cloudbase/garm/params" +) + +var controllerCmd = &cobra.Command{ + Use: "controller", + Aliases: []string{"controller-info"}, + SilenceUsage: true, + Short: "Controller operations", + Long: `Query or update information about the current controller.`, + Run: nil, +} + +var controllerShowCmd = &cobra.Command{ + Use: "show", + Short: "Show information", + Long: `Show information about the current controller.`, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if needsInit { + return errNeedsInitError + } + + showInfo := apiClientControllerInfo.NewControllerInfoParams() + response, err := apiCli.ControllerInfo.ControllerInfo(showInfo, authToken) + if err != nil { + return err + } + return formatInfo(response.Payload) + }, +} + +var controllerUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update controller information", + Long: `Update information about the current controller. + +Warning: Dragons ahead, please read carefully. + +Changing the URLs for the controller metadata, callback and webhooks, will +impact the controller's ability to manage webhooks and runners. + +As GARM can be set up behind a reverse proxy or through several layers of +network address translation or load balancing, we need to explicitly tell +GARM how to reach each of these URLs. Internally, GARM sets up API endpoints +as follows: + + * /webhooks - the base URL for the webhooks. Github needs to reach this URL. + * /api/v1/metadata - the metadata URL. Your runners need to be able to reach this URL. + * /api/v1/callbacks - the callback URL. Your runners need to be able to reach this URL. + +You need to expose these endpoints to the interested parties (github or +your runners), then you need to update the controller with the URLs you set up. + +For example, if you set the webhooks URL in your reverse proxy to +https://garm.example.com/garm-hooks, this still needs to point to the "/webhooks" +URL in the GARM backend, but in the controller info you need to set the URL to +https://garm.example.com/garm-hooks using: + + garm-cli controller update --webhook-url=https://garm.example.com/garm-hooks + +If you expose GARM to the outside world directly, or if you don't rewrite the URLs +above in your reverse proxy config, use the above 3 endpoints without change, +substituting garm.example.com with the correct hostname or IP address. + +In most cases, you will have a GARM backend (say 192.168.100.10) and a reverse +proxy in front of it exposed as https://garm.example.com. If you don't rewrite +the URLs in the reverse proxy, and you just point to your backend, you can set +up the GARM controller URLs as: + + garm-cli controller update \ + --webhook-url=https://garm.example.com/webhooks \ + --metadata-url=https://garm.example.com/api/v1/metadata \ + --callback-url=https://garm.example.com/api/v1/callbacks +`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if needsInit { + return errNeedsInitError + } + + params := params.UpdateControllerParams{} + if cmd.Flags().Changed("metadata-url") { + params.MetadataURL = &metadataURL + } + if cmd.Flags().Changed("callback-url") { + params.CallbackURL = &callbackURL + } + if cmd.Flags().Changed("webhook-url") { + params.WebhookURL = &webhookURL + } + + if params.WebhookURL == nil && params.MetadataURL == nil && params.CallbackURL == nil { + cmd.Help() + return fmt.Errorf("at least one of metadata-url, callback-url or webhook-url must be provided") + } + + updateUrlsReq := apiClientController.NewUpdateControllerParams() + updateUrlsReq.Body = params + + info, err := apiCli.Controller.UpdateController(updateUrlsReq, authToken) + if err != nil { + return fmt.Errorf("error updating controller: %w", err) + } + formatInfo(info.Payload) + return nil + }, +} + +func renderControllerInfoTable(info params.ControllerInfo) string { + 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}) + if info.Hostname != "" { + 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 Base URL", info.WebhookURL}) + t.AppendRow(table.Row{"Controller Webhook URL", info.ControllerWebhookURL}) + return t.Render() +} + +func formatInfo(info params.ControllerInfo) error { + fmt.Println(renderControllerInfoTable(info)) + return nil +} + +func init() { + controllerUpdateCmd.Flags().StringVarP(&metadataURL, "metadata-url", "m", "", "The metadata URL for the controller (ie. https://garm.example.com/api/v1/metadata)") + controllerUpdateCmd.Flags().StringVarP(&callbackURL, "callback-url", "c", "", "The callback URL for the controller (ie. https://garm.example.com/api/v1/callbacks)") + controllerUpdateCmd.Flags().StringVarP(&webhookURL, "webhook-url", "w", "", "The webhook URL for the controller (ie. https://garm.example.com/webhooks)") + + controllerCmd.AddCommand( + controllerShowCmd, + controllerUpdateCmd, + ) + + rootCmd.AddCommand(controllerCmd) +} diff --git a/cmd/garm-cli/cmd/controller_info.go b/cmd/garm-cli/cmd/controller_info.go deleted file mode 100644 index 67ef2b86..00000000 --- a/cmd/garm-cli/cmd/controller_info.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2023 Cloudbase Solutions SRL -// -// Licensed under the Apache License, Version 2.0 (the "License"); you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -package cmd - -import ( - "fmt" - - "github.com/jedib0t/go-pretty/v6/table" - "github.com/spf13/cobra" - - apiClientControllerInfo "github.com/cloudbase/garm/client/controller_info" - "github.com/cloudbase/garm/params" -) - -var infoCmd = &cobra.Command{ - Use: "controller-info", - SilenceUsage: true, - Short: "Information about controller", - Long: `Query information about the current controller.`, - Run: nil, -} - -var infoShowCmd = &cobra.Command{ - Use: "show", - Short: "Show information", - Long: `Show information about the current controller.`, - SilenceUsage: true, - RunE: func(_ *cobra.Command, _ []string) error { - if needsInit { - return errNeedsInitError - } - - showInfo := apiClientControllerInfo.NewControllerInfoParams() - response, err := apiCli.ControllerInfo.ControllerInfo(showInfo, authToken) - if err != nil { - return err - } - return formatInfo(response.Payload) - }, -} - -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 Base URL", info.WebhookURL}) - t.AppendRow(table.Row{"Controller Webhook URL", info.ControllerWebhookURL}) - fmt.Println(t.Render()) - - return nil -} - -func init() { - infoCmd.AddCommand( - infoShowCmd, - ) - - rootCmd.AddCommand(infoCmd) -} diff --git a/cmd/garm-cli/cmd/init.go b/cmd/garm-cli/cmd/init.go index acdbda5a..256a37c6 100644 --- a/cmd/garm-cli/cmd/init.go +++ b/cmd/garm-cli/cmd/init.go @@ -16,12 +16,15 @@ package cmd import ( "fmt" + "net/url" "strings" + openapiRuntimeClient "github.com/go-openapi/runtime/client" "github.com/jedib0t/go-pretty/v6/table" "github.com/pkg/errors" "github.com/spf13/cobra" + apiClientController "github.com/cloudbase/garm/client/controller" apiClientFirstRun "github.com/cloudbase/garm/client/first_run" apiClientLogin "github.com/cloudbase/garm/client/login" "github.com/cloudbase/garm/cmd/garm-cli/common" @@ -29,6 +32,12 @@ import ( "github.com/cloudbase/garm/params" ) +var ( + callbackURL string + metadataURL string + webhookURL string +) + // initCmd represents the init command var initCmd = &cobra.Command{ Use: "init", @@ -52,10 +61,13 @@ garm-cli init --name=dev --url=https://runner.example.com --username=admin --pas } } + url := strings.TrimSuffix(loginURL, "/") if err := promptUnsetInitVariables(); err != nil { return err } + ensureDefaultEndpoints(url) + newUserReq := apiClientFirstRun.NewFirstRunParams() newUserReq.Body = params.NewUserParams{ Username: loginUserName, @@ -63,9 +75,6 @@ garm-cli init --name=dev --url=https://runner.example.com --username=admin --pas FullName: loginFullName, Email: loginEmail, } - - url := strings.TrimSuffix(loginURL, "/") - initAPIClient(url, "") response, err := apiCli.FirstRun.FirstRun(newUserReq, authToken) @@ -90,17 +99,50 @@ garm-cli init --name=dev --url=https://runner.example.com --username=admin --pas Token: token.Payload.Token, }) + authToken = openapiRuntimeClient.BearerToken(token.Payload.Token) cfg.ActiveManager = loginProfileName if err := cfg.SaveConfig(); err != nil { return errors.Wrap(err, "saving config") } - renderUserTable(response.Payload) + updateUrlsReq := apiClientController.NewUpdateControllerParams() + updateUrlsReq.Body = params.UpdateControllerParams{ + MetadataURL: &metadataURL, + CallbackURL: &callbackURL, + WebhookURL: &webhookURL, + } + + controllerInfoResponse, err := apiCli.Controller.UpdateController(updateUrlsReq, authToken) + renderResponseMessage(response.Payload, controllerInfoResponse.Payload, err) return nil }, } +func ensureDefaultEndpoints(loginURL string) (err error) { + if metadataURL == "" { + metadataURL, err = url.JoinPath(loginURL, "api/v1/callbacks") + if err != nil { + return err + } + } + + if callbackURL == "" { + callbackURL, err = url.JoinPath(loginURL, "api/v1/callbacks") + if err != nil { + return err + } + } + + if webhookURL == "" { + webhookURL, err = url.JoinPath(loginURL, "webhooks") + if err != nil { + return err + } + } + return nil +} + func promptUnsetInitVariables() error { var err error if loginUserName == "" { @@ -123,6 +165,7 @@ func promptUnsetInitVariables() error { return err } } + return nil } @@ -133,13 +176,16 @@ func init() { initCmd.Flags().StringVarP(&loginURL, "url", "a", "", "The base URL for the runner manager API") initCmd.Flags().StringVarP(&loginUserName, "username", "u", "", "The desired administrative username") initCmd.Flags().StringVarP(&loginEmail, "email", "e", "", "Email address") + initCmd.Flags().StringVarP(&metadataURL, "metadata-url", "m", "", "The metadata URL for the controller (ie. https://garm.example.com/api/v1/metadata)") + initCmd.Flags().StringVarP(&callbackURL, "callback-url", "c", "", "The callback URL for the controller (ie. https://garm.example.com/api/v1/callbacks)") + initCmd.Flags().StringVarP(&webhookURL, "webhook-url", "w", "", "The webhook URL for the controller (ie. https://garm.example.com/webhooks)") initCmd.Flags().StringVarP(&loginFullName, "full-name", "f", "", "Full name of the user") initCmd.Flags().StringVarP(&loginPassword, "password", "p", "", "The admin password") initCmd.MarkFlagRequired("name") //nolint initCmd.MarkFlagRequired("url") //nolint } -func renderUserTable(user params.User) { +func renderUserTable(user params.User) string { t := table.NewWriter() header := table.Row{"Field", "Value"} t.AppendHeader(header) @@ -148,5 +194,54 @@ func renderUserTable(user params.User) { t.AppendRow(table.Row{"Username", user.Username}) t.AppendRow(table.Row{"Email", user.Email}) t.AppendRow(table.Row{"Enabled", user.Enabled}) - fmt.Println(t.Render()) + return t.Render() +} + +func renderResponseMessage(user params.User, controllerInfo params.ControllerInfo, err error) { + userTable := renderUserTable(user) + controllerInfoTable := renderControllerInfoTable(controllerInfo) + + headerMsg := `Congrats! Your controller is now initialized. + +Following are the details of the admin user and details about the controller. + +Admin user information: + +%s +` + + controllerMsg := `Controller information: + +%s + +Make sure that the URLs in the table above are reachable by the relevant parties. + +The metadata and callback URLs *must* be accessible by the runners that GARM spins up. +The base webhook and the controller webhook URLs must be accessible by GitHub or GHES. +` + + controllerErrorMsg := `WARNING: Failed to set the required controller URLs with error: %q + +Please run: + + garm-cli controller show + +To make sure that the callback, metadata and webhook URLs are set correctly. If not, +you must set them up by running: + + garm-cli controller update \ + --metadata-url= \ + --callback-url= \ + --webhook-url= + +See the help message for garm-cli controller update for more information. +` + var ctrlMsg string + if err != nil { + ctrlMsg = fmt.Sprintf(controllerErrorMsg, err) + } else { + ctrlMsg = fmt.Sprintf(controllerMsg, controllerInfoTable) + } + + fmt.Printf("%s\n%s\n", fmt.Sprintf(headerMsg, userTable), ctrlMsg) } diff --git a/cmd/garm-cli/common/common.go b/cmd/garm-cli/common/common.go index b3850e31..f7b860f4 100644 --- a/cmd/garm-cli/common/common.go +++ b/cmd/garm-cli/common/common.go @@ -16,6 +16,7 @@ package common import ( "errors" + "fmt" "github.com/manifoldco/promptui" "github.com/nbutton23/zxcvbn-go" @@ -45,7 +46,7 @@ func PromptPassword(label string) (string, error) { return result, nil } -func PromptString(label string) (string, error) { +func PromptString(label string, a ...interface{}) (string, error) { validate := func(input string) error { if len(input) == 0 { return errors.New("empty input not allowed") @@ -54,7 +55,7 @@ func PromptString(label string) (string, error) { } prompt := promptui.Prompt{ - Label: label, + Label: fmt.Sprintf(label, a...), Validate: validate, } result, err := prompt.Run() diff --git a/cmd/garm/main.go b/cmd/garm/main.go index 526e2017..83d70326 100644 --- a/cmd/garm/main.go +++ b/cmd/garm/main.go @@ -41,6 +41,7 @@ import ( "github.com/cloudbase/garm/database" "github.com/cloudbase/garm/database/common" "github.com/cloudbase/garm/metrics" + "github.com/cloudbase/garm/params" "github.com/cloudbase/garm/runner" //nolint:typecheck runnerMetrics "github.com/cloudbase/garm/runner/metrics" garmUtil "github.com/cloudbase/garm/util" @@ -142,6 +143,38 @@ func setupLogging(ctx context.Context, logCfg config.Logging, hub *websocket.Hub slog.SetDefault(slog.New(wrapped)) } +func maybeUpdateURLsFromConfig(cfg config.Config, store common.Store) error { + info, err := store.ControllerInfo() + if err != nil { + return errors.Wrap(err, "fetching controller info") + } + + var updateParams params.UpdateControllerParams + + if info.MetadataURL == "" && cfg.Default.MetadataURL != "" { + updateParams.MetadataURL = &cfg.Default.MetadataURL + } + + if info.CallbackURL == "" && cfg.Default.CallbackURL != "" { + updateParams.CallbackURL = &cfg.Default.CallbackURL + } + + if info.WebhookURL == "" && cfg.Default.WebhookURL != "" { + updateParams.WebhookURL = &cfg.Default.WebhookURL + } + + if updateParams.MetadataURL == nil && updateParams.CallbackURL == nil && updateParams.WebhookURL == nil { + // nothing to update + return nil + } + + _, err = store.UpdateController(updateParams) + if err != nil { + return errors.Wrap(err, "updating controller info") + } + return nil +} + func main() { flag.Parse() if *version { @@ -181,6 +214,10 @@ func main() { log.Fatal(err) } + if err := maybeUpdateURLsFromConfig(*cfg, db); err != nil { + log.Fatal(err) + } + runner, err := runner.NewRunner(ctx, *cfg, db) if err != nil { log.Fatalf("failed to create controller: %+v", err) @@ -212,12 +249,17 @@ func main() { log.Fatal(err) } + urlsRequiredMiddleware, err := auth.NewUrlsRequiredMiddleware(db) + if err != nil { + log.Fatal(err) + } + metricsMiddleware, err := auth.NewMetricsMiddleware(cfg.JWTAuth) if err != nil { log.Fatal(err) } - router := routers.NewAPIRouter(controller, jwtMiddleware, initMiddleware, instanceMiddleware, cfg.Default.EnableWebhookManagement) + router := routers.NewAPIRouter(controller, jwtMiddleware, initMiddleware, urlsRequiredMiddleware, instanceMiddleware, cfg.Default.EnableWebhookManagement) // start the metrics collector if cfg.Metrics.Enable { diff --git a/database/common/common.go b/database/common/common.go index 5af12780..4f0df368 100644 --- a/database/common/common.go +++ b/database/common/common.go @@ -129,6 +129,12 @@ type EntityPools interface { ListEntityInstances(ctx context.Context, entity params.GithubEntity) ([]params.Instance, error) } +type ControllerStore interface { + ControllerInfo() (params.ControllerInfo, error) + InitController() (params.ControllerInfo, error) + UpdateController(info params.UpdateControllerParams) (params.ControllerInfo, error) +} + //go:generate mockery --name=Store type Store interface { RepoStore @@ -141,7 +147,5 @@ type Store interface { EntityPools GithubEndpointStore GithubCredentialsStore - - ControllerInfo() (params.ControllerInfo, error) - InitController() (params.ControllerInfo, error) + ControllerStore } diff --git a/database/common/mocks/Store.go b/database/common/mocks/Store.go index 9310e5c4..4af6f403 100644 --- a/database/common/mocks/Store.go +++ b/database/common/mocks/Store.go @@ -1516,6 +1516,34 @@ func (_m *Store) UnlockJob(ctx context.Context, jobID int64, entityID string) er return r0 } +// UpdateController provides a mock function with given fields: info +func (_m *Store) UpdateController(info params.UpdateControllerParams) (params.ControllerInfo, error) { + ret := _m.Called(info) + + if len(ret) == 0 { + panic("no return value specified for UpdateController") + } + + var r0 params.ControllerInfo + var r1 error + if rf, ok := ret.Get(0).(func(params.UpdateControllerParams) (params.ControllerInfo, error)); ok { + return rf(info) + } + if rf, ok := ret.Get(0).(func(params.UpdateControllerParams) params.ControllerInfo); ok { + r0 = rf(info) + } else { + r0 = ret.Get(0).(params.ControllerInfo) + } + + if rf, ok := ret.Get(1).(func(params.UpdateControllerParams) error); ok { + r1 = rf(info) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // UpdateEnterprise provides a mock function with given fields: ctx, enterpriseID, param func (_m *Store) UpdateEnterprise(ctx context.Context, enterpriseID string, param params.UpdateEntityParams) (params.Enterprise, error) { ret := _m.Called(ctx, enterpriseID, param) diff --git a/database/sql/controller.go b/database/sql/controller.go index c4389cb1..8d6c3477 100644 --- a/database/sql/controller.go +++ b/database/sql/controller.go @@ -15,6 +15,8 @@ package sql import ( + "net/url" + "github.com/google/uuid" "github.com/pkg/errors" "gorm.io/gorm" @@ -23,6 +25,21 @@ import ( "github.com/cloudbase/garm/params" ) +func dbControllerToCommonController(dbInfo ControllerInfo) (params.ControllerInfo, error) { + url, err := url.JoinPath(dbInfo.WebhookBaseURL, dbInfo.ControllerID.String()) + if err != nil { + return params.ControllerInfo{}, errors.Wrap(err, "joining webhook URL") + } + + return params.ControllerInfo{ + ControllerID: dbInfo.ControllerID, + MetadataURL: dbInfo.MetadataURL, + WebhookURL: dbInfo.WebhookBaseURL, + ControllerWebhookURL: url, + CallbackURL: dbInfo.CallbackURL, + }, nil +} + func (s *sqlDatabase) ControllerInfo() (params.ControllerInfo, error) { var info ControllerInfo q := s.conn.Model(&ControllerInfo{}).First(&info) @@ -32,9 +49,13 @@ func (s *sqlDatabase) ControllerInfo() (params.ControllerInfo, error) { } return params.ControllerInfo{}, errors.Wrap(q.Error, "fetching controller info") } - return params.ControllerInfo{ - ControllerID: info.ControllerID, - }, nil + + paramInfo, err := dbControllerToCommonController(info) + if err != nil { + return params.ControllerInfo{}, errors.Wrap(err, "converting controller info") + } + + return paramInfo, nil } func (s *sqlDatabase) InitController() (params.ControllerInfo, error) { @@ -60,3 +81,41 @@ func (s *sqlDatabase) InitController() (params.ControllerInfo, error) { ControllerID: newInfo.ControllerID, }, nil } + +func (s *sqlDatabase) UpdateController(info params.UpdateControllerParams) (params.ControllerInfo, error) { + var dbInfo ControllerInfo + q := s.conn.Model(&ControllerInfo{}).First(&dbInfo) + if q.Error != nil { + if errors.Is(q.Error, gorm.ErrRecordNotFound) { + return params.ControllerInfo{}, errors.Wrap(runnerErrors.ErrNotFound, "fetching controller info") + } + return params.ControllerInfo{}, errors.Wrap(q.Error, "fetching controller info") + } + + if err := info.Validate(); err != nil { + return params.ControllerInfo{}, errors.Wrap(err, "validating controller info") + } + + if info.MetadataURL != nil { + dbInfo.MetadataURL = *info.MetadataURL + } + + if info.CallbackURL != nil { + dbInfo.CallbackURL = *info.CallbackURL + } + + if info.WebhookURL != nil { + dbInfo.WebhookBaseURL = *info.WebhookURL + } + + q = s.conn.Save(&dbInfo) + if q.Error != nil { + return params.ControllerInfo{}, errors.Wrap(q.Error, "saving controller info") + } + + paramInfo, err := dbControllerToCommonController(dbInfo) + if err != nil { + return params.ControllerInfo{}, errors.Wrap(err, "converting controller info") + } + return paramInfo, nil +} diff --git a/database/sql/models.go b/database/sql/models.go index 633a1b51..172170b2 100644 --- a/database/sql/models.go +++ b/database/sql/models.go @@ -207,6 +207,10 @@ type ControllerInfo struct { Base ControllerID uuid.UUID + + CallbackURL string + MetadataURL string + WebhookBaseURL string } type WorkflowJob struct { diff --git a/params/requests.go b/params/requests.go index 4a842ef6..25d948ee 100644 --- a/params/requests.go +++ b/params/requests.go @@ -501,3 +501,34 @@ func (u UpdateGithubCredentialsParams) Validate() error { return nil } + +type UpdateControllerParams struct { + MetadataURL *string `json:"metadata_url,omitempty"` + CallbackURL *string `json:"callback_url,omitempty"` + WebhookURL *string `json:"webhook_url,omitempty"` +} + +func (u UpdateControllerParams) Validate() error { + if u.MetadataURL != nil { + u, err := url.Parse(*u.MetadataURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return runnerErrors.NewBadRequestError("invalid metadata_url") + } + } + + if u.CallbackURL != nil { + u, err := url.Parse(*u.CallbackURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return runnerErrors.NewBadRequestError("invalid callback_url") + } + } + + if u.WebhookURL != nil { + u, err := url.Parse(*u.WebhookURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return runnerErrors.NewBadRequestError("invalid webhook_url") + } + } + + return nil +} diff --git a/runner/runner.go b/runner/runner.go index 6f37c55f..2a08ae12 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -29,7 +29,6 @@ import ( "sync" "time" - "github.com/google/uuid" "github.com/juju/clock" "github.com/juju/retry" "github.com/pkg/errors" @@ -65,7 +64,6 @@ func NewRunner(ctx context.Context, cfg config.Config, db dbCommon.Store) (*Runn } poolManagerCtrl := &poolManagerCtrl{ - controllerID: ctrlID.ControllerID.String(), config: cfg, store: db, repositories: map[string]common.PoolManager{}, @@ -78,7 +76,6 @@ func NewRunner(ctx context.Context, cfg config.Config, db dbCommon.Store) (*Runn store: db, poolManagerCtrl: poolManagerCtrl, providers: providers, - controllerID: ctrlID.ControllerID, } if err := runner.loadReposOrgsAndEnterprises(); err != nil { @@ -91,9 +88,8 @@ func NewRunner(ctx context.Context, cfg config.Config, db dbCommon.Store) (*Runn type poolManagerCtrl struct { mux sync.Mutex - controllerID string - config config.Config - store dbCommon.Store + config config.Config + store dbCommon.Store repositories map[string]common.PoolManager organizations map[string]common.PoolManager @@ -345,17 +341,17 @@ func (p *poolManagerCtrl) GetEnterprisePoolManagers() (map[string]common.PoolMan } func (p *poolManagerCtrl) getInternalConfig(_ context.Context, creds params.GithubCredentials, poolBalancerType params.PoolBalancerType) (params.Internal, error) { - var controllerWebhookURL string - if p.config.Default.WebhookURL != "" { - controllerWebhookURL = fmt.Sprintf("%s/%s", p.config.Default.WebhookURL, p.controllerID) + controllerInfo, err := p.store.ControllerInfo() + if err != nil { + return params.Internal{}, errors.Wrap(err, "fetching controller info") } return params.Internal{ - ControllerID: p.controllerID, - InstanceCallbackURL: p.config.Default.CallbackURL, - InstanceMetadataURL: p.config.Default.MetadataURL, - BaseWebhookURL: p.config.Default.WebhookURL, - ControllerWebhookURL: controllerWebhookURL, + ControllerID: controllerInfo.ControllerID.String(), + InstanceCallbackURL: controllerInfo.CallbackURL, + InstanceMetadataURL: controllerInfo.MetadataURL, + BaseWebhookURL: controllerInfo.WebhookURL, + ControllerWebhookURL: controllerInfo.ControllerWebhookURL, JWTSecret: p.config.JWTAuth.Secret, PoolBalancerType: poolBalancerType, GithubCredentialsDetails: creds, @@ -372,9 +368,23 @@ type Runner struct { poolManagerCtrl PoolManagerController providers map[string]common.Provider +} - controllerInfo params.ControllerInfo - controllerID uuid.UUID +// UpdateController will update the controller settings. +func (r *Runner) UpdateController(ctx context.Context, param params.UpdateControllerParams) (params.ControllerInfo, error) { + if !auth.IsAdmin(ctx) { + return params.ControllerInfo{}, runnerErrors.ErrUnauthorized + } + + if err := param.Validate(); err != nil { + return params.ControllerInfo{}, errors.Wrap(err, "validating controller update params") + } + + info, err := r.store.UpdateController(param) + if err != nil { + return params.ControllerInfo{}, errors.Wrap(err, "updating controller info") + } + return info, nil } // GetControllerInfo returns the controller id and the hostname. @@ -408,19 +418,18 @@ func (r *Runner) GetControllerInfo(ctx context.Context) (params.ControllerInfo, if err != nil { 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()) + + info, err := r.store.ControllerInfo() + if err != nil { + return params.ControllerInfo{}, errors.Wrap(err, "fetching controller info") } - return params.ControllerInfo{ - ControllerID: r.controllerID, - Hostname: hostname, - MetadataURL: r.config.Default.MetadataURL, - CallbackURL: r.config.Default.CallbackURL, - WebhookURL: r.config.Default.WebhookURL, - ControllerWebhookURL: controllerWebhook, - }, nil + + // This is temporary. Right now, GARM is a single-instance deployment. When we add the + // ability to scale out, the hostname field will be moved form here to a dedicated node + // object. As a single controller will be made up of multiple nodes, we will need to model + // that aspect of GARM. + info.Hostname = hostname + return info, nil } func (r *Runner) ListProviders(ctx context.Context) ([]params.Provider, error) { diff --git a/testdata/config.toml b/testdata/config.toml index 23e18685..62801052 100644 --- a/testdata/config.toml +++ b/testdata/config.toml @@ -249,48 +249,3 @@ disable_jit_config = false # anything (bash, a binary, python, etc). See documentation in this repo on how to write an # external provider. provider_executable = "/etc/garm/providers.d/azure/garm-external-provider" - -# This is a list of credentials that you can define as part of the repository -# or organization definitions. They are not saved inside the database, as there -# is no Vault integration (yet). This will change in the future. -# Credentials defined here can be listed using the API. Obviously, only the name -# and descriptions are returned. -[[github]] - name = "gabriel" - description = "github token or user gabriel" - # This is the type of authentication to use. It can be "pat" or "app" - auth_type = "pat" - [github.pat] - # This is a personal token with access to the repositories and organizations - # you plan on adding to garm. The "workflow" option needs to be selected in order - # to work with repositories, and the admin:org needs to be set if you plan on - # adding an organization. - oauth2_token = "super secret token" - [github.app] - # This is the app_id of the GitHub App that you want to use to authenticate - # with the GitHub API. - # This needs to be changed - app_id = 1 - # This is the private key path of the GitHub App that you want to use to authenticate - # with the GitHub API. - # This needs to be changed - private_key_path = "/etc/garm/yourAppName.2024-03-01.private-key.pem" - # This is the installation_id of the GitHub App that you want to use to authenticate - # with the GitHub API. - # This needs to be changed - installation_id = 99 - # base_url (optional) is the URL at which your GitHub Enterprise Server can be accessed. - # If these credentials are for github.com, leave this setting blank - base_url = "https://ghe.example.com" - # api_base_url (optional) is the base URL where the GitHub Enterprise Server API can be accessed. - # Leave this blank if these credentials are for github.com. - api_base_url = "https://ghe.example.com" - # upload_base_url (optional) is the base URL where the GitHub Enterprise Server upload API can be accessed. - # Leave this blank if these credentials are for github.com, or if you don't have a separate URL - # for the upload API. - upload_base_url = "https://api.ghe.example.com" - # ca_cert_bundle (optional) is the CA certificate bundle in PEM format that will be used by the github - # client to talk to the API. This bundle will also be sent to all runners as bootstrap params. - # Use this option if you're using a self signed certificate. - # Leave this blank if you're using github.com or if your certificate is signed by a valid CA. - ca_cert_bundle = "/etc/garm/ghe.crt" \ No newline at end of file