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: DNS reconciler engine #441

Merged
5 commits merged into from Mar 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 56 additions & 6 deletions app/lib/dns.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ChangeResourceRecordSetsResponse,
GetChangeResponse,
ListResourceRecordSetsResponse,
Change,
} from '@aws-sdk/client-route-53';
import { DnsRecordType } from '@prisma/client';
import { z } from 'zod';
Expand Down Expand Up @@ -58,7 +59,7 @@ const toFQDN = (domain: string) => domain.replace(/\.?$/, '.');
* dev, we create it on startup if not set.
* @returns string - the AWS Hosted Zone ID to use
*/
async function hostedZoneId() {
async function getHostedZoneId() {
if (!process.env.AWS_ROUTE53_HOSTED_ZONE_ID) {
if (process.env.NODE_ENV === 'production') {
throw new Error('AWS_ROUTE53_HOSTED_ZONE_ID environment variable is missing');
Expand All @@ -74,7 +75,7 @@ async function hostedZoneId() {
return process.env.AWS_ROUTE53_HOSTED_ZONE_ID;
}

const credentials = () => {
const getCredentials = () => {
if (process.env.NODE_ENV === 'production') {
return {
accessKeyId: AWS_ACCESS_KEY_ID,
Expand All @@ -100,7 +101,7 @@ const awsEndpoint = () => {
export const route53Client = new Route53Client({
endpoint: awsEndpoint(),
region: process.env.AWS_REGION || 'us-east-1',
credentials: credentials(),
credentials: getCredentials(),
});

export async function createHostedZone(domain: string) {
Expand Down Expand Up @@ -138,7 +139,7 @@ export const createDnsRecord = (
async function checkDnsRecordExists(type: DnsRecordType, fqdn: string, value: string) {
try {
const command = new ListResourceRecordSetsCommand({
HostedZoneId: await hostedZoneId(),
HostedZoneId: await getHostedZoneId(),
StartRecordName: fqdn,
StartRecordType: type,
MaxItems: 1,
Expand All @@ -165,6 +166,55 @@ async function checkDnsRecordExists(type: DnsRecordType, fqdn: string, value: st
}
}

/**
* Get a full page of records from AWS Route53. If fqdn and recordType is specified,
* load the next page starting with that record
*
* https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-route-53/classes/listresourcerecordsetscommand.html
*/
export const getDnsRecordSetPage = async (
fqdn?: string,
// Using string as it cam be any record type not just the ones we use, also AWS sdk refers to it as string
type?: string
): Promise<ListResourceRecordSetsResponse> => {
try {
const command = new ListResourceRecordSetsCommand({
HostedZoneId: await getHostedZoneId(),
StartRecordName: fqdn,
StartRecordType: type,
});

return route53Client.send(command);
} catch (error) {
logger.warn('DNS Error - Error loading record page from Route53', { type, fqdn, error });

// Rethrow to keep stack
throw error;
}
};

/**
* Execute a complete changeset in Route53 generated by the reconciler
*/
export const executeChangeSet = async (Changes: Change[]) => {
This conversation was marked as resolved.
Show resolved Hide resolved
try {
const command = new ChangeResourceRecordSetsCommand({
ChangeBatch: { Changes },
HostedZoneId: await getHostedZoneId(),
});
const response: ChangeResourceRecordSetsResponse = await route53Client.send(command);

if (!response.ChangeInfo?.Id) {
throw new Error(`DNS Error - missing ID in AWS ChangeInfo response`);
}

return response.ChangeInfo.Id;
} catch (error) {
logger.error('DNS Error - Failed to execute changeSet', { error });
throw error;
}
};

export const upsertDnsRecord = async (
username: string,
type: DnsRecordType,
Expand Down Expand Up @@ -210,7 +260,7 @@ export const upsertDnsRecord = async (
},
],
},
HostedZoneId: await hostedZoneId(),
HostedZoneId: await getHostedZoneId(),
});
const response: ChangeResourceRecordSetsResponse = await route53Client.send(command);

Expand Down Expand Up @@ -278,7 +328,7 @@ export const deleteDnsRecord = async (
},
],
},
HostedZoneId: await hostedZoneId(),
HostedZoneId: await getHostedZoneId(),
});

const response: ChangeResourceRecordSetsResponse = await route53Client.send(command);
Expand Down
13 changes: 13 additions & 0 deletions app/models/dns-record.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,16 @@ export function getExpiredDnsRecords() {
},
});
}

/**
* This Fn gets the base data that is needed for reconciliation
* but does that for all records in the db. Should be no more than 10k
*
* This serves as the ground truth for the entire DNS system, data is
* synchronized to Route53 from this
*/
This conversation was marked as resolved.
Show resolved Hide resolved
export function getReconciliationData() {
return prisma.dnsRecord.findMany({
select: { username: true, subdomain: true, type: true, value: true },
This conversation was marked as resolved.
Show resolved Hide resolved
});
}
42 changes: 42 additions & 0 deletions app/reconciler/DnsDbCompareStructureGenerator.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { get, set } from 'lodash';
import { getReconciliationData } from '~/models/dns-record.server';
import { buildDomain } from '../utils';

import type { ReconcilerCompareStructure } from './ReconcilerTypes';

class DnsDbCompareStructureGenerator {
#MUTATEDcompareStructure: ReconcilerCompareStructure = {};

/**
* This Fn reads the database `Record` table into a `ReconcilerCompareStructure` type object
* to be used for later comparison with Route53
*/
generate = async (): Promise<ReconcilerCompareStructure> => {
const dnsData = await getReconciliationData();

// Populate compareStructure with data from the `Record` table
dnsData.forEach(({ username, subdomain, type, value }) => {
const fqdn = `${buildDomain(username, subdomain)}.`;

/**
* Get the previous value for fqdn.recordType (or an empty array if missing)
* and combine it with the new value we are just processing
*
* creates the following (example) structure
* {
* 'web.john.starchart.com.': {
* 'A': ['1.2.3.4']
* }
* }
*/
const combinedValue = [...get(this.#MUTATEDcompareStructure, [fqdn, type], []), value];

// and set that value on our compare structure
set(this.#MUTATEDcompareStructure, [fqdn, type], combinedValue);
});

return this.#MUTATEDcompareStructure;
};
}

export default DnsDbCompareStructureGenerator;
8 changes: 8 additions & 0 deletions app/reconciler/ReconcilerTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { DnsRecordType } from '@prisma/client';

export interface ReconcilerCompareStructure {
humphd marked this conversation as resolved.
Show resolved Hide resolved
[fqdn: string]: {
// Next layer is the record type
[recordType in DnsRecordType]?: string[];
};
}
64 changes: 64 additions & 0 deletions app/reconciler/Route53CompareStructureGenerator.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { set } from 'lodash';
import { getDnsRecordSetPage } from '~/lib/dns.server';

// Using this in JS code later, cannot `import type`
import { DnsRecordType } from '@prisma/client';
import type { ReconcilerCompareStructure } from './ReconcilerTypes';
import type { ResourceRecordSet, ListResourceRecordSetsResponse } from '@aws-sdk/client-route-53';

class Route53CompareStructureGenerator {
#MUTATEDcompareStructure: ReconcilerCompareStructure = {};

#processRecordSetPage = (recordSetPage: ResourceRecordSet[]) => {
recordSetPage.forEach((recordSet) => {
// Unsure as to how those could be undefined, but according to AWS sdk, they could
if (!recordSet.Name || !recordSet.Type) {
return;
}

// We only care about record types that we handle. NS, SOA, etc. should be ignored
// RecordSet.Type is given as `string`, so we have to do this for TS to compare them
if (!Object.values(DnsRecordType).includes(recordSet.Type as DnsRecordType)) {
return;
}

const value = recordSet.ResourceRecords?.map(({ Value }) => Value).filter(
(Value) => !!Value
) as string[];

// and set that value on our compare structure
set(this.#MUTATEDcompareStructure, [recordSet.Name, recordSet.Type], value);
});
};

/**
* This Fn reads the complete Route53 zone into a `ReconcilerCompareStructure` type object
* to be used for later comparison with our database data
*/
generate = async (): Promise<ReconcilerCompareStructure> => {
let morePages: boolean = true;
let nextFqdn: string | undefined = undefined;
let nextType: string | undefined = undefined;

while (morePages) {
const response: ListResourceRecordSetsResponse = await getDnsRecordSetPage(
nextFqdn,
nextType
);
morePages = !!response.IsTruncated;
nextFqdn = response.NextRecordName;
nextType = response.NextRecordType;

if (!response.ResourceRecordSets) {
continue;
}

// !!! `MUTATEDcompareStructure` is passed by reference and is mutated by fn !!!
this.#processRecordSetPage(response.ResourceRecordSets);
}

return this.#MUTATEDcompareStructure;
};
}

export default Route53CompareStructureGenerator;
103 changes: 103 additions & 0 deletions app/reconciler/createChangeSetFromCompareStructures.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { isEqual } from 'lodash';
import logger from '~/lib/logger.server';

import { DnsRecordType } from '@prisma/client';

import type { Change } from '@aws-sdk/client-route-53';
import type { ReconcilerCompareStructure } from './ReconcilerTypes';

interface CompareStructures {
dbStructure: ReconcilerCompareStructure;
route53Structure: ReconcilerCompareStructure;
}

export const createRemovedChangeSetFromCompareStructures = ({
dbStructure,
route53Structure,
}: CompareStructures) => {
const toChange: Change[] = [];

/**
* If something is present in route53 but is completely missing in db,
* then we have to delete it
*/
Object.keys(route53Structure).forEach((fqdn) => {
Object.keys(route53Structure[fqdn]).forEach((type) => {
const route53Value = route53Structure[fqdn][type as DnsRecordType]!;
const dbValue = dbStructure[fqdn]?.[type as DnsRecordType];

if (dbValue) {
// Both of them have this
return;
}

toChange.push({
Action: 'DELETE',
ResourceRecordSet: {
MultiValueAnswer: route53Value.length > 1,
Name: fqdn,
Type: type,
ResourceRecords: route53Value.map((Value) => ({ Value })),
TTL: 60 * 5,
},
});
});
});

return toChange;
};

export const createUpsertedChangeSetFromCompareStructures = ({
dbStructure,
route53Structure,
}: CompareStructures) => {
const toChange: Change[] = [];

/**
* Now loop through all the data from the DB. If the data in
* Route53 is not the same, we should upsert
*/
Object.keys(dbStructure).forEach((fqdn) => {
Object.keys(dbStructure[fqdn]).forEach((type) => {
const dbValue = dbStructure[fqdn][type as DnsRecordType]!;
const route53Value = route53Structure[fqdn]?.[type as DnsRecordType];
// When comparing, we do not care about the order
if (isEqual(dbValue?.sort(), route53Value?.sort())) {
// Both of them are equal
return;
}

/**
* https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-values-multivalue.html
*
* According to the above, only A, AAAA, CAA, MX, NAPTR, PTR, SPF, SRV and TXT records can be multi-value
*/

if (
dbValue.length > 1 &&
!([DnsRecordType.A, DnsRecordType.AAAA, DnsRecordType.TXT] as DnsRecordType[]).includes(
type as DnsRecordType
)
) {
logger.error(
'Error creating DNS changeset Only A, AAAA and TXT records can be multivalue. Ignoring records',
{ fqdn, type }
);
return;
}

toChange.push({
Action: 'UPSERT',
ResourceRecordSet: {
MultiValueAnswer: dbValue.length > 1,
Name: fqdn,
Type: type,
ResourceRecords: dbValue.map((Value) => ({ Value })),
TTL: 60 * 5,
},
});
});
});

return toChange;
};
39 changes: 39 additions & 0 deletions app/reconciler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { executeChangeSet } from '~/lib/dns.server';
import logger from '~/lib/logger.server';
import DnsDbCompareStructureGenerator from './DnsDbCompareStructureGenerator.server';
import Route53CompareStructureGenerator from './Route53CompareStructureGenerator.server';
import {
createRemovedChangeSetFromCompareStructures,
createUpsertedChangeSetFromCompareStructures,
} from './createChangeSetFromCompareStructures.server';

// S3 limit for a ChangeSet
const CHANGE_SET_MAX_SIZE = 1000;

export const reconcile = async () => {
const [dbStructure, route53Structure] = await Promise.all([
new DnsDbCompareStructureGenerator().generate(),
new Route53CompareStructureGenerator().generate(),
]);

const changeSet = [
...createRemovedChangeSetFromCompareStructures({ dbStructure, route53Structure }),
...createUpsertedChangeSetFromCompareStructures({ dbStructure, route53Structure }),
];

// Limiting the changeSet to 1000 items. AWS limit

if (!changeSet.length) {
logger.debug('Reconciler found no changes to be pushed');
return;
}

logger.debug(`Reconciler intends to push the following ${changeSet.length} changes`, {
changeSet,
});

await executeChangeSet(changeSet.slice(0, CHANGE_SET_MAX_SIZE));

// Returning the changeSet size we are executing
return Math.min(changeSet.length, CHANGE_SET_MAX_SIZE);
};
Loading