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

MI-85: Multi package managers & improvements #10

Merged
merged 11 commits into from
Nov 21, 2024
12 changes: 11 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,14 @@ jobs:
- uses: actions/checkout@v2
with:
ref: ${{ github.event.release.target_commitish }}
- run: docker build --build-arg NODE_TAG=20 .
- uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Build pipeline code
run: |
npm ci
npm run build
- name: Trim down devDependencies
run: npm prune --production
TheOrangePuff marked this conversation as resolved.
Show resolved Hide resolved
- name: Build docker image
run: docker build --build-arg NODE_TAG=20 .
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
node_modules
dist
.vscode/settings.json
18 changes: 9 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
ARG NODE_TAG
ARG NODE_TAG=latest
FROM node:${NODE_TAG}-alpine
kai-nguyen-aligent marked this conversation as resolved.
Show resolved Hide resolved

RUN mkdir /pipe
WORKDIR /pipe

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

COPY tsconfig.json ./
COPY pack*.json ./
RUN npm ci
COPY entrypoint.sh ./
COPY pipe ./pipe
RUN chmod a+x ./pipe/*.ts entrypoint.sh
COPY node_modules ./node_modules
COPY dist/ ./
COPY entrypoint.sh package.json ./
kai-nguyen-aligent marked this conversation as resolved.
Show resolved Hide resolved

RUN chmod a+x ./**/*.js entrypoint.sh

kai-nguyen-aligent marked this conversation as resolved.
Show resolved Hide resolved
ENTRYPOINT ["/pipe/entrypoint.sh"]
68 changes: 51 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Aligent Nx Serverless Deploy Pipe
# Aligent Serverless Deploy Pipe

This pipe is used to deploy multiple Serverless Framework applications in an Nx monorepo.
This pipe is used to deploy:

- Multiple Serverless Framework applications in an Nx monorepo.
- Single Serverless Framework application in a polyrepo.

## YAML Definition

Expand Down Expand Up @@ -37,21 +40,27 @@ Add the following your `bitbucket-pipelines.yml` file:

## Monorepo structure

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.
The pipe expects:

1. A single `nx.json` file at the root folder.
2. Each application to be under a folder called `services`, and to have a `serverless.yml` and a `project.json` files in its own folder.

```
services
- application-one
- serverless.yml
- project.json
... other files
- application-two
- serverless.yml
- project.json
... other files
.
├── nx.json
├── services/
│ ├── application-one/
│ │ ├── serverless.yml
│ │ ├── project.json
│ │ └── ...other files
│ └── application-two/
│ ├── serverless.yml
│ ├── project.json
│ └── ...other files
└── ...other files
```

Each application should also have a `project.json` file defining an `nx` target called `deploy`, which implements serverless deploy.
The `project.json` file defining an `nx` target called `deploy`, which implements serverless deployment command:

```json
{
Expand All @@ -70,12 +79,36 @@ Each application should also have a `project.json` file defining an `nx` target
}
```

## Polyrepo structure

The pipe expects:

1. No `nx.json` file at the root folder.
2. A `serverless.yml` file at the root folder.

```
.
├── serverless.yml
├── src/
│ └── ...other files
└── ...other files
```

## Development

To build the image locally: \
`docker build --build-arg="NODE_TAG=18-alpine" -t aligent/nx-pipe:18-alpine .`
1. build the image locally:

```bash
# Transpile our source code to Javascript
npm run build
# [Optional] Run this if we want to remove devDependencies from node_modules before building docker image
# If we run this, we will need to re-run `npm ci` later on if we fix bug & want to build another image.
npm prune --production
# Build docker image
docker build --build-arg="NODE_TAG=20" -t aligent/nx-pipe:20-alpine .
```

To run the container locally and mount current local directory to the /app/work folder:
2. Run the container locally and mount current local directory to the /app/work folder:

