Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ ROSA: Support for OCM service account credentials #5233

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ROSA-HCP not ROSA

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming you meant to write that comment one line below.

Not saying I cannot change it, but ...

$ git checkout main
$ git grep ROSA docs/book/src/topics/rosa/creating-a-cluster.md
docs/book/src/topics/rosa/creating-a-cluster.md:# Creating a ROSA cluster
docs/book/src/topics/rosa/creating-a-cluster.md:CAPA controller requires service account credentials to be able to provision ROSA clusters:
docs/book/src/topics/rosa/creating-a-cluster.md:1. Create a new kubernetes secret with the service account credentials to be referenced later by `ROSAControlPlane`
docs/book/src/topics/rosa/creating-a-cluster.md:    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`)
docs/book/src/topics/rosa/creating-a-cluster.md:1. Create a credentials secret within the target namespace with the token to be referenced later by `ROSAControlePlane`
docs/book/src/topics/rosa/creating-a-cluster.md:Follow the guide [here](https://docs.aws.amazon.com/ROSA/latest/userguide/getting-started-hcp.html) up until [Step 3](https://docs.aws.amazon.com/ROSA/latest/userguide/getting-started-hcp.html#getting-started-hcp-step-3) 
docs/book/src/topics/rosa/creating-a-cluster.md:Once Step 3 is done, you will be ready to proceed with creating a ROSA cluster using cluster-api.
docs/book/src/topics/rosa/creating-a-cluster.md:1. Render the cluster manifest using the ROSA cluster template:
docs/book/src/topics/rosa/creating-a-cluster.md:1. If a credentials secret was created earlier, edit `ROSAControlPlane` to reference it:
docs/book/src/topics/rosa/creating-a-cluster.md:    kind: ROSAControlPlane
docs/book/src/topics/rosa/creating-a-cluster.md:    kind: ROSAControlPlane
docs/book/src/topics/rosa/creating-a-cluster.md:see [ROSAControlPlane CRD Reference](https://cluster-api-aws.sigs.k8s.io/crd/#controlplane.cluster.x-k8s.io/v1beta2.ROSAControlPlane) for all possible configurations.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most of our doc distinguish between ROSA (classic) and ROSA with HCP like the aws doc here so better to mention it as "ROSA-HCP" or "ROSA with HCP".

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. As I said, I can change it. My point was that the .md document in question uses just ROSA. No HCP, no Hypershift.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay then, lets do it in another PR change all docs at once.
Would you create an issue mentioning that we need to change ROSA to ROSA-HCP in docs so we don't forget it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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....' \
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ocmToken still valid to be used, keep it in the doc with deprecation note so old users be aware of the migration to service-account.

--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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, add deprecation Note

env:

### Authentication using SSO offline token (DEPRECATED)
The SSO offline token is being deprecated and it is recommended to use service account credentials instead, as described above.

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
```
```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add section , for old user how to migrate from using the offline token to service account.


### 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"
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)
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)
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
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add TODO: note to remove this part of the code next release

token = os.Getenv("OCM_TOKEN")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The endUser still should be able to use the os.Getenv to set the token and url for at least 1 releases. The order of getting the cred is 1- ROSAScop-secret 2- rosa-creds-secret 3-os.Getenv
if the endUser still use the os.Env raise warning logs mentioning that OCM_TOKEN & OCM_API_URL will not be used for next release.

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
}
119 changes: 119 additions & 0 deletions pkg/rosa/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package rosa

import (
"context"
"os"
"testing"

. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

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"
)

func createROSAControlPlaneScopeWithSecrets(cp *rosacontrolplanev1.ROSAControlPlane, secrets ...*corev1.Secret) *scope.ROSAControlPlaneScope {
// k8s mock (fake) client
fakeClientBuilder := fake.NewClientBuilder()
for _, sec := range secrets {
fakeClientBuilder.WithObjects(sec)
}

fakeClient := fakeClientBuilder.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 {
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 {
return &rosacontrolplanev1.ROSAControlPlane{
Spec: rosacontrolplanev1.RosaControlPlaneSpec{
CredentialsSecretRef: &corev1.LocalObjectReference{
Name: "rosa-creds-secret",
},
},
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
},
}
}

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
os.Setenv("OCM_API_URL", "env-url")
os.Setenv("OCM_TOKEN", "env-token")
rcpScope := createROSAControlPlaneScopeWithSecrets(cp, wlSecret, mgrSecret)
token, url, clientID, clientSecret, err := ocmCredentials(context.Background(), rcpScope)

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 = createROSAControlPlaneScopeWithSecrets(cp, mgrSecret)
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 = createROSAControlPlaneScopeWithSecrets(cp)
token, url, clientID, clientSecret, err = ocmCredentials(context.Background(), rcpScope)

g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(os.Getenv("OCM_TOKEN")))
g.Expect(url).To(Equal(os.Getenv("OCM_API_URL")))
g.Expect(clientID).To(Equal(""))
g.Expect(clientSecret).To(Equal(""))

// Test that ocmCredentials() returns error in case none of the secrets has been provided
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(clientID).To(Equal(""))
g.Expect(clientSecret).To(Equal(""))
}
Loading