diff --git a/README.md b/README.md index 4f99f66..b2a98d1 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The Orka Runner application utilizes Runner scale sets in a manner similar to th Before using the Orka GitHub Runner, ensure that the following prerequisites are met: -* GitHub App: Having a GitHub App is a prerequisite for using the Orka GitHub Runner. You can find instructions on creating a GitHub App in the [Creating a GitHub app](docs/github-app-setup-steps.md) file. +* GitHub App: Having a GitHub App is a prerequisite for using the Orka GitHub Runner at the repository or organization level. You can find instructions on creating a GitHub App in the [Creating a GitHub app](docs/github-app-setup-steps.md) file. Registering runners at the enterprise level instead requires a personal access token — see [Enterprise-level runners](#enterprise-level-runners). * Connectivity to Orka 3.0+ cluster: Ensure that the machine where the Orka Github Runner is started has connectivity to the Orka cluster. Additionally, ensure that the SSH ports are open to enable the runner to establish SSH connections with Orka VMs. * The Orka GitHub runner has been tested with GitHub.com hosted environments. @@ -37,6 +37,16 @@ When using GHES, make sure to: The runner will automatically detect if you're using GHES based on the provided URLs and adjust its behavior accordingly. +### Enterprise-level runners + +Runners can be registered at the enterprise level by pointing `GITHUB_URL` at an enterprise, for example `https://github.enterprise.com/enterprises/my-enterprise`. + +Enterprise-level registration cannot use GitHub App authentication. GitHub does not grant the `manage_runners:enterprise` permission to App installations, so the registration-token request is rejected with `403 Resource not accessible by integration` regardless of where the App is installed. This is a GitHub limitation and applies equally to the Actions Runner Controller; see [Authenticating ARC to the GitHub API](https://docs.github.com/en/enterprise-cloud@latest/actions/hosting-your-own-runners/managing-self-hosted-runners-with-actions-runner-controller/authenticating-to-the-github-api). + +To register at the enterprise level, set `GITHUB_PAT` to a classic personal access token with the `admin:enterprise` (`manage_runners:enterprise`) scope, owned by an enterprise owner. The GitHub App variables are then not required, and the runner will refuse to start if an enterprise `GITHUB_URL` is provided without `GITHUB_PAT`. + +Fine-grained personal access tokens do not expose enterprise scopes and cannot be used. Because a classic token with this scope is long-lived and broadly privileged, use a dedicated service account and rotate the token on a schedule. + ## Setting up the Orka GitHub runner You can get the Orka GitHub runner by downloading it from [this link](https://github.com/macstadium/orka-github-actions-integration/pkgs/container/orka-github-runner). You will be able to execute the runner (via `docker run`) from any machine that has connectivity to the Orka cluster. If running within MacStadium, you can request 2 vCPU of Private Cloud x86 compute with 15GB of storage to run the container, with the corresponding compute billed as a part of your Virtual Private Cloud (VPC) services. @@ -44,10 +54,11 @@ You can get the Orka GitHub runner by downloading it from [this link](https://gi ### Environment variables The Orka GitHub runner requires the following environment variabales to be configured: -* `GITHUB_APP_ID`: The unique identifier for the GitHub App. Detailed instructions on setting up a GitHub app can be found [here](./docs/github-app-setup-steps.md). -* `GITHUB_APP_INSTALLATION_ID`: The installation identifier for the GitHub App. -* `GITHUB_APP_PRIVATE_KEY_PATH` or `GITHUB_APP_PRIVATE_KEY`: The private key associated with the GitHub App. You can either provide the file path to the private key using `GITHUB_APP_PRIVATE_KEY_PATH` or directly provide the private key string using `GITHUB_APP_PRIVATE_KEY`. At least one of these environment variables must be set. -* `GITHUB_URL`: The URL of the GitHub repository or organization. +* `GITHUB_APP_ID`: The unique identifier for the GitHub App. Detailed instructions on setting up a GitHub app can be found [here](./docs/github-app-setup-steps.md). Not required when `GITHUB_PAT` is set. +* `GITHUB_APP_INSTALLATION_ID`: The installation identifier for the GitHub App. Not required when `GITHUB_PAT` is set. +* `GITHUB_APP_PRIVATE_KEY_PATH` or `GITHUB_APP_PRIVATE_KEY`: The private key associated with the GitHub App. You can either provide the file path to the private key using `GITHUB_APP_PRIVATE_KEY_PATH` or directly provide the private key string using `GITHUB_APP_PRIVATE_KEY`. At least one of these environment variables must be set. Not required when `GITHUB_PAT` is set. +* `GITHUB_PAT`: (Optional) A classic personal access token used instead of GitHub App authentication. Required for enterprise-level runners, where GitHub App authentication is not supported — see [Enterprise-level runners](#enterprise-level-runners). When set, it takes precedence over the GitHub App variables. +* `GITHUB_URL`: The URL of the GitHub repository, organization, or enterprise. * `GITHUB_API_URL`: (Optional) The URL of the GitHub API endpoint. If not provided, it will default to github.com api endpoint if Github URL starts with "https://github.com" otherwise defaults to "/api/v3" * `GITHUB_TOKEN`: (Optional) A GitHub token to avoid rate limiting. Required for GitHub Enterprise Server. * `ORKA_URL`: The URL of the Orka server. diff --git a/examples/.env b/examples/.env index 24cad10..7d590eb 100644 --- a/examples/.env +++ b/examples/.env @@ -17,6 +17,13 @@ GITHUB_APP_PRIVATE_KEY_PATH="" # It directly contains the private key content used for GH authentication. GITHUB_APP_PRIVATE_KEY = "" +# [Optional] GITHUB_PAT is a classic personal access token used instead of GitHub App authentication. +# It is required for registering runners at the enterprise level, where GitHub does not support +# GitHub App authentication. The token must carry the admin:enterprise (manage_runners:enterprise) +# scope and be owned by an enterprise owner. Fine-grained tokens do not expose enterprise scopes. +# When set, it takes precedence over the GITHUB_APP_* variables, which are then not required. +GITHUB_PAT="" + # [Required] GITHUB_URL is set to the URL of your GitHub repository. # For repositories under a personal account, use the format: # GITHUB_URL="https://github.com//" where is your account name and is your repo name @@ -25,6 +32,8 @@ GITHUB_APP_PRIVATE_KEY = "" # For Github Enterprise Self-Hosted instances (GHES) repositories under an organiztion, use the format: # GITHUB_URL="https:///" where is the url endpoint for your GHES instance, # and is the name of your organization in the GHES instance +# For runners registered at the enterprise level, use the format: +# GITHUB_URL="https:///enterprises/" which requires GITHUB_PAT GITHUB_URL="https://github.com/macstadium" # [Optional] Used to customize the API endpoint used for interacting with Github, diff --git a/main.go b/main.go index 3b1bb51..ee824f9 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,14 @@ func main() { logger.Warnf("%s is set to %q but is no longer used. The GitHub API URL is derived from %s; the derived value is logged by the scaleset client below. Remove %s to avoid confusion.", env.GitHubAPIURLEnvName, apiUrl, env.GitHubURLEnvName, env.GitHubAPIURLEnvName) } + if envData.GitHubPAT != "" { + logger.Infof("authenticating with the personal access token from %s", env.GitHubPATEnvName) + + if os.Getenv(env.GitHubAppIDEnvName) != "" { + logger.Warnf("%s is set but is ignored because %s takes precedence. Remove the GitHub App variables to avoid confusion.", env.GitHubAppIDEnvName, env.GitHubPATEnvName) + } + } + runnerName := envData.Runners[0].Name groupId := constants.DefaultRunnerGroupID if envData.Runners[0].Id != 0 { diff --git a/pkg/env/constants.go b/pkg/env/constants.go index 02309c4..26c65a5 100644 --- a/pkg/env/constants.go +++ b/pkg/env/constants.go @@ -9,6 +9,7 @@ const ( GitHubAPIURLEnvName = "GITHUB_API_URL" GitHubRunnerVersionEnvName = "GITHUB_RUNNER_VERSION" GitHubTokenEnvName = "GITHUB_TOKEN" // Token for public GitHub API authentication + GitHubPATEnvName = "GITHUB_PAT" OrkaURLEnvName = "ORKA_URL" OrkaTokenEnvName = "ORKA_TOKEN" diff --git a/pkg/env/env.go b/pkg/env/env.go index 312de83..3f7b643 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -30,6 +30,7 @@ type Data struct { GitHubAPIUrl string GitHubRunnerVersion string GitHubToken string // Token for authenticating with public GitHub API + GitHubPAT string OrkaURL string OrkaToken string @@ -74,6 +75,7 @@ func ParseEnv() *Data { GitHubAPIUrl: os.Getenv(GitHubAPIURLEnvName), GitHubRunnerVersion: os.Getenv(GitHubRunnerVersionEnvName), GitHubToken: os.Getenv(GitHubTokenEnvName), + GitHubPAT: os.Getenv(GitHubPATEnvName), OrkaURL: os.Getenv(OrkaURLEnvName), OrkaToken: os.Getenv(OrkaTokenEnvName), @@ -125,29 +127,35 @@ func ParseEnv() *Data { } } - if appID, err := strconv.ParseInt(os.Getenv(GitHubAppIDEnvName), 10, 64); err != nil { - errors = append(errors, fmt.Sprintf("%s is not set to a valid number: %s", GitHubAppIDEnvName, err)) - } else { - envData.GitHubAppID = appID - } - - if installationID, err := strconv.ParseInt(os.Getenv(GitHubAppInstallationIDEnvName), 10, 64); err != nil { - errors = append(errors, fmt.Sprintf("%s is not set to a valid number: %s", GitHubAppInstallationIDEnvName, err)) - } else { - envData.GitHubAppInstallationID = installationID - } - - if envData.GitHubAppPrivateKey == "" { - gitHubAppPrivateKeyPath := os.Getenv(GitHubAppPrivateKeyPathEnvName) - if gitHubAppPrivateKeyPath == "" { - errors = append(errors, fmt.Sprintf("GitHub App private key is required. Please provide either a file path to the private key using %s env or the private key directly using %s env variable", GitHubAppPrivateKeyPathEnvName, GitHubAppPrivateKeyEnvName)) + if envData.GitHubPAT == "" { + if !hasAnyGitHubAppEnv(envData) { + errors = append(errors, fmt.Sprintf("no GitHub authentication is configured. Set %s to a personal access token, which is required for enterprise-level runners, or configure GitHub App authentication by setting %s, %s, and one of %s or %s", GitHubPATEnvName, GitHubAppIDEnvName, GitHubAppInstallationIDEnvName, GitHubAppPrivateKeyPathEnvName, GitHubAppPrivateKeyEnvName)) } else { - privateKeyContent, err := os.ReadFile(gitHubAppPrivateKeyPath) - if err != nil { - errors = append(errors, err.Error()) + if appID, err := strconv.ParseInt(os.Getenv(GitHubAppIDEnvName), 10, 64); err != nil { + errors = append(errors, fmt.Sprintf("%s is not set to a valid number: %s", GitHubAppIDEnvName, err)) + } else { + envData.GitHubAppID = appID } - envData.GitHubAppPrivateKey = string(privateKeyContent) + if installationID, err := strconv.ParseInt(os.Getenv(GitHubAppInstallationIDEnvName), 10, 64); err != nil { + errors = append(errors, fmt.Sprintf("%s is not set to a valid number: %s", GitHubAppInstallationIDEnvName, err)) + } else { + envData.GitHubAppInstallationID = installationID + } + + if envData.GitHubAppPrivateKey == "" { + gitHubAppPrivateKeyPath := os.Getenv(GitHubAppPrivateKeyPathEnvName) + if gitHubAppPrivateKeyPath == "" { + errors = append(errors, fmt.Sprintf("GitHub App private key is required. Please provide either a file path to the private key using %s env or the private key directly using %s env variable", GitHubAppPrivateKeyPathEnvName, GitHubAppPrivateKeyEnvName)) + } else { + privateKeyContent, err := os.ReadFile(gitHubAppPrivateKeyPath) + if err != nil { + errors = append(errors, err.Error()) + } + + envData.GitHubAppPrivateKey = string(privateKeyContent) + } + } } } @@ -176,22 +184,6 @@ func ParseEnv() *Data { envData.Runners = runners } - if !regexp.MustCompile(`^https?://.+`).MatchString(envData.OrkaURL) { - errors = append(errors, fmt.Sprintf("%s env is required and must be set to the Orka API URL of the Orka cluster, for example, `http://10.221.188.20`", OrkaURLEnvName)) - } - - if envData.OrkaToken == "" { - errors = append(errors, fmt.Sprintf("%s env is required and must be set to a valid JWT token from the Orka cluster", OrkaTokenEnvName)) - } - - if envData.OrkaVMConfig == "" { - errors = append(errors, fmt.Sprintf("%s env is required and must be set to a valid and existing VM config in the Orka cluster", OrkaVMConfigEnvName)) - } - - if envData.OrkaVMMetadata != "" && !validateMetadata(envData.OrkaVMMetadata) { - errors = append(errors, fmt.Sprintf("%s must be formatted as key=value comma separated string", OrkaVMMetadataEnvName)) - } - if errs := validateEnv(envData); len(errs) > 0 { errors = append(errors, errs...) } @@ -269,6 +261,10 @@ func validateEnv(envData *Data) []string { errors = append(errors, fmt.Sprintf("%s env is required and must be set to the GitHub repository or organization URL, for example, 'https://github.com/your-username/your-repository'", GitHubURLEnvName)) } + if IsEnterpriseConfigURL(envData.GitHubURL) && envData.GitHubPAT == "" { + errors = append(errors, fmt.Sprintf("%s points to an enterprise, which GitHub does not support for GitHub App authentication. Set %s to a classic personal access token with the admin:enterprise (manage_runners:enterprise) scope, or point %s at an organization or repository instead", GitHubURLEnvName, GitHubPATEnvName, GitHubURLEnvName)) + } + if !regexp.MustCompile(`^https?://.+`).MatchString(envData.OrkaURL) { errors = append(errors, fmt.Sprintf("%s env is required and must be set to the Orka API URL of the Orka cluster, for example, `http://10.221.188.20`", OrkaURLEnvName)) } @@ -288,6 +284,24 @@ func validateEnv(envData *Data) []string { return errors } +func hasAnyGitHubAppEnv(envData *Data) bool { + return os.Getenv(GitHubAppIDEnvName) != "" || + os.Getenv(GitHubAppInstallationIDEnvName) != "" || + os.Getenv(GitHubAppPrivateKeyPathEnvName) != "" || + envData.GitHubAppPrivateKey != "" +} + +func IsEnterpriseConfigURL(githubURL string) bool { + u, err := url.Parse(strings.Trim(githubURL, "/")) + if err != nil { + return false + } + + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + + return len(pathParts) == 2 && strings.EqualFold(pathParts[0], "enterprises") +} + func validateMetadata(metadata string) bool { r, _ := regexp.Compile(`^(\w+=\w+)(,\s*\w+=\w+)*$`) return r.MatchString(metadata) diff --git a/pkg/env/env_test.go b/pkg/env/env_test.go index 381a463..79515c3 100644 --- a/pkg/env/env_test.go +++ b/pkg/env/env_test.go @@ -30,4 +30,19 @@ var _ = Describe("Env Test", func() { Entry("with invalid string with empty value, should be invalid", "key1=", false), Entry("with invalid string with no equals sign, should be invalid", "key1;value1", false), ) + + DescribeTable("when detecting an enterprise config URL", + func(input string, expected bool) { + Expect(IsEnterpriseConfigURL(input)).To(Equal(expected)) + }, + Entry("with a GHES enterprise URL, should be an enterprise", "https://github.enterprise.com/enterprises/my-enterprise", true), + Entry("with a github.com enterprise URL, should be an enterprise", "https://github.com/enterprises/my-enterprise", true), + Entry("with a trailing slash, should be an enterprise", "https://github.com/enterprises/my-enterprise/", true), + Entry("with mixed case, should be an enterprise", "https://github.com/Enterprises/my-enterprise", true), + Entry("with an organization URL, should not be an enterprise", "https://github.com/my-org", false), + Entry("with a repository URL, should not be an enterprise", "https://github.com/my-org/my-repo", false), + Entry("with a repository named enterprises, should not be an enterprise", "https://github.com/my-org/enterprises", false), + Entry("with an enterprise URL missing the name, should not be an enterprise", "https://github.com/enterprises", false), + Entry("with an empty string, should not be an enterprise", "", false), + ) }) diff --git a/pkg/github/scalesetclient/client.go b/pkg/github/scalesetclient/client.go index 74db90a..c56bffb 100644 --- a/pkg/github/scalesetclient/client.go +++ b/pkg/github/scalesetclient/client.go @@ -41,18 +41,31 @@ type Client struct { func New(envData *env.Data, maxRunners int) (*Client, error) { logger := slog.New(zapslog.NewHandler(logging.Logger.Named("scaleset").Desugar().Core())) - sdk, err := scaleset.NewClientWithGitHubApp(scaleset.ClientWithGitHubAppConfig{ - GitHubConfigURL: envData.GitHubURL, - GitHubAppAuth: scaleset.GitHubAppAuth{ - ClientID: fmt.Sprintf("%d", envData.GitHubAppID), - InstallationID: envData.GitHubAppInstallationID, - PrivateKey: envData.GitHubAppPrivateKey, - }, - SystemInfo: scaleset.SystemInfo{ - System: "orka-github-actions-integration", - Subsystem: "listener", - }, - }, scaleset.WithLogger(logger)) + systemInfo := scaleset.SystemInfo{ + System: "orka-github-actions-integration", + Subsystem: "listener", + } + + var sdk *scaleset.Client + var err error + + if envData.GitHubPAT != "" { + sdk, err = scaleset.NewClientWithPersonalAccessToken(scaleset.NewClientWithPersonalAccessTokenConfig{ + GitHubConfigURL: envData.GitHubURL, + PersonalAccessToken: envData.GitHubPAT, + SystemInfo: systemInfo, + }, scaleset.WithLogger(logger)) + } else { + sdk, err = scaleset.NewClientWithGitHubApp(scaleset.ClientWithGitHubAppConfig{ + GitHubConfigURL: envData.GitHubURL, + GitHubAppAuth: scaleset.GitHubAppAuth{ + ClientID: fmt.Sprintf("%d", envData.GitHubAppID), + InstallationID: envData.GitHubAppInstallationID, + PrivateKey: envData.GitHubAppPrivateKey, + }, + SystemInfo: systemInfo, + }, scaleset.WithLogger(logger)) + } if err != nil { return nil, fmt.Errorf("failed to create scaleset client: %w", err) }