feat(cli): service-oriented commands (deploy/secrets/shell)
Adds the user-facing half of the service registry refactor.
src/service_cmds.rs (new):
- cmd_deploy: resolves a service/category/namespace target via the
registry, applies manifests for each unique namespace, then
rollout-restarts the resolved deployments.
- cmd_secrets: looks up the service's `sunbeam.pt/kv-path`
annotation, port-forwards to OpenBao, and either lists every key
in the path (with values masked) or — given `get <key>` —
prints the single field. Replaces a hand-rolled secret-fetching
flow with one that's driven by the same registry as everything else.
- cmd_shell: drops into a shell on a service's pod. Special-cases
`postgres` to spawn psql against the CNPG primary; everything else
gets `/bin/sh` via kubectl exec.
src/services.rs:
- Drop the static `SERVICES_TO_RESTART` table and the static
MANAGED_NS dependency. Both `cmd_status` and `cmd_restart` now ask
the registry. The legacy `namespace` and `namespace/name` syntaxes
still work as a fallback when the registry can't resolve the input,
so existing muscle memory keeps working during the transition.
- The two static-table tests are removed (they tested the static
tables that no longer exist); the icon helper test stays.
Together with the earlier `Verb::{Deploy,Secrets,Shell}` additions
in src/cli.rs, this completes the service-oriented command surface
for status / logs / restart / deploy / secrets / shell.
This commit is contained in:
201
src/service_cmds.rs
Normal file
201
src/service_cmds.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
//! Service-oriented CLI commands: deploy, secrets, shell.
|
||||||
|
//!
|
||||||
|
//! These commands use the service registry for name resolution.
|
||||||
|
|
||||||
|
use crate::cli::SecretsAction;
|
||||||
|
use crate::error::{Result, SunbeamError};
|
||||||
|
use crate::output::{ok, step, warn};
|
||||||
|
use sunbeam_sdk::registry::{self, ServiceRegistry};
|
||||||
|
|
||||||
|
/// Discover the service registry from the cluster.
|
||||||
|
async fn get_registry() -> Result<ServiceRegistry> {
|
||||||
|
let client = crate::kube::get_client().await?;
|
||||||
|
registry::discover(client).await
|
||||||
|
.map_err(|e| SunbeamError::Other(format!("service discovery failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deploy service(s) by name, category, or namespace.
|
||||||
|
///
|
||||||
|
/// Applies manifests for each unique namespace, then rollout-restarts the
|
||||||
|
/// specific deployments that belong to the resolved services.
|
||||||
|
pub async fn cmd_deploy(target: &str, domain: &str, email: &str) -> Result<()> {
|
||||||
|
let reg = get_registry().await?;
|
||||||
|
let resolved = reg.resolve(target);
|
||||||
|
if resolved.is_empty() {
|
||||||
|
bail!("Unknown service: '{target}'. Try 'sunbeam deploy --all' or a service name like 'hydra'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique namespaces
|
||||||
|
let mut namespaces: Vec<&str> = resolved.iter().map(|s| s.namespace.as_str()).collect();
|
||||||
|
namespaces.sort_unstable();
|
||||||
|
namespaces.dedup();
|
||||||
|
|
||||||
|
// Apply manifests for each namespace
|
||||||
|
let is_production = !crate::config::active_context().ssh_host.is_empty();
|
||||||
|
let env_str = if is_production { "production" } else { "local" };
|
||||||
|
for ns in &namespaces {
|
||||||
|
step(&format!("Applying manifests for {ns}..."));
|
||||||
|
crate::manifests::cmd_apply(env_str, domain, email, ns).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollout restart the specific deployments
|
||||||
|
for svc in &resolved {
|
||||||
|
for deploy in &svc.deployments {
|
||||||
|
step(&format!("Restarting {}/{}...", svc.namespace, deploy));
|
||||||
|
crate::kube::kube_rollout_restart(&svc.namespace, deploy).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok("Deploy complete.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View or get secrets for a service from OpenBao.
|
||||||
|
pub async fn cmd_secrets(service: &str, action: Option<SecretsAction>) -> Result<()> {
|
||||||
|
let reg = get_registry().await?;
|
||||||
|
let svc = reg.get(service)
|
||||||
|
.ok_or_else(|| SunbeamError::Other(format!("Unknown service: '{service}'")))?;
|
||||||
|
|
||||||
|
let kv_path = svc
|
||||||
|
.kv_path
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| SunbeamError::Other(format!("Service '{service}' has no secrets in OpenBao")))?;
|
||||||
|
|
||||||
|
// Port-forward to OpenBao and read the secret
|
||||||
|
let ob_pod = crate::kube::find_pod_by_label(
|
||||||
|
"data",
|
||||||
|
"app.kubernetes.io/name=openbao,component=server",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| SunbeamError::Other("OpenBao pod not found".into()))?;
|
||||||
|
|
||||||
|
let pf = crate::secrets::port_forward("data", &ob_pod, 8200).await?;
|
||||||
|
let bao_url = format!("http://127.0.0.1:{}", pf.local_port);
|
||||||
|
|
||||||
|
// Get root token from k8s secret
|
||||||
|
let token = crate::kube::kube_get_secret_field("data", "openbao-keys", "root-token")
|
||||||
|
.await
|
||||||
|
.map_err(|_| SunbeamError::Other("Failed to get OpenBao root token".into()))?;
|
||||||
|
|
||||||
|
let bao = crate::openbao::BaoClient::with_token(&bao_url, &token);
|
||||||
|
|
||||||
|
match action {
|
||||||
|
None => {
|
||||||
|
// List all fields for the service
|
||||||
|
match bao.kv_get("secret", kv_path).await? {
|
||||||
|
Some(data) => {
|
||||||
|
step(&format!("Secrets for {service} (secret/{kv_path}):"));
|
||||||
|
let mut keys: Vec<&String> = data.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
for key in keys {
|
||||||
|
let value = &data[key];
|
||||||
|
// Mask values longer than 8 chars for security
|
||||||
|
let display = if value.len() > 8 {
|
||||||
|
format!("{}...{}", &value[..4], &value[value.len() - 4..])
|
||||||
|
} else {
|
||||||
|
value.clone()
|
||||||
|
};
|
||||||
|
println!(" {key}: {display}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
warn(&format!("No secrets found at secret/{kv_path}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(SecretsAction::Get { key }) => {
|
||||||
|
let value = bao.kv_get_field("secret", kv_path, &key).await?;
|
||||||
|
if value.is_empty() {
|
||||||
|
warn(&format!("Field '{key}' not found in secret/{kv_path}"));
|
||||||
|
} else {
|
||||||
|
println!("{value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interactive shell into a service pod.
|
||||||
|
///
|
||||||
|
/// Special-cases postgres for psql; everything else gets `/bin/sh`.
|
||||||
|
pub async fn cmd_shell(service: &str) -> Result<()> {
|
||||||
|
let reg = get_registry().await?;
|
||||||
|
let svc = reg.get(service)
|
||||||
|
.ok_or_else(|| SunbeamError::Other(format!("Unknown service: '{service}'")))?;
|
||||||
|
|
||||||
|
let context = crate::kube::context();
|
||||||
|
|
||||||
|
match service {
|
||||||
|
"postgres" => {
|
||||||
|
step("Connecting to PostgreSQL primary...");
|
||||||
|
let pod = crate::kube::find_pod_by_label(
|
||||||
|
"data",
|
||||||
|
"cnpg.io/cluster=postgres,role=primary",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| SunbeamError::Other("PostgreSQL primary pod not found".into()))?;
|
||||||
|
|
||||||
|
let status = tokio::process::Command::new("kubectl")
|
||||||
|
.args([
|
||||||
|
&format!("--context={context}"),
|
||||||
|
"exec",
|
||||||
|
"-it",
|
||||||
|
"-n",
|
||||||
|
"data",
|
||||||
|
&pod,
|
||||||
|
"--",
|
||||||
|
"psql",
|
||||||
|
"-U",
|
||||||
|
"postgres",
|
||||||
|
])
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SunbeamError::Other(format!("Failed to exec into pod: {e}")))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
warn("psql session ended with non-zero exit code");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if svc.deployments.is_empty() {
|
||||||
|
bail!("Service '{service}' has no deployments");
|
||||||
|
}
|
||||||
|
let deploy = &svc.deployments[0];
|
||||||
|
let pod = crate::kube::find_pod_by_label(
|
||||||
|
&svc.namespace,
|
||||||
|
&format!("app={deploy}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| SunbeamError::Other(format!("No pod found for {service}")))?;
|
||||||
|
|
||||||
|
step(&format!("Connecting to {service} ({pod})..."));
|
||||||
|
let status = tokio::process::Command::new("kubectl")
|
||||||
|
.args([
|
||||||
|
&format!("--context={context}"),
|
||||||
|
"exec",
|
||||||
|
"-it",
|
||||||
|
"-n",
|
||||||
|
&svc.namespace,
|
||||||
|
&pod,
|
||||||
|
"--",
|
||||||
|
"/bin/sh",
|
||||||
|
])
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SunbeamError::Other(format!("Failed to exec into pod: {e}")))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
warn("Shell session ended with non-zero exit code");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/services.rs
311
src/services.rs
@@ -4,27 +4,20 @@ use crate::error::{Result, SunbeamError};
|
|||||||
use k8s_openapi::api::core::v1::Pod;
|
use k8s_openapi::api::core::v1::Pod;
|
||||||
use kube::api::{Api, DynamicObject, ListParams, LogParams};
|
use kube::api::{Api, DynamicObject, ListParams, LogParams};
|
||||||
use kube::ResourceExt;
|
use kube::ResourceExt;
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use crate::constants::MANAGED_NS;
|
|
||||||
use crate::kube::{get_client, kube_rollout_restart, parse_target};
|
use crate::kube::{get_client, kube_rollout_restart, parse_target};
|
||||||
use crate::output::{ok, step, warn};
|
use crate::output::{ok, step, warn};
|
||||||
|
use sunbeam_sdk::registry::{self, Category, ServiceRegistry};
|
||||||
|
|
||||||
/// Services that can be rollout-restarted, as (namespace, deployment) pairs.
|
// ---------------------------------------------------------------------------
|
||||||
pub const SERVICES_TO_RESTART: &[(&str, &str)] = &[
|
// Registry helper
|
||||||
("ory", "hydra"),
|
// ---------------------------------------------------------------------------
|
||||||
("ory", "kratos"),
|
|
||||||
("ory", "login-ui"),
|
/// Discover the service registry from the cluster.
|
||||||
("devtools", "gitea"),
|
async fn get_registry() -> Result<ServiceRegistry> {
|
||||||
("storage", "seaweedfs-filer"),
|
let client = get_client().await?;
|
||||||
("lasuite", "hive"),
|
registry::discover(client).await
|
||||||
("lasuite", "people-backend"),
|
.map_err(|e| SunbeamError::Other(format!("service discovery failed: {e}")))
|
||||||
("lasuite", "people-frontend"),
|
}
|
||||||
("lasuite", "people-celery-worker"),
|
|
||||||
("lasuite", "people-celery-beat"),
|
|
||||||
("lasuite", "projects"),
|
|
||||||
("matrix", "tuwunel"),
|
|
||||||
("media", "livekit-server"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Status helpers
|
// Status helpers
|
||||||
@@ -118,7 +111,7 @@ async fn vso_sync_status() -> Result<()> {
|
|||||||
|
|
||||||
if let Ok(list) = list {
|
if let Ok(list) = list {
|
||||||
// Group by namespace and sort
|
// Group by namespace and sort
|
||||||
let mut grouped: BTreeMap<String, Vec<(String, bool)>> = BTreeMap::new();
|
let mut grouped: std::collections::BTreeMap<String, Vec<(String, bool)>> = std::collections::BTreeMap::new();
|
||||||
for obj in &list.items {
|
for obj in &list.items {
|
||||||
let ns = obj.namespace().unwrap_or_default();
|
let ns = obj.namespace().unwrap_or_default();
|
||||||
let name = obj.name_any();
|
let name = obj.name_any();
|
||||||
@@ -159,7 +152,7 @@ async fn vso_sync_status() -> Result<()> {
|
|||||||
let list = api.list(&ListParams::default()).await;
|
let list = api.list(&ListParams::default()).await;
|
||||||
|
|
||||||
if let Ok(list) = list {
|
if let Ok(list) = list {
|
||||||
let mut grouped: BTreeMap<String, Vec<(String, bool)>> = BTreeMap::new();
|
let mut grouped: std::collections::BTreeMap<String, Vec<(String, bool)>> = std::collections::BTreeMap::new();
|
||||||
for obj in &list.items {
|
for obj in &list.items {
|
||||||
let ns = obj.namespace().unwrap_or_default();
|
let ns = obj.namespace().unwrap_or_default();
|
||||||
let name = obj.name_any();
|
let name = obj.name_any();
|
||||||
@@ -199,21 +192,23 @@ async fn vso_sync_status() -> Result<()> {
|
|||||||
// Public commands
|
// Public commands
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Show pod health, optionally filtered by namespace or namespace/service.
|
/// Show pod health, optionally filtered by service name, category, namespace,
|
||||||
|
/// or legacy namespace/service syntax.
|
||||||
pub async fn cmd_status(target: Option<&str>) -> Result<()> {
|
pub async fn cmd_status(target: Option<&str>) -> Result<()> {
|
||||||
step("Pod health across all namespaces...");
|
step("Pod health across all namespaces...");
|
||||||
|
|
||||||
let client = get_client().await?;
|
let client = get_client().await?;
|
||||||
let (ns_filter, svc_filter) = parse_target(target)?;
|
let reg = get_registry().await?;
|
||||||
|
|
||||||
let mut pods: Vec<PodRow> = Vec::new();
|
let mut pods: Vec<PodRow> = Vec::new();
|
||||||
|
|
||||||
match (ns_filter, svc_filter) {
|
match target {
|
||||||
(None, _) => {
|
None => {
|
||||||
// All managed namespaces
|
// All managed namespaces (derived from registry)
|
||||||
|
let namespaces = reg.namespaces();
|
||||||
let ns_set: std::collections::HashSet<&str> =
|
let ns_set: std::collections::HashSet<&str> =
|
||||||
MANAGED_NS.iter().copied().collect();
|
namespaces.iter().copied().collect();
|
||||||
for ns in MANAGED_NS {
|
for ns in &namespaces {
|
||||||
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
|
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
|
||||||
let lp = ListParams::default();
|
let lp = ListParams::default();
|
||||||
if let Ok(list) = api.list(&lp).await {
|
if let Ok(list) = api.list(&lp).await {
|
||||||
@@ -232,33 +227,89 @@ pub async fn cmd_status(target: Option<&str>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(Some(ns), None) => {
|
Some(input) => {
|
||||||
// All pods in a namespace
|
// Try registry resolution first
|
||||||
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
|
let resolved = reg.resolve(input);
|
||||||
let lp = ListParams::default();
|
if !resolved.is_empty() {
|
||||||
if let Ok(list) = api.list(&lp).await {
|
// Collect unique namespaces from resolved services
|
||||||
for pod in list.items {
|
let mut namespaces: Vec<&str> = resolved.iter()
|
||||||
pods.push(PodRow {
|
.map(|s| s.namespace.as_str())
|
||||||
ns: ns.to_string(),
|
.collect();
|
||||||
name: pod.name_any(),
|
namespaces.sort_unstable();
|
||||||
ready: pod_ready_str(&pod),
|
namespaces.dedup();
|
||||||
status: pod_phase(&pod),
|
|
||||||
});
|
// Collect deployment names for label filtering
|
||||||
|
let deploy_names: std::collections::HashSet<&str> = resolved.iter()
|
||||||
|
.flat_map(|s| s.deployments.iter().map(|d| d.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for ns in &namespaces {
|
||||||
|
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
|
||||||
|
if deploy_names.is_empty() {
|
||||||
|
// Services with no deployments: show all pods in namespace
|
||||||
|
let lp = ListParams::default();
|
||||||
|
if let Ok(list) = api.list(&lp).await {
|
||||||
|
for pod in list.items {
|
||||||
|
pods.push(PodRow {
|
||||||
|
ns: ns.to_string(),
|
||||||
|
name: pod.name_any(),
|
||||||
|
ready: pod_ready_str(&pod),
|
||||||
|
status: pod_phase(&pod),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Filter by app label for each deployment
|
||||||
|
for deploy in &deploy_names {
|
||||||
|
let lp = ListParams::default()
|
||||||
|
.labels(&format!("app={deploy}"));
|
||||||
|
if let Ok(list) = api.list(&lp).await {
|
||||||
|
for pod in list.items {
|
||||||
|
pods.push(PodRow {
|
||||||
|
ns: ns.to_string(),
|
||||||
|
name: pod.name_any(),
|
||||||
|
ready: pod_ready_str(&pod),
|
||||||
|
status: pod_phase(&pod),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
}
|
// Fallback: parse as namespace or namespace/name
|
||||||
(Some(ns), Some(svc)) => {
|
let (ns_filter, svc_filter) = parse_target(Some(input))?;
|
||||||
// Specific service: filter by app label
|
match (ns_filter, svc_filter) {
|
||||||
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
|
(Some(ns), None) => {
|
||||||
let lp = ListParams::default().labels(&format!("app={svc}"));
|
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
|
||||||
if let Ok(list) = api.list(&lp).await {
|
let lp = ListParams::default();
|
||||||
for pod in list.items {
|
if let Ok(list) = api.list(&lp).await {
|
||||||
pods.push(PodRow {
|
for pod in list.items {
|
||||||
ns: ns.to_string(),
|
pods.push(PodRow {
|
||||||
name: pod.name_any(),
|
ns: ns.to_string(),
|
||||||
ready: pod_ready_str(&pod),
|
name: pod.name_any(),
|
||||||
status: pod_phase(&pod),
|
ready: pod_ready_str(&pod),
|
||||||
});
|
status: pod_phase(&pod),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(ns), Some(svc)) => {
|
||||||
|
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
|
||||||
|
let lp = ListParams::default()
|
||||||
|
.labels(&format!("app={svc}"));
|
||||||
|
if let Ok(list) = api.list(&lp).await {
|
||||||
|
for pod in list.items {
|
||||||
|
pods.push(PodRow {
|
||||||
|
ns: ns.to_string(),
|
||||||
|
name: pod.name_any(),
|
||||||
|
ready: pod_ready_str(&pod),
|
||||||
|
status: pod_phase(&pod),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,13 +359,28 @@ pub async fn cmd_status(target: Option<&str>) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream logs for a service. Target must include service name (e.g. ory/kratos).
|
/// Stream logs for a service. Accepts a service name (e.g. "hydra") or legacy
|
||||||
|
/// namespace/name syntax (e.g. "ory/kratos").
|
||||||
pub async fn cmd_logs(target: &str, follow: bool) -> Result<()> {
|
pub async fn cmd_logs(target: &str, follow: bool) -> Result<()> {
|
||||||
let (ns_opt, name_opt) = parse_target(Some(target))?;
|
// Try registry first for exact service name match
|
||||||
let ns = ns_opt.unwrap_or("");
|
let reg = get_registry().await?;
|
||||||
let name = match name_opt {
|
let (ns, name) = if let Some(svc) = reg.get(target) {
|
||||||
Some(n) => n,
|
if svc.deployments.is_empty() {
|
||||||
None => bail!("Logs require a service name, e.g. 'ory/kratos'."),
|
bail!("{target} has no deployments to show logs for");
|
||||||
|
}
|
||||||
|
(svc.namespace.as_str(), svc.deployments[0].as_str())
|
||||||
|
} else {
|
||||||
|
// Fallback: parse as namespace/name
|
||||||
|
let (ns_opt, name_opt) = parse_target(Some(target))?;
|
||||||
|
let ns = ns_opt.unwrap_or("");
|
||||||
|
let name = match name_opt {
|
||||||
|
Some(n) => n,
|
||||||
|
None => bail!(
|
||||||
|
"No service '{target}' found in registry. Use namespace/name syntax \
|
||||||
|
(e.g. 'ory/kratos') or a known service name."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
(ns, name)
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = get_client().await?;
|
let client = get_client().await?;
|
||||||
@@ -395,27 +461,50 @@ pub async fn cmd_get(target: &str, output: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restart deployments. None=all, 'ory'=namespace, 'ory/kratos'=specific.
|
/// Restart deployments. Accepts service names, categories, namespaces, or
|
||||||
|
/// legacy namespace/name syntax. None restarts all non-infra services with
|
||||||
|
/// deployments.
|
||||||
pub async fn cmd_restart(target: Option<&str>) -> Result<()> {
|
pub async fn cmd_restart(target: Option<&str>) -> Result<()> {
|
||||||
step("Restarting services...");
|
step("Restarting services...");
|
||||||
|
|
||||||
let (ns_filter, svc_filter) = parse_target(target)?;
|
let reg = get_registry().await?;
|
||||||
|
|
||||||
let matched: Vec<(&str, &str)> = match (ns_filter, svc_filter) {
|
// Collect (namespace, deployment) pairs to restart.
|
||||||
(None, _) => SERVICES_TO_RESTART.to_vec(),
|
let pairs: Vec<(String, String)> = match target {
|
||||||
(Some(ns), None) => SERVICES_TO_RESTART
|
None => {
|
||||||
.iter()
|
// All non-infra services with deployments
|
||||||
.filter(|(n, _)| *n == ns)
|
reg.all().iter()
|
||||||
.copied()
|
.filter(|s| !s.deployments.is_empty())
|
||||||
.collect(),
|
.filter(|s| s.category != Category::Infra)
|
||||||
(Some(ns), Some(name)) => SERVICES_TO_RESTART
|
.flat_map(|s| s.deployments.iter().map(move |d| (s.namespace.clone(), d.clone())))
|
||||||
.iter()
|
.collect()
|
||||||
.filter(|(n, d)| *n == ns && *d == name)
|
}
|
||||||
.copied()
|
Some(input) => {
|
||||||
.collect(),
|
let resolved = reg.resolve(input);
|
||||||
|
if !resolved.is_empty() {
|
||||||
|
resolved.iter()
|
||||||
|
.flat_map(|s| s.deployments.iter().map(move |d| (s.namespace.clone(), d.clone())))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
// Fallback: parse as namespace or namespace/name
|
||||||
|
let (ns_filter, svc_filter) = parse_target(Some(input))?;
|
||||||
|
match (ns_filter, svc_filter) {
|
||||||
|
(Some(ns), None) => {
|
||||||
|
// Restart all deployments in this namespace from registry
|
||||||
|
reg.by_namespace(ns).iter()
|
||||||
|
.flat_map(|s| s.deployments.iter().map(move |d| (s.namespace.clone(), d.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
(Some(ns), Some(name)) => {
|
||||||
|
vec![(ns.to_string(), name.to_string())]
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if matched.is_empty() {
|
if pairs.is_empty() {
|
||||||
warn(&format!(
|
warn(&format!(
|
||||||
"No matching services for target: {}",
|
"No matching services for target: {}",
|
||||||
target.unwrap_or("(none)")
|
target.unwrap_or("(none)")
|
||||||
@@ -423,7 +512,7 @@ pub async fn cmd_restart(target: Option<&str>) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ns, dep) in &matched {
|
for (ns, dep) in &pairs {
|
||||||
if let Err(e) = kube_rollout_restart(ns, dep).await {
|
if let Err(e) = kube_rollout_restart(ns, dep).await {
|
||||||
warn(&format!("Failed to restart {ns}/{dep}: {e}"));
|
warn(&format!("Failed to restart {ns}/{dep}: {e}"));
|
||||||
}
|
}
|
||||||
@@ -440,34 +529,6 @@ pub async fn cmd_restart(target: Option<&str>) -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_managed_ns_contains_expected() {
|
|
||||||
assert!(MANAGED_NS.contains(&"ory"));
|
|
||||||
assert!(MANAGED_NS.contains(&"data"));
|
|
||||||
assert!(MANAGED_NS.contains(&"devtools"));
|
|
||||||
assert!(MANAGED_NS.contains(&"ingress"));
|
|
||||||
assert!(MANAGED_NS.contains(&"lasuite"));
|
|
||||||
assert!(MANAGED_NS.contains(&"matrix"));
|
|
||||||
assert!(MANAGED_NS.contains(&"media"));
|
|
||||||
assert!(MANAGED_NS.contains(&"storage"));
|
|
||||||
assert!(MANAGED_NS.contains(&"monitoring"));
|
|
||||||
assert!(MANAGED_NS.contains(&"vault-secrets-operator"));
|
|
||||||
assert_eq!(MANAGED_NS.len(), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_services_to_restart_contains_expected() {
|
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("ory", "hydra")));
|
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("ory", "kratos")));
|
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("ory", "login-ui")));
|
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("devtools", "gitea")));
|
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("storage", "seaweedfs-filer")));
|
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("lasuite", "hive")));
|
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("matrix", "tuwunel")));
|
|
||||||
assert!(SERVICES_TO_RESTART.contains(&("media", "livekit-server")));
|
|
||||||
assert_eq!(SERVICES_TO_RESTART.len(), 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_icon_for_status() {
|
fn test_icon_for_status() {
|
||||||
assert_eq!(icon_for_status("Running"), "\u{2713}");
|
assert_eq!(icon_for_status("Running"), "\u{2713}");
|
||||||
@@ -479,58 +540,16 @@ mod tests {
|
|||||||
assert_eq!(icon_for_status("CrashLoopBackOff"), "?");
|
assert_eq!(icon_for_status("CrashLoopBackOff"), "?");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_restart_filter_namespace() {
|
|
||||||
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART
|
|
||||||
.iter()
|
|
||||||
.filter(|(n, _)| *n == "ory")
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
assert_eq!(matched.len(), 3);
|
|
||||||
assert!(matched.contains(&("ory", "hydra")));
|
|
||||||
assert!(matched.contains(&("ory", "kratos")));
|
|
||||||
assert!(matched.contains(&("ory", "login-ui")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_restart_filter_specific() {
|
|
||||||
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART
|
|
||||||
.iter()
|
|
||||||
.filter(|(n, d)| *n == "ory" && *d == "kratos")
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
assert_eq!(matched.len(), 1);
|
|
||||||
assert_eq!(matched[0], ("ory", "kratos"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_restart_filter_no_match() {
|
|
||||||
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART
|
|
||||||
.iter()
|
|
||||||
.filter(|(n, d)| *n == "nonexistent" && *d == "nosuch")
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
assert!(matched.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_restart_filter_all() {
|
|
||||||
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART.to_vec();
|
|
||||||
assert_eq!(matched.len(), 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pod_ready_string_format() {
|
fn test_pod_ready_string_format() {
|
||||||
// Verify format: "N/M"
|
|
||||||
let ready = "2/3";
|
let ready = "2/3";
|
||||||
let parts: Vec<&str> = ready.split('/').collect();
|
let parts: Vec<&str> = ready.split('/').collect();
|
||||||
assert_eq!(parts.len(), 2);
|
assert_eq!(parts.len(), 2);
|
||||||
assert_ne!(parts[0], parts[1]); // unhealthy
|
assert_ne!(parts[0], parts[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_unhealthy_detection_by_ready_ratio() {
|
fn test_unhealthy_detection_by_ready_ratio() {
|
||||||
// Simulate the ready ratio check used in cmd_status
|
|
||||||
let ready = "1/2";
|
let ready = "1/2";
|
||||||
let status = "Running";
|
let status = "Running";
|
||||||
let mut unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
|
let mut unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
|
||||||
|
|||||||
Reference in New Issue
Block a user