@granite-js/pulumi-aws 2.5.2 → 2.5.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @granite-js/pulumi-aws
2
2
 
3
+ ## 2.5.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 8b04ab6: Add configurable deployment channels across Forge, deployment storage and the AWS CDN. Forge automatically
8
+ registers each app/channel selector in S3, so channel creation requires no per-channel infrastructure configuration.
9
+ Channel objects, state, history and cluster pointers are isolated under `channels/<channel>/`. Registered path
10
+ suffixes select channels while the legacy default and unregistered filename tags keep their existing routes.
11
+ Conditional selector reservations and checks against retained legacy bundles prevent tag/channel name conflicts
12
+ between updated publishers. Registration and rollout events invalidate service selectors, and unique invalidation
13
+ caller references prevent simultaneous requests from colliding. Missing channel deployments never fall back to
14
+ another namespace. Cluster rollouts now write the `.deploymentInfo` pointer used by readers and invalidation.
15
+
16
+ The exported `paths` helpers take one options object instead of positional arguments.
17
+
3
18
  ## 2.5.2
4
19
 
5
20
  ## 2.5.1
package/README.md CHANGED
@@ -77,6 +77,98 @@ pulumi up
77
77
 
78
78
  Review the changes that Pulumi proposes, then confirm deployment. Pulumi will provision your React Native CDN infrastructure on AWS.
79
79
 
