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

You are reading the development version (master). For the latest release (v3.5.0) see the stable docs.

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).