Azure Key Vault integration to resolve secrets (#4090)

This commit is contained in:
Nikola Jokic
2025-06-11 15:53:33 +02:00
committed by GitHub
parent d4af75d82e
commit e46c929241
48 changed files with 2013 additions and 599 deletions

View File

@@ -1,6 +1,7 @@
package config
import (
"context"
"crypto/x509"
"encoding/json"
"fmt"
@@ -9,20 +10,26 @@ import (
"os"
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1/appconfig"
"github.com/actions/actions-runner-controller/build"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/actions/actions-runner-controller/logging"
"github.com/actions/actions-runner-controller/vault"
"github.com/actions/actions-runner-controller/vault/azurekeyvault"
"github.com/go-logr/logr"
"golang.org/x/net/http/httpproxy"
)
type Config struct {
ConfigureUrl string `json:"configure_url"`
// AppID can be an ID of the app or the client ID
AppID string `json:"app_id"`
AppInstallationID int64 `json:"app_installation_id"`
AppPrivateKey string `json:"app_private_key"`
Token string `json:"token"`
ConfigureUrl string `json:"configure_url"`
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.
AzureKeyVaultConfig *azurekeyvault.Config `json:"azure_key_vault,omitempty"`
// AppConfig contains the GitHub App configuration.
// It is initially set to nil if VaultType is set.
// Otherwise, it is populated with the GitHub App credentials from the GitHub secret.
*appconfig.AppConfig
EphemeralRunnerSetNamespace string `json:"ephemeral_runner_set_namespace"`
EphemeralRunnerSetName string `json:"ephemeral_runner_set_name"`
MaxRunners int `json:"max_runners"`
@@ -37,23 +44,58 @@ type Config struct {
Metrics *v1alpha1.MetricsConfig `json:"metrics"`
}
func Read(path string) (Config, error) {
f, err := os.Open(path)
func Read(ctx context.Context, configPath string) (*Config, error) {
f, err := os.Open(configPath)
if err != nil {
return Config{}, err
return nil, err
}
defer f.Close()
var config Config
if err := json.NewDecoder(f).Decode(&config); err != nil {
return Config{}, fmt.Errorf("failed to decode config: %w", err)
return nil, fmt.Errorf("failed to decode config: %w", err)
}
var vault vault.Vault
switch config.VaultType {
case "":
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("failed to validate configuration: %v", err)
}
return &config, nil
case "azure_key_vault":
akv, err := azurekeyvault.New(*config.AzureKeyVaultConfig)
if err != nil {
return nil, fmt.Errorf("failed to create Azure Key Vault client: %w", err)
}
vault = akv
default:
return nil, fmt.Errorf("unsupported vault type: %s", config.VaultType)
}
appConfigRaw, err := vault.GetSecret(ctx, config.VaultLookupKey)
if err != nil {
return nil, fmt.Errorf("failed to get app config from vault: %w", err)
}
appConfig, err := appconfig.FromJSONString(appConfigRaw)
if err != nil {
return nil, fmt.Errorf("failed to read app config from string: %v", err)
}
config.AppConfig = appConfig
if err := config.Validate(); err != nil {
return Config{}, fmt.Errorf("failed to validate config: %w", err)
return nil, fmt.Errorf("config validation failed: %w", err)
}
return config, nil
if ctx.Err() != nil {
return nil, ctx.Err()
}
return &config, nil
}
// Validate checks the configuration for errors.
@@ -74,15 +116,19 @@ func (c *Config) Validate() error {
return fmt.Errorf(`MinRunners "%d" cannot be greater than MaxRunners "%d"`, c.MinRunners, c.MaxRunners)
}
hasToken := len(c.Token) > 0
hasPrivateKeyConfig := len(c.AppID) > 0 && c.AppPrivateKey != ""
if !hasToken && !hasPrivateKeyConfig {
return fmt.Errorf(`GitHub auth credential is missing, token length: "%d", appId: %q, installationId: "%d", private key length: "%d"`, len(c.Token), c.AppID, c.AppInstallationID, len(c.AppPrivateKey))
if c.VaultType != "" {
if err := c.VaultType.Validate(); err != nil {
return fmt.Errorf("VaultType validation failed: %w", err)
}
if c.VaultLookupKey == "" {
return fmt.Errorf("VaultLookupKey is required when VaultType is set to %q", c.VaultType)
}
}
if hasToken && hasPrivateKeyConfig {
return fmt.Errorf(`only one GitHub auth method supported at a time. Have both PAT and App auth: token length: "%d", appId: %q, installationId: "%d", private key length: "%d"`, len(c.Token), c.AppID, c.AppInstallationID, len(c.AppPrivateKey))
if c.VaultType == "" && c.VaultLookupKey == "" {
if err := c.AppConfig.Validate(); err != nil {
return fmt.Errorf("AppConfig validation failed: %w", err)
}
}
return nil

View File

@@ -9,6 +9,7 @@ import (
"path/filepath"
"testing"
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1/appconfig"
"github.com/actions/actions-runner-controller/cmd/ghalistener/config"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/actions/actions-runner-controller/github/actions/testserver"
@@ -53,7 +54,9 @@ func TestCustomerServerRootCA(t *testing.T) {
config := config.Config{
ConfigureUrl: server.ConfigURLForOrg("myorg"),
ServerRootCA: certsString,
Token: "token",
AppConfig: &appconfig.AppConfig{
Token: "token",
},
}
client, err := config.ActionsClient(logr.Discard())
@@ -80,7 +83,9 @@ func TestProxySettings(t *testing.T) {
config := config.Config{
ConfigureUrl: "https://github.com/org/repo",
Token: "token",
AppConfig: &appconfig.AppConfig{
Token: "token",
},
}
client, err := config.ActionsClient(logr.Discard())
@@ -110,7 +115,9 @@ func TestProxySettings(t *testing.T) {
config := config.Config{
ConfigureUrl: "https://github.com/org/repo",
Token: "token",
AppConfig: &appconfig.AppConfig{
Token: "token",
},
}
client, err := config.ActionsClient(logr.Discard(), actions.WithRetryMax(0))
@@ -145,7 +152,9 @@ func TestProxySettings(t *testing.T) {
config := config.Config{
ConfigureUrl: "https://github.com/org/repo",
Token: "token",
AppConfig: &appconfig.AppConfig{
Token: "token",
},
}
client, err := config.ActionsClient(logr.Discard())

View File

@@ -1,9 +1,10 @@
package config
import (
"fmt"
"testing"
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1/appconfig"
"github.com/actions/actions-runner-controller/vault"
"github.com/stretchr/testify/assert"
)
@@ -15,7 +16,9 @@ func TestConfigValidationMinMax(t *testing.T) {
RunnerScaleSetId: 1,
MinRunners: 5,
MaxRunners: 2,
Token: "token",
AppConfig: &appconfig.AppConfig{
Token: "token",
},
}
err := config.Validate()
assert.ErrorContains(t, err, `MinRunners "5" cannot be greater than MaxRunners "2"`, "Expected error about MinRunners > MaxRunners")
@@ -29,7 +32,7 @@ func TestConfigValidationMissingToken(t *testing.T) {
RunnerScaleSetId: 1,
}
err := config.Validate()
expectedError := fmt.Sprintf(`GitHub auth credential is missing, token length: "%d", appId: %q, installationId: "%d", private key length: "%d"`, len(config.Token), config.AppID, config.AppInstallationID, len(config.AppPrivateKey))
expectedError := "AppConfig validation failed: missing app config"
assert.ErrorContains(t, err, expectedError, "Expected error about missing auth")
}
@@ -39,47 +42,53 @@ func TestConfigValidationAppKey(t *testing.T) {
t.Run("app id integer", func(t *testing.T) {
t.Parallel()
config := &Config{
AppID: "1",
AppInstallationID: 10,
AppConfig: &appconfig.AppConfig{
AppID: "1",
AppInstallationID: 10,
},
ConfigureUrl: "github.com/some_org/some_repo",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
}
err := config.Validate()
expectedError := fmt.Sprintf(`GitHub auth credential is missing, token length: "%d", appId: %q, installationId: "%d", private key length: "%d"`, len(config.Token), config.AppID, config.AppInstallationID, len(config.AppPrivateKey))
expectedError := "AppConfig validation failed: no credentials provided: either a PAT or GitHub App credentials should be provided"
assert.ErrorContains(t, err, expectedError, "Expected error about missing auth")
})
t.Run("app id as client id", func(t *testing.T) {
t.Parallel()
config := &Config{
AppID: "Iv23f8doAlphaNumer1c",
AppInstallationID: 10,
AppConfig: &appconfig.AppConfig{
AppID: "Iv23f8doAlphaNumer1c",
AppInstallationID: 10,
},
ConfigureUrl: "github.com/some_org/some_repo",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
}
err := config.Validate()
expectedError := fmt.Sprintf(`GitHub auth credential is missing, token length: "%d", appId: %q, installationId: "%d", private key length: "%d"`, len(config.Token), config.AppID, config.AppInstallationID, len(config.AppPrivateKey))
expectedError := "AppConfig validation failed: no credentials provided: either a PAT or GitHub App credentials should be provided"
assert.ErrorContains(t, err, expectedError, "Expected error about missing auth")
})
}
func TestConfigValidationOnlyOneTypeOfCredentials(t *testing.T) {
config := &Config{
AppID: "1",
AppInstallationID: 10,
AppPrivateKey: "asdf",
Token: "asdf",
AppConfig: &appconfig.AppConfig{
AppID: "1",
AppInstallationID: 10,
AppPrivateKey: "asdf",
Token: "asdf",
},
ConfigureUrl: "github.com/some_org/some_repo",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
}
err := config.Validate()
expectedError := fmt.Sprintf(`only one GitHub auth method supported at a time. Have both PAT and App auth: token length: "%d", appId: %q, installationId: "%d", private key length: "%d"`, len(config.Token), config.AppID, config.AppInstallationID, len(config.AppPrivateKey))
expectedError := "AppConfig validation failed: both PAT and GitHub App credentials provided. should only provide one"
assert.ErrorContains(t, err, expectedError, "Expected error about missing auth")
}
@@ -91,7 +100,9 @@ func TestConfigValidation(t *testing.T) {
RunnerScaleSetId: 1,
MinRunners: 1,
MaxRunners: 5,
Token: "asdf",
AppConfig: &appconfig.AppConfig{
Token: "asdf",
},
}
err := config.Validate()
@@ -110,3 +121,50 @@ func TestConfigValidationConfigUrl(t *testing.T) {
assert.ErrorContains(t, err, "GitHubConfigUrl is not provided", "Expected error about missing ConfigureUrl")
}
func TestConfigValidationWithVaultConfig(t *testing.T) {
t.Run("valid", func(t *testing.T) {
config := &Config{
ConfigureUrl: "https://github.com/actions",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
MinRunners: 1,
MaxRunners: 5,
VaultType: vault.VaultTypeAzureKeyVault,
VaultLookupKey: "testkey",
}
err := config.Validate()
assert.NoError(t, err, "Expected no error for valid vault type")
})
t.Run("invalid vault type", func(t *testing.T) {
config := &Config{
ConfigureUrl: "https://github.com/actions",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
MinRunners: 1,
MaxRunners: 5,
VaultType: vault.VaultType("invalid_vault_type"),
VaultLookupKey: "testkey",
}
err := config.Validate()
assert.ErrorContains(t, err, `unknown vault type: "invalid_vault_type"`, "Expected error for invalid vault type")
})
t.Run("vault type set without lookup key", func(t *testing.T) {
config := &Config{
ConfigureUrl: "https://github.com/actions",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
MinRunners: 1,
MaxRunners: 5,
VaultType: vault.VaultTypeAzureKeyVault,
VaultLookupKey: "",
}
err := config.Validate()
assert.ErrorContains(t, err, `VaultLookupKey is required when VaultType is set to "azure_key_vault"`, "Expected error for vault type without lookup key")
})
}