```bash
docker run -it --memory=4g --memory-swap=4g --memory-swappiness=0 --cpus=4 --entrypoint=/bin/sh \
Expand All @@ -89,7 +122,8 @@ docker run -it --memory=4g --memory-swap=4g --memory-swappiness=0 --cpus=4 --ent
-e UPLOAD_BADGE=false \
-e APP_USERNAME=test-app-username \
-e APP_PASSWORD=test-app-password \
aligent/nx-pipe:18-alpine
-e DEBUG=true \
aligent/nx-pipe:20-alpine
```

## See also
Expand Down
92 changes: 92 additions & 0 deletions bin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import chalk from 'chalk';
import logSymbols from 'log-symbols';
import { runCLICommand } from '../lib/cmd';
import { env } from '../lib/env';
import { nodeModulesDirectoryExist } from '../lib/findNodeModules';
import { findServerlessYaml } from '../lib/findServerlessYaml';
import { injectCfnRole } from '../lib/injectCfnRole';
import {
detectPackageManager,
getInstallCommand,
} from '../lib/packageManagers';
import { isNxServerlessMonorepo } from '../lib/serverlessProjectType';
import { uploadDeploymentBadge } from '../lib/uploadDeploymentBadge';

async function main() {
let deploymentStatus = false;

try {
const {
awsAccessKeyId,
awsSecretAccessKey,
bitbucketCloneDir,
cfnRole,
cmd,
debug,
profile,
servicesPath,
stage,
} = env;

if (!awsAccessKeyId || !awsSecretAccessKey) {
throw new Error(
'AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not set'
);
}

const packageManager = detectPackageManager(bitbucketCloneDir);
const isMonorepo = await isNxServerlessMonorepo(bitbucketCloneDir);

const searchDirectory = isMonorepo
? `${bitbucketCloneDir}/${servicesPath}`
: bitbucketCloneDir;

let serverlessFiles = await findServerlessYaml(searchDirectory);

await Promise.all(
serverlessFiles.map((file) => injectCfnRole(file, cfnRole))
);

const commands = [
`npx serverless config credentials --provider aws --profile ${profile} --key ${awsAccessKeyId} --secret ${awsSecretAccessKey}`,
];

const isNodeModulesExists = await nodeModulesDirectoryExist(
bitbucketCloneDir
);
if (!isNodeModulesExists) {
const installCmd = getInstallCommand(packageManager);
commands.unshift(installCmd);
}

const baseCommand = isMonorepo
? `npx nx run-many -t ${cmd} --`
: `npx serverless ${cmd}`;
const verbose = debug ? '--verbose' : '';

commands.push(
`${baseCommand} --stage ${stage} --aws-profile ${profile} ${verbose}`
);

await runCLICommand(commands, bitbucketCloneDir);

deploymentStatus = true;
} catch (error) {
if (error instanceof Error) {
console.error(logSymbols.error, chalk.redBright(error.message));
}
console.error(
logSymbols.error,
chalk.redBright(
'Deployment failed! Please check the logs for more details.'
)
);
deploymentStatus = false;
} finally {
const statusCode = await uploadDeploymentBadge(deploymentStatus);
process.exit(statusCode);
}
}

// Execute the main function
main();
3 changes: 1 addition & 2 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#!/bin/sh
#set -x

cd /pipe
npx ts-node pipe/entrypoint.ts
node /pipe/bin/index.js
76 changes: 42 additions & 34 deletions pipe/cmd.ts → lib/cmd.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
import * as cp from 'child_process';
import chalk from 'chalk';
import { SpawnOptions, spawn } from 'child_process';
import logSymbols from 'log-symbols';
import { env } from './env';

interface Command {
command: string;
args: ReadonlyArray<string>;
}

