Skip to content

Commit

Permalink
ROSA: Support for OCM service account credentials
Browse files Browse the repository at this point in the history
  • Loading branch information
mzazrivec committed Dec 13, 2024
1 parent 95b1622 commit 71fcc0c
Show file tree
Hide file tree
Showing 3 changed files with 287 additions and 40 deletions.
77 changes: 65 additions & 12 deletions docs/book/src/topics/rosa/creating-a-cluster.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,83 @@
# Creating a ROSA cluster

## Permissions
CAPA controller requires an API token in order to be able to provision ROSA clusters:
### Authentication using service account credentials
CAPA controller requires service account credentials to be able to provision ROSA clusters:
1. Visit [https://console.redhat.com/iam/service-accounts](https://console.redhat.com/iam/service-accounts) and create a new service account.

1. Visit [https://console.redhat.com/openshift/token](https://console.redhat.com/openshift/token) to retrieve your API authentication token

1. Create a credentials secret within the target namespace with the token to be referenced later by `ROSAControlePlane`
1. Create a new kubernetes secret with the service account credentials to be referenced later by `ROSAControlPlane`
```shell
kubectl create secret generic rosa-creds-secret \
--from-literal=ocmToken='eyJhbGciOiJIUzI1NiIsI....' \
--from-literal=ocmApiUrl='https://api.openshift.com'
--from-literal=ocmClientId='....' \
--from-literal=ocmClientSecret='eyJhbGciOiJIUzI1NiIsI....' \
--from-literal=ocmApiUrl='https://api.openshift.com'
```

Alternatively, you can edit CAPA controller deployment to provide the credentials:
Note: to consume the secret without the need to reference it from your `ROSAControlPlane`, name your secret as `rosa-creds-secret` and create it in the CAPA manager namespace (usually `capa-system`):
```shell
kubectl edit deployment -n capa-system capa-controller-manager
kubectl -n capa-system create secret generic rosa-creds-secret \
--from-literal=ocmClientId='....' \
--from-literal=ocmClientSecret='eyJhbGciOiJIUzI1NiIsI....' \
--from-literal=ocmApiUrl='https://api.openshift.com'
```

and add the following environment variables to the manager container:
```yaml
env:

### Authentication using SSO offline token (DEPRECATED)
Instead of the service account credentials you can use SSO offline token, that you can specify either as a secret or specify the offline token
as an environment variable in the capa manager deployment.
1. Visit https://console.redhat.com/openshift/token to retrieve your SSO offline authentication token

1. Create a credentials secret within the target namespace with the token to be referenced later by `ROSAControlePlane`
```shell
kubectl create secret generic rosa-creds-secret \
--from-literal=ocmToken='eyJhbGciOiJIUzI1NiIsI....' \
--from-literal=ocmApiUrl='https://api.openshift.com'
```

Alternatively, you can edit CAPA controller deployment to provide the credentials:
```shell
kubectl edit deployment -n capa-system capa-controller-manager
```
and add the following environment variables to the manager container:
```yaml
env:
- name: OCM_TOKEN
value: "<token>"
- name: OCM_API_URL
value: "https://api.openshift.com" # or https://api.stage.openshift.com
```
```

### Migration from offline token to service account authentication

1. Visit [https://console.redhat.com/iam/service-accounts](https://console.redhat.com/iam/service-accounts) and create a new service account.

1. If you previously used kubernetes secret to specify the OCM credentials secret, edit the secret:
```shell
kubectl edit secret rosa-creds-secret
```
where you will remove the `ocmToken` credentials and add base64 encoded `ocmClientId` and `ocmClientSecret` credentials like so:
```yaml
apiVersion: v1
data:
ocmApiUrl: aHR0cHM6Ly9hcGkub3BlbnNoaWZ0LmNvbQ==
ocmClientId: Y2xpZW50X2lk...
ocmClientSecret: Y2xpZW50X3NlY3JldA==...
kind: Secret
type: Opaque
```

1. If you previously used capa manager deployment to specify the OCM offline token as environment variable, edit the manager deployment:
```shell
kubectl -n capa-system edit deployment capa-controller-manager
```
and remove the `OCM_TOKEN` and `OCM_API_URL` variables, followed by `kubectl -n capa-system rollout restart deploy capa-controller-manager`. Then create the new default
secret in the `capa-system` namespace with:
```shell
kubectl -n capa-system create secret generic rosa-creds-secret \
--from-literal=ocmClientId='....' \
--from-literal=ocmClientSecret='eyJhbGciOiJIUzI1NiIsI....' \
--from-literal=ocmApiUrl='https://api.openshift.com'
```

## Prerequisites

Expand Down
111 changes: 83 additions & 28 deletions pkg/rosa/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,74 +10,129 @@ import (
ocmcfg "github.com/openshift/rosa/pkg/config"
"github.com/openshift/rosa/pkg/ocm"
"github.com/sirupsen/logrus"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"

"sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/scope"
"sigs.k8s.io/cluster-api-provider-aws/v2/util/system"
)

const (
ocmTokenKey = "ocmToken"
ocmAPIURLKey = "ocmApiUrl"
ocmTokenKey = "ocmToken"
ocmAPIURLKey = "ocmApiUrl"
ocmClientIdKey = "ocmClientId"

Check failure on line 25 in pkg/rosa/client.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: const ocmClientIdKey should be ocmClientIDKey (revive)

Check failure on line 25 in pkg/rosa/client.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: const ocmClientIdKey should be ocmClientIDKey (revive)
ocmClientSecretKey = "ocmClientSecret"
)

// NewOCMClient creates a new OCM client.
func NewOCMClient(ctx context.Context, rosaScope *scope.ROSAControlPlaneScope) (*ocm.Client, error) {
token, url, err := ocmCredentials(ctx, rosaScope)
token, url, clientId, clientSecret, err := ocmCredentials(ctx, rosaScope)

Check failure on line 31 in pkg/rosa/client.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var clientId should be clientID (revive)

Check failure on line 31 in pkg/rosa/client.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var clientId should be clientID (revive)
if err != nil {
return nil, err
}
return ocm.NewClient().Logger(logrus.New()).Config(&ocmcfg.Config{
AccessToken: token,
URL: url,
}).Build()

ocmConfig := ocmcfg.Config{
URL: url,
}

if clientId != "" && clientSecret != "" {
ocmConfig.ClientID = clientId
ocmConfig.ClientSecret = clientSecret
} else if token != "" {
ocmConfig.AccessToken = token
}

return ocm.NewClient().Logger(logrus.New()).Config(&ocmConfig).Build()
}

func newOCMRawConnection(ctx context.Context, rosaScope *scope.ROSAControlPlaneScope) (*sdk.Connection, error) {
logger, err := sdk.NewGoLoggerBuilder().
ocmSdkLogger, err := sdk.NewGoLoggerBuilder().
Debug(false).
Build()
if err != nil {
return nil, fmt.Errorf("failed to build logger: %w", err)
}
token, url, err := ocmCredentials(ctx, rosaScope)

token, url, clientId, clientSecret, err := ocmCredentials(ctx, rosaScope)

Check failure on line 58 in pkg/rosa/client.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var clientId should be clientID (revive)

Check failure on line 58 in pkg/rosa/client.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var clientId should be clientID (revive)
if err != nil {
return nil, err
}

connection, err := sdk.NewConnectionBuilder().
Logger(logger).
Tokens(token).
URL(url).
Build()
connBuilder := sdk.NewConnectionBuilder().
Logger(ocmSdkLogger).
URL(url)

if clientId != "" && clientSecret != "" {
connBuilder.Client(clientId, clientSecret)
} else if token != "" {
connBuilder.Tokens(token)
}

connection, err := connBuilder.Build()
if err != nil {
return nil, fmt.Errorf("failed to create ocm connection: %w", err)
}

return connection, nil
}

func ocmCredentials(ctx context.Context, rosaScope *scope.ROSAControlPlaneScope) (string, string, error) {
var token string
var ocmAPIUrl string
func ocmCredentials(ctx context.Context, rosaScope *scope.ROSAControlPlaneScope) (string, string, string, string, error) {
var token string // Offline SSO token
var ocmClientId string // Service account client id

Check failure on line 83 in pkg/rosa/client.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var ocmClientId should be ocmClientID (revive)

Check failure on line 83 in pkg/rosa/client.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var ocmClientId should be ocmClientID (revive)
var ocmClientSecret string // Service account client secret
var ocmAPIUrl string // https://api.openshift.com by default
var secret *corev1.Secret

secret := rosaScope.CredentialsSecret()
secret = rosaScope.CredentialsSecret() // We'll retrieve the OCM credentials ref from the ROSA control plane
if secret != nil {
if err := rosaScope.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil {
return "", "", fmt.Errorf("failed to get credentials secret: %w", err)
return "", "", "", "", fmt.Errorf("failed to get credentials secret: %w", err)
}
} else { // If the reference to OCM secret wasn't specified in the ROSA control plane, we'll try to use a predefined secret name from the capa namespace
secret = &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "rosa-creds-secret",
Namespace: system.GetManagerNamespace(),
},
}

err := rosaScope.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret)
// We'll ignore non-existent secret so that we can try the ENV variable fallback below
// TODO: once the ENV variable fallback is gone, we can no longer ignore non-existent secret here
if err != nil && !apierrors.IsNotFound(err) {
return "", "", "", "", fmt.Errorf("failed to get credentials secret: %w", err)
}
}

token = string(secret.Data[ocmTokenKey])
ocmAPIUrl = string(secret.Data[ocmAPIURLKey])
ocmClientId = string(secret.Data[ocmClientIdKey])
ocmClientSecret = string(secret.Data[ocmClientSecretKey])

token = string(secret.Data[ocmTokenKey])
ocmAPIUrl = string(secret.Data[ocmAPIURLKey])
} else {
// fallback to env variables if secrert is not set
// Deprecation warning in case SSO offline token was used
if token != "" {
rosaScope.Info("Using SSO offline token is deprecated, use service account credentials instead")
}

if token == "" && (ocmClientId == "" || ocmClientSecret == "") {
// TODO: the ENV variables are to be removed with the next code release
// Last fall-back is to use OCM_TOKEN & OCM_API_URL environment variables (soon to be deprecated)
token = os.Getenv("OCM_TOKEN")
if ocmAPIUrl = os.Getenv("OCM_API_URL"); ocmAPIUrl == "" {
ocmAPIUrl = "https://api.openshift.com"
ocmAPIUrl = os.Getenv("OCM_API_URL")

if token != "" {
rosaScope.Info("Defining OCM credentials in environment variable is deprecated, use secret with service account credentials instead")
} else {
return "", "", "", "",
fmt.Errorf("OCM credentials have not been provided. Make sure to set the secret with service account credentials")
}
}

if token == "" {
return "", "", fmt.Errorf("token is not provided, be sure to set OCM_TOKEN env variable or reference a credentials secret with key %s", ocmTokenKey)
if ocmAPIUrl == "" {
ocmAPIUrl = "https://api.openshift.com" // Defaults to production URL
}
return token, ocmAPIUrl, nil

return token, ocmAPIUrl, ocmClientId, ocmClientSecret, nil
}
139 changes: 139 additions & 0 deletions pkg/rosa/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package rosa

import (
"context"
"os"
"testing"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/klog/v2"

rosacontrolplanev1 "sigs.k8s.io/cluster-api-provider-aws/v2/controlplane/rosa/api/v1beta2"
"sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/scope"
"sigs.k8s.io/cluster-api-provider-aws/v2/pkg/logger"
"sigs.k8s.io/cluster-api-provider-aws/v2/util/system"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

. "github.com/onsi/gomega"
)

func createROSAControlPlaneScope(wlSecret, mgrSecret *corev1.Secret, cp *rosacontrolplanev1.ROSAControlPlane) *scope.ROSAControlPlaneScope {
// k8s mock (fake) client
if wlSecret == nil {
wlSecret = &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "bogus-name-01",
Namespace: "bogus-namespace",
},
}
}

if mgrSecret == nil {
mgrSecret = &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "bogus-name-02",
Namespace: "bogus-namespace",
},
}
}

