mirror of
https://github.com/actions/actions-runner-controller.git
synced 2025-12-15 22:47:13 +00:00
Azure Key Vault integration to resolve secrets (#4090)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user