From 8fb7e8f8e8616317786ab71a655ea44b385aea89 Mon Sep 17 00:00:00 2001 From: Patrick Riel Date: Thu, 26 Mar 2026 19:01:51 +0000 Subject: [PATCH] feat(sandbox): add registry pull secret support Signed-off-by: Patrick Riel --- architecture/sandbox-custom-containers.md | 7 + crates/openshell-cli/src/main.rs | 240 ++++++++++++++++++ crates/openshell-cli/src/run.rs | 228 ++++++++++++++++- .../sandbox_create_lifecycle_integration.rs | 12 +- crates/openshell-server/src/grpc.rs | 43 ++++ crates/openshell-server/src/sandbox/mod.rs | 43 ++++ docs/sandboxes/manage-sandboxes.md | 8 + proto/datamodel.proto | 1 + 8 files changed, 573 insertions(+), 9 deletions(-) diff --git a/architecture/sandbox-custom-containers.md b/architecture/sandbox-custom-containers.md index e44de29d..87340569 100644 --- a/architecture/sandbox-custom-containers.md +++ b/architecture/sandbox-custom-containers.md @@ -83,6 +83,13 @@ openshell sandbox create --from openclaw openshell sandbox create --from myimage:latest -- echo "hello from custom container" ``` +For private registry images, attach one or more pull secrets: + +```bash +openshell sandbox secret create registry regcred --server registry.example.com --username myuser --from-env REGISTRY_PASSWORD +openshell sandbox create --from registry.example.com/team/private-image:latest --image-pull-secret regcred -- echo "hello from custom container" +``` + When `--from` is set the CLI clears the default `run_as_user`/`run_as_group` policy (which expects a `sandbox` user) so that arbitrary images that lack that user can start without error. ### Building from a Dockerfile in one step diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 5de31c79..4119ec39 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1128,6 +1128,13 @@ enum SandboxCommands { #[arg(long, value_hint = ValueHint::FilePath)] policy: Option, + /// Kubernetes image pull secret names to attach to the sandbox pod. + /// + /// Use this when `--from` points at a private registry image that + /// requires registry authentication at pull time. + #[arg(long = "image-pull-secret")] + image_pull_secrets: Vec, + /// Forward a local port to the sandbox before the initial command or shell starts. /// Accepts [bind_address:]port (e.g. 8080, 0.0.0.0:8080). Keeps the sandbox alive. #[arg(long, conflicts_with = "no_keep")] @@ -1272,6 +1279,106 @@ enum SandboxCommands { #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, }, + + /// Manage sandbox-scoped secrets such as image registry pull credentials. + #[command(help_template = SUBCOMMAND_HELP_TEMPLATE)] + Secret { + #[command(subcommand)] + command: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum SandboxSecretCommands { + /// Create a sandbox secret. + #[command(help_template = SUBCOMMAND_HELP_TEMPLATE)] + Create { + #[command(subcommand)] + command: Option, + }, + + /// List sandbox secrets managed by `OpenShell`. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Namespace where the secret lives. + #[arg(long, default_value = "openshell")] + namespace: String, + + /// Override SSH destination for remote gateways. + #[arg(long)] + remote: Option, + + /// Path to SSH private key for remote gateways. + #[arg(long, value_hint = ValueHint::FilePath)] + ssh_key: Option, + }, + + /// Delete a sandbox secret by name. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Secret name. + name: String, + + /// Namespace where the secret lives. + #[arg(long, default_value = "openshell")] + namespace: String, + + /// Override SSH destination for remote gateways. + #[arg(long)] + remote: Option, + + /// Path to SSH private key for remote gateways. + #[arg(long, value_hint = ValueHint::FilePath)] + ssh_key: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum SandboxSecretCreateCommands { + /// Create an image registry pull secret. + #[command( + help_template = LEAF_HELP_TEMPLATE, + next_help_heading = "FLAGS", + group = clap::ArgGroup::new("password_source") + .required(true) + .args(["password", "password_stdin", "from_env"]) + )] + Registry { + /// Secret name. + name: String, + + /// Registry server (for example `ghcr.io` or `registry.example.com`). + #[arg(long)] + server: String, + + /// Registry username. + #[arg(long)] + username: String, + + /// Registry password or token. + #[arg(long)] + password: Option, + + /// Read the registry password from stdin. + #[arg(long)] + password_stdin: bool, + + /// Read the registry password from the named environment variable. + #[arg(long = "from-env", value_name = "VAR")] + from_env: Option, + + /// Namespace where the secret should be created. + #[arg(long, default_value = "openshell")] + namespace: String, + + /// Override SSH destination for remote gateways. + #[arg(long)] + remote: Option, + + /// Path to SSH private key for remote gateways. + #[arg(long, value_hint = ValueHint::FilePath)] + ssh_key: Option, + }, } #[derive(Subcommand, Debug)] @@ -2077,6 +2184,7 @@ async fn main() -> Result<()> { ssh_key, providers, policy, + image_pull_secrets, forward, tty, no_tty, @@ -2159,6 +2267,7 @@ async fn main() -> Result<()> { ssh_key.as_deref(), &providers, policy.as_deref(), + &image_pull_secrets, forward, &command, tty_override, @@ -2181,6 +2290,7 @@ async fn main() -> Result<()> { ssh_key.as_deref(), &providers, policy.as_deref(), + &image_pull_secrets, forward, &command, tty_override, @@ -2286,6 +2396,68 @@ async fn main() -> Result<()> { let name = resolve_sandbox_name(name, &ctx.name)?; run::print_ssh_config(&ctx.name, &name); } + SandboxCommands::Secret { command } => match command { + Some(SandboxSecretCommands::Create { command }) => match command { + Some(SandboxSecretCreateCommands::Registry { + name, + server, + username, + password, + password_stdin, + from_env, + namespace, + remote, + ssh_key, + }) => { + run::sandbox_secret_create_registry( + &ctx.name, + &name, + &server, + &username, + password.as_deref(), + password_stdin, + from_env.as_deref(), + &namespace, + remote.as_deref(), + ssh_key.as_deref(), + )?; + } + None => { + return Err(miette::miette!( + "missing sandbox secret create subcommand" + )); + } + }, + Some(SandboxSecretCommands::List { + namespace, + remote, + ssh_key, + }) => { + run::sandbox_secret_list( + &ctx.name, + &namespace, + remote.as_deref(), + ssh_key.as_deref(), + )?; + } + Some(SandboxSecretCommands::Delete { + name, + namespace, + remote, + ssh_key, + }) => { + run::sandbox_secret_delete( + &ctx.name, + &name, + &namespace, + remote.as_deref(), + ssh_key.as_deref(), + )?; + } + None => { + return Err(miette::miette!("missing sandbox secret subcommand")); + } + }, } } } @@ -2872,6 +3044,74 @@ mod tests { assert_eq!(dest.get_value_hint(), ValueHint::AnyPath); } + #[test] + fn sandbox_create_accepts_image_pull_secret_flags() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "create", + "--from", + "registry.example.com/team/private:latest", + "--image-pull-secret", + "regcred", + "--image-pull-secret", + "backup", + "--", + "echo", + "ok", + ]) + .expect("sandbox create should parse image pull secrets"); + + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Create { + image_pull_secrets, + .. + }) + }) if image_pull_secrets == vec!["regcred".to_string(), "backup".to_string()] + )); + } + + #[test] + fn sandbox_secret_create_registry_parses() { + let cli = Cli::try_parse_from([ + "openshell", + "sandbox", + "secret", + "create", + "registry", + "regcred", + "--server", + "registry.example.com", + "--username", + "myuser", + "--from-env", + "REGISTRY_PASSWORD", + ]) + .expect("sandbox secret registry command should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::Secret { + command: Some(SandboxSecretCommands::Create { + command: Some(SandboxSecretCreateCommands::Registry { + name, + server, + username, + from_env, + .. + }) + }) + }) + }) if name == "regcred" + && server == "registry.example.com" + && username == "myuser" + && from_env.as_deref() == Some("REGISTRY_PASSWORD") + )); + } + #[test] fn parse_upload_spec_without_remote() { let (local, remote) = parse_upload_spec("./src"); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index e32eec2a..7ad611cf 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -38,9 +38,9 @@ use openshell_providers::{ }; use owo_colors::OwoColorize; use std::collections::{HashMap, HashSet, VecDeque}; -use std::io::{IsTerminal, Write}; +use std::io::{IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Output}; use std::time::{Duration, Instant}; use tonic::{Code, Status}; @@ -1730,6 +1730,51 @@ pub fn doctor_exec( Ok(()) } +fn gateway_exec_output( + name: &str, + remote: Option<&str>, + ssh_key: Option<&str>, + inner_cmd: &str, +) -> Result { + validate_gateway_name(name)?; + let container = container_name(name); + + let remote_host = if let Some(dest) = remote { + Some(dest.to_string()) + } else if let Some(metadata) = get_gateway_metadata(name) + && metadata.is_remote + { + metadata.remote_host.clone() + } else { + None + }; + + let mut cmd = if let Some(ref host) = remote_host { + validate_ssh_host(host)?; + let ssh_escaped_cmd = shell_escape(inner_cmd); + let mut c = Command::new("ssh"); + if let Some(key) = ssh_key { + c.args(["-i", key]); + } + c.arg(host); + c.arg("docker"); + c.arg("exec"); + c.arg("-i"); + c.args([&container, "sh", "-lc", &ssh_escaped_cmd]); + c + } else { + let mut c = Command::new("docker"); + c.arg("exec"); + c.arg("-i"); + c.args([&container, "sh", "-lc", inner_cmd]); + c + }; + + cmd.output() + .into_diagnostic() + .wrap_err("failed to execute command inside the gateway container") +} + /// Print the LLM diagnostic prompt to stdout. /// /// Outputs a system prompt that a coding agent can use to autonomously @@ -1835,6 +1880,141 @@ fn validate_ssh_host(host: &str) -> Result<()> { Ok(()) } +fn resolve_secret_value( + password: Option<&str>, + password_stdin: bool, + from_env: Option<&str>, +) -> Result { + if let Some(password) = password { + if password.is_empty() { + return Err(miette!("--password cannot be empty")); + } + return Ok(password.to_string()); + } + + if let Some(env_key) = from_env { + let value = std::env::var(env_key) + .into_diagnostic() + .wrap_err_with(|| format!("environment variable '{env_key}' is not set"))?; + if value.is_empty() { + return Err(miette!( + "environment variable '{env_key}' must be set to a non-empty value" + )); + } + return Ok(value); + } + + if password_stdin { + let mut value = String::new(); + std::io::stdin() + .read_to_string(&mut value) + .into_diagnostic() + .wrap_err("failed to read password from stdin")?; + let value = value.trim_end_matches(['\r', '\n']).to_string(); + if value.is_empty() { + return Err(miette!("stdin did not provide a non-empty password")); + } + return Ok(value); + } + + Err(miette!( + "one of --password, --password-stdin, or --from-env must be provided" + )) +} + +pub fn sandbox_secret_create_registry( + gateway_name: &str, + name: &str, + server: &str, + username: &str, + password: Option<&str>, + password_stdin: bool, + from_env: Option<&str>, + namespace: &str, + remote: Option<&str>, + ssh_key: Option<&str>, +) -> Result<()> { + let password = resolve_secret_value(password, password_stdin, from_env)?; + let command = format!( + "KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n {} create secret docker-registry {} \ +--docker-server={} --docker-username={} --docker-password={} --dry-run=client -o yaml \ +| KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl label --local -f - \ +openshell.ai/managed-by=openshell openshell.ai/secret-kind=registry -o yaml \ +| KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply -f -", + shell_escape(namespace), + shell_escape(name), + shell_escape(server), + shell_escape(username), + shell_escape(&password), + ); + let output = gateway_exec_output(gateway_name, remote, ssh_key, &command)?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if !stderr.trim().is_empty() { + stderr.trim() + } else { + stdout.trim() + }; + return Err(miette!("failed to create registry secret: {detail}")); + } + + println!( + "{} Created registry secret '{}' in namespace '{}'", + "✓".green().bold(), + name, + namespace + ); + Ok(()) +} + +pub fn sandbox_secret_list( + gateway_name: &str, + namespace: &str, + remote: Option<&str>, + ssh_key: Option<&str>, +) -> Result<()> { + let command = format!( + "KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n {} get secret \ +-l openshell.ai/managed-by=openshell,openshell.ai/secret-kind=registry", + shell_escape(namespace) + ); + let output = gateway_exec_output(gateway_name, remote, ssh_key, &command)?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(miette!( + "failed to list registry secrets: {}", + stderr.trim() + )); + } + print!("{}", String::from_utf8_lossy(&output.stdout)); + Ok(()) +} + +pub fn sandbox_secret_delete( + gateway_name: &str, + name: &str, + namespace: &str, + remote: Option<&str>, + ssh_key: Option<&str>, +) -> Result<()> { + let command = format!( + "KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n {} delete secret {}", + shell_escape(namespace), + shell_escape(name) + ); + let output = gateway_exec_output(gateway_name, remote, ssh_key, &command)?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(miette!( + "failed to delete registry secret: {}", + stderr.trim() + )); + } + print!("{}", String::from_utf8_lossy(&output.stdout)); + Ok(()) +} + /// Create a sandbox when no gateway is configured. /// /// Bootstraps a new gateway first, then delegates to [`sandbox_create`]. @@ -1850,6 +2030,7 @@ pub async fn sandbox_create_with_bootstrap( ssh_key: Option<&str>, providers: &[String], policy: Option<&str>, + image_pull_secrets: &[String], forward: Option, command: &[String], tty_override: Option, @@ -1881,6 +2062,7 @@ pub async fn sandbox_create_with_bootstrap( ssh_key, providers, policy, + image_pull_secrets, forward, command, tty_override, @@ -1936,6 +2118,7 @@ pub async fn sandbox_create( ssh_key: Option<&str>, providers: &[String], policy: Option<&str>, + image_pull_secrets: &[String], forward: Option, command: &[String], tty_override: Option, @@ -2029,10 +2212,15 @@ pub async fn sandbox_create( let policy = load_sandbox_policy(policy)?; - let template = image.map(|img| SandboxTemplate { - image: img, - ..SandboxTemplate::default() - }); + let template = if image.is_some() || !image_pull_secrets.is_empty() { + Some(SandboxTemplate { + image: image.unwrap_or_default(), + image_pull_secrets: image_pull_secrets.to_vec(), + ..SandboxTemplate::default() + }) + } else { + None + }; let request = CreateSandboxRequest { spec: Some(SandboxSpec { @@ -4991,8 +5179,9 @@ mod tests { format_gateway_select_items, gateway_auth_label, gateway_select_with, gateway_type_label, git_sync_files, http_health_check, image_requests_gpu, inferred_provider_type, parse_cli_setting_value, parse_credential_pairs, provisioning_timeout_message, - ready_false_condition_message, resolve_gateway_control_target_from, sandbox_should_persist, - shell_escape, source_requests_gpu, validate_gateway_name, validate_ssh_host, + ready_false_condition_message, resolve_gateway_control_target_from, resolve_secret_value, + sandbox_should_persist, shell_escape, source_requests_gpu, validate_gateway_name, + validate_ssh_host, }; use crate::TEST_ENV_LOCK; use hyper::StatusCode; @@ -5112,6 +5301,29 @@ mod tests { )); } + #[test] + fn resolve_secret_value_prefers_direct_password() { + let value = resolve_secret_value(Some("secret"), false, None).expect("resolve"); + assert_eq!(value, "secret"); + } + + #[test] + fn resolve_secret_value_reads_from_environment() { + let _guard = EnvVarGuard::set("OPENSHELL_TEST_SECRET", "from-env"); + let value = resolve_secret_value(None, false, Some("OPENSHELL_TEST_SECRET")) + .expect("resolve from env"); + assert_eq!(value, "from-env"); + } + + #[test] + fn resolve_secret_value_rejects_missing_source() { + let err = resolve_secret_value(None, false, None).expect_err("missing source should fail"); + assert!( + err.to_string() + .contains("one of --password, --password-stdin, or --from-env") + ); + } + #[cfg(feature = "dev-settings")] #[test] fn parse_cli_setting_value_parses_bool_aliases() { diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index d5d39f08..9862d2e9 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -546,6 +546,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { None, &[], None, + &[], None, &["echo".to_string(), "OK".to_string()], Some(false), @@ -586,6 +587,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { None, &[], None, + &[], None, &["echo".to_string(), "OK".to_string()], Some(false), @@ -629,6 +631,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { None, &[], None, + &[], None, &[], Some(true), @@ -672,6 +675,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { None, &[], None, + &[], None, &["echo".to_string(), "OK".to_string()], Some(false), @@ -698,6 +702,11 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { let _env = test_env(&fake_ssh_dir, &xdg_dir); let tls = test_tls(&server); install_fake_ssh(&fake_ssh_dir); + let forward_port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); run::sandbox_create( &server.endpoint, @@ -712,7 +721,8 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { None, &[], None, - Some(openshell_core::forward::ForwardSpec::new(8080)), + &[], + Some(openshell_core::forward::ForwardSpec::new(forward_port)), &["echo".to_string(), "OK".to_string()], Some(false), Some(false), diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index fd4bf585..509e3705 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -3194,6 +3194,12 @@ fn validate_sandbox_template(tmpl: &SandboxTemplate) -> Result<(), Status> { MAX_MAP_VALUE_LEN, "template.environment", )?; + validate_string_list( + &tmpl.image_pull_secrets, + MAX_TEMPLATE_MAP_ENTRIES, + MAX_TEMPLATE_STRING_LEN, + "template.image_pull_secrets", + )?; // Struct fields (serialized size). if let Some(ref s) = tmpl.resources { @@ -3247,6 +3253,29 @@ fn validate_string_map( Ok(()) } +fn validate_string_list( + values: &[String], + max_entries: usize, + max_value_len: usize, + field_name: &str, +) -> Result<(), Status> { + if values.len() > max_entries { + return Err(Status::invalid_argument(format!( + "{field_name} exceeds maximum entries ({} > {max_entries})", + values.len() + ))); + } + for value in values { + if value.len() > max_value_len { + return Err(Status::invalid_argument(format!( + "{field_name} value exceeds maximum length ({} > {max_value_len})", + value.len() + ))); + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // Provider field validation // --------------------------------------------------------------------------- @@ -5771,6 +5800,20 @@ mod tests { assert!(err.message().contains("template.labels")); } + #[test] + fn validate_sandbox_spec_rejects_oversized_image_pull_secret_name() { + let spec = SandboxSpec { + template: Some(SandboxTemplate { + image_pull_secrets: vec!["x".repeat(MAX_TEMPLATE_STRING_LEN + 1)], + ..Default::default() + }), + ..Default::default() + }; + let err = validate_sandbox_spec("ok", &spec).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("template.image_pull_secrets")); + } + #[test] fn validate_sandbox_spec_rejects_oversized_template_struct() { use prost_types::{Struct, Value, value::Kind}; diff --git a/crates/openshell-server/src/sandbox/mod.rs b/crates/openshell-server/src/sandbox/mod.rs index e10b33d0..e9ed9320 100644 --- a/crates/openshell-server/src/sandbox/mod.rs +++ b/crates/openshell-server/src/sandbox/mod.rs @@ -880,6 +880,18 @@ fn sandbox_template_to_k8s( serde_json::json!(template.runtime_class_name), ); } + if !template.image_pull_secrets.is_empty() { + spec.insert( + "imagePullSecrets".to_string(), + serde_json::Value::Array( + template + .image_pull_secrets + .iter() + .map(|name| serde_json::json!({ "name": name })) + .collect(), + ), + ); + } let mut container = serde_json::Map::new(); container.insert("name".to_string(), serde_json::json!("agent")); @@ -1766,6 +1778,37 @@ mod tests { ); } + #[test] + fn image_pull_secrets_are_forwarded_to_pod_spec() { + let template = SandboxTemplate { + image_pull_secrets: vec!["regcred".to_string(), "backup".to_string()], + ..SandboxTemplate::default() + }; + + let pod_template = sandbox_template_to_k8s( + &template, + false, + "openshell/sandbox:latest", + "", + "sandbox-id", + "sandbox-name", + "https://gateway.example.com", + "0.0.0.0:2222", + "secret", + 300, + &std::collections::HashMap::new(), + "", + "", + ); + + let pull_secrets = pod_template["spec"]["imagePullSecrets"] + .as_array() + .expect("imagePullSecrets should exist"); + assert_eq!(pull_secrets.len(), 2); + assert_eq!(pull_secrets[0]["name"], "regcred"); + assert_eq!(pull_secrets[1]["name"], "backup"); + } + #[test] fn tls_secret_volume_uses_restrictive_default_mode() { let template = SandboxTemplate::default(); diff --git a/docs/sandboxes/manage-sandboxes.md b/docs/sandboxes/manage-sandboxes.md index 5306120a..8cecadd5 100644 --- a/docs/sandboxes/manage-sandboxes.md +++ b/docs/sandboxes/manage-sandboxes.md @@ -67,6 +67,14 @@ $ openshell sandbox create --from ./my-sandbox-dir $ openshell sandbox create --from my-registry.example.com/my-image:latest ``` +For private registry images, create a reusable registry secret and attach it at +create time: + +```console +$ openshell sandbox secret create registry regcred --server registry.example.com --username myuser --from-env REGISTRY_PASSWORD +$ openshell sandbox create --from registry.example.com/team/private-image:latest --image-pull-secret regcred -- /bin/bash +``` + The CLI resolves community names against the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog, pulls the bundled Dockerfile and policy, builds the image locally, and creates the sandbox. For the full catalog and how to contribute your own, refer to {doc}`community-sandboxes`. ## Connect to a Sandbox diff --git a/proto/datamodel.proto b/proto/datamodel.proto index 2232a122..993c1119 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -44,6 +44,7 @@ message SandboxTemplate { map annotations = 5; map environment = 6; google.protobuf.Struct resources = 7; + repeated string image_pull_secrets = 8; google.protobuf.Struct volume_claim_templates = 9; }