amplify-hosting icon indicating copy to clipboard operation
amplify-hosting copied to clipboard

How to delete Preview environments?

Open hassankhan opened this issue 5 years ago • 11 comments

** Please describe which feature you have a question about? **

I made a PR on a private GitHub repo with a successful Amplify CI build and Preview link, I expected that closing the PR would delete the environment automatically. Failing that, I expected to see an 'Delete Preview Environment' option somewhere in the Amplify CI Console.

Possibly related to #277

hassankhan avatar Feb 29 '20 16:02 hassankhan

Just to update, the Preview environment has disappeared by itself, no action on my part. Would be great if the Preview environment setup/teardown process was better documented 😞

hassankhan avatar Feb 29 '20 17:02 hassankhan

@hassankhan the previews are automatically deleted when they are closed without requiring any action on your part. What could be better documented?

swaminator avatar Feb 29 '20 23:02 swaminator

On the Preview docs page, there is only one line at the end which describes what happens to Preview environments. It would be nice if it clarified that it does indeed work for both merged AND closed pull requests, since currently it seems a bit vague.

Also, would be nice if the Preview status in the Amplify Console updated the PR's status. For example, in the following screenshot (taken from the docs), when a PR is being removed, it would be helpful for the status field to indicate that.

Amplify Console Previews

hassankhan avatar Mar 01 '20 18:03 hassankhan

Hi, I am facing a similar issue, i created and merged a PR with a preview, the entry in pull requests was removed but the environment is still there one day after merging. has any one faced this issue?

guillesha avatar Nov 13 '20 09:11 guillesha

Amplify Preview is broken. It often fails to create an environment, then fails the most basic thing, which is resuming its creation, which means you have to resort to deleting the stack manually, which is truly painful, because it won't delete non-empty S3 buckets and whatnot. There's a myriad of cases where it won't delete everything. To deal with this, I wrote a node script that lists all environments and then lets you delete them. However, it's proprietary to my app so it won't work perfectly for others. If anyone is interested, I might post it. Amplify should halt adding more features and fix these basic things that may otherwise draw out users from the platform.

adi-works avatar Oct 18 '21 12:10 adi-works

@adi518 please post your script if you get a chance so we have a reference idea of how to delete the preview environment related resources. We are experiencing similar problems with our AWS Amplify Preview environments sometimes hanging around after the PR is closed or merged.

salmontecbsi avatar May 16 '22 14:05 salmontecbsi

I have to package it and then I can post it.

adi-works avatar May 16 '22 15:05 adi-works

Also facing this issue, we have some old preview environments from about a year ago that never got deleted. There does not seem to be away from the AWS amplify console to delete them so they are now stuck forever...

sealabcore avatar Aug 30 '22 15:08 sealabcore

I'll post my script, totally forgot about that.

adi-works avatar Aug 31 '22 08:08 adi-works

How to use:

  1. Create a new file delete_stale_envs.ts.
  2. Place the following script in your app (e.g., app-root/scripts).
  3. Install ts-node and enquirer as dev dependencies. If you don't want to use TS, you'll have to migrate the script to JS.
  4. IMPORTANT Edit the BLACKLISTED_ENVS constant to reflect your protected environments correctly.
  5. Run the script with yarn ts-node ./scripts/delete_stale_envs and follow the prompts.
// delete_stale_envs.ts

// https://awscli.amazonaws.com/v2/documentation/api/latest/reference/amplify/index.html#cli-aws-amplify
// https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks?filteringStatus=active&filteringText=&viewNested=true&hideStacks=false

const { Confirm, Select } = require('enquirer');

function execPromise(
  command: string,
  { logStdout, logStderr }: { logStdout: boolean; logStderr: boolean } = { logStdout: true, logStderr: true }
) {
  return new Promise((resolve, reject) => {
    child_process.exec(command, (error, stdout, stderr) => {
      if (error) {
        logStderr && console.error(error);
        reject(error);
      } else {
        logStdout && console.log(stdout);
        logStderr && console.error(stderr);
        try {
          resolve(JSON.parse(stdout));
        } catch (error) {
          resolve(stdout);
        }
      }
    });
  });
}

export async function execPromiseNoLog(command: string) {
  return execPromise(command, { logStdout: false, logStderr: false }).catch(throwError);
}

