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 <gsamfira@cloudbasesolutions.com>
This commit is contained in:
Gabriel Adrian Samfira 2024-06-05 06:41:16 +00:00
parent 7ee235aeb0
commit 9748aa47af
22 changed files with 1067 additions and 177 deletions

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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=<metadata-url> \
--callback-url=<callback-url> \
--webhook-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)
}

View file

@ -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()

View file

@ -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 {