80
+ ## Deployment channels
81
+
82
+ Deploy the channel-aware infrastructure once. Subsequent channel creation is handled by Forge, without changing
83
+ the Pulumi configuration or redeploying Lambda for each channel:
84
+
85
+ ```sh
86
+ granite-forge deploy --bucket sample-bucket --channel next
87
+ ```
88
+
89
+ Forge gets the app name from the Granite config and registers the app/channel pair in S3 before uploading the
90
+ platform bundles. Shared and app bundles are separate deployments and should use the same channel name.
91
+
92
+ ```text
93
+ /ios/sample-app/1/bundle -> existing unscoped default bundle
94
+ /ios/sample-app/1/next -> next channel's default bundle
95
+ /android/shared/1/next -> next channel's shared bundle
96
+ ```
97
+
98
+ ### S3 registration and lookup
99
+
100
+ The selector record is stored outside the channel's mutable rollout state:
101
+
102
+ ```text
103
+ deployments/sample-app/selectors/next.json
104
+ {"version":1,"type":"CHANNEL"}
105
+
106
+ channels/next/deployments/sample-app/deployment_state
107
+ channels/next/bundles/sample-app/<deploymentId>/bundle.ios.hbc.gz
108
+ ```
109
+
110
+ On a CloudFront cache miss, the origin-request Lambda reads the selector record and then resolves deployment
111
+ state in the selected channel. The URI is rewritten to its S3 bundle key. `bundle` bypasses registration lookup
112
+ and keeps the legacy path. A missing registration keeps the existing filename-tag behavior; a registered channel
113
+ with missing deployment state returns 404, never a legacy bundle. Invalid metadata or storage access errors fail
114
+ instead of being interpreted as an unregistered selector. Lambda does not keep an in-process registration cache.
115
+ Query parameters do not select or override channels.
116
+
117
+ Numeric groups from 1 to 1000 retain their rollout meaning within the chosen channel. Named clusters still require
118
+ `allowAccessCluster`; the component's default handler keeps cluster access disabled.
119
+
120
+ ### Backward compatibility and concurrent publishers
121
+
122
+ `--channel` omission preserves existing S3 keys and URLs. `bundle` is reserved and cannot be a channel name.
123
+ Channel names follow the [Forge CLI rules](../forge-cli/README.md#deployment-channels).
124
+
125
+ Before creating a channel, the deployment manager lists all pages under `bundles/<app>/` and rejects a matching
126
+ legacy tag on either platform, including retained objects from older deployments. New legacy tagged uploads
127
+ reserve the same selector key with `{"version":1,"type":"LEGACY_TAG"}`. Conditional S3 writes (`If-None-Match: *`)
128
+ ensure one owner when tag and channel registrations race. Repeated registration of the same kind is idempotent;
129
+ it does not overwrite or invalidate an existing registration.
130
+
131
+ Upgrade legacy tag publishers to the updated deployment manager before enabling channel creation. Older writers
132
+ cannot honor reservations. Checks cover retained visible objects and registration records, so do not reuse known
133
+ legacy names after deleting their artifacts. Keep selector records permanently: failed uploads do not remove a
134
+ channel registration or enable legacy fallback. Retrying the deployment uses the same registration.
135
+
136
+ Deployment credentials need `s3:ListBucket` for the legacy bundle prefix in addition to object read/write access.
137
+ Lambda only reads registrations and deployment state; it does not scan legacy objects. The extra registration
138
+ read occurs on cache misses for channel-like suffixes, not every cached request.
139
+
140
+ ### Cache invalidation
141
+
142
+ S3 notifications watch `deployments/` and `channels/`. Selector registrations, deployment-state updates and cluster
143
+ pointer changes reach the cache-removal Lambda. Registration events clear previously cached legacy/missing-selector
144
+ responses, and the final rollout update invalidates again after both platform uploads succeed.
145
+
146
+ For `deployments/sample-app/selectors/next.json` or `channels/next/deployments/sample-app/deployment_state`, the paths are:
147
+
148
+ ```text
149
+ /ios/sample-app/*
150
+ /android/sample-app/*
151
+ ```
152
+
153
+ Invalidation covers the affected service across its channels; other services are unaffected. Another channel may
154
+ incur a cache miss but retains its own deployment state and bundle. Cluster changes narrow this to the affected
155
+ cluster. History and bundle uploads do not invalidate selectors. This remains asynchronous; a successful Forge
156
+ command does not mean CloudFront invalidation has finished. See [AWS invalidation path rules](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/invalidation-specifying-objects.html).
157
+
158
+ ### Shared bundles and rollout order
159
+
160
+ The component bootstraps its prebuilt shared bundle only into the existing unscoped namespace. Publish compatible
161
+ shared and app bundles explicitly to each channel. Routing does not validate bytecode/runtime compatibility or
162
+ make separate shared/app publications atomic.
163
+
164
+ Install and verify the common Lambda code and S3 notifications before enabling channel URLs in clients. The old
165
+ Lambda interprets the last segment as a filename tag. Wait for selector invalidations before enabling new clients,
166
+ including clearing any responses cached before the infrastructure upgrade. After this one-time upgrade, deploying
167
+ a new channel only writes S3 data; it needs no per-channel infrastructure configuration.
168
+
169
+ Review the complete Pulumi preview before any future apply: the existing component also manages the legacy
170
+ shared bundle and deployment pointer.
171
+
80
172
  ## Cleaning up
81
173
 
82
174
  To remove the deployed resources, use:
@@ -15867,16 +15867,66 @@ var S3Client2 = class {
15867
15867
  const response = await this.s3Client.send(command);
15868
15868
  return response;
15869
15869
  }
15870
+ async *listObjectKeys(prefix) {
15871
+ let continuationToken;
15872
+ for (; ; ) {
15873
+ const response = await this.s3Client.send(
15874
+ new ListObjectsV2Command({
15875
+ Bucket: this.bucket,
15876
+ Prefix: prefix,
15877
+ ContinuationToken: continuationToken
15878
+ })
15879
+ );
15880
+ for (const object2 of response.Contents ?? []) {
15881
+ if (object2.Key !== void 0) {
15882
+ yield object2.Key;
15883
+ }
15884
+ }
15885
+ if (!response.IsTruncated) {
15886
+ return;
15887
+ }
15888
+ continuationToken = response.NextContinuationToken;
15889
+ if (!continuationToken) {
15890
+ throw new Error("Truncated S3 listing has no continuation token");
15891
+ }
15892
+ }
15893
+ }
15870
15894
  destroy() {
15871
15895
  this.s3Client.destroy();
15872
15896
  }
15873
15897
  };