function throwError(error: Error) {
  throw error;
}

function logError(error: Error) {
  console.error(error.message);
}

const APP_ID = '<APP_ID>';
const APP_NAME = '<APP_NAME>';

// prettier-ignore
const BLACKLISTED_ENVS = [
  'prod',
  'production',
  'dev',
  'development',
  'staging',
  'test',
];

type BackendEnvironment = { [key: string]: any };
type BackendEnvironments = BackendEnvironment[];
type Branch = { [key: string]: any };
type Branches = Branch[];

function confirmPrompt(message = 'Are you sure?') {
  return new Confirm({ name: 'question', message }).run();
}

function SelectPrompt(options: any) {
  return new Select(options).run();
}

async function deleteEnvironment(environmentName: string, stackName: string, bucketNames: string[]) {
  // https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudformation/delete-stack.html
  const deleteStackPromise = execPromiseNoLog(`aws cloudformation delete-stack --stack-name ${stackName}`);
  // https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3/rb.html
  const deleteS3BucketsPromises = bucketNames.map((bucketName) =>
    execPromiseNoLog(`aws s3 rb s3://${bucketName} --force`).catch((error) => {
      if (error.message.includes('NoSuchBucket')) {
        // continue as normal; we don't care about non-existing buckets
      } else {
        throw error;
      }
    })
  );
  // https://awscli.amazonaws.com/v2/documentation/api/latest/reference/amplify/delete-backend-environment.html
  const deleteFromConsolePromise = execPromiseNoLog(`aws amplify delete-backend-environment --app-id ${APP_ID} --environment-name ${environmentName}`);
  return Promise.all([deleteFromConsolePromise, deleteStackPromise, ...deleteS3BucketsPromises]);
}

function getEnvironmentNames(environments: BackendEnvironments) {
  return environments.map((env) => env.environmentName);
}

enum Actions {
  DeleteAllStaleEnvironments = 'delete all stale environments',
  DeleteSingleStaleEnvironment = 'delete single stale environment',
  DeleteSingleEnvironment = 'delete single environment',
}

