@bedrock-rbx/state-s3
v0.3.3
Published
S3 state backend plugin for Bedrock
Downloads
908
Maintainers
Readme
@bedrock-rbx/state-s3
S3 state backend for bedrock.
Status: 0.1, pre-1.0. The public API is stabilizing; breaking changes may land in minor releases until 1.0.
What it does
Persists bedrock's deployment state in an S3 bucket, one object per environment, so deploying two environments at once never puts them in contention. It also locks: two CI jobs deploying one environment are serialized, and the second waits for the first rather than both creating resources on Roblox. It is an ordinary bedrock plugin: it reaches core only through the published plugin contract, so nothing it does is closed to a third-party backend.
Install
pnpm add @bedrock-rbx/state-s3
# or: npm install @bedrock-rbx/state-s3Use
Name the package under plugins and point state at a bucket:
import { defineConfig } from "@bedrock-rbx/core/config";
export default defineConfig({
environments: { production: {} },
plugins: ["@bedrock-rbx/state-s3"],
state: { backend: "s3", bucket: "my-bucket", region: "eu-west-2" },
});Production state now lives at s3://my-bucket/production.json.
Configuration
| Key | Required | What it does |
| --------------------- | -------- | ------------------------------------------------------------- |
| bucket | yes | Bucket the state objects live in |
| region | yes | Region the bucket lives in |
| prefix | no | Folder the objects are written under |
| endpoint | no | Endpoint to address instead of AWS |
| forcePathStyle | no | Address the bucket as a path segment rather than a subdomain |
| checksumCalculation | no | whenSupported (default) or whenRequired |
| lockTimeoutMs | no | How long to wait for a held environment; 5 minutes by default |
| lockLeaseMs | no | How long a hold is leased for; 1 minute by default |
Credentials
Credentials resolve through the standard AWS Node credential chain, so environment variables, a shared profile, an SSO session, and CI role credentials all work with no bedrock-specific configuration.
BEDROCK_S3_ACCESS_KEY_ID and BEDROCK_S3_SECRET_ACCESS_KEY, plus
BEDROCK_S3_SESSION_TOKEN for a temporary credential, are read ahead of
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, so a machine whose AWS
variables already point at another account can send bedrock somewhere else:
export BEDROCK_S3_ACCESS_KEY_ID="<access-key-id>"
export BEDROCK_S3_SECRET_ACCESS_KEY="<secret-access-key>"Each set is read as a whole credential. Setting one half of the prefixed pair
leaves the AWS_ pair signing on its own, and a prefixed pair takes its session
token from BEDROCK_S3_SESSION_TOKEN alone, ignoring any AWS_SESSION_TOKEN
the environment holds, so no signing pairs one account's key with another's
secret.
Grant s3:GetObject and s3:PutObject on the objects and on the locks beside
them, under the prefix if you configured one. s3:DeleteObject is what lets the
conditional-write probe take its scratch object away again:
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::my-bucket/*"
}Migrating from Mantle
bedrock migrate reads a Mantle state file straight out of the bucket it has
always lived in, so state that was never on disk needs no downloading first.
Install this plugin and name it under plugins first, then run
bedrock migrate with no path and pick S3 as the source.
The coordinates asked for are Mantle's own state.remote block. Mantle keys one
object <project>.mantle-state.yml, so the key you answer with is the project
name, and the object name itself is accepted too:
# mantle.yml
state:
remote:
bucket: my-mantle-states
key: pirate-wars
region:
custom:
name: auto
endpoint: https://<account>.r2.cloudflarestorage.comMigrating onto S3 records what that block says, so the bucket is named once rather than answered again:
export default defineConfig({
environments: { production: {} },
plugins: ["@bedrock-rbx/state-s3"],
state: {
backend: "s3",
bucket: "my-mantle-states",
endpoint: "https://<account>.r2.cloudflarestorage.com",
prefix: "pirate-wars",
region: "auto",
},
});Mantle's key becomes the prefix, so production lands at
s3://my-mantle-states/pirate-wars/production.json and two projects that shared
one bucket under Mantle stay apart. Migrating onto a bucket you did not fetch
from asks for the bucket, the region, and an endpoint you can skip. Credentials
resolve exactly as they do for a deploy.
Mantle's config carries nothing about forcePathStyle or checksumCalculation,
so a migrated config has neither. Add them if your store needs them; see
S3-compatible stores below.
S3-compatible stores
endpoint and forcePathStyle make non-AWS stores reachable, and
checksumCalculation: "whenRequired" drops the checksum headers that some of
them reject:
export default defineConfig({
environments: { production: {} },
plugins: ["@bedrock-rbx/state-s3"],
state: {
backend: "s3",
bucket: "my-bucket",
checksumCalculation: "whenRequired",
endpoint: "http://localhost:9000",
forcePathStyle: true,
region: "us-east-1",
},
});Support for those stores is best effort: only AWS is tested.
Locking
A deploy takes a hold on its environment before it applies anything, and gives
it up once state has been written. The hold is a locks/<environment>.json
object beside the state objects, created conditionally so exactly one run can
hold it. Because locks sit under their own prefix segment, a bucket lifecycle
rule can expire abandoned ones without touching state.
A run that finds the environment held waits, retrying with exponential backoff
for five minutes by default; lockTimeoutMs changes that bound and 0 refuses
immediately. The wait is reported through the progress port while it happens,
and giving up names who holds the environment and since when. A credential that
cannot read the lock record ends the wait at once, so a missing s3:GetObject
is reported as itself rather than as five minutes of contention.
A hold carries a deadline it renews while the deploy runs, so a deploy killed by
a cancelled CI job stops blocking every later one. A hold whose lease is still
being renewed is never taken over, however long the deploy holding it runs; a
hold nothing renews past its deadline is taken over by the next deploy, through
the same conditional write a released hold is taken over with. lockLeaseMs
sets how long a hold is leased for, one minute by default and one second at the
shortest. A lease the backend could not keep is reported through the progress
port, and the state write of a run whose hold was taken over is refused rather
than allowed to overwrite what the run that took it over recorded.
A hold is given up by writing a tombstone over its own record, never by deleting the lock object: conditional delete is not portable across S3-compatible stores, and one of them ignores the condition and deletes anyway.
The run is recorded as BEDROCK_LOCK_OWNER when that is set, as the URL of the
GitHub Actions run when GITHUB_RUN_ID is, and as the local user otherwise.
Proving the store first
Before the first hold of a deploy is taken, the backend proves the store refuses
a create of an object it already holds: it writes a scratch object under
locks/.probe-<id>.json, writes it again requiring it to be absent, and reads
the refusal as the proof. The scratch object is taken away once the store has
answered, and the question is asked once per deploy however many holds follow.
A store that takes the second write evaluated no condition, so it would grant every run that asks the same hold. It gets no locking and the deploy stops saying so, rather than running unprotected: exclusion that does not exclude is worse than none, and a deploy the user expected to be held is never quietly downgraded to one that is not. A store that could not be asked at all is refused on those terms too, naming what it answered.
Without s3:DeleteObject the probe still answers, and the scratch objects are
left for the lifecycle rule that expires the locks beside them.
Failures
A missing object is a first deploy, not a failure: reading it yields no state. A corrupt object never collapses to empty state, because a deploy that read empty state would re-create every resource it already owns.
Everything else arrives as a typed StateError. A bucket that does not resolve
is stateNotFound and a credential the store refused is stateAccessDenied.
Anything only this backend can describe - no credential resolving at all, or a
refusal it does not recognize - arrives as pluginStateBackend carrying an
S3StateErrorDetail payload.
A hold that could not be taken arrives as a StateLockError carrying an
S3StateLockErrorDetail payload, which names the lock object, what went wrong,
and on a timeout who held the environment and how long the wait ran. A store
that failed the conditional-write probe arrives the same way, as
conditionalWritesIgnored where the store took a create it should have refused
and conditionalWritesUnproven where it could not be asked.
License
MIT
