Skip to content

Commit

Permalink
feat: archive volumes option (#19)
Browse files Browse the repository at this point in the history
* fix: use correct tag name for 'get boxes'

* feat(stop): archive volumes option
  • Loading branch information
dwmkerr committed Jan 24, 2024
1 parent 2d699a2 commit 19a6bfe
Show file tree
Hide file tree
Showing 11 changed files with 118 additions and 44 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ $ boxes start steambox
Options:

- `--wait`: wait for instance to complete startup
- `--archive-volumes`: [experimental] detach, snapshot and delete instance volumes

Note that the `--archive-volumes` option is experimental and may cause data loss.

### `boxes stop`

Expand Down Expand Up @@ -351,6 +354,7 @@ Quick and dirty task-list.
### Alpha
- [ ] bug: stat/stop show 'pending' rather than 'stopped' due to order of logging
- [x] feat: document copy password in connect, maybe better default off
- [ ] refactor: suck it up and use TS
- [ ] feat: read AWS region from config file, using node-configuration
Expand All @@ -376,6 +380,7 @@ Quick and dirty task-list.
### Later
- [ ] refactor: 'wait' functions can be generalised to take a predicate that uses AWS calls and then share the same loop/logging/etc
- [ ] refactor: make 'debug' command local/debug build only?
- [ ] feat: 'import' command to take an instance ID and create local box config for it and tag the instance
- [ ] docs: cost allocation tags blog post
Expand All @@ -398,8 +403,8 @@ This would be demo-able.
### Epic - Volume Management
- [x] test '-wait' on start/stop and doc
- [ ] 'start' can now check for 'has archived volumes' and restore if available, this is the next big one
- [ ] propagate tags w/test
- [ ] 'start' can now check for 'has archived volumes' and restore if available, this is the next big one
- [ ] delete tag on volume restore...
- [ ] ...so that we can auto restore volumes when calling 'start' - which will need to wait for the volumes to be ready
- [ ] auto-restore on start, this is a good incremental point to check-in, even
Expand Down
28 changes: 25 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ import { TerminatingWarning } from "./lib/errors";
import packageJson from "../package.json";
import { BoxState } from "./box";
import { assertConfirmation } from "./lib/cli-helpers";

// While we're developing, debug output is always enabled.
dbg.enable("boxes*");
import { getConfiguration } from "./configuration";

const ERROR_CODE_WARNING = 1;
const ERROR_CODE_CONNECTION = 2;
Expand Down Expand Up @@ -102,10 +100,22 @@ program
.description("Stop a box")
.argument("<boxId>", 'id of the box, e.g: "steambox"')
.option("-w, --wait", "wait for box to complete startup", false)
.option("-a, --archive-volumes", "[experimental] archive volumes", false)
.option("-y, --yes", "confirm archive volumes", false)
.action(async (boxId, options) => {
// If archiving, demand confirmation.
if (options.archiveVolumes && !options.yes) {
await assertConfirmation(
options,
"yes",
`The '--archive-volumes' feature is experimental and may cause data loss.
To accept this risk, re-run with the '--yes' parameter.`,
);
}
const { instanceId, currentState, previousState } = await stop({
boxId,
wait: options.wait,
archiveVolumes: options.archiveVolumes,
});
console.log(
` ${theme.boxId(boxId)} (${instanceId}): ${theme.state(
Expand Down Expand Up @@ -179,6 +189,18 @@ program

async function run() {
try {
// We will quickly check configuration for debug tracing. If it throws we
// will ignore for now, as later error handling will show the proper error
// output to the user.
try {
const configuration = await getConfiguration();
if (configuration.debugEnable) {
dbg.enable(configuration.debugEnable);
}
} catch {
// no-op
}

await program.parseAsync();
// TODO(refactor): better error typing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
3 changes: 1 addition & 2 deletions src/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ export async function debug(command: string, parameters: string[]) {
return;
}

// TODO fix tag settings
const tags = [{ key: "boxes.boxid", value: "torrentbox" }];
const tags = [{ Key: "boxes.boxid", Value: "debug" }];
console.log("Getting detachable volumes...");
const detachableVolumes = await getDetachableVolumes(instanceId);
logJson(detachableVolumes);
Expand Down
11 changes: 8 additions & 3 deletions src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,25 @@ export async function start(options: StartOptions): Promise<BoxTransition> {
const startingInstances = response.StartingInstances?.find(
(si) => si.InstanceId === box.instanceId,
);
const previousState = awsStateToBoxState(
startingInstances?.PreviousState?.Name,
);
let currentState = awsStateToBoxState(startingInstances?.CurrentState?.Name);

// If the wait flag has been specified, wait for the instance to enter
// the 'started' state.
if (wait) {
console.log(
` waiting for ${boxId} to startup - this may take some time...`,
);
waitForInstanceState(client, box.instanceId, "running");
await waitForInstanceState(client, box.instanceId, "running");
currentState = BoxState.Running; // hacky-ish, but we know it's stopped now...
}

return {
boxId,
instanceId: box.instanceId,
currentState: awsStateToBoxState(startingInstances?.CurrentState?.Name),
previousState: awsStateToBoxState(startingInstances?.PreviousState?.Name),
currentState,
previousState,
};
}
4 changes: 2 additions & 2 deletions src/commands/stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe("stop", () => {
mock.restore();
});

test.skip("can stop boxes", async () => {
test("can stop boxes", async () => {
// Record fixtures with:
// AWS_PROFILE=dwmkerr aws ec2 describe-instances --filters "Name=tag:boxes.boxid,Values=*" > ./src/fixtures/aws-ec2-describe-instances.json
// aws ec2 stop-instances --instance-ids i-043a3c1ce6c9ea6ad | tee ./src/fixtures/instances-stop-steambox.json
Expand All @@ -37,7 +37,7 @@ describe("stop", () => {
.on(StopInstancesCommand)
.resolves(instancesStopSteambox);

await stop({ boxId: "steambox", wait: false });
await stop({ boxId: "steambox", wait: false, archiveVolumes: false });

expect(ec2Mock).toHaveReceivedCommand(DescribeInstancesCommand);
expect(ec2Mock).toHaveReceivedCommandWith(StopInstancesCommand, {
Expand Down
38 changes: 31 additions & 7 deletions src/commands/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@ import dbg from "debug";
import { EC2Client, StopInstancesCommand } from "@aws-sdk/client-ec2";
import { TerminatingWarning } from "../lib/errors";
import { getBoxes } from "../lib/get-boxes";
import { awsStateToBoxState } from "../box";
import { BoxState, awsStateToBoxState } from "../box";
import { getConfiguration } from "../configuration";
import { BoxTransition } from "./start";
import { waitForInstanceState } from "../lib/aws-helpers";
import { getDetachableVolumes, snapshotTagDeleteVolumes } from "../lib/volumes";
import { tagNames } from "../lib/constants";

const debug = dbg("boxes:stop");

export interface StopOptions {
boxId: string;
wait: boolean;
archiveVolumes: boolean;
}

export async function stop(options: StopOptions): Promise<BoxTransition> {
const { boxId, wait } = options;
const { boxId, wait, archiveVolumes } = options;

// Get the box, fail with a warning if it is not found.
const boxes = await getBoxes();
Expand Down Expand Up @@ -47,20 +50,41 @@ export async function stop(options: StopOptions): Promise<BoxTransition> {
const stoppingInstance = response.StoppingInstances?.find(
(si) => si.InstanceId === box.instanceId,
);
const previousState = awsStateToBoxState(
stoppingInstance?.PreviousState?.Name,
);
let currentState = awsStateToBoxState(stoppingInstance?.CurrentState?.Name);

// If the wait flag has been specified, wait for the instance to enter
// the 'started' state.
if (wait) {
// the 'started' state. We also must wait if we are archiving.
if (wait || archiveVolumes) {
console.log(
` waiting for ${boxId} to shutdown - this may take some time...`,
);
waitForInstanceState(client, box.instanceId, "stopped");
await waitForInstanceState(client, box.instanceId, "stopped");
currentState = BoxState.Stopped; // hacky-ish, but we know it's stopped now...
}

// If we are archiving the volumes, do so now before we try and stop the box.
// Make sure to tag the snapshots with the box id so that we track its costs
// and can restore the tag to the volume later.
if (archiveVolumes) {
const volumes = await getDetachableVolumes(box.instanceId);
console.log(
` archiving ${volumes.length} volume(s), this may take some time...`,
);
await snapshotTagDeleteVolumes(box.instanceId, volumes, [
{
Key: tagNames.boxId,
Value: boxId,
},
]);
}

return {
boxId,
instanceId: box.instanceId,
currentState: awsStateToBoxState(stoppingInstance?.CurrentState?.Name),
previousState: awsStateToBoxState(stoppingInstance?.PreviousState?.Name),
currentState,
previousState,
};
}
46 changes: 31 additions & 15 deletions src/lib/aws-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export function snapshotDetailsFromTag(tagValue: string) {
export async function waitForVolumeReady(
client: EC2Client,
volumeId: string,
interval = 5000,
maxAttempts = 60,
intervalMs: number = 5 * 1000,
timeoutMs: number = 60 * 1000,
) {
// When running unit tests in Jest we can return immediately as
// all service calls are mocked to correct values. This function
Expand All @@ -62,7 +62,8 @@ export async function waitForVolumeReady(
return true;
}

let attempts = 0;
const timeStart = new Date();
let currentMs = 0;
let volumeState = "";

do {
Expand All @@ -85,13 +86,22 @@ export async function waitForVolumeReady(
return true;
}

attempts++;
currentMs = new Date().getTime() - timeStart.getTime();
const currentSeconds = Math.round(currentMs / 1000);
debug(
`waiting for volume ${volumeId} to be in a ready state (attempt ${attempts}/${maxAttempts})...`,
`waited ${currentSeconds}s/${
timeoutMs / 1000
}s for ${volumeId} to be in target state 'ready'...`,
);

await new Promise((resolve) => setTimeout(resolve, interval));
} while (attempts < maxAttempts);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
} while (currentMs <= timeoutMs);

debug(
`timeout after ${
currentMs / 1000
}s waiting for ${volumeId} to be in target state 'ready'`,
);

return false;
}
Expand All @@ -108,8 +118,8 @@ export async function waitForInstanceState(
client: EC2Client,
instanceId: string,
targetState: EC2InstanceState,
interval = 5000,
maxAttempts = 60,
intervalMs: number = 5 * 1000,
timeoutMs: number = 60 * 1000,
) {
// When running unit tests in Jest we can return immediately as
// all service calls are mocked to correct values. This function
Expand All @@ -118,7 +128,8 @@ export async function waitForInstanceState(
return true;
}

let attempts = 0;
const timeStart = new Date();
let currentMs = 0;

do {
try {
Expand All @@ -142,16 +153,21 @@ export async function waitForInstanceState(
);
}

attempts++;
currentMs = new Date().getTime() - timeStart.getTime();
const currentSeconds = Math.round(currentMs / 1000);
debug(
`waiting for instance ${instanceId} to be in target state '${targetState}' (attempt ${attempts}/${maxAttempts})...`,
`waited ${currentSeconds}s/${
timeoutMs / 1000
}s for ${instanceId} to be in target state '${targetState}'...`,
);

await new Promise((resolve) => setTimeout(resolve, interval));
} while (attempts < maxAttempts);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
} while (currentMs <= timeoutMs);

debug(
`timeout waiting for instance ${instanceId} to be in the target state '${targetState}'`,
`timeout after ${
currentMs / 1000
}s waiting for ${instanceId} to be in target state '${targetState}'`,
);
return false;
}
10 changes: 9 additions & 1 deletion src/lib/get-boxes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getBoxes } from "./get-boxes";
import describeInstancesResponse from "../fixtures/get-boxes-describe-instances.json";
import describeInstancesWithArchivedVolumesResponse from "../fixtures/get-boxes-describe-instances-with-archived-volumes.json";
import { BoxState } from "../box";
import { tagNames } from "./constants";

describe("get-boxes", () => {
// Mock the config file.
Expand All @@ -32,7 +33,14 @@ describe("get-boxes", () => {

const boxes = await getBoxes();

expect(ec2Mock).toHaveReceivedCommand(DescribeInstancesCommand);
expect(ec2Mock).toHaveReceivedCommandWith(DescribeInstancesCommand, {
Filters: [
{
Name: `tag:${tagNames.boxId}`,
Values: ["*"],
},
],
});
expect(boxes[0]).toMatchObject({
boxId: "torrentbox",
instanceId: "i-08fec1692931e31e7",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/get-boxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export async function getBoxes(): Promise<Box[]> {
// IncludeAllInstances: true,
Filters: [
{
Name: tagNames.boxId,
Name: `tag:${tagNames.boxId}`,
Values: ["*"],
},
],
Expand Down
2 changes: 1 addition & 1 deletion src/lib/volumes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe("volumes", () => {
},
];

const tags = [{ key: tagNames.boxId, value: "torrentbox" }];
const tags = [{ Key: tagNames.boxId, Value: "torrentbox" }];
const result = await snapshotTagDeleteVolumes(
instanceId,
detachableVolumes,
Expand Down
11 changes: 3 additions & 8 deletions src/lib/volumes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DescribeInstancesCommand,
AttachVolumeCommand,
DeleteSnapshotCommand,
Tag,
} from "@aws-sdk/client-ec2";
import { getConfiguration } from "../configuration";
import { TerminatingWarning } from "./errors";
Expand Down Expand Up @@ -89,24 +90,18 @@ export async function getDetachableVolumes(
export async function snapshotTagDeleteVolumes(
instanceId: string,
volumes: DetachableVolume[],
tags: Record<string, string>[],
tags: Tag[],
): Promise<SnapshottedAndDeletedVolume[]> {
// Create an EC2 client.
const { aws: awsConfig } = await getConfiguration();
const client = new EC2Client(awsConfig);
const awsTags = tags.map((tag) => ({
Key: tag.key,
Value: tag.value,
}));
debug(`preparing to snapshot/tag/delete volumes for instance ${instanceId}`);

debug(`waiting for instance ${instanceId} to be in 'stopped' state...`);
const instanceStopped = await aws.waitForInstanceState(
client,
instanceId,
"stopped",
5000,
720, // 5s*720 = 1hr
);
if (!instanceStopped) {
throw new TerminatingWarning(
Expand Down Expand Up @@ -137,7 +132,7 @@ export async function snapshotTagDeleteVolumes(
TagSpecifications: [
{
ResourceType: "snapshot",
Tags: awsTags,
Tags: tags,
},
],
}),
Expand Down

0 comments on commit 19a6bfe

Please sign in to comment.