Skip to content

Commit

Permalink
Sketch first implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
pbrisbin committed Nov 1, 2023
1 parent 47fb55d commit 4f2248e
Show file tree
Hide file tree
Showing 14 changed files with 1,687 additions and 9 deletions.
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
push:
branches: main

# TODO
# concurrency:
# permissions:

jobs:
build:
runs-on: ubuntu-latest
Expand All @@ -17,3 +21,51 @@ jobs:
- run: yarn install
- run: yarn build
- run: yarn test --passWithNoTests

# integration:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: aws-actions/configure-aws-credentials@v4
# with:
# aws-region: ${{ vars.AWS_REGION }}
# role-to-assume: ${{ secrets.AWS_ROLE }}

# - id: lock-1
# name: Acquire for 10s
# uses: ./
# with:
# name: workflows-ci-integration
# expires: 10s

# - id: lock-2
# name: Wait on lock-1 then aquire
# uses: ./
# with:
# name: workflows-ci-integration
# timeout: 15s
# expires: 5s

# - name: Verify locks
# run: |
# cat <<'EOM'
# lock-1 acquired ${{ lock-1.outputs.aquired-at }}
# lock-1 released ${{ lock-1.outputs.released-at }}
# lock-2 acquired ${{ lock-2.outputs.aquired-at }}
# lock-2 released ${{ lock-2.outputs.released-at }}
# EOM

# integration-post:
# needs: integration
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: aws-actions/configure-aws-credentials@v4
# with:
# aws-region: ${{ vars.AWS_REGION }}
# role-to-assume: ${{ secrets.AWS_ROLE }}
# - name: Assert lock was released
# uses: ./
# with:
# name: workflows-ci-integration
# timeout: 0s
9 changes: 5 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ inputs:
description: "Prefix for lock files within s3-bucket"
required: false
default: ""
lease:
expires:
description: |
How long to aquire the lock for. Default is 5m.
How long to aquire the lock for. Default is 15m.
required: true
default: 5m
default: 15m
timeout:
description: |
How long to wait for the lock to become available. Default matches lease.
How long to wait for the lock to become available. Default matches
expires.
required: false
timeout-poll:
description: |
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/exec": "^1.1.1",
"@actions/github": "^6.0.0"
"@actions/github": "^6.0.0",
"@aws-sdk/client-s3": "^3.440.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@actions/glob": "^0.4.0",
"@octokit/plugin-rest-endpoint-methods": "^10.1.2",
"@octokit/types": "^12.1.1",
"@types/jest": "^27.4.0",
"@types/node": "^20.8.9",
"@types/uuid": "^9.0.6",
"@vercel/ncc": "^0.38.1",
"jest": "^27.4.7",
"prettier": "^3.0.3",
Expand Down
106 changes: 106 additions & 0 deletions src/S3Lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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 { v4 as uuidv4 } from "uuid";

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

export type AcquireLockResult = "acquired" | "not-acquired";

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,
) {
this.bucket = bucket;
this.prefix = normalizePrefix(prefix);
this.name = name;
this.uuid = uuid ? uuid : uuidv4();
this.expires = expires;

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

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

const output = await this.listLocks();
const oldestKey = this.getOldestKey(output);

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

await this.releaseLock();
return "not-acquired";
}

async releaseLock(): Promise<void> {
await this.s3.send(
new S3.DeleteObjectCommand({
Bucket: this.bucket,
Key: this.key,
}),
);
}

private async createLock(): Promise<void> {
await this.s3.send(
new S3.PutObjectCommand({
Bucket: this.bucket,
Key: this.key,
Expires: this.expires.after(new Date()),
}),
);
}

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");
}

const contents = output.Contents ?? [];

if (contents.length === 0) {
// If our own lock didn't get written/returned, something is very wrong
throw new Error("No lock objects found");
}

const sorted = contents.sort(compareObjects);
const sortedKey = sorted[0].Key;

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

return sortedKey;
}
}
45 changes: 45 additions & 0 deletions src/acquire.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as core from "@actions/core";

import { S3Lock } from "./S3Lock";
import { getInputs } from "./inputs";
import { Timer } from "./timer";

async function run() {
try {
const { name, s3Bucket, s3Prefix, 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);

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

if (result === "acquired") {
break;
}

if (timer.expired()) {
throw new Error("Lock was not acquired within timeout");
}

await timer.sleep(timeoutPoll);
}

core.setOutput("acquired-at", new Date());
core.info("Lock acquired");
} catch (error) {
if (error instanceof Error) {
core.setFailed(error.message);
} else if (typeof error === "string") {
core.setFailed(error);
} else {
core.setFailed("Non-Error exception");
}
}
}

run();
Loading

0 comments on commit 4f2248e

Please sign in to comment.