fakeClient := fake.NewClientBuilder().WithObjects(wlSecret, mgrSecret).Build()

// ROSA Control Plane Scope
rcpScope := &scope.ROSAControlPlaneScope{
Client: fakeClient,
ControlPlane: cp,
Logger: *logger.NewLogger(klog.Background()),
}

return rcpScope
}

func createSecret(name, namespace, token, url, clientId, clientSecret string) *corev1.Secret {

Check failure on line 53 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: func parameter clientId should be clientID (revive)

Check failure on line 53 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: func parameter clientId should be clientID (revive)
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Data: map[string][]byte{
"ocmToken": []byte(token),
"ocmApiUrl": []byte(url),
"ocmClientId": []byte(clientId),
"ocmClientSecret": []byte(clientSecret),
},
}
}

func createCP(namespace string) *rosacontrolplanev1.ROSAControlPlane {

Check failure on line 68 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

`createCP` - `namespace` is unused (unparam)

Check failure on line 68 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

`createCP` - `namespace` is unused (unparam)
return &rosacontrolplanev1.ROSAControlPlane{
Spec: rosacontrolplanev1.RosaControlPlaneSpec{
CredentialsSecretRef: &corev1.LocalObjectReference{
Name: "rosa-creds-secret",
},
},
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
}

}

