Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Bundle Storage on Google Cloud Storage

Sprint reference: post1-T-3.2.

The --bundle-storage gs://... flag tells boruna workflow run to copy the finalized evidence bundle to a GCS bucket after the local write succeeds. The local bundle remains the authoritative record; the GCS copy is an additional durable destination.

This adapter mirrors the S3 adapter — same trait, same BORUNA_BUNDLE_CACHE semantics, same failure contract, just with GCS auth + the gs:// scheme.

Status

  • Adapter shipped: GCS (this guide). Works against Google Cloud Storage and fsouza/fake-gcs-server for local testing.
  • Already shipped: S3 (T-3.1). See bundle-storage-s3.md.
  • Reserved scheme: azblob:// (T-3.3, Azure Blob) is rejected at parse time until that adapter ships.

Build with the gcs feature

# CLI binary (boruna)
cargo build --release --features boruna-cli/gcs

# Direct orchestrator usage from another Rust crate
[dependencies]
boruna-orchestrator = { path = "...", features = ["gcs"] }

# Combined with S3 if you operate multi-cloud
cargo build --release --features "boruna-cli/s3,boruna-cli/gcs"

When you build without the gcs feature and pass --bundle-storage gs://..., the URI rejects at parse time with a message that points you at the feature flag. Same UX guarantee as S3 — never silently ignored.

Configuring auth

object_store::gcp::GoogleCloudStorageBuilder::from_env() reads:

VariablePurpose
GOOGLE_SERVICE_ACCOUNT / GOOGLE_SERVICE_ACCOUNT_PATHPath to a JSON service-account key file
GOOGLE_SERVICE_ACCOUNT_KEYThe JSON service-account key inline (handy for K8s secrets)
GOOGLE_APPLICATION_CREDENTIALSApplication Default Credentials (Workload Identity, gcloud login)

In production, prefer Workload Identity (GKE) or Service-Account-Attached-to-VM (GCE) — neither requires a key on disk.

Required IAM permissions

Bind a service account with the predefined role roles/storage.objectAdmin on the bucket, or the more granular:

storage.buckets.get
storage.objects.create
storage.objects.delete
storage.objects.get
storage.objects.list

storage.objects.delete is reserved for a future evidence prune flow; the current adapter does not delete objects.

Usage

Per-run

export GOOGLE_APPLICATION_CREDENTIALS=/etc/boruna/sa.json
# Or for a key in env:
# export GOOGLE_SERVICE_ACCOUNT_KEY="$(cat /etc/boruna/sa.json)"

boruna workflow run examples/workflows/llm_code_review \
  --policy allow-all \
  --record \
  --bundle-storage gs://my-audit-bucket/prod/llm-review

After the run finalizes locally, the CLI prints:

evidence bundle: ./data/evidence/<run-id>
  bundle_hash: <hex>
  audit_log_hash: <hex>
  files: 12
  storage_ref: gs://my-audit-bucket/prod/llm-review/<run-id>

Via env var

export BORUNA_BUNDLE_STORAGE=gs://my-audit-bucket/prod

URI shape

PatternEffect
gs://bucketObjects land at <run-id>/<file>
gs://bucket/prefixObjects land at prefix/<run-id>/<file>
gs://bucket/a/b/c/Trailing slash normalized; same as gs://bucket/a/b/c

The StorageRef returned by put is gs://bucket/prefix/<run-id>. Treat it as opaque; only the dispatcher parses it.

Reading bundles back

#![allow(unused)]
fn main() {
use boruna_orchestrator::audit::storage::{from_uri, StorageRef};

let storage = from_uri(Some("gs://my-audit-bucket/prod"))?.unwrap();
let local_dir = storage.get(&StorageRef("gs://my-audit-bucket/prod/<run-id>".into()))?;
boruna_orchestrator::audit::verify_bundle(&local_dir)?;
}

The cache directory (default <temp>/boruna-bundle-cache, overridable via BORUNA_BUNDLE_CACHE) is shared with the S3 adapter — set per-bucket cache dirs if you operate multi-cloud and care about cross-bucket consistency.

Failure semantics

Same as S3 (see bundle-storage-s3.md). A storage failure never masks a successful workflow.

Error taxonomy

StorageError::Backend { kind, msg } uses these stable kinds for GCS operations:

kindMeaningRetry?
gcs.transientNetwork blip, timeout, throttleYes — object_store already retries internally; bubbled up means retries exhausted.
gcs.permanentAuth failure, NoSuchBucket, AccessDeniedNo — operator config issue.
gcs.runtimeCould not build the tokio runtime backing the adapterNo — host issue.
gcs.unexpected_keyObject listed under the run prefix but doesn’t match the expected path layoutNo — investigate; possible bucket pollution.

StorageError::NotFound(ref) fires when get is called against a ref that has zero objects under its prefix.

Testing against fake-gcs-server locally

# Spin up fake-gcs-server
docker run -p 4443:4443 \
  fsouza/fake-gcs-server:1.49.2 \
  -scheme http -host 0.0.0.0 -port 4443

# Create a bucket
curl -X POST 'http://localhost:4443/storage/v1/b?project=test-project' \
  -H 'Content-Type: application/json' \
  -d '{"name":"boruna-audit"}'

# Programmatic use from Rust:
use boruna_orchestrator::audit::storage_gcs::GcsBucketBuilder;
let store = GcsBucketBuilder::new("gs://boruna-audit/local-test")
    .with_endpoint("http://localhost:4443")
    .build()?;

(fake-gcs-server doesn’t need credentials; the adapter still calls from_env(), which simply doesn’t pick anything up — the endpoint override is what matters.)

The --features gcs-it integration tests under orchestrator/tests/ run the full round-trip against a testcontainers-managed fake-gcs-server container; see orchestrator/tests/gcs_integration.rs for the canonical example.

Determinism contract

storage_ref is operational metadata — it does not feed any audit-log hash or replay comparison. The bundle’s bundle_hash / audit_log_hash come from the local manifest and are independent of where the bundle is also stored.

Limitations

Same as S3: no automatic bucket creation, no multipart-upload tuning knob, shared cache directory across adapters, no retention/lifecycle policy (configure server-side via GCS Object Lifecycle rules).