Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enable the Downloader to accept a revision or environment #499

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion NOTICE
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
apigeelint
Copyright (c) 2018-2024 Google LLC
Copyright (c) 2018-2025 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ You can install apigeellint using npm. But, there is a minimum version of `npm`
## Basic Usage

Help
```
```sh
apigeelint -h
Usage: apigeelint [options]

Expand All @@ -62,8 +62,9 @@ Options:
--ignoreDirectives ignore any directives within XML files that disable warnings
-h, --help output usage information
```

Example:
```
```sh
apigeelint -s sampleProxy/apiproxy -f table.js
```

Expand All @@ -75,7 +76,7 @@ Possible formatters are: "json.js" (the default), "stylish.js", "compact.js", "c
## Examples

### Basic usage: ingest from a directory
```
```sh
apigeelint -f table.js -s path/to/your/apiproxy
```

Expand Down Expand Up @@ -119,10 +120,10 @@ perform the export, which means it will work only with Apigee X or hybrid.

```
# to download and then analyze a proxy bundle
apigeelint -f table.js -d org:your-org-name,api:name-of-your-api-proxy
apigeelint -f table.js -d org:ORG-NAME,api:name-of-your-api-proxy

# to download and then analyze a sharedflow bundle
apigeelint -f table.js -d org:your-org-name,sf:name-of-your-shared-flow
apigeelint -f table.js -d org:ORG-NAME,sf:name-of-your-shared-flow
```

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


You can also specify a token you have obtained previously:
#### Variations

```
apigeelint -f table.js -d org:your-org-name,api:name-of-your-api-proxy,token:ACCESS_TOKEN_HERE
```
1. To tell apigeelint to skip invocation of `gcloud`, specify a token you have obtained previously:
```sh
apigeelint -f table.js -d org:ORG-NAME,api:NAME-OF-APIPROXY,token:ACCESS_TOKEN_HERE
```

In this case, apigeelint does not try to use `gcloud` to obtain an access token.

In this case, apigeelint does not try to use `gcloud` to obtain an access token.
2. To tell apigeelint to download a particular revision to scan, specify the `rev:` segment:
```sh
apigeelint -f table.js -d org:ORG-NAME,api:NAME-OF-APIPROXY,rev:4
```

3. To tell apigeelint to download the latest revision that is deployed in a particular
environment, specify the `env:` segment:
```sh
apigeelint -f table.js -d org:ORG-NAME,api:NAME-OF-APIPROXY,env:stg
```



Expand Down Expand Up @@ -524,7 +537,7 @@ Apigee customers should use [formal support channels](https://cloud.google.com/a

## License and Copyright

This material is [Copyright (c) 2018-2024 Google LLC](./NOTICE).
This material is [Copyright (c) 2018-2025 Google LLC](./NOTICE).
and is licensed under the [Apache 2.0 License](LICENSE).

## Disclaimer
Expand Down
10 changes: 7 additions & 3 deletions cli.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

/*
Copyright 2019-2024 Google LLC
Copyright 2019-2025 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -38,10 +38,14 @@ const findBundle = (p) => {
// handle zipped bundles
if (p.endsWith(".zip") && fs.existsSync(p) && fs.statSync(p).isFile()) {
const tmpdir = tmp.dirSync({
prefix: `apigeelint-${path.basename(p)}-`,
prefix: `apigeelint-${path.basename(p)}`,
keep: false,
unsafeCleanup: true, // this does not seem to work in apigeelint
});
// make sure to cleanup when the process exits
process.on("exit", function () {
tmpdir.removeCallback();
});
//console.log(`tmpdir: ` + JSON.stringify(tmpdir));
const zip = new AdmZip(p);
zip.extractAllTo(tmpdir.name, false);
const found = findBundle(tmpdir.name);
Expand Down
243 changes: 198 additions & 45 deletions lib/package/downloader.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright © 2024 Google LLC
Copyright © 2024-2025 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -23,75 +23,228 @@ const fs = require("fs"),
debug = require("debug")("apigeelint:download");

const downloadBundle = async (downloadSpec) => {
// 0. validate the input. it should be org:ORGNAME,api:APINAME or org:ORGNAME,sf:SHAREDFLOWNAME
let parts = downloadSpec.split(",");
let invalidArgument = () => {
// 0. validate the input. it should be one of the following formats:
let validSegmentExamples = [
"org:ORGNAME",
"api:APINAME",
"sf:SHAREDFLOWNAME",
"rev:REVISION",
"env:ENVIRONMENT",
"org:ORGNAME,sf:SHAREDFLOWNAME,rev:REVISION",
"org:ORGNAME,sf:SHAREDFLOWNAME,env:ENVIRONMENT",
];

const segments = downloadSpec.split(",");

const invalidArgument = (casenum_for_diagnostics) => {
console.log(
"Specify the value in the form org:ORGNAME,api:APINAME or org:ORGNAME,sf:SHAREDFLOWNAME",
`Invalid download argument (${downloadSpec}).\n` +
"The value should be a set of 2 or more comma-separated segments of this form:\n " +
validSegmentExamples.join("\n ") +
"\n\n" +
"Specify segments in any order. You must always specify the org.\n" +
"Specify at least one of {api,sf}. Specify at most one of {rev,env}.\n" +
"The token segment is optional. Multiple segments of the same type are not allowed.",
);
process.exit(1);
};
if (!parts || (parts.length != 2 && parts.length != 3)) {
invalidArgument();
}
let orgparts = parts[0].split(":");
if (!orgparts || orgparts.length != 2 || orgparts[0] != "org") {
invalidArgument();
}
let assetparts = parts[1].split(":");
if (!assetparts || assetparts.length != 2) {
invalidArgument();
}
if (assetparts[0] != "api" && assetparts[0] != "sf") {
invalidArgument();

if (!segments || segments.length < 2 || segments.length > 4) {
invalidArgument(1);
}

let providedToken = null;
if (parts.length == 3) {
let tokenParts = parts[2].split(":");
if (!tokenParts || tokenParts.length != 2 || tokenParts[0] != "token") {
invalidArgument();
const processSegment = (acc, segment) => {
const parts = segment.split(":");
if (!parts || parts.length != 2) {
invalidArgument(2);
}
providedToken = tokenParts[1];
switch (parts[0]) {
case "org":
if (acc.org || !parts[1]) {
invalidArgument(4);
}
acc.org = parts[1];
break;
case "api":
case "sf":
if (acc.assetName || !parts[1]) {
invalidArgument(5);
}
acc.assetName = parts[1];
acc.assetFlavor = parts[0];
break;
case "rev":
if (acc.revision || acc.environment || !parts[1]) {
invalidArgument(6);
}
acc.revision = parts[1];
break;
case "env":
if (acc.revision || acc.environment || !parts[1]) {
invalidArgument(7);
}
acc.environment = parts[1];
break;
case "token":
if (acc.token || !parts[1]) {
invalidArgument(8);
}
acc.token = parts[1];
break;
default:
invalidArgument(3);
break;
}
return acc;
};

const digest = segments.reduce(processSegment, {});
// make sure we got enough information
if (!digest.assetName || !digest.assetFlavor || !digest.org) {
invalidArgument(9);
}

const execOptions = {
// cwd: proxyDir, // I think i do not care
encoding: "utf8",
};
try {
// 1. use the provided token, or get a new one using gcloud. This may fail.
let accessToken =
providedToken ||
// 1. figure the access token. Use the provided one, or try to get a new one
// using gcloud, which may fail.
const execOptions = {
encoding: "utf8",
};
const accessToken =
digest.token ||
child_process.execSync("gcloud auth print-access-token", execOptions);
// 2. inquire the revisions
let flavor = assetparts[0] == "api" ? "apis" : "sharedflows";
const urlbase = `https://apigee.googleapis.com/v1/organizations/${orgparts[1]}/${flavor}`;

