Skip to content

Commit

Permalink
Merge pull request #102 from zanerock/work-liquid-labs/cloudsite/93
Browse files Browse the repository at this point in the history
Implement delayed cleanup support
  • Loading branch information
zanerock authored Mar 22, 2024
2 parents 8a0b2cf + 3ee574d commit af02884
Show file tree
Hide file tree
Showing 19 changed files with 189 additions and 76 deletions.
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ const siteInfo = {
"apexDomain": "your-website-domain.com",
"sourceType": "docusaurus", // or 'vanilla'
"sourcePath": "/Users/your-home-dir/path/to/website/source"
"pluginSettings": {
"plugins": {
"contactHandler": {
"path": "/contact-handler",
"emailFrom": "[email protected]"
"settings": {
"path": "/contact-handler",
"emailFrom": "[email protected]"
}
}
}
}
Expand Down Expand Up @@ -287,6 +289,7 @@ cloudsite update your-domain.com
### Commands
- [`cleanup`](#cloudsite-cleanup): Attempts to fully delete partially deleted sites in the 'needs to be cleaned up' state.
- [`configuration`](#cloudsite-configuration): Command group for managing the Cloudsite CLI configuration.
- [`create`](#cloudsite-create): Creates a new website, setting up infrastructure and copying content.
- [`destroy`](#cloudsite-destroy): Destroys the named site. I.e., deletes all cloud resources associated with the site.
Expand All @@ -298,6 +301,18 @@ cloudsite update your-domain.com
- [`update`](#cloudsite-update): Updates a website content and/or infrastructure.
- [`verify`](#cloudsite-verify): Verifies the site is up and running and that the stack and content are up-to-date.
<span id="cloudsite-cleanup"></span>
#### `cloudsite cleanup <options> <apex-domain>`
Attempts to fully delete partially deleted sites in the 'needs to be cleaned up' state.
##### `cleanup` options
|Option|Description|
|------|------|
|`<apex-domain>`|(_main argument_,_optional_) Specifies the site to clean up rather than trying to cleanup all pending sites.|
|`--list`|Lists the sites in need of cleaning up.|
<span id="cloudsite-configuration"></span>
#### `cloudsite configuration [subcommand]`
Expand Down
7 changes: 5 additions & 2 deletions src/cli/cloudsite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { commandLineDocumentation } from 'command-line-documentation'
import isEqual from 'lodash/isEqual'

import { cliSpec, DB_PATH } from './constants'
import { handleCleanup } from './lib/handle-cleanup'
import { handleConfiguration } from './lib/handle-configuration'
import { handleCreate } from './lib/handle-create'
import { handleDestroy } from './lib/handle-destroy'
Expand Down Expand Up @@ -32,16 +33,18 @@ const cloudsite = async () => {
throw e
}
// otherwise, it's fine, there just are no options
db = { account : { settings : {} }, sites : {}, todos : [], reminders : [] }
db = { account : { settings : {} }, sites : {}, toCleanup : {}, reminders : [] }
}

const origDB = structuredClone(db)

let exitCode = 0
try {
switch (command) {
case 'cleanup':
await handleCleanup({ argv, db }); break
case 'configuration':
await handleConfiguration({ argv, cliSpec, db }); break
await handleConfiguration({ argv, db }); break
case 'create':
await handleCreate({ argv, db }); break
case 'destroy':
Expand Down
16 changes: 16 additions & 0 deletions src/cli/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ const cliSpec = {
}
],
commands : [
{
name : 'cleanup',
description : "Attempts to fully delete partially deleted sites in the 'needs to be cleaned up' state.",
arguments : [
{
name : 'apex-domain',
defaultOption : true,
description : 'Specifies the site to clean up rather than trying to cleanup all pending sites.'
},
{
name : 'list',
description : 'Lists the sites in need of cleaning up.',
type : Boolean
}
]
},
{
name : 'configuration',
description : 'Command group for managing the Cloudsite CLI configuration.',
Expand Down
43 changes: 43 additions & 0 deletions src/cli/lib/handle-cleanup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import commandLineArgs from 'command-line-args'

import { cliSpec } from '../constants'
import { destroy } from '../../lib/actions/destroy'

const handleCleanup = async ({ argv, db }) => {
const cleanupOptionsSpec = cliSpec.commands.find(({ name }) => name === 'cleanup').arguments
const cleanupOptions = commandLineArgs(cleanupOptionsSpec, { argv })
const apexDomain = cleanupOptions['apex-domain']
const { list } = cleanupOptions

if (list === true) {
process.stdout.write(Object.keys(db.toCleanup).join('\n') + '\n')
return
}

const listOfSitesToCleanup = apexDomain === undefined
? Object.keys(db.toCleanup)
: [apexDomain]

const deleteActions = listOfSitesToCleanup
.map((apexDomain) => {
process.stdout.write(`Cleaning up ${apexDomain}...\n`)
return destroy({ db, siteInfo : db.toCleanup[apexDomain], verbose : false })
})

process.stdout.write('.')
const intervalID = setInterval(() => process.stdout.write('.'), 2000)
const cleanupResults = await Promise.all(deleteActions)
clearInterval(intervalID)
process.stdout.write('\n')

listOfSitesToCleanup.forEach((apexDomain, i) => {
const cleanupResult = cleanupResults[i]
process.stdout.write(`${apexDomain}: ${cleanupResult === true ? 'CLEANED' : 'NOT cleaned'}\n`)
if (cleanupResult === true) {
delete db.toCleanup[apexDomain]
db.reminders.splice(db.reminders.findIndex(({ apexDomain: testDomain }) => testDomain === apexDomain), 1)
}
})
}

export { handleCleanup }
16 changes: 15 additions & 1 deletion src/cli/lib/handle-destroy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,21 @@ const handleDestroy = async ({ argv, db }) => {
const deleted = await destroy({ db, siteInfo, verbose : true })

if (deleted === true) {
process.stdout.write(`Removing ${apexDomain} from local DB.\n`)
process.stdout.write(`\n${apexDomain} deleted.\nRemoving ${apexDomain} from local DB.\n`)
delete db.sites[apexDomain]
} else {
process.stdout.write(`\nThe delete has failed, which is expected because the 'replicated Lambda functions' need to be cleared by AWS before all resources can be deleted. This can take 30 min to a few hours.\n\nThe site has been marked for cleanup and you can now create new sites using the '${apexDomain}' domain.\n\nYou can complete deletion by executing:\ncloudsite cleanup`)

const now = new Date()
const remindAfter = new Date(now.getTime() + 2 * 60 * 60 * 1000)
siteInfo.lastCleanupAttempt = now.toISOString()
db.toCleanup[apexDomain] = siteInfo
db.reminders.push({
todo : `Cleanup partially deleted site '${apexDomain}'.`,
action : 'cloudsite cleanup',
remindAfter : remindAfter.toISOString(),
references : apexDomain
})
delete db.sites[apexDomain]
}
}
Expand Down
13 changes: 11 additions & 2 deletions src/cli/lib/options.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,22 @@ const updatePluginSettings = ({ confirmed, doDelete, options, siteInfo }) => {
errorOut(`No such plugin '${pluginName}'; use one of: ${Object.keys(plugins).join(', ')}.\n`)
}

if (siteInfo.plugins === undefined) {
siteInfo.plugins = {}
}
const pluginData = siteInfo.plugins[pluginName] || {}
siteInfo.plugins[pluginName] = pluginData // in case we just created it
const pluginSettings = siteInfo.plugins[pluginName].settings || {}
siteInfo.plugins[pluginName].settings = pluginSettings // in case we just created it
const spec = plugin.config.options

const { valueContainer, valueKey } =
getValueContainerAndKey({ path : pathBits, pathPrefix : pluginName + '.', rootContainer : pluginSettings })
const { valueContainer, valueKey } = getValueContainerAndKey({
path : pathBits,
pathPrefix : pluginName + '.',
rootContainer : pluginSettings,
spec,
value
})

if (doDelete === true && valueKey === undefined) { // then we're deleting/disabling the entire plugin
if (confirmed === true) {
Expand Down
8 changes: 5 additions & 3 deletions src/docs/README-prefix.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ const siteInfo = {
"apexDomain": "your-website-domain.com",
"sourceType": "docusaurus", // or 'vanilla'
"sourcePath": "/Users/your-home-dir/path/to/website/source"
"pluginSettings": {
"plugins": {
"contactHandler": {
"path": "/contact-handler",
"emailFrom": "[email protected]"
"settings": {
"path": "/contact-handler",
"emailFrom": "[email protected]"
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/lib/actions/create.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const create = async ({
if (stackCreated === true) {
process.stdout.write('Stack created.\n')

const postUpdateHandlers = Object.keys(siteInfo.pluginSettings || {}).map((pluginKey) =>
const postUpdateHandlers = Object.keys(siteInfo.plugins || {}).map((pluginKey) =>
[pluginKey, plugins[pluginKey].postUpdateHandler]
)
.filter(([, postUpdateHandler]) => postUpdateHandler !== undefined)
Expand All @@ -72,7 +72,7 @@ const create = async ({
syncSiteContent({ credentials, noBuild, siteInfo }),
createOrUpdateDNSRecords({ credentials, siteInfo }),
...(postUpdateHandlers.map(([pluginKey, handler]) =>
handler({ settings : siteInfo.pluginSettings[pluginKey], siteInfo })))
handler({ pluginData : siteInfo.plugins[pluginKey], siteInfo })))
])

try {
Expand Down
17 changes: 8 additions & 9 deletions src/lib/actions/destroy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ const destroy = async ({ db, siteInfo, verbose }) => {

// this method provides user udptaes
try {
progressLogger?.write('Deleting site bucket...\n')
if (verbose === true) { progressLogger?.write('Deleting site bucket...\n') }
await emptyBucket({ bucketName, doDelete : true, s3Client, verbose })
} catch (e) {
if (e.name === 'NoSuchBucket') {
progressLogger?.write('Bucket already deleted.\n')
if (verbose === true) { progressLogger?.write('Bucket already deleted.\n') }
} else {
throw e
}
Expand All @@ -29,32 +29,31 @@ const destroy = async ({ db, siteInfo, verbose }) => {
const siteTemplate = new SiteTemplate({ credentials, siteInfo })
await siteTemplate.destroyPlugins()

progressLogger.write('Deleting stack...\n')
if (verbose === true) { progressLogger.write('Deleting stack') }
const cloudFormationClient = new CloudFormationClient({ credentials })
const deleteStackCommand = new DeleteStackCommand({ StackName : stackName })
await cloudFormationClient.send(deleteStackCommand)

// the delete command is doesn't mind if the bucket doesn't exist, but trackStackStatus does
try {
const finalStatus = await trackStackStatus({ cloudFormationClient, noDeleteOnFailure : true, stackName })
progressLogger?.write('Final status: ' + finalStatus + '\n')
if (verbose === true) { progressLogger?.write('\nFinal status: ' + finalStatus + '.') }

if (finalStatus === 'DELETE_FAILED') {
progressLogger?.write('\nThe delete is expected to fail at first because the \'replicated Lambda functions\' take a while to clear and the stack cannot be fully deleted until AWS clears the replicated functions. Give it at least 30 min and up to a few hours and try again.')
return false
} else if (finalStatus === 'DELETE_COMPLETE') {
} else if (finalStatus === 'DELETE_COMPLETE') { // actually, we should never see this, see note below
return true
}
} catch (e) {
// oddly, if the stack does not exist we get a ValidationError; which means it's already deleted
// if the stack does not exist we get a ValidationError; so this is the expected outcome when deleting a stack as
// the last call for update will result in a validation error.
if (e.name === 'ValidationError') {
progressLogger.write(' already deleted.\n')
return true
} else {
throw e
}
} finally {
progressLogger?.write('\n')
if (verbose === true) { progressLogger?.write('\n') }
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/lib/actions/import.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ const doImport = async ({ commonLogsBucket, db, domain, region, sourcePath, sour

progressLogger?.write('Loading plugins data...\n')

const pluginSettings = {}
siteInfo.pluginSettings = pluginSettings
const pluginsData = {}
siteInfo.plugins = pluginsData

for (const pluginName of Object.keys(plugins)) {
progressLogger?.write(`Importing plugin settings for '${pluginName}'...\n`)
Expand All @@ -64,7 +64,7 @@ const doImport = async ({ commonLogsBucket, db, domain, region, sourcePath, sour
throw new Error(`Plugin '${pluginName}' does not define 'importHandler'; cannot continue with import.`)
}

await importHandler({ credentials, name : pluginName, pluginSettings, siteInfo, template })
await importHandler({ credentials, name : pluginName, pluginsData, siteInfo, template })
}

return siteInfo
Expand Down
6 changes: 3 additions & 3 deletions src/lib/actions/lib/update-plugins.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import * as plugins from '../../plugins'

const updatePlugins = async ({ credentials, siteInfo }) => {
const { apexDomain, pluginSettings } = siteInfo
const { apexDomain, plugins : pluginsData } = siteInfo
const updates = []

for (const [pluginKey, settings] of Object.entries(pluginSettings)) {
for (const [pluginKey, pluginData] of Object.entries(pluginsData)) {
const plugin = plugins[pluginKey]
if (plugin === undefined) {
throw new Error(`Unknown plugin found in '${apexDomain}' during update.`)
}

const { updateHandler } = plugin
updates.push(updateHandler?.({ credentials, siteInfo, settings }))
updates.push(updateHandler?.({ credentials, pluginData, siteInfo }))
}

await Promise.all(updates)
Expand Down
8 changes: 4 additions & 4 deletions src/lib/plugins/cloudfront-logs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,28 @@ const config = {
}
}

const importHandler = ({ /* credentials, */ name, pluginSettings, /* siteInfo, */ template }) => {
const importHandler = ({ /* credentials, */ name, pluginsData, /* siteInfo, */ template }) => {
const cloudFrontLoggingConfig = template.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.Logging
if (cloudFrontLoggingConfig !== undefined) {
const settings = {
includeCookies : cloudFrontLoggingConfig.IncludeCookies
}
pluginSettings[name] = settings
pluginsData[name] = settings
}
}

const preStackDestroyHandler = async ({ siteTemplate }) => {
await siteTemplate.destroyCommonLogsBucket()
}

const stackConfig = async ({ siteTemplate, settings }) => {
const stackConfig = async ({ siteTemplate, pluginData }) => {
const { finalTemplate } = siteTemplate

await siteTemplate.enableCommonLogsBucket()

finalTemplate.Resources.SiteCloudFrontDistribution.Properties.DistributionConfig.Logging = {
Bucket : { 'Fn::GetAtt' : ['commonLogsBucket', 'DomainName'] },
IncludeCookies : settings.includeCookies,
IncludeCookies : pluginData.settings.includeCookies,
Prefix : 'cloudfront-logs/'
}
}
Expand Down
Loading

0 comments on commit af02884

Please sign in to comment.