Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
with:
persist-credentials: false
- name: Install pinned Rust formatter
run: |
rustup toolchain install 1.97.1 --profile minimal --component rustfmt
rustup default 1.97.1
- name: Check formatting
run: cargo fmt --all --check
- name: Build the tested production image
run: |
version=$(sed -n 's/^version = "\(.*\)"$/\1/p' crates/celld/Cargo.toml | head -1)
docker build --build-arg CELLD_COMMIT="$GITHUB_SHA" --build-arg CELLD_VERSION="$version" .
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ concurrency:

env:
RUST_VERSION: 1.97.1
IMAGE: ghcr.io/denoland/celld
IMAGE: ghcr.io/coderabbitai/celld

jobs:
build-celld:
Expand Down Expand Up @@ -302,7 +302,7 @@ jobs:
if: github.event.repository.visibility == 'public'
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/denoland/celld
subject-name: ghcr.io/coderabbitai/celld
subject-digest: ${{ steps.digest.outputs.digest }}
push-to-registry: true

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ ARG CELLD_COMMIT=unknown

FROM rust:${RUST_VERSION}-bookworm AS build
ARG TARGETARCH
ARG CELLD_COMMIT
# `release` for shipped artifacts; a fast-loop caller passes `lab` to skip
# the fat-LTO relink and keep incremental state in the target cache.
ARG CELLD_PROFILE=release
Expand All @@ -15,7 +16,7 @@ RUN --mount=type=cache,id=celld-cargo-registry,target=/usr/local/cargo/registry,
--mount=type=cache,id=celld-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=celld-target-${TARGETARCH},target=/src/target,sharing=locked \
mkdir -p /out && \
cargo build --profile "${CELLD_PROFILE}" --locked -p celld && \
CELLD_BUILD_COMMIT="${CELLD_COMMIT}" cargo build --profile "${CELLD_PROFILE}" --locked -p celld && \
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
install -m 755 "target/${CELLD_PROFILE}/celld" /out/celld

# The final image depends on this stage, so a break in the engine's tests or
Expand Down
24 changes: 6 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,33 +29,21 @@ truth; nodes are replaceable.

## Install

The installer downloads the `celld` binary (provenance is verifiable with
`gh attestation verify`):

```sh
curl -fsSL https://celld.dev/install.sh | sh
```

