Skip to content

Commit ffe94ae

Browse files
committed
fix(homebrew): stabilize local mtls gateway startup
1 parent 79cf9d8 commit ffe94ae

10 files changed

Lines changed: 174 additions & 28 deletions

File tree

.github/workflows/release-dev.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ jobs:
501501
docker buildx build \
502502
--file deploy/docker/Dockerfile.gateway-macos \
503503
--build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \
504+
--build-arg OPENSHELL_IMAGE_TAG=dev \
504505
--build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \
505506
--target binary \
506507
--output type=local,dest=out/ \

.github/workflows/release-tag.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ jobs:
620620
docker buildx build \
621621
--file deploy/docker/Dockerfile.gateway-macos \
622622
--build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \
623+
--build-arg OPENSHELL_IMAGE_TAG="${{ needs.compute-versions.outputs.semver }}" \
623624
--build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \
624625
--target binary \
625626
--output type=local,dest=out/ \

architecture/build.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ target architecture, stages them under `deploy/docker/.build/`, and then uses
2727
Buildx to publish per-architecture images and multi-architecture tags.
2828
Gateway image builds bake the corresponding supervisor image tag into the
2929
gateway binary so Docker sandboxes do not depend on `:latest` by default.
30+
Package formulas also pin Docker supervisor extraction to the matching release
31+
image tag so standalone gateway binaries do not infer image tags from package
32+
versions.
3033

3134
Local image work should use `mise` tasks rather than direct Docker commands so
3235
the same staging and tagging assumptions are used locally and in CI.

crates/openshell-cli/src/run.rs

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,60 @@ fn mtls_certs_exist_for_endpoint(name: &str, endpoint: &str) -> bool {
643643
})
644644
}
645645

