Skip to content

Commit

Permalink
Refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
pbrisbin committed Nov 2, 2023
1 parent f68f82e commit 29f9107
Show file tree
Hide file tree
Showing 10 changed files with 76 additions and 211 deletions.
4 changes: 0 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ inputs:
s3-bucket:
description: "Name of existing S3 bucket to use. If empty, one will be created."
required: true
s3-prefix:
description: "Prefix for lock files within s3-bucket"
required: false
default: ""
expires:
description: |
How long to acquire the lock for. Default is 15m.
Expand Down
138 changes: 50 additions & 88 deletions src/S3Lock.ts
Original file line number Diff line number Diff line change
@@ -1,116 +1,78 @@
// https://stackoverflow.com/questions/45222819/can-pseudo-lock-objects-be-used-in-the-amazon-s3-api/75347123#75347123

import * as S3 from "@aws-sdk/client-s3";
import * as core from "@actions/core";
import { v4 as uuidv4 } from "uuid";

import type { S3Client as S3ClientType } from "@aws-sdk/client-s3";
import {
S3Client,
PutObjectCommand,
ListObjectsV2Command,
DeleteObjectCommand,
} from "@aws-sdk/client-s3";

import { mapMaybe } from "./maybe";
import { Duration } from "./duration";
import { compareObjects } from "./sort-objects";
import { normalizePrefix } from "./normalize-prefix";

export type AcquireLockResult = "acquired" | "not-acquired";
import { createObjectKey, validateObjectKey } from "./S3LockExt";

export class S3Lock {
bucket: string;
prefix: string;
name: string;
uuid: string;
expires: Duration;

private key: string;
private keyPrefix: string;
private s3: S3.S3Client;

constructor(
bucket: string,
prefix: string,
name: string,
expires: Duration,
uuid?: string,
) {
private bucket: string;
private prefix: string;
private expires: Duration;
private s3: S3ClientType;

constructor(bucket: string, name: string, expires: Duration) {
this.bucket = bucket;
this.prefix = normalizePrefix(prefix);
this.name = name;
this.uuid = uuid ? uuid : uuidv4();
this.prefix = `${name}.`;
this.expires = expires;

this.keyPrefix = `${this.prefix}${this.name}.`;
this.key = `${this.keyPrefix}${this.uuid}`;
this.s3 = new S3.S3Client();
this.s3 = new S3Client();
}

async acquireLock(): Promise<AcquireLockResult> {
await this.createLock();

const output = await this.listLocks();
const outputKeys = (output.Contents || []).map((o) => o.Key);
const oldestKey = this.getOldestKey(output);

core.debug(`Keys\n ${outputKeys.join("\n ")}`);
core.debug(`Oldest: ${oldestKey}`);
async acquireLock(): Promise<string | null> {
const key = createObjectKey(this.prefix, this.expires);

if (oldestKey === this.key) {
return "acquired";
}

await this.releaseLock();
return "not-acquired";
}
core.debug(`[s3] PutObject ${key}`);

async releaseLock(): Promise<void> {
core.debug(`DELETE ${this.key}`);
await this.s3.send(
new S3.DeleteObjectCommand({
Bucket: this.bucket,
Key: this.key,
}),
new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: "" }),
);
}

private async createLock(): Promise<void> {
const expires = this.expires.after(new Date());

core.debug(`PUT ${this.key} (Expires: ${expires})`);
core.debug(`[s3] ListObjectsV2 ${this.prefix}`);

await this.s3.send(
new S3.PutObjectCommand({
const output = await this.s3.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Key: this.key,
Expires: expires,
Prefix: this.prefix,
}),
);
}

private async listLocks(): Promise<S3.ListObjectsV2Output> {
return await this.s3.send(
new S3.ListObjectsV2Command({
Bucket: this.bucket,
Prefix: this.keyPrefix,
}),
);
}

private getOldestKey(output: S3.ListObjectsV2Output): string {
if (output.IsTruncated) {
// If we've got > ~1,000 locks here, something is very wrong
throw new Error("Too many lock objects present");
throw new Error("TODO");
}

const contents = output.Contents ?? [];
const keys = mapMaybe(output.Contents || [], (o) =>
validateObjectKey(this.prefix, o),
).sort();

if (contents.length === 0) {
// If our own lock didn't get written/returned, something is very wrong
throw new Error("No lock objects found");
}
core.debug(`Keys:\n- ${keys.join("\n- ")}`);

const sorted = contents.sort(compareObjects);
const sortedKey = sorted[0].Key;
if (keys.length === 0) {
throw new Error("TODO");
}

if (!sortedKey) {
// If the thing doesn't have a Key, something is very wrong
throw new Error("Oldest object has no Key");
if (keys[0] === key) {
return key;
}

return sortedKey;
await S3Lock.releaseLock(this.bucket, key);
return null;
}

static async releaseLock(bucket: string, key: string): Promise<void> {
const s3 = new S3Client();
core.debug(`[s3] DeleteObject ${key}`);

await s3.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
}),
);
}
}
19 changes: 8 additions & 11 deletions src/acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ import { Timer } from "./timer";

