Skip to content

Commit

Permalink
feat(node-project): Added ability to provide scoped private packages (p…
Browse files Browse the repository at this point in the history
…rojen#1688)

Currently there is no way for the packages that have some dependencies that are private to use projen. Thia allows scoped packages (eg, `@myscope`) to have different registryUrl and have code artifact login.

---
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
  • Loading branch information
AminFazlMondo authored Apr 12, 2022
1 parent cd2f1e0 commit bde910c
Show file tree
Hide file tree
Showing 8 changed files with 911 additions and 63 deletions.
117 changes: 83 additions & 34 deletions docs/api/API.md

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions docs/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,33 @@ TODO
## Releases (CI/CD)

TODO

## Features

### Scoped Private Packages

Scoped private packages can be configured in this project and its ancestors.

All npm packages have a name. Some package names also have a scope. A scope follows the usual rules for package names (URL-safe characters, no leading dots or underscores). When used in package names, scopes are preceded by an @ symbol and followed by a slash, e.g. `@somescope/somepackagename`

This feature supports specifying options on how package managers should access packages in each of the scopes. If no options are specified, npm or yarn will try to install scoped packages from the public npm registry.

Currently, it only supports fetching packages from AWS CodeArtifact, either by directly access via credentials or by assuming a role using the specified credentials. Credentials must be provided in the CodeArtifactOptions property.

Multiple scoped package options may be specified if required.

example
```js
const { javascript } = require('projen');
const project = new javascript.NodeProject({
defaultReleaseBranch: 'main',
name: 'my-project',
scopedPackagesOptions: [
{
registryUrl: '<code-artifact-registry-url>',
scope: '@somescope',
}
]
});
project.synth();
```
3 changes: 3 additions & 0 deletions src/github/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function secretToString(secretName: string): string {
return `\${{ secrets.${secretName} }}`;
}
120 changes: 113 additions & 7 deletions src/javascript/node-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Project } from "../project";
import { isAwsCodeArtifactRegistry } from "../release";
import { Task } from "../task";
import { exec, isTruthy, sorted, writeFile } from "../util";
import { extractCodeArtifactDetails } from "./util";

const UNLICENSED = "UNLICENSED";
const DEFAULT_NPM_REGISTRY_URL = "https://registry.npmjs.org/";
Expand Down Expand Up @@ -281,11 +282,19 @@ export interface NodePackageOptions {
readonly npmTokenSecret?: string;

/**
* Options for publishing npm package to AWS CodeArtifact.
* Options for npm packages using AWS CodeArtifact.
* This is required if publishing packages to, or installing scoped packages from AWS CodeArtifact
*
* @default - undefined
*/
readonly codeArtifactOptions?: CodeArtifactOptions;

/**
* Options for privately hosted scoped packages
*
* @default - fetch all scoped packages from the public npm registry
*/
readonly scopedPackagesOptions?: ScopedPackagesOptions[];
}

