Skip to content

Commit 1783c86

Browse files
Merge pull request #499 from DinoChiesa/downloader-revision
enable the Downloader to accept a revision or environment
2 parents f8fdb33 + 8a1ef18 commit 1783c86

File tree

6 files changed

+790
-503
lines changed

6 files changed

+790
-503
lines changed

NOTICE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
apigeelint
2-
Copyright (c) 2018-2024 Google LLC
2+
Copyright (c) 2018-2025 Google LLC
33

44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.

README.md

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ You can install apigeellint using npm. But, there is a minimum version of `npm`
4242
## Basic Usage
4343

4444
Help
45-
```
45+
46+
```sh
4647
apigeelint -h
4748
Usage: apigeelint [options]
4849

@@ -62,8 +63,9 @@ Options:
6263
--ignoreDirectives ignore any directives within XML files that disable warnings
6364
-h, --help output usage information
6465
```
66+
6567
Example:
66-
```
68+
```sh
6769
apigeelint -s sampleProxy/apiproxy -f table.js
6870
```
6971

@@ -75,7 +77,7 @@ Possible formatters are: "json.js" (the default), "stylish.js", "compact.js", "c
7577
## Examples
7678

7779
### Basic usage: ingest from a directory
78-
```
80+
```sh
7981
apigeelint -f table.js -s path/to/your/apiproxy
8082
```
8183

@@ -119,10 +121,10 @@ perform the export, which means it will work only with Apigee X or hybrid.
119121

120122
```
121123
# to download and then analyze a proxy bundle
122-
apigeelint -f table.js -d org:your-org-name,api:name-of-your-api-proxy
124+
apigeelint -f table.js -d org:ORG-NAME,api:name-of-your-api-proxy
123125
124126
# to download and then analyze a sharedflow bundle
125-
apigeelint -f table.js -d org:your-org-name,sf:name-of-your-shared-flow
127+
apigeelint -f table.js -d org:ORG-NAME,sf:name-of-your-shared-flow
126128
```
127129

128130
With this invocation, the tool will:
@@ -139,13 +141,25 @@ tool](https://cloud.google.com/sdk/gcloud) installed, and available on your
139141
path, this will fail.
140142

141143

142-
You can also specify a token you have obtained previously:
144+
#### Variations
143145

144-
```
145-
apigeelint -f table.js -d org:your-org-name,api:name-of-your-api-proxy,token:ACCESS_TOKEN_HERE
146-
```
146+
1. To tell apigeelint to skip invocation of `gcloud`, specify a token you have obtained previously:
147+
```sh
148+
apigeelint -f table.js -d org:ORG-NAME,api:NAME-OF-APIPROXY,token:ACCESS_TOKEN_HERE
149+
```
150+
151+
In this case, apigeelint does not try to use `gcloud` to obtain an access token.
147152

148-
In this case, apigeelint does not try to use `gcloud` to obtain an access token.
153+
2. To tell apigeelint to download a particular revision to scan, specify the `rev:` segment:
154+
```sh
155+
apigeelint -f table.js -d org:ORG-NAME,api:NAME-OF-APIPROXY,rev:4
156+
```
157+
158+
3. To tell apigeelint to download the latest revision that is deployed in a particular
159+
environment, specify the `env:` segment:
160+
```sh
161+
apigeelint -f table.js -d org:ORG-NAME,api:NAME-OF-APIPROXY,env:stg
162+
```
149163

150164

151165

@@ -530,7 +544,7 @@ Apigee customers should use [formal support channels](https://cloud.google.com/a
530544
531545
## License and Copyright
532546
533-
This material is [Copyright (c) 2018-2024 Google LLC](./NOTICE).
547+
This material is [Copyright (c) 2018-2025 Google LLC](./NOTICE).
534548
and is licensed under the [Apache 2.0 License](LICENSE).
535549
536550
## Disclaimer

cli.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22

33
/*
4-
Copyright 2019-2024 Google LLC
4+
Copyright 2019-2025 Google LLC
55
66
Licensed under the Apache License, Version 2.0 (the "License");
77
you may not use this file except in compliance with the License.
@@ -38,10 +38,14 @@ const findBundle = (p) => {
3838
// handle zipped bundles
3939
if (p.endsWith(".zip") && fs.existsSync(p) && fs.statSync(p).isFile()) {
4040
const tmpdir = tmp.dirSync({
41-
prefix: `apigeelint-${path.basename(p)}-`,
41+
prefix: `apigeelint-${path.basename(p)}`,
4242
keep: false,
43+
unsafeCleanup: true, // this does not seem to work in apigeelint
44+
});
45+
// make sure to cleanup when the process exits
46+
process.on("exit", function () {
47+
tmpdir.removeCallback();
4348
});
44-
//console.log(`tmpdir: ` + JSON.stringify(tmpdir));
4549
const zip = new AdmZip(p);
4650
zip.extractAllTo(tmpdir.name, false);
4751
const found = findBundle(tmpdir.name);

lib/package/downloader.js

Lines changed: 198 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright © 2024 Google LLC
2+
Copyright © 2024-2025 Google LLC
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -23,75 +23,228 @@ const fs = require("fs"),
2323
debug = require("debug")("apigeelint:download");
2424

2525
const downloadBundle = async (downloadSpec) => {
26-
// 0. validate the input. it should be org:ORGNAME,api:APINAME or org:ORGNAME,sf:SHAREDFLOWNAME
27-
let parts = downloadSpec.split(",");
28-
let invalidArgument = () => {
26+
// 0. validate the input. it should be one of the following formats:
27+
let validSegmentExamples = [
28+
"org:ORGNAME",
29+
"api:APINAME",
30+
"sf:SHAREDFLOWNAME",
31+
"rev:REVISION",
32+
"env:ENVIRONMENT",
33+
"org:ORGNAME,sf:SHAREDFLOWNAME,rev:REVISION",
34+
"org:ORGNAME,sf:SHAREDFLOWNAME,env:ENVIRONMENT",
35+
];
36+
37+
const segments = downloadSpec.split(",");
38+
39+
const invalidArgument = (casenum_for_diagnostics) => {
2940
console.log(
30-
"Specify the value in the form org:ORGNAME,api:APINAME or org:ORGNAME,sf:SHAREDFLOWNAME",
41+
`Invalid download argument (${downloadSpec}).\n` +
42+
"The value should be a set of 2 or more comma-separated segments of this form:\n " +
43+
validSegmentExamples.join("\n ") +
44+
"\n\n" +
45+
"Specify segments in any order. You must always specify the org.\n" +
46+
"Specify at least one of {api,sf}. Specify at most one of {rev,env}.\n" +
47+
"The token segment is optional. Multiple segments of the same type are not allowed.",
3148
);
3249
process.exit(1);
3350
};
34-
if (!parts || (parts.length != 2 && parts.length != 3)) {
35-
invalidArgument();
36-
}
37-
let orgparts = parts[0].split(":");
38-
if (!orgparts || orgparts.length != 2 || orgparts[0] != "org") {
39-
invalidArgument();
40-
}
41-
let assetparts = parts[1].split(":");
42-
if (!assetparts || assetparts.length != 2) {
43-
invalidArgument();
44-
}
45-
if (assetparts[0] != "api" && assetparts[0] != "sf") {
46-
invalidArgument();
51+
52+
if (!segments || segments.length < 2 || segments.length > 4) {
53+
invalidArgument(1);
4754
}
4855

49-
let providedToken = null;
50-
if (parts.length == 3) {
51-
let tokenParts = parts[2].split(":");
52-
if (!tokenParts || tokenParts.length != 2 || tokenParts[0] != "token") {
53-
invalidArgument();
56+
const processSegment = (acc, segment) => {
57+
const parts = segment.split(":");
58+
if (!parts || parts.length != 2) {
59+
invalidArgument(2);
5460
}
55-
providedToken = tokenParts[1];
61+
switch (parts[0]) {
62+
case "org":
63+
if (acc.org || !parts[1]) {
64+
invalidArgument(4);
65+
}
66+
acc.org = parts[1];
67+
break;
68+
case "api":
69+
case "sf":
70+
if (acc.assetName || !parts[1]) {
71+
invalidArgument(5);
72+
}
73+
acc.assetName = parts[1];
74+
acc.assetFlavor = parts[0];
75+
break;
76+
case "rev":
77+
if (acc.revision || acc.environment || !parts[1]) {
78+
invalidArgument(6);
79+
}
80+
acc.revision = parts[1];
81+
break;
82+
case "env":
83+
if (acc.revision || acc.environment || !parts[1]) {
84+
invalidArgument(7);
85+
}
86+
acc.environment = parts[1];
87+
break;
88+
case "token":
89+
if (acc.token || !parts[1]) {
90+
invalidArgument(8);
91+
}
92+
acc.token = parts[1];
93+
break;
94+
default:
95+
invalidArgument(3);
96+
break;
97+
}
98+
return acc;
99+
};
100+
101+
const digest = segments.reduce(processSegment, {});
102+
// make sure we got enough information
103+
if (!digest.assetName || !digest.assetFlavor || !digest.org) {
104+
invalidArgument(9);
56105
}
57106

58-
const execOptions = {
59-
// cwd: proxyDir, // I think i do not care
60-
encoding: "utf8",
61-
};
62107
try {
63-
// 1. use the provided token, or get a new one using gcloud. This may fail.
64-
let accessToken =
65-
providedToken ||
108+
// 1. figure the access token. Use the provided one, or try to get a new one
109+
// using gcloud, which may fail.
110+
const execOptions = {
111+
encoding: "utf8",
112+
};
113+
const accessToken =
114+
digest.token ||
66115
child_process.execSync("gcloud auth print-access-token", execOptions);
67-
// 2. inquire the revisions
68-
let flavor = assetparts[0] == "api" ? "apis" : "sharedflows";
69-
const urlbase = `https://apigee.googleapis.com/v1/organizations/${orgparts[1]}/${flavor}`;
116+
117+
// 2. set up some basic stuff.
118+
const collectionName = digest.assetFlavor == "api" ? "apis" : "sharedflows";
119+
const urlbase = `https://apigee.googleapis.com/v1/organizations/${digest.org}`;
70120
const headers = {
71121
Accept: "application/json",
72122
Authorization: `Bearer ${accessToken}`,
73123
};
74124

