Skip to content
Open
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
31 changes: 30 additions & 1 deletion src/api/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,18 @@ where
I: AsRef<ApiImpl> + Send + Sync,
{
if !has_routing_header(request.headers()) {
return StatusCode::NOT_FOUND.into_response();
// Unmatched control-plane route: return the API error envelope so
// JSON clients surface "route not found" instead of failing to parse
// an empty 404 body.
let error = agentenv_http_server::models::Error::new(
404,
format!(
"route not found: {} {}",
request.method(),
request.uri().path()
),
);
return (StatusCode::NOT_FOUND, axum::Json(error)).into_response();
}
let forward_path = request.uri().path().to_owned();
with_route_source(
Expand Down Expand Up @@ -2120,6 +2131,24 @@ mod tests {
.unwrap();

assert_eq!(response.status(), StatusCode::NOT_FOUND);
let content_type = response
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(
content_type.starts_with("application/json"),
"unexpected content-type: {content_type}"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
let payload: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(payload["code"], 404);
let message = payload["message"].as_str().unwrap();
assert!(
message.contains("route not found: GET /nonexistent/path"),
"unexpected message: {message}"
);
}

#[tokio::test]
Expand Down
2 changes: 2 additions & 0 deletions src/snapshot/image_export/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ impl SnapshotImageService {
let repository = Arc::new(posixfs::PosixFsSnapshotRepository::new(
Arc::new(posixfs::PosixFsCatalogStore::new(root.clone())),
Arc::new(posixfs::PosixFsArtifactStore::new(root.clone())),
posixfs::PosixFsTemplateBuildFileStore::new(&root),
));
(repository, ManagedLayerLocator::PosixFs { root })
}
Expand Down Expand Up @@ -431,6 +432,7 @@ mod tests {
let repository = posixfs::PosixFsSnapshotRepository::new(
Arc::new(posixfs::PosixFsCatalogStore::new(root.clone())),
Arc::new(posixfs::PosixFsArtifactStore::new(root.clone())),
posixfs::PosixFsTemplateBuildFileStore::new(&root),
);
let uncommitted =
SnapshotRecord::template_waiting(SnapshotId::generate(), None, Default::default());
Expand Down
8 changes: 8 additions & 0 deletions src/snapshot/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ impl SnapshotManager {
self.repository.create(record).await
}

/// Returns the shared build-context archive store, when the configured
/// repository backend provides one.
pub fn template_build_files(
&self,
) -> Option<Arc<dyn crate::snapshot::repository::TemplateBuildFileStore>> {
self.repository.template_build_files()
}

#[tracing::instrument(skip(self, metadata, manifest), fields(snapshot_id = %metadata.id))]
pub async fn publish(
&self,
Expand Down
169 changes: 169 additions & 0 deletions src/snapshot/repository/backends/oss/build_files.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;

use async_trait::async_trait;

use super::client::OssClient;
use crate::snapshot::repository::build_files::{
generate_upload_token, is_valid_build_files_hash, is_valid_upload_token,
TemplateBuildFileStore, TemplateBuildUploadGrant,
};
use crate::snapshot::repository::{RepositoryError, RepositoryResult};

const BUILD_FILES_PREFIX: &str = "template-build-files";

/// Build-context archive store backed by the OSS repository bucket.
///
/// Layout: `template-build-files/{hash}.tar` plus durable bearer grants under
/// `template-build-files/upload-grants/`. Retention is delegated to bucket
/// lifecycle rules; archives are cache entries the SDK re-uploads when absent.
pub(crate) struct OssTemplateBuildFileStore {
client: Arc<OssClient>,
}

impl OssTemplateBuildFileStore {
pub(crate) fn new(client: Arc<OssClient>) -> Arc<Self> {
Arc::new(Self { client })
}

fn archive_key(hash: &str) -> RepositoryResult<String> {
if !is_valid_build_files_hash(hash) {
return Err(RepositoryError::InvalidRequest {
reason: format!("invalid build files hash '{hash}'"),
});
}
Ok(format!("{BUILD_FILES_PREFIX}/{hash}.tar"))
}

fn grant_key(token: &str) -> Option<String> {
is_valid_upload_token(token)
.then(|| format!("{BUILD_FILES_PREFIX}/upload-grants/{token}.json"))
}

/// Reads a grant record, mapping an absent object to `None`.
async fn read_grant(&self, key: &str) -> RepositoryResult<Option<TemplateBuildUploadGrant>> {
let bytes = match self.client.get_bytes(key).await {
Ok(bytes) => bytes,
Err(error) if OssClient::is_not_found_error(&error) => return Ok(None),
Err(error) => return Err(RepositoryError::backend("read upload grant", error)),
};
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|error| RepositoryError::backend("parse upload grant", error))
}
}

#[async_trait]
impl TemplateBuildFileStore for OssTemplateBuildFileStore {
async fn exists(&self, hash: &str) -> RepositoryResult<bool> {
let key = Self::archive_key(hash)?;
self.client
.exists(&key)
.await
.map_err(|error| RepositoryError::backend("check build archive", error))
}

async fn import(&self, hash: &str, staged: &Path) -> RepositoryResult<()> {
let key = Self::archive_key(hash)?;
// Archives are immutable so a repeat upload cannot change what an
// in-flight build reads. This fast path is not atomic against a
// concurrent import: the loser's bytes are dropped, and since the hash
// is a caller-supplied cache key rather than a verified digest, which
// racing upload wins is undefined — first-write-wins stability, not
// content authenticity.
if self
.client
.exists(&key)
.await
.map_err(|error| RepositoryError::backend("check build archive", error))?
{
return Ok(());
}
self.client
.put_file(&key, staged)
.await
.map_err(|error| RepositoryError::backend("upload build archive", error))
}

async fn materialize(
&self,
hash: &str,
scratch_dir: &Path,
) -> RepositoryResult<Option<PathBuf>> {
let key = Self::archive_key(hash)?;
let dest = scratch_dir.join(format!("{hash}.tar"));
match self.client.get_to_file(&key, &dest).await {
Ok(_) => Ok(Some(dest)),
Err(error) if OssClient::is_not_found_error(&error) => Ok(None),
Err(error) => Err(RepositoryError::backend("download build archive", error)),
}
}

async fn create_upload_grant(
&self,
template_id: &str,
hash: &str,
expires_unix: i64,
) -> RepositoryResult<String> {
let token = generate_upload_token();
let key = Self::grant_key(&token).expect("generated token is valid");
let grant = serde_json::to_vec(&TemplateBuildUploadGrant::new(
template_id,
hash,
expires_unix,
))
.map_err(|error| RepositoryError::backend("serialize upload grant", error))?;
self.client
.put_bytes(&key, grant)
.await
.map_err(|error| RepositoryError::backend("write upload grant", error))?;
Ok(token)
}

async fn verify_upload_grant(
&self,
token: &str,
template_id: &str,
hash: &str,
expires_unix: i64,
now_unix: i64,
) -> RepositoryResult<bool> {
let Some(key) = Self::grant_key(token) else {
return Ok(false);
};
// Deliberately does not delete the object: verification must leave the
// upload URL usable for a retry.
let Some(grant) = self.read_grant(&key).await? else {
return Ok(false);
};
Ok(grant.authorizes(template_id, hash, expires_unix, now_unix))
}

async fn claim_upload_grant(
&self,
token: &str,
template_id: &str,
hash: &str,
expires_unix: i64,
now_unix: i64,
) -> RepositoryResult<bool> {
let Some(key) = Self::grant_key(token) else {
return Ok(false);
};
let Some(grant) = self.read_grant(&key).await? else {
return Ok(false);
};
if !grant.authorizes(template_id, hash, expires_unix, now_unix) {
return Ok(false);
}
// Consume the grant so the upload URL cannot be replayed. S3-compatible
// stores offer no conditional delete, so simultaneous replays of one
// token can both observe the grant; archives are immutable, which is
// what keeps that from mattering.
self.client
.delete(&key)
.await
.map_err(|error| RepositoryError::backend("consume upload grant", error))?;
Ok(true)
}
}
1 change: 1 addition & 0 deletions src/snapshot/repository/backends/oss/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod build_files;
mod client;
mod config;
mod layout;
Expand Down
Loading
Loading