Skip to content

Commit a6d0833

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

33 files changed

Lines changed: 1143 additions & 609 deletions

File tree

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

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

@@ -4260,7 +4264,7 @@ pub async fn service_expose(
42604264
);
42614265
}
42624266
if !response.url.is_empty() {
4263-
let url = service_url_for_gateway(&response.url, server);
4267+
let url = preferred_service_url(&response, server);
42644268
println!(" URL: {}", url.cyan());
42654269
}
42664270
Ok(())
@@ -4473,7 +4477,7 @@ fn print_service_endpoint_table(
44734477
let url = if response.url.is_empty() {
44744478
String::new()
44754479
} else {
4476-
service_url_for_gateway(&response.url, gateway_endpoint)
4480+
preferred_service_url(response, gateway_endpoint)
44774481
};
44784482
Some((
44794483
workspace.to_string(),
@@ -4564,7 +4568,7 @@ fn service_endpoint_to_json(
45644568
let url = if response.url.is_empty() {
45654569
String::new()
45664570
} else {
4567-
service_url_for_gateway(&response.url, gateway_endpoint)
4571+
preferred_service_url(response, gateway_endpoint)
45684572
};
45694573

45704574
Some(serde_json::json!({
@@ -4574,6 +4578,8 @@ fn service_endpoint_to_json(
45744578
"target_port": endpoint.target_port,
45754579
"authorization_mode": service_authorization_mode_name(endpoint.authorization_mode),
45764580
"url": url,
4581+
"application_url": response.application_url,
4582+
"gateway_url": service_url_for_gateway(&response.gateway_url, gateway_endpoint),
45774583
}))
45784584
}
45794585

@@ -4588,13 +4594,16 @@ fn service_authorization_mode_name(mode: i32) -> &'static str {
45884594
}
45894595
}
45904596

4591-
/// Read gcloud Application Default Credentials from disk.
4592-
///
4593-
/// Returns `(client_id, client_secret, refresh_token)`.
4594-
///
4595-
/// Checks `GOOGLE_APPLICATION_CREDENTIALS` first; falls back to
4596-
/// `$CLOUDSDK_CONFIG/application_default_credentials.json` when set, then to
4597-
/// `~/.config/gcloud/application_default_credentials.json`.
4597+
/// Application origins are independent of the gateway transport. Only legacy
4598+
/// gateway URLs inherit a caller's external gateway port.
4599+
fn preferred_service_url(response: &ServiceEndpointResponse, gateway: &str) -> String {
4600+
if response.application_url.is_empty() {
4601+
service_url_for_gateway(&response.url, gateway)
4602+
} else {
4603+
response.application_url.clone()
4604+
}
4605+
}
4606+
45984607
fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String {
45994608
let (Ok(mut service_url), Ok(gateway_endpoint)) = (
46004609
url::Url::parse(service_url),
@@ -7188,6 +7197,8 @@ mod tests {
71887197
#[test]
71897198
fn service_endpoint_json_has_raw_fields_and_normalized_url() {
71907199
let response = ServiceEndpointResponse {
7200+
application_url: String::new(),
7201+
gateway_url: String::new(),
71917202
endpoint: Some(ServiceEndpoint {
71927203
metadata: Some(ObjectMeta {
71937204
workspace: "team-a".to_string(),
@@ -7213,6 +7224,8 @@ mod tests {
72137224
"target_port": 8080,
72147225
"authorization_mode": "bearer_passthrough",
72157226
"url": "https://api.openshell.localhost:17670/",
7227+
"application_url": "",
7228+
"gateway_url": "",
72167229
})
72177230
);
72187231
assert!(service_endpoint_to_json(&ServiceEndpointResponse::default(), "unused").is_none());
@@ -7972,6 +7985,20 @@ mod tests {
79727985
assert!(rootfs_tar_sources_supported_for_gateway(None));
79737986
}
79747987

7988+
#[test]
7989+
fn application_origin_is_preserved_when_gateway_uses_a_port_forward() {
7990+
let response = ServiceEndpointResponse {
7991+
application_url: "https://default--demo--web.services.example.com:10443/".into(),
7992+
gateway_url: "https://default--demo--web.api.example.com:8080/".into(),
7993+
url: "https://default--demo--web.services.example.com:10443/".into(),
7994+
..Default::default()
7995+
};
7996+
assert_eq!(
7997+
super::preferred_service_url(&response, "https://localhost:29092"),
7998+
response.application_url
7999+
);
8000+
}
8001+
79758002
#[test]
79768003
fn service_url_for_gateway_uses_external_gateway_port() {
79778004
assert_eq!(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,8 @@ impl OpenShell for TestOpenShell {
364364
let name = request.name;
365365
// Return a minimal sandbox with metadata for CAS operations
366366
Ok(Response::new(SandboxResponse {
367+
application_service_urls: HashMap::new(),
368+
gateway_service_urls: HashMap::new(),
367369
sandbox: Some(Sandbox {
368370
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
369371
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
}))
@@ -277,6 +279,8 @@ impl OpenShell for TestOpenShell {
277279
};
278280
sandbox.set_phase(SandboxPhase::Ready as i32);
279281
Ok(Response::new(SandboxResponse {
282+
application_service_urls: HashMap::new(),
283+
gateway_service_urls: HashMap::new(),
280284
sandbox: Some(sandbox),
281285
service_urls: HashMap::new(),
282286
}))
@@ -513,6 +517,8 @@ impl OpenShell for TestOpenShell {
513517
.push(request.into_inner());
514518
Ok(Response::new(
515519
openshell_core::proto::ServiceEndpointResponse {
520+
application_url: String::new(),
521+
gateway_url: String::new(),
516522
url: "https://default--sandbox.openshell.localhost:17670/".to_string(),
517523
..Default::default()
518524
},
@@ -525,6 +531,8 @@ impl OpenShell for TestOpenShell {
525531
) -> Result<Response<openshell_core::proto::ServiceEndpointResponse>, Status> {
526532
Ok(Response::new(
527533
openshell_core::proto::ServiceEndpointResponse {
534+
application_url: String::new(),
535+
gateway_url: String::new(),
528536
url: "https://default--sandbox.openshell.localhost:17670/".to_string(),
529537
..Default::default()
530538
},

‎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
@@ -173,6 +173,8 @@ impl OpenShell for TestOpenShell {
173173
let name = request.name.clone();
174174
*self.state.last_get_name.lock().await = Some(name.clone());
175175
Ok(Response::new(SandboxResponse {
176+
application_service_urls: std::collections::HashMap::new(),
177+
gateway_service_urls: std::collections::HashMap::new(),
176178
sandbox: Some(Sandbox {
177179
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
178180
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
@@ -1148,6 +1148,7 @@ mod tests {
11481148
assert!(!config.app_listener.common.enabled);
11491149
assert!(config.validate().is_ok());
11501150
config.app_listener.common = openshell_core::app_listener::AppListenerConfig {
1151+
public_url: None,
11511152
enabled: true,
11521153
port: 9443,
11531154
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
@@ -1417,6 +1417,8 @@ fn sandbox_from_response(sandbox: Option<proto::Sandbox>) -> Result<SandboxRef>
14171417
fn sandbox_from_create_response(response: proto::SandboxResponse) -> Result<SandboxRef> {
14181418
let mut sandbox = sandbox_from_response(response.sandbox)?;
14191419
sandbox.service_urls = response.service_urls;
1420+
sandbox.application_service_urls = response.application_service_urls;
1421+
sandbox.gateway_service_urls = response.gateway_service_urls;
14201422
Ok(sandbox)
14211423
}
14221424

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,10 @@ pub struct SandboxRef {
390390
/// Service URLs returned by sandbox creation, keyed by service name. The
391391
/// empty key identifies the unnamed service. Non-create reads leave this empty.
392392
pub service_urls: HashMap<String, String>,
393+
/// Configured application origins; these do not establish installation readiness.
394+
pub application_service_urls: HashMap<String, String>,
395+
/// Gateway-routed alternatives.
396+
pub gateway_service_urls: HashMap<String, String>,
393397
pub restart_count: u32,
394398
pub next_restart_at_ms: Option<i64>,
395399
pub main_process_started_at_ms: Option<i64>,
@@ -443,6 +447,8 @@ impl SandboxRef {
443447
.then_some(sandbox.host_key_fingerprint),
444448
created_from_workload_template,
445449
service_urls: HashMap::new(),
450+
application_service_urls: HashMap::new(),
451+
gateway_service_urls: HashMap::new(),
446452
restart_count,
447453
next_restart_at_ms,
448454
main_process_started_at_ms,

0 commit comments

Comments
 (0)