diff --git a/api/PclusterApiHandler.py b/api/PclusterApiHandler.py index c29714ab2..2e3974878 100644 --- a/api/PclusterApiHandler.py +++ b/api/PclusterApiHandler.py @@ -33,12 +33,13 @@ AUTH_PATH = os.getenv("AUTH_PATH") API_BASE_URL = os.getenv("API_BASE_URL") API_VERSION = os.getenv("API_VERSION", "3.1.0") +DEFAULT_API_VERSION = '3.12.0' API_USER_ROLE = os.getenv("API_USER_ROLE") OIDC_PROVIDER = os.getenv("OIDC_PROVIDER") CLIENT_ID = os.getenv("CLIENT_ID") CLIENT_SECRET = os.getenv("CLIENT_SECRET") SECRET_ID = os.getenv("SECRET_ID") -SITE_URL = os.getenv("SITE_URL", API_BASE_URL) +SITE_URL = os.getenv("SITE_URL") SCOPES_LIST = os.getenv("SCOPES_LIST") REGION = os.getenv("AWS_DEFAULT_REGION") TOKEN_URL = os.getenv("TOKEN_URL", f"{AUTH_PATH}/oauth2/token") @@ -62,6 +63,13 @@ if not JWKS_URL: JWKS_URL = os.getenv("JWKS_URL", f"https://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}/" ".well-known/jwks.json") +API_BASE_URL_MAPPING = {} + +# for url in API_BASE_URL.split(","): +# if url: +# pair=url.split("=") +# API_BASE_URL_MAPPING[pair[0]] = pair[1] + def jwt_decode(token, audience=None, access_token=None): @@ -165,7 +173,7 @@ def authenticate(groups): if (not groups): return abort(403) - + jwt_roles = set(decoded.get(USER_ROLES_CLAIM, [])) groups_granted = groups.intersection(jwt_roles) if len(groups_granted) == 0: @@ -191,7 +199,7 @@ def get_scopes_list(): def get_redirect_uri(): return f"{SITE_URL}/login" - + # Local Endpoints @@ -233,9 +241,9 @@ def ec2_action(): def get_cluster_config_text(cluster_name, region=None): url = f"/v3/clusters/{cluster_name}" if region: - info_resp = sigv4_request("GET", API_BASE_URL, url, params={"region": region}) + info_resp = sigv4_request("GET", API_BASE_URL_MAPPING['3.12.0'], url, params={"region": region}) else: - info_resp = sigv4_request("GET", API_BASE_URL, url) + info_resp = sigv4_request("GET", API_BASE_URL_MAPPING['3.12.0'], url) if info_resp.status_code != 200: abort(info_resp.status_code) @@ -365,7 +373,7 @@ def sacct(): user, f"sacct {sacct_args} --json " + "| jq -c .jobs[0:120]\\|\\map\\({name,user,partition,state,job_id,exit_code\\}\\)", - ) + ) if type(accounting) is tuple: return accounting else: @@ -484,7 +492,7 @@ def get_dcv_session(): def get_custom_image_config(): - image_info = sigv4_request("GET", API_BASE_URL, f"/v3/images/custom/{request.args.get('image_id')}").json() + image_info = sigv4_request("GET", API_BASE_URL_MAPPING['3.12.0'], f"/v3/images/custom/{request.args.get('image_id')}").json() configuration = requests.get(image_info["imageConfiguration"]["url"]) return configuration.text @@ -596,9 +604,9 @@ def _get_identity_from_token(decoded, claims): identity["username"] = decoded["username"] for claim in claims: - if claim in decoded: - identity["attributes"][claim] = decoded[claim] - + if claim in decoded: + identity["attributes"][claim] = decoded[claim] + return identity def get_identity(): @@ -735,6 +743,10 @@ def _get_params(_request): params.pop("path") return params +def _get_version(v): + + return DEFAULT_API_VERSION + pc = Blueprint('pc', __name__) @@ -742,7 +754,7 @@ def _get_params(_request): @authenticated({'admin'}) @validated(params=PCProxyArgs) def pc_proxy_get(): - response = sigv4_request(request.method, API_BASE_URL, request.args.get("path"), _get_params(request)) + response = sigv4_request(request.method, API_BASE_URL_MAPPING[_get_version('3.12.0')], request.args.get("path"), _get_params(request)) return response.json(), response.status_code @pc.route('/', methods=['POST','PUT','PATCH','DELETE'], strict_slashes=False) @@ -756,5 +768,5 @@ def pc_proxy(): except: pass - response = sigv4_request(request.method, API_BASE_URL, request.args.get("path"), _get_params(request), body=body) + response = sigv4_request(request.method, API_BASE_URL_MAPPING[_get_version('3.12.0')], request.args.get("path"), _get_params(request), body=body) return response.json(), response.status_code diff --git a/frontend/locales/en/strings.json b/frontend/locales/en/strings.json index 855dd9e54..d40bdd7e0 100644 --- a/frontend/locales/en/strings.json +++ b/frontend/locales/en/strings.json @@ -527,6 +527,12 @@ "alreadyExists": "Cluster with name {{clusterName}} already exists. Choose a unique name.", "doesntMatchRegex": "Cluster name '{{clusterName}}' doesn't match the the required format. Enter a name that starts with an alphabetical character and has up to 60 characters. If Slurm accounting is configured, the name can have up to 40 characters. Valid characters: A-Z, a-z, 0-9, and - (hyphen)" }, + "version": { + "label": "Version", + "description": "The ParallelCluster version that will be used for the cluster.", + "placeholder": "Enter your cluster version", + "cannotBeBlank": "Cluster version must not be blank." + }, "region": { "label": "Region", "description": "The AWS Region for the cluster.", diff --git a/frontend/src/__tests__/CreateCluster.test.ts b/frontend/src/__tests__/CreateCluster.test.ts index 34f5ef6a8..ef73b2bcf 100644 --- a/frontend/src/__tests__/CreateCluster.test.ts +++ b/frontend/src/__tests__/CreateCluster.test.ts @@ -8,6 +8,7 @@ const mockRequest = executeRequest as jest.Mock describe('given a CreateCluster command and a cluster configuration', () => { const clusterName = 'any-name' + const clusterVersion = 'some-version' const clusterConfiguration = 'Imds:\n ImdsSupport: v2.0' const mockRegion = 'some-region' const mockSelectedRegion = 'some-region' @@ -37,11 +38,12 @@ describe('given a CreateCluster command and a cluster configuration', () => { clusterConfiguration, mockRegion, mockSelectedRegion, + clusterVersion, ) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith( 'post', - 'api?path=/v3/clusters®ion=some-region', + 'api?path=/v3/clusters®ion=some-region&version=some-version', expectedBody, expect.any(Object), expect.any(Object), @@ -55,6 +57,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { clusterConfiguration, mockRegion, mockSelectedRegion, + clusterVersion, false, mockSuccessCallback, ) @@ -71,12 +74,13 @@ describe('given a CreateCluster command and a cluster configuration', () => { clusterConfiguration, mockRegion, mockSelectedRegion, + clusterVersion, mockDryRun, ) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith( 'post', - 'api?path=/v3/clusters&dryrun=true®ion=some-region', + 'api?path=/v3/clusters&dryrun=true®ion=some-region&version=some-version', expect.any(Object), expect.any(Object), expect.any(Object), @@ -106,6 +110,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { clusterConfiguration, mockRegion, mockSelectedRegion, + clusterVersion, false, undefined, mockErrorCallback, @@ -128,6 +133,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { 'Imds:\n ImdsSupport: v2.0', mockRegion, mockSelectedRegion, + clusterVersion, ) expect(mockRequest).toHaveBeenCalledWith( @@ -154,6 +160,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { 'Imds:\n ImdsSupport: v2.0\nTags:\n - Key: foo\n Value: bar', mockRegion, mockSelectedRegion, + clusterVersion, ) expect(mockRequest).toHaveBeenCalledWith( @@ -180,6 +187,7 @@ describe('given a CreateCluster command and a cluster configuration', () => { "Imds:\n ImdsSupport: v2.0\nTags:\n - Key: parallelcluster-ui\n Value: 'true'", mockRegion, mockSelectedRegion, + clusterVersion, ) expect(mockRequest).not.toHaveBeenCalledWith( diff --git a/frontend/src/model.tsx b/frontend/src/model.tsx index b7959d5cf..eff4d07cc 100644 --- a/frontend/src/model.tsx +++ b/frontend/src/model.tsx @@ -103,6 +103,7 @@ function CreateCluster( clusterConfig: string, region: string, selectedRegion: string, + version: string, dryrun = false, successCallback?: Callback, errorCallback?: Callback, @@ -110,6 +111,7 @@ function CreateCluster( var url = 'api?path=/v3/clusters' url += dryrun ? '&dryrun=true' : '' url += region ? `®ion=${region}` : '' + url += version ? `&version=${version}` : '' var body = { clusterName: clusterName, clusterConfiguration: mapAndApplyTags(clusterConfig), @@ -159,12 +161,14 @@ function UpdateCluster( clusterConfig: any, dryrun = false, forceUpdate: any, + version: any, successCallback?: Callback, errorCallback?: Callback, ) { var url = `api?path=/v3/clusters/${clusterName}` url += dryrun ? '&dryrun=true' : '' url += forceUpdate ? '&forceUpdate=true' : '' + url += version ? `&version=${version}` : '' var body = {clusterConfiguration: clusterConfig} request('put', url, body) .then((response: any) => { diff --git a/frontend/src/old-pages/Clusters/Actions.tsx b/frontend/src/old-pages/Clusters/Actions.tsx index 66765a1f1..45a33a23d 100644 --- a/frontend/src/old-pages/Clusters/Actions.tsx +++ b/frontend/src/old-pages/Clusters/Actions.tsx @@ -74,8 +74,8 @@ export default function Actions() { clusterStatus === ClusterStatus.CreateInProgress || clusterStatus === ClusterStatus.DeleteInProgress || clusterStatus === ClusterStatus.UpdateInProgress || - clusterStatus === ClusterStatus.CreateFailed || - clusterVersion !== apiVersion + clusterStatus === ClusterStatus.CreateFailed + // !apiVersion.split(",").includes(clusterVersion) const isStartFleetDisabled = fleetStatus !== 'STOPPED' const isStopFleetDisabled = fleetStatus !== 'RUNNING' const isDeleteDisabled = diff --git a/frontend/src/old-pages/Clusters/Details.tsx b/frontend/src/old-pages/Clusters/Details.tsx index a94faafe8..49b226648 100644 --- a/frontend/src/old-pages/Clusters/Details.tsx +++ b/frontend/src/old-pages/Clusters/Details.tsx @@ -56,9 +56,9 @@ export default function ClusterTabs() { return cluster ? ( <> - {cluster.version !== apiVersion ? ( - {t('cluster.editAlert')} - ) : null} + {/*{!apiVersion.split(",").includes(cluster.version) ? (*/} + {/* {t('cluster.editAlert')}*/} + {/*) : null}*/} + {/**/} = + useCallback(({detail}) => { + setState(clusterVersionPath, detail.value) + }, []) + + return ( + + + + ) +} diff --git a/frontend/src/old-pages/Configure/Create.tsx b/frontend/src/old-pages/Configure/Create.tsx index 032fb5831..8364a37f9 100644 --- a/frontend/src/old-pages/Configure/Create.tsx +++ b/frontend/src/old-pages/Configure/Create.tsx @@ -96,6 +96,7 @@ function handleCreate( const dryRun = false const region = getState(['app', 'wizard', 'config', 'Region']) const selectedRegion = getState(['app', 'selectedRegion']) + const version = getState(['app', 'wizard', 'version']) setClusterLoadingMsg(clusterName, editing, dryRun) setState(wizardSubmissionLoading, true) @@ -120,6 +121,7 @@ function handleCreate( UpdateCluster( clusterName, clusterConfig, + version, dryRun, forceUpdate, successHandler, @@ -131,6 +133,7 @@ function handleCreate( clusterConfig, region, selectedRegion, + version, dryRun, successHandler, errHandler, @@ -145,6 +148,7 @@ function handleDryRun() { const clusterConfig = getState(configPath) || '' const region = getState(['app', 'wizard', 'config', 'Region']) const selectedRegion = getState(['app', 'selectedRegion']) + const version = getState(['app', 'wizard', 'version']) const dryRun = true setClusterLoadingMsg(clusterName, editing, dryRun) setState(wizardSubmissionLoading, true) @@ -163,6 +167,7 @@ function handleDryRun() { UpdateCluster( clusterName, clusterConfig, + version, dryRun, forceUpdate, successHandler, @@ -174,6 +179,7 @@ function handleDryRun() { clusterConfig, region, selectedRegion, + version, dryRun, successHandler, errHandler, diff --git a/infrastructure/parallelcluster-ui.yaml b/infrastructure/parallelcluster-ui.yaml index a49bf262d..327d2ea7a 100644 --- a/infrastructure/parallelcluster-ui.yaml +++ b/infrastructure/parallelcluster-ui.yaml @@ -1,3 +1,4 @@ +Transform: 'AWS::LanguageExtensions' Parameters: AdminUserEmail: Description: Email address of administrative user to setup by default (only with new Cognito instances). @@ -161,17 +162,6 @@ Conditions: - !Not [!Equals [!Ref SNSRole, ""]] UseNewCognito: !Not [ Condition: UseExistingCognito] - UseNonDockerizedPCAPI: - !Not [ Condition: UseDockerizedPCAPI] - UseDockerizedPCAPI: !And - - !Equals ['3', !Select [ 0, !Split ['.', !Ref Version] ] ] # Check PC version major is 3 and PC version minor is 0-5 - - !Or - - !Equals ['0', !Select [ 1, !Split ['.', !Ref Version] ] ] - - !Equals ['1', !Select [ 1, !Split ['.', !Ref Version] ] ] - - !Equals ['2', !Select [ 1, !Split ['.', !Ref Version] ] ] - - !Equals ['3', !Select [ 1, !Split ['.', !Ref Version] ] ] - - !Equals ['4', !Select [ 1, !Split ['.', !Ref Version] ] ] - - !Equals ['5', !Select [ 1, !Split ['.', !Ref Version] ] ] InGovCloud: !Equals ['us-gov-west-1', !Ref "AWS::Region"] UsePermissionBoundary: !Not [!Equals [!Ref PermissionsBoundaryPolicy, '']] UsePermissionBoundaryPCAPI: !Not [!Equals [!Ref PermissionsBoundaryPolicyPCAPI, '']] @@ -208,36 +198,178 @@ Resources: TimeoutInMinutes: 10 - ParallelClusterApi: - Type: AWS::CloudFormation::Stack + Fn::ForEach::ParallelClusterApi: + - ApiVersion + - !Split [",", !Ref Version] + - ParallelClusterApi&{ApiVersion}: + Type: AWS::CloudFormation::Stack + Properties: + Parameters: + PermissionsBoundaryPolicy: !If [ UsePermissionBoundaryPCAPI, !Ref PermissionsBoundaryPolicyPCAPI, !Ref AWS::NoValue ] + IAMRoleAndPolicyPrefix: !If [ UseIAMRoleAndPolicyPrefix, !Ref IAMRoleAndPolicyPrefix, !Ref AWS::NoValue ] + ParallelClusterFunctionAdditionalPolicies: !If [ UseAdditionalPoliciesPCAPI, !Ref AdditionalPoliciesPCAPI, !Ref AWS::NoValue ] + ApiDefinitionS3Uri: !Sub s3://${AWS::Region}-aws-parallelcluster/parallelcluster/${ApiVersion}/api/ParallelCluster.openapi.yaml + CreateApiUserRole: False + EnableIamAdminAccess: True + VpcEndpointId: !If [ IsPrivate, !Ref VpcEndpointId, !Ref AWS::NoValue ] + TemplateURL: !Sub https://${AWS::Region}-aws-parallelcluster.s3.${AWS::Region}.amazonaws.com/parallelcluster/${ApiVersion}/api/parallelcluster-api.yaml + TimeoutInMinutes: 30 + Tags: + - Key: 'parallelcluster:api-id' + Value: !Ref ApiGatewayRestApi + +# ParallelClusterApi: +# Type: AWS::CloudFormation::Stack +# Properties: +# Parameters: +# PermissionsBoundaryPolicy: !If [ UsePermissionBoundaryPCAPI, !Ref PermissionsBoundaryPolicyPCAPI, !Ref AWS::NoValue ] +# IAMRoleAndPolicyPrefix: !If [ UseIAMRoleAndPolicyPrefix, !Ref IAMRoleAndPolicyPrefix, !Ref AWS::NoValue ] +# ParallelClusterFunctionAdditionalPolicies: !If [ UseAdditionalPoliciesPCAPI, !Ref AdditionalPoliciesPCAPI, !Ref AWS::NoValue ] +# ApiDefinitionS3Uri: !Sub s3://${AWS::Region}-aws-parallelcluster/parallelcluster/${Version}/api/ParallelCluster.openapi.yaml +# CreateApiUserRole: False +# EnableIamAdminAccess: True +# VpcEndpointId: !If [ IsPrivate, !Ref VpcEndpointId, !Ref AWS::NoValue ] +# ImageBuilderSubnetId: !If +# - UseNonDockerizedPCAPI +# - !Ref AWS::NoValue +# - Fn::If: +# - NonDefaultVpc +# - !Ref ImageBuilderSubnetId +# - !Ref AWS::NoValue +# ImageBuilderVpcId: !If +# - UseNonDockerizedPCAPI +# - !Ref AWS::NoValue +# - Fn::If: +# - NonDefaultVpc +# - !Ref ImageBuilderVpcId +# - !Ref AWS::NoValue +# TemplateURL: !Sub https://${AWS::Region}-aws-parallelcluster.s3.${AWS::Region}.amazonaws.com/parallelcluster/${Version}/api/parallelcluster-api.yaml +# TimeoutInMinutes: 30 + + ApiVersionMap: + Type: Custom::ApiVersionMap Properties: - Parameters: - PermissionsBoundaryPolicy: !If [ UsePermissionBoundaryPCAPI, !Ref PermissionsBoundaryPolicyPCAPI, !Ref AWS::NoValue ] - IAMRoleAndPolicyPrefix: !If [ UseIAMRoleAndPolicyPrefix, !Ref IAMRoleAndPolicyPrefix, !Ref AWS::NoValue ] - ParallelClusterFunctionAdditionalPolicies: !If [ UseAdditionalPoliciesPCAPI, !Ref AdditionalPoliciesPCAPI, !Ref AWS::NoValue ] - ApiDefinitionS3Uri: !Sub s3://${AWS::Region}-aws-parallelcluster/parallelcluster/${Version}/api/ParallelCluster.openapi.yaml - CreateApiUserRole: False - EnableIamAdminAccess: True - VpcEndpointId: !If [ IsPrivate, !Ref VpcEndpointId, !Ref AWS::NoValue ] - ImageBuilderSubnetId: !If - - UseNonDockerizedPCAPI - - !Ref AWS::NoValue - - Fn::If: - - NonDefaultVpc - - !Ref ImageBuilderSubnetId - - !Ref AWS::NoValue - ImageBuilderVpcId: !If - - UseNonDockerizedPCAPI - - !Ref AWS::NoValue - - Fn::If: - - NonDefaultVpc - - !Ref ImageBuilderVpcId - - !Ref AWS::NoValue - TemplateURL: !Sub https://${AWS::Region}-aws-parallelcluster.s3.${AWS::Region}.amazonaws.com/parallelcluster/${Version}/api/parallelcluster-api.yaml - TimeoutInMinutes: 30 + ServiceToken: !GetAtt ApiVersionMapFunction.Arn + Version: !Ref Version + ApiGatewayRestApiId: !Ref ApiGatewayRestApi + + ApiVersionMapFunction: + Type: AWS::Lambda::Function + Properties: + Handler: index.handler + Runtime: python3.12 + TracingConfig: + Mode: Active + Role: !GetAtt ApiVersionMapFunctionRole.Arn + Code: + ZipFile: | + import boto3 + import cfnresponse + import os + import re + import time + + def handler(event, context): + response_data = {} + response_status = cfnresponse.SUCCESS + reason = None + try: + + if event['RequestType'] in ['Create', 'Update']: + response_data["Message"] = "Resource creation successful!" + cfn = boto3.client('cloudformation') + result = "" + + api_id = event['ResourceProperties'].get('ApiGatewayRestApiId') + print(f"ApiGatewayRestApiId: {api_id}") + versions = event['ResourceProperties'].get('Version').split(",") + print(f"Versions: {versions}") + + paginator = cfn.get_paginator('list_stacks') + for page in paginator.paginate( + StackStatusFilter=['CREATE_COMPLETE', 'UPDATE_COMPLETE', 'CREATE_IN_PROGRESS'] + ): + for stack in page['StackSummaries']: + try: + # Check if stack name matches the pattern + print(f"Checking stack {stack['StackName']}") + + for version in versions: + clean_version = version.replace('.','') + pattern = f'.*-ParallelClusterApi{clean_version}-[^-]*$' + stack_pattern = re.compile(pattern) + match = stack_pattern.match(stack['StackName']) + if not match: + continue + + # Get stack details including tags + stack_response = cfn.describe_stacks( + StackName=stack['StackName'] + ) + + stack_tags = stack_response['Stacks'][0].get('Tags', []) + print(f"Match: {match}, tags: {stack_tags}") + # Check if stack has the specific tag and value + for tag in stack_tags: + if (tag.get('Key') == 'parallelcluster:api-id' and + tag.get('Value') == api_id): + # Get stack outputs + waiter = cfn.get_waiter('stack_create_complete') + waiter.wait(StackName=stack['StackName']) + stack_response = cfn.describe_stacks( + StackName=stack['StackName'] + ) + print(f"Found stack {stack['StackName']} with outputs: {stack_response['Stacks'][0]['Outputs']}") + + for output in stack_response['Stacks'][0]['Outputs']: + if output['OutputKey'] == 'ParallelClusterApiInvokeUrl': + # Construct the result string + result = f"{result}{version}={output['OutputValue']}," + print(f"Version={version}, ApiURL={output['OutputValue']}") + break + + except Exception as e: + print(f"Error processing stack {stack['StackName']}: {str(e)}") + continue + print(f"Result: {result}") + + response_data = {"ApiVersionMapping": result} + cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data, result) + + except Exception as e: + response_status = cfnresponse.FAILED + reason = "Failed {}: {}".format(event["RequestType"], e) + cfnresponse.send(event, context, response_status, response_data, reason) + + Timeout: 300 + MemorySize: 128 + + ApiVersionMapFunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: CloudFormationAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - cloudformation:ListStacks + - cloudformation:DescribeStacks + Resource: '*' ParallelClusterUIFun: Type: AWS::Lambda::Function + DependsOn: ApiVersionMap Properties: Role: !GetAtt ParallelClusterUIUserRole.Arn PackageType: Image @@ -256,7 +388,7 @@ Resources: - !Ref AWS::NoValue Environment: Variables: - API_BASE_URL: !GetAtt [ ParallelClusterApi, Outputs.ParallelClusterApiInvokeUrl ] + API_BASE_URL: !GetAtt ApiVersionMap.ApiVersionMapping API_VERSION: !Ref Version SITE_URL: !If - UseCustomDomain @@ -580,7 +712,7 @@ Resources: PermissionsBoundary: !If [UsePermissionBoundary, !Ref PermissionsBoundaryPolicy, !Ref 'AWS::NoValue'] PrivateEcrRepository: - DependsOn: ParallelClusterApi + DependsOn: ParallelClusterApi3120 Type: AWS::ECR::Repository Properties: RepositoryName: !Sub @@ -897,9 +1029,8 @@ Resources: - Action: - execute-api:Invoke Effect: Allow - Resource: !Sub - - arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${PCApiGateway}/*/* - - { PCApiGateway: !Select [2, !Split ['/', !Select [0, !Split ['.', !GetAtt [ ParallelClusterApi, Outputs.ParallelClusterApiInvokeUrl ]]]]] } + Resource: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:*/*/*" +# - { PCApiGateway: !Select [2, !Split ['/', !Select [0, !Split ['.', !GetAtt [ ParallelClusterApi3120, Outputs.ParallelClusterApiInvokeUrl ]]]]] } CognitoPolicy: Type: AWS::IAM::ManagedPolicy