mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-20 06:00:06 +08:00
feat(providers): add CLI label creation and filtering (#4265)
* docs: document workspace and provider labels (fixes #4250) Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> * feat(providers): support CLI labels and selector filtering Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> * fix(providers): update schema inventory and scoped test environment Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --------- Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
This commit is contained in:
@@ -382,6 +382,7 @@ pub async fn ensure_required_providers(
|
||||
loop {
|
||||
let response = client
|
||||
.list_providers(ListProvidersRequest {
|
||||
label_selector: String::new(),
|
||||
page_size: 100,
|
||||
page_token,
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
|
||||
@@ -1046,6 +1047,7 @@ pub async fn provider_create(
|
||||
server,
|
||||
name,
|
||||
provider_type,
|
||||
labels: &[],
|
||||
credentials,
|
||||
credential_source,
|
||||
config,
|
||||
@@ -1060,6 +1062,7 @@ pub struct ProviderCreateOptions<'a> {
|
||||
pub server: &'a str,
|
||||
pub name: &'a str,
|
||||
pub provider_type: &'a str,
|
||||
pub labels: &'a [String],
|
||||
pub credentials: &'a [String],
|
||||
pub credential_source: ProviderCreateCredentialSource,
|
||||
pub config: &'a [String],
|
||||
@@ -1082,6 +1085,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->
|
||||
server,
|
||||
name,
|
||||
provider_type,
|
||||
labels,
|
||||
credentials,
|
||||
credential_source,
|
||||
config,
|
||||
@@ -1090,6 +1094,8 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->
|
||||
tls,
|
||||
} = options;
|
||||
|
||||
let labels = parse_key_value_pairs(labels, "--label")?;
|
||||
|
||||
let from_existing = credential_source == ProviderCreateCredentialSource::Existing;
|
||||
let from_gcloud_adc = credential_source == ProviderCreateCredentialSource::GcloudAdc;
|
||||
let from_oidc_token = credential_source == ProviderCreateCredentialSource::OidcToken;
|
||||
@@ -1209,7 +1215,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->
|
||||
id: String::new(),
|
||||
name: name.to_string(),
|
||||
created_time: None,
|
||||
labels: HashMap::new(),
|
||||
labels,
|
||||
resource_version: 0,
|
||||
annotations: HashMap::new(),
|
||||
workspace: workspace.to_string(),
|
||||
@@ -1346,6 +1352,21 @@ pub async fn provider_get(
|
||||
println!(" {} {}", "Id:".dimmed(), provider.object_id());
|
||||
println!(" {} {}", "Name:".dimmed(), provider.object_name());
|
||||
println!(" {} {}", "Type:".dimmed(), provider.r#type);
|
||||
if let Some(meta) = &provider.metadata
|
||||
&& !meta.labels.is_empty()
|
||||
{
|
||||
let mut labels: Vec<_> = meta.labels.iter().collect();
|
||||
labels.sort_unstable_by_key(|(key, _)| *key);
|
||||
println!(
|
||||
" {} {}",
|
||||
"Labels:".dimmed(),
|
||||
labels
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" {} {}",
|
||||
"Resource version:".dimmed(),
|
||||
@@ -1455,6 +1476,7 @@ pub async fn provider_list(
|
||||
server: &str,
|
||||
page_size: i32,
|
||||
page_token: &str,
|
||||
label_selector: &str,
|
||||
names_only: bool,
|
||||
output: &str,
|
||||
workspace: &str,
|
||||
@@ -1466,6 +1488,7 @@ pub async fn provider_list(
|
||||
.list_providers(ListProvidersRequest {
|
||||
page_size,
|
||||
page_token: page_token.to_string(),
|
||||
label_selector: label_selector.to_string(),
|
||||
workspace_scope: Some(if all_workspaces {
|
||||
openshell_core::proto::all_workspaces_selector()
|
||||
} else {
|
||||
|
||||
@@ -63,6 +63,7 @@ pub fn complete_provider_names(_prefix: &OsStr) -> Vec<CompletionCandidate> {
|
||||
let mut client = completion_grpc_client(&endpoint, &gateway_name).await?;
|
||||
let response = client
|
||||
.list_providers(ListProvidersRequest {
|
||||
label_selector: String::new(),
|
||||
page_size: 200,
|
||||
page_token: String::new(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
|
||||
@@ -860,6 +860,10 @@ enum ProviderCommands {
|
||||
#[arg(long = "type")]
|
||||
provider_type: String,
|
||||
|
||||
/// Labels to attach to the provider (KEY=VALUE, repeatable).
|
||||
#[arg(long = "label", value_name = "KEY=VALUE")]
|
||||
labels: Vec<String>,
|
||||
|
||||
/// Load provider credentials/config from existing local state.
|
||||
#[arg(long, conflicts_with_all = ["credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"])]
|
||||
from_existing: bool,
|
||||
@@ -919,6 +923,10 @@ enum ProviderCommands {
|
||||
#[arg(long, default_value = "")]
|
||||
page_token: String,
|
||||
|
||||
/// Filter providers by labels (e.g. env=dev,team=ml).
|
||||
#[arg(long)]
|
||||
label_selector: Option<String>,
|
||||
|
||||
/// Print only provider names, one per line.
|
||||
#[arg(long, conflicts_with = "output")]
|
||||
names: bool,
|
||||
@@ -3876,6 +3884,7 @@ async fn run_async() -> Result<()> {
|
||||
ProviderCommands::Create {
|
||||
name,
|
||||
provider_type,
|
||||
labels,
|
||||
from_existing,
|
||||
credentials,
|
||||
from_gcloud_adc,
|
||||
@@ -3900,6 +3909,7 @@ async fn run_async() -> Result<()> {
|
||||
server: endpoint,
|
||||
name: &name,
|
||||
provider_type: provider_type.as_str(),
|
||||
labels: &labels,
|
||||
credentials: &credentials,
|
||||
credential_source,
|
||||
config: &config,
|
||||
@@ -3995,6 +4005,7 @@ async fn run_async() -> Result<()> {
|
||||
ProviderCommands::List {
|
||||
page_size,
|
||||
page_token,
|
||||
label_selector,
|
||||
names,
|
||||
output,
|
||||
all_workspaces,
|
||||
@@ -4003,6 +4014,7 @@ async fn run_async() -> Result<()> {
|
||||
endpoint,
|
||||
page_size,
|
||||
&page_token,
|
||||
label_selector.as_deref().unwrap_or_default(),
|
||||
names,
|
||||
output.as_str(),
|
||||
&cli.workspace,
|
||||
@@ -5653,6 +5665,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_create_accepts_labels_with_each_credential_source() {
|
||||
for source in [
|
||||
vec![],
|
||||
vec!["--from-existing"],
|
||||
vec!["--credential", "GITHUB_TOKEN=token"],
|
||||
vec!["--from-gcloud-adc"],
|
||||
vec!["--from-oidc-token"],
|
||||
vec!["--runtime-credentials"],
|
||||
] {
|
||||
let mut args = vec![
|
||||
"openshell",
|
||||
"provider",
|
||||
"create",
|
||||
"--name",
|
||||
"labeled",
|
||||
"--type",
|
||||
"github",
|
||||
"--label",
|
||||
"env=dev",
|
||||
"--label",
|
||||
"team=ml",
|
||||
];
|
||||
args.extend(source);
|
||||
let cli = Cli::try_parse_from(args).unwrap();
|
||||
match cli.command {
|
||||
Some(Commands::Provider {
|
||||
command: Some(ProviderCommands::Create { labels, .. }),
|
||||
}) => assert_eq!(labels, ["env=dev", "team=ml"]),
|
||||
other => panic!("expected provider create, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_list_accepts_label_selector_in_all_output_modes() {
|
||||
for output in [
|
||||
vec![],
|
||||
vec!["--names"],
|
||||
vec!["-o", "json"],
|
||||
vec!["-o", "yaml"],
|
||||
] {
|
||||
let mut args = vec![
|
||||
"openshell",
|
||||
"provider",
|
||||
"list",
|
||||
"--all-workspaces",
|
||||
"--label-selector",
|
||||
"env=dev,team=ml",
|
||||
];
|
||||
args.extend(output);
|
||||
let cli = Cli::try_parse_from(args).unwrap();
|
||||
match cli.command {
|
||||
Some(Commands::Provider {
|
||||
command:
|
||||
Some(ProviderCommands::List {
|
||||
label_selector,
|
||||
all_workspaces,
|
||||
..
|
||||
}),
|
||||
}) => {
|
||||
assert_eq!(label_selector.as_deref(), Some("env=dev,team=ml"));
|
||||
assert!(all_workspaces);
|
||||
}
|
||||
other => panic!("expected provider list, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_create_accepts_no_credential_source() {
|
||||
let cli = Cli::try_parse_from([
|
||||
|
||||
@@ -42,6 +42,7 @@ use tonic::{Response, Status};
|
||||
#[derive(Clone, Default)]
|
||||
struct ProviderState {
|
||||
providers: Arc<Mutex<HashMap<String, Provider>>>,
|
||||
list_requests: Arc<Mutex<Vec<ListProvidersRequest>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -364,8 +365,13 @@ impl OpenShell for TestOpenShell {
|
||||
|
||||
async fn list_providers(
|
||||
&self,
|
||||
_request: tonic::Request<ListProvidersRequest>,
|
||||
request: tonic::Request<ListProvidersRequest>,
|
||||
) -> Result<Response<ListProvidersResponse>, Status> {
|
||||
self.state
|
||||
.list_requests
|
||||
.lock()
|
||||
.await
|
||||
.push(request.into_inner());
|
||||
let providers = self
|
||||
.state
|
||||
.providers
|
||||
@@ -867,6 +873,108 @@ async fn run_server() -> TestServer {
|
||||
|
||||
// ── tests ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_create_forwards_labels_for_explicit_and_discovered_credentials() {
|
||||
let ts = run_server().await;
|
||||
Environment::from_pairs(&[("NVIDIA_API_KEY", "nvapi-test-key")])
|
||||
.run_async(async {
|
||||
let labels = vec!["env=dev".to_string(), "team=ml".to_string()];
|
||||
let credentials = vec!["NVIDIA_API_KEY=test-explicit".to_string()];
|
||||
for (name, source, credentials) in [
|
||||
(
|
||||
"explicit",
|
||||
run::ProviderCreateCredentialSource::ExplicitCredentials,
|
||||
credentials.as_slice(),
|
||||
),
|
||||
(
|
||||
"discovered",
|
||||
run::ProviderCreateCredentialSource::Existing,
|
||||
&[],
|
||||
),
|
||||
] {
|
||||
run::provider_create_with_options(run::ProviderCreateOptions {
|
||||
server: &ts.endpoint,
|
||||
name,
|
||||
provider_type: "nvidia",
|
||||
labels: &labels,
|
||||
credentials,
|
||||
credential_source: source,
|
||||
config: &[],
|
||||
workspace: "default",
|
||||
profile_workspace: "",
|
||||
tls: &ts.tls,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let providers = ts.openshell.state.providers.lock().await;
|
||||
let provider = &providers[name];
|
||||
assert_eq!(
|
||||
provider.metadata.as_ref().unwrap().labels,
|
||||
HashMap::from([("env".into(), "dev".into()), ("team".into(), "ml".into())])
|
||||
);
|
||||
assert!(provider.credentials.contains_key("NVIDIA_API_KEY"));
|
||||
}
|
||||
|
||||
for label in ["no-equals-sign", "=no-key"] {
|
||||
let err = run::provider_create_with_options(run::ProviderCreateOptions {
|
||||
server: &ts.endpoint,
|
||||
name: "invalid",
|
||||
provider_type: "nvidia",
|
||||
labels: &[label.into()],
|
||||
credentials: &credentials,
|
||||
credential_source: run::ProviderCreateCredentialSource::ExplicitCredentials,
|
||||
config: &[],
|
||||
workspace: "default",
|
||||
profile_workspace: "",
|
||||
tls: &ts.tls,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("--label"));
|
||||
}
|
||||
assert!(
|
||||
!ts.openshell
|
||||
.state
|
||||
.providers
|
||||
.lock()
|
||||
.await
|
||||
.contains_key("invalid")
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_list_forwards_selector_pagination_and_workspace_scope() {
|
||||
let ts = run_server().await;
|
||||
for all_workspaces in [false, true] {
|
||||
run::provider_list(
|
||||
&ts.endpoint,
|
||||
3,
|
||||
"cursor",
|
||||
"env=dev,team=ml",
|
||||
false,
|
||||
"json",
|
||||
"team-ml",
|
||||
all_workspaces,
|
||||
&ts.tls,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = ts.openshell.state.list_requests.lock().await;
|
||||
let request = requests.last().unwrap();
|
||||
assert_eq!(request.page_size, 3);
|
||||
assert_eq!(request.page_token, "cursor");
|
||||
assert_eq!(request.label_selector, "env=dev,team=ml");
|
||||
let expected = if all_workspaces {
|
||||
openshell_core::proto::all_workspaces_selector()
|
||||
} else {
|
||||
openshell_core::proto::workspace_selector("team-ml")
|
||||
};
|
||||
assert_eq!(request.workspace_scope.as_ref(), Some(&expected));
|
||||
}
|
||||
}
|
||||
|
||||
/// When `--provider nvidia` is passed and a provider named "nvidia" already
|
||||
/// exists, `ensure_required_providers` should return it directly without
|
||||
/// creating anything new.
|
||||
|
||||
@@ -3432,6 +3432,7 @@ async fn provider_cli_run_functions_support_full_crud_flow() {
|
||||
&ts.endpoint,
|
||||
100,
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
"table",
|
||||
"default",
|
||||
@@ -3744,6 +3745,7 @@ async fn provider_list_json_output() {
|
||||
&ts.endpoint,
|
||||
100,
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
"json",
|
||||
"default",
|
||||
@@ -3787,6 +3789,7 @@ async fn provider_list_yaml_output() {
|
||||
&ts.endpoint,
|
||||
100,
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
"yaml",
|
||||
"default",
|
||||
@@ -3815,6 +3818,7 @@ async fn provider_list_json_empty() {
|
||||
&ts.endpoint,
|
||||
100,
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
"json",
|
||||
"default",
|
||||
@@ -4061,6 +4065,7 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles()
|
||||
);
|
||||
|
||||
run::provider_create_with_options(run::ProviderCreateOptions {
|
||||
labels: &["team=ml".into()],
|
||||
server: &ts.endpoint,
|
||||
name: "custom-refresh-provider",
|
||||
provider_type: "custom-refresh",
|
||||
@@ -4077,6 +4082,7 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles()
|
||||
let stored = ts.state.providers.lock().await;
|
||||
let provider = stored.get("custom-refresh-provider").expect("provider");
|
||||
assert_eq!(provider.r#type, "custom-refresh");
|
||||
assert_eq!(provider.metadata.as_ref().unwrap().labels["team"], "ml");
|
||||
assert!(provider.credentials.is_empty());
|
||||
}
|
||||
|
||||
@@ -5377,6 +5383,7 @@ async fn provider_create_sends_inline_credentials() {
|
||||
let ts = run_server().await;
|
||||
|
||||
run::provider_create_with_options(run::ProviderCreateOptions {
|
||||
labels: &[],
|
||||
server: &ts.endpoint,
|
||||
name: "openai-inline",
|
||||
provider_type: "openai",
|
||||
@@ -5413,6 +5420,7 @@ async fn provider_create_prefers_exact_imported_alias_profile() {
|
||||
install_test_profile(&ts, "gh", "GITHUB_TOKEN").await;
|
||||
|
||||
run::provider_create_with_options(run::ProviderCreateOptions {
|
||||
labels: &[],
|
||||
server: &ts.endpoint,
|
||||
name: "enterprise-github",
|
||||
provider_type: "gh",
|
||||
@@ -5607,17 +5615,18 @@ async fn provider_create_from_gcloud_adc_happy_path() {
|
||||
|
||||
Environment::from_pairs(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)])
|
||||
.run_async(async {
|
||||
run::provider_create(
|
||||
&ts.endpoint,
|
||||
"my-vertex",
|
||||
"google-vertex-ai",
|
||||
false,
|
||||
&[], // no explicit credentials; refresh bootstrap covers it
|
||||
true, // from_gcloud_adc
|
||||
&[],
|
||||
"default",
|
||||
&ts.tls,
|
||||
)
|
||||
run::provider_create_with_options(run::ProviderCreateOptions {
|
||||
server: &ts.endpoint,
|
||||
name: "my-vertex",
|
||||
provider_type: "google-vertex-ai",
|
||||
labels: &["team=ml".into()],
|
||||
credentials: &[],
|
||||
credential_source: run::ProviderCreateCredentialSource::GcloudAdc,
|
||||
config: &[],
|
||||
workspace: "default",
|
||||
profile_workspace: "default",
|
||||
tls: &ts.tls,
|
||||
})
|
||||
.await
|
||||
.expect("provider_create with --from-gcloud-adc should succeed");
|
||||
|
||||
@@ -5627,6 +5636,7 @@ async fn provider_create_from_gcloud_adc_happy_path() {
|
||||
.get("my-vertex")
|
||||
.expect("provider should be stored after create");
|
||||
assert_eq!(provider.r#type, "google-vertex-ai");
|
||||
assert_eq!(provider.metadata.as_ref().unwrap().labels["team"], "ml");
|
||||
assert_eq!(
|
||||
provider
|
||||
.credentials
|
||||
|
||||
@@ -2727,6 +2727,7 @@ pub(super) async fn handle_list_providers(
|
||||
) -> Result<Response<ListProvidersResponse>, Status> {
|
||||
let principal = super::extract_principal(&request)?;
|
||||
let request = request.into_inner();
|
||||
super::validation::validate_label_selector(&request.label_selector)?;
|
||||
let scope = authorize_list_workspace_selector(
|
||||
&state.store,
|
||||
&state.admin_role,
|
||||
@@ -2751,12 +2752,18 @@ pub(super) async fn handle_list_providers(
|
||||
request.page_size,
|
||||
&request.page_token,
|
||||
"ListProviders",
|
||||
&[scope_fingerprint],
|
||||
&[scope_fingerprint, &request.label_selector],
|
||||
)?;
|
||||
let after = pagination.object_cursor()?;
|
||||
let query = workspace
|
||||
.as_deref()
|
||||
.map_or(ObjectListQuery::AllWorkspaces, ObjectListQuery::Workspace);
|
||||
let query = match (workspace.as_deref(), request.label_selector.as_str()) {
|
||||
(Some(workspace), "") => ObjectListQuery::Workspace(workspace),
|
||||
(Some(workspace), label_selector) => ObjectListQuery::WorkspaceSelector {
|
||||
workspace,
|
||||
label_selector,
|
||||
},
|
||||
(None, "") => ObjectListQuery::AllWorkspaces,
|
||||
(None, label_selector) => ObjectListQuery::AllWorkspacesSelector(label_selector),
|
||||
};
|
||||
let page = state
|
||||
.store
|
||||
.list_message_page::<Provider>(query, after.as_ref(), pagination.page_size())
|
||||
@@ -15026,6 +15033,7 @@ mod tests {
|
||||
let listed = handle_list_providers(
|
||||
&state,
|
||||
authed_request(ListProvidersRequest {
|
||||
label_selector: String::new(),
|
||||
page_size: 100,
|
||||
page_token: String::new(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
@@ -15042,6 +15050,7 @@ mod tests {
|
||||
let listed = handle_list_providers(
|
||||
&state,
|
||||
authed_request(ListProvidersRequest {
|
||||
label_selector: String::new(),
|
||||
page_size: 100,
|
||||
page_token: String::new(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
@@ -15078,6 +15087,7 @@ mod tests {
|
||||
let listed = handle_list_providers(
|
||||
&state,
|
||||
authed_request(ListProvidersRequest {
|
||||
label_selector: String::new(),
|
||||
page_size: 100,
|
||||
page_token: String::new(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
@@ -15135,6 +15145,7 @@ mod tests {
|
||||
let listed = handle_list_providers(
|
||||
&state,
|
||||
authed_request(ListProvidersRequest {
|
||||
label_selector: String::new(),
|
||||
page_size: 100,
|
||||
page_token: String::new(),
|
||||
workspace_scope: Some(openshell_core::proto::all_workspaces_selector()),
|
||||
@@ -15146,6 +15157,140 @@ mod tests {
|
||||
assert_eq!(listed.providers.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_providers_filters_labels_before_pagination_and_redacts_secrets() {
|
||||
let state = test_server_state().await;
|
||||
crate::grpc::workspace::handle_create_workspace(
|
||||
&state,
|
||||
Request::new(CreateWorkspaceRequest {
|
||||
name: "beta".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (workspace, name, env, team) in [
|
||||
("default", "prod", "prod", "ml"),
|
||||
("default", "dev-one", "dev", "ml"),
|
||||
("default", "other-team", "dev", "platform"),
|
||||
("default", "dev-two", "dev", "ml"),
|
||||
("beta", "beta-dev", "dev", "ml"),
|
||||
] {
|
||||
let labels = HashMap::from([("env".into(), env.into()), ("team".into(), team.into())]);
|
||||
let created = handle_create_provider(
|
||||
&state,
|
||||
authed_request(CreateProviderRequest {
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
|
||||
provider: Some(Provider {
|
||||
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
||||
name: name.into(),
|
||||
labels: labels.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
r#type: "github".into(),
|
||||
credentials: HashMap::from([(
|
||||
"GITHUB_TOKEN".into(),
|
||||
"secret-token".into(),
|
||||
)]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.provider
|
||||
.unwrap();
|
||||
assert_eq!(created.metadata.unwrap().labels, labels);
|
||||
}
|
||||
|
||||
for (all_workspaces, expected) in [(false, 2), (true, 3)] {
|
||||
let scope = if all_workspaces {
|
||||
openshell_core::proto::all_workspaces_selector()
|
||||
} else {
|
||||
openshell_core::proto::workspace_selector("default")
|
||||
};
|
||||
let mut request = ListProvidersRequest {
|
||||
workspace_scope: Some(scope),
|
||||
page_size: 1,
|
||||
label_selector: "env=dev,team=ml".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut names = HashSet::new();
|
||||
loop {
|
||||
let response = handle_list_providers(&state, authed_request(request.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert_eq!(
|
||||
response.providers.len(),
|
||||
1,
|
||||
"filtering must precede pagination"
|
||||
);
|
||||
let provider = &response.providers[0];
|
||||
assert_eq!(provider.metadata.as_ref().unwrap().labels["env"], "dev");
|
||||
assert_eq!(provider.metadata.as_ref().unwrap().labels["team"], "ml");
|
||||
assert_eq!(provider.credentials["GITHUB_TOKEN"], "REDACTED");
|
||||
assert!(provider.credential_handles.is_empty());
|
||||
if !all_workspaces {
|
||||
assert_eq!(provider.object_workspace(), "default");
|
||||
}
|
||||
assert!(
|
||||
names.insert(provider.object_name().to_string()),
|
||||
"pages must not repeat objects"
|
||||
);
|
||||
if response.next_page_token.is_empty() {
|
||||
break;
|
||||
}
|
||||
let changed_selector = ListProvidersRequest {
|
||||
label_selector: "env=prod".into(),
|
||||
page_token: response.next_page_token.clone(),
|
||||
..request.clone()
|
||||
};
|
||||
let err = handle_list_providers(&state, authed_request(changed_selector))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
request.page_token = response.next_page_token;
|
||||
}
|
||||
assert_eq!(names.len(), expected);
|
||||
}
|
||||
|
||||
let empty = handle_list_providers(
|
||||
&state,
|
||||
authed_request(ListProvidersRequest {
|
||||
workspace_scope: Some(openshell_core::proto::all_workspaces_selector()),
|
||||
label_selector: "env=missing".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert!(empty.providers.is_empty());
|
||||
assert!(empty.next_page_token.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_providers_rejects_invalid_label_selectors() {
|
||||
let state = test_server_state().await;
|
||||
for selector in ["=no-key", "no-equals-sign", "env=bad value"] {
|
||||
let err = handle_list_providers(
|
||||
&state,
|
||||
authed_request(ListProvidersRequest {
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
||||
label_selector: selector.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), Code::InvalidArgument, "selector: {selector}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn platform_provider_profile_operations_require_platform_admin() {
|
||||
let mut state = test_server_state().await;
|
||||
@@ -16125,6 +16270,7 @@ mod tests {
|
||||
&state,
|
||||
non_member_request(ListProvidersRequest {
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")),
|
||||
label_selector: "env=dev".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
@@ -16136,6 +16282,18 @@ mod tests {
|
||||
"handle_list_providers should reject non-members"
|
||||
);
|
||||
|
||||
let err = handle_list_providers(
|
||||
&state,
|
||||
non_member_request(ListProvidersRequest {
|
||||
workspace_scope: Some(openshell_core::proto::all_workspaces_selector()),
|
||||
label_selector: "env=dev".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), Code::PermissionDenied);
|
||||
|
||||
let err = handle_update_provider(
|
||||
&state,
|
||||
non_member_request(UpdateProviderRequest {
|
||||
|
||||
@@ -146,8 +146,10 @@ mod tests {
|
||||
// Legacy payloads decode empty owners; the gateway rebuilds their authority
|
||||
// from effective policy rather than trusting persisted owner stamps.
|
||||
// Operator credential export adds four public messages and no durable types.
|
||||
// ListProvidersRequest field 4 adds a public-only label selector. Older
|
||||
// requests decode an empty selector; durable schemas remain unchanged.
|
||||
const PUBLIC_RPC_SCHEMA_SHA256: &str =
|
||||
"5ae8334d9f6212a00c38ba965ff62e567d2846b15f4df8faf85954023da542e7";
|
||||
"b6734ba353b4ffe81240309f7971ef48f6473e14ce2196412beabe1984b0548d";
|
||||
const DURABLE_SCHEMA_SHA256: &str =
|
||||
"5281143e2825539af6148785c91f9df67f654eb74d215f57940af9314867a55f";
|
||||
const PUBLIC_DURABLE_OVERLAP_SHA256: &str =
|
||||
|
||||
@@ -2227,6 +2227,7 @@ async fn fetch_providers(
|
||||
let mut page_token = String::new();
|
||||
loop {
|
||||
let req = openshell_core::proto::ListProvidersRequest {
|
||||
label_selector: String::new(),
|
||||
page_size: 100,
|
||||
page_token,
|
||||
workspace_scope: Some(list_workspace_scope(¤t_workspace, all_workspaces)),
|
||||
|
||||
@@ -240,6 +240,35 @@ openshell provider update my-graph \
|
||||
|
||||
Use `0` as the timestamp to clear expiry for a credential key.
|
||||
|
||||
## Label a Provider
|
||||
|
||||
Attach labels when you create a provider to track environment or team ownership.
|
||||
Repeat `--label` for each key-value pair. This example uses the imported `pypi`
|
||||
profile, which requires no static credentials.
|
||||
|
||||
```shell
|
||||
openshell provider create --name public-pypi --type pypi --label env=dev --label team=ml
|
||||
```
|
||||
|
||||
List providers that match every key-value pair in a comma-separated selector.
|
||||
Filtering applies before pagination and only returns providers in the selected
|
||||
workspace. Platform Admins can filter across workspaces with `--all-workspaces`.
|
||||
|
||||
```shell
|
||||
openshell provider list --label-selector env=dev,team=ml
|
||||
openshell provider list --all-workspaces --label-selector team=ml --output json
|
||||
openshell provider get public-pypi
|
||||
```
|
||||
|
||||
`get` displays attached labels. JSON and YAML list output also include labels.
|
||||
When continuing a paginated list with `--page-token`, keep the selector and
|
||||
workspace scope unchanged.
|
||||
|
||||
API clients set `CreateProviderRequest.provider.metadata.labels` and filter with
|
||||
`ListProvidersRequest.label_selector`. The gateway returns labels in
|
||||
`provider.metadata.labels`. Provider updates preserve existing labels and do
|
||||
not change them, so set labels during creation.
|
||||
|
||||
## Credential Refresh
|
||||
|
||||
Provider refresh stores non-injectable refresh material separately from the
|
||||
|
||||
@@ -133,6 +133,35 @@ continue. An empty result has an empty `members` collection.
|
||||
To change a member's role, remove the existing membership and add it again
|
||||
with the new role. A Platform Admin must perform any change to `admin`.
|
||||
|
||||
## Label and List Workspaces
|
||||
|
||||
Attach labels when you create a workspace to track environment or team ownership.
|
||||
Repeat `--label` for each key-value pair.
|
||||
|
||||
```shell
|
||||
openshell workspace create --name team-research --label env=dev --label team=ml
|
||||
```
|
||||
|
||||
List workspaces that match every key-value pair in a comma-separated selector.
|
||||
Filtering only returns workspaces you are authorized to view.
|
||||
|
||||
```shell
|
||||
openshell workspace list --label-selector env=dev,team=ml
|
||||
```
|
||||
|
||||
Inspect a workspace to see its labels, or use structured list output for
|
||||
automation.
|
||||
|
||||
```shell
|
||||
openshell workspace get team-research
|
||||
openshell workspace list --label-selector env=dev --output json
|
||||
```
|
||||
|
||||
API clients set `CreateWorkspaceRequest.labels` and filter with
|
||||
`ListWorkspacesRequest.label_selector`. Returned workspace labels are stored in
|
||||
`metadata.labels`. OpenShell does not provide a workspace update operation to
|
||||
change labels after creation.
|
||||
|
||||
## Target a Workspace
|
||||
|
||||
Pass `--workspace` to scope a resource operation. The flag is global, so it
|
||||
|
||||
@@ -2178,6 +2178,8 @@ message ListProvidersRequest {
|
||||
// Token from a previous ListProviders response. All other request parameters
|
||||
// except page_size must match the request that produced it.
|
||||
string page_token = 2;
|
||||
// Optional label selector for filtering (format: "key1=value1,key2=value2").
|
||||
string label_selector = 4;
|
||||
}
|
||||
|
||||
// Update provider request.
|
||||
|
||||
@@ -8423,7 +8423,9 @@ type ListProvidersRequest struct {
|
||||
PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||
// Token from a previous ListProviders response. All other request parameters
|
||||
// except page_size must match the request that produced it.
|
||||
PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
|
||||
PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
|
||||
// Optional label selector for filtering (format: "key1=value1,key2=value2").
|
||||
LabelSelector string `protobuf:"bytes,4,opt,name=label_selector,json=labelSelector,proto3" json:"label_selector,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -8479,6 +8481,13 @@ func (x *ListProvidersRequest) GetPageToken() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListProvidersRequest) GetLabelSelector() string {
|
||||
if x != nil {
|
||||
return x.LabelSelector
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Update provider request.
|
||||
type UpdateProviderRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -18798,12 +18807,13 @@ const file_openshell_proto_rawDesc = "" +
|
||||
"\vcredentials\x18\x01 \x03(\v2=.openshell.v1.GetProviderCredentialsResponse.CredentialsEntryR\vcredentials\x1ae\n" +
|
||||
"\x10CredentialsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12;\n" +
|
||||
"\x05value\x18\x02 \x01(\v2%.openshell.v1.ProviderCredentialValueR\x05value:\x028\x01\"\xa6\x01\n" +
|
||||
"\x05value\x18\x02 \x01(\v2%.openshell.v1.ProviderCredentialValueR\x05value:\x028\x01\"\xcd\x01\n" +
|
||||
"\x14ListProvidersRequest\x12R\n" +
|
||||
"\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1b\n" +
|
||||
"\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" +
|
||||
"\n" +
|
||||
"page_token\x18\x02 \x01(\tR\tpageToken\"\xa0\x04\n" +
|
||||
"page_token\x18\x02 \x01(\tR\tpageToken\x12%\n" +
|
||||
"\x0elabel_selector\x18\x04 \x01(\tR\rlabelSelector\"\xa0\x04\n" +
|
||||
"\x15UpdateProviderRequest\x12R\n" +
|
||||
"\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12<\n" +
|
||||
"\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12\x82\x01\n" +
|
||||
|
||||
@@ -161,6 +161,24 @@ Profile-backed providers always contribute policy unless a gateway-global
|
||||
policy is active. Static credential endpoint binding remains independently
|
||||
enforced.
|
||||
|
||||
### Label and select providers
|
||||
|
||||
Attach labels during provider creation with repeatable `--label KEY=VALUE`
|
||||
flags. Labels work with each credential source. List selectors require every
|
||||
comma-separated key-value pair to match and apply before pagination.
|
||||
|
||||
```shell
|
||||
openshell provider create --name public-pypi --type pypi --label env=dev --label team=ml
|
||||
openshell provider list --label-selector env=dev,team=ml
|
||||
openshell provider list --all-workspaces --label-selector team=ml --output json
|
||||
openshell provider get public-pypi
|
||||
```
|
||||
|
||||
Use an imported profile, such as `pypi` in this example. `get` displays labels;
|
||||
structured list output includes them. `--all-workspaces` requires Platform
|
||||
Admin access. Preserve the selector and workspace scope when passing the next
|
||||
`--page-token`. Labels cannot be changed or removed after provider creation.
|
||||
|
||||
### Inspect and manage provider profiles
|
||||
|
||||
```shell
|
||||
|
||||
Reference in New Issue
Block a user