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

feat: maintenance mode #1338

Merged
merged 11 commits into from
Mar 27, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";

const IP_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/([1-9]|[1-2][0-9]|3[1-2]))?$/;

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const MAINTENANCE_FILE_PATH = process.env.MAINTENANCE_FILE_PATH!;
const FILE_NAME = "maintenance";
const PATHS = [
`${MAINTENANCE_FILE_PATH}/${FILE_NAME}.disabled`,
`${MAINTENANCE_FILE_PATH}/${FILE_NAME}.enabled`,
];

export const getFilePath = (): string => {
for (const path of PATHS) {
if (existsSync(path)) {
return path;
}
}

// If the maintenance file wasn't found, create one
writeFileSync(PATHS[0], "");
return PATHS[0];
};

export const getFileContents = (): string => {
return readFileSync(getFilePath(), "utf-8");
};

export const setFileContents = (input: string): void => {
if (!validateIps(input)) throw new Error("List of IP addresses not valid");
const uniqueIps = [...new Set(input.split(","))].join(",");

writeFileSync(getFilePath(), uniqueIps, { encoding: "utf-8" });
};

export const updateFileContents = (input: string): void => {
if (!input) throw new Error("Nothing to update.");
if (!validateIps(input)) throw new Error("List of IP addresses not valid");

setFileContents(`${getFileContents()},${input}`);
};

export const getCurrentStatus = (): "disabled" | "enabled" => {
return inMaintenanceMode() ? "enabled" : "disabled";
};

export const inMaintenanceMode = (): boolean => {
return getFilePath().includes("enabled");
};

export const toggleMaintenanceStatus = () => {
const desiredStatus = inMaintenanceMode() ? "disabled" : "enabled";

renameSync(
getFilePath(),
`${MAINTENANCE_FILE_PATH}/${FILE_NAME}.${desiredStatus}`
);
};

const validateIps = (ipList: string) => {
const ips = ipList.split(",");
return ips.find(ip => !IP_REGEX.test(ip)) === undefined;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { getCurrentStatus, toggleMaintenanceStatus } from "./lib/file";

const MAINTENANCE_FILE_PATH = process.env.MAINTENANCE_FILE_PATH;

export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
if (!MAINTENANCE_FILE_PATH) throw new Error("a");
let body = "Method not implemented";
let status = 200;

switch (event.httpMethod) {
case "GET":
body = getCurrentStatus();
break;
case "POST":
body = changeMaintenanceStatus(extractDesiredStatusFromEvent(event));
break;
default:
status = 501;
}

return {
body: body,
statusCode: status,
};
};
const extractDesiredStatusFromEvent = (
event: APIGatewayProxyEvent
): "enabled" | "disabled" | undefined => {
if (event.resource.includes("enable")) return "enabled";
if (event.resource.includes("disable")) return "disabled";
return undefined;
};

const changeMaintenanceStatus = (
desiredStatus?: "enabled" | "disabled"
): string => {
// If no status is provided then toggle
if (desiredStatus === undefined) {
toggleMaintenanceStatus();
return getCurrentStatus();
}

const currentStatus = getCurrentStatus();
if (currentStatus === desiredStatus) return currentStatus;

toggleMaintenanceStatus();
return getCurrentStatus();
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import {
getFileContents,
setFileContents,
updateFileContents,
} from "./lib/file";

const MAINTENANCE_FILE_PATH = process.env.MAINTENANCE_FILE_PATH;

export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
if (!MAINTENANCE_FILE_PATH) throw new Error("a");
let body = "Method not implemented";
let status = 200;

try {
switch (event.httpMethod) {
case "GET":
body = getFileContents();
break;
case "PUT":
setFileContents(event.body || "");
body = "Successfully updated whitelist";
break;
case "PATCH":
updateFileContents(event.body || "");
body = "Successfully updated whitelist";
break;
default:
status = 501;
}
} catch (error) {
let statusCode = 500;
const errorMsg = error.errorMessage;

if (!errorMsg) {
return {
body: "Unknown Error",
statusCode: statusCode,
};
}

if (errorMsg === "List of IP addresses not valid") statusCode = 403;

return {
body: errorMsg,
statusCode: statusCode,
};
}

return {
body: body,
statusCode: status,
};
};
25 changes: 25 additions & 0 deletions packages/graphql-mesh-server/lib/fargate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as ecr from "aws-cdk-lib/aws-ecr";
import * as ecsPatterns from "aws-cdk-lib/aws-ecs-patterns";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ssm from "aws-cdk-lib/aws-ssm";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import { Port, SecurityGroup, IVpc, Vpc } from "aws-cdk-lib/aws-ec2";
import { RedisService } from "./redis-construct";
import {
Expand Down Expand Up @@ -130,6 +131,11 @@ export interface MeshServiceProps {
* Defaults to 'graphql-server'
*/
logStreamPrefix?: string;
/**
* Whether a DynamoDB table should be created to store session data
* @default authentication-table
*/
authenticationTable?: string;
}

export class MeshService extends Construct {
Expand Down Expand Up @@ -287,6 +293,25 @@ export class MeshService extends Construct {
managedPolicyArn: "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess",
});

if (props.authenticationTable || props.authenticationTable === undefined) {
const authTable = new dynamodb.Table(this, "authenticationTable", {
tableName: props.authenticationTable || "authentication-table",
partitionKey: {
name: "customer_id",
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: "refresh_token_hash",
type: dynamodb.AttributeType.STRING,
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: RemovalPolicy.DESTROY,
timeToLiveAttribute: "ttl",
});

authTable.grantReadWriteData(this.service.taskDefinition.taskRole);
}

const allowedIpList = new CfnIPSet(this, "allowList", {
addresses: props.allowedIps || [],
ipAddressVersion: "IPV4",
Expand Down
25 changes: 25 additions & 0 deletions packages/graphql-mesh-server/lib/graphql-mesh-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2"
import { LogGroup } from "aws-cdk-lib/aws-logs";
import { Topic } from "aws-cdk-lib/aws-sns";
import { Alarm } from "aws-cdk-lib/aws-cloudwatch";
import { Maintenance } from "./maintenance";

export type MeshHostingProps = {
/**
Expand Down Expand Up @@ -153,6 +154,18 @@ export type MeshHostingProps = {
* CloudFront distribution ID to clear cache on after a Mesh deploy.
*/
cloudFrontDistributionId?: string;

/**
* If maintenance mode lambdas and efs volume should be created
* @default true
*/
enableMaintenanceMode?: boolean;

/**
* Maintenance auth key
* @default true
*/
maintenanceAuthKey?: string;
};

export class MeshHosting extends Construct {
Expand Down Expand Up @@ -200,6 +213,18 @@ export class MeshHosting extends Construct {
this.logGroup = mesh.logGroup;
this.repository = mesh.repository;

if (
props.enableMaintenanceMode ||
props.enableMaintenanceMode === undefined
) {
new Maintenance(this, "maintenance", {
...props,
vpc: this.vpc,
fargateService: this.service,
authKey: props.maintenanceAuthKey,
});
}

new CodePipelineService(this, "pipeline", {
repository: this.repository,
service: this.service,
Expand Down
Loading
Loading