Skip to content

Commit 16b5f0e

Browse files
committed
BufferPrimitiveCollection: Material API
1 parent 8df4d52 commit 16b5f0e

File tree

96 files changed

+7079
-1952
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

96 files changed

+7079
-1952
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
package-lock.json
3+
.env
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Update tokensin CI
2+
3+
The purpose of this "sub-project" is to automate our update of access tokens and keys that we include with the release and deployment of CesiumJS and Sandcastle.
4+
5+
Our general policy for access tokens and keys is that **they should work for two releases of CesiumJS**, ie 2 months.
6+
7+
The main work happens in `index.js` and the list of "replacements" is defined in `replacements.js`. If you want to add new replacements they should go in `replacements.js`. Each replacement relies on the file path containing the token, an [esquery "js selector"](https://estools.github.io/esquery/) (see [AST Explorer](https://astexplorer.net/) for help) and a value or function to define the new value.
8+
9+
The main update logic is scheduled to happen in CI with the [`update-tokens`](../../workflows/update-tokens.yml) workflow. This lets us define the necessary access secrets in Github's settings.
10+
11+
Access tokens in ion do not automatically expire like share keys do in the itwin platform. To account for this we have a separate `ionTokenDeleter.js` script to handle deleting tokens. This is meant to run in the [`prod`](../../workflows/prod.yml) workflow _after_ the normal release process is done.
12+
13+
## Running locally
14+
15+
The scripts rely on a few environment variables. The easiest way to set these up is using `dotenvx`. Create a `.env` file in this directory and populate it with the following values.
16+
17+
```sh
18+
ITWIN_SERVICE_APP_CLIENT_ID=[client id]
19+
ITWIN_SERVICE_APP_CLIENT_SECRET=[client secret]
20+
ION_TOKEN_CONTROLLER_TOKEN=[ion token with scopes: tokens:read, tokens:write]
21+
```
22+
23+
Then you can just run `npx dotenvx run -- node index.js` and it will pull in the appropriate values.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { add, getMonth, setDate } from "date-fns";
2+
import { readFile } from "node:fs/promises";
3+
import { dirname, join } from "node:path";
4+
import { exit } from "node:process";
5+
import { fileURLToPath } from "node:url";
6+
7+
const CLIENT_ID = process.env.ITWIN_SERVICE_APP_CLIENT_ID;
8+
const CLIENT_SECRET = process.env.ITWIN_SERVICE_APP_CLIENT_SECRET;
9+
10+
if (!CLIENT_ID || !CLIENT_SECRET) {
11+
console.error("Missing client id or secret");
12+
exit(1);
13+
}
14+
15+
const IMS_URL = "https://ims.bentley.com";
16+
const ITWIN_API_URL = "https://api.bentley.com";
17+
18+
const __dirname = dirname(fileURLToPath(import.meta.url));
19+
const projectRoot = join(__dirname, "../../../");
20+
const packageJsonPath = join(projectRoot, "package.json");
21+
22+
async function getCurrentMinorVersion() {
23+
const data = await readFile(packageJsonPath, "utf8");
24+
const { version } = JSON.parse(data);
25+
const majorMinor = version.match(/^(.*)\.(.*)\./);
26+
const minor = Number(majorMinor[2]);
27+
return minor;
28+
}
29+
30+
/**
31+
* @typedef {object} Share
32+
* @property {string} id
33+
* @property {string} iTwinId
34+
* @property {string} shareKey
35+
* @property {string} shareContract
36+
* @property {string} expiration ISO date string
37+
*/
38+
39+
/**
40+
* @typedef {object} Itwin
41+
* @property {string} id
42+
* @property {string} class
43+
* @property {string} subClass
44+
* @property {string | null} type
45+
* @property {string} number
46+
* @property {string} displayName
47+
* @property {string} status
48+
*/
49+
50+
/**
51+
* @param {string} clientId
52+
* @param {string} clientSecret
53+
* @returns {Promise<string>}
54+
*/
55+
async function getIMSToken(clientId, clientSecret) {
56+
const resp = await fetch(`${IMS_URL}/connect/token`, {
57+
method: "POST",
58+
body: new URLSearchParams({
59+
grant_type: "client_credentials",
60+
client_id: clientId,
61+
client_secret: clientSecret,
62+
scope: "itwin-platform",
63+
}),
64+
});
65+
66+
if (!resp.ok) {
67+
const result = await resp.json();
68+
throw new Error(
69+
`Error fetching token from IMS. Status: ${resp.status}. ${result.error} - ${result.error_description}`,
70+
);
71+
}
72+
73+
const result = await resp.json();
74+
return result.access_token;
75+
}
76+
77+
/**
78+
* @param {string} itwinId
79+
* @param {string} accessToken
80+
* @returns {Promise<Share[]>}
81+
*/
82+
async function getShares(itwinId, accessToken) {
83+
const resp = await fetch(
84+
`${ITWIN_API_URL}/accesscontrol/itwins/${itwinId}/shares`,
85+
{
86+
headers: {
87+
Authorization: `Bearer ${accessToken}`,
88+
Accept: "application/vnd.bentley.itwin-platform.v2+json",
89+
},
90+
},
91+
);
92+
93+
if (!resp.ok) {
94+
throw new Error(
95+
`Error getting iTwin shares for ${itwinId}. Status: ${resp.status}`,
96+
);
97+
}
98+
99+
const result = await resp.json();
100+
return result.shares;
101+
}
102+
103+
/**
104+
* The iTwin API doesn't provide any way to save metadata with a share key.
105+
* We would like a way to match keys to release versions to avoid creating duplicates
106+
* or deleting keys that may still be in use. This could be because the GH job was re-run
107+
* or we do a patch release where the key doesn't have to change
108+
*
109+
* @param {Share[]} shares
110+
* @param {number} currentMajorVersion
111+
*/
112+
function mapSharesToVersion(shares, currentMajorVersion) {
113+
// Tokens should last for 2 releases per our Release Guide/cycle
114+
// If current version is 1.139 and it's 2026-03-25
115+
// 1.138 should expire 2026-04-01 Current month + 1 === currentVersion - 1 (nextToExpire)
116+
// 1.139 should expire 2026-05-01 Current month + 2 === currentVersion (nextToExpire + 1)
117+
// 1.140 should expire 2026-06-01 Current month + 3 === currentVersion + 1 (nextToExpire + 2)
118+
119+
const today = new Date();
120+
121+
const nextToExpire = currentMajorVersion - 1;
122+
/** @type {Record<number, Share>} */
123+
const keyPerVersion = {};
124+
for (const share of shares) {
125+
const expiration = new Date(share.expiration);
126+
const monthsApart = getMonth(expiration) - getMonth(today);
127+
if (monthsApart === 0) {
128+
throw new Error("Unknown version for share key");
129+
}
130+
const forVersion = nextToExpire + monthsApart - 1;
131+
if (keyPerVersion[forVersion]) {
132+
throw new Error("Found multiple keys per version");
133+
}
134+
keyPerVersion[forVersion] = share;
135+
}
136+
return keyPerVersion;
137+
}
138+
139+
/**
140+
* @param {string} itwinId
141+
* @param {string} contractName Should be "SandCastle"
142+
* @param {string} expiration
143+
* @param {string} accessToken
144+
* @returns {Promise<Share>}
145+
*/
146+
async function createShare(itwinId, contractName, expiration, accessToken) {
147+
const resp = await fetch(
148+
`${ITWIN_API_URL}/accesscontrol/itwins/${itwinId}/shares`,
149+
{
150+
method: "POST",
151+
headers: {
152+
Authorization: `Bearer ${accessToken}`,
153+
Accept: "application/vnd.bentley.itwin-platform.v2+json",
154+
"Content-Type": "application/json",
155+
},
156+
body: JSON.stringify({
157+
shareContract: contractName,
158+
// setting this to null will default to the max length. For the SandCastle contract that's 365 days
159+
expiration: expiration,
160+
}),
161+
},
162+
);
163+
164+
if (!resp.ok) {
165+
throw new Error(
166+
`Error creating iTwin share for ${itwinId}. Status: ${resp.status}`,
167+
);
168+
}
169+
170+
const result = await resp.json();
171+
172+
return result.share;
173+
}
174+
175+
/**
176+
* Store a single access token to reuse for subsequent requests sisnce we have multiple itwins
177+
* @type {string | undefined}
178+
*/
179+
let accessToken;
180+
/** @type {Record<string, string>} */
181+
const cacheKeysPerItwin = {};
182+
183+
/**
184+
* @param {string} itwinId
185+
*/
186+
export async function getNewKeyForItwin(itwinId) {
187+
if (cacheKeysPerItwin[itwinId]) {
188+
console.log(" already generated for this itwin, reusing");
189+
// no need to go through the whole process of checking/generating for the same itwin
190+
return cacheKeysPerItwin[itwinId];
191+
}
192+
193+
if (accessToken === undefined) {
194+
accessToken = await getIMSToken(CLIENT_ID, CLIENT_SECRET);
195+
}
196+
197+
const existingShares = await getShares(itwinId, accessToken);
198+
const currentVersion = await getCurrentMinorVersion();
199+
const nextVersion = currentVersion + 1;
200+
const sharePerVersion = mapSharesToVersion(existingShares, currentVersion);
201+
202+
console.log(
203+
" creating new key for version",
204+
nextVersion,
205+
"for itwin",
206+
itwinId,
207+
);
208+
209+
if (sharePerVersion[nextVersion]) {
210+
console.log(" found existing key for version", nextVersion, "reusing it");
211+
cacheKeysPerItwin[itwinId] = sharePerVersion[nextVersion].shareKey;
212+
// If a share key already exists for the target version then just reuse it
213+
return sharePerVersion[nextVersion].shareKey;
214+
}
215+
216+
console.log("Generating new key for itwin", itwinId);
217+
const maxAllowedForContract = 10;
218+
if (existingShares.length >= maxAllowedForContract - 1) {
219+
throw new Error(`This itwin has too many share keys: ${itwinId}`);
220+
}
221+
222+
const expiration = setDate(add(new Date(), { months: 3 }), 1);
223+
const newShare = await createShare(
224+
itwinId,
225+
"SandCastle",
226+
expiration.toISOString(),
227+
accessToken,
228+
);
229+
230+
console.log(" new key generated");
231+
cacheKeysPerItwin[itwinId] = newShare.shareKey;
232+
return newShare.shareKey;
233+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { readFileSync, writeFileSync } from "node:fs";
2+
import { parse, print } from "recast";
3+
import * as espree from "espree";
4+
import * as prettier from "prettier";
5+
import * as babelPlugin from "prettier/plugins/babel";
6+
import * as estreePlugin from "prettier/plugins/estree";
7+
import esquery from "esquery";
8+
import { getReplacements } from "./replacements.js";
9+
import yargs from "yargs";
10+
import { hideBin } from "yargs/helpers";
11+
12+
/** @import {Replacement, NewValueFunction} from './replacements.js' */
13+
14+
const argv = await yargs(hideBin(process.argv))
15+
.options({
16+
update: {
17+
type: "boolean",
18+
description:
19+
"Use to force an update. By default this will behave as a dry run and replace with placeholders",
20+
},
21+
})
22+
.help(false)
23+
.version(false)
24+
.strict()
25+
.parse();
26+
27+
/**
28+
* Replace the value of a variable assignment's literal in an AST
29+
*
30+
* @param {object} ast Full AST for the file being processed
31+
* @param {string} selector an esquery selector to the literal to change
32+
* @param {string | NewValueFunction | undefined} newValue the new value to set
33+
*/
34+
async function replaceVariableValue(ast, selector, newValue) {
35+
if (!ast) {
36+
throw new Error("Missing ast");
37+
}
38+
if (!selector) {
39+
throw new Error("Missing selector");
40+
}
41+
if (!newValue) {
42+
throw new Error("Missing newValue");
43+
}
44+
45+
const foundNodes = esquery.query(ast, selector);
46+
if (!foundNodes || foundNodes.length === 0) {
47+
throw new Error(`Unable to find node for selector: ${selector}`);
48+
}
49+
if (foundNodes.length > 1) {
50+
console.error("selected", foundNodes);
51+
throw new Error(
52+
`Found too many nodes for selector: ${selector}. See above for the nodes selected`,
53+
);
54+
}
55+
56+
if (foundNodes[0].type !== "Literal") {
57+
console.error(foundNodes[0]);
58+
throw new Error(
59+
"Selected node that is not a Literal. See above for the node that was selected",
60+
);
61+
}
62+
63+
if (!argv.update) {
64+
console.log(" using fake value for dry-run");
65+
foundNodes[0].value = "fake-new-value";
66+
return;
67+
}
68+
69+
const existingValue = foundNodes[0].value;
70+
try {
71+
const actualNewValue =
72+
typeof newValue === "function"
73+
? await newValue(`${existingValue}`)
74+
: newValue;
75+
76+
if (actualNewValue === undefined) {
77+
// Skip if there is no new value. This can be used in the new value function to indicate no update
78+
console.log(" Skipping - no new value provided");
79+
return;
80+
}
81+
82+
foundNodes[0].value = actualNewValue;
83+
} catch (error) {
84+
console.error("Failed to generate new value:");
85+
console.error(error);
86+
foundNodes[0].value =
87+
"Failed to get new value, check the logs for more info";
88+
}
89+
}
90+
91+
/**
92+
* Format code with Prettier
93+
*
94+
* @param {string} code the code to format
95+
*/
96+
async function formatCode(code) {
97+
const formatted = await prettier.format(code, {
98+
parser: "babel",
99+
plugins: [babelPlugin, estreePlugin],
100+
});
101+
return formatted;
102+
}
103+
104+
/**
105+
* Process a replacement for a file. Reads in the file and writes it back out to the same location
106+
*
107+
* @param {Replacement} replacement The replacement to process
108+
*/
109+
async function processReplacement(replacement) {
110+
const { filePath, selector, newValue } = replacement;
111+
console.log("Processing", filePath);
112+
const code = readFileSync(filePath, "utf-8");
113+
114+
const ast = parse(code, {
115+
parser: {
116+
parse: (js, opts) => espree.parse(js, { ...opts, ecmaVersion: 2022 }),
117+
tokenize: (js, opts) =>
118+
espree.tokenize(js, { ...opts, ecmaVersion: 2022 }),
119+
},
120+
});
121+
122+
await replaceVariableValue(ast, selector, newValue);
123+
124+
const output = print(ast).code;
125+
126+
writeFileSync(filePath, await formatCode(output));
127+
}
128+
129+
for (const replacement of getReplacements()) {
130+
await processReplacement(replacement);
131+
}

0 commit comments

Comments
 (0)