Skip to main content

orca_core/config/
cluster.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::ai::AiConfig;
6use crate::backup::BackupConfig;
7
8/// Top-level cluster configuration (`cluster.toml`).
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct ClusterConfig {
11    pub cluster: ClusterMeta,
12    #[serde(default)]
13    pub node: Vec<NodeConfig>,
14    #[serde(default)]
15    pub observability: Option<ObservabilityConfig>,
16    #[serde(default)]
17    pub ai: Option<AiConfig>,
18    #[serde(default)]
19    pub backup: Option<BackupConfig>,
20    /// API bearer tokens for authentication. Empty = allow all requests.
21    /// Deprecated: use `[[token]]` entries with roles instead.
22    #[serde(default)]
23    pub api_tokens: Vec<String>,
24    /// Named API tokens with role-based access control.
25    #[serde(default)]
26    pub token: Vec<ApiToken>,
27    /// Mesh networking configuration (NetBird).
28    #[serde(default)]
29    pub network: Option<NetworkConfig>,
30    /// Fallback proxy for unmatched requests (e.g., point to coolify-proxy).
31    #[serde(default)]
32    pub fallback: Option<FallbackConfig>,
33}
34
35/// Fallback proxy configuration. When orca's route table has no match,
36/// requests are forwarded here. Lets orca coexist with another reverse proxy.
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct FallbackConfig {
39    /// HTTP fallback target (e.g., "127.0.0.1:8081").
40    pub http: Option<String>,
41    /// HTTPS/TLS fallback target for SNI passthrough (e.g., "127.0.0.1:8443").
42    pub tls: Option<String>,
43}
44
45/// Mesh networking configuration.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct NetworkConfig {
48    /// Network provider: "netbird" (default).
49    #[serde(default = "default_network_provider")]
50    pub provider: String,
51    /// NetBird setup key for joining the mesh.
52    pub setup_key: Option<String>,
53    /// NetBird management URL (default: api.netbird.io).
54    pub management_url: Option<String>,
55}
56
57/// Named API token with role-based access control.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ApiToken {
60    /// Human-readable name (e.g., "sharang", "gitea-ci").
61    pub name: String,
62    /// Bearer token value.
63    pub value: String,
64    /// Role: admin, deployer, or viewer.
65    #[serde(default = "default_role")]
66    pub role: Role,
67}
68
69/// Access control role.
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum Role {
73    /// Full access: deploy, stop, scale, drain, manage tokens.
74    #[default]
75    Admin,
76    /// Deploy, stop, scale, logs, status. For CI/CD service accounts.
77    Deployer,
78    /// Read-only: status, logs, metrics. For dashboards.
79    Viewer,
80}
81
82impl Role {
83    /// Check if this role can perform the given action.
84    ///
85    /// `secrets` is admin-only. Everything else escalates from viewer →
86    /// deployer → admin in the obvious way.
87    pub fn can(self, action: &str) -> bool {
88        match self {
89            Role::Admin => true,
90            Role::Deployer => matches!(
91                action,
92                "deploy" | "stop" | "scale" | "rollback" | "status" | "logs" | "cluster_info"
93            ),
94            Role::Viewer => matches!(action, "status" | "logs" | "cluster_info"),
95        }
96    }
97}
98
99fn default_role() -> Role {
100    Role::Admin
101}
102
103fn default_network_provider() -> String {
104    "netbird".into()
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ClusterMeta {
109    #[serde(default = "default_cluster_name")]
110    pub name: String,
111    pub domain: Option<String>,
112    pub acme_email: Option<String>,
113    #[serde(default = "default_log_level")]
114    pub log_level: String,
115    #[serde(default = "default_api_port")]
116    pub api_port: u16,
117    #[serde(default = "default_grpc_port")]
118    pub grpc_port: u16,
119}
120
121impl Default for ClusterMeta {
122    fn default() -> Self {
123        Self {
124            name: default_cluster_name(),
125            domain: None,
126            acme_email: None,
127            log_level: default_log_level(),
128            api_port: default_api_port(),
129            grpc_port: default_grpc_port(),
130        }
131    }
132}
133
134fn default_cluster_name() -> String {
135    "orca".into()
136}
137
138pub(crate) fn default_log_level() -> String {
139    "info".into()
140}
141
142pub(crate) fn default_api_port() -> u16 {
143    6880
144}
145
146pub(crate) fn default_grpc_port() -> u16 {
147    6881
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct NodeConfig {
152    pub address: String,
153    #[serde(default)]
154    pub labels: HashMap<String, String>,
155    /// GPU devices available on this node.
156    #[serde(default)]
157    pub gpus: Vec<NodeGpuConfig>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct NodeGpuConfig {
162    /// Vendor: "nvidia" or "amd".
163    pub vendor: String,
164    /// Number of GPUs of this type.
165    #[serde(default = "default_gpu_count")]
166    pub count: u32,
167    /// Model name for scheduling (e.g., "A100", "RTX4090").
168    pub model: Option<String>,
169}
170
171pub(crate) fn default_gpu_count() -> u32 {
172    1
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ObservabilityConfig {
177    pub otlp_endpoint: Option<String>,
178    pub alerts: Option<AlertChannelConfig>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct AlertChannelConfig {
183    pub webhook: Option<String>,
184    pub email: Option<String>,
185}