async function main() {
  // should we delete a single or all environment?
  const selectedAction = await SelectPrompt({
    name: 'action',
    message: 'Select action',
    // prettier-ignore
    choices: [
      Actions.DeleteAllStaleEnvironments,
      Actions.DeleteSingleStaleEnvironment,
      Actions.DeleteSingleEnvironment
    ],
  });
  // prompt for blacklisted environments; we do not want to delete
  // newly user-created environments (e.g., prod, dev, etc').
  // https://stackoverflow.com/a/63338302/4106263
  // https://www.npmjs.com/package/endent
  console.log(`The following environments will *NOT* be deleted.\n\
  If you created a new environment that does not appear in this list,\n\
  please choose NO and add it to the blacklist.`);
  console.table(BLACKLISTED_ENVS);
  const confirmBlacklist = await confirmPrompt();

  if (confirmBlacklist === false) {
    return;
  }

  // https://awscli.amazonaws.com/v2/documentation/api/latest/reference/amplify/list-branches.html
  const branchesPromise = getBranches().catch(throwError);
  const backendEnvironmentsPromise = getBackendEnvironments().catch(throwError);
  const [backendEnvironments = [], branches = []] = await Promise.all([backendEnvironmentsPromise, branchesPromise]);
  // get all environments that don't have a branch;
  // those are the ones we want to delete.
  let backendEnvironmentsToDelete = backendEnvironments.filter((env) => branches.find((branch) => branch.backendEnvironmentArn === env.backendEnvironmentArn) === undefined);
  if (backendEnvironments.length === 0) {
    return console.log('No environments found.');
  }
  if (selectedAction === Actions.DeleteAllStaleEnvironments || selectedAction === Actions.DeleteSingleStaleEnvironment) {
    if (backendEnvironmentsToDelete.length === 0) return console.log('No stale environments found.');
  }
  if (selectedAction === Actions.DeleteSingleStaleEnvironment) {
    const staleEnvironmentNames = getEnvironmentNames(backendEnvironmentsToDelete);
    const selectedEnvironment = await SelectPrompt({
      name: 'Select stale environment',
      choices: staleEnvironmentNames,
    });
    backendEnvironmentsToDelete = [backendEnvironmentsToDelete.find((env) => env.environmentName === selectedEnvironment)];
  } else if (selectedAction === Actions.DeleteSingleEnvironment) {
    console.warn('\nThe following environments are currently in use! proceed with caution.');
    const environmentNames = getEnvironmentNames(backendEnvironments);
    const selectedEnvironment = await SelectPrompt({ name: 'Select environment', choices: environmentNames });
    backendEnvironmentsToDelete = [backendEnvironments.find((env) => env.environmentName === selectedEnvironment)];
  }
  // prompt to confirm deletion of stale environments;
  // in order to delete an environment, we have to delete the following:
  // * delete cloudformation stack
  // * delete S3 buckets
  // * delete environment from amplify console
  //
  // we have to manually delete s3 buckets, because
  // when deleting a cloudformation stack, it does *NOT*
  // delete non-empty buckets; they are not empty because
  // amplify populates them when creating a new environment.
  // every environment consists of two buckets, e.g:
  // `amplify-<APP_NAME>-<ENV>-<BUCKET_ID>-deployment` (where amplify stores all its internals)
  // `<APP_NAME><BUCKET_ID>-<ENV>` (where app content is stored)
  // https://s3.console.aws.amazon.com/s3/home?region=us-east-1#
  //
  console.table(getEnvironmentNames(backendEnvironmentsToDelete));
  const shouldDelete = await confirmPrompt('The following environments will be deleted, please confirm:');
  if (shouldDelete) {
    try {
      const deleteEnvironmentsPromises = backendEnvironmentsToDelete.map((env) => {
        const stackName = env.stackName;
        const environmentName = env.environmentName;
        const environmentId = env.stackName.split('-').pop();
        const deploymentBucket = env.deploymentArtifacts;
        const appContentBucket = `${APP_NAME}${environmentId}-${environmentName}`;
        const bucketNames = [deploymentBucket, appContentBucket];
        return deleteEnvironment(environmentName, stackName, bucketNames);
      });
      await Promise.all(deleteEnvironmentsPromises).catch(throwError);
      if (selectedAction === Actions.DeleteSingleStaleEnvironment || selectedAction === Actions.DeleteSingleEnvironment) {
        console.log('Deleted environment successfully ✓');
      } else {
        console.log('Deleted all environments successfully ✓');
      }
    } catch (error) {
      console.error('Failed to delete environment(s) with the following error:', error);
    }
  }
}

async function getBranches(): Promise<Branches> {
  const response: any = await execPromiseNoLog(`aws amplify list-branches --app-id ${APP_ID}`).catch(throwError);
  return response.branches;
}

async function getBackendEnvironments(): Promise<BackendEnvironments> {
  return new Promise((resolve, reject) => {
    (async () => {
      console.log('\nFetching Amplify backend environments...');
      let nextToken = null;
      let backendEnvironments = [];
      do {
        // https://awscli.amazonaws.com/v2/documentation/api/latest/reference/amplify/list-backend-environments.html
        let command = `aws amplify list-backend-environments --app-id ${APP_ID}`;
        if (nextToken) command = `${command} --next-token ${nextToken}`;
        let response: any = await execPromiseNoLog(command).catch(reject);
        backendEnvironments.push(...response.backendEnvironments);
        nextToken = response.nextToken;
        if (nextToken === null) break;
      } while (nextToken);
      backendEnvironments = backendEnvironments.filter((env) => !BLACKLISTED_ENVS.includes(env.environmentName));
      resolve(backendEnvironments);
    })();
  });
}

main().catch(logError);

adi-works avatar Aug 31 '22 09:08 adi-works

For anyone that stumbles on this and wants to delete orphaned amplify previews, you can use the amplify CLI to delete connected branches. Be careful to only delete your preview branches...

# list branches for app
aws amplify list-branches --app-id <amplify_app_id>

# delete branch for app
aws amplify delete-branch --app-id <amplify_app_id> --branch-name <your_branch_name>

# example
aws amplify delete-branch --app-id <amplify_app_id> --branch-name pr-1000

Your amplify app ID is in the URL of the amplify console and is the last part of the ARN.

allienx avatar Jan 24 '24 17:01 allienx