diff --git a/README.md b/README.md index b673badb4b..5e1d0e8f54 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ For further information on what is supported by GitHub and what's managed by the ARC documentation is available on [docs.github.com](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners-with-actions-runner-controller/quickstart-for-actions-runner-controller). +### Multi-Repository Listener Support + +The listener now supports listening to multiple repositories from a single listener pod, which is ideal for individual users or small teams with multiple repositories. See [Multi-Repository Listener Guide](/docs/multi-repository-listener.md) for configuration details. + ### Legacy documentation The following documentation is for the legacy autoscaling modes that continue to be maintained by the community: diff --git a/cmd/ghalistener/app/app.go b/cmd/ghalistener/app/app.go index 004898a7d8..6c421759fd 100644 --- a/cmd/ghalistener/app/app.go +++ b/cmd/ghalistener/app/app.go @@ -21,9 +21,10 @@ type App struct { logger logr.Logger // initialized fields - listener Listener - worker Worker - metrics metrics.ServerExporter + listener Listener // Single listener (used in single-repo mode) + listeners []Listener // Multiple listeners (used in multi-repo mode) + worker Worker + metrics metrics.ServerExporter } //go:generate mockery --name Listener --output ./mocks --outpkg mocks --case underscore @@ -46,11 +47,6 @@ func New(config config.Config) (*App, error) { config: &config, } - ghConfig, err := actions.ParseGitHubConfigFromURL(config.ConfigureUrl) - if err != nil { - return nil, fmt.Errorf("failed to parse GitHub config from URL: %w", err) - } - { logger, err := config.Logger() if err != nil { @@ -59,25 +55,6 @@ func New(config config.Config) (*App, error) { app.logger = logger.WithName("listener-app") } - actionsClient, err := config.ActionsClient(app.logger) - if err != nil { - return nil, fmt.Errorf("failed to create actions client: %w", err) - } - - if config.MetricsAddr != "" { - app.metrics = metrics.NewExporter(metrics.ExporterConfig{ - ScaleSetName: config.EphemeralRunnerSetName, - ScaleSetNamespace: config.EphemeralRunnerSetNamespace, - Enterprise: ghConfig.Enterprise, - Organization: ghConfig.Organization, - Repository: ghConfig.Repository, - ServerAddr: config.MetricsAddr, - ServerEndpoint: config.MetricsEndpoint, - Metrics: config.Metrics, - Logger: app.logger.WithName("metrics exporter"), - }) - } - worker, err := worker.New( worker.Config{ EphemeralRunnerSetNamespace: config.EphemeralRunnerSetNamespace, @@ -92,18 +69,121 @@ func New(config config.Config) (*App, error) { } app.worker = worker - listener, err := listener.New(listener.Config{ - Client: actionsClient, - ScaleSetID: app.config.RunnerScaleSetId, - MinRunners: app.config.MinRunners, - MaxRunners: app.config.MaxRunners, - Logger: app.logger.WithName("listener"), - Metrics: app.metrics, - }) - if err != nil { - return nil, fmt.Errorf("failed to create new listener: %w", err) + if config.IsMultiRepository() { + // Multi-repository mode + app.logger.Info("Initializing multi-repository mode", "repositories", len(config.Repositories)) + + for _, repoUrl := range config.Repositories { + ghConfig, err := actions.ParseGitHubConfigFromURL(repoUrl) + if err != nil { + return nil, fmt.Errorf("failed to parse GitHub config from URL %s: %w", repoUrl, err) + } + + actionsClient, err := config.ActionsClientForURL(repoUrl, 0, app.logger) + if err != nil { + return nil, fmt.Errorf("failed to create actions client for %s: %w", repoUrl, err) + } + + // Get or create scale set for this repository + scaleSetName := config.RunnerScaleSetName + if scaleSetName == "" { + scaleSetName = config.EphemeralRunnerSetName + } + + app.logger.Info("Getting or creating scale set", "repository", repoUrl, "scaleSetName", scaleSetName) + + scaleSet, err := actionsClient.GetRunnerScaleSet(context.Background(), 1, scaleSetName) + if err != nil { + // Try to create the scale set + app.logger.Info("Scale set not found, attempting to create", "repository", repoUrl, "scaleSetName", scaleSetName) + newScaleSet := &actions.RunnerScaleSet{ + Name: scaleSetName, + RunnerGroupId: 1, + Labels: []actions.Label{{Type: "System", Name: "self-hosted"}}, + RunnerSetting: actions.RunnerSetting{}, + } + scaleSet, err = actionsClient.CreateRunnerScaleSet(context.Background(), newScaleSet) + if err != nil { + return nil, fmt.Errorf("failed to create scale set for %s: %w", repoUrl, err) + } + } + + app.logger.Info("Using scale set", "repository", repoUrl, "scaleSetId", scaleSet.Id, "scaleSetName", scaleSet.Name) + + // Create metrics exporter if configured + var metricsPublisher metrics.Publisher + if config.MetricsAddr != "" && app.metrics == nil { + // Only create one metrics server for all listeners + app.metrics = metrics.NewExporter(metrics.ExporterConfig{ + ScaleSetName: config.EphemeralRunnerSetName, + ScaleSetNamespace: config.EphemeralRunnerSetNamespace, + Enterprise: ghConfig.Enterprise, + Organization: ghConfig.Organization, + Repository: ghConfig.Repository, + ServerAddr: config.MetricsAddr, + ServerEndpoint: config.MetricsEndpoint, + Metrics: config.Metrics, + Logger: app.logger.WithName("metrics exporter"), + }) + metricsPublisher = app.metrics + } + + // Create listener for this repository + listener, err := listener.New(listener.Config{ + Client: actionsClient, + ScaleSetID: scaleSet.Id, + MinRunners: app.config.MinRunners, + MaxRunners: app.config.MaxRunners, + Logger: app.logger.WithName("listener").WithValues("repository", repoUrl, "scaleSetId", scaleSet.Id), + Metrics: metricsPublisher, + }) + if err != nil { + return nil, fmt.Errorf("failed to create listener for %s: %w", repoUrl, err) + } + + app.listeners = append(app.listeners, listener) + } + + app.logger.Info("Multi-repository mode initialized", "listeners", len(app.listeners)) + } else { + // Single repository mode (existing behavior) + ghConfig, err := actions.ParseGitHubConfigFromURL(config.ConfigureUrl) + if err != nil { + return nil, fmt.Errorf("failed to parse GitHub config from URL: %w", err) + } + + actionsClient, err := config.ActionsClient(app.logger) + if err != nil { + return nil, fmt.Errorf("failed to create actions client: %w", err) + } + + if config.MetricsAddr != "" { + app.metrics = metrics.NewExporter(metrics.ExporterConfig{ + ScaleSetName: config.EphemeralRunnerSetName, + ScaleSetNamespace: config.EphemeralRunnerSetNamespace, + Enterprise: ghConfig.Enterprise, + Organization: ghConfig.Organization, + Repository: ghConfig.Repository, + ServerAddr: config.MetricsAddr, + ServerEndpoint: config.MetricsEndpoint, + Metrics: config.Metrics, + Logger: app.logger.WithName("metrics exporter"), + }) + } + + listener, err := listener.New(listener.Config{ + Client: actionsClient, + ScaleSetID: app.config.RunnerScaleSetId, + MinRunners: app.config.MinRunners, + MaxRunners: app.config.MaxRunners, + Logger: app.logger.WithName("listener"), + Metrics: app.metrics, + }) + if err != nil { + return nil, fmt.Errorf("failed to create new listener: %w", err) + } + app.listener = listener } - app.listener = listener app.logger.Info("app initialized") @@ -115,7 +195,7 @@ func (app *App) Run(ctx context.Context) error { if app.worker == nil { errs = append(errs, fmt.Errorf("worker not initialized")) } - if app.listener == nil { + if app.listener == nil && len(app.listeners) == 0 { errs = append(errs, fmt.Errorf("listener not initialized")) } if err := errors.Join(errs...); err != nil { @@ -125,12 +205,31 @@ func (app *App) Run(ctx context.Context) error { g, ctx := errgroup.WithContext(ctx) metricsCtx, cancelMetrics := context.WithCancelCause(ctx) - g.Go(func() error { - app.logger.Info("Starting listener") - listnerErr := app.listener.Listen(ctx, app.worker) - cancelMetrics(fmt.Errorf("Listener exited: %w", listnerErr)) - return listnerErr - }) + if len(app.listeners) > 0 { + // Multi-repository mode: run all listeners + for i, listener := range app.listeners { + listenerIndex := i + listenerInstance := listener + g.Go(func() error { + app.logger.Info("Starting multi-repo listener", "listenerIndex", listenerIndex) + err := listenerInstance.Listen(ctx, app.worker) + if err != nil { + app.logger.Error(err, "Multi-repo listener exited with error", "listenerIndex", listenerIndex) + } + // Don't cancel metrics or other listeners if one fails + // Just log and return the error + return fmt.Errorf("listener %d failed: %w", listenerIndex, err) + }) + } + } else { + // Single repository mode + g.Go(func() error { + app.logger.Info("Starting listener") + listenerErr := app.listener.Listen(ctx, app.worker) + cancelMetrics(fmt.Errorf("Listener exited: %w", listenerErr)) + return listenerErr + }) + } if app.metrics != nil { g.Go(func() error { diff --git a/cmd/ghalistener/config/config.go b/cmd/ghalistener/config/config.go index 0df638bcbf..b5bb79f2c8 100644 --- a/cmd/ghalistener/config/config.go +++ b/cmd/ghalistener/config/config.go @@ -21,7 +21,11 @@ import ( ) type Config struct { - ConfigureUrl string `json:"configure_url"` + ConfigureUrl string `json:"configure_url"` + // Repositories is an optional list of repository URLs to listen to. + // If provided, the listener will create/get scale sets for each repository and listen to all of them. + // This is mutually exclusive with ConfigureUrl (if Repositories is set, ConfigureUrl is ignored). + Repositories []string `json:"repositories,omitempty"` VaultType vault.VaultType `json:"vault_type"` VaultLookupKey string `json:"vault_lookup_key"` // If the VaultType is set to "azure_key_vault", this field must be populated. @@ -100,16 +104,32 @@ func Read(ctx context.Context, configPath string) (*Config, error) { // Validate checks the configuration for errors. func (c *Config) Validate() error { - if len(c.ConfigureUrl) == 0 { - return fmt.Errorf("GitHubConfigUrl is not provided") + // If Repositories is provided, ConfigureUrl is not required + if len(c.Repositories) == 0 && len(c.ConfigureUrl) == 0 { + return fmt.Errorf("either GitHubConfigUrl or Repositories list must be provided") } - if len(c.EphemeralRunnerSetNamespace) == 0 || len(c.EphemeralRunnerSetName) == 0 { - return fmt.Errorf("EphemeralRunnerSetNamespace %q or EphemeralRunnerSetName %q is missing", c.EphemeralRunnerSetNamespace, c.EphemeralRunnerSetName) + // If Repositories is provided, validate each URL + if len(c.Repositories) > 0 { + for _, repo := range c.Repositories { + if len(repo) == 0 { + return fmt.Errorf("empty repository URL in Repositories list") + } + } + // When using Repositories, RunnerScaleSetName should be provided + if len(c.RunnerScaleSetName) == 0 { + return fmt.Errorf("RunnerScaleSetName is required when using multi-repository mode") + } + // RunnerScaleSetId and ConfigureUrl are optional in multi-repo mode + } else { + // Single repository mode - require RunnerScaleSetId + if c.RunnerScaleSetId == 0 { + return fmt.Errorf(`RunnerScaleSetId "%d" is missing`, c.RunnerScaleSetId) + } } - if c.RunnerScaleSetId == 0 { - return fmt.Errorf(`RunnerScaleSetId "%d" is missing`, c.RunnerScaleSetId) + if len(c.EphemeralRunnerSetNamespace) == 0 || len(c.EphemeralRunnerSetName) == 0 { + return fmt.Errorf("EphemeralRunnerSetNamespace %q or EphemeralRunnerSetName %q is missing", c.EphemeralRunnerSetNamespace, c.EphemeralRunnerSetName) } if c.MaxRunners < c.MinRunners { @@ -153,6 +173,23 @@ func (c *Config) Logger() (logr.Logger, error) { return logger, nil } +// GetConfigureUrls returns the list of GitHub configuration URLs to listen to. +// If Repositories is set, returns those URLs; otherwise returns ConfigureUrl as a single-element slice. +func (c *Config) GetConfigureUrls() []string { + if len(c.Repositories) > 0 { + return c.Repositories + } + if len(c.ConfigureUrl) > 0 { + return []string{c.ConfigureUrl} + } + return []string{} +} + +// IsMultiRepository returns true if the config specifies multiple repositories +func (c *Config) IsMultiRepository() bool { + return len(c.Repositories) > 0 +} + func (c *Config) ActionsClient(logger logr.Logger, clientOptions ...actions.ClientOption) (*actions.Client, error) { var creds actions.ActionsAuth switch c.Token { @@ -205,6 +242,59 @@ func (c *Config) ActionsClient(logger logr.Logger, clientOptions ...actions.Clie return client, nil } +// ActionsClientForURL creates an actions client for a specific GitHub URL +func (c *Config) ActionsClientForURL(configureUrl string, scaleSetId int, logger logr.Logger, clientOptions ...actions.ClientOption) (*actions.Client, error) { + var creds actions.ActionsAuth + switch c.Token { + case "": + creds.AppCreds = &actions.GitHubAppAuth{ + AppID: c.AppID, + AppInstallationID: c.AppInstallationID, + AppPrivateKey: c.AppPrivateKey, + } + default: + creds.Token = c.Token + } + + options := append([]actions.ClientOption{ + actions.WithLogger(logger), + }, clientOptions...) + + if c.ServerRootCA != "" { + systemPool, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("failed to load system cert pool: %w", err) + } + pool := systemPool.Clone() + ok := pool.AppendCertsFromPEM([]byte(c.ServerRootCA)) + if !ok { + return nil, fmt.Errorf("failed to parse root certificate") + } + + options = append(options, actions.WithRootCAs(pool)) + } + + proxyFunc := httpproxy.FromEnvironment().ProxyFunc() + options = append(options, actions.WithProxy(func(req *http.Request) (*url.URL, error) { + return proxyFunc(req.URL) + })) + + client, err := actions.NewClient(configureUrl, &creds, options...) + if err != nil { + return nil, fmt.Errorf("failed to create actions client: %w", err) + } + + client.SetUserAgent(actions.UserAgentInfo{ + Version: build.Version, + CommitSHA: build.CommitSHA, + ScaleSetID: scaleSetId, + HasProxy: hasProxy(), + Subsystem: "ghalistener", + }) + + return client, nil +} + func hasProxy() bool { proxyFunc := httpproxy.FromEnvironment().ProxyFunc() return proxyFunc != nil diff --git a/cmd/ghalistener/config/config_example_test.go b/cmd/ghalistener/config/config_example_test.go new file mode 100644 index 0000000000..fc6903311e --- /dev/null +++ b/cmd/ghalistener/config/config_example_test.go @@ -0,0 +1,171 @@ +package config + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMultiRepositoryConfigExample demonstrates how to create and read a multi-repository configuration +func TestMultiRepositoryConfigExample(t *testing.T) { + // Create a temporary directory for the test + tempDir, err := os.MkdirTemp("", "listener-config-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create an example multi-repository configuration + exampleConfig := Config{ + Repositories: []string{ + "https://github.com/testuser/repo1", + "https://github.com/testuser/repo2", + "https://github.com/testuser/repo3", + }, + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "multi-repo-runners", + RunnerScaleSetName: "multi-repo-scale-set", + MaxRunners: 10, + MinRunners: 1, + LogLevel: "info", + LogFormat: "text", + } + + // Note: In real usage, you would add authentication via: + // - Token field for PAT: exampleConfig.Token = "ghp_..." + // - Or GitHub App fields: exampleConfig.AppID, AppInstallationID, AppPrivateKey + + // Write the configuration to a file + configPath := filepath.Join(tempDir, "config.json") + configFile, err := os.Create(configPath) + require.NoError(t, err) + defer configFile.Close() + + encoder := json.NewEncoder(configFile) + encoder.SetIndent("", " ") + err = encoder.Encode(&exampleConfig) + require.NoError(t, err) + + // Verify the configuration can be read back (without validation since we didn't add auth) + readConfig, err := os.Open(configPath) + require.NoError(t, err) + defer readConfig.Close() + + var loadedConfig Config + err = json.NewDecoder(readConfig).Decode(&loadedConfig) + require.NoError(t, err) + + // Verify the loaded configuration + assert.Equal(t, 3, len(loadedConfig.Repositories)) + assert.Equal(t, "https://github.com/testuser/repo1", loadedConfig.Repositories[0]) + assert.Equal(t, "https://github.com/testuser/repo2", loadedConfig.Repositories[1]) + assert.Equal(t, "https://github.com/testuser/repo3", loadedConfig.Repositories[2]) + assert.Equal(t, "multi-repo-runners", loadedConfig.EphemeralRunnerSetName) + assert.Equal(t, "multi-repo-scale-set", loadedConfig.RunnerScaleSetName) + assert.Equal(t, 10, loadedConfig.MaxRunners) + assert.Equal(t, 1, loadedConfig.MinRunners) + + // Verify helper methods work correctly + assert.True(t, loadedConfig.IsMultiRepository()) + urls := loadedConfig.GetConfigureUrls() + assert.Equal(t, 3, len(urls)) +} + +// TestSingleRepositoryConfigBackwardCompatibility demonstrates that single-repository mode still works +func TestSingleRepositoryConfigBackwardCompatibility(t *testing.T) { + // Create a temporary directory for the test + tempDir, err := os.MkdirTemp("", "listener-config-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create an example single-repository configuration (existing behavior) + exampleConfig := Config{ + ConfigureUrl: "https://github.com/testuser/repo", + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "single-repo-runners", + RunnerScaleSetId: 12345, + MaxRunners: 5, + MinRunners: 0, + LogLevel: "debug", + LogFormat: "json", + } + + // Write the configuration to a file + configPath := filepath.Join(tempDir, "config.json") + configFile, err := os.Create(configPath) + require.NoError(t, err) + defer configFile.Close() + + encoder := json.NewEncoder(configFile) + encoder.SetIndent("", " ") + err = encoder.Encode(&exampleConfig) + require.NoError(t, err) + + // Verify the configuration can be read back + readConfig, err := os.Open(configPath) + require.NoError(t, err) + defer readConfig.Close() + + var loadedConfig Config + err = json.NewDecoder(readConfig).Decode(&loadedConfig) + require.NoError(t, err) + + // Verify the loaded configuration + assert.Equal(t, "https://github.com/testuser/repo", loadedConfig.ConfigureUrl) + assert.Equal(t, "single-repo-runners", loadedConfig.EphemeralRunnerSetName) + assert.Equal(t, 12345, loadedConfig.RunnerScaleSetId) + assert.Equal(t, 5, loadedConfig.MaxRunners) + assert.Equal(t, 0, loadedConfig.MinRunners) + + // Verify helper methods work correctly + assert.False(t, loadedConfig.IsMultiRepository()) + urls := loadedConfig.GetConfigureUrls() + assert.Equal(t, 1, len(urls)) + assert.Equal(t, "https://github.com/testuser/repo", urls[0]) +} + +// TestReadMultiRepositoryConfigWithAuth demonstrates reading a complete multi-repo config with authentication +func TestReadMultiRepositoryConfigWithAuth(t *testing.T) { + // Skip this test by default since it doesn't actually connect to GitHub + // To run: go test -v ./cmd/ghalistener/config -run TestReadMultiRepositoryConfigWithAuth + t.Skip("Skipping integration test - requires GitHub credentials") + + // Create a temporary directory for the test + tempDir, err := os.MkdirTemp("", "listener-config-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a configuration file with authentication (using fake credentials for test) + configPath := filepath.Join(tempDir, "config.json") + configData := `{ + "repositories": [ + "https://github.com/testuser/repo1", + "https://github.com/testuser/repo2" + ], + "github_token": "ghp_fakeTokenForTestingOnly123456789", + "ephemeral_runner_set_namespace": "default", + "ephemeral_runner_set_name": "multi-repo-runners", + "runner_scale_set_name": "multi-repo-scale-set", + "max_runners": 10, + "min_runners": 1, + "log_level": "info", + "log_format": "text" +}` + err = os.WriteFile(configPath, []byte(configData), 0644) + require.NoError(t, err) + + // Read the configuration using the Read function + ctx := context.Background() + config, err := Read(ctx, configPath) + require.NoError(t, err) + + // Verify the configuration + assert.Equal(t, 2, len(config.Repositories)) + assert.Equal(t, "https://github.com/testuser/repo1", config.Repositories[0]) + assert.Equal(t, "https://github.com/testuser/repo2", config.Repositories[1]) + assert.Equal(t, "ghp_fakeTokenForTestingOnly123456789", config.Token) + assert.True(t, config.IsMultiRepository()) +} diff --git a/cmd/ghalistener/config/config_multi_repo_test.go b/cmd/ghalistener/config/config_multi_repo_test.go new file mode 100644 index 0000000000..3f7e354288 --- /dev/null +++ b/cmd/ghalistener/config/config_multi_repo_test.go @@ -0,0 +1,173 @@ +package config + +import ( + "testing" + + "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1/appconfig" + "github.com/stretchr/testify/assert" +) + +func TestConfig_MultiRepository(t *testing.T) { + t.Run("GetConfigureUrls returns repositories when set", func(t *testing.T) { + config := &Config{ + Repositories: []string{ + "https://github.com/user/repo1", + "https://github.com/user/repo2", + "https://github.com/user/repo3", + }, + AppConfig: &appconfig.AppConfig{ + Token: "test-token", + }, + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "test", + RunnerScaleSetName: "test-scale-set", + } + + urls := config.GetConfigureUrls() + assert.Equal(t, 3, len(urls)) + assert.Equal(t, "https://github.com/user/repo1", urls[0]) + assert.Equal(t, "https://github.com/user/repo2", urls[1]) + assert.Equal(t, "https://github.com/user/repo3", urls[2]) + }) + + t.Run("GetConfigureUrls returns single URL when ConfigureUrl is set", func(t *testing.T) { + config := &Config{ + ConfigureUrl: "https://github.com/user/repo", + AppConfig: &appconfig.AppConfig{ + Token: "test-token", + }, + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "test", + RunnerScaleSetId: 123, + } + + urls := config.GetConfigureUrls() + assert.Equal(t, 1, len(urls)) + assert.Equal(t, "https://github.com/user/repo", urls[0]) + }) + + t.Run("IsMultiRepository returns true when Repositories is set", func(t *testing.T) { + config := &Config{ + Repositories: []string{ + "https://github.com/user/repo1", + "https://github.com/user/repo2", + }, + } + + assert.True(t, config.IsMultiRepository()) + }) + + t.Run("IsMultiRepository returns false when only ConfigureUrl is set", func(t *testing.T) { + config := &Config{ + ConfigureUrl: "https://github.com/user/repo", + } + + assert.False(t, config.IsMultiRepository()) + }) + + t.Run("Validate fails when neither ConfigureUrl nor Repositories is set", func(t *testing.T) { + config := &Config{ + AppConfig: &appconfig.AppConfig{ + Token: "test-token", + }, + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "test", + RunnerScaleSetId: 123, + } + + err := config.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "either GitHubConfigUrl or Repositories list must be provided") + }) + + t.Run("Validate succeeds with Repositories set", func(t *testing.T) { + config := &Config{ + Repositories: []string{ + "https://github.com/user/repo1", + "https://github.com/user/repo2", + }, + AppConfig: &appconfig.AppConfig{ + Token: "test-token", + }, + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "test", + RunnerScaleSetName: "test-scale-set", + } + + err := config.Validate() + assert.NoError(t, err) + }) + + t.Run("Validate fails with empty repository URL", func(t *testing.T) { + config := &Config{ + Repositories: []string{ + "https://github.com/user/repo1", + "", + "https://github.com/user/repo2", + }, + AppConfig: &appconfig.AppConfig{ + Token: "test-token", + }, + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "test", + } + + err := config.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty repository URL") + }) + + t.Run("Validate does not require RunnerScaleSetId in multi-repo mode", func(t *testing.T) { + config := &Config{ + Repositories: []string{ + "https://github.com/user/repo1", + "https://github.com/user/repo2", + }, + AppConfig: &appconfig.AppConfig{ + Token: "test-token", + }, + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "test", + RunnerScaleSetName: "test-scale-set", + // RunnerScaleSetId intentionally not set + } + + err := config.Validate() + assert.NoError(t, err) + }) + + t.Run("Validate requires RunnerScaleSetId in single-repo mode", func(t *testing.T) { + config := &Config{ + ConfigureUrl: "https://github.com/user/repo", + AppConfig: &appconfig.AppConfig{ + Token: "test-token", + }, + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "test", + // RunnerScaleSetId intentionally not set (0) + } + + err := config.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "RunnerScaleSetId") + }) + + t.Run("Validate requires RunnerScaleSetName in multi-repo mode", func(t *testing.T) { + config := &Config{ + Repositories: []string{ + "https://github.com/user/repo1", + "https://github.com/user/repo2", + }, + AppConfig: &appconfig.AppConfig{ + Token: "test-token", + }, + EphemeralRunnerSetNamespace: "default", + EphemeralRunnerSetName: "test", + // RunnerScaleSetName intentionally not set + } + + err := config.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "RunnerScaleSetName is required") + }) +} diff --git a/cmd/ghalistener/config/config_validation_test.go b/cmd/ghalistener/config/config_validation_test.go index 18551f6610..66e4a35511 100644 --- a/cmd/ghalistener/config/config_validation_test.go +++ b/cmd/ghalistener/config/config_validation_test.go @@ -119,7 +119,7 @@ func TestConfigValidationConfigUrl(t *testing.T) { err := config.Validate() - assert.ErrorContains(t, err, "GitHubConfigUrl is not provided", "Expected error about missing ConfigureUrl") + assert.ErrorContains(t, err, "either GitHubConfigUrl or Repositories list must be provided", "Expected error about missing ConfigureUrl or Repositories") } func TestConfigValidationWithVaultConfig(t *testing.T) { diff --git a/docs/multi-repository-listener-example.json b/docs/multi-repository-listener-example.json new file mode 100644 index 0000000000..5d8f35e58c --- /dev/null +++ b/docs/multi-repository-listener-example.json @@ -0,0 +1,15 @@ +{ + "repositories": [ + "https://github.com/myuser/repo1", + "https://github.com/myuser/repo2", + "https://github.com/myuser/repo3" + ], + "github_token": "ghp_yourPersonalAccessToken", + "ephemeral_runner_set_namespace": "default", + "ephemeral_runner_set_name": "multi-repo-runners", + "runner_scale_set_name": "multi-repo-scale-set", + "max_runners": 10, + "min_runners": 1, + "log_level": "info", + "log_format": "text" +} diff --git a/docs/multi-repository-listener.md b/docs/multi-repository-listener.md new file mode 100644 index 0000000000..a5554a90ed --- /dev/null +++ b/docs/multi-repository-listener.md @@ -0,0 +1,225 @@ +# Multi-Repository Listener Support + +## Overview + +The GitHub Actions Listener now supports listening to multiple repositories from a single listener pod. This is useful for individual users or small teams with multiple repositories who want to share a single runner pool across all their repositories. + +## Features + +- **Multiple Repository Support**: Configure a single listener to monitor jobs from multiple GitHub repositories +- **Automatic Scale Set Management**: The listener automatically creates or retrieves scale sets for each repository +- **Shared Runner Pool**: All repositories share the same Kubernetes runner pool +- **Robust Error Handling**: Each repository listener runs independently with retry and reconnect logic +- **Minimal Configuration**: Simple JSON configuration file with list of repository URLs + +## Configuration + +### Prerequisites + +- A Personal Access Token (PAT) or GitHub App with permissions across all target repositories +- Kubernetes cluster with actions-runner-controller installed +- Sufficient permissions to create runner scale sets in each repository + +### Configuration File Format + +Create a JSON configuration file with the following structure: + +```json +{ + "repositories": [ + "https://github.com/myuser/repo1", + "https://github.com/myuser/repo2", + "https://github.com/myuser/repo3" + ], + "github_token": "ghp_yourPersonalAccessToken", + "ephemeral_runner_set_namespace": "default", + "ephemeral_runner_set_name": "multi-repo-runners", + "runner_scale_set_name": "multi-repo-scale-set", + "max_runners": 10, + "min_runners": 1, + "log_level": "info", + "log_format": "text" +} +``` + +### Configuration Fields + +- **repositories** (required): Array of GitHub repository URLs to listen to +- **github_token** (required): Personal Access Token with repo and workflow permissions +- **ephemeral_runner_set_namespace** (required): Kubernetes namespace for the runner pods +- **ephemeral_runner_set_name** (required): Name of the ephemeral runner set +- **runner_scale_set_name** (required): Base name for the scale sets (one will be created per repository) +- **max_runners** (optional): Maximum number of concurrent runners (default: 5) +- **min_runners** (optional): Minimum number of idle runners (default: 0) +- **log_level** (optional): Logging level - debug, info, warn, error (default: debug) +- **log_format** (optional): Log format - text or json (default: text) + +### Alternative: GitHub App Configuration + +You can also use a GitHub App instead of a PAT: + +```json +{ + "repositories": [ + "https://github.com/myuser/repo1", + "https://github.com/myuser/repo2" + ], + "github_app_id": "123456", + "github_app_installation_id": "12345678", + "github_app_private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", + "ephemeral_runner_set_namespace": "default", + "ephemeral_runner_set_name": "multi-repo-runners", + "runner_scale_set_name": "multi-repo-scale-set", + "max_runners": 10, + "min_runners": 1 +} +``` + +## Usage + +### Running the Listener + +1. Create your configuration file (e.g., `multi-repo-config.json`) + +2. Set the configuration path environment variable: + ```bash + export LISTENER_CONFIG_PATH=/path/to/multi-repo-config.json + ``` + +3. Run the listener: + ```bash + ./bin/github-runnerscaleset-listener + ``` + +### Kubernetes Deployment + +Create a ConfigMap with your configuration: + +```bash +kubectl create configmap listener-config \ + --from-file=config.json=multi-repo-config.json \ + --namespace=default +``` + +Update your listener deployment to mount the ConfigMap and set the environment variable: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: multi-repo-listener +spec: + replicas: 1 + selector: + matchLabels: + app: multi-repo-listener + template: + metadata: + labels: + app: multi-repo-listener + spec: + containers: + - name: listener + image: ghcr.io/actions/actions-runner-controller:latest + command: ["/github-runnerscaleset-listener"] + env: + - name: LISTENER_CONFIG_PATH + value: /etc/listener/config.json + volumeMounts: + - name: config + mountPath: /etc/listener + volumes: + - name: config + configMap: + name: listener-config +``` + +## How It Works + +1. **Initialization**: On startup, the listener reads the configuration file and validates all repository URLs + +2. **Scale Set Creation**: For each repository: + - The listener connects to the GitHub API + - Attempts to retrieve an existing scale set with the configured name + - If not found, creates a new scale set for that repository + +3. **Listener Setup**: A separate listener instance is created for each repository's scale set + +4. **Concurrent Operation**: All listeners run concurrently, each maintaining its own: + - Message session with GitHub Actions + - Job queue monitoring + - Retry and reconnect logic + +5. **Runner Allocation**: When jobs are triggered in any repository: + - The corresponding listener receives the job notification + - Jobs are acquired and runners are scaled accordingly + - All runners are created in the same Kubernetes namespace + +## Retry and Reconnect Logic + +The listener includes robust error handling: + +- **Session Creation**: Up to 10 retries with 30-second intervals if session creation fails +- **Token Refresh**: Automatic token refresh when message queue tokens expire +- **Network Resilience**: Retryable HTTP client with exponential backoff for transient failures +- **Independent Listeners**: Each repository listener operates independently; if one fails, others continue + +## Limitations + +- All repositories must be accessible with the same authentication credentials (PAT or GitHub App) +- Scale sets are created with default runner group (ID: 1) +- All runners share the same resource limits and pod specifications +- Metrics are aggregated across all repositories + +## Migration from Single Repository + +To migrate from single repository mode: + +1. Keep your existing configuration structure +2. Add the `repositories` field with your repository URLs +3. Remove the `configure_url` field (it will be ignored if `repositories` is present) +4. Restart the listener + +The system will automatically create scale sets for each repository while maintaining backward compatibility with single-repository configurations. + +## Troubleshooting + +### Listener fails to start + +- Verify all repository URLs are valid and accessible +- Ensure your PAT or GitHub App has permissions for all repositories +- Check Kubernetes namespace exists and has sufficient resources + +### Scale set creation fails + +- Verify authentication credentials have admin permissions +- Check GitHub API rate limits +- Ensure runner group ID (1) exists in your GitHub organization/repository + +### Jobs not being picked up + +- Verify scale sets were created successfully in each repository +- Check listener logs for connection errors +- Ensure runners have proper labels configured in workflows + +### High resource usage + +- Reduce `max_runners` if too many concurrent runners are created +- Adjust `min_runners` to 0 to avoid idle runners +- Monitor Kubernetes resource usage and adjust pod limits + +## Example Logs + +Successful multi-repository initialization: + +``` +INFO listener-app Initializing multi-repository mode repositories=3 +INFO listener-app Getting or creating scale set repository=https://github.com/myuser/repo1 scaleSetName=multi-repo-scale-set +INFO listener-app Using scale set repository=https://github.com/myuser/repo1 scaleSetId=12345 scaleSetName=multi-repo-scale-set +INFO listener-app Getting or creating scale set repository=https://github.com/myuser/repo2 scaleSetName=multi-repo-scale-set +INFO listener-app Using scale set repository=https://github.com/myuser/repo2 scaleSetId=12346 scaleSetName=multi-repo-scale-set +INFO listener-app Multi-repository mode initialized listeners=3 +INFO listener-app Starting multi-repo listener listenerIndex=0 +INFO listener-app Starting multi-repo listener listenerIndex=1 +INFO listener-app Starting multi-repo listener listenerIndex=2 +```