aws / aws-cdk

The AWS Cloud Development Kit is a framework for defining cloud infrastructure in code
https://aws.amazon.com/cdk
Apache License 2.0
11.5k stars 3.84k forks source link

StackSet/Conformance Packs support - make stacks available as an asset #11896

Open pgarbe opened 3 years ago

pgarbe commented 3 years ago

A stack should be made available as an asset. At the moment it's not possible to add the generated template file as asset in another stack, as the file does not exist at this point in time.

Use Case

If you want to deploy a StackSet or ServiceCatalog Product (in a pipeline) two stacks are used. One stack contains the StackSet construct and the other stack (let's call it template stack) is the one which should be deployed in target accounts via StackSet. This template stack will never deployed directly but just synthesized. The StackSet stack needs an s3 url of the template stack.

This could be also related to an integration with Proton where it's also the synthesized stack template needs to be available as asset as well.

Proposed Solution

The usage could look like this:

export interface StackSetStackProps extends cdk.StackProps {
  stack: cdk.Stack
  ...
}

export class StackSetStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props: StackSetStackProps) {
    super(scope, id, props);

    new cfn.CfnStackSet(this, 'StackSet', {
      ...
      templateUrl: new s3assets.Asset(props.stack),
    });
  }
}

Not sure how the implementation could look like.

Other


This is a :rocket: Feature Request

eladb commented 3 years ago

@pgarbe I am curious if you can use addFileAsset() to achieve this?

export interface StackSetStackProps extends cdk.StackProps {
  stack: cdk.Stack
  ...
}

export class StackSetStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props: StackSetStackProps) {
    super(scope, id, props);

    const template = stack.addFileAsset({
      packaging: cdk.FileAssetPackaging.FILE,
      sourceHash: 'git-commit',
      fileName: props.stack.templateFile,
    });

    new cfn.CfnStackSet(this, 'StackSet', {
      ...
      templateUrl: template.s3ObjectUrl
    });
  }
}

I'd be interested in adding official support for StackSets so let's figure the right model and add it.

eladb commented 3 years ago

Copy @rix0rrr

pgarbe commented 3 years ago

@eladb Thanks, that actually works :) I'm not just sure what could be a good source hash. Will look into that.

eladb commented 3 years ago

That's definitely the tricky part. You could use the current git commit in production but for dev iterations you'd need something else

hoegertn commented 3 years ago

I really like this solution. And it makes so much sense if you see it ;-)

I would love to have this wrapped in a construct so this magic does not need to be copied.

The hash might really be tricky.

pgarbe commented 3 years ago

Only the synth part works. Within a pipeline the upload action is missing. How can I enforce that?

eladb commented 3 years ago

Only the synth part works. Within a pipeline the upload action is missing. How can I enforce that?

What do you mean only the synth part works?

Can you paste the manifest.json file in your cloud assembly?

pgarbe commented 3 years ago

Previously, I had issues that synth failed to circular dependencies of the PipelineStack and StackSetStack. With your snippet this has been solved and it synthesized the correct template url.

But the pipeline does not contain a stage to upload the assets. Here's a sample project: https://github.com/pgarbe/cdk-stackset

pgarbe commented 3 years ago

It might be useful to upload the synthesized stack into a separate bucket as there might be a different lifecycle. For me assets (and the assets bucket) are ephemeral and I can recreate everything needed when I run the pipeline. But in case of a service catalog product it's needed to keep different versions of a template for a longer time.

mrpackethead commented 3 years ago

Multipel +1 on this.

I have quite a few stacks i want to deploy to every account in our org.

deyceg commented 3 years ago

It might be useful to upload the synthesized stack into a separate bucket as there might be a different lifecycle. For me assets (and the assets bucket) are ephemeral and I can recreate everything needed when I run the pipeline. But in case of a service catalog product it's needed to keep different versions of a template for a longer time.

Theres a good chance StackSets are managed by centralized teams so a separate bucket would make a lot sense with different access permissions. The initial bootstrap for CDK is targeted at teams deploying workloads IMO but alot of enterprises will have governance models that would prevent this e.g. multi-tenanted account structure where customers can only deploy in to their accounts, but admins can deploy stacks in to all accounts. A trivial example might be a ChatOps stack.

mrpackethead commented 3 years ago

@eladb and everyone else.

I've just been standing up some stack sets using the L1 construct.. Just some thoughts about using that ( and pipelines )

    cdkqualifier = parameters['env_parameters']['CdkQualifier']    # TODO. Load this from cdk.json
    execution_role_name = f'cdk-${cdkqualifier}-stacksetExecution-${account}-${self.region}'