export interface CodeArtifactOptions {
Expand Down Expand Up @@ -314,6 +323,23 @@ export interface CodeArtifactOptions {
readonly roleToAssume?: string;
}

/**
* Options for scoped packages
*/
export interface ScopedPackagesOptions {
/**
* Scope of the packages
*
* @example "@angular"
*/
readonly scope: string;

/**
* URL of the registry for scoped packages
*/
readonly registryUrl: string;
}

/**
* Represents the npm `package.json` file.
*/
Expand Down Expand Up @@ -376,12 +402,20 @@ export class NodePackage extends Component {
public readonly npmTokenSecret?: string;

/**
* Options for publishing npm package to AWS CodeArtifact.
* Options for npm packages using AWS CodeArtifact.
* This is required if publishing packages to, or installing scoped packages from AWS CodeArtifact
*
* @default - undefined
*/
readonly codeArtifactOptions?: CodeArtifactOptions;

/**
* Options for privately hosted scoped packages
*
* @default undefined
*/
readonly scopedPackagesOptions?: ScopedPackagesOptions[];

/**
* npm package access level.
*/
Expand Down Expand Up @@ -420,15 +454,19 @@ export class NodePackage extends Component {
npmRegistryUrl,
npmTokenSecret,
codeArtifactOptions,
scopedPackagesOptions,
} = this.parseNpmOptions(options);
this.npmAccess = npmAccess;
this.npmRegistry = npmRegistry;
this.npmRegistryUrl = npmRegistryUrl;
this.npmTokenSecret = npmTokenSecret;
this.codeArtifactOptions = codeArtifactOptions;
this.scopedPackagesOptions = scopedPackagesOptions;

this.processDeps(options);

this.addCodeArtifactLoginScript();

const prev = this.readPackageJson() ?? {};

// empty objects are here to preserve order for backwards compatibility
Expand Down Expand Up @@ -792,6 +830,10 @@ export class NodePackage extends Component {
}

const isAwsCodeArtifact = isAwsCodeArtifactRegistry(npmRegistryUrl);
const hasScopedPackage =
options.scopedPackagesOptions &&
options.scopedPackagesOptions.length !== 0;

if (isAwsCodeArtifact) {
if (options.npmTokenSecret) {
throw new Error(
Expand All @@ -800,19 +842,20 @@ export class NodePackage extends Component {
}
} else {
if (
options.codeArtifactOptions?.accessKeyIdSecret ||
options.codeArtifactOptions?.secretAccessKeySecret ||
options.codeArtifactOptions?.roleToAssume
(options.codeArtifactOptions?.accessKeyIdSecret ||
options.codeArtifactOptions?.secretAccessKeySecret ||
options.codeArtifactOptions?.roleToAssume) &&
!hasScopedPackage
) {
throw new Error(
"codeArtifactOptions must only be specified when publishing AWS CodeArtifact."
"codeArtifactOptions must only be specified when publishing AWS CodeArtifact or used in scoped packages."
);
}
}

// apply defaults for AWS CodeArtifact
let codeArtifactOptions: CodeArtifactOptions | undefined;
if (isAwsCodeArtifact) {
if (isAwsCodeArtifact || hasScopedPackage) {
codeArtifactOptions = {
accessKeyIdSecret:
options.codeArtifactOptions?.accessKeyIdSecret ?? "AWS_ACCESS_KEY_ID",
Expand All @@ -829,9 +872,72 @@ export class NodePackage extends Component {
npmRegistryUrl: npmr.href,
npmTokenSecret: defaultNpmToken(options.npmTokenSecret, npmr.hostname),
codeArtifactOptions,
scopedPackagesOptions: this.parseScopedPackagesOptions(
options.scopedPackagesOptions
),
};
}

private parseScopedPackagesOptions(
scopedPackagesOptions?: ScopedPackagesOptions[]
): ScopedPackagesOptions[] | undefined {
if (!scopedPackagesOptions) {
return undefined;
}

return scopedPackagesOptions.map((option): ScopedPackagesOptions => {
if (!isScoped(option.scope)) {
throw new Error(
`Scope must start with "@" in options, found ${option.scope}`
);
}

if (!isAwsCodeArtifactRegistry(option.registryUrl)) {
throw new Error(
`Only AWS Code artifact scoped registry is supported for now, found ${option.registryUrl}`
);
}

const result: ScopedPackagesOptions = {
registryUrl: option.registryUrl,
scope: option.scope,
};

return result;
});
}

private addCodeArtifactLoginScript() {
if (
!this.scopedPackagesOptions ||
this.scopedPackagesOptions.length === 0
) {
return;
}

this.project.addTask("ca:login", {
requiredEnv: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
steps: [
{ exec: "which aws" }, // check that AWS CLI is installed
...this.scopedPackagesOptions.map((scopedPackagesOption) => {
const { registryUrl, scope } = scopedPackagesOption;
const { domain, repository, region, accountId } =
extractCodeArtifactDetails(registryUrl);
// reference: https://docs.aws.amazon.com/codeartifact/latest/ug/npm-auth.html
const commands = [
`npm config set ${scope}:registry ${registryUrl}`,
`CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token --domain ${domain} --repository ${repository} --region ${region} --domain-owner ${accountId})`,
`npm config set //${registryUrl}:_authToken=$CODEARTIFACT_AUTH_TOKEN`,
`npm config set //${registryUrl}:always-auth=true`,
];
return {
exec: commands.join("; "),
};
}),
],
});
}

private addNodeEngine() {
if (!this.minNodeVersion && !this.maxNodeVersion) {
return;
Expand Down
89 changes: 82 additions & 7 deletions src/javascript/node-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
GitIdentity,
} from "../github";
import { DEFAULT_GITHUB_ACTIONS_USER } from "../github/constants";
import { secretToString } from "../github/util";
import { JobStep, Triggers } from "../github/workflows-model";
import { IgnoreFile } from "../ignore-file";
import {
Expand All @@ -18,13 +19,19 @@ import {
UpgradeDependenciesOptions,
} from "../javascript";
import { License } from "../license";
import { Publisher, Release, ReleaseProjectOptions } from "../release";
import {
isAwsCodeArtifactRegistry,
Publisher,
Release,
ReleaseProjectOptions,
} from "../release";
import { Task } from "../task";
import { deepMerge } from "../util";
import { Version } from "../version";
import { Bundler, BundlerOptions } from "./bundler";
import { Jest, JestOptions } from "./jest";
import {
CodeArtifactOptions,
NodePackage,
NodePackageManager,
NodePackageOptions,
Expand Down Expand Up @@ -563,15 +570,20 @@ export class NodeProject extends GitHubProject {
this.publisher = this.release.publisher;

if (options.releaseToNpm ?? false) {
const codeArtifactOptions = isAwsCodeArtifactRegistry(
this.package.npmRegistry
)
? {
accessKeyIdSecret: options.codeArtifactOptions?.accessKeyIdSecret,
secretAccessKeySecret:
options.codeArtifactOptions?.secretAccessKeySecret,
roleToAssume: options.codeArtifactOptions?.roleToAssume,
}
: {};
this.release.publisher.publishToNpm({
registry: this.package.npmRegistry,
npmTokenSecret: this.package.npmTokenSecret,
codeArtifactOptions: {
accessKeyIdSecret: options.codeArtifactOptions?.accessKeyIdSecret,
secretAccessKeySecret:
options.codeArtifactOptions?.secretAccessKeySecret,
roleToAssume: options.codeArtifactOptions?.roleToAssume,
},
codeArtifactOptions,
});
}
} else {
Expand Down Expand Up @@ -746,6 +758,63 @@ export class NodeProject extends GitHubProject {
this.package.addKeywords(...keywords);
}

/**
* Get steps for scoped package access
*
* @param codeArtifactOptions Details of logging in to AWS
* @returns array of job steps required for each private scoped packages
*/
private getScopedPackageSteps(
codeArtifactOptions: CodeArtifactOptions | undefined
): JobStep[] {
const parsedCodeArtifactOptions = {
accessKeyIdSecret:
codeArtifactOptions?.accessKeyIdSecret ?? "AWS_ACCESS_KEY_ID",
secretAccessKeySecret:
codeArtifactOptions?.secretAccessKeySecret ?? "AWS_SECRET_ACCESS_KEY",
roleToAssume: codeArtifactOptions?.roleToAssume,
};

if (parsedCodeArtifactOptions.roleToAssume) {
return [
{
name: "Configure AWS Credentials",
uses: "aws-actions/configure-aws-credentials@v1",
with: {
"aws-access-key-id": secretToString(
parsedCodeArtifactOptions.accessKeyIdSecret
),
"aws-secret-access-key": secretToString(
parsedCodeArtifactOptions.secretAccessKeySecret
),
"aws-region": "us-east-2",
"role-to-assume": parsedCodeArtifactOptions.roleToAssume,
"role-duration-seconds": 900,
},
},
{
name: "AWS CodeArtifact Login",
run: `${this.runScriptCommand} ca:login`,
},
];
}

return [
{
name: "AWS CodeArtifact Login",
run: `${this.runScriptCommand} ca:login`,
env: {
AWS_ACCESS_KEY_ID: secretToString(
parsedCodeArtifactOptions.accessKeyIdSecret
),
AWS_SECRET_ACCESS_KEY: secretToString(
parsedCodeArtifactOptions.secretAccessKeySecret
),
},
},
];
}

/**
* Returns the set of workflow steps which should be executed to bootstrap a
* workflow.
Expand Down Expand Up @@ -779,6 +848,12 @@ export class NodeProject extends GitHubProject {

const mutable = options.mutable ?? false;

if (this.package.scopedPackagesOptions) {
install.push(
...this.getScopedPackageSteps(this.package.codeArtifactOptions)
);
}

install.push({
name: "Install dependencies",
run: mutable
Expand Down
Loading

0 comments on commit bde910c

Please sign in to comment.