Anvil is distributed object storage for application state. It keeps opaque bytes at stable paths and supplies the coordination primitives applications otherwise have to assemble around a blob store: streaming writes, compare-and-swap, immutable namespaces, Zanzibar authorization, bounded atomic programs, change notification, and materialized search indexes.
Run one process while developing. Add nodes when you need capacity or availability; clients keep using the same API, any active node can accept a request, and Anvil places data across heterogeneous nodes with capacity-weighted rendezvous hashing.
| Capability | Release | Status |
|---|---|---|
| Object storage | 0.5.0 | Streaming puts, deduplication, CAS, immutable puts, bulk writes, batch reads, deletes, optional version retention, prefix listing, and watches |
| Authorization | 0.5.0 | Application credentials, short-lived JWTs, protected administration, Zanzibar schemas, tuples, roles, and checks |
| Atomic programs | 0.5.0 | Explicitly selected, deterministic multi-path state transitions without routing ordinary uploads through a transaction system |
| Distributed clusters | 0.5.1 | Any-node ingress, peer mTLS, replicated metadata, weighted placement, and 2+1 erasure-coded payload durability |
| Materialized indexes | 0.5.2 | Path, object metadata, typed JSON, full text, vector, hybrid, Git-source, and tensor indexes |
| Rust client | 0.5.2 | Credential exchange, authenticated clients, streaming upload helpers, and the complete generated gRPC API |
| PersonalDB, public reads, accounting, S3 and Git | 0.5.3 | Protocol-native PersonalDB groups and projections, authorized usage aggregates, opt-in anonymous reads, and standard S3/Git gateways |
| Online cluster growth | 0.5.4 | Large objects use complete replicas below the configured erasure width, then move online to the fixed erasure profile as nodes join |
| Java client | — | TODO |
| Python client | — | TODO |
| Node.js client | — | TODO |
| Ruby client | — | TODO |
| Network plugins | — | Planned after 0.5.3 |
The published container is a single multi-platform image for Linux AMD64 and ARM64.
This walkthrough starts one node, creates the system administrator, provisions a tenant and its first application, creates a bucket, then writes and reads an object. It uses the CLI inside the published image, so only Docker and this repository are required.
export ANVIL_IMAGE=ghcr.io/worka-ai/anvil:0.5.4
export ANVIL_TOKEN_SIGNING_KEY_FILE="$PWD/anvil-data/token-signing-key"
mkdir -p anvil-data
head -c 64 /dev/urandom > "$ANVIL_TOKEN_SIGNING_KEY_FILE"
chmod 0600 "$ANVIL_TOKEN_SIGNING_KEY_FILE"
# The container runs as UID 10001 and deliberately rejects a broadly readable
# signing key.
docker run --rm --user 0 \
-v "$ANVIL_TOKEN_SIGNING_KEY_FILE:/key" \
"$ANVIL_IMAGE" chown 10001:10001 /key
ANVIL_RUN_SYSTEM_BOOTSTRAP=true \
docker compose -f crates/anvil/docker-compose.yml up -dThe first successful bootstrap creates one mode-0600 credential at
/var/lib/anvil/system-bootstrap-credential.json. It belongs to the protected
system administrator application. Copy it to an operator secret store, then
remove the generated copy:
docker compose -f crates/anvil/docker-compose.yml cp \
anvil:/var/lib/anvil/system-bootstrap-credential.json \
anvil-data/system-bootstrap-credential.json
chmod 0600 anvil-data/system-bootstrap-credential.jsonBootstrap is explicit and one-shot. Recreate the process without the flag after the first successful start; the named data volume is retained:
ANVIL_RUN_SYSTEM_BOOTSTRAP=false \
docker compose -f crates/anvil/docker-compose.yml up -d --force-recreateAn application authenticates with a client_id and client_secret. Credential
exchange returns a short-lived bearer token; the CLI and Rust client perform
that exchange for you.
Choose and retain a strong application secret:
export ANVIL_OWNER_SECRET="$(openssl rand -hex 32)"Use the system credential to create tenant example, owner application
example-owner, and client example-client in one operation:
docker compose -f crates/anvil/docker-compose.yml exec \
-e ANVIL_NEW_CLIENT_SECRET="$ANVIL_OWNER_SECRET" anvil \
anvil --endpoint http://127.0.0.1:50051 \
--credentials-file /var/lib/anvil/system-bootstrap-credential.json \
provision-tenant example example-owner example-clientThe generated system credential should not remain on the server after you have copied it and completed bootstrap:
docker compose -f crates/anvil/docker-compose.yml exec --user 0 anvil \
rm /var/lib/anvil/system-bootstrap-credential.jsonThe tenant owner may create buckets. The application that creates a bucket becomes its owner:
docker compose -f crates/anvil/docker-compose.yml exec \
-e ANVIL_CLIENT_ID=example-client \
-e ANVIL_CLIENT_SECRET="$ANVIL_OWNER_SECRET" anvil \
anvil --endpoint http://127.0.0.1:50051 \
create-bucket objectsBuckets are unversioned by default: overwriting or deleting a current object
does not expose an older value later. Use create-bucket objects --versioning enabled when retained versions are part of the application contract.
printf 'hello from Anvil\n' > anvil-data/hello.txt
docker compose -f crates/anvil/docker-compose.yml cp \
anvil-data/hello.txt anvil:/tmp/hello.txt
docker compose -f crates/anvil/docker-compose.yml exec \
-e ANVIL_CLIENT_ID=example-client \
-e ANVIL_CLIENT_SECRET="$ANVIL_OWNER_SECRET" anvil \
anvil --endpoint http://127.0.0.1:50051 \
put example objects greetings/hello.txt /tmp/hello.txt \
--content-type text/plain --command-id first-upload
docker compose -f crates/anvil/docker-compose.yml exec \
-e ANVIL_CLIENT_ID=example-client \
-e ANVIL_CLIENT_SECRET="$ANVIL_OWNER_SECRET" anvil \
anvil --endpoint http://127.0.0.1:50051 \
get example objects greetings/hello.txtThe last command prints hello from Anvil. You now have a tenant-isolated,
Zanzibar-authorized object addressed by (tenant, bucket, path).
cargo add anvil-storage@0.5.4
cargo add tokio --features macros,rt-multi-threadThe application needs the public endpoint and the client ID and secret created above. Tenant and bucket are part of each object address, not connection-wide state.
use anvil_storage::v1::{
Durability, HeadObjectRequest, ObjectAddress, PutHeader, PutOperation,
put_header,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut objects = anvil_storage::connect_with_credentials(
"http://127.0.0.1:50051",
"example-client",
std::env::var("ANVIL_OWNER_SECRET")?,
)
.await?;
let address = ObjectAddress {
tenant: "example".into(),
bucket: "objects".into(),
path: "greetings/from-rust.txt".into(),
};
let receipt = anvil_storage::put_chunks(
&mut objects,
PutHeader {
address: Some(address.clone()),
content_type: "text/plain".into(),
command_id: "rust-first-upload".into(),
durability: Durability::Local as i32,
operation: Some(put_header::Operation::Put(PutOperation {})),
},
[b"hello from Rust\n".to_vec()],
)
.await?;
let head = objects
.head_object(HeadObjectRequest {
address: Some(address),
})
.await?
.into_inner();
println!("published version {}: {head:?}", receipt.version);
Ok(())
}LOCAL and REPLICATED describe when the client is acknowledged, not where
the object ultimately lives. LOCAL returns after the ingress node has durably
accepted the write while normal placement continues. REPLICATED waits for
the fixed 2+1 payload guarantee (or the corresponding mutable-record quorum).
For long-running processes, exchange credentials again when the returned token
expires. connect_channel, exchange_client_credentials, BearerToken, and
the generated service clients let an application share one transport across
object, index, authorization, and administration calls. The focused Rust guide
is at clients/rust/README.md.
PersonalDB gives an application a witnessed, predecessor-linked log for SQLite changesets. Source and standalone groups accept authorized appends; projection groups are materialized explicitly from a source group. Group roles are Zanzibar-authorized independently of ordinary object traffic.
Add the public client and canonical protocol types:
cargo add anvil-storage@0.5.4 personaldb-protocol@0.2.2 serde_jsonUse the same application credential created above to create a source group and verify Anvil's signed descriptor:
use anvil_storage::v1::{CreatePersonalDbGroupRequest, PersonalDbGroupKind};
use personaldb_protocol::{
GroupDescriptor, PublicKeyTrustRecord, PublicKeyTrustStore, Sha256Digest,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let channel = anvil_storage::connect_channel("http://127.0.0.1:50051").await?;
let token = anvil_storage::exchange_client_credentials(
channel.clone(),
"example-client",
std::env::var("ANVIL_OWNER_SECRET")?,
)
.await?;
let mut personaldb = anvil_storage::personaldb_client(channel, &token.access_token)?;
let group = personaldb
.create_group(CreatePersonalDbGroupRequest {
bucket: "objects".into(),
database_id: "main".into(),
group_id: "primary".into(),
kind: PersonalDbGroupKind::Source as i32,
schema_hash_sha256: Sha256Digest::hash(b"application-schema-v1")
.as_bytes()
.to_vec(),
mirror_projection: None,
command_id: "create-main-primary".into(),
})
.await?
.into_inner();
let records = group
.trust_records_json
.iter()
.map(|bytes| serde_json::from_slice::<PublicKeyTrustRecord>(bytes))
.collect::<Result<Vec<_>, _>>()?;
let trust = PublicKeyTrustStore::from_records(records)?;
let descriptor = GroupDescriptor::decode_canonical(&group.descriptor)?;
descriptor.verify(&trust)?;
println!("created {}", descriptor.group_id());
Ok(())
}AppendEntry advances the exact committed predecessor and carries the SQLite
changeset plus client, voter, and admission evidence. CatchUp streams
canonical personaldb-protocol frames, while MaterializeProjection derives
projection output server-side. The complete public workflow is executable in
crates/anvil/examples/personaldb_qualification.rs.
Accounting is opt-in for a whole bucket or one path prefix. Anvil maintains the aggregate asynchronously and authorizes both configuration and reads through Zanzibar. Each snapshot reports current object count and logical stored bytes, cumulative accepted inbound and served outbound bytes, and a source checkpoint so billing code can judge freshness.
Use an empty path_prefix for the whole bucket, or a canonical prefix such as
customers/acme for one application's billing boundary:
use anvil_storage::{BearerToken, connect_channel, exchange_client_credentials};
use anvil_storage::v1::accounting_service_client::AccountingServiceClient;
use anvil_storage::v1::{EnableAccountingRequest, GetAccountingRequest};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let channel = connect_channel("http://127.0.0.1:50051").await?;
let token = exchange_client_credentials(
channel.clone(),
"example-client",
std::env::var("ANVIL_OWNER_SECRET")?,
)
.await?;
let mut accounting = AccountingServiceClient::with_interceptor(
channel,
BearerToken::new(&token.access_token)?,
);
accounting
.enable_accounting(EnableAccountingRequest {
bucket: "objects".into(),
path_prefix: "customers/acme".into(),
command_id: "enable-acme-accounting".into(),
})
.await?;
// Query after application traffic; freshness identifies the exact source
// journal checkpoints included by the asynchronous aggregate.
let usage = accounting
.get_accounting(GetAccountingRequest {
bucket: "objects".into(),
path_prefix: "customers/acme".into(),
})
.await?
.into_inner();
println!(
"{} objects, {} stored bytes, {} bytes in, {} bytes out; freshness={:?}",
usage.object_count,
usage.logical_stored_bytes,
usage.accepted_inbound_bytes,
usage.served_outbound_bytes,
usage.freshness,
);
Ok(())
}The complete enable, traffic, convergence, and disable flow is executable in
crates/anvil/examples/accounting_qualification.rs.
The HTTP gateway on port 50053 accepts standard SigV4 path-style requests.
Use an Anvil application client_id as the AWS access-key ID and its
client_secret as the AWS secret-access key; Zanzibar still decides what that
application may do.
With the AWS CLI installed, the owner application created above can exercise the minimum S3 surface directly:
export AWS_ACCESS_KEY_ID=example-client
export AWS_SECRET_ACCESS_KEY="$ANVIL_OWNER_SECRET"
export AWS_DEFAULT_REGION=eu-west-2
export ANVIL_S3_ENDPOINT=http://127.0.0.1:50053
aws --endpoint-url "$ANVIL_S3_ENDPOINT" s3api create-bucket \
--bucket s3-demo
printf 'hello through S3\n' > anvil-data/s3-hello.txt
aws --endpoint-url "$ANVIL_S3_ENDPOINT" s3api put-object \
--bucket s3-demo --key greetings/hello.txt \
--content-type text/plain --body anvil-data/s3-hello.txt
aws --endpoint-url "$ANVIL_S3_ENDPOINT" s3api head-object \
--bucket s3-demo --key greetings/hello.txt
aws --endpoint-url "$ANVIL_S3_ENDPOINT" s3api get-object \
--bucket s3-demo --key greetings/hello.txt anvil-data/s3-downloaded.txt
cmp anvil-data/s3-hello.txt anvil-data/s3-downloaded.txt
aws --endpoint-url "$ANVIL_S3_ENDPOINT" s3api list-objects-v2 \
--bucket s3-demo --prefix greetings/
aws --endpoint-url "$ANVIL_S3_ENDPOINT" s3api delete-object \
--bucket s3-demo --key greetings/hello.txtThe same endpoint works with the official AWS SDKs. The Rust SDK qualification
uses one or three public gateways and verifies every returned byte in
crates/anvil/examples/s3_qualification.rs.
Anvil serves Git's smart HTTP protocol at
/git/<tenant>/<bucket>/<repository>.git on the same port 50053 gateway.
The first authenticated push creates the repository; subsequent pushes use CAS
when publishing its ordinary Anvil bundle object. Basic authentication accepts
the same application client ID and secret used by the gRPC and S3 APIs.
mkdir -p anvil-data/git-demo
git -C anvil-data/git-demo init --initial-branch=main
git -C anvil-data/git-demo config user.name "Anvil Example"
git -C anvil-data/git-demo config user.email "anvil@example.invalid"
printf '# Stored in Anvil\n' > anvil-data/git-demo/README.md
git -C anvil-data/git-demo add README.md
git -C anvil-data/git-demo commit -m initial
export ANVIL_GIT_URL=http://127.0.0.1:50053/git/example/objects/demo.git
export ANVIL_GIT_AUTH="$(printf '%s:%s' example-client "$ANVIL_OWNER_SECRET" | base64 | tr -d '\n')"
git -C anvil-data/git-demo \
-c "http.extraHeader=Authorization: Basic $ANVIL_GIT_AUTH" \
push "$ANVIL_GIT_URL" main
git -c "http.extraHeader=Authorization: Basic $ANVIL_GIT_AUTH" \
clone --branch main "$ANVIL_GIT_URL" anvil-data/authenticated-clone
git -C anvil-data/authenticated-clone \
-c "http.extraHeader=Authorization: Basic $ANVIL_GIT_AUTH" pullTo make pulls and clones public, enable public reads for the repository's bucket through the authorized command shown below, then omit the authentication header:
docker compose -f crates/anvil/docker-compose.yml exec \
-e ANVIL_CLIENT_ID=example-client \
-e ANVIL_CLIENT_SECRET="$ANVIL_OWNER_SECRET" anvil \
anvil --endpoint http://127.0.0.1:50051 \
set-bucket-public-read objects enabled
git clone --branch main "$ANVIL_GIT_URL" anvil-data/public-clonePush always requires an authorized application. A real-client push, pull, and
public-clone qualification is kept in
crates/anvil/tests/git_gateway.rs.
Authorization is enforced on the API; deployment topology is not a security boundary.
- The one unauthenticated credential RPC exchanges an application
client_idand Argon2id-verified secret for a short-lived signed JWT. - The JWT identifies one stable application and tenant. It contains no trusted caller-supplied roles.
- Every protected RPC evaluates that identity through Zanzibar relationships.
- The protected
_anvil/systemrealm governs Anvil administration. Customer realms use the same Zanzibar primitives for application-domain models, but cannot modify the system realm.
Tenant roles are owner, admin, reader, manage_tenant, read_tenant,
manage_buckets, and manage_authz. Bucket roles are owner, admin,
reader, writer, get, put, delete, and manage_policy. A bucket owner
may delegate the narrowest useful role to another application; index results
are authorization-filtered before they are returned.
A bucket owner can enable public reads with the CLI or
AdministrationService.SetBucketPublicRead:
docker compose -f crates/anvil/docker-compose.yml exec \
-e ANVIL_CLIENT_ID=example-client \
-e ANVIL_CLIENT_SECRET="$ANVIL_OWNER_SECRET" anvil \
anvil --endpoint http://127.0.0.1:50051 \
set-bucket-public-read objects enabledA request with no bearer token is then evaluated as Anvil's built-in, unmanageable anonymous application. It may read only where the owner explicitly granted that bucket policy. Supplying an invalid token remains an authentication error, and anonymous callers never bypass Zanzibar or gain write, index-management, or administration access.
Anvil exposes separate operations so intent is visible in code:
Putalways advances the path version.PutIfAbsentsucceeds only if the path has never had a live value.PutIfVersion(expected_version)succeeds only against that exact current version.PutImmutablecreates write-once content.Deleteadvances the path to a tombstone.DeleteIfVersion(expected_version)deletes only the expected current value.
Bucket policies can also reserve immutable or PROGRAM_ONLY path prefixes.
The latter may be changed only by an atomic program, preventing an ordinary
writer from racing a program dependency.
An atomic program is a small, bounded JSON state-transition definition—not uploaded executable code and not a transaction wrapper around ordinary object traffic. Use one where several paths represent one application invariant, such as moving a balance while appending a corresponding immutable ledger entry.
The lifecycle is deliberately explicit:
- Put an immutable definition at
_anvil/programs/<name>@<version>. HeadObjectit and retain the returned BLAKE3 hash.- Mark every path the program may read or write as
PROGRAM_ONLY. - Invoke the exact path and hash with a unique invocation ID and JSON bindings.
- Anvil authorizes and locks the declared paths, evaluates the bounded DSL on the nominated executor, and publishes every resulting head atomically.
Ordinary uploads—including large media—continue to use Put; they never enter
this orchestration path. A complete two-document program and invocation is kept
executable in
scripts/qualify-three-node.sh.
Indexes are bucket-local definitions scoped by an optional path prefix and content type. One cluster writer consumes every node's ordered source journal, publishes immutable index generations through the ordinary object store, and up to three HRW-selected query replicas materialize them through a bounded cache. Query responses always include freshness evidence; a newly created or busy index may return a valid partial generation while it catches up.
Construct an authenticated index client from the same token used for objects:
use anvil_storage::{BearerToken, connect_channel, exchange_client_credentials};
use anvil_storage::v1::index_service_client::IndexServiceClient;
# async fn client() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let channel = connect_channel("http://127.0.0.1:50051").await?;
let token = exchange_client_credentials(
channel.clone(),
"example-client",
std::env::var("ANVIL_OWNER_SECRET")?,
)
.await?;
let mut indexes = IndexServiceClient::with_interceptor(
channel,
BearerToken::new(&token.access_token)?,
);
# let _ = &mut indexes;
# Ok(())
# }Call CreateIndex with a CreateIndexRequest, then QueryIndex with the
matching query variant. These are the eight engines and their minimal useful
definitions:
| Engine | Definition | Query | Source object shape |
|---|---|---|---|
| Path | PathIndexSpec {} |
PathIndexQuery { prefix, start_after } |
Any object |
| Metadata filter | fields such as path, content_type, content_length |
Predicates over retained head fields | Any object |
| Typed JSON | named fields mapped to JSON Pointers, e.g. status -> /status |
Canonical JSON scalar predicates and ordering | {"status":"active"} |
| Full text | named text fields mapped to JSON Pointers | Text or phrase query | {"body":"durable journal delivery"} |
| Vector | JSON Pointer, dimensions, cosine/dot/Euclidean metric | A vector with the declared dimensions | {"embedding":[1.0,0.0,0.0]} |
| Hybrid | Full-text and vector specifications plus weights | Text and vector together | {"title":"rust search","embedding":[1.0,0.0,0.0]} |
| Git source | Repository ID | Commit ID and exact/prefix tree path | Git manifest containing repository, commit, tree path, object ID, pack path/version, offset, and length |
| Tensor | Model ID | Tensor name | Tensor manifest containing model, tensor name, source path/version, offset, length, dtype, and shape |
For example, a typed JSON definition retains /status and queries it using one
canonical JSON string:
use anvil_storage::v1::index_query::Query as QueryValue;
use anvil_storage::v1::index_specification::Specification as SpecValue;
use anvil_storage::v1::*;
# async fn example(mut indexes: anvil_storage::v1::index_service_client::IndexServiceClient<tonic::service::interceptor::InterceptedService<tonic::transport::Channel, anvil_storage::BearerToken>>) -> Result<(), tonic::Status> {
indexes.create_index(CreateIndexRequest {
bucket: "objects".into(),
name: "active-documents".into(),
path_prefix: "documents/".into(),
content_type: "application/json".into(),
specification: Some(IndexSpecification {
specification: Some(SpecValue::TypedJson(TypedJsonIndexSpec {
fields: vec![IndexField {
name: "status".into(),
json_pointer: "/status".into(),
}],
})),
}),
command_id: "create-active-documents".into(),
}).await?;
let response = indexes.query_index(QueryIndexRequest {
bucket: "objects".into(),
index_name: "active-documents".into(),
query: Some(IndexQuery {
query: Some(QueryValue::TypedJson(TypedJsonIndexQuery {
predicates: vec![IndexPredicate {
field: "status".into(),
operator: IndexPredicateOperator::Equal as i32,
values_json: vec![br#""active""#.to_vec()],
}],
order: vec![],
})),
}),
limit: 100,
page_token: vec![],
}).await?.into_inner();
for hit in response.hits {
println!("{}", hit.address.unwrap().path);
}
println!("freshness: {:?}", response.freshness);
# Ok(())
# }The repository's public-API qualification constructs, populates, and queries
all eight variants in
crates/anvil/examples/cluster_index_qualification.rs.
The local qualification starts three real nodes, admits joiners with one-use bundles, establishes peer mTLS, exercises replicated and erasure-coded storage, queries every index type, and performs a rolling restart:
ANVIL_IMAGE=ghcr.io/worka-ai/anvil:0.5.4 \
./scripts/qualify-three-node.shProduction formation uses the same sequence:
- Start the first node with
--run-system-bootstrap, a stable--peer-advertiseaddress, durable storage, and the operator-managed JWT signing key. - From an authorized active node, run
anvil prepare-node <node-id> <peer-address>. - Copy the generated mode-
0600join bundle to the configured joining node, delete the source copy, and startanvil-server --join-bundle <path>there. - Repeat for additional nodes. Set each node's storage weight to its usable capacity; weighted HRW moves only ownership affected by membership changes.
The public gRPC endpoint may sit behind an ordinary TLS terminator. Peer traffic uses mandatory certificates created and rotated by the cluster.
| Service | Purpose |
|---|---|
CredentialService |
Exchange durable application credentials for short-lived bearer tokens |
ObjectService |
Streaming writes, CAS, bulk/batch operations, reads, versions, listing, watches, policies, and atomic programs |
IndexService |
Create, update, inspect, list, delete, and query materialized indexes |
PersonalDbService |
Create and authorize database groups, append protocol evidence, explicitly materialize projections, catch up, and transfer snapshots |
AccountingService |
Enable and query authorization-protected bucket or path-prefix usage aggregates |
AuthzService |
Manage customer Zanzibar realms, schemas, relationships, bindings, and checks |
AdministrationService |
Protected tenant, bucket, credential, role, and cluster lifecycle |
The versioned contracts are
anvil.proto and
personaldb.proto.
Every successful write creates immutable content and advances one current exact-path head. Values up to 64 KiB remain inline in a RocksDB column family; larger values are content-addressed, use complete copies while the cluster is smaller than its fixed erasure width, and move online to erasure-coded shards as nodes join. Mutable records are replicated. Compact cluster membership and atomic-publication decisions use Raft; object bodies, path inventories, program locks, and index files do not.
Names resolve to stable numeric IDs, so renaming human-facing identifiers does not rewrite every storage key. Ordered per-node source journals feed watches, reference accounting, and the single writer for each materialized index. Index artifacts are ordinary Anvil objects; local materialization is disposable acceleration, not another authoritative storage plane.
The architecture contracts live in ANVIL-0009 and ANVIL-0010.
Anvil requires Rust 1.96 or newer.
cargo fmt --all -- --check
cargo test --workspaceBuild and qualify the container locally before CI:
ANVIL_IMAGE=anvil:local ./scripts/build-image.sh
ANVIL_IMAGE=anvil:local ./scripts/release-gates.sh image
ANVIL_IMAGE=anvil:local ./scripts/qualify-three-node.shAnvil 0.5 has a new API and storage format; migrate 0.4 data by export and import. Current operational boundaries are collected in the known limitations.
Apache-2.0 licensed.