Check failure on line 80 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

unnecessary trailing newline (whitespace)

Check failure on line 80 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

unnecessary trailing newline (whitespace)

func TestOcmCredentials(t *testing.T) {
g := NewWithT(t)

wlSecret := createSecret("rosa-creds-secret", "default", "", "url", "client-id", "client-secret")
mgrSecret := createSecret("rosa-creds-secret", system.GetManagerNamespace(), "", "url", "global-client-id", "global-client-secret")

cp := createCP("default")

// Test that ocmCredentials() prefers workload secret to global and environment secrets
cp.Spec = rosacontrolplanev1.RosaControlPlaneSpec{
CredentialsSecretRef: &corev1.LocalObjectReference{
Name: "rosa-creds-secret",
},
}
os.Setenv("OCM_API_URL", "env-url")
os.Setenv("OCM_TOKEN", "env-token")
rcpScope := createROSAControlPlaneScope(wlSecret, mgrSecret, cp)
token, url, clientId, clientSecret, err := ocmCredentials(context.Background(), rcpScope)

Check failure on line 99 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var clientId should be clientID (revive)

Check failure on line 99 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var clientId should be clientID (revive)

g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(string(wlSecret.Data["ocmToken"])))
g.Expect(url).To(Equal(string(wlSecret.Data["ocmApiUrl"])))
g.Expect(clientId).To(Equal(string(wlSecret.Data["ocmClientId"])))
g.Expect(clientSecret).To(Equal(string(wlSecret.Data["ocmClientSecret"])))

