Skip to content

Commit 66220a7

Browse files
committed
feat(services): publish authoritative application origins
Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
1 parent 0939a70 commit 66220a7

33 files changed

Lines changed: 1196 additions & 616 deletions

File tree

‎crates/openshell-cli/src/run.rs‎

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -723,10 +723,17 @@ pub async fn sandbox_create(
723723
Err(status) => return Err(miette::miette!(status.to_string())),
724724
};
725725
let response = response.into_inner();
726+
let application_service_urls = response.application_service_urls;
726727
let service_urls = response
727728
.service_urls
728729
.into_iter()
729-
.map(|(service, url)| (service, service_url_for_gateway(&url, &effective_server)))
730+
.map(|(service, url)| {
731+
let url = application_service_urls
732+
.get(&service)
733+
.cloned()
734+
.unwrap_or_else(|| service_url_for_gateway(&url, &effective_server));
735+
(service, url)
736+
})
730737
.collect::<HashMap<_, _>>();
731738
let sandbox = response
732739
.sandbox
@@ -1115,10 +1122,7 @@ pub async fn sandbox_create(
11151122
"\u{2713}".green().bold(),
11161123
);
11171124
if let Some(url) = service_urls.get("").filter(|url| !url.is_empty()) {
1118-
eprintln!(
1119-
" Access at: {}",
1120-
service_url_for_gateway(url, &effective_server)
1121-
);
1125+
eprintln!(" Access at: {url}");
11221126
}
11231127
}
11241128