75-
let url = `${urlbase}/${assetparts[1]}/revisions`;
76-
let revisionsResponse = await fetch(url, { method: "GET", headers });
125+
const determineRevision = async () => {
126+
const rev = digest.revision,
127+
env = digest.environment;
128+
const getLatestRevision = async () => {
129+
const url = `${urlbase}/${collectionName}/${digest.assetName}/revisions`;
130+
const revisionsResponse = await fetch(url, { method: "GET", headers });
131+
if (!revisionsResponse.ok) {
132+
throw new Error(
133+
`HTTP error: ${revisionsResponse.status}, on GET ${url}`,
134+
);
135+
}
136+
const revisions = await revisionsResponse.json();
137+
revisions.sort((a, b) => a - b);
138+
return revisions[revisions.length - 1];
139+
};
140+
const getLatestDeployedRevision = async (environment) => {
141+
// find latest deployed revision in environment (could be more than one!)
142+
// verify that the environment exists
143+
let url = `${urlbase}/environments/${environment}`;
144+
const envResponse = await fetch(url, { method: "GET", headers });
145+
if (envResponse.status == 404) {
146+
throw new Error(
147+
`The environment ${environment} does not appear to exist`,
148+
);
149+
}
150+
if (!envResponse.ok) {
151+
throw new Error(
152+
`cannot inquire environment ${environment}, on GET ${url}`,
153+
);
154+
}
77155

78-
// 3. export the latest revision
79-
if (!revisionsResponse.ok) {
80-
throw new Error(`HTTP error: ${revisionsResponse.status}, on GET ${url}`);
156+
url = `${urlbase}/environments/${environment}/${collectionName}/${digest.assetName}/deployments`;
157+
const deploymentsResponse = await fetch(url, {
158+
method: "GET",
159+
headers,
160+
});
161+
if (!deploymentsResponse.ok) {
162+
throw new Error(
163+
`HTTP error: ${deploymentsResponse.status}, on GET ${url}`,
164+
);
165+
}
166+
const r = await deploymentsResponse.json();
167+
// {
168+
// "deployments": [
169+
// {
170+
// "environment": "eval",
171+
// "apiProxy": "vjwt-b292612131",
172+
// "revision": "3",
173+
// "deployStartTime": "1695131144728",
174+
// "proxyDeploymentType": "EXTENSIBLE"
175+
// }
176+
// ]
177+
// }
178+
if (!r.deployments || !r.deployments.length) {
179+
throw new Error(
180+
`That ${digest.assetFlavor} is not deployed in ${digest.environment}`,
181+
);
182+
}
183+
r.deployments.sort((a, b) => Number(a.revision) - Number(b.revision));
184+
return r.deployments[r.deployments.length - 1].revision;
185+
};
186+
187+
if (rev && env) {
188+
// both revision or environment specified
189+
throw new Error("overspecified arguments"); // should never happen
190+
}
191+
192+
if ((!rev && !env) || (rev && rev.toLowerCase() == "latest")) {
193+
// no revision or environment specified,
194+
// the keyword 'latest' is specified; get the latest revision (deployed or not).
195+
const rev = await getLatestRevision();
196+
console.log(`Downloading revision ${rev}`);
197+
return Number(rev);
198+
}
199+
200+
if (env) {
201+
// an environment is specified
202+
const rev = await getLatestDeployedRevision(env);
203+
console.log(`Downloading revision ${rev}`);
204+
return Number(rev);
205+
}
206+
207+
// a revision number is specified; return it.
208+
return !isNaN(rev) && Number(rev);
209+
};
210+
211+
// 3. determine the revision. Use the provided one, or select the right one.
212+
const revision = await determineRevision();
213+
214+
if (!revision || revision < 0) {
215+
throw new Error(`Invalid revision number`);
216+
}
217+
// 4. verify that the revision exists
218+
let url = `${urlbase}/${collectionName}/${digest.assetName}/revisions/${revision}`;
219+
const revisionResponse = await fetch(url, { method: "GET", headers });
220+
if (revisionResponse.status == 404) {
221+
throw new Error(
222+
`Revision ${revision} of ${digest.assetFlavor} ${digest.assetName} does not appear to exist`,
223+
);
224+
}
225+
if (!revisionResponse.ok) {
226+
throw new Error(
227+
`cannot inquire revision ${revision} of ${digest.assetFlavor} ${digest.assetName}, on GET ${url}`,
228+
);
81229
}
82-
const revisions = await revisionsResponse.json();
83-
revisions.sort((a, b) => a - b);
84-
const rev = revisions[revisions.length - 1];
85-
url = `${urlbase}/${assetparts[1]}/revisions/${rev}?format=bundle`;
86230

231+
// 5. export the revision.
232+
url = `${urlbase}/${collectionName}/${digest.assetName}/revisions/${revision}?format=bundle`;
87233
const tmpdir = tmp.dirSync({
88-
prefix: `apigeelint-download-${assetparts[0]}`,
234+
prefix: `apigeelint-download-${digest.assetFlavor}`,
89235
keep: false,
236+
unsafeCleanup: true, // this does not seem to work in apigeelint
90237
});
238+
// make sure to cleanup when the process exits
239+
process.on("exit", function () {
240+
tmpdir.removeCallback();
241+
});
242+
91243
const pathToDownloadedAsset = path.join(
92244
tmpdir.name,
93-
`${assetparts[1]}-rev${rev}.zip`,
245+
`${digest.assetName}-r${revision}.zip`,
94246
);
247+
95248
const stream = fs.createWriteStream(pathToDownloadedAsset);
96249
const { body } = await fetch(url, { method: "GET", headers });
97250
await finished(Readable.fromWeb(body).pipe(stream));

0 commit comments

Comments
 (0)