646+
fn package_managed_tls_dirs() -> Vec<PathBuf> {
647+
if let Some(path) = std::env::var_os("OPENSHELL_LOCAL_TLS_DIR") {
648+
return vec![PathBuf::from(path)];
649+
}
650+
651+
let mut dirs = Vec::new();
652+
653+
if cfg!(target_os = "macos") {
654+
dirs.push(PathBuf::from("/opt/homebrew/var/openshell/tls"));
655+
dirs.push(PathBuf::from("/usr/local/var/openshell/tls"));
656+
}
657+
658+
let state_dir = std::env::var_os("XDG_STATE_HOME")
659+
.map(PathBuf::from)
660+
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")));
661+
if let Some(state_dir) = state_dir {
662+
dirs.push(state_dir.join("openshell/tls"));
663+
}
664+
665+
dirs
666+
}
667+
668+
fn import_local_package_mtls_bundle(name: &str) -> Result<Option<PathBuf>> {
669+
for dir in package_managed_tls_dirs() {
670+
let ca = dir.join("ca.crt");
671+
let cert = dir.join("client/tls.crt");
672+
let key = dir.join("client/tls.key");
673+
if !(ca.is_file() && cert.is_file() && key.is_file()) {
674+
continue;
675+
}
676+
677+
let bundle = openshell_bootstrap::pki::PkiBundle {
678+
ca_cert_pem: std::fs::read_to_string(&ca)
679+
.into_diagnostic()
680+
.wrap_err_with(|| format!("failed to read {}", ca.display()))?,
681+
ca_key_pem: String::new(),
682+
server_cert_pem: String::new(),
683+
server_key_pem: String::new(),
684+
client_cert_pem: std::fs::read_to_string(&cert)
685+
.into_diagnostic()
686+
.wrap_err_with(|| format!("failed to read {}", cert.display()))?,
687+
client_key_pem: std::fs::read_to_string(&key)
688+
.into_diagnostic()
689+
.wrap_err_with(|| format!("failed to read {}", key.display()))?,
690+
};
691+
openshell_bootstrap::mtls::store_pki_bundle(name, &bundle)
692+
.wrap_err_with(|| format!("failed to store mTLS bundle for gateway '{name}'"))?;
693+
694+
return Ok(Some(dir));
695+
}
696+
697+
Ok(None)
698+
}
699+
646700
fn plaintext_gateway_is_remote(endpoint: &str, remote: Option<&str>, local: bool) -> bool {
647701
if local {
648702
return false;
@@ -924,16 +978,13 @@ pub async fn gateway_add(
924978

925979
// Verify the gateway is reachable.
926980
let tls = TlsOptions::default();
927-
match http_health_check(&endpoint, &tls).await {
928-
Ok(Some(status)) if status.is_success() => {}
929-
_ => {
930-
eprintln!(
931-
"{} Gateway is not reachable at {endpoint}",
932-
"⚠".yellow().bold(),
933-
);
934-
if !has_mtls_certs {
935-
eprintln!(" Verify the gateway is running and the endpoint is correct.");
936-
}
981+
if !gateway_reachable(&endpoint, &tls).await {
982+
eprintln!(
983+
"{} Gateway is not reachable at {endpoint}",
984+
"⚠".yellow().bold(),
985+
);
986+
if !has_mtls_certs {
987+
eprintln!(" Verify the gateway is running and the endpoint is correct.");
937988
}
938989
}
939990

@@ -951,7 +1002,13 @@ pub async fn gateway_add(
9511002

9521003
if remote.is_some() || local {
9531004
// mTLS gateway (remote or local).
954-
let certs_on_disk = mtls_certs_exist_for_endpoint(name, &endpoint);
1005+
let imported_mtls_dir = if local {
1006+
import_local_package_mtls_bundle(name)?
1007+
} else {
1008+
None
1009+
};
1010+
let certs_on_disk =
1011+
imported_mtls_dir.is_some() || mtls_certs_exist_for_endpoint(name, &endpoint);
9551012
if !certs_on_disk {
9561013
return Err(miette::miette!(
9571014
"mTLS certificates for gateway '{name}' were not found.\n\
@@ -984,14 +1041,11 @@ pub async fn gateway_add(
9841041

9851042
// Verify the gateway is reachable over mTLS.
9861043
let tls = TlsOptions::default().with_gateway_name(name);
987-
match http_health_check(&endpoint, &tls).await {
988-
Ok(Some(status)) if status.is_success() => {}
989-
_ => {
990-
eprintln!(
991-
"{} Gateway is not reachable at {endpoint}. Verify the gateway is running.",
992-
"⚠".yellow().bold(),
993-
);
994-
}
1044+
if !gateway_reachable(&endpoint, &tls).await {
1045+
eprintln!(
1046+
"{} Gateway is not reachable at {endpoint}. Verify the gateway is running.",
1047+
"⚠".yellow().bold(),
1048+
);
9951049
}
9961050

9971051
eprintln!(
@@ -1252,6 +1306,16 @@ async fn http_health_check(server: &str, tls: &TlsOptions) -> Result<Option<Stat
12521306
Ok(Some(resp.status()))
12531307
}
12541308

1309+
async fn gateway_reachable(server: &str, tls: &TlsOptions) -> bool {
1310+
if let Ok(mut client) = grpc_client(server, tls).await
1311+
&& client.health(HealthRequest {}).await.is_ok()
1312+
{
1313+
return true;
1314+
}
1315+
1316+
matches!(http_health_check(server, tls).await, Ok(Some(status)) if status.is_success())
1317+
}
1318+
12551319
fn remove_gateway_registration(name: &str) {
12561320
if let Err(err) = openshell_bootstrap::edge_token::remove_edge_token(name) {
12571321
tracing::debug!("failed to remove edge token: {err}");
@@ -5391,18 +5455,19 @@ mod tests {
53915455
TlsOptions, dockerfile_sources_supported_for_gateway, format_gateway_select_header,
53925456
format_gateway_select_items, format_provider_attachment_table, gateway_add,
53935457
gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_type_label,
5394-
git_sync_files, http_health_check, image_requests_gpu, inferred_provider_type,
5395-
parse_cli_setting_value, parse_credential_pairs, plaintext_gateway_is_remote,
5396-
provisioning_timeout_message, ready_false_condition_message, resolve_from,
5397-
sandbox_should_persist,
5458+
git_sync_files, http_health_check, image_requests_gpu, import_local_package_mtls_bundle,
5459+
inferred_provider_type, package_managed_tls_dirs, parse_cli_setting_value,
5460+
parse_credential_pairs,
5461+
plaintext_gateway_is_remote, provisioning_timeout_message, ready_false_condition_message,
5462+
resolve_from, sandbox_should_persist,
53985463
};
53995464
use crate::TEST_ENV_LOCK;
54005465
use hyper::StatusCode;
54015466
use openshell_bootstrap::{load_active_gateway, load_gateway_metadata, store_gateway_metadata};
54025467
use std::fs;
54035468
use std::io::{Read, Write};
54045469
use std::net::TcpListener;
5405-
use std::path::Path;
5470+
use std::path::{Path, PathBuf};
54065471
use std::process::Command;
54075472
use std::thread;
54085473

@@ -6023,6 +6088,52 @@ mod tests {
60236088
assert_eq!(gateway_auth_label(&gateway), "mtls");
60246089
}
60256090

6091+
#[test]
6092+
fn package_managed_tls_dirs_respects_override() {
6093+
let _guard = TEST_ENV_LOCK
6094+
.lock()
6095+
.unwrap_or_else(std::sync::PoisonError::into_inner);
6096+
let _tls_dir = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", "/tmp/openshell-test-tls");
6097+
6098+
assert_eq!(
6099+
package_managed_tls_dirs(),
6100+
vec![PathBuf::from("/tmp/openshell-test-tls")],
6101+
);
6102+
}
6103+
6104+
#[test]
6105+
fn import_local_package_mtls_bundle_copies_client_materials() {
6106+
let tmpdir = tempfile::tempdir().expect("create tmpdir");
6107+
let package_tls = tmpdir.path().join("package-tls");
6108+
fs::create_dir_all(package_tls.join("client")).expect("create package tls dir");
6109+
fs::write(package_tls.join("ca.crt"), "ca").expect("write ca");
6110+
fs::write(package_tls.join("client/tls.crt"), "client cert").expect("write cert");
6111+
fs::write(package_tls.join("client/tls.key"), "client key").expect("write key");
6112+
6113+
with_tmp_xdg(tmpdir.path(), || {
6114+
let _tls_dir = EnvVarGuard::set(
6115+
"OPENSHELL_LOCAL_TLS_DIR",
6116+
package_tls.to_str().expect("temp path should be utf-8"),
6117+
);
6118+
6119+
let imported =
6120+
import_local_package_mtls_bundle("openshell").expect("import local bundle");
6121+
6122+
assert_eq!(imported.as_deref(), Some(package_tls.as_path()));
6123+
6124+
let mtls = tmpdir.path().join("openshell/gateways/openshell/mtls");
6125+
assert_eq!(fs::read_to_string(mtls.join("ca.crt")).unwrap(), "ca");
6126+
assert_eq!(
6127+
fs::read_to_string(mtls.join("tls.crt")).unwrap(),
6128+
"client cert",
6129+
);
6130+
assert_eq!(
6131+
fs::read_to_string(mtls.join("tls.key")).unwrap(),
6132+
"client key",
6133+
);
6134+
});
6135+
}
6136+
60266137
#[test]
60276138
fn plaintext_gateway_locality_infers_loopback_endpoints_as_local() {
60286139
assert!(!plaintext_gateway_is_remote(

crates/openshell-driver-docker/src/lib.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ pub fn default_docker_supervisor_image() -> String {
8989
/// fallback covers image build wrappers that already tag the gateway and
9090
/// supervisor together. Standalone release binaries also patch the Cargo
9191
/// package version, so use it when it has been set to a real release value.
92-
fn default_docker_supervisor_image_tag() -> &'static str {
92+
fn default_docker_supervisor_image_tag() -> String {
9393
resolve_default_docker_supervisor_image_tag(
9494
option_env!("OPENSHELL_IMAGE_TAG"),
9595
option_env!("IMAGE_TAG"),
@@ -101,8 +101,8 @@ fn resolve_default_docker_supervisor_image_tag(
101101
openshell_image_tag: Option<&'static str>,
102102
image_tag: Option<&'static str>,
103103
cargo_pkg_version: &'static str,
104-
) -> &'static str {
105-
openshell_image_tag
104+
) -> String {
105+
let tag = openshell_image_tag
106106
.filter(|tag| !tag.is_empty())
107107
.or_else(|| image_tag.filter(|tag| !tag.is_empty()))
108108
.unwrap_or_else(|| {
@@ -111,7 +111,9 @@ fn resolve_default_docker_supervisor_image_tag(
111111
} else {
112112
cargo_pkg_version
113113
}
114-
})
114+
});
115+
116+
tag.replace('+', "-")
115117
}
116118

117119
/// Queried by the Docker driver to decide when a sandbox's supervisor

crates/openshell-driver-docker/src/tests.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,22 @@ fn docker_supervisor_image_tag_prefers_explicit_build_tags() {
722722
);
723723
}
724724

725+
#[test]
726+
fn docker_supervisor_image_tag_sanitizes_build_metadata_for_docker() {
727+
assert_eq!(
728+
resolve_default_docker_supervisor_image_tag(None, None, "0.0.37-dev.156+g1d3b741ee"),
729+
"0.0.37-dev.156-g1d3b741ee",
730+
);
731+
assert_eq!(
732+
resolve_default_docker_supervisor_image_tag(
733+
Some("0.0.37-dev.156+g1d3b741ee"),
734+
None,
735+
"0.0.0",
736+
),
737+
"0.0.37-dev.156-g1d3b741ee",
738+
);
739+
}
740+
725741
#[test]
726742
fn supervisor_cache_path_namespaces_by_digest_under_openshell_data_dir() {
727743
let base = PathBuf::from("/var/cache/share");

deploy/docker/Dockerfile.gateway-macos

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ RUN touch crates/openshell-core/src/lib.rs \
9494
proto/*.proto
9595

9696
ARG OPENSHELL_CARGO_VERSION
97+
ARG OPENSHELL_IMAGE_TAG
9798
RUN --mount=type=cache,id=cargo-registry-gateway-macos,sharing=locked,target=/root/.cargo/registry \
9899
--mount=type=cache,id=cargo-git-gateway-macos,sharing=locked,target=/root/.cargo/git \
99100
--mount=type=cache,id=cargo-target-gateway-macos-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \

docs/reference/gateway-auth.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Set these environment variables before starting the gateway:
3939
For local access, the server certificate must be valid for the endpoint the CLI uses. Include `localhost` and `127.0.0.1` in the certificate SANs when users connect to a local gateway through loopback.
4040

4141
Package-managed local gateways on Homebrew and Debian generate this bundle automatically for the `openshell` gateway name and use `https://127.0.0.1:17670` by default.
42+
When you register a package-managed local gateway with `openshell gateway add https://127.0.0.1:17670 --local --name openshell`, the CLI refreshes its mTLS bundle from the package-managed TLS directory.
4243

4344
The CLI loads its mTLS bundle from `~/.config/openshell/gateways/<name>/mtls/`:
4445

python/openshell/release_formula_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,8 @@ def test_generate_homebrew_formula_uses_tagged_macos_driver_asset_without_defaul
5353
assert 'sha256 "' + "b" * 64 + '"' in formula
5454
assert "OPENSHELL_DRIVERS" not in formula
5555
assert 'OPENSHELL_DRIVER_DIR: "#{opt_libexec}"' in formula
56+
assert (
57+
'OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "ghcr.io/nvidia/openshell/supervisor:0.0.10"'
58+
) in formula
5659
assert "entitlements.atomic_write" in formula
5760
assert "brew services restart openshell" in formula

tasks/scripts/release.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ def _asset_url(release_tag: str, filename: str) -> str:
214214
return f"{GITHUB_RELEASE_DOWNLOADS}/{release_tag}/{filename}"
215215

216216

217+
def _homebrew_supervisor_image(release_tag: str) -> str:
218+
image_tag = "dev" if release_tag == "dev" else release_tag.removeprefix("v")
219+
return f"ghcr.io/nvidia/openshell/supervisor:{image_tag}"
220+
221+
217222
def render_homebrew_formula(
218223
*,
219224
release_tag: str,
@@ -225,6 +230,7 @@ def render_homebrew_formula(
225230
raise ValueError(f"release tag contains unsupported characters: {release_tag}")
226231

227232
version = release_tag.removeprefix("v")
233+
docker_supervisor_image = _homebrew_supervisor_image(release_tag)
228234
return f"""# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
229235
# SPDX-License-Identifier: Apache-2.0
230236
#
@@ -302,6 +308,7 @@ def post_install
302308
OPENSHELL_VM_TLS_CA: "#{{var}}/openshell/tls/ca.crt",
303309
OPENSHELL_VM_TLS_CERT: "#{{var}}/openshell/tls/client/tls.crt",
304310
OPENSHELL_VM_TLS_KEY: "#{{var}}/openshell/tls/client/tls.key",
311+
OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "{docker_supervisor_image}",
305312
OPENSHELL_DOCKER_TLS_CA: "#{{var}}/openshell/tls/ca.crt",
306313
OPENSHELL_DOCKER_TLS_CERT: "#{{var}}/openshell/tls/client/tls.crt",
307314
OPENSHELL_DOCKER_TLS_KEY: "#{{var}}/openshell/tls/client/tls.key",

0 commit comments

Comments
 (0)