15898
+ var InvalidRequest3 = class extends Error {
15899
+ constructor(message) {
15900
+ super();
15901
+ this.name = "InvalidRequest";
15902
+ this.message = message;
15903
+ }
15904
+ };
15905
+ function validateChannel(channel) {
15906
+ if (channel === "bundle") {
15907
+ throw new InvalidRequest3("bundle is reserved for the legacy default bundle");
15908
+ }
15909
+ if (!isChannelName(channel)) {
15910
+ throw new InvalidRequest3(
15911
+ "Channel must contain 1-64 letters, digits, underscores or hyphens, starting with a letter or digit"
15912
+ );
15913
+ }
15914
+ return channel;
15915
+ }
15916
+ function isChannelName(value) {
15917
+ return typeof value === "string" && value !== "bundle" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value);
15918
+ }
15919
+ function channelPrefix(channel) {
15920
+ return channel === void 0 ? "" : `channels/${validateChannel(channel)}/`;
15921
+ }
15874
15922
  var paths = {
15875
- bundlePathPrefix: (appName, deploymentId) => `bundles/${appName}/${deploymentId}`,
15876
- bundleList: (appName) => `deployments/${appName}/DEPLOYMENTS`,
15877
- deploymentState: (appName) => `deployments/${appName}/deployment_state`,
15878
- clusterDeploymentState: (appName, clusterId) => `deployments/${appName}/clusters/${clusterId}`,
15879
- clusterDeploymentInfoPath: (appName, clusterId) => `deployments/${appName}/clusters/${clusterId}.deploymentInfo`
15923
+ appBundlePrefix: ({ appName, channel }) => `${channelPrefix(channel)}bundles/${appName}/`,
15924
+ selectorRegistration: ({ appName, selector }) => `deployments/${appName}/selectors/${encodeURIComponent(selector)}.json`,
15925
+ bundlePathPrefix: ({ appName, deploymentId, channel }) => `${channelPrefix(channel)}bundles/${appName}/${deploymentId}`,
15926
+ bundleList: ({ appName, channel }) => `${channelPrefix(channel)}deployments/${appName}/DEPLOYMENTS`,
15927
+ deploymentState: ({ appName, channel }) => `${channelPrefix(channel)}deployments/${appName}/deployment_state`,
15928
+ clusterDeploymentState: ({ appName, clusterId, channel }) => `${channelPrefix(channel)}deployments/${appName}/clusters/${clusterId}`,
15929
+ clusterDeploymentInfoPath: ({ appName, clusterId, channel }) => `${channelPrefix(channel)}deployments/${appName}/clusters/${clusterId}.deploymentInfo`
15880
15930
  };
