canopycms-cdk 0.0.0 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopycms-cdk",
3
- "version": "0.0.0",
3
+ "version": "0.0.2",
4
4
  "description": "AWS CDK constructs and EC2 worker for CanopyCMS deployment",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,7 +17,6 @@
17
17
  },
18
18
  "files": [
19
19
  "dist",
20
- "src",
21
20
  "worker/dist",
22
21
  "worker/canopy-worker.service"
23
22
  ],
@@ -53,8 +52,8 @@
53
52
  "devDependencies": {
54
53
  "@types/node": "^22.9.0",
55
54
  "aws-cdk-lib": "^2.150.0",
56
- "canopycms": "*",
57
- "canopycms-auth-clerk": "*",
55
+ "canopycms": "^0.0.2",
56
+ "canopycms-auth-clerk": "^0.0.2",
58
57
  "@octokit/rest": "^20.0.2",
59
58
  "simple-git": "^3.22.0",
60
59
  "@aws-sdk/client-secrets-manager": "^3.600.0",
@@ -1,151 +0,0 @@
1
- import { Construct } from 'constructs'
2
- import {
3
- Duration,
4
- Fn,
5
- aws_cloudfront as cloudfront,
6
- aws_cloudfront_origins as origins,
7
- aws_certificatemanager as acm,
8
- aws_route53 as route53,
9
- aws_route53_targets as targets,
10
- aws_lambda as lambda,
11
- } from 'aws-cdk-lib'
12
-
13
- export interface CanopyCmsDistributionProps {
14
- /** Lambda Function URL from CanopyCmsService */
15
- functionUrl: lambda.FunctionUrl
16
-
17
- /** Domain name for the CMS (e.g., 'cms.docs.example.org') */
18
- domainName: string
19
-
20
- /** Route53 hosted zone domain (e.g., 'example.org') */
21
- hostedZoneDomain: string
22
-
23
- /** Optional: provide an existing hosted zone instead of looking up by domain */
24
- hostedZone?: route53.IHostedZone
25
-
26
- /** Optional: provide an existing ACM certificate instead of creating one */
27
- certificate?: acm.ICertificate
28
- }
29
-
30
- /**
31
- * Optional CDK construct for CanopyCMS CloudFront distribution.
32
- *
33
- * Use this if you don't have existing CloudFront infrastructure.
34
- * If you do, use the functionUrl output from CanopyCmsService
35
- * and wire it into your own CloudFront setup.
36
- *
37
- * Creates:
38
- * - ACM certificate (DNS validated) — unless provided
39
- * - CloudFront distribution with Function URL origin
40
- * - Route53 A/AAAA alias records
41
- * - Cache policies: no-cache for /api/* and /edit*, cache /_next/static/*
42
- */
43
- export class CanopyCmsDistribution extends Construct {
44
- /** The CloudFront distribution */
45
- public readonly distribution: cloudfront.Distribution
46
-
47
- constructor(scope: Construct, id: string, props: CanopyCmsDistributionProps) {
48
- super(scope, id)
49
-
50
- // ========================================================================
51
- // DNS — Hosted Zone lookup
52
- // ========================================================================
53
-
54
- const hostedZone =
55
- props.hostedZone ??
56
- route53.HostedZone.fromLookup(this, 'Zone', {
57
- domainName: props.hostedZoneDomain,
58
- })
59
-
60
- // ========================================================================
61
- // ACM Certificate
62
- // ========================================================================
63
-
64
- const certificate =
65
- props.certificate ??
66
- new acm.Certificate(this, 'Cert', {
67
- domainName: props.domainName,
68
- validation: acm.CertificateValidation.fromDns(hostedZone),
69
- })
70
-
71
- // ========================================================================
72
- // CloudFront Distribution
73
- // ========================================================================
74
-
75
- // Extract the domain from the Function URL (https://xxx.lambda-url.region.on.aws)
76
- const functionUrlDomain = extractFunctionUrlDomain(props.functionUrl)
77
-
78
- // Origin: Lambda Function URL
79
- const origin = new origins.HttpOrigin(functionUrlDomain, {
80
- protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
81
- })
82
-
83
- // Cache policy for API/editor routes: no caching, forward all headers
84
- const noCachePolicy = new cloudfront.CachePolicy(this, 'NoCachePolicy', {
85
- cachePolicyName: `${id}-no-cache`,
86
- defaultTtl: Duration.seconds(0),
87
- maxTtl: Duration.seconds(0),
88
- minTtl: Duration.seconds(0),
89
- headerBehavior: cloudfront.CacheHeaderBehavior.allowList('Authorization', 'Cookie', 'Host'),
90
- queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
91
- cookieBehavior: cloudfront.CacheCookieBehavior.all(),
92
- })
93
-
94
- // Cache policy for static assets
95
- const staticCachePolicy = new cloudfront.CachePolicy(this, 'StaticCachePolicy', {
96
- cachePolicyName: `${id}-static`,
97
- defaultTtl: Duration.days(365),
98
- maxTtl: Duration.days(365),
99
- minTtl: Duration.days(365),
100
- headerBehavior: cloudfront.CacheHeaderBehavior.none(),
101
- queryStringBehavior: cloudfront.CacheQueryStringBehavior.none(),
102
- cookieBehavior: cloudfront.CacheCookieBehavior.none(),
103
- })
104
-
105
- this.distribution = new cloudfront.Distribution(this, 'Distribution', {
106
- domainNames: [props.domainName],
107
- certificate,
108
- defaultBehavior: {
109
- origin,
110
- cachePolicy: noCachePolicy,
111
- viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
112
- allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
113
- originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
114
- },
115
- additionalBehaviors: {
116
- '/_next/static/*': {
117
- origin,
118
- cachePolicy: staticCachePolicy,
119
- viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
120
- },
121
- },
122
- })
123
-
124
- // ========================================================================
125
- // DNS Records
126
- // ========================================================================
127
-
128
- new route53.ARecord(this, 'ARecord', {
129
- zone: hostedZone,
130
- recordName: props.domainName,
131
- target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(this.distribution)),
132
- })
133
-
134
- new route53.AaaaRecord(this, 'AaaaRecord', {
135
- zone: hostedZone,
136
- recordName: props.domainName,
137
- target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(this.distribution)),
138
- })
139
- }
140
- }
141
-
142
- /**
143
- * Extract the domain from a Lambda Function URL.
144
- * The URL is like https://xxx.lambda-url.region.on.aws/
145
- * We need just the domain part for CloudFront origin.
146
- */
147
- function extractFunctionUrlDomain(functionUrl: lambda.FunctionUrl): string {
148
- // Function URL is a Token at synth time, so we use Fn.select + Fn.split
149
- // to extract the domain from the URL: https://xxx.lambda-url.region.on.aws/
150
- return Fn.select(2, Fn.split('/', functionUrl.url))
151
- }
@@ -1,325 +0,0 @@
1
- import * as path from 'node:path'
2
- import { Construct } from 'constructs'
3
- import {
4
- Duration,
5
- RemovalPolicy,
6
- aws_ec2 as ec2,
7
- aws_efs as efs,
8
- aws_iam as iam,
9
- aws_lambda as lambda,
10
- aws_autoscaling as autoscaling,
11
- aws_s3_assets as s3assets,
12
- } from 'aws-cdk-lib'
13
-
14
- export interface CanopyCmsServiceProps {
15
- /** Docker image for the CMS Lambda function */
16
- cmsDockerImage: lambda.DockerImageCode
17
-
18
- /** Optional: use an existing VPC instead of creating one */
19
- vpc?: ec2.IVpc
20
-
21
- /** Lambda memory in MB (default: 2048) */
22
- memorySize?: number
23
-
24
- /** Lambda timeout (default: 60 seconds) */
25
- timeout?: Duration
26
-
27
- /** Lambda reserved concurrency cap (default: 10) */
28
- reservedConcurrency?: number
29
-
30
- /** EC2 spot max price (default: on-demand rate for t4g.nano) */
31
- spotMaxPrice?: string
32
-
33
- /** Secrets Manager ARNs the worker needs to read (GitHub token, Clerk key) */
34
- secretsArns?: string[]
35
-
36
- /** Environment variables for the Lambda function */
37
- environment?: Record<string, string>
38
-
39
- /** EFS removal policy (default: RETAIN) */
40
- efsRemovalPolicy?: RemovalPolicy
41
-
42
- /** GitHub owner for worker git operations (e.g., 'safeinsights') */
43
- githubOwner: string
44
-
45
- /** GitHub repo name for worker git operations (e.g., 'docs-site') */
46
- githubRepo: string
47
-
48
- /** Secrets Manager ARN for the GitHub bot token */
49
- githubTokenSecretArn?: string
50
-
51
- /** Secrets Manager ARN for the Clerk secret key */
52
- clerkSecretKeySecretArn?: string
53
-
54
- /** Base branch name (default: 'main') */
55
- baseBranch?: string
56
- }
57
-
58
- /**
59
- * Core CDK construct for CanopyCMS deployment.
60
- *
61
- * Creates:
62
- * - VPC (2 AZs, public + private subnets, NO NAT)
63
- * - EFS filesystem with access point at /workspace
64
- * - Lambda function (Docker image, EFS mount, private subnet, no internet)
65
- * - Lambda Function URL (for CloudFront origin)
66
- * - EC2 Worker (t4g.nano spot in ASG, public subnet, EFS mount, systemd)
67
- * - Security groups (least-privilege)
68
- * - IAM roles (Lambda: EFS only; EC2: EFS + Secrets Manager)
69
- */
70
- export class CanopyCmsService extends Construct {
71
- /** Lambda Function URL — use as CloudFront origin */
72
- public readonly functionUrl: lambda.FunctionUrl
73
-
74
- /** The EFS filesystem */
75
- public readonly fileSystem: efs.FileSystem
76
-
77
- /** The VPC */
78
- public readonly vpc: ec2.IVpc
79
-
80
- /** The Lambda function */
81
- public readonly lambdaFunction: lambda.Function
82
-
83
- /** The EC2 worker Auto Scaling Group */
84
- public readonly workerAsg: autoscaling.AutoScalingGroup
85
-
86
- constructor(scope: Construct, id: string, props: CanopyCmsServiceProps) {
87
- super(scope, id)
88
-
89
- // ========================================================================
90
- // VPC — 2 AZs, public + private subnets, NO NAT
91
- // ========================================================================
92
-
93
- this.vpc =
94
- props.vpc ??
95
- new ec2.Vpc(this, 'Vpc', {
96
- maxAzs: 2,
97
- natGateways: 0, // No NAT — Lambda has no internet access
98
- subnetConfiguration: [
99
- {
100
- name: 'Public',
101
- subnetType: ec2.SubnetType.PUBLIC,
102
- cidrMask: 24,
103
- },
104
- {
105
- name: 'Private',
106
- subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
107
- cidrMask: 24,
108
- },
109
- ],
110
- })
111
-
112
- // ========================================================================
113
- // EFS — persistent filesystem for content, git repos, cache
114
- // ========================================================================
115
-
116
- const efsSg = new ec2.SecurityGroup(this, 'EfsSg', {
117
- vpc: this.vpc,
118
- description: 'CanopyCMS EFS',
119
- allowAllOutbound: false,
120
- })
121
-
122
- this.fileSystem = new efs.FileSystem(this, 'FileSystem', {
123
- vpc: this.vpc,
124
- encrypted: true,
125
- performanceMode: efs.PerformanceMode.GENERAL_PURPOSE,
126
- removalPolicy: props.efsRemovalPolicy ?? RemovalPolicy.RETAIN,
127
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
128
- securityGroup: efsSg,
129
- })
130
-
131
- const accessPoint = this.fileSystem.addAccessPoint('WorkspaceAP', {
132
- path: '/workspace',
133
- createAcl: {
134
- ownerGid: '1000',
135
- ownerUid: '1000',
136
- permissions: '755',
137
- },
138
- posixUser: {
139
- gid: '1000',
140
- uid: '1000',
141
- },
142
- })
143
-
144
- // ========================================================================
145
- // Lambda — CMS app, private subnet, no internet, EFS mount
146
- // ========================================================================
147
-
148
- const lambdaSg = new ec2.SecurityGroup(this, 'LambdaSg', {
149
- vpc: this.vpc,
150
- description: 'CanopyCMS Lambda',
151
- allowAllOutbound: false, // No internet access
152
- })
153
-
154
- // Lambda → EFS
155
- efsSg.addIngressRule(lambdaSg, ec2.Port.tcp(2049), 'Lambda NFS access')
156
-
157
- this.lambdaFunction = new lambda.DockerImageFunction(this, 'CmsFunction', {
158
- code: props.cmsDockerImage,
159
- memorySize: props.memorySize ?? 2048,
160
- timeout: props.timeout ?? Duration.seconds(60),
161
- reservedConcurrentExecutions: props.reservedConcurrency ?? 10,
162
- vpc: this.vpc,
163
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
164
- securityGroups: [lambdaSg],
165
- filesystem: lambda.FileSystem.fromEfsAccessPoint(accessPoint, '/mnt/efs'),
166
- environment: {
167
- CANOPYCMS_WORKSPACE_ROOT: '/mnt/efs/workspace',
168
- CANOPY_AUTH_CACHE_PATH: '/mnt/efs/workspace/.cache',
169
- ...props.environment,
170
- },
171
- })
172
-
173
- // Function URL for CloudFront origin
174
- this.functionUrl = this.lambdaFunction.addFunctionUrl({
175
- authType: lambda.FunctionUrlAuthType.NONE,
176
- })
177
-
178
- // ========================================================================
179
- // EC2 Worker — t4g.nano spot, public subnet, internet, EFS mount
180
- // ========================================================================
181
-
182
- const workerSg = new ec2.SecurityGroup(this, 'WorkerSg', {
183
- vpc: this.vpc,
184
- description: 'CanopyCMS EC2 Worker',
185
- allowAllOutbound: false,
186
- })
187
-
188
- // Worker ↔ EFS (ingress on EFS SG + egress on Worker SG)
189
- efsSg.addIngressRule(workerSg, ec2.Port.tcp(2049), 'Worker NFS access')
190
- workerSg.addEgressRule(efsSg, ec2.Port.tcp(2049), 'NFS to EFS')
191
-
192
- // Worker → internet (HTTPS only)
193
- workerSg.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'HTTPS outbound')
194
-
195
- // Worker → DNS (needed for EFS DNS-based mount targets)
196
- workerSg.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(53), 'DNS TCP')
197
- workerSg.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.udp(53), 'DNS UDP')
198
-
199
- // Worker IAM role
200
- const workerRole = new iam.Role(this, 'WorkerRole', {
201
- assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
202
- description: 'CanopyCMS EC2 Worker role',
203
- })
204
-
205
- // Worker needs to read secrets
206
- if (props.secretsArns && props.secretsArns.length > 0) {
207
- workerRole.addToPolicy(
208
- new iam.PolicyStatement({
209
- actions: ['secretsmanager:GetSecretValue'],
210
- resources: props.secretsArns,
211
- }),
212
- )
213
- }
214
-
215
- // Worker needs EFS access (handled via security group, but mount needs ec2:DescribeAvailabilityZones)
216
- workerRole.addManagedPolicy(
217
- iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonElasticFileSystemClientReadWriteAccess'),
218
- )
219
-
220
- // Worker S3 Asset — upload bundled worker code to CDK assets bucket
221
- // The worker is bundled with esbuild into a single JS file (npm run build:worker)
222
- const workerAsset = new s3assets.Asset(this, 'WorkerCode', {
223
- path: path.join(__dirname, '../../worker/dist'),
224
- })
225
- workerAsset.grantRead(workerRole)
226
-
227
- // Build worker .env file content from CDK props
228
- const envLines = [
229
- `CANOPYCMS_WORKSPACE_ROOT=/mnt/efs/workspace`,
230
- `CANOPYCMS_GITHUB_OWNER=${props.githubOwner}`,
231
- `CANOPYCMS_GITHUB_REPO=${props.githubRepo}`,
232
- `CANOPYCMS_BASE_BRANCH=${props.baseBranch ?? 'main'}`,
233
- ]
234
- if (props.githubTokenSecretArn) {
235
- envLines.push(`CANOPYCMS_GITHUB_TOKEN_SECRET_ARN=${props.githubTokenSecretArn}`)
236
- }
237
- if (props.clerkSecretKeySecretArn) {
238
- envLines.push(`CLERK_SECRET_KEY_SECRET_ARN=${props.clerkSecretKeySecretArn}`)
239
- }
240
- const envFileContent = envLines.join('\n')
241
-
242
- // UserData script
243
- const userData = ec2.UserData.forLinux()
244
- userData.addCommands(
245
- '#!/bin/bash',
246
- 'set -euo pipefail',
247
- '',
248
- '# Install dependencies',
249
- 'yum install -y git',
250
- 'curl -fsSL https://rpm.nodesource.com/setup_20.x | bash -',
251
- 'yum install -y nodejs',
252
- '',
253
- '# Mount EFS',
254
- 'yum install -y amazon-efs-utils',
255
- 'mkdir -p /mnt/efs',
256
- `mount -t efs ${this.fileSystem.fileSystemId}:/ /mnt/efs`,
257
- '',
258
- '# Download worker from CDK S3 Asset',
259
- `aws s3 cp s3://${workerAsset.s3BucketName}/${workerAsset.s3ObjectKey} /tmp/canopy-worker.zip`,
260
- 'mkdir -p /opt/canopy-worker',
261
- 'cd /opt/canopy-worker',
262
- 'unzip -o /tmp/canopy-worker.zip',
263
- '',
264
- '# Write environment file for systemd service',
265
- `cat > /opt/canopy-worker/.env << 'ENVEOF'`,
266
- envFileContent,
267
- 'ENVEOF',
268
- '',
269
- '# Create systemd service',
270
- `cat > /etc/systemd/system/canopy-worker.service << 'SVCEOF'`,
271
- '[Unit]',
272
- 'Description=CanopyCMS Worker Daemon',
273
- 'After=network.target',
274
- '',
275
- '[Service]',
276
- 'Type=simple',
277
- 'User=ec2-user',
278
- 'WorkingDirectory=/opt/canopy-worker',
279
- 'ExecStart=/usr/bin/node index.js',
280
- 'Restart=always',
281
- 'RestartSec=5',
282
- 'TimeoutStartSec=300',
283
- 'StandardOutput=journal',
284
- 'StandardError=journal',
285
- 'EnvironmentFile=/opt/canopy-worker/.env',
286
- '',
287
- '[Install]',
288
- 'WantedBy=multi-user.target',
289
- 'SVCEOF',
290
- '',
291
- '# Set ownership for ec2-user',
292
- 'chown -R ec2-user:ec2-user /opt/canopy-worker',
293
- '# Non-recursive: EFS access point enforces UID 1000 for Lambda.',
294
- '# Only set ownership on mount point and workspace dir to avoid',
295
- '# slow recursive chown on large filesystems during ASG replacements.',
296
- 'chown ec2-user:ec2-user /mnt/efs',
297
- 'mkdir -p /mnt/efs/workspace',
298
- 'chown ec2-user:ec2-user /mnt/efs/workspace',
299
- '',
300
- '# Start worker',
301
- 'systemctl daemon-reload',
302
- 'systemctl enable canopy-worker',
303
- 'systemctl start canopy-worker',
304
- )
305
-
306
- // Auto Scaling Group
307
- this.workerAsg = new autoscaling.AutoScalingGroup(this, 'WorkerAsg', {
308
- vpc: this.vpc,
309
- vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
310
- instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.NANO),
311
- machineImage: ec2.MachineImage.latestAmazonLinux2023({
312
- cpuType: ec2.AmazonLinuxCpuType.ARM_64,
313
- }),
314
- role: workerRole,
315
- securityGroup: workerSg,
316
- minCapacity: 1,
317
- maxCapacity: 1,
318
- userData,
319
- spotPrice: props.spotMaxPrice ?? '0.0042', // On-demand rate for t4g.nano
320
- healthCheck: autoscaling.HealthCheck.ec2({
321
- grace: Duration.minutes(5),
322
- }),
323
- })
324
- }
325
- }
package/src/index.ts DELETED
@@ -1,9 +0,0 @@
1
- // Worker daemon
2
- export { CmsWorker } from './worker'
3
- export type { CmsWorkerConfig } from './worker'
4
-
5
- // CDK Constructs
6
- export { CanopyCmsService } from './constructs/cms-service'
7
- export type { CanopyCmsServiceProps } from './constructs/cms-service'
8
- export { CanopyCmsDistribution } from './constructs/cms-distribution'
9
- export type { CanopyCmsDistributionProps } from './constructs/cms-distribution'
package/src/worker.ts DELETED
@@ -1,7 +0,0 @@
1
- /**
2
- * Re-export CmsWorker from the core canopycms package.
3
- * The worker is cloud-agnostic and auth-agnostic — it lives in canopycms core.
4
- * This file re-exports it for convenience from the CDK package.
5
- */
6
- export { CmsWorker } from 'canopycms/worker/cms-worker'
7
- export type { CmsWorkerConfig, AuthCacheRefresher } from 'canopycms/worker/cms-worker'