@@ -4361,7 +4365,7 @@ pub async fn service_expose(
43614365
);
43624366
}
43634367
if !response.url.is_empty() {
4364-
let url = service_url_for_gateway(&response.url, server);
4368+
let url = preferred_service_url(&response, server);
43654369
println!(" URL: {}", url.cyan());
43664370
}
43674371
Ok(())
@@ -4574,7 +4578,7 @@ fn print_service_endpoint_table(
45744578
let url = if response.url.is_empty() {
45754579
String::new()
45764580
} else {
4577-
service_url_for_gateway(&response.url, gateway_endpoint)
4581+
preferred_service_url(response, gateway_endpoint)
45784582
};
45794583
Some((
45804584
workspace.to_string(),
@@ -4665,7 +4669,7 @@ fn service_endpoint_to_json(
46654669
let url = if response.url.is_empty() {
46664670
String::new()
46674671
} else {
4668-
service_url_for_gateway(&response.url, gateway_endpoint)
4672+
preferred_service_url(response, gateway_endpoint)
46694673
};
46704674

46714675
Some(serde_json::json!({
@@ -4675,6 +4679,8 @@ fn service_endpoint_to_json(
46754679
"target_port": endpoint.target_port,
46764680
"authorization_mode": service_authorization_mode_name(endpoint.authorization_mode),
46774681
"url": url,
4682+
"application_url": response.application_url,
4683+
"gateway_url": service_url_for_gateway(&response.gateway_url, gateway_endpoint),
46784684
}))
46794685
}
46804686

@@ -4689,13 +4695,16 @@ fn service_authorization_mode_name(mode: i32) -> &'static str {
46894695
}
46904696
}
46914697

4692-
/// Read gcloud Application Default Credentials from disk.
4693-
///
4694-
/// Returns `(client_id, client_secret, refresh_token)`.
4695-
///
4696-
/// Checks `GOOGLE_APPLICATION_CREDENTIALS` first; falls back to
4697-
/// `$CLOUDSDK_CONFIG/application_default_credentials.json` when set, then to
4698-
/// `~/.config/gcloud/application_default_credentials.json`.
4698+
/// Application origins are independent of the gateway transport. Only legacy
4699+
/// gateway URLs inherit a caller's external gateway port.
4700+
fn preferred_service_url(response: &ServiceEndpointResponse, gateway: &str) -> String {
4701+
if response.application_url.is_empty() {
4702+
service_url_for_gateway(&response.url, gateway)
4703+
} else {
4704+
response.application_url.clone()
4705+
}
4706+
}
4707+
46994708
fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String {
47004709
let (Ok(mut service_url), Ok(gateway_endpoint)) = (
47014710
url::Url::parse(service_url),
@@ -7324,6 +7333,8 @@ mod tests {
73247333
#[test]
73257334
fn service_endpoint_json_has_raw_fields_and_normalized_url() {
73267335
let response = ServiceEndpointResponse {
7336+
application_url: String::new(),
7337+
gateway_url: String::new(),
73277338
endpoint: Some(ServiceEndpoint {
73287339
metadata: Some(ObjectMeta {
73297340
workspace: "team-a".to_string(),
@@ -7349,6 +7360,8 @@ mod tests {
73497360
"target_port": 8080,
73507361
"authorization_mode": "bearer_passthrough",
73517362
"url": "https://api.openshell.localhost:17670/",
7363+
"application_url": "",
7364+
"gateway_url": "",
73527365
})
73537366
);
73547367
assert!(service_endpoint_to_json(&ServiceEndpointResponse::default(), "unused").is_none());
@@ -8108,6 +8121,20 @@ mod tests {
81088121
assert!(rootfs_tar_sources_supported_for_gateway(None));
81098122
}
81108123

8124+
#[test]
8125+
fn application_origin_is_preserved_when_gateway_uses_a_port_forward() {
8126+
let response = ServiceEndpointResponse {
8127+
application_url: "https://default--demo--web.services.example.com:10443/".into(),
8128+
gateway_url: "https://default--demo--web.api.example.com:8080/".into(),
8129+
url: "https://default--demo--web.services.example.com:10443/".into(),
8130+
..Default::default()
8131+
};
8132+
assert_eq!(
8133+
super::preferred_service_url(&response, "https://localhost:29092"),
8134+
response.application_url
8135+
);
8136+
}
8137+
81118138
#[test]
81128139
fn service_url_for_gateway_uses_external_gateway_port() {
81138140
assert_eq!(

‎crates/openshell-cli/tests/provider_commands_integration.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,8 @@ impl OpenShell for TestOpenShell {
378378
let name = request.name;
379379
// Return a minimal sandbox with metadata for CAS operations
380380
Ok(Response::new(SandboxResponse {
381+
application_service_urls: HashMap::new(),
382+
gateway_service_urls: HashMap::new(),
381383
sandbox: Some(Sandbox {
382384
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
383385
id: format!("sb-{name}"),

‎crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ impl OpenShell for TestOpenShell {
237237
},
238238
);
239239
Ok(Response::new(SandboxResponse {
240+
application_service_urls: HashMap::new(),
241+
gateway_service_urls: HashMap::new(),
240242
sandbox: Some(sandbox),
241243
service_urls,
242244
}))
@@ -291,6 +293,8 @@ impl OpenShell for TestOpenShell {
291293
};
292294
sandbox.set_phase(SandboxPhase::Ready as i32);
293295
Ok(Response::new(SandboxResponse {
296+
application_service_urls: HashMap::new(),
297+
gateway_service_urls: HashMap::new(),
294298
sandbox: Some(sandbox),
295299
service_urls: HashMap::new(),
296300
}))
@@ -527,6 +531,8 @@ impl OpenShell for TestOpenShell {
527531
.push(request.into_inner());
528532
Ok(Response::new(
529533
openshell_core::proto::ServiceEndpointResponse {
534+
application_url: String::new(),
535+
gateway_url: String::new(),
530536
url: "https://default--sandbox.openshell.localhost:17670/".to_string(),
531537
..Default::default()
532538
},
@@ -539,6 +545,8 @@ impl OpenShell for TestOpenShell {
539545
) -> Result<Response<openshell_core::proto::ServiceEndpointResponse>, Status> {
540546
Ok(Response::new(
541547
openshell_core::proto::ServiceEndpointResponse {
548+
application_url: String::new(),
549+
gateway_url: String::new(),
542550
url: "https://default--sandbox.openshell.localhost:17670/".to_string(),
543551
..Default::default()
544552
},

‎crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ macro_rules! mock_gateway {
188188
async fn get_sandbox(&self, _: Request<proto::GetSandboxRequest>) -> Result<Response<proto::SandboxResponse>, Status> {
189189
self.calls.lock().unwrap().lookups += 1;
190190
Ok(Response::new(proto::SandboxResponse {
191+
application_service_urls: std::collections::HashMap::new(),
192+
gateway_service_urls: std::collections::HashMap::new(),
191193
sandbox: Some(proto::Sandbox {
192194
metadata: Some(proto::datamodel::v1::ObjectMeta {
193195
id: "test-id".to_string(),

‎crates/openshell-cli/tests/sandbox_name_fallback_integration.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ impl OpenShell for TestOpenShell {
187187
let name = request.name.clone();
188188
*self.state.last_get_name.lock().await = Some(name.clone());
189189
Ok(Response::new(SandboxResponse {
190+
application_service_urls: std::collections::HashMap::new(),
191+
gateway_service_urls: std::collections::HashMap::new(),
190192
sandbox: Some(Sandbox {
191193
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
192194
id: "test-id".to_string(),

‎crates/openshell-core/src/app_listener.rs‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ pub struct AppListenerConfig {
1313
pub enabled: bool,
1414
pub port: u16,
1515
pub domain: String,
16+
/// Public HTTPS origin, independently of the supervisor's listener port.
17+
pub public_url: Option<String>,
1618
}
1719

1820
impl Default for AppListenerConfig {
@@ -21,13 +23,17 @@ impl Default for AppListenerConfig {
2123
enabled: false,
2224
port: 8443,
2325
domain: String::new(),
26+
public_url: None,
2427
}
2528
}
2629
}
2730

2831
impl AppListenerConfig {
2932
pub fn validate(&self) -> Result<(), String> {
3033
if !self.enabled {
34+
if self.public_url.is_some() {
35+
return Err("supervisor.app_listener.public_url requires enabled = true".into());
36+
}
3137
return Ok(());
3238
}
3339
if self.port < 1024 {
@@ -46,8 +52,40 @@ impl AppListenerConfig {
4652
{
4753
return Err("supervisor.app_listener.domain must be a lowercase DNS name".into());
4854
}
55+
if let Some(origin) = &self.public_url {
56+
let parsed = url::Url::parse(origin)
57+
.map_err(|_| "supervisor.app_listener.public_url must be an HTTPS origin")?;
58+
if parsed.scheme() != "https"
59+
|| parsed.host_str() != Some(self.domain.as_str())
60+
|| !parsed.username().is_empty()
61+
|| parsed.password().is_some()
62+
|| parsed.path() != "/"
63+
|| parsed.query().is_some()
64+
|| parsed.fragment().is_some()
65+
|| parsed.port() == Some(0)
66+
{
67+
return Err("supervisor.app_listener.public_url must be an HTTPS origin matching domain, without credentials, path, query, or fragment".into());
68+
}
69+
}
4970
Ok(())
5071
}
72+
73+
/// Configured address only; this does not imply installation or readiness.
74+
pub fn service_url(&self, workspace: &str, sandbox: &str, service: &str) -> Option<String> {
75+
if !self.enabled {
76+
return None;
77+
}
78+
let mut origin = url::Url::parse(self.public_url.as_deref()?).ok()?;
79+
let label = if service.is_empty() {
80+
format!("{workspace}--{sandbox}")
81+
} else {
82+
format!("{workspace}--{sandbox}--{service}")
83+
};
84+
origin
85+
.set_host(Some(&format!("{label}.{}", self.domain)))
86+
.ok()?;
87+
Some(origin.into())
88+
}
5189
}
5290

5391
/// A synchronous view of installed ingress, evaluated on the sandbox instance
@@ -60,6 +98,43 @@ pub type InstallationSource = std::sync::Arc<
6098
mod tests {
6199
use super::*;
62100

101+
#[test]
102+
fn public_origin_is_explicit_https_and_matches_listener_domain() {
103+
let mut config = AppListenerConfig {
104+
enabled: true,
105+
domain: "services.example.com".into(),
106+
public_url: Some("https://services.example.com:10443".into()),
107+
..Default::default()
108+
};
109+
config.validate().unwrap();
110+
assert_eq!(
111+
config.service_url("default", "demo", "web").as_deref(),
112+
Some("https://default--demo--web.services.example.com:10443/")
113+
);
114+
config.public_url = Some("https://services.example.com:443/".into());
115+
assert_eq!(
116+
config.service_url("default", "demo", "").as_deref(),
117+
Some("https://default--demo.services.example.com/")
118+
);
119+
for bad in [
120+
"http://services.example.com",
121+
"https://other.example.com",
122+
"https://user:password@services.example.com",
123+
"https://services.example.com/path",
124+
"https://services.example.com/?query",
125+
"https://services.example.com/#fragment",
126+
"https://services.example.com:0",
127+
"not-a-url",
128+
] {
129+
config.public_url = Some(bad.into());
130+
assert!(config.validate().is_err(), "{bad}");
131+
}
132+
config.public_url = Some("https://services.example.com".into());
133+
config.enabled = false;
134+
assert!(config.validate().is_err());
135+
assert!(config.service_url("default", "demo", "web").is_none());
136+
}
137+
63138
#[test]
64139
fn only_explicit_enablement_requires_a_valid_endpoint() {
65140
assert!(!AppListenerConfig::default().enabled);

‎crates/openshell-driver-kubernetes/src/app_listener.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ mod tests {
255255
let config = KubernetesAppListenerConfig {
256256
create_service: true,
257257
common: openshell_core::app_listener::AppListenerConfig {
258+
public_url: None,
258259
enabled,
259260
port: 9443,
260261
domain: "services.example.com".into(),

‎crates/openshell-driver-kubernetes/src/config.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,6 +1153,7 @@ mod tests {
11531153
assert!(!config.app_listener.common.enabled);
11541154
assert!(config.validate().is_ok());
11551155
config.app_listener.common = openshell_core::app_listener::AppListenerConfig {
1156+
public_url: None,
11561157
enabled: true,
11571158
port: 9443,
11581159
domain: "services.example.com".into(),

‎crates/openshell-sdk/src/client.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,6 +1492,8 @@ fn sandbox_from_response(sandbox: Option<proto::Sandbox>) -> Result<SandboxRef>
14921492
fn sandbox_from_create_response(response: proto::SandboxResponse) -> Result<SandboxRef> {
14931493
let mut sandbox = sandbox_from_response(response.sandbox)?;
14941494
sandbox.service_urls = response.service_urls;
1495+
sandbox.application_service_urls = response.application_service_urls;
1496+
sandbox.gateway_service_urls = response.gateway_service_urls;
14951497
Ok(sandbox)
14961498
}
14971499

‎crates/openshell-sdk/src/types.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,10 @@ pub struct SandboxRef {
402402
/// Service URLs returned by sandbox creation, keyed by service name. The
403403
/// empty key identifies the unnamed service. Non-create reads leave this empty.
404404
pub service_urls: HashMap<String, String>,
405+
/// Configured application origins; these do not establish installation readiness.
406+
pub application_service_urls: HashMap<String, String>,
407+
/// Gateway-routed alternatives.
408+
pub gateway_service_urls: HashMap<String, String>,
405409
pub restart_count: u32,
406410
pub next_restart_at_ms: Option<i64>,
407411
pub main_process_started_at_ms: Option<i64>,
@@ -455,6 +459,8 @@ impl SandboxRef {
455459
.then_some(sandbox.host_key_fingerprint),
456460
created_from_workload_template,
457461
service_urls: HashMap::new(),
462+
application_service_urls: HashMap::new(),
463+
gateway_service_urls: HashMap::new(),
458464
restart_count,
459465
next_restart_at_ms,
460466
main_process_started_at_ms,

0 commit comments

Comments
 (0)