From 7c62e34a21182018dec91e15508928c2e7d366af Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Sat, 13 Jun 2026 15:26:07 +0300 Subject: [PATCH] Refactor garm command, add password reset, cleanup This change does a bunch of stuff: * Migrates the garm command to cobra command. This means that the -config flag will require two dashes instead of 1. So this will potentially break systemd units, but it can't be helped. * Add an admin subcommand that allows listing users and resetting the password for the admin user. * Removes old code that migrated github credentials from the config to the DB. It's time to let that bit go into the sunset. * Completely random convert spaces to tabs for the runner install templates because I forgot to do that in the previous PR. Signed-off-by: Gabriel Adrian Samfira --- cmd/garm/cmd/admin.go | 139 ++++++ cmd/garm/cmd/root.go | 47 ++ cmd/garm/cmd/run.go | 432 ++++++++++++++++++ cmd/garm/cmd/version.go | 35 ++ cmd/garm/main.go | 411 +---------------- config/config.go | 244 +--------- config/config_test.go | 385 ---------------- .../userdata/gitea_linux_userdata.tmpl | 56 +-- .../userdata/github_linux_userdata.tmpl | 56 +-- runner/pools_test.go | 2 - runner/runner.go | 6 - 11 files changed, 715 insertions(+), 1098 deletions(-) create mode 100644 cmd/garm/cmd/admin.go create mode 100644 cmd/garm/cmd/root.go create mode 100644 cmd/garm/cmd/run.go create mode 100644 cmd/garm/cmd/version.go diff --git a/cmd/garm/cmd/admin.go b/cmd/garm/cmd/admin.go new file mode 100644 index 000000000..332601d6a --- /dev/null +++ b/cmd/garm/cmd/admin.go @@ -0,0 +1,139 @@ +// Copyright 2026 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 ( + "context" + "errors" + "fmt" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/nbutton23/zxcvbn-go" + "github.com/spf13/cobra" + + runnerErrors "github.com/cloudbase/garm-provider-common/errors" + "github.com/cloudbase/garm-provider-common/util" + "github.com/cloudbase/garm/config" + "github.com/cloudbase/garm/database" + dbCommon "github.com/cloudbase/garm/database/common" + "github.com/cloudbase/garm/database/watcher" + "github.com/cloudbase/garm/params" +) + +var newPassword string + +var adminCmd = &cobra.Command{ + Use: "admin", + Short: "Administrative commands for managing the GARM server", +} + +var adminListCmd = &cobra.Command{ + Use: "list", + Short: "List admin users", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + db, err := openDatabase(ctx) + if err != nil { + return err + } + + user, err := db.GetAdminUser(ctx) + if err != nil { + if errors.Is(err, runnerErrors.ErrNotFound) { + fmt.Println("No admin users found.") + return nil + } + return fmt.Errorf("fetching admin user: %w", err) + } + + t := table.NewWriter() + t.AppendHeader(table.Row{"ID", "Username", "Email", "Enabled"}) + t.AppendRow(table.Row{user.ID, user.Username, user.Email, user.Enabled}) + fmt.Println(t.Render()) + return nil + }, +} + +var adminPasswordResetCmd = &cobra.Command{ + Use: "password-reset [flags] ", + Short: "Reset the password for an admin user", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + ctx := cmd.Context() + + db, err := openDatabase(ctx) + if err != nil { + return err + } + + user, err := db.GetUser(ctx, username) + if err != nil { + return fmt.Errorf("fetching user %q: %w", username, err) + } + if !user.IsAdmin { + return fmt.Errorf("user %q is not an admin", username) + } + + strength := zxcvbn.PasswordStrength(newPassword, nil) + if strength.Score < 4 { + return fmt.Errorf("password is too weak (score: %d/4, minimum required: 4)", strength.Score) + } + + hashed, err := util.PaswsordToBcrypt(newPassword) + if err != nil { + return fmt.Errorf("hashing password: %w", err) + } + + if _, err := db.UpdateUser(ctx, username, params.UpdateUserParams{ + Password: hashed, + }); err != nil { + return fmt.Errorf("updating password: %w", err) + } + + fmt.Printf("Password for user %q has been reset successfully.\n", username) + return nil + }, +} + +func init() { + adminPasswordResetCmd.Flags().StringVar(&newPassword, "new-password", "", "new password for the admin user") + if err := adminPasswordResetCmd.MarkFlagRequired("new-password"); err != nil { + panic(err) + } + + adminCmd.AddCommand(adminListCmd) + adminCmd.AddCommand(adminPasswordResetCmd) + rootCmd.AddCommand(adminCmd) +} + +// openDatabase loads the GARM config and opens a direct database connection. +func openDatabase(ctx context.Context) (dbCommon.Store, error) { + cfg, err := config.NewConfig(cfgFile) + if err != nil { + return nil, fmt.Errorf("loading config: %w", err) + } + + watcher.InitWatcher(ctx) + + db, err := database.NewDatabase(ctx, cfg.Database) + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + return db, nil +} diff --git a/cmd/garm/cmd/root.go b/cmd/garm/cmd/root.go new file mode 100644 index 000000000..3ccb7c93f --- /dev/null +++ b/cmd/garm/cmd/root.go @@ -0,0 +1,47 @@ +// Copyright 2026 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 ( + "os" + + "github.com/spf13/cobra" + + "github.com/cloudbase/garm/util/appdefaults" +) + +var cfgFile string + +var rootCmd = &cobra.Command{ + Use: "garm", + Short: "GitHub Actions Runner Manager", + Long: "GitHub Actions Runner Manager (GARM) - A self hosted runners manager for GitHub and Gitea Actions.", + SilenceUsage: true, + Version: appdefaults.GetVersion(), + RunE: func(_ *cobra.Command, _ []string) error { + return runServer() + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", appdefaults.DefaultConfigFilePath, "path to garm config file") + rootCmd.SetVersionTemplate("{{.Version}}\n") +} diff --git a/cmd/garm/cmd/run.go b/cmd/garm/cmd/run.go new file mode 100644 index 000000000..2cbe60ff1 --- /dev/null +++ b/cmd/garm/cmd/run.go @@ -0,0 +1,432 @@ +// Copyright 2026 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 ( + "context" + "fmt" + "log" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "runtime" + "syscall" + "time" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + lumberjack "gopkg.in/natefinch/lumberjack.v2" + + "github.com/cloudbase/garm-provider-common/util" + "github.com/cloudbase/garm/apiserver/controllers" + "github.com/cloudbase/garm/apiserver/routers" + "github.com/cloudbase/garm/auth" + "github.com/cloudbase/garm/config" + "github.com/cloudbase/garm/database" + dbCommon "github.com/cloudbase/garm/database/common" + "github.com/cloudbase/garm/database/watcher" + "github.com/cloudbase/garm/locking" + "github.com/cloudbase/garm/metrics" + "github.com/cloudbase/garm/params" + "github.com/cloudbase/garm/runner" + runnerMetrics "github.com/cloudbase/garm/runner/metrics" + "github.com/cloudbase/garm/runner/providers" + garmUtil "github.com/cloudbase/garm/util" + "github.com/cloudbase/garm/websocket" + "github.com/cloudbase/garm/workers/cache" + "github.com/cloudbase/garm/workers/entity" + "github.com/cloudbase/garm/workers/provider" + "github.com/cloudbase/garm/workers/websocket/agent" + wsMetrics "github.com/cloudbase/garm/workers/websocket/metrics" +) + +var signals = []os.Signal{ + os.Interrupt, + syscall.SIGTERM, +} + +// serverComponents holds initialized server components that require +// lifecycle management during shutdown. +type serverComponents struct { + db dbCommon.Store + hub *websocket.Hub + agentHub *agent.Hub + metricsHub *wsMetrics.MetricsHub + cacheWorker *cache.Worker + providerWorker *provider.Provider + entityCtrl *entity.Controller + runner *runner.Runner +} + +func runServer() error { + ctx, stop := signal.NotifyContext(context.Background(), signals...) + defer stop() + watcher.InitWatcher(ctx) + + ctx = auth.GetAdminContext(ctx) + + cfg, err := config.NewConfig(cfgFile) + if err != nil { + return fmt.Errorf("fetching config: %w", err) + } + + logCfg := cfg.GetLoggingConfig() + var hub *websocket.Hub + if logCfg.EnableLogStreamer != nil && *logCfg.EnableLogStreamer { + hub = websocket.NewHub(ctx) + if err := hub.Start(); err != nil { + return fmt.Errorf("starting log streamer: %w", err) + } + defer hub.Stop() //nolint + } + setupLogging(ctx, logCfg, hub) + + comp, err := initInfrastructure(ctx, cfg, hub) + if err != nil { + return err + } + + srv, listener, err := buildHTTPServer(ctx, cfg, comp) + if err != nil { + return err + } + + go serve(ctx, srv, listener, cfg.APIServer) + + <-ctx.Done() + + shutdownServer(srv) + shutdownComponents(comp) + + return nil +} + +func initInfrastructure(ctx context.Context, cfg *config.Config, hub *websocket.Hub) (*serverComponents, error) { + db, err := database.NewDatabase(ctx, cfg.Database) + if err != nil { + return nil, fmt.Errorf("creating database: %w", err) + } + + controllerInfo, err := maybeInitController(db) + if err != nil { + return nil, err + } + + agentHub, err := agent.NewHub(ctx) + if err != nil { + return nil, fmt.Errorf("creating agent hub: %w", err) + } + if err := agentHub.Start(); err != nil { + return nil, fmt.Errorf("starting agent hub: %w", err) + } + + // Local locker for now. Will be configurable in the future, + // as we add scale-out capability to GARM. + lock, err := locking.NewLocalLocker(ctx, db) + if err != nil { + return nil, fmt.Errorf("creating locker: %w", err) + } + if err := locking.RegisterLocker(lock); err != nil { + return nil, fmt.Errorf("registering locker: %w", err) + } + + instanceTokenGetter, err := auth.NewInstanceTokenGetter(cfg.JWTAuth.Secret) + if err != nil { + return nil, fmt.Errorf("creating instance token getter: %w", err) + } + + rnr, err := runner.NewRunner(ctx, *cfg, db, instanceTokenGetter) + if err != nil { + return nil, fmt.Errorf("creating runner: %w", err) + } + + cacheWorker := cache.NewWorker(ctx, db, rnr) + if err := cacheWorker.Start(); err != nil { + return nil, fmt.Errorf("starting cache worker: %w", err) + } + + metricsHub := wsMetrics.NewMetricsHub(ctx) + if err := metricsHub.Start(); err != nil { + return nil, fmt.Errorf("starting metrics hub: %w", err) + } + + loadedProviders, err := providers.LoadProvidersFromConfig(ctx, *cfg, controllerInfo.ControllerID.String()) + if err != nil { + return nil, fmt.Errorf("loading providers: %w", err) + } + + // Provider worker must start first — its watcher consumer must be + // registered before entity/scaleset workers create instances. + providerWorker, err := provider.NewWorker(ctx, db, loadedProviders, instanceTokenGetter) + if err != nil { + return nil, fmt.Errorf("creating provider worker: %w", err) + } + if err := providerWorker.Start(); err != nil { + return nil, fmt.Errorf("starting provider worker: %w", err) + } + + entityCtrl, err := entity.NewController(ctx, db, loadedProviders) + if err != nil { + return nil, fmt.Errorf("creating entity controller: %w", err) + } + if err := entityCtrl.Start(); err != nil { + return nil, fmt.Errorf("starting entity controller: %w", err) + } + + // If there are many repos/pools, this may take a long time. + if err := rnr.Start(); err != nil { + return nil, fmt.Errorf("starting runner: %w", err) + } + + return &serverComponents{ + db: db, + hub: hub, + agentHub: agentHub, + metricsHub: metricsHub, + cacheWorker: cacheWorker, + providerWorker: providerWorker, + entityCtrl: entityCtrl, + runner: rnr, + }, nil +} + +func buildHTTPServer(ctx context.Context, cfg *config.Config, comp *serverComponents) (*http.Server, net.Listener, error) { + authenticator := auth.NewAuthenticator(cfg.JWTAuth, comp.db) + controller, err := controllers.NewAPIController(comp.runner, authenticator, comp.hub, comp.agentHub, comp.metricsHub, cfg.APIServer) + if err != nil { + return nil, nil, fmt.Errorf("creating API controller: %w", err) + } + + instanceMiddleware, err := auth.NewInstanceMiddleware(comp.db, cfg.JWTAuth) + if err != nil { + return nil, nil, fmt.Errorf("creating instance middleware: %w", err) + } + + jwtMiddleware, err := auth.NewjwtMiddleware(comp.db, cfg.JWTAuth) + if err != nil { + return nil, nil, fmt.Errorf("creating JWT middleware: %w", err) + } + + initMiddleware, err := auth.NewInitRequiredMiddleware(comp.db) + if err != nil { + return nil, nil, fmt.Errorf("creating init-required middleware: %w", err) + } + + urlsRequiredMiddleware, err := auth.NewUrlsRequiredMiddleware(comp.db) + if err != nil { + return nil, nil, fmt.Errorf("creating URLs-required middleware: %w", err) + } + + metricsMiddleware, err := auth.NewMetricsMiddleware(cfg.JWTAuth) + if err != nil { + return nil, nil, fmt.Errorf("creating metrics middleware: %w", err) + } + + agentMiddleware, err := auth.AgentMiddleware(comp.db, cfg.JWTAuth) + if err != nil { + return nil, nil, fmt.Errorf("creating agent middleware: %w", err) + } + + router := routers.NewAPIRouter( + controller, + jwtMiddleware, + initMiddleware, + urlsRequiredMiddleware, + instanceMiddleware, + cfg.Default.EnableWebhookManagement) + + // Add WebUI routes + router = routers.WithWebUI(router, cfg.APIServer) + router = routers.WithAgentRouter(router, controller, agentMiddleware) + + // start the metrics collector + if cfg.Metrics.Enable { + slog.InfoContext(ctx, "setting up metric routes") + router = routers.WithMetricsRouter(router, cfg.Metrics.DisableAuth, metricsMiddleware) + + slog.InfoContext(ctx, "register metrics") + if err := metrics.RegisterMetrics(); err != nil { + return nil, nil, fmt.Errorf("registering metrics: %w", err) + } + + slog.InfoContext(ctx, "start metrics collection") + runnerMetrics.CollectObjectMetric(ctx, comp.runner, cfg.Metrics.Duration()) + } + + if cfg.Default.DebugServer { + runtime.SetBlockProfileRate(1) + runtime.SetMutexProfileFraction(1) + slog.InfoContext(ctx, "setting up debug routes") + router = routers.WithDebugServer(router) + } + + corsMw := mux.CORSMethodMiddleware(router) + router.Use(corsMw) + + allowedOrigins := handlers.AllowedOrigins(cfg.APIServer.CORSOrigins) + methodsOk := handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "OPTIONS", "DELETE"}) + headersOk := handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"}) + + srv := &http.Server{ + Addr: cfg.APIServer.BindAddress(), + // Pass our instance of gorilla/mux in. + Handler: handlers.CORS(methodsOk, headersOk, allowedOrigins)(router), + ReadHeaderTimeout: 5 * time.Second, + MaxHeaderBytes: 1 << 20, // 1 MB + // Increased timeouts to support large file uploads/downloads + // ReadTimeout covers the entire request read including body + ReadTimeout: 30 * time.Minute, + // WriteTimeout covers the entire response write + WriteTimeout: 30 * time.Minute, + IdleTimeout: 60 * time.Second, + } + + listener, err := net.Listen("tcp", srv.Addr) + if err != nil { + return nil, nil, fmt.Errorf("creating listener: %w", err) + } + + return srv, listener, nil +} + +func serve(ctx context.Context, srv *http.Server, listener net.Listener, apiCfg config.APIServer) { + if apiCfg.UseTLS { + if err := srv.ServeTLS(listener, apiCfg.TLSConfig.CRT, apiCfg.TLSConfig.Key); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "Listening") + } + } else { + if err := srv.Serve(listener); err != http.ErrServerClosed { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "Listening") + } + } +} + +func shutdownServer(srv *http.Server) { + slog.Info("shutting down http server") + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer shutdownCancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.With(slog.Any("error", err)).Error("graceful api server shutdown failed") + } +} + +// shutdownComponents stops all components in reverse startup order. +func shutdownComponents(comp *serverComponents) { + slog.Info("waiting for runner to stop") + if err := comp.runner.Wait(); err != nil { + slog.With(slog.Any("error", err)).Error("failed to shutdown runner") + os.Exit(1) + } + + slog.Info("shutting down entity controller") + if err := comp.entityCtrl.Stop(); err != nil { + slog.With(slog.Any("error", err)).Error("failed to stop entity controller") + } + + slog.Info("shutting down provider worker") + if err := comp.providerWorker.Stop(); err != nil { + slog.With(slog.Any("error", err)).Error("failed to stop provider worker") + } + + comp.metricsHub.Stop() //nolint + + if err := comp.cacheWorker.Stop(); err != nil { + slog.With(slog.Any("error", err)).Error("failed to stop cache worker") + } +} + +func maybeInitController(db dbCommon.Store) (params.ControllerInfo, error) { + if info, err := db.ControllerInfo(); err == nil { + return info, nil + } + + info, err := db.InitController() + if err != nil { + return params.ControllerInfo{}, fmt.Errorf("initializing controller: %w", err) + } + + return info, nil +} + +func setupLogging(ctx context.Context, logCfg config.Logging, hub *websocket.Hub) { + logWriter, err := util.GetLoggingWriter(logCfg.LogFile) + if err != nil { + log.Fatalf("fetching log writer: %+v", err) + } + + // rotate log file on SIGHUP + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGHUP) + go func() { + for { + select { + case <-ctx.Done(): + // Daemon is exiting. + return + case <-ch: + // we got a SIGHUP. Rotate log file. + if logger, ok := logWriter.(*lumberjack.Logger); ok { + if err := logger.Rotate(); err != nil { + slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to rotate log file") + } + } + } + } + }() + + var logLevel slog.Level + switch logCfg.LogLevel { + case config.LevelDebug: + logLevel = slog.LevelDebug + case config.LevelInfo: + logLevel = slog.LevelInfo + case config.LevelWarn: + logLevel = slog.LevelWarn + case config.LevelError: + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + + // logger options + opts := slog.HandlerOptions{ + AddSource: logCfg.LogSource, + Level: logLevel, + } + + var fileHan slog.Handler + switch logCfg.LogFormat { + case config.FormatJSON: + fileHan = slog.NewJSONHandler(logWriter, &opts) + default: + fileHan = slog.NewTextHandler(logWriter, &opts) + } + + slogHandlers := []slog.Handler{ + fileHan, + } + + if hub != nil { + wsHan := slog.NewJSONHandler(hub, &opts) + slogHandlers = append(slogHandlers, wsHan) + } + + wrapped := &garmUtil.SlogMultiHandler{ + Handlers: slogHandlers, + } + slog.SetDefault(slog.New(wrapped)) +} diff --git a/cmd/garm/cmd/version.go b/cmd/garm/cmd/version.go new file mode 100644 index 000000000..a2699809b --- /dev/null +++ b/cmd/garm/cmd/version.go @@ -0,0 +1,35 @@ +// Copyright 2026 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/spf13/cobra" + + "github.com/cloudbase/garm/util/appdefaults" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version and exit", + Run: func(_ *cobra.Command, _ []string) { + fmt.Println(appdefaults.GetVersion()) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/cmd/garm/main.go b/cmd/garm/main.go index c88224395..178e9ba3c 100644 --- a/cmd/garm/main.go +++ b/cmd/garm/main.go @@ -1,4 +1,4 @@ -// Copyright 2022 Cloudbase Solutions SRL +// Copyright 2026 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 @@ -14,413 +14,8 @@ package main -import ( - "context" - "flag" - "fmt" - "log" - "log/slog" - "net" - "net/http" - "os" - "os/signal" - "runtime" - "syscall" - "time" +import "github.com/cloudbase/garm/cmd/garm/cmd" - "github.com/gorilla/handlers" - "github.com/gorilla/mux" - lumberjack "gopkg.in/natefinch/lumberjack.v2" - - "github.com/cloudbase/garm-provider-common/util" - "github.com/cloudbase/garm/apiserver/controllers" - "github.com/cloudbase/garm/apiserver/routers" - "github.com/cloudbase/garm/auth" - "github.com/cloudbase/garm/config" - "github.com/cloudbase/garm/database" - "github.com/cloudbase/garm/database/common" - "github.com/cloudbase/garm/database/watcher" - "github.com/cloudbase/garm/locking" - "github.com/cloudbase/garm/metrics" - "github.com/cloudbase/garm/params" - "github.com/cloudbase/garm/runner" //nolint:typecheck - runnerMetrics "github.com/cloudbase/garm/runner/metrics" - "github.com/cloudbase/garm/runner/providers" - garmUtil "github.com/cloudbase/garm/util" - "github.com/cloudbase/garm/util/appdefaults" - "github.com/cloudbase/garm/websocket" - "github.com/cloudbase/garm/workers/cache" - "github.com/cloudbase/garm/workers/entity" - "github.com/cloudbase/garm/workers/provider" - "github.com/cloudbase/garm/workers/websocket/agent" - wsMetrics "github.com/cloudbase/garm/workers/websocket/metrics" -) - -var ( - conf = flag.String("config", appdefaults.DefaultConfigFilePath, "garm config file") - version = flag.Bool("version", false, "prints version") -) - -var signals = []os.Signal{ - os.Interrupt, - syscall.SIGTERM, -} - -func maybeInitController(db common.Store) (params.ControllerInfo, error) { - if info, err := db.ControllerInfo(); err == nil { - return info, nil - } - - info, err := db.InitController() - if err != nil { - return params.ControllerInfo{}, fmt.Errorf("error initializing controller: %w", err) - } - - return info, nil -} - -func setupLogging(ctx context.Context, logCfg config.Logging, hub *websocket.Hub) { - logWriter, err := util.GetLoggingWriter(logCfg.LogFile) - if err != nil { - log.Fatalf("fetching log writer: %+v", err) - } - - // rotate log file on SIGHUP - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGHUP) - go func() { - for { - select { - case <-ctx.Done(): - // Daemon is exiting. - return - case <-ch: - // we got a SIGHUP. Rotate log file. - if logger, ok := logWriter.(*lumberjack.Logger); ok { - if err := logger.Rotate(); err != nil { - slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to rotate log file") - } - } - } - } - }() - - var logLevel slog.Level - switch logCfg.LogLevel { - case config.LevelDebug: - logLevel = slog.LevelDebug - case config.LevelInfo: - logLevel = slog.LevelInfo - case config.LevelWarn: - logLevel = slog.LevelWarn - case config.LevelError: - logLevel = slog.LevelError - default: - logLevel = slog.LevelInfo - } - - // logger options - opts := slog.HandlerOptions{ - AddSource: logCfg.LogSource, - Level: logLevel, - } - - var fileHan slog.Handler - switch logCfg.LogFormat { - case config.FormatJSON: - fileHan = slog.NewJSONHandler(logWriter, &opts) - default: - fileHan = slog.NewTextHandler(logWriter, &opts) - } - - handlers := []slog.Handler{ - fileHan, - } - - if hub != nil { - wsHan := slog.NewJSONHandler(hub, &opts) - handlers = append(handlers, wsHan) - } - - wrapped := &garmUtil.SlogMultiHandler{ - Handlers: handlers, - } - slog.SetDefault(slog.New(wrapped)) -} - -func maybeUpdateURLsFromConfig(cfg config.Config, store common.Store) error { - info, err := store.ControllerInfo() - if err != nil { - return fmt.Errorf("error fetching controller info: %w", err) - } - - 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 fmt.Errorf("error updating controller info: %w", err) - } - return nil -} - -//gocyclo:ignore func main() { - flag.Parse() - if *version { - fmt.Println(appdefaults.GetVersion()) - return - } - ctx, stop := signal.NotifyContext(context.Background(), signals...) - defer stop() - watcher.InitWatcher(ctx) - - ctx = auth.GetAdminContext(ctx) - - cfg, err := config.NewConfig(*conf) - if err != nil { - log.Fatalf("Fetching config: %+v", err) //nolint:gocritic - } - - logCfg := cfg.GetLoggingConfig() - var hub *websocket.Hub - if logCfg.EnableLogStreamer != nil && *logCfg.EnableLogStreamer { - hub = websocket.NewHub(ctx) - if err := hub.Start(); err != nil { - log.Fatal(err) - } - defer hub.Stop() //nolint - } - setupLogging(ctx, logCfg, hub) - - // Migrate credentials to the new format. This field will be read - // by the DB migration logic. - cfg.Database.MigrateCredentials = cfg.Github - db, err := database.NewDatabase(ctx, cfg.Database) - if err != nil { - log.Fatal(err) - } - - controllerInfo, err := maybeInitController(db) - if err != nil { - log.Fatal(err) - } - - agentHub, err := agent.NewHub(ctx) - if err != nil { - log.Fatalf("failed to create agent hub: %q", err) - } - - if err := agentHub.Start(); err != nil { - log.Fatalf("failed to start agent hub: %q", err) - } - - // Local locker for now. Will be configurable in the future, - // as we add scale-out capability to GARM. - lock, err := locking.NewLocalLocker(ctx, db) - if err != nil { - log.Fatalf("failed to create locker: %q", err) - } - - if err := locking.RegisterLocker(lock); err != nil { - log.Fatalf("failed to register locker: %q", err) - } - - if err := maybeUpdateURLsFromConfig(*cfg, db); err != nil { - log.Fatal(err) - } - - instanceTokenGetter, err := auth.NewInstanceTokenGetter(cfg.JWTAuth.Secret) - if err != nil { - log.Fatalf("failed to create instance token getter: %+v", err) - } - - runner, err := runner.NewRunner(ctx, *cfg, db, instanceTokenGetter) - if err != nil { - log.Fatalf("failed to create controller: %+v", err) - } - - cacheWorker := cache.NewWorker(ctx, db, runner) - if err := cacheWorker.Start(); err != nil { - log.Fatalf("failed to start cache worker: %+v", err) - } - - metricsHub := wsMetrics.NewMetricsHub(ctx) - if err := metricsHub.Start(); err != nil { - log.Fatalf("failed to start metrics hub: %+v", err) - } - defer metricsHub.Stop() //nolint - - providers, err := providers.LoadProvidersFromConfig(ctx, *cfg, controllerInfo.ControllerID.String()) - if err != nil { - log.Fatalf("loading providers: %+v", err) - } - - // Provider worker must start first — its watcher consumer must be - // registered before entity/scaleset workers create instances. - providerWorker, err := provider.NewWorker(ctx, db, providers, instanceTokenGetter) - if err != nil { - log.Fatalf("failed to create provider worker: %+v", err) - } - if err := providerWorker.Start(); err != nil { - log.Fatalf("failed to start provider worker: %+v", err) - } - - entityController, err := entity.NewController(ctx, db, providers) - if err != nil { - log.Fatalf("failed to create entity controller: %+v", err) - } - if err := entityController.Start(); err != nil { - log.Fatalf("failed to start entity controller: %+v", err) - } - - // If there are many repos/pools, this may take a long time. - if err := runner.Start(); err != nil { - log.Fatal(err) - } - - authenticator := auth.NewAuthenticator(cfg.JWTAuth, db) - controller, err := controllers.NewAPIController(runner, authenticator, hub, agentHub, metricsHub, cfg.APIServer) - if err != nil { - log.Fatalf("failed to create controller: %+v", err) - } - - instanceMiddleware, err := auth.NewInstanceMiddleware(db, cfg.JWTAuth) - if err != nil { - log.Fatal(err) - } - - jwtMiddleware, err := auth.NewjwtMiddleware(db, cfg.JWTAuth) - if err != nil { - log.Fatal(err) - } - - initMiddleware, err := auth.NewInitRequiredMiddleware(db) - if err != nil { - 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) - } - agentMiddleware, err := auth.AgentMiddleware(db, cfg.JWTAuth) - if err != nil { - log.Fatal(err) - } - - router := routers.NewAPIRouter(controller, jwtMiddleware, initMiddleware, urlsRequiredMiddleware, instanceMiddleware, cfg.Default.EnableWebhookManagement) - - // Add WebUI routes - router = routers.WithWebUI(router, cfg.APIServer) - router = routers.WithAgentRouter(router, controller, agentMiddleware) - - // start the metrics collector - if cfg.Metrics.Enable { - slog.InfoContext(ctx, "setting up metric routes") - router = routers.WithMetricsRouter(router, cfg.Metrics.DisableAuth, metricsMiddleware) - - slog.InfoContext(ctx, "register metrics") - if err := metrics.RegisterMetrics(); err != nil { - log.Fatal(err) - } - - slog.InfoContext(ctx, "start metrics collection") - runnerMetrics.CollectObjectMetric(ctx, runner, cfg.Metrics.Duration()) - } - - if cfg.Default.DebugServer { - runtime.SetBlockProfileRate(1) - runtime.SetMutexProfileFraction(1) - slog.InfoContext(ctx, "setting up debug routes") - router = routers.WithDebugServer(router) - } - - corsMw := mux.CORSMethodMiddleware(router) - router.Use(corsMw) - - allowedOrigins := handlers.AllowedOrigins(cfg.APIServer.CORSOrigins) - methodsOk := handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "OPTIONS", "DELETE"}) - headersOk := handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"}) - - srv := &http.Server{ - Addr: cfg.APIServer.BindAddress(), - // Pass our instance of gorilla/mux in. - Handler: handlers.CORS(methodsOk, headersOk, allowedOrigins)(router), - ReadHeaderTimeout: 5 * time.Second, - MaxHeaderBytes: 1 << 20, // 1 MB - // Increased timeouts to support large file uploads/downloads - // ReadTimeout covers the entire request read including body - ReadTimeout: 30 * time.Minute, - // WriteTimeout covers the entire response write - WriteTimeout: 30 * time.Minute, - IdleTimeout: 60 * time.Second, - } - - listener, err := net.Listen("tcp", srv.Addr) - if err != nil { - log.Fatalf("creating listener: %q", err) - } - - go func() { - if cfg.APIServer.UseTLS { - if err := srv.ServeTLS(listener, cfg.APIServer.TLSConfig.CRT, cfg.APIServer.TLSConfig.Key); err != nil { - slog.With(slog.Any("error", err)).ErrorContext(ctx, "Listening") - } - } else { - if err := srv.Serve(listener); err != http.ErrServerClosed { - slog.With(slog.Any("error", err)).ErrorContext(ctx, "Listening") - } - } - }() - - <-ctx.Done() - - slog.InfoContext(ctx, "shutting down http server") - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 60*time.Second) - defer shutdownCancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - slog.With(slog.Any("error", err)).ErrorContext(ctx, "graceful api server shutdown failed") - } - - if err := cacheWorker.Stop(); err != nil { - slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to stop credentials worker") - } - - slog.InfoContext(ctx, "shutting down entity controller") - if err := entityController.Stop(); err != nil { - slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to stop entity controller") - } - - slog.InfoContext(ctx, "shutting down provider worker") - if err := providerWorker.Stop(); err != nil { - slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to stop provider worker") - } - - slog.With(slog.Any("error", err)).InfoContext(ctx, "waiting for runner to stop") - if err := runner.Wait(); err != nil { - slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to shutdown workers") - os.Exit(1) - } + cmd.Execute() } diff --git a/config/config.go b/config/config.go index f2c9083ff..62c809535 100644 --- a/config/config.go +++ b/config/config.go @@ -15,14 +15,10 @@ package config import ( - "context" "crypto/tls" - "crypto/x509" - "encoding/pem" "fmt" "log/slog" "net" - "net/http" "net/url" "os" "path/filepath" @@ -30,19 +26,16 @@ import ( "time" "github.com/BurntSushi/toml" - "github.com/bradleyfalzon/ghinstallation/v2" zxcvbn "github.com/nbutton23/zxcvbn-go" - "golang.org/x/oauth2" "github.com/cloudbase/garm/params" "github.com/cloudbase/garm/util/appdefaults" ) type ( - DBBackendType string - LogLevel string - LogFormat string - GithubAuthType string + DBBackendType string + LogLevel string + LogFormat string ) const ( @@ -75,13 +68,6 @@ const ( FormatJSON LogFormat = "json" ) -const ( - // GithubAuthTypePAT is the OAuth token based authentication - GithubAuthTypePAT GithubAuthType = "pat" - // GithubAuthTypeApp is the GitHub App based authentication - GithubAuthTypeApp GithubAuthType = "app" -) - // NewConfig returns a new Config func NewConfig(cfgFile string) (*Config, error) { var config Config @@ -100,7 +86,6 @@ type Config struct { Metrics Metrics `toml:"metrics,omitempty" json:"metrics,omitempty"` Database Database `toml:"database,omitempty" json:"database,omitempty"` Providers []Provider `toml:"provider,omitempty" json:"provider,omitempty"` - Github []Github `toml:"github,omitempty"` JWTAuth JWTAuth `toml:"jwt_auth" json:"jwt-auth"` Logging Logging `toml:"logging" json:"logging"` } @@ -118,12 +103,6 @@ func (c *Config) Validate() error { return fmt.Errorf("error validating default config: %w", err) } - for _, gh := range c.Github { - if err := gh.Validate(); err != nil { - return fmt.Errorf("error validating github config: %w", err) - } - } - if err := c.JWTAuth.Validate(); err != nil { return fmt.Errorf("error validating jwt_auth config: %w", err) } @@ -235,218 +214,6 @@ func (d *Default) Validate() error { return nil } -type GithubPAT struct { - OAuth2Token string `toml:"oauth2_token" json:"oauth2-token"` -} - -type GithubApp struct { - AppID int64 `toml:"app_id" json:"app-id"` - PrivateKeyPath string `toml:"private_key_path" json:"private-key-path"` - InstallationID int64 `toml:"installation_id" json:"installation-id"` -} - -func (a *GithubApp) PrivateKeyBytes() ([]byte, error) { - keyBytes, err := os.ReadFile(a.PrivateKeyPath) - if err != nil { - return nil, fmt.Errorf("reading private_key_path: %w", err) - } - return keyBytes, nil -} - -func (a *GithubApp) Validate() error { - if a.AppID == 0 { - return fmt.Errorf("missing app_id") - } - if a.PrivateKeyPath == "" { - return fmt.Errorf("missing private_key_path") - } - if a.InstallationID == 0 { - return fmt.Errorf("missing installation_id") - } - - if _, err := os.Stat(a.PrivateKeyPath); err != nil { - return fmt.Errorf("error accessing private_key_path: %w", err) - } - // Read the private key as bytes - keyBytes, err := os.ReadFile(a.PrivateKeyPath) - if err != nil { - return fmt.Errorf("reading private_key_path: %w", err) - } - block, _ := pem.Decode(keyBytes) - // Parse the private key as PCKS1 - _, err = x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return fmt.Errorf("parsing private_key_path: %w", err) - } - - return nil -} - -// Github hold configuration options specific to interacting with github. -// Currently that is just a OAuth2 personal token. -type Github struct { - Name string `toml:"name" json:"name"` - Description string `toml:"description" json:"description"` - // OAuth2Token is the personal access token used to authenticate with the - // github API. This is deprecated and will be removed in the future. - // Use the PAT section instead. - OAuth2Token string `toml:"oauth2_token" json:"oauth2-token"` - APIBaseURL string `toml:"api_base_url" json:"api-base-url"` - UploadBaseURL string `toml:"upload_base_url" json:"upload-base-url"` - BaseURL string `toml:"base_url" json:"base-url"` - // CACertBundlePath is the path on disk to a CA certificate bundle that - // can validate the endpoints defined above. Leave empty if not using a - // self signed certificate. - CACertBundlePath string `toml:"ca_cert_bundle" json:"ca-cert-bundle"` - AuthType GithubAuthType `toml:"auth_type" json:"auth-type"` - PAT GithubPAT `toml:"pat" json:"pat"` - App GithubApp `toml:"app" json:"app"` -} - -func (g *Github) GetAuthType() GithubAuthType { - if g.AuthType == "" { - return GithubAuthTypePAT - } - return g.AuthType -} - -func (g *Github) APIEndpoint() string { - if g.APIBaseURL != "" { - return g.APIBaseURL - } - return appdefaults.GithubDefaultBaseURL -} - -func (g *Github) CACertBundle() ([]byte, error) { - if g.CACertBundlePath == "" { - // No CA bundle defined. - return nil, nil - } - if _, err := os.Stat(g.CACertBundlePath); err != nil { - return nil, fmt.Errorf("error accessing ca_cert_bundle: %w", err) - } - - contents, err := os.ReadFile(g.CACertBundlePath) - if err != nil { - return nil, fmt.Errorf("reading ca_cert_bundle: %w", err) - } - - roots := x509.NewCertPool() - if ok := roots.AppendCertsFromPEM(contents); !ok { - return nil, fmt.Errorf("failed to parse CA cert bundle") - } - - return contents, nil -} - -func (g *Github) UploadEndpoint() string { - if g.UploadBaseURL == "" { - if g.APIBaseURL != "" { - return g.APIBaseURL - } - return appdefaults.GithubDefaultUploadBaseURL - } - return g.UploadBaseURL -} - -func (g *Github) BaseEndpoint() string { - if g.BaseURL != "" { - return g.BaseURL - } - return appdefaults.DefaultGithubURL -} - -func (g *Github) Validate() error { - if g.Name == "" { - return fmt.Errorf("missing credentials name") - } - - if g.APIBaseURL != "" { - if _, err := url.ParseRequestURI(g.APIBaseURL); err != nil { - return fmt.Errorf("invalid api_base_url: %w", err) - } - } - - if g.UploadBaseURL != "" { - if _, err := url.ParseRequestURI(g.UploadBaseURL); err != nil { - return fmt.Errorf("invalid upload_base_url: %w", err) - } - } - - if g.BaseURL != "" { - if _, err := url.ParseRequestURI(g.BaseURL); err != nil { - return fmt.Errorf("invalid base_url: %w", err) - } - } - - switch g.AuthType { - case GithubAuthTypeApp: - if err := g.App.Validate(); err != nil { - return fmt.Errorf("invalid github app config: %w", err) - } - default: - if g.OAuth2Token == "" && g.PAT.OAuth2Token == "" { - return fmt.Errorf("missing github oauth2 token") - } - if g.OAuth2Token != "" { - slog.Warn("the github.oauth2_token option is deprecated, please use the PAT section") - } - } - - return nil -} - -func (g *Github) HTTPClient(ctx context.Context) (*http.Client, error) { - if err := g.Validate(); err != nil { - return nil, fmt.Errorf("invalid github config: %w", err) - } - var roots *x509.CertPool - caBundle, err := g.CACertBundle() - if err != nil { - return nil, fmt.Errorf("fetching CA cert bundle: %w", err) - } - if caBundle != nil { - roots = x509.NewCertPool() - ok := roots.AppendCertsFromPEM(caBundle) - if !ok { - return nil, fmt.Errorf("failed to parse CA cert") - } - } - // nolint:golangci-lint,gosec,godox - // TODO: set TLS MinVersion - httpTransport := &http.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: roots, - }, - } - - var tc *http.Client - switch g.AuthType { - case GithubAuthTypeApp: - itr, err := ghinstallation.NewKeyFromFile(httpTransport, g.App.AppID, g.App.InstallationID, g.App.PrivateKeyPath) - if err != nil { - return nil, fmt.Errorf("failed to create github app installation transport: %w", err) - } - - tc = &http.Client{Transport: itr} - default: - httpClient := &http.Client{Transport: httpTransport} - ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - - token := g.PAT.OAuth2Token - if token == "" { - token = g.OAuth2Token - } - - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ) - tc = oauth2.NewClient(ctx, ts) - } - - return tc, nil -} - // Provider holds access information for a particular provider. // A provider offers compute resources on which we spin up self hosted runners. type Provider struct { @@ -489,11 +256,6 @@ type Database struct { // Don't lose or change this. It will invalidate all encrypted data // in the DB. This field must be set and must be exactly 32 characters. Passphrase string `toml:"passphrase"` - - // MigrateCredentials is a list of github credentials that need to be migrated - // from the config file to the database. This field will be removed once GARM - // reaches version 0.2.x. It's only meant to be used for the migration process. - MigrateCredentials []Github `toml:"-"` } // GormParams returns the database type and connection URI diff --git a/config/config_test.go b/config/config_test.go index 6c052bcbf..ff17907a0 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -15,15 +15,12 @@ package config import ( - "context" "os" "path/filepath" "testing" "time" - "github.com/bradleyfalzon/ghinstallation/v2" "github.com/stretchr/testify/require" - "golang.org/x/oauth2" "github.com/cloudbase/garm/util/appdefaults" ) @@ -94,16 +91,6 @@ func getDefaultProvidersConfig() []Provider { return []Provider{} } -func getDefaultGithubConfig() []Github { - return []Github{ - { - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "bogus", - }, - } -} - func getDefaultJWTCofig() JWTAuth { return JWTAuth{ Secret: EncryptionPassphrase, @@ -123,7 +110,6 @@ func getDefaultConfig(t *testing.T) Config { APIServer: getDefaultAPIServerConfig(), Database: getDefaultDatabaseConfig(dir), Providers: getDefaultProvidersConfig(), - Github: getDefaultGithubConfig(), JWTAuth: getDefaultJWTCofig(), } } @@ -787,374 +773,3 @@ func TestNewConfigInvalidConfig(t *testing.T) { require.NotNil(t, err) require.Regexp(t, "validating config", err.Error()) } - -func TestGithubConfig(t *testing.T) { - cfg := getDefaultGithubConfig() - - tests := []struct { - name string - cfg Github - errString string - }{ - { - name: "Config is valid", - cfg: cfg[0], - errString: "", - }, - { - name: "BaseURL is invalid", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - BaseURL: "bogus", - AuthType: GithubAuthTypePAT, - PAT: GithubPAT{ - OAuth2Token: "bogus", - }, - }, - errString: "invalid base_url: parse.*", - }, - { - name: "APIBaseURL is invalid", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - APIBaseURL: "bogus", - AuthType: GithubAuthTypePAT, - PAT: GithubPAT{ - OAuth2Token: "bogus", - }, - }, - errString: "invalid api_base_url: parse.*", - }, - { - name: "UploadBaseURL is invalid", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - UploadBaseURL: "bogus", - AuthType: GithubAuthTypePAT, - PAT: GithubPAT{ - OAuth2Token: "bogus", - }, - }, - errString: "invalid upload_base_url: parse.*", - }, - { - name: "BaseURL is set and is valid", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - BaseURL: "https://github.example.com/", - AuthType: GithubAuthTypePAT, - PAT: GithubPAT{ - OAuth2Token: "bogus", - }, - }, - }, - { - name: "APIBaseURL is set and is valid", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - APIBaseURL: "https://github.example.com/api/v3", - AuthType: GithubAuthTypePAT, - PAT: GithubPAT{ - OAuth2Token: "bogus", - }, - }, - }, - { - name: "UploadBaseURL is set and is valid", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - UploadBaseURL: "https://github.example.com/uploads", - AuthType: GithubAuthTypePAT, - PAT: GithubPAT{ - OAuth2Token: "bogus", - }, - }, - }, - { - name: "OAuth2Token is empty", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "", - }, - errString: "missing github oauth2 token", - }, - { - name: "Name is empty", - cfg: Github{ - Name: "", - Description: "dummy github credentials", - OAuth2Token: "bogus", - }, - errString: "missing credentials name", - }, - { - name: "OAuth token is set in the PAT section", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "", - AuthType: GithubAuthTypePAT, - PAT: GithubPAT{ - OAuth2Token: "bogus", - }, - }, - }, - { - name: "OAuth token is empty in the PAT section", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "", - AuthType: GithubAuthTypePAT, - PAT: GithubPAT{ - OAuth2Token: "", - }, - }, - errString: "missing github oauth2 token", - }, - { - name: "Valid App section", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "", - AuthType: GithubAuthTypeApp, - App: GithubApp{ - AppID: 1, - InstallationID: 99, - PrivateKeyPath: "../testdata/certs/srv-key.pem", - }, - }, - }, - { - name: "AppID is missing", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "", - AuthType: GithubAuthTypeApp, - App: GithubApp{ - AppID: 0, - InstallationID: 99, - PrivateKeyPath: "../testdata/certs/srv-key.pem", - }, - }, - errString: "missing app_id", - }, - { - name: "InstallationID is missing", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "", - AuthType: GithubAuthTypeApp, - App: GithubApp{ - AppID: 1, - InstallationID: 0, - PrivateKeyPath: "../testdata/certs/srv-key.pem", - }, - }, - errString: "missing installation_id", - }, - { - name: "PrivateKeyPath is missing", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "", - AuthType: GithubAuthTypeApp, - App: GithubApp{ - AppID: 1, - InstallationID: 99, - PrivateKeyPath: "", - }, - }, - errString: "missing private_key_path", - }, - { - name: "PrivateKeyPath is invalid", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "", - AuthType: GithubAuthTypeApp, - App: GithubApp{ - AppID: 1, - InstallationID: 99, - PrivateKeyPath: "/i/dont/exist", - }, - }, - errString: "invalid github app config: error accessing private_key_path: stat /i/dont/exist: no such file or directory", - }, - { - name: "PrivateKeyPath is not a valid RSA private key", - cfg: Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "", - AuthType: GithubAuthTypeApp, - App: GithubApp{ - AppID: 1, - InstallationID: 99, - PrivateKeyPath: "../testdata/certs/srv-pub.pem", - }, - }, - errString: "invalid github app config: parsing private_key_path: asn1: structure error:.*", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := tc.cfg.Validate() - if tc.errString == "" { - require.Nil(t, err) - } else { - require.NotNil(t, err) - require.Regexp(t, tc.errString, err.Error()) - } - }) - } -} - -func TestGithubAPIEndpoint(t *testing.T) { - cfg := getDefaultGithubConfig() - - require.Equal(t, "https://api.github.com/", cfg[0].APIEndpoint()) -} - -func TestGithubAPIEndpointIsSet(t *testing.T) { - cfg := getDefaultGithubConfig() - cfg[0].APIBaseURL = "https://github.example.com/api/v3" - - require.Equal(t, "https://github.example.com/api/v3", cfg[0].APIEndpoint()) -} - -func TestUploadEndpoint(t *testing.T) { - cfg := getDefaultGithubConfig() - - require.Equal(t, "https://uploads.github.com/", cfg[0].UploadEndpoint()) -} - -func TestUploadEndpointIsSet(t *testing.T) { - cfg := getDefaultGithubConfig() - cfg[0].UploadBaseURL = "https://github.example.com/uploads" - - require.Equal(t, "https://github.example.com/uploads", cfg[0].UploadEndpoint()) -} - -func TestGithubBaseURL(t *testing.T) { - cfg := getDefaultGithubConfig() - - require.Equal(t, "https://github.com", cfg[0].BaseEndpoint()) -} - -func TestGithubBaseURLIsSet(t *testing.T) { - cfg := getDefaultGithubConfig() - cfg[0].BaseURL = "https://github.example.com" - - require.Equal(t, "https://github.example.com", cfg[0].BaseEndpoint()) -} - -func TestCACertBundle(t *testing.T) { - cfg := Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "bogus", - CACertBundlePath: "../testdata/certs/srv-pub.pem", - } - - cert, err := cfg.CACertBundle() - require.Nil(t, err) - require.NotNil(t, cert) -} - -func TestCACertBundleInvalidPath(t *testing.T) { - cfg := Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "bogus", - CACertBundlePath: "/i/dont/exist", - } - - cert, err := cfg.CACertBundle() - require.NotNil(t, err) - require.EqualError(t, err, "error accessing ca_cert_bundle: stat /i/dont/exist: no such file or directory") - require.Nil(t, cert) -} - -func TestCACertBundleInvalidFile(t *testing.T) { - cfg := Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "bogus", - CACertBundlePath: "../testdata/config.toml", - } - - cert, err := cfg.CACertBundle() - require.NotNil(t, err) - require.EqualError(t, err, "failed to parse CA cert bundle") - require.Nil(t, cert) -} - -func TestGithubHTTPClientDeprecatedPAT(t *testing.T) { - cfg := Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - OAuth2Token: "bogus", - } - - client, err := cfg.HTTPClient(context.Background()) - require.Nil(t, err) - require.NotNil(t, client) - - transport, ok := client.Transport.(*oauth2.Transport) - require.True(t, ok) - require.NotNil(t, transport) -} - -func TestGithubHTTPClientPAT(t *testing.T) { - cfg := Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - AuthType: GithubAuthTypePAT, - PAT: GithubPAT{ - OAuth2Token: "bogus", - }, - } - - client, err := cfg.HTTPClient(context.Background()) - require.Nil(t, err) - require.NotNil(t, client) - - transport, ok := client.Transport.(*oauth2.Transport) - require.True(t, ok) - require.NotNil(t, transport) -} - -func TestGithubHTTPClientApp(t *testing.T) { - cfg := Github{ - Name: "dummy_creds", - Description: "dummy github credentials", - AuthType: GithubAuthTypeApp, - App: GithubApp{ - AppID: 1, - InstallationID: 99, - PrivateKeyPath: "../testdata/certs/srv-key.pem", - }, - } - - client, err := cfg.HTTPClient(context.Background()) - require.Nil(t, err) - require.NotNil(t, client) - - transport, ok := client.Transport.(*ghinstallation.Transport) - require.True(t, ok) - require.NotNil(t, transport) -} diff --git a/internal/templates/userdata/gitea_linux_userdata.tmpl b/internal/templates/userdata/gitea_linux_userdata.tmpl index b64dec990..eeec09809 100644 --- a/internal/templates/userdata/gitea_linux_userdata.tmpl +++ b/internal/templates/userdata/gitea_linux_userdata.tmpl @@ -62,26 +62,26 @@ AGENT_MODE="" {{- end }} if [ "$AGENT_MODE" == "true" ]; then - sendStatus "Agent mode is enabled; setting up agent" - DOWNLOAD_URL="{{ .AgentDownloadURL }}" - AGENT_URL="{{ .AgentURL }}" - AGENT_TOKEN="{{ .AgentToken }}" - AGENT_SHELL={{ .AgentShell }} - sendStatus "Downloading agent from $DOWNLOAD_URL" - sudo curl --retry 5 \ - --retry-delay 5 \ - --retry-connrefused \ - --fail -L \ - -H "Authorization: Bearer ${BEARER_TOKEN}" \ - -o /usr/local/bin/garm-agent "$DOWNLOAD_URL" || fail "failed to download garm-agent" - sudo chmod +x /usr/local/bin/garm-agent || fail "failed to make garm-agent executable" - sudo mkdir -p /var/log/garm-agent || fail "failed to create /var/log/garm-agent" - sudo chown {{ .RunnerUsername }}:{{ .RunnerUsername }} /var/log/garm-agent || fail "failed to chown /var/log/garm-agent" - sudo mkdir -p /etc/garm-agent || fail "failed to create /etc/garm" - sudo chown {{ .RunnerUsername }}:{{ .RunnerUsername }} /etc/garm-agent || fail "failed to change owner on /etc/garm-agent" - - sendStatus "Creating config and systemd unit" - cat > /etc/garm-agent/garm-agent.toml << EOF + sendStatus "Agent mode is enabled; setting up agent" + DOWNLOAD_URL="{{ .AgentDownloadURL }}" + AGENT_URL="{{ .AgentURL }}" + AGENT_TOKEN="{{ .AgentToken }}" + AGENT_SHELL={{ .AgentShell }} + sendStatus "Downloading agent from $DOWNLOAD_URL" + sudo curl --retry 5 \ + --retry-delay 5 \ + --retry-connrefused \ + --fail -L \ + -H "Authorization: Bearer ${BEARER_TOKEN}" \ + -o /usr/local/bin/garm-agent "$DOWNLOAD_URL" || fail "failed to download garm-agent" + sudo chmod +x /usr/local/bin/garm-agent || fail "failed to make garm-agent executable" + sudo mkdir -p /var/log/garm-agent || fail "failed to create /var/log/garm-agent" + sudo chown {{ .RunnerUsername }}:{{ .RunnerUsername }} /var/log/garm-agent || fail "failed to chown /var/log/garm-agent" + sudo mkdir -p /etc/garm-agent || fail "failed to create /etc/garm" + sudo chown {{ .RunnerUsername }}:{{ .RunnerUsername }} /etc/garm-agent || fail "failed to change owner on /etc/garm-agent" + + sendStatus "Creating config and systemd unit" + cat > /etc/garm-agent/garm-agent.toml << EOF server_url = "$AGENT_URL" log_file = "/var/log/garm-agent/garm-agent.log" work_dir = "$RUN_HOME" @@ -91,7 +91,7 @@ runner_cmdline = ["$RUN_HOME/gitea-runner", "daemon", "--once"] state_db_path = "/etc/garm-agent/agent-state.db" EOF - cat > /tmp/garm-agent.service << EOF + cat > /tmp/garm-agent.service << EOF [Unit] Description=GARM agent After=multi-user.target @@ -110,15 +110,15 @@ Environment=LANG=en_US.UTF-8 WantedBy=multi-user.target EOF - sudo mv /tmp/garm-agent.service /etc/systemd/system/garm-agent.service || fail "failed to create /etc/systemd/system/garm-agent.service" - sudo chown root:root /etc/systemd/system/garm-agent.service || fail "failed to change owner on /etc/systemd/system/garm-agent.service" - sendStatus "Reloading systemd unit" - sudo systemctl daemon-reload || fail "failed to reload systemd" + sudo mv /tmp/garm-agent.service /etc/systemd/system/garm-agent.service || fail "failed to create /etc/systemd/system/garm-agent.service" + sudo chown root:root /etc/systemd/system/garm-agent.service || fail "failed to change owner on /etc/systemd/system/garm-agent.service" + sendStatus "Reloading systemd unit" + sudo systemctl daemon-reload || fail "failed to reload systemd" fi function downloadAndExtractRunner() { sendStatus "downloading tools from {{ .DownloadURL }}" - mkdir -p "$RUN_HOME" || fail "failed to create actions-runner folder" + mkdir -p "$RUN_HOME" || fail "failed to create actions-runner folder" curl --retry 5 --retry-delay 5 --retry-connrefused --fail -L -o "$RUN_HOME/gitea-runner" "{{ .DownloadURL }}" || fail "failed to download tools" chown {{ .RunnerUsername }}:{{ .RunnerGroup }} -R "$RUN_HOME"/ || fail "failed to change owner" chmod +x "$RUN_HOME/gitea-runner" || fail "failed to set executable flag" @@ -175,8 +175,8 @@ SVC_NAME=$(cat "$RUN_HOME"/.service) AGENT_ID="" sendStatus "starting service" if [ "$AGENT_MODE" == "true" ]; then - sendStatus "Enabling garm-agent service" - sudo systemctl enable --now garm-agent + sendStatus "Enabling garm-agent service" + sudo systemctl enable --now garm-agent else sendStatus "generating systemd unit file" getRunnerFile "systemd/unit-file?runAsUser={{ .RunnerUsername }}" "$SVC_NAME" || fail "failed to get service file" diff --git a/internal/templates/userdata/github_linux_userdata.tmpl b/internal/templates/userdata/github_linux_userdata.tmpl index 609623cff..1c97c340f 100644 --- a/internal/templates/userdata/github_linux_userdata.tmpl +++ b/internal/templates/userdata/github_linux_userdata.tmpl @@ -71,26 +71,26 @@ AGENT_MODE="" {{- end }} if [ "$AGENT_MODE" == "true" ]; then - sendStatus "Agent mode is enabled; setting up agent" - DOWNLOAD_URL="{{ .AgentDownloadURL }}" - AGENT_URL="{{ .AgentURL }}" - AGENT_TOKEN="{{ .AgentToken }}" - AGENT_SHELL={{ .AgentShell }} - sendStatus "Downloading agent from $DOWNLOAD_URL" - sudo curl --retry 5 \ - --retry-delay 5 \ - --retry-connrefused \ - --fail -L \ - -H "Authorization: Bearer ${BEARER_TOKEN}" \ - -o /usr/local/bin/garm-agent "$DOWNLOAD_URL" || fail "failed to download garm-agent" - sudo chmod +x /usr/local/bin/garm-agent || fail "failed to make garm-agent executable" - sudo mkdir -p /var/log/garm-agent || fail "failed to create /var/log/garm-agent" - sudo chown {{ .RunnerUsername }}:{{ .RunnerUsername }} /var/log/garm-agent || fail "failed to chown /var/log/garm-agent" - sudo mkdir -p /etc/garm-agent || fail "failed to create /etc/garm" - sudo chown {{ .RunnerUsername }}:{{ .RunnerUsername }} /etc/garm-agent || fail "failed to change owner on /etc/garm-agent" - - sendStatus "Creating config and systemd unit" - cat > /etc/garm-agent/garm-agent.toml << EOF + sendStatus "Agent mode is enabled; setting up agent" + DOWNLOAD_URL="{{ .AgentDownloadURL }}" + AGENT_URL="{{ .AgentURL }}" + AGENT_TOKEN="{{ .AgentToken }}" + AGENT_SHELL={{ .AgentShell }} + sendStatus "Downloading agent from $DOWNLOAD_URL" + sudo curl --retry 5 \ + --retry-delay 5 \ + --retry-connrefused \ + --fail -L \ + -H "Authorization: Bearer ${BEARER_TOKEN}" \ + -o /usr/local/bin/garm-agent "$DOWNLOAD_URL" || fail "failed to download garm-agent" + sudo chmod +x /usr/local/bin/garm-agent || fail "failed to make garm-agent executable" + sudo mkdir -p /var/log/garm-agent || fail "failed to create /var/log/garm-agent" + sudo chown {{ .RunnerUsername }}:{{ .RunnerUsername }} /var/log/garm-agent || fail "failed to chown /var/log/garm-agent" + sudo mkdir -p /etc/garm-agent || fail "failed to create /etc/garm" + sudo chown {{ .RunnerUsername }}:{{ .RunnerUsername }} /etc/garm-agent || fail "failed to change owner on /etc/garm-agent" + + sendStatus "Creating config and systemd unit" + cat > /etc/garm-agent/garm-agent.toml << EOF server_url = "$AGENT_URL" log_file = "/var/log/garm-agent/garm-agent.log" work_dir = "$RUN_HOME" @@ -100,7 +100,7 @@ runner_cmdline = ["/bin/bash", "-C", "/home/runner/actions-runner/run.sh"] state_db_path = "/etc/garm-agent/agent-state.db" EOF - cat > /tmp/garm-agent.service << EOF + cat > /tmp/garm-agent.service << EOF [Unit] Description=GARM agent After=multi-user.target @@ -119,10 +119,10 @@ Environment=LANG=en_US.UTF-8 WantedBy=multi-user.target EOF - sudo mv /tmp/garm-agent.service /etc/systemd/system/garm-agent.service || fail "failed to create /etc/systemd/system/garm-agent.service" - sudo chown root:root /etc/systemd/system/garm-agent.service || fail "failed to change owner on /etc/systemd/system/garm-agent.service" - sendStatus "Reloading systemd unit" - sudo systemctl daemon-reload || fail "failed to reload systemd" + sudo mv /tmp/garm-agent.service /etc/systemd/system/garm-agent.service || fail "failed to create /etc/systemd/system/garm-agent.service" + sudo chown root:root /etc/systemd/system/garm-agent.service || fail "failed to change owner on /etc/systemd/system/garm-agent.service" + sendStatus "Reloading systemd unit" + sudo systemctl daemon-reload || fail "failed to reload systemd" fi function downloadAndExtractRunner() { @@ -244,12 +244,12 @@ if [ -f "$RUN_HOME/env.sh" ];then popd fi if [ "$AGENT_MODE" != "true" ]; then - sudo systemctl start $SVC_NAME || fail "failed to start service" + sudo systemctl start $SVC_NAME || fail "failed to start service" fi {{- else}} if [ "$AGENT_MODE" != "true" ]; then - sendStatus "starting service" - sudo ./svc.sh start || fail "failed to start service" + sendStatus "starting service" + sudo ./svc.sh start || fail "failed to start service" fi set +e diff --git a/runner/pools_test.go b/runner/pools_test.go index 87224a9e6..bbd7d3253 100644 --- a/runner/pools_test.go +++ b/runner/pools_test.go @@ -23,7 +23,6 @@ import ( runnerErrors "github.com/cloudbase/garm-provider-common/errors" "github.com/cloudbase/garm/auth" - "github.com/cloudbase/garm/config" "github.com/cloudbase/garm/database" dbCommon "github.com/cloudbase/garm/database/common" garmTesting "github.com/cloudbase/garm/internal/testing" @@ -36,7 +35,6 @@ type PoolTestFixtures struct { Store dbCommon.Store Pools []params.Pool Providers map[string]common.Provider - Credentials map[string]config.Github CreateInstanceParams params.CreateInstanceParams UpdatePoolParams params.UpdatePoolParams } diff --git a/runner/runner.go b/runner/runner.go index a77f18d17..e34c3f0d9 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -66,12 +66,6 @@ func NewRunner(ctx context.Context, cfg config.Config, db dbCommon.Store, token return nil, fmt.Errorf("error loading providers: %w", err) } - creds := map[string]config.Github{} - - for _, ghcreds := range cfg.Github { - creds[ghcreds.Name] = ghcreds - } - poolManagerCtrl := &poolManagerCtrl{ config: cfg, store: db,