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

Implement delayed cleanup support #102

Merged
merged 8 commits into from
Mar 22, 2024
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
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