implement the swarm resource associations for stack / deployment targeting swarm

This commit is contained in:
mbecker20
2025-12-05 13:43:49 -08:00
committed by Maxwell Becker
parent 775f5bf703
commit a1c00a8d32
11 changed files with 455 additions and 178 deletions
+3 -9
View File
@@ -61,14 +61,6 @@ pub async fn get_user(user: &str) -> anyhow::Result<User> {
.with_context(|| format!("No user found matching '{user}'"))
}
pub async fn get_swarm_with_reachability(
swarm_id_or_name: &str,
) -> anyhow::Result<(Swarm, bool)> {
let swarm = resource::get::<Swarm>(swarm_id_or_name).await?;
let reachable = get_swarm_reachability(&swarm).await.is_ok();
Ok((swarm, reachable))
}
pub async fn get_swarm_reachability(
swarm: &Swarm,
) -> anyhow::Result<()> {
@@ -202,7 +194,9 @@ pub fn get_stack_state_from_containers(
pub async fn get_stack_state(
stack: &Stack,
) -> anyhow::Result<StackState> {
if stack.config.server_id.is_empty() {
if stack.config.swarm_id.is_empty()
&& stack.config.server_id.is_empty()
{
return Ok(StackState::Down);
}
let state = stack_status_cache()
+2
View File
@@ -60,6 +60,7 @@ pub async fn insert_stacks_status_unknown(stacks: Vec<Stack>) {
id: stack.id,
state: StackState::Unknown,
services: Vec::new(),
swarm_stack: None,
},
prev,
}
@@ -84,6 +85,7 @@ pub async fn insert_deployments_status_unknown(
id: deployment.id,
state: DeploymentState::Unknown,
container: None,
service: None,
update_available: false,
},
prev,
+7 -7
View File
@@ -197,19 +197,19 @@ pub async fn update_cache_for_server(server: &Server, force: bool) {
.unwrap_or(&[]);
tokio::join!(
resources::update_deployment_cache(
resources::update_server_stack_cache(
server.name.clone(),
resources.stacks,
containers,
images
),
resources::update_server_deployment_cache(
server.name.clone(),
resources.deployments,
containers,
images,
&resources.builds,
),
resources::update_stack_cache(
server.name.clone(),
resources.stacks,
containers,
images
),
);
insert_server_status(
+175 -13
View File
@@ -15,9 +15,13 @@ use komodo_client::{
docker::{
container::{ContainerListItem, ContainerStateStatusEnum},
image::ImageListItem,
service::SwarmServiceListItem,
stack::SwarmStackListItem,
task::SwarmTaskListItem,
},
komodo_timestamp,
stack::{Stack, StackService, StackServiceNames, StackState},
swarm::SwarmState,
user::auto_redeploy_user,
},
};
@@ -44,7 +48,87 @@ fn stack_alert_sent_cache() -> &'static AlertCache<(String, String)> {
CACHE.get_or_init(Default::default)
}
pub async fn update_stack_cache(
pub async fn update_swarm_stack_cache(
stacks: Vec<Stack>,
swarm_stacks: &[SwarmStackListItem],
swarm_services: &[SwarmServiceListItem],
) {
let stack_status_cache = stack_status_cache();
for stack in stacks {
let project_name = stack.project_name(false);
let swarm_stack = swarm_stacks
.iter()
.find(|stack| {
stack
.name
.as_ref()
.map(|name| name == &project_name)
.unwrap_or_default()
})
.cloned();
let services = extract_services_from_stack(&stack);
let mut services_with_swarm_services = services
.iter()
.map(
|StackServiceNames {
service_name,
image,
..
}| {
let swarm_service = swarm_services
.iter()
.find(|service| {
service
.name
.as_ref()
.map(|name| name == service_name)
.unwrap_or_default()
})
.cloned();
StackService {
service: service_name.clone(),
image: image.clone(),
container: None,
swarm_service,
update_available: false,
}
},
)
.collect::<Vec<_>>();
services_with_swarm_services
.sort_by(|a, b| a.service.cmp(&b.service));
let current_state = swarm_stack
.as_ref()
.map(|stack| match stack.state {
Some(SwarmState::Healthy) => StackState::Running,
Some(SwarmState::Unhealthy) => StackState::Unhealthy,
Some(SwarmState::Unknown) | None => StackState::Unknown,
})
.unwrap_or(StackState::Down);
let prev_state = stack_status_cache
.get(&stack.id)
.await
.map(|s| s.curr.state);
let status = CachedStackStatus {
id: stack.id.clone(),
state: current_state,
services: services_with_swarm_services,
swarm_stack,
};
stack_status_cache
.insert(
stack.id,
History {
curr: status,
prev: prev_state,
}
.into(),
)
.await;
}
}
pub async fn update_server_stack_cache(
server_name: String,
stacks: Vec<Stack>,
containers: &[ContainerListItem],
@@ -132,6 +216,7 @@ pub async fn update_stack_cache(
service: service_name.clone(),
image: image.clone(),
container,
swarm_service: None,
update_available,
}
}).collect::<Vec<_>>();
@@ -155,14 +240,14 @@ pub async fn update_stack_cache(
}
}
let state = get_stack_state_from_containers(
let current_state = get_stack_state_from_containers(
&stack.config.ignore_services,
&services,
containers,
);
if !services_to_update.is_empty()
&& stack.config.auto_update
&& state == StackState::Running
&& current_state == StackState::Running
&& !action_states()
.stack
.get_or_insert_default(&stack.id)
@@ -221,17 +306,25 @@ pub async fn update_stack_cache(
}
services_with_containers
.sort_by(|a, b| a.service.cmp(&b.service));
let prev = stack_status_cache
let prev_state = stack_status_cache
.get(&stack.id)
.await
.map(|s| s.curr.state);
let status = CachedStackStatus {
id: stack.id.clone(),
state,
state: current_state,
services: services_with_containers,
swarm_stack: None,
};
stack_status_cache
.insert(stack.id, History { curr: status, prev }.into())
.insert(
stack.id,
History {
curr: status,
prev: prev_state,
}
.into(),
)
.await;
}
}
@@ -241,7 +334,75 @@ fn deployment_alert_sent_cache() -> &'static AlertCache<String> {
CACHE.get_or_init(Default::default)
}
pub async fn update_deployment_cache(
pub async fn update_swarm_deployment_cache(
deployments: Vec<Deployment>,
swarm_services: &[SwarmServiceListItem],
swarm_tasks: &[SwarmTaskListItem],
) {
let deployment_status_cache = deployment_status_cache();
for deployment in deployments {
let service = swarm_services
.iter()
.find(|service| {
service
.name
.as_ref()
.map(|name| name == &deployment.name)
.unwrap_or_default()
})
.cloned();
let prev_state = deployment_status_cache
.get(&deployment.id)
.await
.map(|s| s.curr.state);
let current_state = service
.as_ref()
.map(|service| {
let Some(service_id) = &service.id else {
return DeploymentState::Unknown;
};
let tasks = swarm_tasks
.iter()
.filter(|task| {
task
.service_id
.as_ref()
.map(|id| id == service_id)
.unwrap_or_default()
})
.collect::<Vec<_>>();
// If service exists but no tasks, it is unhealthy
if tasks.is_empty() {
return DeploymentState::Unhealthy;
}
for task in tasks {
if task.desired_state != task.state {
return DeploymentState::Unhealthy;
}
}
DeploymentState::Running
})
.unwrap_or(DeploymentState::NotDeployed);
deployment_status_cache
.insert(
deployment.id.clone(),
History {
curr: CachedDeploymentStatus {
id: deployment.id,
state: current_state,
service,
container: None,
update_available: false,
},
prev: prev_state,
}
.into(),
)
.await;
}
}
pub async fn update_server_deployment_cache(
server_name: String,
deployments: Vec<Deployment>,
containers: &[ContainerListItem],
@@ -256,11 +417,11 @@ pub async fn update_deployment_cache(
.iter()
.find(|container| container.name == deployment.name)
.cloned();
let prev = deployment_status_cache
let prev_state = deployment_status_cache
.get(&deployment.id)
.await
.map(|s| s.curr.state);
let state = container
let current_state = container
.as_ref()
.map(|c| c.state.into())
.unwrap_or(DeploymentState::NotDeployed);
@@ -307,7 +468,7 @@ pub async fn update_deployment_cache(
if update_available {
if deployment.config.auto_update {
if state == DeploymentState::Running
if current_state == DeploymentState::Running
&& !action_states()
.deployment
.get_or_insert_default(&deployment.id)
@@ -362,7 +523,7 @@ pub async fn update_deployment_cache(
}
});
}
} else if state == DeploymentState::Running
} else if current_state == DeploymentState::Running
&& deployment.config.send_alerts
&& deployment_alert_sent_cache.contains(&deployment.id)
{
@@ -404,11 +565,12 @@ pub async fn update_deployment_cache(
History {
curr: CachedDeploymentStatus {
id: deployment.id,
state,
state: current_state,
container,
service: None,
update_available,
},
prev,
prev: prev_state,
}
.into(),
)
+22 -7
View File
@@ -22,7 +22,12 @@ use tokio::sync::Mutex;
use crate::{
config::monitoring_interval,
helpers::swarm::swarm_request_custom_timeout,
monitor::UpdateCacheResources,
monitor::{
UpdateCacheResources,
resources::{
update_swarm_deployment_cache, update_swarm_stack_cache,
},
},
state::{CachedSwarmStatus, db_client, swarm_status_cache},
};
@@ -30,17 +35,16 @@ const ADDITIONAL_MS: u128 = 1000;
pub fn spawn_swarm_monitoring_loop() {
tokio::spawn(async move {
refresh_swarm_cache(komodo_timestamp()).await;
refresh_swarm_cache().await;
let interval = monitoring_interval();
loop {
let ts = (wait_until_timelength(interval, ADDITIONAL_MS).await
- ADDITIONAL_MS) as i64;
refresh_swarm_cache(ts).await;
wait_until_timelength(interval, ADDITIONAL_MS).await;
refresh_swarm_cache().await;
}
});
}
async fn refresh_swarm_cache(_ts: i64) {
async fn refresh_swarm_cache() {
let swarms =
match find_collect(&db_client().swarms, None, None).await {
Ok(swarms) => swarms,
@@ -148,7 +152,18 @@ pub async fn update_cache_for_swarm(swarm: &Swarm, force: bool) {
}
}
// TODO: UPDATE STACKS / DEPLOYMENT CACHES
tokio::join!(
update_swarm_stack_cache(
resources.stacks,
&lists.stacks,
&lists.services,
),
update_swarm_deployment_cache(
resources.deployments,
&lists.services,
&lists.tasks,
)
);
swarm_status_cache()
.insert(
+98 -55
View File
@@ -3,7 +3,7 @@ use database::mungos::mongodb::Collection;
use formatting::format_serror;
use indexmap::IndexSet;
use komodo_client::entities::{
Operation, ResourceTarget, ResourceTargetVariant,
Operation, ResourceTarget, ResourceTargetVariant, SwarmOrServer,
build::Build,
deployment::{
Deployment, DeploymentConfig, DeploymentConfigDiff,
@@ -19,13 +19,16 @@ use komodo_client::entities::{
update::Update,
user::User,
};
use periphery_client::api::container::RemoveContainer;
use periphery_client::api::{
container::RemoveContainer, swarm::RemoveSwarmServices,
};
use crate::{
config::core_config,
helpers::{
empty_or_only_spaces, periphery_client,
query::get_deployment_state,
query::{get_deployment_state, get_swarm_or_server},
swarm::swarm_request,
},
monitor::update_cache_for_server,
state::{action_states, db_client, deployment_status_cache},
@@ -115,14 +118,28 @@ impl super::KomodoResource for Deployment {
let (image, update_available) = status
.as_ref()
.and_then(|s| {
s.curr.container.as_ref().map(|c| {
(
c.image
.clone()
.unwrap_or_else(|| String::from("Unknown")),
s.curr.update_available,
)
})
s.curr
.service
.as_ref()
.map(|service| {
(
service
.image
.clone()
.unwrap_or_else(|| String::from("Unknown")),
s.curr.update_available,
)
})
.or_else(|| {
s.curr.container.as_ref().map(|c| {
(
c.image
.clone()
.unwrap_or_else(|| String::from("Unknown")),
s.curr.update_available,
)
})
})
})
.unwrap_or((build_image, false));
DeploymentListItem {
@@ -229,6 +246,11 @@ impl super::KomodoResource for Deployment {
deployment: &Resource<Self::Config, Self::Info>,
update: &mut Update,
) -> anyhow::Result<()> {
if deployment.config.swarm_id.is_empty()
&& deployment.config.server_id.is_empty()
{
return Ok(());
}
let state = get_deployment_state(&deployment.id)
.await
.context("Failed to get deployment state")?;
@@ -238,65 +260,86 @@ impl super::KomodoResource for Deployment {
) {
return Ok(());
}
// container needs to be destroyed
let server = match super::get::<Server>(
// container / service needs to be destroyed
let swarm_or_server = match get_swarm_or_server(
&deployment.config.swarm_id,
&deployment.config.server_id,
)
.await
{
Ok(server) => server,
Ok(res) => res,
Err(e) => {
update.push_error_log(
"Remove Container",
"Remove Container / Service",
format_serror(
&e.context(format!(
"failed to retrieve server at {} from db.",
deployment.config.server_id
))
&e.context(
"Failed to retrieve Swarm / Server from database",
)
.into(),
),
);
return Ok(());
}
};
if !server.config.enabled {
// Don't need to
update.push_simple_log(
"Remove Container",
"Skipping container removal, server is disabled.",
);
return Ok(());
}
let periphery = match periphery_client(&server).await {
Ok(periphery) => periphery,
Err(e) => {
// This case won't ever happen, as periphery_client only fallible if the server is disabled.
// Leaving it for completeness sake
update.push_error_log(
"Remove Container",
format_serror(
&e.context("Failed to get periphery client").into(),
),
);
return Ok(());
}
};
match periphery
.request(RemoveContainer {
name: deployment.name.clone(),
signal: deployment.config.termination_signal.into(),
time: deployment.config.termination_timeout.into(),
})
match swarm_or_server {
SwarmOrServer::Swarm(swarm) => match swarm_request(
&swarm.config.server_ids,
RemoveSwarmServices {
services: vec![deployment.name.clone()],
},
)
.await
{
Ok(log) => update.logs.push(log),
Err(e) => update.push_error_log(
"Remove Container",
format_serror(
&e.context("Failed to remove container").into(),
{
Ok(log) => update.logs.push(log),
Err(e) => update.push_error_log(
"Remove Service",
format_serror(
&e.context("Failed to remove service").into(),
),
),
),
};
},
SwarmOrServer::Server(server) => {
if !server.config.enabled {
// Don't need to
update.push_simple_log(
"Remove Container",
"Skipping container removal, server is disabled.",
);
return Ok(());
}
let periphery = match periphery_client(&server).await {
Ok(periphery) => periphery,
Err(e) => {
// This case won't ever happen, as periphery_client only fallible if the server is disabled.
// Leaving it for completeness sake
update.push_error_log(
"Remove Container",
format_serror(
&e.context("Failed to get periphery client").into(),
),
);
return Ok(());
}
};
match periphery
.request(RemoveContainer {
name: deployment.name.clone(),
signal: deployment.config.termination_signal.into(),
time: deployment.config.termination_timeout.into(),
})
.await
{
Ok(log) => update.logs.push(log),
Err(e) => update.push_error_log(
"Remove Container",
format_serror(
&e.context("Failed to remove container").into(),
),
),
};
}
}
Ok(())
}
+120 -72
View File
@@ -5,7 +5,7 @@ use indexmap::IndexSet;
use komodo_client::{
api::write::RefreshStackCache,
entities::{
Operation, ResourceTarget, ResourceTargetVariant,
Operation, ResourceTarget, ResourceTargetVariant, SwarmOrServer,
permission::{PermissionLevel, SpecificPermission},
repo::Repo,
resource::Resource,
@@ -20,14 +20,21 @@ use komodo_client::{
user::{User, stack_user},
},
};
use periphery_client::api::compose::ComposeExecution;
use periphery_client::api::{
compose::ComposeExecution, swarm::RemoveSwarmStacks,
};
use resolver_api::Resolve;
use crate::{
api::write::WriteArgs,
config::core_config,
helpers::{periphery_client, query::get_stack_state, repo_link},
monitor::update_cache_for_server,
helpers::{
periphery_client,
query::{get_stack_state, get_swarm_or_server},
repo_link,
swarm::swarm_request,
},
monitor::{update_cache_for_server, update_cache_for_swarm},
state::{
action_states, all_resources_cache, db_client,
server_status_cache, stack_status_cache,
@@ -138,14 +145,14 @@ impl super::KomodoResource for Stack {
// This is only true if it is KNOWN to be true. so other cases are false.
let (project_missing, status) =
if stack.config.server_id.is_empty()
|| matches!(state, StackState::Down | StackState::Unknown)
{
if matches!(state, StackState::Down | StackState::Unknown) {
(false, None)
} else if let Some(status) = server_status_cache()
.get(&stack.config.server_id)
.await
.as_ref()
} else if stack.config.swarm_id.is_empty()
&& !stack.config.server_id.is_empty()
&& let Some(status) = server_status_cache()
.get(&stack.config.server_id)
.await
.as_ref()
{
if let Some(docker) = &status.docker {
if let Some(project) = docker
@@ -177,6 +184,7 @@ impl super::KomodoResource for Stack {
services,
project_missing,
file_contents: !stack.config.file_contents.is_empty(),
swarm_id: stack.config.swarm_id,
server_id: stack.config.server_id,
linked_repo: stack.config.linked_repo,
missing_files: stack.info.missing_files,
@@ -239,21 +247,32 @@ impl super::KomodoResource for Stack {
format_serror(&e.error.context("The stack cache has failed to refresh. This may be due to a misconfiguration of the Stack").into())
);
};
if created.config.server_id.is_empty() {
if created.config.swarm_id.is_empty()
&& created.config.server_id.is_empty()
{
return Ok(());
}
let Ok(server) = super::get::<Server>(&created.config.server_id)
.await
.inspect_err(|e| {
warn!(
"Failed to get Server for Stack {} | {e:#}",
created.name
)
})
else {
let Ok(swarm_or_server) = get_swarm_or_server(
&created.config.swarm_id,
&created.config.server_id,
)
.await
.inspect_err(|e| {
warn!(
"Failed to get Swarm or Server for Stack {} | {e:#}",
created.name
)
}) else {
return Ok(());
};
update_cache_for_server(&server, true).await;
match swarm_or_server {
SwarmOrServer::Swarm(swarm) => {
update_cache_for_swarm(&swarm, true).await;
}
SwarmOrServer::Server(server) => {
update_cache_for_server(&server, true).await;
}
}
Ok(())
}
@@ -302,65 +321,94 @@ impl super::KomodoResource for Stack {
return Ok(());
}
// stack needs to be destroyed
let server =
match super::get::<Server>(&stack.config.server_id).await {
Ok(server) => server,
Err(e) => {
update.push_error_log(
"destroy stack",
format_serror(
&e.context(format!(
"failed to retrieve server at {} from db.",
stack.config.server_id
))
.into(),
),
);
return Ok(());
}
};
if !server.config.enabled {
update.push_simple_log(
"destroy stack",
"skipping stack destroy, server is disabled.",
);
return Ok(());
}
let periphery = match periphery_client(&server).await {
Ok(periphery) => periphery,
let swarm_or_server = match get_swarm_or_server(
&stack.config.swarm_id,
&stack.config.server_id,
)
.await
{
Ok(res) => res,
Err(e) => {
// This case won't ever happen, as periphery_client only fallible if the server is disabled.
// Leaving it for completeness sake
update.push_error_log(
"destroy stack",
"Destroy Stack",
format_serror(
&e.context("failed to get periphery client").into(),
&e.context(
"Failed to retrieve Swarm or Server from database.",
)
.into(),
),
);
return Ok(());
}
};
match periphery
.request(ComposeExecution {
project: stack.project_name(false),
command: String::from("down --remove-orphans"),
})
.await
{
Ok(log) => update.logs.push(log),
Err(e) => update.push_simple_log(
"Failed to destroy stack",
format_serror(
&e.context(
"failed to destroy stack on periphery server before delete",
)
.into(),
),
),
};
match swarm_or_server {
SwarmOrServer::Swarm(swarm) => {
match swarm_request(
&swarm.config.server_ids,
RemoveSwarmStacks {
stacks: vec![stack.project_name(false)],
detach: true,
},
)
.await
{
Ok(log) => update.logs.push(log),
Err(e) => update.push_simple_log(
"Failed to destroy stack",
format_serror(
&e.context(
"Failed to destroy Stack on Swarm before delete",
)
.into(),
),
),
}
}
SwarmOrServer::Server(server) => {
if !server.config.enabled {
update.push_simple_log(
"Destroy Stack",
"Skipping stack destroy, Server is disabled.",
);
return Ok(());
}
let periphery = match periphery_client(&server).await {
Ok(periphery) => periphery,
Err(e) => {
// This case won't ever happen, as periphery_client only fallible if the server is disabled.
// Leaving it for completeness sake
update.push_error_log(
"Destroy Stack",
format_serror(
&e.context("Failed to get periphery client").into(),
),
);
return Ok(());
}
};
match periphery
.request(ComposeExecution {
project: stack.project_name(false),
command: String::from("down --remove-orphans"),
})
.await
{
Ok(log) => update.logs.push(log),
Err(e) => update.push_simple_log(
"Failed to destroy stack",
format_serror(
&e.context(
"failed to destroy stack on periphery server before delete",
)
.into(),
),
),
};
}
}
Ok(())
}
+4
View File
@@ -9,6 +9,7 @@ use komodo_client::entities::{
deployment::DeploymentState,
docker::{
DockerLists, SwarmLists, container::ContainerListItem,
service::SwarmServiceListItem, stack::SwarmStackListItem,
swarm::SwarmInspectInfo,
},
procedure::ProcedureState,
@@ -141,6 +142,8 @@ pub struct CachedStackStatus {
pub state: StackState,
/// The services connected to the stack
pub services: Vec<StackService>,
/// Swarm mode only. Associated swarm stack.
pub swarm_stack: Option<SwarmStackListItem>,
}
pub type StackStatusCache =
@@ -158,6 +161,7 @@ pub struct CachedDeploymentStatus {
pub id: String,
pub state: DeploymentState,
pub container: Option<ContainerListItem>,
pub service: Option<SwarmServiceListItem>,
pub update_available: bool,
}
+12 -10
View File
@@ -168,7 +168,7 @@ pub struct DeploymentConfig {
/// Extra args which are interpolated into the
/// `docker run` / `docker service create` command,
/// and affect the container configuration.
///
///
/// - Container ref: https://docs.docker.com/reference/cli/docker/container/run/#options
/// - Swarm Service ref: https://docs.docker.com/reference/cli/docker/service/create/#options
#[serde(default, deserialize_with = "string_list_deserializer")]
@@ -379,23 +379,25 @@ pub fn conversions_from_str(
pub enum DeploymentState {
/// The deployment is currently re/deploying
Deploying,
/// Container is running
/// Container / Service is running
Running,
/// Container is created but not running
/// Server mode only. Container is created but not running.
Created,
/// Container is in restart loop
/// Server mode only. Container is in restart loop
Restarting,
/// Container is being removed
/// Server mode only. Container is being removed
Removing,
/// Container is paused
/// Server mode only. Container is paused
Paused,
/// Container is exited
/// Server mode only. Container is exited
Exited,
/// Container is dead
/// Server mode only. Container is dead
Dead,
/// The deployment is not deployed (no matching container)
/// Swarm mode only. Some tasks don't match their desired state.
Unhealthy,
/// The deployment is not deployed (no matching Container / Service)
NotDeployed,
/// Server not reachable for status
/// Server / Swarm not reachable for status
#[default]
Unknown,
}
+2 -2
View File
@@ -33,11 +33,11 @@ pub mod volume;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmLists {
pub nodes: Vec<SwarmNodeListItem>,
pub stacks: Vec<SwarmStackListItem>,
pub services: Vec<SwarmServiceListItem>,
pub tasks: Vec<SwarmTaskListItem>,
pub secrets: Vec<SwarmSecretListItem>,
pub configs: Vec<SwarmConfigListItem>,
pub stacks: Vec<SwarmStackListItem>,
pub secrets: Vec<SwarmSecretListItem>,
}
/// Standard docker lists available from a Server.
+10 -3
View File
@@ -20,7 +20,10 @@ use crate::{
option_maybe_string_i64_deserializer,
option_string_list_deserializer, string_list_deserializer,
},
entities::{EnvironmentVar, environment_vars_from_str},
entities::{
EnvironmentVar, docker::service::SwarmServiceListItem,
environment_vars_from_str,
},
};
use super::{
@@ -133,7 +136,9 @@ pub type StackListItem = ResourceListItem<StackListItemInfo>;
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StackListItemInfo {
/// The server that stack is deployed on.
/// The swarm that stack is deployed on, when in Swarm mode.
pub swarm_id: String,
/// The server that stack is deployed on, when in Server mode.
pub server_id: String,
/// Whether stack is using files on host mode
pub files_on_host: bool,
@@ -731,8 +736,10 @@ pub struct StackService {
pub service: String,
/// The service image
pub image: String,
/// The container
/// The container (Server mode)
pub container: Option<ContainerListItem>,
/// The service (Swarm mode)
pub swarm_service: Option<SwarmServiceListItem>,
/// Whether there is an update available for this services image.
pub update_available: bool,
}