pkg/frontend/openshiftcluster_putorpatch.go (358 lines of code) (raw):
package frontend
// Copyright (c) Microsoft Corporation.
// Licensed under the Apache License 2.0.
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/Azure/go-autorest/autorest/azure"
"github.com/go-chi/chi/v5"
"github.com/sirupsen/logrus"
"github.com/Azure/ARO-RP/pkg/api"
"github.com/Azure/ARO-RP/pkg/api/admin"
"github.com/Azure/ARO-RP/pkg/database/cosmosdb"
"github.com/Azure/ARO-RP/pkg/env"
"github.com/Azure/ARO-RP/pkg/frontend/middleware"
"github.com/Azure/ARO-RP/pkg/operator"
"github.com/Azure/ARO-RP/pkg/util/version"
)
var errMissingIdentityParameter error = fmt.Errorf("identity parameter not provided but required for workload identity cluster")
type PutOrPatchOpenshiftClusterParameters struct {
body []byte
correlationData *api.CorrelationData
systemData *api.SystemData
path string
originalPath string
method string
referer string
header *http.Header
converter api.OpenShiftClusterConverter
staticValidator api.OpenShiftClusterStaticValidator
subId string
resourceProviderNamespace string
apiVersion string
identityURL string
identityTenantID string
}
func (f *frontend) putOrPatchOpenShiftCluster(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := ctx.Value(middleware.ContextKeyLog).(*logrus.Entry)
var header http.Header
var b []byte
body := r.Context().Value(middleware.ContextKeyBody).([]byte)
correlationData := api.GetCorrelationDataFromCtx(r.Context())
systemData, _ := r.Context().Value(middleware.ContextKeySystemData).(*api.SystemData) // don't panic
originalPath := r.Context().Value(middleware.ContextKeyOriginalPath).(string)
referer := r.Header.Get("Referer")
subId := chi.URLParam(r, "subscriptionId")
resourceProviderNamespace := chi.URLParam(r, "resourceProviderNamespace")
identityURL := r.Header.Get("x-ms-identity-url")
identityTenantID := r.Header.Get("x-ms-home-tenant-id")
apiVersion := r.URL.Query().Get(api.APIVersionKey)
putOrPatchClusterParameters := PutOrPatchOpenshiftClusterParameters{
body,
correlationData,
systemData,
r.URL.Path,
originalPath,
r.Method,
referer,
&header,
f.apis[apiVersion].OpenShiftClusterConverter,
f.apis[apiVersion].OpenShiftClusterStaticValidator,
subId,
resourceProviderNamespace,
apiVersion,
identityURL,
identityTenantID,
}
err := cosmosdb.RetryOnPreconditionFailed(func() error {
var err error
b, err = f._putOrPatchOpenShiftCluster(ctx, log, putOrPatchClusterParameters)
return err
})
frontendOperationResultLog(log, r.Method, err)
reply(log, w, header, b, err)
}
func (f *frontend) _putOrPatchOpenShiftCluster(ctx context.Context, log *logrus.Entry, putOrPatchClusterParameters PutOrPatchOpenshiftClusterParameters) ([]byte, error) {
subscription, err := f.validateSubscriptionState(ctx, putOrPatchClusterParameters.path, api.SubscriptionStateRegistered)
if err != nil {
return nil, err
}
dbOpenShiftClusters, err := f.dbGroup.OpenShiftClusters()
if err != nil {
return nil, err
}
doc, err := dbOpenShiftClusters.Get(ctx, putOrPatchClusterParameters.path)
if err != nil && !cosmosdb.IsErrorStatusCode(err, http.StatusNotFound) {
return nil, err
}
isCreate := doc == nil
if isCreate {
originalR, err := azure.ParseResourceID(putOrPatchClusterParameters.originalPath)
if err != nil {
return nil, err
}
doc = &api.OpenShiftClusterDocument{
ID: dbOpenShiftClusters.NewUUID(),
Key: putOrPatchClusterParameters.path,
OpenShiftCluster: &api.OpenShiftCluster{
ID: putOrPatchClusterParameters.originalPath,
Name: originalR.ResourceName,
Type: originalR.Provider + "/" + originalR.ResourceType,
Properties: api.OpenShiftClusterProperties{
ArchitectureVersion: version.InstallArchitectureVersion,
ProvisioningState: api.ProvisioningStateSucceeded,
CreatedAt: f.now().UTC(),
CreatedBy: version.GitCommit,
ProvisionedBy: version.GitCommit,
},
},
}
if !f.env.IsLocalDevelopmentMode() /* not local dev or CI */ {
doc.OpenShiftCluster.Properties.FeatureProfile.GatewayEnabled = true
}
}
doc.CorrelationData = putOrPatchClusterParameters.correlationData
err = validateTerminalProvisioningState(doc.OpenShiftCluster.Properties.ProvisioningState)
if err != nil {
return nil, err
}
if doc.OpenShiftCluster.Properties.ProvisioningState == api.ProvisioningStateFailed {
switch doc.OpenShiftCluster.Properties.FailedProvisioningState {
case api.ProvisioningStateCreating:
return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeRequestNotAllowed, "", "Request is not allowed on cluster whose creation failed. Delete the cluster.")
case api.ProvisioningStateUpdating:
// allow: a previous failure to update should not prevent a new
// operation.
case api.ProvisioningStateDeleting:
return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeRequestNotAllowed, "", "Request is not allowed on cluster whose deletion failed. Delete the cluster.")
default:
return nil, fmt.Errorf("unexpected failedProvisioningState %q", doc.OpenShiftCluster.Properties.FailedProvisioningState)
}
}
// If Put or Patch is executed we will enrich document with cluster data.
if !isCreate {
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
f.clusterEnricher.Enrich(timeoutCtx, log, doc.OpenShiftCluster)
}
var ext interface{}
switch putOrPatchClusterParameters.method {
// In case of PUT we will take customer request payload and store into database
// Our base structure for unmarshal is skeleton document with values we
// think is required. We expect payload to have everything else required.
case http.MethodPut:
document := &api.OpenShiftCluster{
ID: doc.OpenShiftCluster.ID,
Name: doc.OpenShiftCluster.Name,
Type: doc.OpenShiftCluster.Type,
Properties: api.OpenShiftClusterProperties{
ProvisioningState: doc.OpenShiftCluster.Properties.ProvisioningState,
ClusterProfile: api.ClusterProfile{
PullSecret: doc.OpenShiftCluster.Properties.ClusterProfile.PullSecret,
Version: doc.OpenShiftCluster.Properties.ClusterProfile.Version,
},
},
SystemData: doc.OpenShiftCluster.SystemData,
}
if doc.OpenShiftCluster.Properties.ServicePrincipalProfile != nil {
document.Properties.ServicePrincipalProfile = &api.ServicePrincipalProfile{}
document.Properties.ServicePrincipalProfile.ClientSecret = doc.OpenShiftCluster.Properties.ServicePrincipalProfile.ClientSecret
}
ext = putOrPatchClusterParameters.converter.ToExternal(document)
// In case of PATCH we take current cluster document, which is enriched
// from the cluster and use it as base for unmarshal. So customer can
// provide single field json to be updated in the database.
// Patch should be used for updating individual fields of the document.
case http.MethodPatch:
if putOrPatchClusterParameters.apiVersion == admin.APIVersion {
// OperatorFlagsMergeStrategy==reset will place the default flags in
// the external object and then merge in the body's flags when the
// request is unmarshaled below.
err = admin.OperatorFlagsMergeStrategy(doc.OpenShiftCluster, putOrPatchClusterParameters.body)
if err != nil {
// OperatorFlagsMergeStrategy returns CloudErrors
return nil, err
}
}
ext = putOrPatchClusterParameters.converter.ToExternal(doc.OpenShiftCluster)
}
putOrPatchClusterParameters.converter.ExternalNoReadOnly(ext)
err = json.Unmarshal(putOrPatchClusterParameters.body, &ext)
if err != nil {
return nil, api.NewCloudError(http.StatusBadRequest, api.CloudErrorCodeInvalidRequestContent, "", fmt.Sprintf("The request content was invalid and could not be deserialized: %q.", err))
}
if isCreate {
putOrPatchClusterParameters.converter.ToInternal(ext, doc.OpenShiftCluster)
err = f.ValidateNewCluster(ctx, subscription, doc.OpenShiftCluster, putOrPatchClusterParameters.staticValidator, ext, putOrPatchClusterParameters.path)
if err != nil {
return nil, err
}
} else {
err = putOrPatchClusterParameters.staticValidator.Static(ext, doc.OpenShiftCluster, f.env.Location(), f.env.Domain(), f.env.FeatureIsSet(env.FeatureRequireD2sWorkers), putOrPatchClusterParameters.path)
if err != nil {
return nil, err
}
}
oldID, oldName, oldType, oldSystemData := doc.OpenShiftCluster.ID, doc.OpenShiftCluster.Name, doc.OpenShiftCluster.Type, doc.OpenShiftCluster.SystemData
putOrPatchClusterParameters.converter.ToInternal(ext, doc.OpenShiftCluster)
doc.OpenShiftCluster.ID, doc.OpenShiftCluster.Name, doc.OpenShiftCluster.Type, doc.OpenShiftCluster.SystemData = oldID, oldName, oldType, oldSystemData
// This will update systemData from the values in the header. Old values, which
// is not provided in the header must be preserved
f.systemDataClusterDocEnricher(doc, putOrPatchClusterParameters.systemData)
if doc.OpenShiftCluster.UsesWorkloadIdentity() {
if err := f.validatePlatformWorkloadIdentities(doc.OpenShiftCluster); err != nil {
return nil, err
}
}
if isCreate {
err = f.validateInstallVersion(ctx, doc.OpenShiftCluster)
if err != nil {
return nil, err
}
// on create, make the cluster resourcegroup ID lower case to work
// around LB/PLS bug
doc.OpenShiftCluster.Properties.ClusterProfile.ResourceGroupID = strings.ToLower(doc.OpenShiftCluster.Properties.ClusterProfile.ResourceGroupID)
doc.ClusterResourceGroupIDKey = strings.ToLower(doc.OpenShiftCluster.Properties.ClusterProfile.ResourceGroupID)
// doc.ClientIDKey is used as part of the Cosmos DB instance's unique key policy. Because we have
// one Cosmos DB instance per region, this value must be unique within the region.
//
// This effectively enforces:
// - Among all service principal clusters within a region, the service principal must be unique
// - Among all workload identity clusters within a region, the cluster MSI must be unique
//
// The name "clientIdKey" is an artifact of the world before workload identity where there were
// only service principal clusters.
if doc.OpenShiftCluster.UsesWorkloadIdentity() {
clusterMsiResourceId, err := doc.OpenShiftCluster.ClusterMsiResourceId()
if err != nil {
return nil, err
}
doc.ClientIDKey = strings.ToLower(clusterMsiResourceId.String())
} else {
doc.ClientIDKey = strings.ToLower(doc.OpenShiftCluster.Properties.ServicePrincipalProfile.ClientID)
}
doc.OpenShiftCluster.Properties.ProvisioningState = api.ProvisioningStateCreating
// Persist identity URL and tenant ID only for managed/workload identity cluster create
// We don't support updating cluster managed identity after cluster creation
if doc.OpenShiftCluster.UsesWorkloadIdentity() {
if err := validateIdentityUrl(doc.OpenShiftCluster, putOrPatchClusterParameters.identityURL); err != nil {
return nil, err
}
if err := validateIdentityTenantID(doc.OpenShiftCluster, putOrPatchClusterParameters.identityTenantID); err != nil {
return nil, err
}
}
doc.Bucket, err = f.bucketAllocator.Allocate()
if err != nil {
return nil, err
}
} else {
setUpdateProvisioningState(doc, putOrPatchClusterParameters.apiVersion)
}
// SetDefaults will set defaults on cluster document
api.SetDefaults(doc, operator.DefaultOperatorFlags)
doc.AsyncOperationID, err = f.newAsyncOperation(ctx, putOrPatchClusterParameters.subId, putOrPatchClusterParameters.resourceProviderNamespace, doc)
if err != nil {
return nil, err
}
u, err := url.Parse(putOrPatchClusterParameters.referer)
if err != nil {
return nil, err
}
u.Path = f.operationsPath(putOrPatchClusterParameters.subId, putOrPatchClusterParameters.resourceProviderNamespace, doc.AsyncOperationID)
*putOrPatchClusterParameters.header = http.Header{
"Azure-AsyncOperation": []string{u.String()},
}
if isCreate {
newdoc, err := dbOpenShiftClusters.Create(ctx, doc)
if cosmosdb.IsErrorStatusCode(err, http.StatusPreconditionFailed) {
return nil, f.validateOpenShiftUniqueKey(ctx, doc)
}
doc = newdoc
} else {
doc, err = dbOpenShiftClusters.Update(ctx, doc)
}
if err != nil {
return nil, err
}
// We remove sensitive data from document to prevent sensitive data being
// returned to the customer.
doc.OpenShiftCluster.Properties.ClusterProfile.PullSecret = ""
if doc.OpenShiftCluster.Properties.ServicePrincipalProfile != nil {
doc.OpenShiftCluster.Properties.ServicePrincipalProfile.ClientSecret = ""
}
doc.OpenShiftCluster.Properties.ClusterProfile.BoundServiceAccountSigningKey = nil
// We don't return enriched worker profile data on PUT/PATCH operations
doc.OpenShiftCluster.Properties.WorkerProfilesStatus = nil
b, err := json.MarshalIndent(putOrPatchClusterParameters.converter.ToExternal(doc.OpenShiftCluster), "", " ")
if err != nil {
return nil, err
}
if isCreate {
err = statusCodeError(http.StatusCreated)
}
return b, err
}
// enrichClusterSystemData will selectively overwrite systemData fields based on
// arm inputs
func enrichClusterSystemData(doc *api.OpenShiftClusterDocument, systemData *api.SystemData) {
if systemData == nil {
return
}
if systemData.CreatedAt != nil {
doc.OpenShiftCluster.SystemData.CreatedAt = systemData.CreatedAt
}
if systemData.CreatedBy != "" {
doc.OpenShiftCluster.SystemData.CreatedBy = systemData.CreatedBy
}
if systemData.CreatedByType != "" {
doc.OpenShiftCluster.SystemData.CreatedByType = systemData.CreatedByType
}
if systemData.LastModifiedAt != nil {
doc.OpenShiftCluster.SystemData.LastModifiedAt = systemData.LastModifiedAt
}
if systemData.LastModifiedBy != "" {
doc.OpenShiftCluster.SystemData.LastModifiedBy = systemData.LastModifiedBy
}
if systemData.LastModifiedByType != "" {
doc.OpenShiftCluster.SystemData.LastModifiedByType = systemData.LastModifiedByType
}
}
func validateIdentityUrl(cluster *api.OpenShiftCluster, identityURL string) error {
if identityURL == "" {
return fmt.Errorf("%w: %s", errMissingIdentityParameter, "identity URL")
}
cluster.Identity.IdentityURL = identityURL
return nil
}
func validateIdentityTenantID(cluster *api.OpenShiftCluster, identityTenantID string) error {
if identityTenantID == "" {
return fmt.Errorf("%w: %s", errMissingIdentityParameter, "identity tenant ID")
}
cluster.Identity.TenantID = identityTenantID
return nil
}
func (f *frontend) ValidateNewCluster(ctx context.Context, subscription *api.SubscriptionDocument, cluster *api.OpenShiftCluster, staticValidator api.OpenShiftClusterStaticValidator, ext interface{}, path string) error {
err := staticValidator.Static(ext, nil, f.env.Location(), f.env.Domain(), f.env.FeatureIsSet(env.FeatureRequireD2sWorkers), path)
if err != nil {
return err
}
err = f.skuValidator.ValidateVMSku(ctx, f.env.Environment(), f.env, subscription.ID, subscription.Subscription.Properties.TenantID, cluster)
if err != nil {
return err
}
err = f.quotaValidator.ValidateQuota(ctx, f.env.Environment(), f.env, subscription.ID, subscription.Subscription.Properties.TenantID, cluster)
if err != nil {
return err
}
err = f.providersValidator.ValidateProviders(ctx, f.env.Environment(), f.env, subscription.ID, subscription.Subscription.Properties.TenantID)
if err != nil {
return err
}
return nil
}
// setUpdateProvisioningState Sets either the admin update or update provisioning state
func setUpdateProvisioningState(doc *api.OpenShiftClusterDocument, apiVersion string) {
switch apiVersion {
case admin.APIVersion:
adminUpdateProvisioningState(doc)
default:
updateProvisioningState(doc)
}
}
// Non-admin update (ex: customer cluster update)
func updateProvisioningState(doc *api.OpenShiftClusterDocument) {
doc.OpenShiftCluster.Properties.LastProvisioningState = doc.OpenShiftCluster.Properties.ProvisioningState
doc.OpenShiftCluster.Properties.ProvisioningState = api.ProvisioningStateUpdating
doc.Dequeues = 0
}
// Admin update (ex: cluster maintenance)
func adminUpdateProvisioningState(doc *api.OpenShiftClusterDocument) {
if doc.OpenShiftCluster.Properties.MaintenanceTask.IsMaintenanceOngoingTask() {
doc.OpenShiftCluster.Properties.LastProvisioningState = doc.OpenShiftCluster.Properties.ProvisioningState
doc.OpenShiftCluster.Properties.ProvisioningState = api.ProvisioningStateAdminUpdating
doc.OpenShiftCluster.Properties.LastAdminUpdateError = ""
doc.Dequeues = 0
// Set the maintenance to ongoing so we emit the appropriate signal to customerss
if doc.OpenShiftCluster.Properties.MaintenanceState == api.MaintenanceStatePending {
doc.OpenShiftCluster.Properties.MaintenanceState = api.MaintenanceStatePlanned
} else {
doc.OpenShiftCluster.Properties.MaintenanceState = api.MaintenanceStateUnplanned
}
} else {
// No default needed since we're using an enum
switch doc.OpenShiftCluster.Properties.MaintenanceTask {
case api.MaintenanceTaskPending:
doc.OpenShiftCluster.Properties.MaintenanceState = api.MaintenanceStatePending
case api.MaintenanceTaskNone:
doc.OpenShiftCluster.Properties.MaintenanceState = api.MaintenanceStateNone
case api.MaintenanceTaskCustomerActionNeeded:
doc.OpenShiftCluster.Properties.MaintenanceState = api.MaintenanceStateCustomerActionNeeded
}
// This enables future admin update actions with body `{}` to succeed
doc.OpenShiftCluster.Properties.MaintenanceTask = ""
}
}