for vpc in vpc_to_use:

                routeresolver_rules = {}
                for resolver_rule in resolver_rules:
                    routeresolver_rules[resolver_rule['Name']] = {
                        'Type': 'AWS::Route53Resolver::ResolverRuleAssociation',
                        'Properties': {
                            'ResolverRuleId': resolver_rule['Id'],
                            'VPCId': vpc['VpcId']
                        }
                    }

                stack_set = cfn.CfnStackSet(self, 'stackset',
                    permission_model= 'SELF_MANAGED',
                    stack_set_name= 'Route53Assn',
                    administration_role_arn= stack_set_administrator.arn,
                    execution_role_name=  execution_role_name,
                    stack_instances_group = [
                        {
                            'Regions': self.region,     #list of regions
                            'DeploymentTargets': {
                                'Accounts': account     #list of accounts
                            }
                        }
                    ], 
                    template_body= {'Resources': routeresolver_rules}
                )

For non trivial stacks this becomes really limiting.. I'd want to be able to create stack sets more natively with Cdk.. Sure i can create them, and copy them from cdk.out to a bucket by hand, but thats going to be quite a few layers of hackery... ( not impossible though )...

0xjjoyy commented 3 years ago

Making the stack available as an asset use cases is also needed for AWS Config conformance packs.

https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-config.CfnOrganizationConformancePack.html

s0enke commented 2 years ago

But the pipeline does not contain a stage to upload the assets. Here's a sample project: https://github.com/pgarbe/cdk-stackset

@pgarbe I got it working by utilizing the new Service Catalog ProductStack support. This way, the asset for the stack set is also uploaded during cdk deploy:

class DeployedViaStackSet extends servicecatalog.ProductStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    new aws_codecommit.Repository(this, 'CodeRepo', {
      repositoryName: "example" 
    })
  }
}

export class CdkStack extends Stack {

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const deployedViaStackSet = new DeployedViaStackSet(this, 'DeployedViaStackSet');

    new CfnStackSet(this, 'StackSet', {
      templateUrl: servicecatalog.CloudFormationTemplate.fromProductStack(DeployedViaStackSet).bind(this).httpUrl,
...
    });
  }
}
skinny85 commented 2 years ago

@s0enke that's great, that's exactly what this functionality is for 🙂.

The documentation is here: https://docs.aws.amazon.com/cdk/api/latest/docs/aws-servicecatalog-readme.html#creating-a-product-from-a-stack

I will close this issue as "done", if anyone runs into problems and can't use the ServiceCatalog ProductStack mentioned above, please leave a comment (or open a new issue)!

Thanks, Adam

github-actions[bot] commented 2 years ago

⚠️COMMENT VISIBILITY WARNING⚠️

Comments on closed issues are hard for our team to see. If you need more assistance, please either tag a team member or open a new issue that references this one. If you wish to keep having a conversation with other community members under this issue feel free to do so.

fitzoh commented 2 years ago

@skinny85 that makes it pretty clear how to manage Service Catalog products , but doesn't provide a clear solution for stacksets, so that covers half the issue.

It also doesn't handle generic usage such as AWS config conformance packs as mentioned here

skinny85 commented 2 years ago

Hmm... could the solution be as simple as adding a class to the @aws-cdk/core library that's very similar in capabilities to the ServiceCatalog's ProductStack?

s0enke commented 2 years ago

@skinny85 @fitzoh I assume that Conformance Packs can be populated in the same way, but I did not test it (they are no real CFN templates, but a subset IIRC):

class DeployedViaConformancePack extends servicecatalog.ProductStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    new aws_config_rule(...)
  }
}

export class CdkStack extends Stack {

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const deployedViaConformancePack = new DeployedViaConformancePack(this, 'DeployedViaConformancePack');

const cfnOrganizationConformancePack = new config.CfnOrganizationConformancePack(this, 'MyCfnOrganizationConformancePack', {
  ...
  templateS3Uri: servicecatalog.CloudFormationTemplate.fromProductStack( DeployedViaConformancePack).bind(this).httpUrl,
});
  }
}

I agree that it looks a bit more like a workaround/hack, since we are utilizing something from the Service Catalog Construct, so a convenience method/construct in the core might make sense. I count four occurrences which work with CFN templates under the hood: Service Catalog, Stack Sets, and Conformance Packs, and Organizational Conformance Packs.

skinny85 commented 2 years ago

Ok. I've edited the title of the issue, let's re-open this one.

skinny85 commented 2 years ago

BTW, contributions are always welcome 😉.

mrpackethead commented 2 years ago

Same topic.... ( request for conformance packs.. ). https://github.com/aws/aws-cdk/issues/16682

