diff --git a/action.yml b/action.yml index 80b3381..48a4f76 100644 --- a/action.yml +++ b/action.yml @@ -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. diff --git a/src/S3Lock.ts b/src/S3Lock.ts index ca4fd75..912d920 100644 --- a/src/S3Lock.ts +++ b/src/S3Lock.ts @@ -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 { - 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 { + 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 { - 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 { - 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 { - 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 { + const s3 = new S3Client(); + core.debug(`[s3] DeleteObject ${key}`); + + await s3.send( + new DeleteObjectCommand({ + Bucket: bucket, + Key: key, + }), + ); } } diff --git a/src/acquire.ts b/src/acquire.ts index aa536f3..f6eb84f 100644 --- a/src/acquire.ts +++ b/src/acquire.ts @@ -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; } @@ -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); diff --git a/src/duration.ts b/src/duration.ts index 055679d..b15d9a1 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -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()); } diff --git a/src/inputs.ts b/src/inputs.ts index 8ef0eda..e7475fd 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -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 }; } diff --git a/src/normalize-prefix.test.ts b/src/normalize-prefix.test.ts deleted file mode 100644 index 13c5740..0000000 --- a/src/normalize-prefix.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { normalizePrefix } from "./normalize-prefix"; - -test("empty values are returned as is", () => { - expect(normalizePrefix("")).toBe(""); - expect(normalizePrefix(" ")).toBe(""); -}); - -test("non-empty values drop leading slash and ensure trailing", () => { - expect(normalizePrefix("some/thing ")).toBe("some/thing/"); - expect(normalizePrefix("some/thing/ ")).toBe("some/thing/"); - expect(normalizePrefix("/some/thing")).toBe("some/thing/"); - expect(normalizePrefix("/some/thing/")).toBe("some/thing/"); - expect(normalizePrefix(" some/thing ")).toBe("some/thing/"); -}); diff --git a/src/normalize-prefix.ts b/src/normalize-prefix.ts deleted file mode 100644 index 85bfbb3..0000000 --- a/src/normalize-prefix.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function normalizePrefix(raw: string): string { - const trimmed = raw.trim(); - - if (trimmed === "") { - return trimmed; - } - - const leadingSlashRemoved = trimmed.startsWith("/") - ? trimmed.substring(1) - : trimmed; - - const trailingSlashAdded = leadingSlashRemoved.endsWith("/") - ? leadingSlashRemoved - : `${leadingSlashRemoved}/`; - - return trailingSlashAdded; -} diff --git a/src/release.ts b/src/release.ts index 2851a8e..bca3418 100644 --- a/src/release.ts +++ b/src/release.ts @@ -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); diff --git a/src/sort-objects.test.ts b/src/sort-objects.test.ts deleted file mode 100644 index fa669a9..0000000 --- a/src/sort-objects.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { compareObjects } from "./sort-objects"; - -test("compares last modified first", () => { - const newer = { LastModified: new Date("2023-02-01"), Key: "" }; - const older = { LastModified: new Date("2023-01-01"), Key: "" }; - - expect(compareObjects(older, newer)).toEqual(-1); - expect(compareObjects(older, older)).toEqual(0); - expect(compareObjects(newer, older)).toEqual(1); -}); - -test("compares key if last modified is the same", () => { - const date = new Date("2023-01-01"); - const uuid1 = { LastModified: date, Key: "uuid1" }; - const uuid2 = { LastModified: date, Key: "uuid2" }; - expect(compareObjects(uuid1, uuid2)).toEqual(-1); - expect(compareObjects(uuid1, uuid1)).toEqual(0); - expect(compareObjects(uuid2, uuid1)).toEqual(1); -}); - -test("sorting", () => { - const date1 = new Date("2023-01-01"); - const date2 = new Date("2023-01-02"); - const date3 = new Date("2023-01-03"); - - const objects = [ - { LastModified: date2, Key: "prefix/lock.uuid1" }, - { LastModified: date3, Key: "prefix/lock.uuid3" }, - { LastModified: date1, Key: "prefix/lock.uuid2" }, - { LastModified: date3, Key: "prefix/lock.uuid4" }, - ]; - - expect(objects.sort(compareObjects)).toEqual([ - { LastModified: date1, Key: "prefix/lock.uuid2" }, - { LastModified: date2, Key: "prefix/lock.uuid1" }, - { LastModified: date3, Key: "prefix/lock.uuid3" }, - { LastModified: date3, Key: "prefix/lock.uuid4" }, - ]); -}); diff --git a/src/sort-objects.ts b/src/sort-objects.ts deleted file mode 100644 index 593e738..0000000 --- a/src/sort-objects.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface Object { - LastModified?: Date; - Key?: string; -} - -export function compareObjects(a: Object, b: Object): number { - return compareBy(a, b, [(x) => x.LastModified, (x) => x.Key]); -} - -function compareBy(a: T, b: T, fns: ((arg: T) => any)[]): number { - // Go through each function - for (const fn of fns) { - // Call it on both items - const ax = fn(a); - const bx = fn(b); - - // If there's a difference, compare by it - if (ax !== bx) { - return ax > bx ? 1 : -1; - } - } - - // If we get here, all functions return equal values (or there were none) - return 0; -}