diff --git a/clients/rancher/auth/auth.go b/clients/rancher/auth/auth.go index 1187e60f..3928465d 100644 --- a/clients/rancher/auth/auth.go +++ b/clients/rancher/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( "github.com/rancher/shepherd/clients/rancher/auth/activedirectory" + "github.com/rancher/shepherd/clients/rancher/auth/oidc" "github.com/rancher/shepherd/clients/rancher/auth/openldap" management "github.com/rancher/shepherd/clients/rancher/generated/management/v3" "github.com/rancher/shepherd/pkg/session" @@ -10,6 +11,7 @@ import ( type Client struct { OLDAP *openldap.OLDAPClient ActiveDirectory *activedirectory.Client + OIDC *oidc.APIClient } // NewClient constructs the Auth Provider Struct @@ -27,5 +29,6 @@ func NewClient(mgmt *management.Client, session *session.Session) (*Client, erro return &Client{ OLDAP: oLDAP, ActiveDirectory: activeDirectory, + OIDC: oidc.NewAPIClient(mgmt.Opts.URL, session), }, nil } diff --git a/clients/rancher/auth/oidc/config.go b/clients/rancher/auth/oidc/config.go new file mode 100644 index 00000000..ac083514 --- /dev/null +++ b/clients/rancher/auth/oidc/config.go @@ -0,0 +1,25 @@ +package oidc + +const ( + ConfigurationFileKey = "oidc" + OIDCProviderFeatureFlag = "oidc-provider" + DefaultTokenExpirationSeconds = 3600 + DefaultRefreshTokenExpirationSeconds = 86400 +) + +var DefaultAutomationScopes = []string{ + "openid", + "profile", + "offline_access", + "rancher:users", +} + +type Config struct { + ClientName string `json:"clientName" yaml:"clientName"` + RedirectURI string `json:"redirectURI" yaml:"redirectURI"` + Scopes []string `json:"scopes" yaml:"scopes"` + TokenExpirationSeconds int `json:"tokenExpirationSeconds" yaml:"tokenExpirationSeconds"` + RefreshTokenExpirationSeconds int `json:"refreshTokenExpirationSeconds" yaml:"refreshTokenExpirationSeconds"` + AdminUsername string `json:"adminUsername" yaml:"adminUsername"` + AdminPassword string `json:"adminPassword" yaml:"adminPassword"` +} diff --git a/clients/rancher/auth/oidc/oidc.go b/clients/rancher/auth/oidc/oidc.go new file mode 100644 index 00000000..f1e2371f --- /dev/null +++ b/clients/rancher/auth/oidc/oidc.go @@ -0,0 +1,170 @@ +package oidc + +import ( + "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + management "github.com/rancher/shepherd/clients/rancher/generated/management/v3" + oidcext "github.com/rancher/shepherd/extensions/auth/oidc" + sheptoken "github.com/rancher/shepherd/extensions/token" + "github.com/rancher/shepherd/pkg/clientbase" + "github.com/rancher/shepherd/pkg/config" + "github.com/rancher/shepherd/pkg/session" + "github.com/sirupsen/logrus" +) + +type APIClient struct { + rancherURL string + httpClient *http.Client + noRedirectClient *http.Client + Config *Config + session *session.Session +} + +// NewAPIClient constructs an APIClient and loads the "oidc" config block. +func NewAPIClient(rancherURL string, session *session.Session) *APIClient { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint:gosec + } + normalizedURL := strings.TrimRight(rancherURL, "/") + if !strings.HasPrefix(normalizedURL, "https://") && !strings.HasPrefix(normalizedURL, "http://") { + normalizedURL = "https://" + normalizedURL + } + oidcConfig := new(Config) + config.LoadConfig(ConfigurationFileKey, oidcConfig) + return &APIClient{ + rancherURL: normalizedURL, + Config: oidcConfig, + session: session, + httpClient: &http.Client{Transport: transport}, + noRedirectClient: &http.Client{ + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +// GetDiscovery fetches the OIDC provider metadata document. +func (c *APIClient) GetDiscovery() (*clientbase.Response, map[string]interface{}, error) { + return oidcext.Discovery(c.httpClient, c.rancherURL) +} + +// RefreshAccessToken exchanges a refresh_token for a new TokenSet. +func (c *APIClient) RefreshAccessToken(refreshToken, clientID, clientSecret string) (*oidcext.TokenSet, error) { + return oidcext.RefreshAccessToken(c.httpClient, c.rancherURL, refreshToken, clientID, clientSecret) +} + +// RawRequest executes an HTTP request against a Rancher API path. +func (c *APIClient) RawRequest(method, path, authHeader string) (*clientbase.Response, error) { + headers := map[string]string{} + if authHeader != "" { + headers["Authorization"] = authHeader + } + return clientbase.Do(c.httpClient, method, c.rancherURL+path, nil, headers) +} + +// CompleteAuthCodeFlow drives the headless PKCE authorization-code flow and returns a TokenSet. +func (c *APIClient) CompleteAuthCodeFlow(clientID, clientSecret, redirectURI, scopes, username, password string) (*oidcext.TokenSet, error) { + const maxAttempts = 5 + for attempt := 1; attempt <= maxAttempts; attempt++ { + tokenResp, err := sheptoken.GenerateUserToken(&management.User{Username: username, Password: password}, strings.TrimPrefix(c.rancherURL, "https://")) + if err != nil { + return nil, fmt.Errorf("step 1 (Rancher login): %w", err) + } + rancherToken := tokenResp.Token + pkce, err := oidcext.GeneratePKCE() + if err != nil { + return nil, fmt.Errorf("step 2 (PKCE generation): %w", err) + } + state, err := randomState(12) + if err != nil { + return nil, fmt.Errorf("step 2 (state generation): %w", err) + } + nonce, err := randomState(12) + if err != nil { + return nil, fmt.Errorf("step 2 (nonce generation): %w", err) + } + params := url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {redirectURI}, + "scope": {scopes}, + "state": {state}, + "nonce": {nonce}, + "code_challenge": {pkce.Challenge}, + "code_challenge_method": {"S256"}, + } + authURL := c.rancherURL + oidcext.OIDCAuthPath + "?" + params.Encode() + authResp, err := clientbase.Do(c.noRedirectClient, "GET", authURL, nil, map[string]string{ + "Authorization": "Bearer " + rancherToken, + }) + if err != nil { + return nil, fmt.Errorf("step 3 (auth endpoint GET): %w", err) + } + if authResp.StatusCode != http.StatusFound { + return nil, fmt.Errorf("step 3 expected 302 from auth endpoint, got %d: %s", + authResp.StatusCode, authResp.Body) + } + location := authResp.Header.Get("Location") + if location == "" { + return nil, fmt.Errorf("step 4: auth endpoint returned 302 but no Location header") + } + if strings.Contains(location, "/dashboard/auth/login") { + logrus.Infof("step 4: auth endpoint redirected to dashboard login — Rancher session token was rejected, retrying") + continue + } + redirectParsed, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("step 4: parsing Location URL %q: %w", location, err) + } + authCode := redirectParsed.Query().Get("code") + if authCode == "" { + return nil, fmt.Errorf("step 4: no 'code' parameter in redirect Location %q", location) + } + if redirectParsed.Query().Get("state") != state { + return nil, fmt.Errorf("step 4: state mismatch") + } + body := url.Values{ + "grant_type": {"authorization_code"}, + "code": {authCode}, + "code_verifier": {pkce.Verifier}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + "redirect_uri": {redirectURI}, + } + resp, err := clientbase.Do(c.httpClient, "POST", c.rancherURL+oidcext.OIDCTokenPath, body, map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }) + if err != nil { + return nil, fmt.Errorf("token exchange POST: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token exchange returned %d: %s", resp.StatusCode, resp.Body) + } + var ts oidcext.TokenSet + if err := json.Unmarshal(resp.Body, &ts); err != nil { + return nil, fmt.Errorf("parsing token response: %w", err) + } + if ts.AccessToken == "" { + return nil, fmt.Errorf("token response missing access_token field") + } + return &ts, nil + } + return nil, fmt.Errorf("PKCE auth flow failed after %d attempts", maxAttempts) +} + +func randomState(n int) (string, error) { + raw := make([]byte, n) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generating random state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} diff --git a/clients/rancher/auth/scim/client.go b/clients/rancher/auth/scim/client.go index 5b31ea18..0e0f376e 100644 --- a/clients/rancher/auth/scim/client.go +++ b/clients/rancher/auth/scim/client.go @@ -1,12 +1,10 @@ package scim import ( - "bytes" "crypto/tls" "crypto/x509" "encoding/json" "fmt" - "io" "net/http" "net/url" "time" @@ -89,34 +87,17 @@ func (t *scimTransport) do(method, resource, id string, query url.Values, body i rawURL += "?" + query.Encode() } - var bodyReader io.Reader - if body != nil { - b, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(b) - } - - req, err := http.NewRequest(method, rawURL, bodyReader) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+t.token) - req.Header.Set("Content-Type", "application/scim+json") - req.Header.Set("Accept", "application/scim+json") - - resp, err := t.httpClient.Do(req) - if err != nil { - return nil, err + headers := map[string]string{ + "Authorization": "Bearer " + t.token, + "Content-Type": "application/scim+json", + "Accept": "application/scim+json", } - defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + resp, err := clientbase.Do(t.httpClient, method, rawURL, body, headers) if err != nil { return nil, err } - return &Response{StatusCode: resp.StatusCode, Body: respBody, Header: resp.Header}, nil + return &Response{StatusCode: resp.StatusCode, Body: resp.Body, Header: resp.Header}, nil } // Users is the interface for SCIM User operations. diff --git a/extensions/auth/oidc/jwt.go b/extensions/auth/oidc/jwt.go new file mode 100644 index 00000000..2e17588e --- /dev/null +++ b/extensions/auth/oidc/jwt.go @@ -0,0 +1,39 @@ +package oidc + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// DecodeJWTPayload decodes the payload section of a JWT without verifying the signature. +func DecodeJWTPayload(token string) (map[string]interface{}, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT structure: expected 3 parts, got %d", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decoding JWT payload: %w", err) + } + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("parsing JWT claims: %w", err) + } + return claims, nil +} + +// TamperJWTSignature replaces the signature segment of a JWT with random bytes. +func TamperJWTSignature(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid JWT: cannot tamper signature") + } + fakeSig := make([]byte, 32) + if _, err := rand.Read(fakeSig); err != nil { + return "", fmt.Errorf("generating fake signature: %w", err) + } + return parts[0] + "." + parts[1] + "." + base64.RawURLEncoding.EncodeToString(fakeSig), nil +} diff --git a/extensions/auth/oidc/oidc.go b/extensions/auth/oidc/oidc.go new file mode 100644 index 00000000..94d84216 --- /dev/null +++ b/extensions/auth/oidc/oidc.go @@ -0,0 +1,69 @@ +package oidc + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/rancher/shepherd/pkg/clientbase" +) + +const ( + OIDCClientGroup = "management.cattle.io" + OIDCClientVersion = "v3" + OIDCClientResource = "oidcclients" + OIDCClientKind = "OIDCClient" + + OIDCDiscoveryPath = "/oidc/.well-known/openid-configuration" + OIDCAuthPath = "/oidc/authorize" + OIDCTokenPath = "/oidc/token" + UsersPath = "/v3/users" + ClustersPath = "/v3/clusters" +) + +type TokenSet struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +// Discovery fetches the OIDC provider metadata document. +func Discovery(httpClient *http.Client, rancherURL string) (*clientbase.Response, map[string]interface{}, error) { + resp, err := clientbase.Do(httpClient, "GET", rancherURL+OIDCDiscoveryPath, nil, nil) + if err != nil { + return nil, nil, fmt.Errorf("fetching OIDC discovery: %w", err) + } + var doc map[string]interface{} + if err := json.Unmarshal(resp.Body, &doc); err != nil { + return resp, nil, fmt.Errorf("parsing discovery document: %w", err) + } + return resp, doc, nil +} + +// RefreshAccessToken exchanges a refresh_token for a new TokenSet. +func RefreshAccessToken(httpClient *http.Client, rancherURL, refreshToken, clientID, clientSecret string) (*TokenSet, error) { + body := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + } + resp, err := clientbase.Do(httpClient, "POST", rancherURL+OIDCTokenPath, body, map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }) + if err != nil { + return nil, fmt.Errorf("refresh token POST: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("refresh returned %d: %s", resp.StatusCode, resp.Body) + } + var ts TokenSet + if err := json.Unmarshal(resp.Body, &ts); err != nil { + return nil, fmt.Errorf("parsing refresh response: %w", err) + } + return &ts, nil +} diff --git a/extensions/auth/oidc/pkce.go b/extensions/auth/oidc/pkce.go new file mode 100644 index 00000000..3ae59e0f --- /dev/null +++ b/extensions/auth/oidc/pkce.go @@ -0,0 +1,25 @@ +package oidc + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +type PKCEPair struct { + Verifier string + Challenge string +} + +// GeneratePKCE creates a fresh PKCE verifier/challenge pair using the S256 method. +func GeneratePKCE() (PKCEPair, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return PKCEPair{}, fmt.Errorf("generating PKCE verifier entropy: %w", err) + } + verifier := base64.RawURLEncoding.EncodeToString(raw) + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return PKCEPair{Verifier: verifier, Challenge: challenge}, nil +} diff --git a/extensions/kubeapi/auth/oidcclient/oidcclient.go b/extensions/kubeapi/auth/oidcclient/oidcclient.go new file mode 100644 index 00000000..19ae488c --- /dev/null +++ b/extensions/kubeapi/auth/oidcclient/oidcclient.go @@ -0,0 +1,155 @@ +package oidcclient + +import ( + "context" + "fmt" + + "github.com/rancher/shepherd/clients/rancher" + oidcext "github.com/rancher/shepherd/extensions/auth/oidc" + "github.com/rancher/shepherd/extensions/defaults" + "github.com/sirupsen/logrus" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + kwait "k8s.io/apimachinery/pkg/util/wait" +) + +const OIDCClientSecretNamespace = "cattle-oidc-client-secrets" + +var oidcClientGVR = schema.GroupVersionResource{ + Group: oidcext.OIDCClientGroup, + Version: oidcext.OIDCClientVersion, + Resource: oidcext.OIDCClientResource, +} + +type ClientSpec struct { + RedirectURIs []string + Scopes []string + TokenExpirationSeconds int + RefreshTokenExpirationSeconds int +} + +// CreateOIDCClient creates an OIDCClient CRD and registers DeleteOIDCClient as session cleanup. +func CreateOIDCClient(client *rancher.Client, name string, spec ClientSpec) error { + logrus.Infof("[OIDC setup] Creating OIDCClient CRD %q on management cluster", name) + redirectURIs := make([]interface{}, len(spec.RedirectURIs)) + for i, v := range spec.RedirectURIs { + redirectURIs[i] = v + } + scopes := make([]interface{}, len(spec.Scopes)) + for i, v := range spec.Scopes { + scopes[i] = v + } + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": oidcext.OIDCClientGroup + "/" + oidcext.OIDCClientVersion, + "kind": oidcext.OIDCClientKind, + "metadata": map[string]interface{}{ + "name": name, + }, + "spec": map[string]interface{}{ + "redirectURIs": redirectURIs, + "scopes": scopes, + "tokenExpirationSeconds": int64(spec.TokenExpirationSeconds), + "refreshTokenExpirationSeconds": int64(spec.RefreshTokenExpirationSeconds), + }, + }, + } + dynClient, err := client.GetRancherDynamicClient() + if err != nil { + return err + } + _, err = dynClient.Resource(oidcClientGVR).Create(context.Background(), obj, metav1.CreateOptions{}) + if err != nil { + if !k8serrors.IsAlreadyExists(err) { + return fmt.Errorf("creating OIDCClient %q: %w", name, err) + } + logrus.Infof("[OIDC setup] OIDCClient %q already exists — skipping creation", name) + return nil + } + logrus.Infof("[OIDC setup] OIDCClient %q created", name) + client.Session.RegisterCleanupFunc(func() error { + return DeleteOIDCClient(client, name) + }) + return nil +} + +// WaitForOIDCClientReady polls until status.clientID and status.clientSecrets are populated. +func WaitForOIDCClientReady(client *rancher.Client, name string) (clientID, secretKeyName string, err error) { + logrus.Infof("[OIDC setup] Waiting for OIDCClient %q status.clientID (max 2m)", name) + dynClient, err := client.GetRancherDynamicClient() + if err != nil { + return "", "", err + } + err = kwait.PollUntilContextTimeout( + context.Background(), defaults.FiveSecondTimeout, defaults.TwoMinuteTimeout, true, + func(ctx context.Context) (bool, error) { + obj, getErr := dynClient.Resource(oidcClientGVR).Get(ctx, name, metav1.GetOptions{}) + if getErr != nil { + logrus.Debugf("[OIDC] OIDCClient %q not yet visible: %v", name, getErr) + return false, nil + } + status, ok := obj.Object["status"].(map[string]interface{}) + if !ok { + return false, nil + } + id, _ := status["clientID"].(string) + if id == "" { + return false, nil + } + secrets, _ := status["clientSecrets"].(map[string]interface{}) + if len(secrets) == 0 { + return false, nil + } + for k := range secrets { + secretKeyName = k + break + } + clientID = id + logrus.Infof("[OIDC] OIDCClient %q ready — clientID=%s secretKey=%s", name, clientID, secretKeyName) + return true, nil + }, + ) + if err != nil { + return "", "", fmt.Errorf("timed out waiting for OIDCClient %q status.clientID: %w", name, err) + } + return clientID, secretKeyName, nil +} + +// FetchOIDCClientSecret retrieves the OIDCClient secret value from the cattle-oidc-client-secrets namespace. +func FetchOIDCClientSecret(client *rancher.Client, clientID, secretKeyName string) (string, error) { + logrus.Infof("[OIDC setup] Fetching client secret from %s/%s key=%s", + OIDCClientSecretNamespace, clientID, secretKeyName) + secret, err := client.WranglerContext.Core.Secret().Get( + OIDCClientSecretNamespace, clientID, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("getting OIDCClient secret %s/%s: %w", + OIDCClientSecretNamespace, clientID, err) + } + value, ok := secret.Data[secretKeyName] + if !ok || len(value) == 0 { + return "", fmt.Errorf("key %q not found or empty in secret %s/%s", + secretKeyName, OIDCClientSecretNamespace, clientID) + } + return string(value), nil +} + +// DeleteOIDCClient deletes the OIDCClient by name; NotFound is treated as success. +func DeleteOIDCClient(client *rancher.Client, name string) error { + logrus.Infof("[OIDC teardown] Deleting OIDCClient %q", name) + dynClient, err := client.GetRancherDynamicClient() + if err != nil { + return err + } + err = dynClient.Resource(oidcClientGVR).Delete(context.Background(), name, metav1.DeleteOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + logrus.Debugf("[OIDC teardown] OIDCClient %q already gone — skipping", name) + return nil + } + return fmt.Errorf("deleting OIDCClient %q: %w", name, err) + } + logrus.Infof("[OIDC teardown] OIDCClient %q deleted", name) + return nil +} diff --git a/extensions/kubeapi/features/features.go b/extensions/kubeapi/features/features.go new file mode 100644 index 00000000..8a012bab --- /dev/null +++ b/extensions/kubeapi/features/features.go @@ -0,0 +1,52 @@ +package features + +import ( + "github.com/rancher/shepherd/clients/rancher" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// IsFeatureEnabled returns true when the named feature flag is enabled. +func IsFeatureEnabled(client *rancher.Client, name string) (bool, error) { + feature, err := client.WranglerContext.Mgmt.Feature().Get(name, metav1.GetOptions{}) + if err != nil { + return false, err + } + if feature.Spec.Value == nil { + return false, nil + } + return *feature.Spec.Value, nil +} + +// UpdateFeatureFlag sets the named feature flag to value; no-op if already at value. +func UpdateFeatureFlag(client *rancher.Client, name string, value bool) error { + feature, err := client.WranglerContext.Mgmt.Feature().Get(name, metav1.GetOptions{}) + if err != nil { + return err + } + if feature.Spec.Value != nil && *feature.Spec.Value == value { + return nil + } + feature.Spec.Value = &value + _, err = client.WranglerContext.Mgmt.Feature().Update(feature) + return err +} + +// EnableFeatureFlag enables the named feature flag and registers DisableFeatureFlag as session cleanup. +func EnableFeatureFlag(client *rancher.Client, name string) error { + enabled, err := IsFeatureEnabled(client, name) + if err != nil { + return err + } + if enabled { + return nil + } + client.Session.RegisterCleanupFunc(func() error { + return DisableFeatureFlag(client, name) + }) + return UpdateFeatureFlag(client, name, true) +} + +// DisableFeatureFlag disables the named feature flag. +func DisableFeatureFlag(client *rancher.Client, name string) error { + return UpdateFeatureFlag(client, name, false) +} diff --git a/extensions/kubeapi/workloads/deployments/deployments.go b/extensions/kubeapi/workloads/deployments/deployments.go new file mode 100644 index 00000000..17043da8 --- /dev/null +++ b/extensions/kubeapi/workloads/deployments/deployments.go @@ -0,0 +1,44 @@ +package deployments + +import ( + "context" + "fmt" + + "github.com/rancher/shepherd/clients/rancher" + "github.com/rancher/shepherd/extensions/defaults" + "github.com/rancher/shepherd/extensions/kubeapi/cluster" + "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kwait "k8s.io/apimachinery/pkg/util/wait" +) + +const ( + RancherDeploymentName = "rancher" + RancherDeploymentNamespace = "cattle-system" +) + +// WaitForDeploymentActive polls until the deployment has all replicas updated, ready, and available. +func WaitForDeploymentActive(client *rancher.Client, clusterID, namespaceName, deploymentName string) error { + wranglerContext, err := cluster.GetClusterWranglerContext(client, clusterID) + if err != nil { + return fmt.Errorf("getting wrangler context for cluster %s: %w", clusterID, err) + } + return kwait.PollUntilContextTimeout( + context.Background(), defaults.TenSecondTimeout, defaults.FiveMinuteTimeout, false, + func(ctx context.Context) (bool, error) { + d, err := wranglerContext.Apps.Deployment().Get(namespaceName, deploymentName, metav1.GetOptions{}) + if err != nil { + logrus.Debugf("waiting for deployment %s/%s: %v", namespaceName, deploymentName, err) + return false, nil + } + if d.Spec.Replicas == nil { + return false, nil + } + desired := *d.Spec.Replicas + return d.Status.UpdatedReplicas == desired && + d.Status.ReadyReplicas == desired && + d.Status.AvailableReplicas == desired && + d.Status.Replicas == desired, nil + }, + ) +} diff --git a/pkg/clientbase/raw.go b/pkg/clientbase/raw.go new file mode 100644 index 00000000..53c13e71 --- /dev/null +++ b/pkg/clientbase/raw.go @@ -0,0 +1,58 @@ +package clientbase + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type Response struct { + StatusCode int + Body []byte + Header http.Header +} + +// Do executes a raw HTTP request; body may be url.Values (form-encoded) or any JSON-serializable value. +func Do(client *http.Client, method, rawURL string, body interface{}, headers map[string]string) (*Response, error) { + var bodyReader io.Reader + if body != nil { + switch v := body.(type) { + case url.Values: + bodyReader = strings.NewReader(v.Encode()) + default: + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshaling request body: %w", err) + } + bodyReader = bytes.NewReader(b) + } + } + req, err := http.NewRequest(method, rawURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("creating request %s %s: %w", method, rawURL, err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request %s %s: %w", method, rawURL, err) + } + defer func() { + io.Copy(io.Discard, resp.Body) //nolint:errcheck + resp.Body.Close() //nolint:errcheck + }() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body from %s %s: %w", method, rawURL, err) + } + return &Response{ + StatusCode: resp.StatusCode, + Body: respBody, + Header: resp.Header, + }, nil +}