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:
John T. Myers
2026-10-13 16:14:35 +00:00
committed by GitHub
parent fce4e5aea5
commit 0b1f6f86d2
13 changed files with 493 additions and 21 deletions
+24 -1
View File
@@ -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 {
+1
View File
@@ -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(
+81
View File
@@ -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
+162 -4
View File
@@ -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 {
+3 -1
View File
@@ -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 =
+1
View File
@@ -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(&current_workspace, all_workspaces)),
+29
View File
@@ -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
+29
View File
@@ -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
+2
View File
@@ -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.
+13 -3
View File
@@ -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" +
+18
View File
@@ -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