15881
15931
  function shuffle(arr) {
15882
15932
  const result = arr.slice();
@@ -15991,7 +16041,7 @@ function isFullyRollout(config) {
15991
16041
  }
15992
16042
  async function readBundleList(appName, context) {
15993
16043
  const { s3Client } = context;
15994
- const data = await s3Client.getObject(paths.bundleList(appName));
16044
+ const data = await s3Client.getObject(paths.bundleList({ appName, channel: context.channel }));
15995
16045
  return JSON.parse(data);
15996
16046
  }
15997
16047
  var store$4;
@@ -16357,8 +16407,8 @@ var clusterDeploymentInfo = /* @__PURE__ */ object({
16357
16407
  var deploymentState = /* @__PURE__ */ union([pendingDeploymentState, stableDeploymentState, canaryDeploymentState]);
16358
16408
  async function readCluster({ appName, clusterId }, context) {
16359
16409
  const { s3Client } = context;
16410
+ const deploymentInfoPath = paths.clusterDeploymentInfoPath({ appName, clusterId, channel: context.channel });
16360
16411
  try {
16361
- const deploymentInfoPath = paths.clusterDeploymentInfoPath(appName, clusterId);
16362
16412
  const rawData = await s3Client.getObject(deploymentInfoPath);
16363
16413
  const { deploymentId } = parse(clusterDeploymentInfo, JSON.parse(rawData));
16364
16414
  return deploymentId;
@@ -16375,7 +16425,7 @@ var InternalServerError = class extends Error {
16375
16425
  };
16376
16426
  async function readDeploymentState(appName, context) {
16377
16427
  const { s3Client } = context;
16378
- const deploymentStatePath = paths.deploymentState(appName);
16428
+ const deploymentStatePath = paths.deploymentState({ appName, channel: context.channel });
16379
16429
  try {
16380
16430
  const rawData = await s3Client.getObject(deploymentStatePath);
16381
16431
  const state = parse(deploymentState, JSON.parse(rawData));
@@ -16387,19 +16437,12 @@ async function readDeploymentState(appName, context) {
16387
16437
  throw error;
16388
16438
  }
16389
16439
  }
16390
- function resolveBundle({ appName, platform, deploymentId, tag }) {
16391
- const bundleUrlPrefix = paths.bundlePathPrefix(appName, deploymentId);
16440
+ function resolveBundle({ appName, platform, deploymentId, tag, channel }) {
16441
+ const bundleUrlPrefix = paths.bundlePathPrefix({ appName, deploymentId, channel });
16392
16442
  const extension = "hbc.gz";
16393
16443
  const name = [BUNDLE_BASE_NAME, platform, tag, extension].filter(Boolean).join(".");
16394
16444
  return `${bundleUrlPrefix}/${name}`;
16395
16445
  }
16396
- var InvalidRequest3 = class extends Error {
16397
- constructor(message) {
16398
- super();
16399
- this.name = "InvalidRequest";
16400
- this.message = message;
16401
- }
16402
- };
16403
16446
  var NotFoundError = class extends Error {
16404
16447
  constructor(message) {
16405
16448
  super();
@@ -16437,8 +16480,8 @@ async function resolveDeploymentIdByGroupId(appName, numericGroupId, context) {
16437
16480
  }
16438
16481
  async function resolveDeploymentIdByCluster(appName, clusterId, context) {
16439
16482
  const { s3Client } = context;
16483
+ const clusterDeploymentInfoPath = paths.clusterDeploymentInfoPath({ appName, clusterId, channel: context.channel });
16440
16484
  try {
16441
- const clusterDeploymentInfoPath = paths.clusterDeploymentInfoPath(appName, clusterId);
16442
16485
  const rawData = await s3Client.getObject(clusterDeploymentInfoPath);
16443
16486
  const deploymentInfo = parse(clusterDeploymentInfo, JSON.parse(rawData));
16444
16487
  return deploymentInfo.deploymentId;
@@ -16449,9 +16492,94 @@ async function resolveDeploymentIdByCluster(appName, clusterId, context) {
16449
16492
  throw new InternalServerError("resolveDeploymentIdByCluster", error);
16450
16493
  }
16451
16494
  }
16495
+ var registrationSchema = /* @__PURE__ */ object({
16496
+ version: /* @__PURE__ */ literal(1),
16497
+ type: /* @__PURE__ */ union([/* @__PURE__ */ literal("CHANNEL"), /* @__PURE__ */ literal("LEGACY_TAG")])
16498
+ });
16499
+ function isAppName(appName) {
16500
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(appName);
16501
+ }
16502
+ async function readRegistration(options, { s3Client }) {
16503
+ let raw;
16504
+ try {
16505
+ raw = await s3Client.getObject(paths.selectorRegistration(options));
16506
+ } catch (error) {
16507
+ if (error instanceof NoSuchKey) {
16508
+ return void 0;
16509
+ }
16510
+ throw error;
16511
+ }
16512
+ try {
16513
+ return parse(registrationSchema, JSON.parse(raw));
16514
+ } catch (error) {
16515
+ throw new InternalServerError("Invalid selector registration", error);
16516
+ }
16517
+ }
16518
+ async function resolveChannel(options, context) {
16519
+ if (!isAppName(options.appName) || !isChannelName(options.selector)) {
16520
+ return void 0;
16521
+ }
16522
+ const registration = await readRegistration(options, context);
16523
+ return registration?.type === "CHANNEL" ? options.selector : void 0;
16524
+ }
16525
+ async function registerChannel({ appName, channel }, context) {
16526
+ validateChannel(channel);
16527
+ if (!isAppName(appName)) {
16528
+ throw new InvalidRequest3("Channel registration requires a URL-safe application name");
16529
+ }
16530
+ await claimSelector({ appName, selector: channel }, "CHANNEL", context);
16531
+ }
16532
+ async function reserveLegacyTag(options, context) {
16533
+ if (isAppName(options.appName) && isChannelName(options.selector)) {
16534
+ await claimSelector(options, "LEGACY_TAG", context);
16535
+ }
16536
+ }
16537
+ async function claimSelector(options, type, context) {
16538
+ const { s3Client } = context;
16539
+ let lastConflict;
16540
+ for (let attempt = 0; attempt < 3; attempt++) {
16541
+ const existing = await readRegistration(options, context);
16542
+ if (existing !== void 0) {
16543
+ if (existing.type !== type) {
16544
+ throw new InvalidRequest3(
16545
+ `Selector ${options.selector} for ${options.appName} is already reserved as ${existing.type}`
16546
+ );
16547
+ }
16548
+ return;
16549
+ }
16550
+ if (type === "CHANNEL") {
16551
+ const prefix = paths.appBundlePrefix({ appName: options.appName });
16552
+ for await (const key of s3Client.listObjectKeys(prefix)) {
16553
+ if (["ios", "android"].some((platform) => key.endsWith(`/bundle.${platform}.${options.selector}.hbc.gz`))) {
16554
+ throw new InvalidRequest3(
16555
+ `Channel ${options.selector} conflicts with an existing legacy bundle tag for ${options.appName}`
16556
+ );
16557
+ }
16558
+ }
16559
+ }
16560
+ try {
16561
+ await s3Client.putObject(paths.selectorRegistration(options), {
16562
+ Body: JSON.stringify({ version: 1, type }),
16563
+ ContentType: "application/json",
16564
+ CacheControl: "no-store",
16565
+ IfNoneMatch: "*"
16566
+ });
16567
+ return;
16568
+ } catch (error) {
16569
+ if (!(error instanceof S3ServiceException) || ![409, 412].includes(error.$metadata.httpStatusCode ?? 0)) {
16570
+ throw error;
16571
+ }
16572
+ lastConflict = error;
16573
+ }
16574
+ }
16575
+ throw new InternalServerError("Concurrent selector registration could not be completed", lastConflict);
16576
+ }
16452
16577
  async function rollout({ state, appName }, context) {
16453
16578
  const { s3Client } = context;
16454
- await s3Client.putObject(paths.deploymentState(appName), {
16579
+ if (context.channel !== void 0) {
16580
+ await registerChannel({ appName, channel: context.channel }, context);
16581
+ }
16582
+ await s3Client.putObject(paths.deploymentState({ appName, channel: context.channel }), {
16455
16583
  Body: JSON.stringify(state),
16456
16584
  ContentType: "application/json"
16457
16585
  });
@@ -16459,14 +16587,17 @@ async function rollout({ state, appName }, context) {
16459
16587
  }
16460
16588
  async function rolloutCluster({ appName, deploymentId, clusterId }, context) {
16461
16589
  const { s3Client } = context;
16462
- await s3Client.putObject(paths.clusterDeploymentState(appName, clusterId), {
16590
+ if (context.channel !== void 0) {
16591
+ await registerChannel({ appName, channel: context.channel }, context);
16592
+ }
16593
+ await s3Client.putObject(paths.clusterDeploymentInfoPath({ appName, clusterId, channel: context.channel }), {
16463
16594
  Body: JSON.stringify({ deploymentId }),
16464
16595
  ContentType: "application/json"
16465
16596
  });
16466
16597
  }
16467
16598
  async function writeBundleList(appName, deploymentInfo, context) {
16468
16599
  const { s3Client } = context;
16469
- await s3Client.putObject(paths.bundleList(appName), {
16600
+ await s3Client.putObject(paths.bundleList({ appName, channel: context.channel }), {
16470
16601
  Body: JSON.stringify(deploymentInfo.slice(0, MAX_HISTORY_COUNT)),
16471
16602
  ContentType: "application/json"
16472
16603
  });
@@ -16491,7 +16622,12 @@ function toDeployedAtString(date) {
16491
16622
  }
16492
16623
  async function uploadBundle({ bundlePath, appName, deploymentId, deployedAt, platform, tag }, context) {
16493
16624
  const { s3Client } = context;
16494
- const bundlePathKey = resolveBundle({ appName, platform, deploymentId, tag });
16625
+ const bundlePathKey = resolveBundle({ appName, platform, deploymentId, tag, channel: context.channel });
16626
+ if (context.channel !== void 0) {
16627
+ await registerChannel({ appName, channel: context.channel }, context);
16628
+ } else if (tag) {
16629
+ await reserveLegacyTag({ appName, selector: tag }, context);
16630
+ }
16495
16631
  await s3Client.putObject(bundlePathKey, {
16496
16632
  Body: fs.createReadStream(bundlePath),
16497
16633
  Metadata: {
@@ -16508,6 +16644,8 @@ async function uploadBundle({ bundlePath, appName, deploymentId, deployedAt, pla
16508
16644
  return { bundlePathKey, deploymentInfo };
16509
16645
  }
16510
16646
  var DeployManager = {
16647
+ registerChannel,
16648
+ resolveChannel,
16511
16649
  planRollout,
16512
16650
  rollout,
16513
16651
  rolloutCluster,
@@ -16521,8 +16659,10 @@ var DeployManager = {
16521
16659
  };
16522
16660
 
16523
16661
  export {
16662
+ NoSuchKey,
16524
16663
  S3Client2,
16525
16664
  InvalidRequest3,
16665
+ validateChannel,
16526
16666
  NotFoundError,
16527
16667
  DeployManager
16528
16668
  };
package/dist/index.cjs CHANGED
@@ -1982,6 +1982,11 @@ var ReactNativeBundleCDN = class extends pulumi.ComponentResource {
1982
1982
  events: ["s3:ObjectCreated:*", "s3:ObjectRemoved:*"],
1983
1983
  filterPrefix: "deployments/"
1984
1984
  // Only trigger for objects in deployments/ folder
1985
+ },
1986
+ {
1987
+ lambdaFunctionArn: cacheInvalidationLambda.arn,
1988
+ events: ["s3:ObjectCreated:*", "s3:ObjectRemoved:*"],
1989
+ filterPrefix: "channels/"
1985
1990
  }
1986
1991
  ]
1987
1992
  },
package/dist/index.js CHANGED
@@ -1983,6 +1983,11 @@ var ReactNativeBundleCDN = class extends pulumi.ComponentResource {
1983
1983
  events: ["s3:ObjectCreated:*", "s3:ObjectRemoved:*"],
1984
1984
  filterPrefix: "deployments/"
1985
1985
  // Only trigger for objects in deployments/ folder
1986
+ },
1987
+ {
1988
+ lambdaFunctionArn: cacheInvalidationLambda.arn,
1989
+ events: ["s3:ObjectCreated:*", "s3:ObjectRemoved:*"],
1990
+ filterPrefix: "channels/"
1986
1991
  }
1987
1992
  ]
1988
1993
  },