Skip to content

Instantly share code, notes, and snippets.

@josiahbryan
Last active February 7, 2023 00:19
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save josiahbryan/0f0b30e10380d3820a95b26e0a217baa to your computer and use it in GitHub Desktop.
Save josiahbryan/0f0b30e10380d3820a95b26e0a217baa to your computer and use it in GitHub Desktop.
/**
* This is a simple wrapper script that orchestrates the PlanetScale API
* so we can synchronize our schema changes via DDL to a dev branch and
* automatically release them.'
*
* This was created for my own project using my own ORM (https://github.com/josiahbryan/yass-orm)
* but the PlanetScale API operations below should be easily reusable
* for other scenarios.
*
* The main flow looks something like:
*
* - Create branch in PlanetScale (wait for it to come online)
* - Create passwords for branch in PlanetScale
* - Write a custom database env config to disk for use by the schema sync script
* - Execute the script with the custom branch config and passwords to send any new DDL to PlanetScale
* - When schema sync finishes, create a "Deploy Request" in planet scale
* - Wait for Deploy Request to become "not pending" (move out of pending status in PS)
* - Check the deployment_state of the DR and if the DR didn't find any schema changes, just close the DR and remove the branch
* - If there were actually schema changes, then we release the DR via the PS API
* - Wait for the DR release process to complete
* - Delete the branch we created to clean everything up
* - All done!
*
* Changes/suggestions/improvements welcome!
*
* Author: Josiah Bryan <josiahbryan@gmail.com>
*/
/* eslint-disable no-shadow */
/* eslint-disable no-unused-vars */
import fs from 'fs';
import path from 'path';
import cp from 'child_process';
import { Spinner } from 'cli-spinner';
import AppConfig from 'shared/config';
import Logger from 'shared/utils/Logger';
import { defer } from 'shared/utils/defer';
// Ref: https://api-docs.planetscale.com/reference/list-organizations
const PlanetScaleApi = require('api')('@pscale/v1.0#7ooypmcldnhl7h7');
// Grab our PS config from AppConfig, service token generated in PS dashboard
const {
planetScale: {
serviceToken: { id, token },
},
} = AppConfig;
PlanetScaleApi.auth(`${id}:${token}`);
const logger = Logger.getPrefixedCustomLogger('schema-sync-wrapper');
const {
// Grab our app-specific configs from AppConfig
// The appVersion and gitVersion generated at build time by our genConfig script
version: appVersion,
gitVersion,
// Get our static PS config so we don't hardcode strings here
planetScale: { organization, primaryDatabase: database, mainBranch },
} = AppConfig;
/**
* Create a temporary branch in PS for use for this sync
* @returns {object} Branch object from PS, we just use the `name` prop mainly
*/
async function createBranch() {
// Construct body and meta for branch create based on docs:
// ref: https://api-docs.planetscale.com/reference/create-a-branch
const body = {
name: `sync-${gitVersion}`,
parent_branch: mainBranch,
};
const meta = {
organization,
database,
};
const { data } = await PlanetScaleApi.createABranch(body, meta);
return data;
}
/**
* Generic waiting helper that we can re-use below multiple times to wait for
* various PlanetScale operations
* @param {function} callback Async callback, return a truthy value to stop waiting, falsey to continue waiting
* @param {string} label String to print on console / use for warnings while waiting
* @param {Logger} logger Logger instance to use for warnings if needed
* @param {number} maxWaitSeconds [default: 60 * 2] Max seconds to wait for completion before timing out
* @param {timeout} timeout [default: 1000ms] Milliseconds to wait before re-running callback
* @returns {any} Actual truthy from the `callback` (not cast, just raw result)
*/
async function waitFor({
callback,
label = 'Waiting',
logger = Logger,
maxWaitSeconds = 60 * 2,
timeout = 1000,
} = {}) {
const time = Date.now();
const spinner = new Spinner(label);
spinner.start();
const checkStatus = async () => {
const result = await callback();
if (!result) {
const deltaSeconds = Math.round((Date.now() - time) / 1000);
if (deltaSeconds > maxWaitSeconds) {
spinner.stop(true);
logger.warn(`'${label}' timed out while waiting`);
return null;
}
await new Promise((resolve) => {
setTimeout(resolve, timeout);
});
return checkStatus();
}
spinner.stop(true);
return result;
};
return checkStatus();
}
/**
* Wait for branch to become ready in PlanetScale
* @param {object} branch Branch object created by `createBranch`
* @param {object} waitForOptions Optional options to pass to `waitFor` (see above)
* @returns `true` if created or `null` if failure
*/
async function waitForBranchReady(branch, waitForOptions) {
const { name } = branch;
const getStatus = () =>
PlanetScaleApi.getBranchStatus({
organization,
database,
name,
});
const success = await waitFor({
...waitForOptions,
label: `Waiting for branch '${name}' to become ready`,
callback: async () => {
const { data: status } = await getStatus();
return !!status.ready;
},
});
if (!success) {
const final = await getStatus();
logger.debug(`Failure status of branch`, final);
return null;
}
return true;
}
/**
* Create a password set for our temporary branch
* @param {object} branch Branch object created by `createBranch
* @returns {object} Password set from PlanetScale
*/
async function createBranchPasswords(branch) {
const body = {
display_name: 'schemasync',
role: 'admin',
ttl: 3600,
};
const meta = {
organization,
database,
branch: branch.name,
};
const { data: passwordSet } = await PlanetScaleApi.createABranchPassword(
body,
meta,
);
return passwordSet;
}
/**
* Generates a config for use by YASS-ORM (https://github.com/josiahbryan/yass-orm)
*
* @param {object} passwordSet Password set returned from `createBranchPasswords` above
* @returns {string} File name for the generated YASS-ORM config
*/
function writePasswordSetAsYassConfig(passwordSet) {
const {
id: passwordId,
access_host_url: dbHost,
plain_text: dbPass,
username: dbUser,
database_branch: { name: branchName },
} = passwordSet;
const yassEnv = JSON.stringify({
schema: database,
host: dbHost,
user: dbUser,
password: dbPass,
port: 3306,
readonlyNodes: [],
ssl: { rejectUnauthorized: false },
});
const yassConfig = `
const config = {
development: ${yassEnv},
production: ${yassEnv},
// Applies to all envs above
shared: {
// Required for id: t.uuidKey to work with t.linked - applies to ALL fields
uuidLinkedIds: true,
// Required for PlanetScale ...
disableTimezone: true,
disableFunctions: true,
commonFields: (t) => {
return {
isDeleted: t.bool,
createdBy: t.linked('user', { inverse: null }),
createdAt: t.datetime,
updatedBy: t.linked('user', { inverse: null }),
updatedAt: t.datetime,
};
},
},
};
console.log(
"Using PlanetScale config for YASS for branch schemasync-57c576f4-test1:",
JSON.stringify(config, null, 4)
);
module.exports = config;
`;
// logger.debug(`Compiled yassConfig:`, yassConfig);
const yassConfigFileName = path.resolve(
'/opt/rubber',
`yass-${branchName}.config.js`,
);
fs.writeFileSync(yassConfigFileName, yassConfig);
return yassConfigFileName;
}
/**
* Executes the app-specific schema sync wrapper that just calls
* YASS' `schema-sync` script with the paths to our database definitions,
* but with the `YASS_CONFIG` env var override set to our branch config
*
* @param {string} yassConfigFileName Config file absolute path generated by `writePasswordSetAsYassConfig`
* @param {Logger} logger Logger instance for logging
* @returns
*/
async function executeSyncWithConfig(
yassConfigFileName,
{ logger = Logger } = {},
) {
const syncCmd = `YASS_CONFIG=${yassConfigFileName} NODE_ENV=production ./scripts/exec-schema-sync-novb`;
logger.debug(`Executing schema sync command: `, syncCmd);
const promise = defer();
const proc = cp.exec(syncCmd);
proc.stdout.pipe(process.stdout);
proc.stderr.pipe(process.stderr);
proc.on('exit', (code) => {
logger.info('Schema sync finished.');
promise.resolve();
});
return promise;
}
/**
* Delete the password set we created earlier
* @param {object} passwordSet Password set from `createBranchPasswords`
*/
async function deleteBranchPasswords(passwordSet) {
const {
id,
database_branch: { name: branch },
} = passwordSet;
await PlanetScaleApi.deleteABranchPassword({
organization,
database,
branch,
id,
});
}
/**
* Create a deploy request in PS
* @param {object} branch Branch object from `createBranch` above
* @returns {object} Deploy Request object from PlanetScale - we really only use the `number` property
*/
async function createDeployRequest(branch) {
const { data: dr } = await PlanetScaleApi.createADeployRequest(
{
branch: branch.name,
into_branch: mainBranch,
notes: `Version ${appVersion} Automated Schema Sync`,
},
{
organization,
database,
},
);
return dr;
}
/**
* Release the DR to production
* @param {object} dr Deploy Request object (or any object with a `number` prop)
* @returns {object} Response from PS
*/
async function releaseDeployRequest(dr) {
const { number } = dr;
const body = {
organization,
database,
number,
};
const { data: queueResponse } = await PlanetScaleApi.queueADeployRequest(
body,
);
return queueResponse;
}
/**
* Get the status of the DR in PlanetScale
* @param {object} dr Deploy Request object (or any object with a `number` prop)
* @returns {object} Response from PS
*/
async function getDeploymentRequest(dr) {
const { number } = dr;
const { data: newDr } = await PlanetScaleApi.getADeployRequest({
organization,
database,
number,
});
return newDr;
}
/**
* Close (cancel) the DR - used if no actual schema changes
* @param {object} dr Deploy Request object (or any object with a `number` prop)
* @returns {object} Response from PS
*/
async function closeDeploymentRequest(dr) {
const { number } = dr;
const { data } = await PlanetScaleApi.closeADeployRequest({
organization,
database,
number,
});
return data;
}
/**
* Wait for the DR to move out of the 'pending' status
* @param {object} dr Deploy Request object (or any object with a `number` prop)
* @param {object} waitForOptions Options to pass to `waitFor`
* @returns {string} Final `deployment_state` (or `false` if never moved out of "pending")
*/
async function waitForDeployRequestReady(dr, waitForOptions) {
const success = await waitFor({
...waitForOptions,
callback: async () => {
const { deployment_state: state } = await getDeploymentRequest(dr);
return state !== 'pending';
},
label: `Waiting for DR # '${dr.number}' to move out of pending`,
});
if (!success) {
return false;
}
const { deployment_state: state } = await getDeploymentRequest(dr);
return state;
}
/**
* Get the status of the deployment itself in PS
* @param {object} dr Deploy Request object (or any object with a `number` prop)
* @returns {object} Response from PS
*/
async function getDeploymentStatus(dr) {
const { number } = dr;
const { data: deployment } = await PlanetScaleApi.getADeployment({
organization,
database,
number,
});
return deployment;
}
/**
* Wait for the deployment to finish releasing to production (move to "complete" state)
* @param {object} dr Deploy Request object (or any object with a `number` prop)
* @returns {boolean} Truthy if completed, `null` if timed out
*/
async function waitForDeploymentComplete(dr, waitForOptions) {
return waitFor({
...waitForOptions,
label: `Waiting for deploy # ${dr.number} to complete ...`,
callback: async () => {
const { state } = await getDeploymentStatus(dr);
return state === 'complete';
},
});
}
/**
* Remove the branch from PS because PS limits number of branches open,
* and this is just a temporary branch anyway.
* @param {object} branch Branch created by `createBranch` above
* @returns {object} Response from PS
*/
async function deleteBranch(branch) {
const { name } = branch;
const { data } = await PlanetScaleApi.deleteABranch({
organization,
database,
name,
});
return data;
}
/**
* Main function that ties all the above mini functions together into
* one useful script.
*/
async function main() {
// Branch off of main to a dedicated branch for changes
// because we can't execute DDL on main in PlanetScale
const branch = await createBranch();
// const branch = { name: 'sync-57c576f4-test1' };
logger.info(
`Syncing schema for ${appVersion} to PlanetScale branch '${branch.name}'...`,
);
// Wait for branch servers to spin up
logger.debug(`Waiting for branch servers to become ready...`);
const flag = await waitForBranchReady(branch);
if (!flag) {
throw new Error(`Cannot continue, branch not ready`);
}
// Create a password for this branch and create a dedicated YASS config file with branch config
const passwordSet = await createBranchPasswords(branch);
const yassConfigFileName = writePasswordSetAsYassConfig(passwordSet);
logger.debug(
`Branch online, generated YASS config set and executing scheme sync with config at`,
yassConfigFileName,
);
// Run schema-sync with this branch's config overriding the usual yass config
await executeSyncWithConfig(yassConfigFileName, { logger });
// Remove config file and delete password set in PS
logger.debug(`Done, removing config: `, yassConfigFileName);
fs.unlinkSync(yassConfigFileName);
await deleteBranchPasswords(passwordSet);
// Now that PlanetScale has the changes from our schema sync script
// as DDL changes in a dedicated branch, we now have to tell PS to
// deploy those changes in a "Deploy Request" which we automatically "approve".
// Note that we must wait for the deploy to succeed because we have to remove
// the branch when we're done due to a limited number of active branches allowed.
// Create a DR for branch > main
const dr = await createDeployRequest(branch);
logger.debug(`Created deploy request #`, dr.number);
// const dr = { number: 3 };
// const status = await getDeploymentRequest(dr);
// logger.debug(`Initial DR status`, status);
const deploymentState = await waitForDeployRequestReady(dr);
// Escape hatch - if no DDL changes detected, then just cancel instead of deploying branch
if (deploymentState === 'no_changes') {
logger.debug(
`No changes found in the schema, closing DR and removing branch`,
);
await closeDeploymentRequest(dr);
await deleteBranch(branch);
logger.info(`Sync done for ${appVersion}`);
return;
}
// Release it to production (does not complete right away
// even though it returns success, that's why we wait, below)
await releaseDeployRequest(dr);
// Check and log status
logger.debug(`Waiting for deploy`, dr.number);
const success = await waitForDeploymentComplete(dr);
if (!success) {
throw new Error(`Failed to deploy DR ${dr.number}`);
}
// Must delete branches because PlanetScale limits number
// of branches available on a given account at any one time
logger.debug(`DR done, deleting branch '${branch.name}' ...`);
await deleteBranch(branch);
logger.info(`Sync done for ${appVersion}`);
}
main()
.catch((err) => Logger.error(err))
.finally(() => process.exit());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment