limgen
Version:
Infrastructure as Code generator
269 lines (231 loc) • 7.41 kB
text/typescript
import ejs from 'ejs';
import path from 'path';
import prompts from 'prompts';
import z from 'zod';
import fs from 'fs/promises';
import { initOptionsSchema } from '../../commands/init';
import { cliBoolean, cliEnum, cliInteger } from '../../schema';
import { fileExists } from '../../files';
import { BaseProjectInputOptions, readProjectMetadata } from '../../project';
import { execPromise } from '../../exec';
export const dependsOn = async (opts: FullstackAWSProjectOptions) => {
const files: string[] = [
'components/app-fargate.ts',
'components/cdn-cloudfront.ts',
'components/lb-alb-public.ts',
'components/vpc-public.ts',
'utils/deep-merge.ts',
'utils/prefixed.ts',
];
const packages = [
'@pulumi/aws',
'@pulumi/awsx',
'@pulumi/pulumi',
'zod',
];
if (opts.includeStorage) {
files.push('components/storage-s3.ts');
}
if (opts.includeDb) {
files.push('components/db-postgres-rds.ts');
packages.push('@pulumi/random');
}
if (opts.networkType === 'private') {
files.push('components/bastion-ec2.ts');
}
return {
files,
packages,
};
}
export const inputs = async () => {
return [
{
name: 'includeStorage',
message: 'Include storage',
schema: cliBoolean(),
},
{
name: 'includeDb',
message: 'Include a database',
schema: cliBoolean(),
},
{
name: 'networkType',
message: 'Network type',
schema: cliEnum(['public', 'private', 'unknown']).optional(),
},
{
name: 'storageAccess',
messsage: 'Storage access',
schema: cliEnum(['private', 'public', 'unknown']).optional(),
},
{
name: 'port',
message: 'Port to expose',
schema: cliInteger().optional().default('3000'),
}
]
}
export const collectInput = async (cmdArgs: z.infer<typeof initOptionsSchema>, projectArgs: any) => {
let includeStorage
if (projectArgs.includeStorage === 'unknown') {
const answer = await prompts(
{
type: 'confirm',
name: 'includeStorage',
message: 'Include storage?',
initial: true,
}
);
includeStorage = answer.includeStorage;
} else {
includeStorage = projectArgs.includeStorage === 'true';
}
let storageAccess = projectArgs.storageAccess;
if (includeStorage && projectArgs.storageAccess === 'unknown') {
const answer = await prompts(
{
type: 'select',
name: 'storageAccess',
message: 'Storage access',
choices: [
{ title: 'Public (useful for static content / media)', value: 'public' },
{ title: 'Private (better for sensitive / personal data)', value: 'private' },
],
initial: 0,
}
);
storageAccess = answer.storageAccess;
}
let includeDb;
if (projectArgs.includeDb === 'unknown') {
const answer = await prompts(
{
type: 'confirm',
name: 'includeDb',
message: 'Include a database?',
initial: true,
}
);
includeDb = answer.includeDb;
} else {
includeDb = projectArgs.includeDb === 'true';
}
let networkType = projectArgs.networkType;
if (projectArgs.networkType === 'unknown') {
const answer = await prompts(
{
type: 'select',
name: 'networkType',
message: 'Network type',
choices: [
{ title: 'Public (simpler, suitable for development)', value: 'public' },
{ title: 'Private (more secure, requires tunnel to access database)', value: 'private' },
],
initial: 0,
}
);
networkType = answer.networkType;
}
return {
includeStorage,
includeDb,
networkType,
port: projectArgs.port,
storageAccess,
};
}
export const envPull = async ({
projectName,
stack,
}: {
projectName: string;
stack: string;
}) => {
const metadata = await readProjectMetadata({ projectName });
const projectInputs = metadata.projectInputs as FullstackAWSProjectOptions;
const properties = {};
if (projectInputs.includeStorage) {
const { stdout: BUCKET_NAME } = await execPromise(
`pulumi stack output objectStorageBucket --stack ${stack}`,
{ cwd: path.join('infrastructure', 'projects', projectName) });
Object.assign(properties, { BUCKET_NAME });
}
if (projectInputs.includeDb) {
const { stdout: secretId } = await execPromise(
`pulumi stack output dbSecret --stack ${stack}`,
{ cwd: path.join('infrastructure', 'projects', projectName) });
const { stdout: secretResponse } = await execPromise(`aws secretsmanager get-secret-value --secret-id ${secretId}`);
const DATABASE_URL = JSON.parse(secretResponse).SecretString;
if (!DATABASE_URL) {
throw new Error(`Could not get secret value for ${secretId}`);
}
Object.assign(properties, { DATABASE_URL });
}
await fs.writeFile('.env', Object.entries(properties).map(([key, value]) => `${key}=${value}`.trim()).join('\n'));
}
export type FullstackAWSProjectOptions = BaseProjectInputOptions & {
projectName: string;
includeStorage: boolean;
includeDb: boolean;
networkType: 'public' | 'private';
storageAccess: 'public' | 'private'|'unknown';
port: string;
};
export default async function fullstackAWSProject(inputs: FullstackAWSProjectOptions): Promise<string> {
const [indexContents] = await Promise.all([
renderIndex(inputs),
updateDockerignore(),
ensureTunnelScript(inputs),
]);
return indexContents;
}
const updateDockerignore = async () => {
if (await fileExists('.dockerignore')) {
// see if infrastructure is already in the .dockerignore file
const dockerignore = await fs.readFile('.dockerignore', 'utf-8');
if (!dockerignore.includes('infrastructure')) {
await fs.appendFile('.dockerignore', '\ninfrastructure\n');
}
} else {
await fs.writeFile('.dockerignore', `
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.env*
infrastructure/`.trim());
}
}
export const renderIndex = async (inputs: FullstackAWSProjectOptions) => {
return ejs.renderFile(path.join(__dirname, 'index.ejs.t'), inputs);
}
export const ensureTunnelScript = async (inputs: FullstackAWSProjectOptions) => {
if (!(inputs.includeDb && inputs.networkType === 'private')) {
return;
}
// copy the scripts from the scripts folder
await fs.mkdir('infrastructure/scripts', { recursive: true });
await fs.copyFile(path.join(__dirname, 'scripts', 'db-tunnel.sh'), 'infrastructure/scripts/db-tunnel.sh');
// does the current working directory contain a package.json file?
if (await fileExists('package.json')) {
// read the package.json file
const packageJson = JSON.parse(await fs.readFile('package.json', 'utf-8'));
// add the start script
packageJson.scripts = packageJson.scripts || {};
if (packageJson.scripts.tunnel) {
// TODO: Is there something better than a dead-end here we can help the user with?
// What if their tunnel script is different?
// What if it's outdated version and they want to update it?
// What they have multiple tunnels they need to manage?
return;
}
packageJson.scripts.tunnel = `infrastructure/scripts/db-tunnel.sh ${inputs.projectName}`;
// write the package.json file
await fs.writeFile('package.json', JSON.stringify(packageJson, null, 2));
}
}