// 2. set up some basic stuff.
const collectionName = digest.assetFlavor == "api" ? "apis" : "sharedflows";
const urlbase = `https://apigee.googleapis.com/v1/organizations/${digest.org}`;
const headers = {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
};

let url = `${urlbase}/${assetparts[1]}/revisions`;
let revisionsResponse = await fetch(url, { method: "GET", headers });
const determineRevision = async () => {
const rev = digest.revision,
env = digest.environment;
const getLatestRevision = async () => {
const url = `${urlbase}/${collectionName}/${digest.assetName}/revisions`;
const revisionsResponse = await fetch(url, { method: "GET", headers });
if (!revisionsResponse.ok) {
throw new Error(
`HTTP error: ${revisionsResponse.status}, on GET ${url}`,
);
}
const revisions = await revisionsResponse.json();
revisions.sort((a, b) => a - b);
return revisions[revisions.length - 1];
};
const getLatestDeployedRevision = async (environment) => {
// find latest deployed revision in environment (could be more than one!)
// verify that the environment exists
let url = `${urlbase}/environments/${environment}`;
const envResponse = await fetch(url, { method: "GET", headers });
if (envResponse.status == 404) {
throw new Error(
`The environment ${environment} does not appear to exist`,
);
}
if (!envResponse.ok) {
throw new Error(
`cannot inquire environment ${environment}, on GET ${url}`,
);
}

// 3. export the latest revision
if (!revisionsResponse.ok) {
throw new Error(`HTTP error: ${revisionsResponse.status}, on GET ${url}`);
url = `${urlbase}/environments/${environment}/${collectionName}/${digest.assetName}/deployments`;
const deploymentsResponse = await fetch(url, {
method: "GET",
headers,
});
if (!deploymentsResponse.ok) {
throw new Error(
`HTTP error: ${deploymentsResponse.status}, on GET ${url}`,
);
}
const r = await deploymentsResponse.json();
// {
// "deployments": [
// {
// "environment": "eval",
// "apiProxy": "vjwt-b292612131",
// "revision": "3",
// "deployStartTime": "1695131144728",
// "proxyDeploymentType": "EXTENSIBLE"
// }
// ]
// }
if (!r.deployments || !r.deployments.length) {
throw new Error(
`That ${digest.assetFlavor} is not deployed in ${digest.environment}`,
);
}
r.deployments.sort((a, b) => Number(a.revision) - Number(b.revision));
return r.deployments[r.deployments.length - 1].revision;
};

if (rev && env) {
// both revision or environment specified
throw new Error("overspecified arguments"); // should never happen
}

if ((!rev && !env) || (rev && rev.toLowerCase() == "latest")) {
// no revision or environment specified,
// the keyword 'latest' is specified; get the latest revision (deployed or not).
const rev = await getLatestRevision();
console.log(`Downloading revision ${rev}`);
return Number(rev);
}

if (env) {
// an environment is specified
const rev = await getLatestDeployedRevision(env);
console.log(`Downloading revision ${rev}`);
return Number(rev);
}

// a revision number is specified; return it.
return !isNaN(rev) && Number(rev);
};

// 3. determine the revision. Use the provided one, or select the right one.
const revision = await determineRevision();

if (!revision || revision < 0) {
throw new Error(`Invalid revision number`);
}
// 4. verify that the revision exists
let url = `${urlbase}/${collectionName}/${digest.assetName}/revisions/${revision}`;
const revisionResponse = await fetch(url, { method: "GET", headers });
if (revisionResponse.status == 404) {
throw new Error(
`Revision ${revision} of ${digest.assetFlavor} ${digest.assetName} does not appear to exist`,
);
}
if (!revisionResponse.ok) {
throw new Error(
`cannot inquire revision ${revision} of ${digest.assetFlavor} ${digest.assetName}, on GET ${url}`,
);
}
const revisions = await revisionsResponse.json();
revisions.sort((a, b) => a - b);
const rev = revisions[revisions.length - 1];
url = `${urlbase}/${assetparts[1]}/revisions/${rev}?format=bundle`;

// 5. export the revision.
url = `${urlbase}/${collectionName}/${digest.assetName}/revisions/${revision}?format=bundle`;
const tmpdir = tmp.dirSync({
prefix: `apigeelint-download-${assetparts[0]}`,
prefix: `apigeelint-download-${digest.assetFlavor}`,
keep: false,
unsafeCleanup: true, // this does not seem to work in apigeelint
});
// make sure to cleanup when the process exits
process.on("exit", function () {
tmpdir.removeCallback();
});

const pathToDownloadedAsset = path.join(
tmpdir.name,
`${assetparts[1]}-rev${rev}.zip`,
`${digest.assetName}-r${revision}.zip`,
);

const stream = fs.createWriteStream(pathToDownloadedAsset);
const { body } = await fetch(url, { method: "GET", headers });
await finished(Readable.fromWeb(body).pipe(stream));
Expand Down
Loading
Loading