Skip to content

Commit 69f156b

Browse files
Merge pull request #10 from aligent/feature/MI-85-multi-package-managers
MI-85: Multi package managers & improvements
2 parents 65438f5 + 135c068 commit 69f156b

19 files changed

+832
-1524
lines changed

.github/workflows/build.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ jobs:
99
- uses: actions/checkout@v2
1010
with:
1111
ref: ${{ github.event.release.target_commitish }}
12-
- run: docker build --build-arg NODE_TAG=20 .
12+
- name: Build docker image
13+
run: docker build --build-arg NODE_TAG=20 .

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
node_modules
2+
dist
3+
.vscode/settings.json

Dockerfile

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
ARG NODE_TAG
2+
FROM node:${NODE_TAG}-alpine AS builder
3+
4+
# `WORKDIR` will create the folder if it doesn't exsist
5+
WORKDIR /build-stage
6+
COPY package*.json ./
7+
RUN npm ci
8+
COPY . ./
9+
RUN npm run build
10+
11+
# This remove dev dependencies from `node_modules` folder
12+
RUN npm prune --production
13+
214
FROM node:${NODE_TAG}-alpine
315

4-
RUN mkdir /pipe
516
WORKDIR /pipe
617

7-
RUN apk add wget
8-
RUN wget -P / https://bitbucket.org/bitbucketpipelines/bitbucket-pipes-toolkit-bash/raw/0.4.0/common.sh
18+
# The `--force` flag force replace `yarn` if it exist in base image
19+
# This ensure we have the latest version of package managers
20+
RUN npm install -g --force npm pnpm yarn
921

