diff --git a/.github/services/vercel_artifacts/default/action.yml b/.github/services/vercel_artifacts/default/action.yml new file mode 100644 index 000000000000..9aa51cab7ef3 --- /dev/null +++ b/.github/services/vercel_artifacts/default/action.yml @@ -0,0 +1,30 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: default +description: 'Behavior test for Vercel Remote Cache' + +runs: + using: "composite" + steps: + - name: Setup + uses: 1Password/load-secrets-action@92467eb28f72e8255933372f1e0707c567ce2259 # v4.0.0 + with: + export-env: true + env: + OPENDAL_VERCEL_ARTIFACTS_ACCESS_TOKEN: op://services/vercel_artifacts/access_token + OPENDAL_VERCEL_ARTIFACTS_TEAM_ID: op://services/vercel_artifacts/team_id diff --git a/core/services/vercel-artifacts/src/backend.rs b/core/services/vercel-artifacts/src/backend.rs index 9d274b82d737..36bcf466ff94 100644 --- a/core/services/vercel-artifacts/src/backend.rs +++ b/core/services/vercel-artifacts/src/backend.rs @@ -96,7 +96,6 @@ impl Builder for VercelArtifactsBuilder { stat: true, read: true, - read_with_suffix: true, write: true, @@ -166,6 +165,7 @@ impl Service for VercelArtifactsBackend { _path: &str, _args: OpCreateDir, ) -> Result { + // Vercel Remote Cache is append-only and does not support folder operations. Err(Error::new( ErrorKind::Unsupported, "operation is not supported", @@ -173,16 +173,36 @@ impl Service for VercelArtifactsBackend { } async fn stat(&self, ctx: &OperationContext, path: &str, _args: OpStat) -> Result { + if path == "/" || path.ends_with('/') { + return Ok(RpStat::new(Metadata::new(EntryMode::DIR))); + } + let response = self.core.vercel_artifacts_stat(ctx, path).await?; let status = response.status(); match status { + StatusCode::PARTIAL_CONTENT => { + // 206: Content-Range header encodes total artifact size. + let meta = parse_into_metadata(path, response.headers())?; + Ok(RpStat::new(meta)) + } StatusCode::OK => { + // 200: server returned full content (Range header not honoured). + // Content-Length may be absent when transfer is chunked; fall back + // to body length so that stat always returns the correct file size. + let (parts, body) = response.into_parts(); + let mut meta = parse_into_metadata(path, &parts.headers)?; + if meta.content_length() == 0 && !body.is_empty() { + meta.set_content_length(body.len() as u64); + } + Ok(RpStat::new(meta)) + } + StatusCode::RANGE_NOT_SATISFIABLE => { + // 416: artifact exists but is empty (size = 0). let meta = parse_into_metadata(path, response.headers())?; Ok(RpStat::new(meta)) } - _ => Err(parse_error(response)), } } @@ -200,6 +220,14 @@ impl Service for VercelArtifactsBackend { } fn write(&self, ctx: &OperationContext, path: &str, args: OpWrite) -> Result { + // Vercel Remote Cache is key-based; paths ending with '/' are treated as directories. + if path.ends_with('/') { + return Err(Error::new( + ErrorKind::IsADirectory, + "write to a directory path is not supported", + )); + } + let output: oio::OneShotWriter = { Ok(oio::OneShotWriter::new(VercelArtifactsWriter::new( self.core.clone(), @@ -213,6 +241,7 @@ impl Service for VercelArtifactsBackend { } fn delete(&self, _ctx: &OperationContext) -> Result { + // Vercel Remote Cache is append-only and does not support artifact deletion. Err(Error::new( ErrorKind::Unsupported, "operation is not supported", diff --git a/core/services/vercel-artifacts/src/core.rs b/core/services/vercel-artifacts/src/core.rs index 7da1e73d6c39..ce772e32909b 100644 --- a/core/services/vercel-artifacts/src/core.rs +++ b/core/services/vercel-artifacts/src/core.rs @@ -114,17 +114,19 @@ impl VercelArtifactsCore { self.query_string ); - let mut req = Request::head(&url); - - let auth_header_content = format!("Bearer {}", self.access_token); - req = req.header(header::AUTHORIZATION, auth_header_content); - req = req.header(header::CONTENT_LENGTH, 0); - - req = req + // Use a Range request instead of HEAD so that the Content-Range response + // header gives us the total artifact size (Vercel's HEAD responses do not + // include Content-Length). Matches the pattern used by the ghac backend. + let req = Request::get(&url) + .header( + header::AUTHORIZATION, + format!("Bearer {}", self.access_token), + ) + .header(header::RANGE, "bytes=0-0") .extension(Operation::Stat) - .extension(ServiceOperation("ArtifactExists")); - - let req = req.body(Buffer::new()).map_err(new_request_build_error)?; + .extension(ServiceOperation("ArtifactExists")) + .body(Buffer::new()) + .map_err(new_request_build_error)?; ctx.http_transport().send(req).await } @@ -144,7 +146,9 @@ mod error { let (kind, retryable) = match parts.status { StatusCode::NOT_FOUND => (ErrorKind::NotFound, false), - StatusCode::FORBIDDEN => (ErrorKind::PermissionDenied, false), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + (ErrorKind::PermissionDenied, false) + } StatusCode::INTERNAL_SERVER_ERROR | StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE diff --git a/core/services/vercel-artifacts/src/docs.md b/core/services/vercel-artifacts/src/docs.md index 9a359a3fa6b0..8a438cc9a733 100644 --- a/core/services/vercel-artifacts/src/docs.md +++ b/core/services/vercel-artifacts/src/docs.md @@ -12,6 +12,14 @@ This service can be used to: - [ ] ~~rename~~ - [ ] ~~presign~~ +## Limitations + +Vercel Remote Cache stores build artifacts addressed by a hash of the task inputs. +Because of its append-only, hash-keyed design, it has the following limitations: +- **Folder Operations**: It does not support creating directories (`create_dir`) or listing files (`list`). +- **Resource Deletion**: It does not support deleting individual remote cache artifacts (`delete`). Cache invalidation is handled automatically by Vercel or locally via input changes (which produce a new task hash). +- **Suffix Range Reads**: `read_with_suffix` is not declared because suffix range reads (`Range: bytes=-N`) have not been verified against the Vercel Remote Cache API. Full reads and standard range reads (`Range: bytes=X-Y`) are supported. + ## Configuration - `access_token`: set the access_token for Rest API diff --git a/core/services/vercel-artifacts/src/writer.rs b/core/services/vercel-artifacts/src/writer.rs index 201cc06fc7a5..c7023e0aefc2 100644 --- a/core/services/vercel-artifacts/src/writer.rs +++ b/core/services/vercel-artifacts/src/writer.rs @@ -50,15 +50,20 @@ impl VercelArtifactsWriter { impl oio::OneShotWrite for VercelArtifactsWriter { async fn write_once(&self, bs: Buffer) -> Result { + let size = bs.len() as u64; let response = self .core - .vercel_artifacts_put(&self.ctx, self.path.as_str(), bs.len() as u64, bs) + .vercel_artifacts_put(&self.ctx, self.path.as_str(), size, bs) .await?; let status = response.status(); match status { - StatusCode::OK | StatusCode::ACCEPTED => Ok(Metadata::default()), + StatusCode::OK | StatusCode::ACCEPTED => { + let mut meta = Metadata::default(); + meta.set_content_length(size); + Ok(meta) + } _ => Err(parse_error(response)), } } diff --git a/core/tests/behavior/async_write.rs b/core/tests/behavior/async_write.rs index d3b5b05e788c..cd946ab6c196 100644 --- a/core/tests/behavior/async_write.rs +++ b/core/tests/behavior/async_write.rs @@ -693,6 +693,12 @@ pub async fn test_writer_write_with_overwrite(op: Operator) -> Result<()> { if op.info().scheme() == services::GHAC_SCHEME { return Ok(()); } + // vercel-artifacts does not support overwrite: a PUT to an existing + // artifact hash returns 200 (cache hit) without updating the stored content. + #[cfg(feature = "services-vercel-artifacts")] + if op.info().scheme() == services::VERCEL_ARTIFACTS_SCHEME { + return Ok(()); + } let path = uuid::Uuid::new_v4().to_string(); let (content_one, _) = gen_bytes(op.info().capability());