Skip to content
Closed
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
116 changes: 108 additions & 8 deletions src/safeoutputs/upload_pipeline_artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@
//! 3. **Associate artifact** — `POST /_apis/build/builds/{buildId}/artifacts`
//! links the container to the build as a named artifact.
//!
//! Steps 1 and 2 target the **FileContainer service**, which in Azure DevOps
//! Services lives at a different host than the main org URL
//! (`vstmr.dev.azure.com/{org}` rather than `dev.azure.com/{org}`). Posting
//! to the main org URL returns HTTP 405 because the routing proxy does not
//! handle the unscoped `_apis/resources/containers` POST. The executor
//! discovers the correct base URL by calling the resource-areas endpoint:
//! `GET {org}/_apis/resourceAreas/e4f45d1e-c5e5-4112-a594-9d59a8f6c707`
//! and reading `locationUrl` from the response. If the lookup fails (e.g.
//! an on-premises ADO Server instance that doesn't support resource areas),
//! it falls back to the plain org URL.
//!
//! The flow mirrors `upload-build-attachment`:
//!
//! * **Stage 1 (MCP, in the agent sandbox):** the MCP server validates the
Expand Down Expand Up @@ -212,6 +223,77 @@ impl Default for UploadPipelineArtifactConfig {
}
}

/// Resource-area ID for the Azure DevOps FileContainer (distributedtask) service.
///
/// Querying `GET {org}/_apis/resourceAreas/{CONTAINER_RESOURCE_AREA_ID}` returns
/// the `locationUrl` for the service that actually handles `_apis/resources/containers`
/// requests (e.g. `https://vstmr.dev.azure.com/{org}/` in Azure DevOps Services).
/// Posting to the main org URL returns HTTP 405 because that routing proxy does not
/// expose a POST handler for the unscoped containers collection.
const CONTAINER_RESOURCE_AREA_ID: &str = "e4f45d1e-c5e5-4112-a594-9d59a8f6c707";

/// Returns the base URL to use for `_apis/resources/containers` requests.
///
/// Queries the resource-areas endpoint to discover the FileContainer service URL
/// (which differs from the org URL in Azure DevOps Services). Falls back to
/// `org_url` if the lookup fails or returns an unusable response, so the function
/// is infallible from the caller's perspective.
async fn resolve_file_container_base_url(
client: &reqwest::Client,
org_url: &str,
token: &str,
) -> String {
let area_url = format!(
"{}/_apis/resourceAreas/{}?api-version=7.1",
org_url.trim_end_matches('/'),
CONTAINER_RESOURCE_AREA_ID,
);
debug!("Resolving FileContainer service URL via: {}", area_url);
match client
.get(&area_url)
.basic_auth("", Some(token))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>().await {
Ok(json) => {
if let Some(location_url) = json
.get("locationUrl")
.and_then(|v| v.as_str())
.map(|s| s.trim_end_matches('/').to_string())
{
debug!("FileContainer service URL resolved to: {}", location_url);
return location_url;
}
debug!(
"Resource area response missing 'locationUrl'; falling back to org_url"
);
}
Err(e) => {
debug!(
"Failed to parse resource area response: {}; falling back to org_url",
e
);
}
}
}
Ok(resp) => {
debug!(
"Resource area lookup returned HTTP {}; falling back to org_url",
resp.status()
);
}
Err(e) => {
debug!(
"Resource area lookup failed: {}; falling back to org_url",
e
);
}
}
org_url.trim_end_matches('/').to_string()
}

#[async_trait::async_trait]
impl Executor for UploadPipelineArtifactResult {
fn dry_run_summary(&self) -> String {
Expand Down Expand Up @@ -403,15 +485,23 @@ impl Executor for UploadPipelineArtifactResult {
.and_then(|n| n.to_str())
.unwrap_or(&self.staged_file);

// ── Resolve FileContainer service URL ────────────────────────────
// The `_apis/resources/containers` endpoint is served by the ADO
// FileContainer (distributedtask) service, which in Azure DevOps
// Services lives at a different host than the main org URL. Posting
// directly to `{org_url}/_apis/resources/containers` returns HTTP 405
// because the org routing proxy has no POST handler for that path.
// We discover the correct base URL via the resource-areas API and fall
// back to org_url for on-premises instances that don't support the
// lookup.
let container_base_url =
resolve_file_container_base_url(&client, org_url, token).await;

// ── Step 1: Create container ─────────────────────────────────────
// The `scopeIdentifier` query parameter (project GUID) is required for
// the POST to route correctly in ADO. Omitting it causes a 405 because
// the unscoped `_apis/resources/containers` collection does not support
// POST. The body only needs the container name; the project scope must
// be in the query string.
// `scopeIdentifier` (project GUID) scopes the container to the project.
let container_url = format!(
"{}/_apis/resources/containers?scopeIdentifier={}&api-version=7.1-preview.4",
org_url.trim_end_matches('/'),
container_base_url,
utf8_percent_encode(project_id, PATH_SEGMENT),
);
debug!("Creating container for artifact '{}': {}", final_name, container_url);
Expand Down Expand Up @@ -450,10 +540,10 @@ impl Executor for UploadPipelineArtifactResult {
debug!("Container created: id={}", container_id);

// ── Step 2: Upload file to container ─────────────────────────────
// Use `scopeIdentifier` (not `scope`) to match the ADO containers API.
// Use the same FileContainer service base URL resolved above.
let upload_url = format!(
"{}/_apis/resources/containers/{}?itemPath={}/{}&scopeIdentifier={}&api-version=7.1-preview.4",
org_url.trim_end_matches('/'),
container_base_url,
container_id,
utf8_percent_encode(&final_name, PATH_SEGMENT),
utf8_percent_encode(filename, PATH_SEGMENT),
Expand Down Expand Up @@ -858,4 +948,14 @@ name-prefix: "ci-"
assert!(!exec_result.success);
assert!(exec_result.message.contains("not in the allowed list"));
}

#[tokio::test]
async fn test_resolve_file_container_base_url_falls_back_on_error() {
// Point at a non-listening port so the HTTP request fails;
// the function must return the original org_url as fallback.
let client = reqwest::Client::new();
let org_url = "http://127.0.0.1:1"; // nothing listening here
let result = resolve_file_container_base_url(&client, org_url, "token").await;
assert_eq!(result, org_url);
}
}
Loading