Skip to content

Commit 5ed7c0d

Browse files
committed
feat(providers): support CLI labels and selector filtering
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
1 parent 8851ddd commit 5ed7c0d

11 files changed

Lines changed: 454 additions & 44 deletions

File tree

‎crates/openshell-cli/src/commands/provider.rs‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ pub async fn ensure_required_providers(
382382
loop {
383383
let response = client
384384
.list_providers(ListProvidersRequest {
385+
label_selector: String::new(),
385386
page_size: 100,
386387
page_token,
387388
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
@@ -1046,6 +1047,7 @@ pub async fn provider_create(
10461047
server,
10471048
name,
10481049
provider_type,
1050+
labels: &[],
10491051
credentials,
10501052
credential_source,
10511053
config,
@@ -1060,6 +1062,7 @@ pub struct ProviderCreateOptions<'a> {
10601062
pub server: &'a str,
10611063
pub name: &'a str,
10621064
pub provider_type: &'a str,
1065+
pub labels: &'a [String],
10631066
pub credentials: &'a [String],
10641067
pub credential_source: ProviderCreateCredentialSource,
10651068
pub config: &'a [String],
@@ -1082,6 +1085,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->
10821085
server,
10831086
name,
10841087
provider_type,
1088+
labels,
10851089
credentials,
10861090
credential_source,
10871091
config,
@@ -1090,6 +1094,8 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->
10901094
tls,
10911095
} = options;
10921096

1097+
let labels = parse_key_value_pairs(labels, "--label")?;
1098+
10931099
let from_existing = credential_source == ProviderCreateCredentialSource::Existing;
10941100
let from_gcloud_adc = credential_source == ProviderCreateCredentialSource::GcloudAdc;
10951101
let from_oidc_token = credential_source == ProviderCreateCredentialSource::OidcToken;
@@ -1209,7 +1215,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->
12091215
id: String::new(),
12101216
name: name.to_string(),
12111217
created_time: None,
1212-
labels: HashMap::new(),
1218+
labels,
12131219
resource_version: 0,
12141220
annotations: HashMap::new(),
12151221
workspace: workspace.to_string(),
@@ -1346,6 +1352,21 @@ pub async fn provider_get(
13461352
println!(" {} {}", "Id:".dimmed(), provider.object_id());
13471353
println!(" {} {}", "Name:".dimmed(), provider.object_name());
13481354
println!(" {} {}", "Type:".dimmed(), provider.r#type);
1355+
if let Some(meta) = &provider.metadata
1356+
&& !meta.labels.is_empty()
1357+
{
1358+
let mut labels: Vec<_> = meta.labels.iter().collect();
1359+
labels.sort_unstable_by_key(|(key, _)| *key);
1360+
println!(
1361+
" {} {}",
1362+
"Labels:".dimmed(),
1363+
labels
1364+
.into_iter()
1365+
.map(|(key, value)| format!("{key}={value}"))
1366+
.collect::<Vec<_>>()
1367+
.join(", ")
1368+
);
1369+
}
13491370
println!(
13501371
" {} {}",
13511372
"Resource version:".dimmed(),
@@ -1455,6 +1476,7 @@ pub async fn provider_list(
14551476
server: &str,
14561477
page_size: i32,
14571478
page_token: &str,
1479+
label_selector: &str,
14581480
names_only: bool,
14591481
output: &str,
14601482
workspace: &str,
@@ -1466,6 +1488,7 @@ pub async fn provider_list(
14661488
.list_providers(ListProvidersRequest {
14671489
page_size,
14681490
page_token: page_token.to_string(),
1491+
label_selector: label_selector.to_string(),
14691492
workspace_scope: Some(if all_workspaces {
14701493
openshell_core::proto::all_workspaces_selector()
14711494
} else {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
6363
let mut client = completion_grpc_client(&endpoint, &gateway_name).await?;
6464
let response = client
6565
.list_providers(ListProvidersRequest {
66+
label_selector: String::new(),
6667
page_size: 200,
6768
page_token: String::new(),
6869
workspace_scope: Some(openshell_core::proto::workspace_selector(

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,10 @@ enum ProviderCommands {
858858
#[arg(long = "type")]
859859
provider_type: String,
860860

861+
/// Labels to attach to the provider (KEY=VALUE, repeatable).
862+
#[arg(long = "label", value_name = "KEY=VALUE")]
863+
labels: Vec<String>,
864+
861865
/// Load provider credentials/config from existing local state.
862866
#[arg(long, conflicts_with_all = ["credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"])]
863867
from_existing: bool,
@@ -917,6 +921,10 @@ enum ProviderCommands {
917921
#[arg(long, default_value = "")]
918922
page_token: String,
919923

924+
/// Filter providers by labels (e.g. env=dev,team=ml).
925+
#[arg(long)]
926+
label_selector: Option<String>,
927+
920928
/// Print only provider names, one per line.
921929
#[arg(long, conflicts_with = "output")]
922930
names: bool,
@@ -3888,6 +3896,7 @@ async fn run_async() -> Result<()> {
38883896
ProviderCommands::Create {
38893897
name,
38903898
provider_type,
3899+
labels,
38913900
from_existing,
38923901
credentials,
38933902
from_gcloud_adc,
@@ -3912,6 +3921,7 @@ async fn run_async() -> Result<()> {
39123921
server: endpoint,
39133922
name: &name,
39143923
provider_type: provider_type.as_str(),
3924+
labels: &labels,
39153925
credentials: &credentials,
39163926
credential_source,
39173927
config: &config,
@@ -4007,6 +4017,7 @@ async fn run_async() -> Result<()> {
40074017
ProviderCommands::List {
40084018
page_size,
40094019
page_token,
4020+
label_selector,
40104021
names,
40114022
output,
40124023
all_workspaces,
@@ -4015,6 +4026,7 @@ async fn run_async() -> Result<()> {
40154026
endpoint,
40164027
page_size,
40174028
&page_token,
4029+
label_selector.as_deref().unwrap_or_default(),
40184030
names,
40194031
output.as_str(),
40204032
&cli.workspace,
@@ -5684,6 +5696,75 @@ mod tests {
56845696
}
56855697
}
56865698

5699+
#[test]
5700+
fn provider_create_accepts_labels_with_each_credential_source() {
5701+
for source in [
5702+
vec![],
5703+
vec!["--from-existing"],
5704+
vec!["--credential", "GITHUB_TOKEN=token"],
5705+
vec!["--from-gcloud-adc"],
5706+
vec!["--from-oidc-token"],
5707+
vec!["--runtime-credentials"],
5708+
] {
5709+
let mut args = vec![
5710+
"openshell",
5711+
"provider",
5712+
"create",
5713+
"--name",
5714+
"labeled",
5715+
"--type",
5716+
"github",
5717+
"--label",
5718+
"env=dev",
5719+
"--label",
5720+
"team=ml",
5721+
];
5722+
args.extend(source);
5723+
let cli = Cli::try_parse_from(args).unwrap();
5724+
match cli.command {
5725+
Some(Commands::Provider {
5726+
command: Some(ProviderCommands::Create { labels, .. }),
5727+
}) => assert_eq!(labels, ["env=dev", "team=ml"]),
5728+
other => panic!("expected provider create, got {other:?}"),
5729+
}
5730+
}
5731+
}
5732+
5733+
#[test]
5734+
fn provider_list_accepts_label_selector_in_all_output_modes() {
5735+
for output in [
5736+
vec![],
5737+
vec!["--names"],
5738+
vec!["-o", "json"],
5739+
vec!["-o", "yaml"],
5740+
] {
5741+
let mut args = vec![
5742+
"openshell",
5743+
"provider",
5744+
"list",
5745+
"--all-workspaces",
5746+
"--label-selector",
5747+
"env=dev,team=ml",
5748+
];
5749+
args.extend(output);
5750+
let cli = Cli::try_parse_from(args).unwrap();
5751+
match cli.command {
5752+
Some(Commands::Provider {
5753+
command:
5754+
Some(ProviderCommands::List {
5755+
label_selector,
5756+
all_workspaces,
5757+
..
5758+
}),
5759+
}) => {
5760+
assert_eq!(label_selector.as_deref(), Some("env=dev,team=ml"));
5761+
assert!(all_workspaces);
5762+
}
5763+
other => panic!("expected provider list, got {other:?}"),
5764+
}
5765+
}
5766+
}
5767+
56875768
#[test]
56885769
fn provider_create_accepts_no_credential_source() {
56895770
let cli = Cli::try_parse_from([

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

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ use tonic::{Response, Status};
4242
#[derive(Clone, Default)]
4343
struct ProviderState {
4444
providers: Arc<Mutex<HashMap<String, Provider>>>,
45+
list_requests: Arc<Mutex<Vec<ListProvidersRequest>>>,
4546
}
4647

4748
#[derive(Clone, Default)]
@@ -357,8 +358,13 @@ impl OpenShell for TestOpenShell {
357358

358359
async fn list_providers(
359360
&self,
360-
_request: tonic::Request<ListProvidersRequest>,
361+
request: tonic::Request<ListProvidersRequest>,
361362
) -> Result<Response<ListProvidersResponse>, Status> {
363+
self.state
364+
.list_requests
365+
.lock()
366+
.await
367+
.push(request.into_inner());
362368
let providers = self
363369
.state
364370
.providers
@@ -860,6 +866,105 @@ async fn run_server() -> TestServer {
860866

861867
// ── tests ────────────────────────────────────────────────────────────
862868

869+
#[tokio::test]
870+
async fn provider_create_forwards_labels_for_explicit_and_discovered_credentials() {
871+
let ts = run_server().await;
872+
let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "nvapi-test-key")]);
873+
let labels = vec!["env=dev".to_string(), "team=ml".to_string()];
874+
let credentials = vec!["NVIDIA_API_KEY=test-explicit".to_string()];
875+
for (name, source, credentials) in [
876+
(
877+
"explicit",
878+
run::ProviderCreateCredentialSource::ExplicitCredentials,
879+
credentials.as_slice(),
880+
),
881+
(
882+
"discovered",
883+
run::ProviderCreateCredentialSource::Existing,
884+
&[],
885+
),
886+
] {
887+
run::provider_create_with_options(run::ProviderCreateOptions {
888+
server: &ts.endpoint,
889+
name,
890+
provider_type: "nvidia",
891+
labels: &labels,
892+
credentials,
893+
credential_source: source,
894+
config: &[],
895+
workspace: "default",
896+
profile_workspace: "",
897+
tls: &ts.tls,
898+
})
899+
.await
900+
.unwrap();
901+
let providers = ts.openshell.state.providers.lock().await;
902+
let provider = &providers[name];
903+
assert_eq!(
904+
provider.metadata.as_ref().unwrap().labels,
905+
HashMap::from([("env".into(), "dev".into()), ("team".into(), "ml".into())])
906+
);
907+
assert!(provider.credentials.contains_key("NVIDIA_API_KEY"));
908+
}
909+
910+
for label in ["no-equals-sign", "=no-key"] {
911+
let err = run::provider_create_with_options(run::ProviderCreateOptions {
912+
server: &ts.endpoint,
913+
name: "invalid",
914+
provider_type: "nvidia",
915+
labels: &[label.into()],
916+
credentials: &credentials,
917+
credential_source: run::ProviderCreateCredentialSource::ExplicitCredentials,
918+
config: &[],
919+
workspace: "default",
920+
profile_workspace: "",
921+
tls: &ts.tls,
922+
})
923+
.await
924+
.unwrap_err();
925+
assert!(err.to_string().contains("--label"));
926+
}
927+
assert!(
928+
!ts.openshell
929+
.state
930+
.providers
931+
.lock()
932+
.await
933+
.contains_key("invalid")
934+
);
935+
}
936+
937+
#[tokio::test]
938+
async fn provider_list_forwards_selector_pagination_and_workspace_scope() {
939+
let ts = run_server().await;
940+
for all_workspaces in [false, true] {
941+
run::provider_list(
942+
&ts.endpoint,
943+
3,
944+
"cursor",
945+
"env=dev,team=ml",
946+
false,
947+
"json",
948+
"team-ml",
949+
all_workspaces,
950+
&ts.tls,
951+
)
952+
.await
953+
.unwrap();
954+
let requests = ts.openshell.state.list_requests.lock().await;
955+
let request = requests.last().unwrap();
956+
assert_eq!(request.page_size, 3);
957+
assert_eq!(request.page_token, "cursor");
958+
assert_eq!(request.label_selector, "env=dev,team=ml");
959+
let expected = if all_workspaces {
960+
openshell_core::proto::all_workspaces_selector()
961+
} else {
962+
openshell_core::proto::workspace_selector("team-ml")
963+
};
964+
assert_eq!(request.workspace_scope.as_ref(), Some(&expected));
965+
}
966+
}
967+
863968
/// When `--provider nvidia` is passed and a provider named "nvidia" already
864969
/// exists, `ensure_required_providers` should return it directly without
865970
/// creating anything new.

0 commit comments

Comments
 (0)