Put `~/.local/bin` on your `PATH` if the installer asks you to.
Use the release container below, or download a native binary from the
[CodeRabbit fork releases](https://github.com/coderabbitai/celld/releases).
Release provenance is verifiable with
`gh attestation verify <asset> --repo coderabbitai/celld`.

Worker projects deployed with `celld deploy` need
[esbuild](https://esbuild.github.io) on `PATH`; asset-only projects do not.

The installer keeps each release under `~/.local/lib/celld/releases` and points
one symlink at the current one. To remove celld, delete the symlink and the
releases:

```sh
rm `which celld` && rm -rf ~/.local/lib/celld
```

## Container

The release image contains the `celld` binary and is published for Linux
x86-64 and ARM64:

```sh
docker run --rm ghcr.io/denoland/celld --version
docker run --rm ghcr.io/coderabbitai/celld --version
```

Persist the runtime's local state and pass the standard AWS credential
Expand All @@ -69,7 +57,7 @@ docker run --rm --network host \
-e AWS_SESSION_TOKEN \
-e CELLD_WATCH=/var/lib/celld/state \
-v celld-state:/var/lib/celld \
ghcr.io/denoland/celld \
ghcr.io/coderabbitai/celld \
--bucket s3://my-cells-bucket \
--endpoint https://ACCOUNT.r2.cloudflarestorage.com \
--region auto \
Expand Down
2 changes: 1 addition & 1 deletion crates/celld/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "celld"
version = "0.2.1"
version = "0.2.1-coderabbit.1"
edition = "2021"
rust-version = "1.94.1"
license = "Apache-2.0"
Expand Down
12 changes: 9 additions & 3 deletions crates/celld/control_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1817,6 +1817,12 @@ async fn apply_deployment(
validate_managed_module_envelope(deployment)?;
validate_managed_class_migrations(&deployment.manifest)?;
crate::protocol::validate_required_features(&deployment.manifest.required_features)?;
let manifest_bytes = serde_json::to_vec_pretty(&deployment.manifest)?;
crate::deployment_auth::verify(
&deployment.pointer,
&manifest_bytes,
&crate::deployment_auth::configured_verifying_keys()?,
)?;

let mut asset_files = 0_u32;
let mut asset_bytes = 0_u64;
Expand Down Expand Up @@ -1967,7 +1973,7 @@ async fn apply_deployment(
bucket
.put(
&format!("{}/manifest.json", deployment.pointer.prefix),
serde_json::to_vec_pretty(&deployment.manifest)?,
manifest_bytes,
)
.await?;
bucket
Expand Down Expand Up @@ -2028,7 +2034,7 @@ fn validate_managed_module_envelope(deployment: &AgentDeployment) -> anyhow::Res
let mut manifest_modules = std::collections::BTreeMap::new();
let mut total_bytes = 0_usize;
for module in &deployment.manifest.modules {
if !valid_managed_module_name(&module.name) || !valid_lower_hex(&module.sha256, 16) {
if !valid_managed_module_name(&module.name) || !valid_lower_hex(&module.sha256, 64) {
return Err(anyhow!(
"control-plane manifest has an invalid module reference: {:?}",
module.name
Expand Down Expand Up @@ -2089,7 +2095,7 @@ fn validate_managed_module_envelope(deployment: &AgentDeployment) -> anyhow::Res
let transport = transport_modules
.get(name)
.expect("module name sets compared above");
if !transport.sha256.starts_with(&manifest.sha256) {
if transport.sha256 != manifest.sha256 {
return Err(anyhow!(
"control-plane transport checksum does not match manifest module {name:?}"
));
Expand Down
113 changes: 108 additions & 5 deletions crates/celld/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
//! model is refused, never silently dropped.
use crate::bucket::Bucket;
use crate::protocol::{
asset_blob_key, AssetConfig, AssetEntry, AssetIndex, AssetManifestRef, DeployPointer, Manifest,
ModuleKind, ModuleRef, Rollout, RunWorkerFirst, FEATURE_ASSETS_V1, FEATURE_SQLITE_VEC_V1,
FEATURE_WASM_V1,
asset_blob_key, AssetConfig, AssetEntry, AssetIndex, AssetManifestRef, DeployPointer,
DeploymentSignature, Manifest, ModuleKind, ModuleRef, Rollout, RunWorkerFirst,
FEATURE_ASSETS_V1, FEATURE_SQLITE_VEC_V1, FEATURE_WASM_V1,
};
use anyhow::{anyhow, bail, Context};
use flate2::write::GzEncoder;
Expand Down Expand Up @@ -53,6 +53,9 @@ pub struct Options {
pub endpoint: Option<String>,
pub region: Option<String>,
pub dry_run: bool,
pub attestation: Option<PathBuf>,
pub signing_key: Option<PathBuf>,
pub signing_key_id: Option<String>,
}

pub fn print_help() {
Expand All @@ -61,7 +64,7 @@ pub fn print_help() {
USAGE:\n celld deploy [PROJECT] --bucket [s3://|gs://]NAME[/PREFIX] [OPTIONS]\n\n\
PROJECT is a directory or a Wrangler config; it defaults to the working\n\
directory, where celld looks for wrangler.jsonc or wrangler.json.\n\n\
OPTIONS:\n --config PATH Same as passing PROJECT positionally\n --bucket [s3://|gs://]NAME[/PREFIX]\n Fleet bucket and prefix; defaults to CELLD_BUCKET.\n gs:// selects a Google Cloud Storage bucket; celld\n then rejects --endpoint and ignores --region\n --endpoint URL S3-compatible endpoint; defaults to S3_ENDPOINT\n --region REGION Storage region; defaults to AWS_REGION\n --dry-run Bundle and print the version without writing\n -h, --help Show this help\n\n\
OPTIONS:\n --config PATH Same as passing PROJECT positionally\n --bucket [s3://|gs://]NAME[/PREFIX]\n Fleet bucket and prefix; defaults to CELLD_BUCKET.\n gs:// selects a Google Cloud Storage bucket; celld\n then rejects --endpoint and ignores --region\n --endpoint URL S3-compatible endpoint; defaults to S3_ENDPOINT\n --region REGION Storage region; defaults to AWS_REGION\n --dry-run Bundle and print the version without writing\n --attestation PATH JSON string map of release metadata to sign\n --signing-key PATH File containing a base64-encoded Ed25519 seed\n --signing-key-id ID Verification-key ID recorded in the signature\n -h, --help Show this help\n\n\
Credentials come from the standard AWS credential chain, or from Google\n\
Application Default Credentials for a gs:// bucket.\n\n\
Worker projects require `esbuild` on PATH; asset-only projects do not. Static\n\
Expand All @@ -81,12 +84,32 @@ pub fn options_from_arguments(
endpoint: None,
region: None,
dry_run: false,
attestation: None,
signing_key: None,
signing_key_id: None,
};
let mut arguments = arguments.into_iter();
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--help" | "-h" => return Ok(None),
"--dry-run" => options.dry_run = true,
"--attestation" => {
options.attestation = Some(PathBuf::from(
arguments.next().context("--attestation requires a value")?,
));
}
"--signing-key" => {
options.signing_key = Some(PathBuf::from(
arguments.next().context("--signing-key requires a value")?,
));
}
"--signing-key-id" => {
options.signing_key_id = Some(
arguments
.next()
.context("--signing-key-id requires a value")?,
);
}
"--config" => {
options.config = Some(PathBuf::from(
arguments.next().context("--config requires a value")?,
Expand All @@ -109,6 +132,16 @@ pub fn options_from_arguments(
other => bail!("`celld deploy` takes one project path, and already has one: {other}"),
}
}
let signing_arguments = [
options.attestation.is_some(),
options.signing_key.is_some(),
options.signing_key_id.is_some(),
];
if signing_arguments.iter().any(|configured| *configured)
&& !signing_arguments.iter().all(|configured| *configured)
{
bail!("--attestation, --signing-key, and --signing-key-id must be configured together");
}
Ok(Some(options))
}

Expand Down Expand Up @@ -151,6 +184,7 @@ pub struct Built {
pub modules: Vec<(String, Vec<u8>)>,
pub assets: Option<BuiltAssets>,
pub bundled_in: Duration,
pub deployment_signature: Option<DeploymentSignature>,
}

impl Built {
Expand Down Expand Up @@ -340,7 +374,7 @@ pub fn build(options: &Options) -> anyhow::Result<Built> {
.map(|(name, bytes)| ModuleRef {
name: name.clone(),
bytes: bytes.len(),
sha256: format!("{:x}", Sha256::digest(bytes))[..16].to_string(),
sha256: format!("{:x}", Sha256::digest(bytes)),
kind: wasm_names.contains(name).then_some(ModuleKind::Wasm),
})
.collect(),
Expand All @@ -363,6 +397,36 @@ pub fn build(options: &Options) -> anyhow::Result<Built> {
},
raw_metadata: project.metadata,
};
let deployment_signature = match (
options.attestation.as_ref(),
options.signing_key.as_ref(),
options.signing_key_id.as_ref(),
) {
(Some(attestation_path), Some(signing_key_path), Some(key_id)) => {
let mut metadata: BTreeMap<String, String> =
serde_json::from_slice(&std::fs::read(attestation_path).with_context(|| {
format!("read deployment attestation {}", attestation_path.display())
})?)
.context("deployment attestation must be a JSON object of string values")?;
add_runtime_provenance(&mut metadata)?;
let unsigned_pointer = DeployPointer {
script_name: Some(project.script_name.clone()),
version: version.clone(),
prefix: prefix.clone(),
rollout: Rollout { percent: 100 },
signature: None,
};
Some(crate::deployment_auth::sign(
&unsigned_pointer,
&serde_json::to_vec_pretty(&manifest)?,
key_id.clone(),
metadata,
&crate::deployment_auth::read_signing_key(signing_key_path)?,
)?)
}
(None, None, None) => None,
_ => unreachable!("signing options are validated while parsing"),
};
Ok(Built {
script_name: project.script_name,
version,
Expand All @@ -371,9 +435,29 @@ pub fn build(options: &Options) -> anyhow::Result<Built> {
modules,
assets: built_assets,
bundled_in,
deployment_signature,
})
}

fn add_runtime_provenance(metadata: &mut BTreeMap<String, String>) -> anyhow::Result<()> {
for reserved in ["celld_version", "celld_commit"] {
if metadata.contains_key(reserved) {
bail!("deployment attestation field {reserved:?} is assigned by celld");
}
}
metadata.insert(
"celld_version".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
);
metadata.insert(
"celld_commit".to_string(),
option_env!("CELLD_BUILD_COMMIT")
.unwrap_or("unknown")
.to_string(),
);
Ok(())
}

pub async fn write(bucket: &Bucket, built: &Built) -> anyhow::Result<()> {
// Asset bodies are fleet-wide and content-addressed. Finish every body
// before publishing the deployment-local index or manifest so a reader
Expand Down Expand Up @@ -409,6 +493,7 @@ pub async fn write(bucket: &Bucket, built: &Built) -> anyhow::Result<()> {
version: built.version.clone(),
prefix: built.prefix.clone(),
rollout: Rollout { percent: 100 },
signature: built.deployment_signature.clone(),
};
let encoded = serde_json::to_vec_pretty(&pointer)?;
// The named pointer resolves service-binding components; the fleet-wide
Expand Down Expand Up @@ -1255,3 +1340,21 @@ fn strip_jsonc(source: &str) -> String {
}
out
}

#[cfg(test)]
mod deployment_attestation_tests {
use super::*;

#[test]
fn runtime_provenance_is_assigned_by_the_binary() {
let mut metadata = BTreeMap::from([("source_commit".to_string(), "abc".to_string())]);
add_runtime_provenance(&mut metadata).unwrap();
assert_eq!(
metadata.get("celld_version").map(String::as_str),
Some(env!("CARGO_PKG_VERSION"))
);
assert!(metadata.contains_key("celld_commit"));

assert!(add_runtime_provenance(&mut metadata).is_err());
}
}
Loading