10-
COPY tsconfig.json ./
11-
COPY pack*.json ./
12-
RUN npm ci
13-
COPY entrypoint.sh ./
14-
COPY pipe ./pipe
15-
RUN chmod a+x ./pipe/*.ts entrypoint.sh
22+
COPY --from=builder /build-stage/node_modules ./node_modules
23+
COPY --from=builder /build-stage/dist/ ./
24+
COPY --from=builder /build-stage/entrypoint.sh ./
25+
26+
RUN chmod a+x entrypoint.sh
1627

1728
ENTRYPOINT ["/pipe/entrypoint.sh"]

README.md

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
# Aligent Nx Serverless Deploy Pipe
1+
# Aligent Serverless Deploy Pipe
22

3-
This pipe is used to deploy multiple Serverless Framework applications in an Nx monorepo.
3+
This pipe is used to deploy:
4+
5+
- Multiple Serverless Framework applications in an Nx monorepo.
6+
- Single Serverless Framework application in a polyrepo.
47

58
## YAML Definition
69

@@ -30,28 +33,37 @@ Add the following your `bitbucket-pipelines.yml` file:
3033
| UPLOAD_BADGE | Whether or not to upload a deployment badge to the repositories downloads section. (Accepted values: `true`/`false`) |
3134
| APP_USERNAME | The user to upload the badge as. Required if UPLOAD_BADGE is set to `true`. |
3235
| APP_PASSWORD | The app password of the user uploading the badge. Required if UPLOAD_BADGE is set to `true`. |
33-
| TIMEZONE | Which time zone the time in the badge should use (Default: 'Australia/Adelaide') |
36+
| TIMEZONE | Which time zone the time in the badge should use (Default: `Australia/Adelaide`) |
37+
| PROFILE | The profile name that is used for deployment (Default: `bitbucket-deployer`) |
38+
| CMD | The command that this pipe will run (Eg: `deploy`, `remove`. Default: `deploy`) |
39+
| SERVICES_PATH | The relative path from root folder to the folder where applications are defined (Default: `services`) |
3440

3541
- Default pipelines variables that are available for builds: https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/
3642
- Please check: https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/ for how to generate an app password.
3743

3844
## Monorepo structure
3945

40-
The pipe expects each application to be under a root folder called `services`, and to have a `serverless.yml` file in its own root folder.
46+
The pipe expects:
47+
48+
1. A single `nx.json` file at the root folder.
49+
2. Each application to be under a folder as defined via `SERVICES_PATH` variable above (default as `services`), and to have a `serverless.yml` and a `project.json` files in its own folder.
4150

4251
```
43-
services
44-
- application-one
45-
- serverless.yml
46-
- project.json
47-
... other files
48-
- application-two
49-
- serverless.yml
50-
- project.json
51-
... other files
52+
.
53+
├── nx.json
54+
├── services/
55+
│ ├── application-one/
56+
│ │ ├── serverless.yml
57+
│ │ ├── project.json
58+
│ │ └── ...other files
59+
│ └── application-two/
60+
│ ├── serverless.yml
61+
│ ├── project.json
62+
│ └── ...other files
63+
└── ...other files
5264
```
5365
54-
Each application should also have a `project.json` file defining an `nx` target called `deploy`, which implements serverless deploy.
66+
The `project.json` file defining an Nx target called `deploy`, which implements serverless deployment command:
5567
5668
```json
5769
{
@@ -70,12 +82,36 @@ Each application should also have a `project.json` file defining an `nx` target
7082
}
7183
```
7284

85+
## Polyrepo structure
86+
87+
The pipe expects:
88+
89+
1. No `nx.json` file at the root folder.
90+
2. A `serverless.yml` file at the root folder.
91+
92+
```
93+
.
94+
├── serverless.yml
95+
├── src/
96+
│ └── ...other files
97+
└── ...other files
98+
```
99+
73100
## Development
74101

75-
To build the image locally: \
76-
`docker build --build-arg="NODE_TAG=18-alpine" -t aligent/nx-pipe:18-alpine .`
102+
1. build the image locally:
103+
104+
```bash
105+
# Transpile our source code to Javascript
106+
npm run build
107+
# [Optional] Run this if we want to remove devDependencies from node_modules before building docker image
108+
# If we run this, we will need to re-run `npm ci` later on if we fix bug & want to build another image.
109+
npm prune --production
110+
# Build docker image
111+
docker build --build-arg="NODE_TAG=20" -t aligent/nx-pipe:20-alpine .
112+
```
77113

78-
To run the container locally and mount current local directory to the /app/work folder:
114+
2. Run the container locally and mount current local directory to the /app/work folder:
79115

80116
```bash
81117
docker run -it --memory=4g --memory-swap=4g --memory-swappiness=0 --cpus=4 --entrypoint=/bin/sh \
@@ -89,7 +125,8 @@ docker run -it --memory=4g --memory-swap=4g --memory-swappiness=0 --cpus=4 --ent
89125
-e UPLOAD_BADGE=false \
90126
-e APP_USERNAME=test-app-username \
91127
-e APP_PASSWORD=test-app-password \
92-
aligent/nx-pipe:18-alpine
128+
-e DEBUG=true \
129+
aligent/nx-pipe:20-alpine
93130
```
94131

95132
## See also

bin/index.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import chalk from 'chalk';
2+
import logSymbols from 'log-symbols';
3+
import { runCLICommand } from '../lib/cmd';
4+
import { env } from '../lib/env';
5+
import { nodeModulesDirectoryExist } from '../lib/findNodeModules';
6+
import { findServerlessYaml } from '../lib/findServerlessYaml';
7+
import { injectCfnRole } from '../lib/injectCfnRole';
8+
import {
9+
detectPackageManager,
10+
getInstallCommand,
11+
} from '../lib/packageManagers';
12+
import { isNxServerlessMonorepo } from '../lib/serverlessProjectType';
13+
import { uploadDeploymentBadge } from '../lib/uploadDeploymentBadge';
14+
15+
async function main() {
16+
let deploymentStatus = false;
17+
18+
try {
19+
const {
20+
awsAccessKeyId,
21+
awsSecretAccessKey,
22+
bitbucketCloneDir,
23+
cfnRole,
24+
cmd,
25+
debug,
26+
profile,
27+
servicesPath,
28+
stage,
29+
} = env;
30+
31+
if (!awsAccessKeyId || !awsSecretAccessKey) {
32+
throw new Error(
33+
'AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not set'
34+
);
35+
}
36+
37+
const packageManager = detectPackageManager(bitbucketCloneDir);
38+
const isMonorepo = await isNxServerlessMonorepo(bitbucketCloneDir);
39+
40+
const searchDirectory = isMonorepo
41+
? `${bitbucketCloneDir}/${servicesPath}`
42+
: bitbucketCloneDir;
43+
44+
let serverlessFiles = await findServerlessYaml(searchDirectory);
45+
46+
await Promise.all(
47+
serverlessFiles.map((file) => injectCfnRole(file, cfnRole))
48+
);
49+
50+
const commands = [
51+
`npx serverless config credentials --provider aws --profile ${profile} --key ${awsAccessKeyId} --secret ${awsSecretAccessKey}`,
52+
];
53+
54+
const isNodeModulesExists = await nodeModulesDirectoryExist(
55+
bitbucketCloneDir
56+
);
57+
if (!isNodeModulesExists) {
58+
const installCmd = getInstallCommand(packageManager);
59+
commands.unshift(installCmd);
60+
}
61+
62+
const baseCommand = isMonorepo
63+
? `npx nx run-many -t ${cmd} --`
64+
: `npx serverless ${cmd}`;
65+
const verbose = debug ? '--verbose' : '';
66+
67+
commands.push(
68+
`${baseCommand} --stage ${stage} --aws-profile ${profile} ${verbose}`
69+
);
70+
71+
await runCLICommand(commands, bitbucketCloneDir);
72+
73+
deploymentStatus = true;
74+
} catch (error) {
75+
if (error instanceof Error) {
76+
console.error(logSymbols.error, chalk.redBright(error.message));
77+
}
78+
console.error(
79+
logSymbols.error,
80+
chalk.redBright(
81+
'Deployment failed! Please check the logs for more details.'
82+
)
83+
);
84+
deploymentStatus = false;
85+
} finally {
86+
const statusCode = await uploadDeploymentBadge(deploymentStatus);
87+
process.exit(statusCode);
88+
}
89+
}
90+
91+
// Execute the main function
92+
main();

entrypoint.sh

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
#!/bin/sh
22
#set -x
33

4-
cd /pipe
5-
npx ts-node pipe/entrypoint.ts
4+
node /pipe/bin/index.js

pipe/cmd.ts renamed to lib/cmd.ts

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,41 @@
1-
import * as cp from 'child_process';
1+
import chalk from 'chalk';
2+
import { SpawnOptions, spawn } from 'child_process';
3+
import logSymbols from 'log-symbols';
24
import { env } from './env';
35

6+
interface Command {
7+
command: string;
8+
args: ReadonlyArray<string>;
9+
}
10+
11+
function splitCommandAndArgs(command: string): Command {
12+
// Split the command string at all white spaces excluding white spaces wrapped with single quotes
13+
const cmd = command.split(/\s(?=(?:[^']*'[^']*')*[^']*$)/g);
14+
return {
15+
command: cmd.shift() as string,
16+
args: cmd,
17+
};
18+
}
19+
420
// Wrap spawn in a promise
521
function asyncSpawn(
622
command: string,
7-
args?: ReadonlyArray<string>,
8-
options?: cp.SpawnOptionsWithoutStdio
23+
args: ReadonlyArray<string>,
24+
options: SpawnOptions
925
): Promise<number | null> {
1026
return new Promise(function (resolve, reject) {
11-
const process = cp.spawn(command, args, options);
12-
if (env.debug)
27+
if (env.debug) {
28+
const commandWithArgs = `${command} ${args?.join(' ')}`;
29+
const optionsStr = JSON.stringify(options);
1330
console.log(
14-
`ℹ️ Executing command: ${command} ${args?.join(
15-
' '
16-
)} with options: ${JSON.stringify(options)}`
31+
logSymbols.info,
32+
chalk.whiteBright(
33+
`Executing command: ${commandWithArgs} with options: ${optionsStr}`
34+
)
1735
);
36+
}
1837

19-
process.stdout.on('data', (data) => {
20-
console.log(data.toString());
21-
});
22-
23-
process.stderr.on('data', (data) => {
24-
console.log(`Error: ${data.toString()}`);
25-
});
38+
const process = spawn(command, args, options);
2639

2740
process.on('exit', function (code) {
2841
if (code !== 0) reject(code);
@@ -34,32 +47,27 @@ function asyncSpawn(
3447
});
3548
});
3649
}
37-
interface Command {
38-
command: string;
39-
args: ReadonlyArray<string>;
40-
}
41-
42-
function splitCommandAndArgs(command: string): Command {
43-
// Split the command string at all white spaces excluding white spaces wrapped with single quotes
44-
const cmd = command.split(/\s(?=(?:[^']*'[^']*')*[^']*$)/g);
45-
return {
46-
command: cmd.shift() as string,
47-
args: cmd,
48-
};
49-
}
5050

5151
function runCommandString(
5252
command: string,
53-
workDir?: string
53+
workDir: string
5454
): Promise<number | null> {
55-
console.log(`Running command: ${command}`);
55+
console.log(
56+
logSymbols.info,
57+
chalk.whiteBright(`Running command: ${command}`)
58+
);
5659
const cmd = splitCommandAndArgs(command);
57-
return asyncSpawn(cmd.command, cmd.args, { cwd: workDir });
60+
return asyncSpawn(cmd.command, cmd.args, {
61+
cwd: workDir,
62+
stdio: ['pipe', 'inherit', 'inherit'],
63+
});
5864
}
5965

60-
export async function runCLICommand(commandStr: Array<string>) {
61-
const workDir = process.env.BITBUCKET_CLONE_DIR;
62-
console.log(`Running commands in ${workDir}`);
66+
export async function runCLICommand(
67+
commandStr: Array<string>,
68+
workDir: string
69+
) {
70+
console.log(logSymbols.info, chalk.white(`Running commands in ${workDir}`));
6371

6472
for (const cmd of commandStr) {
6573
await runCommandString(cmd, workDir);

pipe/env.ts renamed to lib/env.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,17 @@ interface Env {
1111
appPassword?: string;
1212
timezone: string;
1313
bitbucketBranch?: string;
14+
bitbucketCloneDir: string;
1415
bitbucketRepoSlug?: string;
1516
bitbucketWorkspace?: string;
16-
servicesPath?: string;
17+
servicesPath: string;
1718
}
1819

1920
export const env: Env = {
2021
debug: process.env.DEBUG === 'true',
2122
stage: process.env.STAGE || 'stg',
2223
profile: process.env.PROFILE || 'bitbucket-deployer',
23-
cmd: process.env.cmd || 'deploy',
24+
cmd: process.env.CMD || 'deploy',
2425
awsAccessKeyId: process.env.AWS_ACCESS_KEY_ID,
2526
awsSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
2627
cfnRole: process.env.CFN_ROLE,
@@ -29,7 +30,8 @@ export const env: Env = {
2930
appPassword: process.env.APP_PASSWORD,
3031
timezone: process.env.TIMEZONE || 'Australia/Adelaide',
3132
bitbucketBranch: process.env.BITBUCKET_BRANCH,
33+
bitbucketCloneDir: process.env.BITBUCKET_CLONE_DIR || '',
3234
bitbucketRepoSlug: process.env.BITBUCKET_REPO_SLUG,
3335
bitbucketWorkspace: process.env.BITBUCKET_WORKSPACE,
34-
servicesPath: process.env.servicesPath || '/services',
36+
servicesPath: process.env.SERVICES_PATH || 'services',
3537
};

0 commit comments

Comments
 (0)