// Test that ocmCredentials() prefers global manager secret to environment secret in case workload secret is not specified
cp.Spec = rosacontrolplanev1.RosaControlPlaneSpec{}
rcpScope = createROSAControlPlaneScope(wlSecret, mgrSecret, cp)
token, url, clientId, clientSecret, err = ocmCredentials(context.Background(), rcpScope)

g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(string(mgrSecret.Data["ocmToken"])))
g.Expect(url).To(Equal(string(mgrSecret.Data["ocmApiUrl"])))
g.Expect(clientId).To(Equal(string(mgrSecret.Data["ocmClientId"])))
g.Expect(clientSecret).To(Equal(string(mgrSecret.Data["ocmClientSecret"])))

// Test that ocmCredentials() returns environment secret in case workload and manager secret are not specified
cp.Spec = rosacontrolplanev1.RosaControlPlaneSpec{}
rcpScope = createROSAControlPlaneScope(nil, nil, cp)
token, url, clientId, clientSecret, err = ocmCredentials(context.Background(), rcpScope)

g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(string(os.Getenv("OCM_TOKEN"))))

Check failure on line 124 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

unnecessary conversion (unconvert)

Check failure on line 124 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

unnecessary conversion (unconvert)
g.Expect(url).To(Equal(string(os.Getenv("OCM_API_URL"))))

Check failure on line 125 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

unnecessary conversion (unconvert)

Check failure on line 125 in pkg/rosa/client_test.go

View workflow job for this annotation

GitHub Actions / lint

unnecessary conversion (unconvert)
g.Expect(clientSecret).To(Equal(""))

// Test that ocmCredentials() returns error in case none of the secrets has been provided
cp.Spec = rosacontrolplanev1.RosaControlPlaneSpec{}
rcpScope = createROSAControlPlaneScope(nil, nil, cp)
os.Unsetenv("OCM_API_URL")
os.Unsetenv("OCM_TOKEN")
token, url, clientId, clientSecret, err = ocmCredentials(context.Background(), rcpScope)

g.Expect(err).To(HaveOccurred())
g.Expect(token).To(Equal(""))
g.Expect(url).To(Equal(""))
g.Expect(clientSecret).To(Equal(""))
}

0 comments on commit 71fcc0c

Please sign in to comment.