async function run() {
try {
const { name, s3Bucket, s3Prefix, expires, timeout, timeoutPoll } =
getInputs();
const { name, bucket, expires, timeout, timeoutPoll } = getInputs();

const timer = new Timer(timeout);
const s3Lock = new S3Lock(s3Bucket, s3Prefix, name, expires);

// Used to instantiate the same S3Lock for release
core.saveState("uuid", s3Lock.uuid);
const s3Lock = new S3Lock(bucket, name, expires);

while (true) {
let result = await s3Lock.acquireLock();
let key = await s3Lock.acquireLock();

if (result === "acquired") {
if (key) {
core.info(`Lock acquired at s3://${bucket}${key}`);
core.setOutput("acquired-at", new Date());
core.setOutput("key", key);
core.saveState("key", key);
break;
}

Expand All @@ -28,9 +28,6 @@ async function run() {

await timer.sleep(timeoutPoll);
}

core.setOutput("acquired-at", new Date());
core.info("Lock acquired");
} catch (error) {
if (error instanceof Error) {
core.setFailed(error.message);
Expand Down
4 changes: 4 additions & 0 deletions src/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ export class Duration {
return new Date(date.valueOf() + this._milliseconds);
}

before(date: DateLike): Date {
return new Date(date.valueOf() - this._milliseconds);
}

static since(date: DateLike): Duration {
return new Duration(new Date().valueOf() - date.valueOf());
}
Expand Down
17 changes: 8 additions & 9 deletions src/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,26 @@ import * as core from "@actions/core";
import { Duration } from "./duration";

export type Inputs = {
bucket: string;
name: string;
s3Bucket: string;
s3Prefix: string;
expires: Duration;
timeout: Duration;
timeoutPoll: Duration;
};

export function getInputs(): Inputs {
// Required
const bucket = core.getInput("bucket", { required: true });
const name = core.getInput("name", { required: true });
const s3Bucket = core.getInput("s3-bucket", { required: true });
const s3Prefix = core.getInput("s3-prefix", { required: false });

// Optional or defaulted
const rawExpires = core.getInput("expires", { required: true });
const expires = Duration.parse(rawExpires);

const rawTimeout = core.getInput("timeout", { required: false });
const timeout = rawTimeout === "" ? expires : Duration.parse(rawTimeout);

const rawTimeoutPoll = core.getInput("timeout-poll", { required: true });

const expires = Duration.parse(rawExpires);
const timeout = rawTimeout === "" ? expires : Duration.parse(rawTimeout);
const timeoutPoll = Duration.parse(rawTimeoutPoll);

return { name, s3Bucket, s3Prefix, expires, timeout, timeoutPoll };
return { name, bucket, expires, timeout, timeoutPoll };
}
14 changes: 0 additions & 14 deletions src/normalize-prefix.test.ts

This file was deleted.

17 changes: 0 additions & 17 deletions src/normalize-prefix.ts

This file was deleted.

10 changes: 6 additions & 4 deletions src/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { getInputs } from "./inputs";

async function run() {
try {
const { name, s3Bucket, s3Prefix, expires } = getInputs();
const uuid = core.getState("uuid");
const s3Lock = new S3Lock(s3Bucket, s3Prefix, name, expires, uuid);
await s3Lock.releaseLock();
const key = core.getState("key");

if (key !== "") {
const { bucket } = getInputs();
await S3Lock.releaseLock(bucket, key);
}
} catch (error) {
if (error instanceof Error) {
core.setFailed(error.message);
Expand Down
39 changes: 0 additions & 39 deletions src/sort-objects.test.ts

This file was deleted.

25 changes: 0 additions & 25 deletions src/sort-objects.ts

This file was deleted.

0 comments on commit 29f9107

Please sign in to comment.