function splitCommandAndArgs(command: string): Command {
// Split the command string at all white spaces excluding white spaces wrapped with single quotes
const cmd = command.split(/\s(?=(?:[^']*'[^']*')*[^']*$)/g);
return {
command: cmd.shift() as string,
args: cmd,
};
}

// Wrap spawn in a promise
function asyncSpawn(
command: string,
args?: ReadonlyArray<string>,
options?: cp.SpawnOptionsWithoutStdio
args: ReadonlyArray<string>,
options: SpawnOptions
): Promise<number | null> {
return new Promise(function (resolve, reject) {
const process = cp.spawn(command, args, options);
if (env.debug)
if (env.debug) {
const commandWithArgs = `${command} ${args?.join(' ')}`;
const optionsStr = JSON.stringify(options);
console.log(
`ℹ️ Executing command: ${command} ${args?.join(
' '
)} with options: ${JSON.stringify(options)}`
logSymbols.info,
chalk.whiteBright(
`Executing command: ${commandWithArgs} with options: ${optionsStr}`
)
);
}

process.stdout.on('data', (data) => {
console.log(data.toString());
});

process.stderr.on('data', (data) => {
console.log(`Error: ${data.toString()}`);
});
const process = spawn(command, args, options);

process.on('exit', function (code) {
if (code !== 0) reject(code);
Expand All @@ -34,32 +47,27 @@ function asyncSpawn(
});
});
}
interface Command {
command: string;
args: ReadonlyArray<string>;
}

function splitCommandAndArgs(command: string): Command {
// Split the command string at all white spaces excluding white spaces wrapped with single quotes
const cmd = command.split(/\s(?=(?:[^']*'[^']*')*[^']*$)/g);
return {
command: cmd.shift() as string,
args: cmd,
};
}

function runCommandString(
command: string,
workDir?: string
workDir: string
): Promise<number | null> {
console.log(`Running command: ${command}`);
console.log(
logSymbols.info,
chalk.whiteBright(`Running command: ${command}`)
);
const cmd = splitCommandAndArgs(command);
return asyncSpawn(cmd.command, cmd.args, { cwd: workDir });
return asyncSpawn(cmd.command, cmd.args, {
cwd: workDir,
stdio: ['pipe', 'inherit', 'inherit'],
});
}

export async function runCLICommand(commandStr: Array<string>) {
const workDir = process.env.BITBUCKET_CLONE_DIR;
console.log(`Running commands in ${workDir}`);
export async function runCLICommand(
commandStr: Array<string>,
workDir: string
) {
console.log(logSymbols.info, chalk.white(`Running commands in ${workDir}`));

for (const cmd of commandStr) {
await runCommandString(cmd, workDir);
Expand Down
6 changes: 4 additions & 2 deletions pipe/env.ts → lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ interface Env {
appPassword?: string;
timezone: string;
bitbucketBranch?: string;
bitbucketCloneDir: string;
bitbucketRepoSlug?: string;
bitbucketWorkspace?: string;
servicesPath?: string;
servicesPath: string;
}

export const env: Env = {
Expand All @@ -29,7 +30,8 @@ export const env: Env = {
appPassword: process.env.APP_PASSWORD,
timezone: process.env.TIMEZONE || 'Australia/Adelaide',
bitbucketBranch: process.env.BITBUCKET_BRANCH,
bitbucketCloneDir: process.env.BITBUCKET_CLONE_DIR || '',
bitbucketRepoSlug: process.env.BITBUCKET_REPO_SLUG,
bitbucketWorkspace: process.env.BITBUCKET_WORKSPACE,
servicesPath: process.env.servicesPath || '/services',
servicesPath: process.env.servicesPath || 'services',
};
8 changes: 4 additions & 4 deletions pipe/findNodeModules.ts → lib/findNodeModules.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as fs from 'fs';
import fs from 'fs';

export async function nodeModulesDirectoryExist(
directoryPath: string
Expand All @@ -7,10 +7,10 @@ export async function nodeModulesDirectoryExist(
.access(`${directoryPath}/node_modules`, fs.constants.F_OK)
.then(() => true)
.catch((error) => {
if (error.code === 'ENOENT') {
return false;
} else {
if (error.code !== 'ENOENT') {
throw error;
}

return false;
});
}
Loading
Loading