Skip to content

Commit 48545cf

Browse files
authored
feat(sandbox): add GCE metadata emulator for Google Cloud (#1763)
* feat(core): add shared GCP constants module Single source of truth for GCP naming: env var aliases, provider config keys, token search order, and Vertex-specific env vars. Consumed by openshell-server, openshell-providers, and openshell-sandbox. - Add google_cloud.rs with metadata emulator host and loopback address - Define PROJECT_ID, REGION, and SERVICE_ACCOUNT_EMAIL env var aliases - Add provider config key constants for gcp provider implementations - Define TOKEN_ENV_KEYS search order (SA token takes priority over ADC) - Add Vertex-specific env vars for Goose and Claude Code SDK integration - Add STATIC_CONFIG_KEYS as union of all alias arrays for env resolution - Export module via openshell-core lib.rs Signed-off-by: Robert Sturla <rsturla@redhat.com> * feat(providers): add google-cloud and vertex provider plugins Add GoogleCloudProvider and VertexProvider implementing inject_env to project GCP config (project ID, region, SA email, metadata host) into sandbox environment variables. Replace the inline Vertex AI env injection in the server with the registry-based inject_env dispatch. Also adds the google-cloud.yaml provider profile with SA JWT and ADC OAuth2 credential refresh flows. Signed-off-by: Robert Sturla <rsturla@redhat.com> * feat(sandbox): add GCE metadata emulator for GCP Add a loopback HTTP server on 127.0.0.1:8174 inside the sandbox network namespace that emulates the GCE instance metadata API. GCP client SDKs discover it via GCE_METADATA_HOST and obtain credential placeholders that the proxy resolves to real tokens at egress. Add metadata_server module with MetadataHandler trait and netns-aware TCP binding via std::thread (not spawn_blocking) to avoid tokio pool namespace contamination Add google_cloud_metadata module implementing the GCE metadata API subset (token, project-id, email, scopes, service-accounts) Add child_env_resolved() and gcp_token_response() to ProviderCredentialState for GCP-aware credential projection Wire metadata server into sandbox lifecycle before SSH handler Collapse multi-line HTTP response format string into single line Signed-off-by: Robert Sturla <rsturla@redhat.com> * docs(sandbox): add GCP credentials documentation Document the google-cloud provider setup for ADC and service account flows, injected environment variables, metadata emulator behavior, and network policy configuration for GCP APIs. Signed-off-by: Robert Sturla <rsturla@redhat.com> * feat(cli): support --from-gcloud-adc for google-cloud providers Widen --from-gcloud-adc to accept google-cloud providers. The ADC credential key is derived from the provider profile rather than hardcoded per type, so future GCP provider types get ADC support by declaring the right refresh metadata in their profile YAML. Add ProviderTypeProfile::adc_credential() to find the ADC-compatible credential from a profile's refresh metadata. Remove unused VERTEX_AI_ADC_TOKEN_KEY and GCP_ADC_TOKEN_KEY constants. Signed-off-by: Robert Sturla <rsturla@redhat.com> --------- Signed-off-by: Robert Sturla <rsturla@redhat.com>
1 parent 48a7d09 commit 48545cf

23 files changed

Lines changed: 2151 additions & 91 deletions

File tree

architecture/sandbox.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,10 @@ live under a dedicated directory. Children also enter a private mount namespace
7676
where that socket directory is hidden before privilege drop.
7777

7878
Credential placeholders in proxied HTTP requests can be resolved by the proxy
79-
when policy allows the target endpoint. Secrets must not be logged in OCSF or
80-
plain tracing output.
79+
when policy allows the target endpoint. For GCP providers, a loopback metadata
80+
server inside the network namespace serves placeholders to SDKs that bypass the
81+
proxy (e.g. Go's `cloud.google.com/go/compute/metadata`). Secrets must not be
82+
logged in OCSF or plain tracing output.
8183

8284
Provider profiles can also declare dynamic token grants. For matching HTTP
8385
endpoints, the supervisor obtains a SPIFFE JWT-SVID from the local Workload API,

crates/openshell-cli/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -740,7 +740,7 @@ enum ProviderCommands {
740740

741741
/// Configure credentials from gcloud Application Default Credentials
742742
/// (`~/.config/gcloud/application_default_credentials.json`).
743-
/// Only valid for google-vertex-ai providers.
743+
/// Valid for providers whose profile declares an ADC-compatible credential.
744744
#[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "runtime_credentials"])]
745745
from_gcloud_adc: bool,
746746

crates/openshell-cli/src/run.rs

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4347,7 +4347,7 @@ fn read_gcloud_adc() -> Result<(String, String, String)> {
43474347
Ok((client_id, client_secret, refresh_token))
43484348
}
43494349

4350-
async fn rollback_provider_create_after_vertex_adc_failure(
4350+
async fn rollback_provider_create_after_gcloud_adc_failure(
43514351
client: &mut crate::tls::GrpcClient,
43524352
provider_name: &str,
43534353
stage: &str,
@@ -4360,7 +4360,7 @@ async fn rollback_provider_create_after_vertex_adc_failure(
43604360
.await
43614361
{
43624362
Ok(_) => Err(miette!(
4363-
"failed to {stage} Vertex AI credentials from gcloud ADC for provider '{provider_name}': {source}. \
4363+
"failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \
43644364
The provider was rolled back successfully."
43654365
)),
43664366
Err(cleanup_err) => {
@@ -4374,7 +4374,7 @@ async fn rollback_provider_create_after_vertex_adc_failure(
43744374
provider_name
43754375
);
43764376
Err(miette!(
4377-
"failed to {stage} Vertex AI credentials from gcloud ADC for provider '{provider_name}': {source}. \
4377+
"failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \
43784378
Cleanup also failed, so the provider may still exist. \
43794379
Run 'openshell provider delete {provider_name}' to remove it manually."
43804380
))
@@ -4483,6 +4483,9 @@ async fn discover_existing_provider_data(
44834483
/// Canonical provider type string for Google Vertex AI.
44844484
const VERTEX_AI_PROVIDER_TYPE: &str = "google-vertex-ai";
44854485

4486+
/// Canonical provider type string for Google Cloud (GCP APIs).
4487+
const GOOGLE_CLOUD_PROVIDER_TYPE: &str = "google-cloud";
4488+
44864489
fn missing_credentials_error(provider_type: &str) -> miette::Report {
44874490
if provider_type == VERTEX_AI_PROVIDER_TYPE {
44884491
return miette::miette!(
@@ -4493,6 +4496,14 @@ fn missing_credentials_error(provider_type: &str) -> miette::Report {
44934496
);
44944497
}
44954498

4499+
if provider_type == GOOGLE_CLOUD_PROVIDER_TYPE {
4500+
return miette::miette!(
4501+
"no credentials resolved for provider type '{provider_type}'. \
4502+
Set GCP_ADC_ACCESS_TOKEN or GCP_SA_ACCESS_TOKEN; \
4503+
or use --from-gcloud-adc / --from-existing with those env vars set."
4504+
);
4505+
}
4506+
44964507
miette::miette!(
44974508
"no credentials resolved for provider type '{provider_type}'. \
44984509
Use --credential KEY[=VALUE], --runtime-credentials for runtime-resolved profile credentials, \
@@ -4583,11 +4594,34 @@ pub async fn provider_create_with_options(
45834594
}
45844595
};
45854596

4586-
if from_gcloud_adc && provider_type != VERTEX_AI_PROVIDER_TYPE {
4587-
return Err(miette::miette!(
4588-
"--from-gcloud-adc is only valid for google-vertex-ai providers"
4589-
));
4590-
}
4597+
let adc_credential_key = if from_gcloud_adc {
4598+
let profile =
4599+
openshell_providers::get_default_profile(&provider_type).ok_or_else(|| {
4600+
miette::miette!(
4601+
"--from-gcloud-adc requires a built-in provider profile, \
4602+
but '{provider_type}' has none"
4603+
)
4604+
})?;
4605+
let adc_cred = profile.adc_credential().ok_or_else(|| {
4606+
miette::miette!(
4607+
"--from-gcloud-adc is not supported for '{provider_type}' providers \
4608+
(no ADC-compatible credential in the provider profile)"
4609+
)
4610+
})?;
4611+
Some(
4612+
adc_cred
4613+
.env_vars
4614+
.first()
4615+
.ok_or_else(|| {
4616+
miette::miette!(
4617+
"ADC credential in '{provider_type}' profile has no env_vars declared"
4618+
)
4619+
})?
4620+
.clone(),
4621+
)
4622+
} else {
4623+
None
4624+
};
45914625

45924626
let mut credential_map = parse_credential_pairs(credentials)?;
45934627
let mut config_map = parse_key_value_pairs(config, "--config")?;
@@ -4636,10 +4670,12 @@ pub async fn provider_create_with_options(
46364670
}
46374671

46384672
// Validate and read the ADC file BEFORE creating the provider so that
4639-
// a bad/missing ADC does not leave an orphan provider behind.
4640-
let gcloud_adc_material = if from_gcloud_adc {
4673+
// a bad/missing ADC does not leave an orphan provider behind. Bundle the
4674+
// credential key with the material so they stay coupled.
4675+
let gcloud_adc_bootstrap = if from_gcloud_adc {
46414676
let (client_id, client_secret, refresh_token) = read_gcloud_adc()?;
4642-
Some((client_id, client_secret, refresh_token))
4677+
let key = adc_credential_key.expect("set when from_gcloud_adc is true");
4678+
Some((key, client_id, client_secret, refresh_token))
46434679
} else {
46444680
None
46454681
};
@@ -4669,7 +4705,9 @@ pub async fn provider_create_with_options(
46694705
.ok_or_else(|| miette::miette!("provider missing from response"))?;
46704706
let provider_name = provider.object_name().to_string();
46714707

4672-
if let Some((client_id, client_secret, refresh_token)) = gcloud_adc_material {
4708+
if let Some((adc_credential_key, client_id, client_secret, refresh_token)) =
4709+
gcloud_adc_bootstrap
4710+
{
46734711
let mut material = HashMap::new();
46744712
material.insert("client_id".to_string(), client_id);
46754713
material.insert("client_secret".to_string(), client_secret);
@@ -4678,7 +4716,7 @@ pub async fn provider_create_with_options(
46784716
if let Err(configure_err) = client
46794717
.configure_provider_refresh(ConfigureProviderRefreshRequest {
46804718
provider: provider_name.clone(),
4681-
credential_key: openshell_core::inference::VERTEX_AI_ADC_TOKEN_KEY.to_string(),
4719+
credential_key: adc_credential_key.clone(),
46824720
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32,
46834721
material,
46844722
secret_material_keys: vec![
@@ -4689,7 +4727,7 @@ pub async fn provider_create_with_options(
46894727
})
46904728
.await
46914729
{
4692-
return rollback_provider_create_after_vertex_adc_failure(
4730+
return rollback_provider_create_after_gcloud_adc_failure(
46934731
&mut client,
46944732
&provider_name,
46954733
"configure",
@@ -4701,11 +4739,11 @@ pub async fn provider_create_with_options(
47014739
if let Err(rotate_err) = client
47024740
.rotate_provider_credential(RotateProviderCredentialRequest {
47034741
provider: provider_name.clone(),
4704-
credential_key: openshell_core::inference::VERTEX_AI_ADC_TOKEN_KEY.to_string(),
4742+
credential_key: adc_credential_key,
47054743
})
47064744
.await
47074745
{
4708-
return rollback_provider_create_after_vertex_adc_failure(
4746+
return rollback_provider_create_after_gcloud_adc_failure(
47094747
&mut client,
47104748
&provider_name,
47114749
"mint the initial access token for",
@@ -4715,9 +4753,7 @@ pub async fn provider_create_with_options(
47154753
}
47164754

47174755
println!("{} Created provider {}", "✓".green().bold(), provider_name);
4718-
println!(
4719-
"Configured Vertex AI credentials from gcloud ADC and minted the initial access token"
4720-
);
4756+
println!("Configured GCP credentials from gcloud ADC and minted the initial access token");
47214757
return Ok(());
47224758
}
47234759

crates/openshell-cli/tests/provider_commands_integration.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2329,8 +2329,7 @@ async fn provider_create_from_gcloud_adc_rejects_wrong_provider_type_before_cred
23292329
.expect_err("wrong provider type should fail before generic credential validation");
23302330

23312331
assert!(
2332-
err.to_string()
2333-
.contains("--from-gcloud-adc is only valid for google-vertex-ai providers"),
2332+
err.to_string().contains("--from-gcloud-adc"),
23342333
"unexpected error: {err}"
23352334
);
23362335
assert!(ts.state.providers.lock().await.is_empty());
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Shared GCP constants for the metadata emulator, provider env injection,
5+
//! and credential resolution.
6+
//!
7+
//! This module is the single source of truth for GCP naming: env var aliases,
8+
//! provider config keys, token search order, and Vertex-specific env vars.
9+
//! `openshell-server`, `openshell-providers`, and `openshell-sandbox`
10+
//! import from here.
11+
12+
// ── Metadata emulator ───────────────────────────────────────────────────────
13+
14+
/// Hostname served by the GCE metadata emulator via proxy interception.
15+
pub const METADATA_HOST: &str = "gcp.metadata.openshell.internal";
16+
17+
/// Loopback address for the GCE metadata server inside sandbox namespaces.
18+
/// Go's metadata client dials this directly (bypasses `HTTP_PROXY`).
19+
pub const METADATA_LOOPBACK_ADDR: &str = "127.0.0.1:8174";
20+
21+
// ── Env var alias arrays ────────────────────────────────────────────────────
22+
23+
/// Env vars that carry the GCP project ID inside sandboxes.
24+
pub const PROJECT_ID_ENV_VARS: &[&str] = &["GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT"];
25+
26+
/// Env vars that carry the GCP region/location inside sandboxes.
27+
pub const REGION_ENV_VARS: &[&str] = &["CLOUD_ML_REGION", "GCP_LOCATION"];
28+
29+
/// Env vars that carry the GCP service account email inside sandboxes.
30+
pub const SERVICE_ACCOUNT_EMAIL_ENV_VARS: &[&str] = &["GCP_SERVICE_ACCOUNT_EMAIL"];
31+
32+
// ── Provider config keys ────────────────────────────────────────────────────
33+
34+
/// Config key for project ID in `gcp` providers.
35+
pub const GCP_PROJECT_ID_CONFIG_KEY: &str = "project_id";
36+
37+
/// Config key for region in `gcp` providers.
38+
pub const GCP_REGION_CONFIG_KEY: &str = "region";
39+
40+
/// Config key for service account email in `gcp` providers.
41+
pub const GCP_SERVICE_ACCOUNT_EMAIL_CONFIG_KEY: &str = "service_account_email";
42+
43+
// ── Token search order ──────────────────────────────────────────────────────
44+
45+
/// GCP token env vars searched in priority order by the metadata emulator.
46+
/// SA token wins over ADC if both are configured, matching GCP's own
47+
/// credential precedence.
48+
pub const TOKEN_ENV_KEYS: &[&str] = &["GCP_SA_ACCESS_TOKEN", "GCP_ADC_ACCESS_TOKEN"];
49+
50+
// ── Vertex-specific env vars ────────────────────────────────────────────────
51+
52+
/// Env var injected to signal Vertex AI usage to Goose.
53+
pub const GOOSE_PROVIDER_ENV_VAR: &str = "GOOSE_PROVIDER";
54+
55+
/// Env var for Anthropic Vertex project ID (consumed by Claude Code SDK).
56+
pub const ANTHROPIC_VERTEX_PROJECT_ID_ENV_VAR: &str = "ANTHROPIC_VERTEX_PROJECT_ID";
57+
58+
/// Env var for Vertex location (consumed by Claude Code SDK).
59+
pub const VERTEX_LOCATION_ENV_VAR: &str = "VERTEX_LOCATION";
60+
61+
/// Non-secret GCP/Vertex config vars that must be resolved to real values
62+
/// in the child environment. Everything else stays as placeholders for
63+
/// proxy-time resolution.
64+
///
65+
/// This list MUST be the union of all alias arrays above plus all
66+
/// Vertex-specific env vars. If you add an alias to `PROJECT_ID_ENV_VARS`,
67+
/// `REGION_ENV_VARS`, or a Vertex constant, add it here too.
68+
pub const STATIC_CONFIG_KEYS: &[&str] = &[
69+
// project_id aliases
70+
"GCP_PROJECT_ID",
71+
"GOOGLE_CLOUD_PROJECT",
72+
// region aliases
73+
"CLOUD_ML_REGION",
74+
"GCP_LOCATION",
75+
// service account email
76+
"GCP_SERVICE_ACCOUNT_EMAIL",
77+
// Vertex-specific non-secret config
78+
GOOSE_PROVIDER_ENV_VAR,
79+
ANTHROPIC_VERTEX_PROJECT_ID_ENV_VAR,
80+
VERTEX_LOCATION_ENV_VAR,
81+
];
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
use std::collections::HashSet;
87+
88+
#[test]
89+
fn static_config_keys_matches_alias_arrays_and_vertex_vars() {
90+
let expected: HashSet<&str> = PROJECT_ID_ENV_VARS
91+
.iter()
92+
.chain(REGION_ENV_VARS)
93+
.chain(SERVICE_ACCOUNT_EMAIL_ENV_VARS)
94+
.copied()
95+
.chain([
96+
GOOSE_PROVIDER_ENV_VAR,
97+
ANTHROPIC_VERTEX_PROJECT_ID_ENV_VAR,
98+
VERTEX_LOCATION_ENV_VAR,
99+
])
100+
.collect();
101+
let actual: HashSet<&str> = STATIC_CONFIG_KEYS.iter().copied().collect();
102+
assert_eq!(
103+
expected,
104+
actual,
105+
"STATIC_CONFIG_KEYS must be the union of all alias arrays + Vertex vars. \
106+
Missing: {:?}, Extra: {:?}",
107+
expected.difference(&actual).collect::<Vec<_>>(),
108+
actual.difference(&expected).collect::<Vec<_>>(),
109+
);
110+
}
111+
}

crates/openshell-core/src/inference.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,6 @@ pub const VERTEX_AI_CREDENTIAL_KEY_NAMES: &[&str] = &[
102102
"VERTEX_AI_TOKEN",
103103
];
104104

105-
/// The credential key used for tokens minted from gcloud Application Default Credentials.
106-
///
107-
/// This is the key written by the gateway's `OAuth2` refresh worker when using the
108-
/// `--from-gcloud-adc` CLI flow. It must match `VERTEX_AI_CREDENTIAL_KEY_NAMES[2]`.
109-
pub const VERTEX_AI_ADC_TOKEN_KEY: &str = "GOOGLE_VERTEX_AI_TOKEN";
110-
111105
/// GCP project ID config key for Vertex AI providers.
112106
pub const VERTEX_AI_PROJECT_ID_KEY: &str = "VERTEX_AI_PROJECT_ID";
113107

crates/openshell-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod driver_mounts;
1717
pub mod driver_utils;
1818
pub mod error;
1919
pub mod forward;
20+
pub mod google_cloud;
2021
pub mod gpu;
2122
pub mod grpc_client;
2223
pub mod image;

0 commit comments

Comments
 (0)