I solved this problem by creating two apps, the first one creates templates, synths them and sticks them in cfassests.out. The 'main' stack, picks those templates which become 'assests'.. This

    cfassests = cdk.App(outdir='cfassests.out')
    createtemplates(cfassests, 'createtemplates'
    cfassests.synth()

#Create the main application.
app = cdk.App()
Dosomething(app, 'do something')
app.synth()
peterb154 commented 2 years ago
    const template = stack.addFileAsset({
      packaging: cdk.FileAssetPackaging.FILE,
      sourceHash: 'git-commit',
      fileName: props.stack.templateFile,
    });

This is AWESOME.. Maybe I am missing something @eladb ? Wy wouldn't you just use the hash of the synthesized template as the sourceHash?

eladb commented 2 years ago

@peterb154 when this code is executed there isn't a synthesized template yet :-)

ArielPrevu3D commented 2 years ago

I ended up doing something absolutely horrible to make assets work with StackSet stack instances. Had to change the staging bucket encryption by using a custom bootstrap template. One stackset per region because the bucket is passed in parameters. One stack with all the stacksets to allow parallel deployment. Let me know if you guys find a better way.


class ComplianceStackInstance extends Stack {
  constructor(scope: Construct) {
    super(scope, 'Custom-Config-Rules', {
      env: {
        region: MAIN_REGION,
        account: ACCOUNTS.Root.Management.toString(),
      },
    });
    new AccountDefaults(this);
  }
}

const DEPLOYMENT_ORG_UNITS = [ORGUNITS.Development];

class ComplianceAssetStack extends Stack {
  readonly toolkitStagingBucket: IBucket;

  constructor(
    scope: App,
    env: Environment,
    region: string,
    virtualStackArtifact: CloudFormationStackArtifact,
  ) {
    super(scope, `${env.stage}-ComplianceAssets-${region}`, {
      env: {
        account: ACCOUNTS.Root.Management.toString(),
        region,
      },
    });
    for (const asset of virtualStackArtifact.assets) {
      if (asset.packaging === FileAssetPackaging.ZIP_DIRECTORY)
        this.synthesizer.addFileAsset({
          packaging: FileAssetPackaging.ZIP_DIRECTORY,
          fileName: asset.path,
          sourceHash: asset.sourceHash,
        });
    }
    this.toolkitStagingBucket = Bucket.fromBucketName(
      this,
      'CDKStagingBucket',
      (MANAGEMENT_STAGING_BUCKETS as Record<string, string>)[this.region],
    );

    const pol = new BucketPolicy(this, 'CDKStagingBucketPolicy', {
      bucket: this.toolkitStagingBucket,
    });

    pol.document.addStatements(
      new PolicyStatement({
        actions: ['s3:GetObject'],
        resources: virtualStackArtifact.assets.map((asset) =>
          this.toolkitStagingBucket.arnForObjects(
            `assets/${asset.sourceHash}.zip`,
          ),
        ),
        principals: [new AnyPrincipal()],
        conditions: {
          'ForAnyValue:StringEquals': {
            'aws:PrincipalOrgPaths': DEPLOYMENT_ORG_UNITS.map(
              (ou) => `${ORGID}/${ORGUNITS.Root}/${ou}/`,
            ),
          },
        },
      }),
    );
  }
}

export class ComplianceStack extends Stack {
  constructor(scope: App, env: Environment) {
    super(scope, `${env.stage}-Compliance`, {
      env: {
        region: MAIN_REGION,
        account: ACCOUNTS.Root.Management.toString(),
      },
      terminationProtection: env.isProductionStage,
    });

    const app = new App();
    const virtualComplianceStack = new ComplianceStackInstance(app);

    const templateAsset = this.synthesizer.addFileAsset({
      packaging: FileAssetPackaging.FILE,
      sourceHash: randomBytes(18).toString('base64'),
      fileName: virtualComplianceStack.templateFile,
    });

    const virtualStackArtifact = app
      .synth()
      .getStackArtifact(virtualComplianceStack.artifactId);

    for (const region of GOVERNED_REGIONS) {
      const assetStack = new ComplianceAssetStack(
        scope,
        env,
        region,
        virtualStackArtifact,
      );
      this.addDependency(assetStack);
      new CfnStackSet(this, `StackSet-${region}`, {
        autoDeployment: {
          enabled: true,
          retainStacksOnAccountRemoval: false,
        },
        stackInstancesGroup: [
          {
            regions: [region],
            deploymentTargets: { organizationalUnitIds: DEPLOYMENT_ORG_UNITS },
          },
        ],
        capabilities: ['CAPABILITY_IAM'],
        operationPreferences: {
          maxConcurrentPercentage: 50,
          failureTolerancePercentage: 50,
        },
        parameters: virtualStackArtifact.assets.flatMap((asset) => {
          return 's3BucketParameter' in asset
            ? [
                {
                  parameterKey: asset.s3BucketParameter,
                  parameterValue: assetStack.toolkitStagingBucket.bucketName,
                },
                {
                  parameterKey: asset.s3KeyParameter,
                  parameterValue: `assets/||${asset.sourceHash}.zip`,
                },
                {
                  parameterKey: asset.artifactHashParameter,
                  parameterValue: asset.sourceHash,
                },
              ]
            : [];
        }),
        stackSetName: `Custom-Compliance-${region}`,
        permissionModel: 'SERVICE_MANAGED',
        templateUrl: templateAsset.httpUrl,
      });
    }
  }
}
pgarbe commented 1 year ago

Started to work on a StackSet L2 construct which supports StackSetStacks with file-based assets: https://github.com/pgarbe/cdk-stackset