use std::{collections::HashMap, sync::OnceLock};
use anyhow::Context;
use bson::{Document, doc};
use derive_builder::Builder;
use derive_default_builder::DefaultBuilder;
use indexmap::IndexSet;
use partial_derive2::Partial;
use serde::{
Deserialize, Serialize,
de::{IntoDeserializer, Visitor, value::MapAccessDeserializer},
};
use strum::Display;
use typeshare::typeshare;
use crate::{
deserializers::{
env_vars_deserializer, file_contents_deserializer,
option_env_vars_deserializer, option_file_contents_deserializer,
option_maybe_string_i64_deserializer,
option_string_list_deserializer, string_list_deserializer,
},
entities::{
EnvironmentVar, ImageDigest,
docker::service::SwarmServiceListItem, environment_vars_from_str,
},
};
use super::{
FileContents, SystemCommand,
docker::container::ContainerListItem,
resource::{Resource, ResourceListItem, ResourceQuery},
};
#[cfg(feature = "utoipa")]
#[derive(utoipa::ToSchema)]
#[schema(as = Stack)]
pub struct StackSchema(
#[schema(inline)] pub Resource<StackConfig, StackInfo>,
);
#[typeshare]
pub type Stack = Resource<StackConfig, StackInfo>;
impl Stack {
pub fn project_name(&self, fresh: bool) -> String {
if !fresh
&& let Some(project_name) = &self.info.deployed_project_name
{
return project_name.clone();
}
if self.config.project_name.is_empty() {
self.name.clone()
} else {
self.config.project_name.clone()
}
}
pub fn compose_file_paths(&self) -> &[String] {
if self.config.file_paths.is_empty() {
default_stack_file_paths()
} else {
&self.config.file_paths
}
}
pub fn is_compose_file(&self, path: &str) -> bool {
if self.is_config_file(path) {
return false;
}
for compose_path in self.compose_file_paths() {
if path.ends_with(compose_path) {
return true;
}
}
false
}
pub fn is_config_file(&self, path: &str) -> bool {
for file in &self.config.config_files {
if path.ends_with(&file.path) {
return true;
}
}
false
}
fn tracked_env_files(&self) -> impl Iterator<Item = &str> {
self
.config
.additional_env_files
.iter()
.filter(|f| f.track)
.map(|f| f.path.as_str())
}
pub fn all_tracked_file_paths(&self) -> Vec<String> {
let mut res = self
.compose_file_paths()
.iter()
.cloned()
.collect::<IndexSet<_>>();
res.extend(self.tracked_env_files().map(str::to_string));
res.extend(
self.config.config_files.iter().map(|f| f.path.clone()),
);
res.into_iter().collect()
}
pub fn all_file_dependencies(&self) -> Vec<StackFileDependency> {
let mut res = self
.compose_file_paths()
.iter()
.cloned()
.map(StackFileDependency::full_redeploy)
.collect::<IndexSet<_>>();
res.extend(
self
.tracked_env_files()
.map(|p| StackFileDependency::full_redeploy(p.to_string())),
);
res.extend(self.config.config_files.clone());
res.into_iter().collect()
}
}
fn default_stack_file_paths() -> &'static [String] {
static DEFAULT_FILE_PATHS: OnceLock<Vec<String>> = OnceLock::new();
DEFAULT_FILE_PATHS
.get_or_init(|| vec![String::from("compose.yaml")])
}
#[typeshare]
pub type StackListItem = ResourceListItem<StackListItemInfo>;
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackListItemInfo {
pub swarm_id: String,
pub server_id: String,
pub files_on_host: bool,
pub file_contents: bool,
pub linked_repo: String,
pub git_provider: String,
pub repo: String,
pub branch: String,
pub repo_link: String,
pub state: StackState,
pub status: Option<String>,
pub services: Vec<StackServiceWithUpdate>,
pub project_missing: bool,
pub missing_files: Vec<String>,
pub deployed_hash: Option<String>,
pub latest_hash: Option<String>,
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackServiceWithUpdate {
pub service: String,
pub image: String,
pub update_available: bool,
}
#[typeshare]
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
PartialOrd,
Ord,
Serialize,
Deserialize,
Display,
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum StackState {
Deploying,
Running,
Paused,
Stopped,
Created,
Restarting,
Dead,
Removing,
Unhealthy,
Down,
#[default]
Unknown,
}
#[typeshare]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackInfo {
#[serde(default)]
pub missing_files: Vec<String>,
pub deployed_project_name: Option<String>,
pub deployed_hash: Option<String>,
pub deployed_message: Option<String>,
pub deployed_contents: Option<Vec<FileContents>>,
pub deployed_services: Option<Vec<StackServiceNames>>,
pub deployed_config: Option<String>,
#[serde(default)]
pub latest_services: Vec<StackServiceNames>,
pub remote_contents: Option<Vec<StackRemoteFileContents>>,
pub remote_errors: Option<Vec<FileContents>>,
pub latest_hash: Option<String>,
pub latest_message: Option<String>,
}
#[typeshare(serialized_as = "Partial<StackConfig>")]
pub type _PartialStackConfig = PartialStackConfig;
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, Builder, Partial)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[partial_derive(Debug, Clone, Default, Serialize, Deserialize)]
#[diff_derive(Debug, Clone, Default, Serialize, Deserialize)]
#[partial(skip_serializing_none, from, diff)]
pub struct StackConfig {
#[serde(default, alias = "swarm")]
#[partial_attr(serde(alias = "swarm"))]
#[builder(default)]
pub swarm_id: String,
#[serde(default, alias = "server")]
#[partial_attr(serde(alias = "server"))]
#[builder(default)]
pub server_id: String,
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default)]
pub links: Vec<String>,
#[serde(default)]
#[builder(default)]
pub project_name: String,
#[serde(default = "default_auto_pull")]
#[builder(default = "default_auto_pull()")]
#[partial_default(default_auto_pull())]
pub auto_pull: bool,
#[serde(default)]
#[builder(default)]
pub run_build: bool,
#[serde(default)]
#[builder(default)]
pub poll_for_updates: bool,
#[serde(default)]
#[builder(default)]
pub auto_update: bool,
#[serde(default)]
#[builder(default)]
pub auto_update_all_services: bool,
#[serde(default)]
#[builder(default)]
pub destroy_before_deploy: bool,
#[serde(default)]
#[builder(default)]
pub skip_secret_interp: bool,
#[serde(default)]
#[builder(default)]
pub linked_repo: String,
#[serde(default = "default_git_provider")]
#[builder(default = "default_git_provider()")]
#[partial_default(default_git_provider())]
pub git_provider: String,
#[serde(default = "default_git_https")]
#[builder(default = "default_git_https()")]
#[partial_default(default_git_https())]
pub git_https: bool,
#[serde(default)]
#[builder(default)]
pub git_account: String,
#[serde(default)]
#[builder(default)]
pub repo: String,
#[serde(default = "default_branch")]
#[builder(default = "default_branch()")]
#[partial_default(default_branch())]
pub branch: String,
#[serde(default)]
#[builder(default)]
pub commit: String,
#[serde(default)]
#[builder(default)]
pub clone_path: String,
#[serde(default)]
#[builder(default)]
pub reclone: bool,
#[serde(default = "default_webhook_enabled")]
#[builder(default = "default_webhook_enabled()")]
#[partial_default(default_webhook_enabled())]
pub webhook_enabled: bool,
#[serde(default)]
#[builder(default)]
pub webhook_secret: String,
#[serde(default)]
#[builder(default)]
pub webhook_force_deploy: bool,
#[serde(default)]
#[builder(default)]
pub files_on_host: bool,
#[serde(default)]
#[builder(default)]
pub run_directory: String,
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default)]
pub file_paths: Vec<String>,
#[serde(default = "default_env_file_path")]
#[builder(default = "default_env_file_path()")]
#[partial_default(default_env_file_path())]
pub env_file_path: String,
#[serde(default)]
#[partial_attr(serde(default))]
#[builder(default)]
pub additional_env_files: Vec<AdditionalEnvFile>,
#[serde(default)]
#[partial_attr(serde(default))]
#[builder(default)]
pub config_files: Vec<StackFileDependency>,
#[serde(default = "default_send_alerts")]
#[builder(default = "default_send_alerts()")]
#[partial_default(default_send_alerts())]
pub send_alerts: bool,
#[serde(default)]
#[builder(default)]
pub registry_provider: String,
#[serde(default)]
#[builder(default)]
pub registry_account: String,
#[serde(default)]
#[builder(default)]
pub pre_deploy: SystemCommand,
#[serde(default)]
#[builder(default)]
pub post_deploy: SystemCommand,
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default)]
pub extra_args: Vec<String>,
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default)]
pub build_extra_args: Vec<String>,
#[serde(default)]
#[builder(default)]
pub compose_cmd_wrapper: String,
#[serde(
default = "default_wrapper_include",
deserialize_with = "string_list_deserializer"
)]
#[partial_attr(serde(
default,
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default = "default_wrapper_include()")]
pub compose_cmd_wrapper_include: Vec<String>,
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default)]
pub ignore_services: Vec<String>,
#[serde(default, deserialize_with = "file_contents_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_file_contents_deserializer"
))]
#[builder(default)]
pub file_contents: String,
#[serde(default, deserialize_with = "env_vars_deserializer")]
#[partial_attr(serde(
default,
deserialize_with = "option_env_vars_deserializer"
))]
#[builder(default)]
pub environment: String,
}
impl StackConfig {
pub fn builder() -> StackConfigBuilder {
StackConfigBuilder::default()
}
pub fn env_vars(&self) -> anyhow::Result<Vec<EnvironmentVar>> {
environment_vars_from_str(&self.environment)
.context("Invalid environment")
}
}
fn default_env_file_path() -> String {
String::from(".env")
}
fn default_auto_pull() -> bool {
true
}
fn default_git_provider() -> String {
String::from("github.com")
}
fn default_git_https() -> bool {
true
}
fn default_branch() -> String {
String::from("main")
}
fn default_webhook_enabled() -> bool {
true
}
fn default_send_alerts() -> bool {
true
}
fn default_wrapper_include() -> Vec<String> {
vec![]
}
impl Default for StackConfig {
fn default() -> Self {
Self {
swarm_id: Default::default(),
server_id: Default::default(),
project_name: Default::default(),
run_directory: Default::default(),
file_paths: Default::default(),
files_on_host: Default::default(),
registry_provider: Default::default(),
registry_account: Default::default(),
file_contents: Default::default(),
auto_pull: default_auto_pull(),
poll_for_updates: Default::default(),
auto_update: Default::default(),
auto_update_all_services: Default::default(),
ignore_services: Default::default(),
pre_deploy: Default::default(),
post_deploy: Default::default(),
extra_args: Default::default(),
environment: Default::default(),
env_file_path: default_env_file_path(),
additional_env_files: Default::default(),
config_files: Default::default(),
run_build: Default::default(),
destroy_before_deploy: Default::default(),
build_extra_args: Default::default(),
compose_cmd_wrapper: Default::default(),
compose_cmd_wrapper_include: default_wrapper_include(),
skip_secret_interp: Default::default(),
linked_repo: Default::default(),
git_provider: default_git_provider(),
git_https: default_git_https(),
repo: Default::default(),
branch: default_branch(),
commit: Default::default(),
clone_path: Default::default(),
reclone: Default::default(),
git_account: Default::default(),
webhook_enabled: default_webhook_enabled(),
webhook_secret: Default::default(),
webhook_force_deploy: Default::default(),
send_alerts: default_send_alerts(),
links: Default::default(),
}
}
}
#[cfg(feature = "utoipa")]
impl utoipa::PartialSchema for PartialStackConfig {
fn schema()
-> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::schema!(#[inline] std::collections::HashMap<String, serde_json::Value>).into()
}
}
#[cfg(feature = "utoipa")]
impl utoipa::ToSchema for PartialStackConfig {}
#[typeshare]
#[derive(
Debug, Clone, Default, PartialEq, Serialize, Deserialize,
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ComposeProject {
pub name: String,
pub status: Option<String>,
pub compose_files: Vec<String>,
}
#[typeshare]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackServiceNames {
pub service_name: String,
pub container_name: String,
#[serde(default)]
pub image: String,
pub image_digest: Option<ImageDigest>,
}
#[typeshare]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackService {
pub service: String,
pub image: String,
pub container: Option<ContainerListItem>,
pub swarm_service: Option<SwarmServiceListItem>,
pub image_digests: Option<Vec<ImageDigest>>,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackActionState {
pub pulling: bool,
pub deploying: bool,
pub starting: bool,
pub restarting: bool,
pub pausing: bool,
pub unpausing: bool,
pub stopping: bool,
pub destroying: bool,
}
#[typeshare]
pub type StackQuery = ResourceQuery<StackQuerySpecifics>;
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder,
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackQuerySpecifics {
#[serde(default)]
pub server_ids: Vec<String>,
#[serde(default)]
pub linked_repos: Vec<String>,
#[serde(default)]
pub repos: Vec<String>,
#[serde(default)]
pub update_available: bool,
}
impl super::resource::AddFilters for StackQuerySpecifics {
fn add_filters(&self, filters: &mut Document) {
if !self.server_ids.is_empty() {
filters
.insert("config.server_id", doc! { "$in": &self.server_ids });
}
if !self.linked_repos.is_empty() {
filters.insert(
"config.linked_repo",
doc! { "$in": &self.linked_repos },
);
}
if !self.repos.is_empty() {
filters.insert("config.repo", doc! { "$in": &self.repos });
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComposeFile {
pub name: Option<String>,
#[serde(default)]
pub services: HashMap<String, ComposeService>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComposeService {
pub image: Option<String>,
pub container_name: Option<String>,
pub deploy: Option<ComposeServiceDeploy>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComposeServiceDeploy {
#[serde(
default,
deserialize_with = "option_maybe_string_i64_deserializer"
)]
pub replicas: Option<i64>,
}
#[typeshare]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackRemoteFileContents {
pub path: String,
pub contents: String,
#[serde(default)]
pub services: Vec<String>,
#[serde(default)]
pub requires: StackFileRequires,
}
#[typeshare]
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Default,
Serialize,
Deserialize,
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub enum StackFileRequires {
#[serde(alias = "redeploy")]
Redeploy,
#[serde(alias = "restart")]
Restart,
#[default]
#[serde(alias = "none")]
None,
}
#[typeshare]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct AdditionalEnvFile {
pub path: String,
#[serde(default = "default_true")]
pub track: bool,
}
fn default_true() -> bool {
true
}
#[derive(Deserialize)]
struct __AdditionalEnvFile {
path: String,
#[serde(default = "default_true")]
track: bool,
}
impl<'de> Deserialize<'de> for AdditionalEnvFile {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct AdditionalEnvFileVisitor;
impl<'de> Visitor<'de> for AdditionalEnvFileVisitor {
type Value = AdditionalEnvFile;
fn expecting(
&self,
formatter: &mut std::fmt::Formatter,
) -> std::fmt::Result {
write!(formatter, "string or AdditionalEnvFile (object)")
}
fn visit_string<E>(self, path: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(AdditionalEnvFile {
path,
track: default_true(),
})
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Self::visit_string(self, v.to_string())
}
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
__AdditionalEnvFile::deserialize(
MapAccessDeserializer::new(map).into_deserializer(),
)
.map(|v| AdditionalEnvFile {
path: v.path,
track: v.track,
})
}
}
deserializer.deserialize_any(AdditionalEnvFileVisitor)
}
}
#[typeshare]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackFileDependency {
pub path: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<String>,
#[serde(default, skip_serializing_if = "is_none")]
pub requires: StackFileRequires,
}
impl StackFileDependency {
pub fn full_redeploy(path: String) -> StackFileDependency {
StackFileDependency {
path,
services: Vec::new(),
requires: StackFileRequires::Redeploy,
}
}
}
fn is_none(requires: &StackFileRequires) -> bool {
matches!(requires, StackFileRequires::None)
}
#[derive(Deserialize)]
struct __StackFileDependency {
path: String,
#[serde(
default,
alias = "service",
deserialize_with = "string_list_deserializer"
)]
services: Vec<String>,
#[serde(default, alias = "req")]
requires: StackFileRequires,
}
impl<'de> Deserialize<'de> for StackFileDependency {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct StackFileDependencyVisitor;
impl<'de> Visitor<'de> for StackFileDependencyVisitor {
type Value = StackFileDependency;
fn expecting(
&self,
formatter: &mut std::fmt::Formatter,
) -> std::fmt::Result {
write!(formatter, "string or StackFileDependency (object)")
}
fn visit_string<E>(self, path: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(StackFileDependency {
path,
services: Vec::new(),
requires: StackFileRequires::None,
})
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Self::visit_string(self, v.to_string())
}
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
__StackFileDependency::deserialize(
MapAccessDeserializer::new(map).into_deserializer(),
)
.map(|v| StackFileDependency {
path: v.path,
services: v.services,
requires: v.requires,
})
}
}
deserializer.deserialize_any(StackFileDependencyVisitor)
}
}