Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -37,17 +37,28 @@ 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.

### 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 "<GITHUB_URL>/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.
Expand Down
9 changes: 9 additions & 0 deletions examples/.env
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ GITHUB_APP_PRIVATE_KEY_PATH="<file-path-to-private-key.pem>"
# 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/<account-name>/<repo-name>" where <account-name> is your account name and <repo-name> is your repo name
Expand All @@ -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://<my-enterprise-github-url>/<org-name>" where <my-enterprise-github-url> is the url endpoint for your GHES instance,
# and <org-name> is the name of your organization in the GHES instance
# For runners registered at the enterprise level, use the format:
# GITHUB_URL="https://<my-enterprise-github-url>/enterprises/<enterprise-name>" which requires GITHUB_PAT
GITHUB_URL="https://github.com/macstadium"

# [Optional] Used to customize the API endpoint used for interacting with Github,
Expand Down
8 changes: 8 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions pkg/env/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
86 changes: 50 additions & 36 deletions pkg/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
}
}
}
}

Expand Down Expand Up @@ -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...)
}
Expand Down Expand Up @@ -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))
}
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions pkg/env/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
})
37 changes: 25 additions & 12 deletions pkg/github/scalesetclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading