initialize
This commit is contained in:
commit
f6e3fdd3c2
22 changed files with 7447 additions and 0 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
scripts/
|
||||||
|
demo-app/
|
||||||
|
*.md
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/target
|
||||||
|
/data
|
||||||
|
/demo*
|
||||||
|
/logs
|
||||||
|
/config.toml
|
||||||
|
*.txt
|
||||||
3041
Cargo.lock
generated
Normal file
3041
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
38
Cargo.toml
Normal file
38
Cargo.toml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
[package]
|
||||||
|
name = "silserv"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
authors = ["pakin"]
|
||||||
|
description = "Container manager service for testing"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.98"
|
||||||
|
async-trait = "0.1.88"
|
||||||
|
axum = "0.8.4"
|
||||||
|
bollard = "0.19.2"
|
||||||
|
chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
|
clap = { version = "4.5.43", features = ["derive"] }
|
||||||
|
config = "0.15.13"
|
||||||
|
futures = "0.3.31"
|
||||||
|
hyper = { version = "1.6.0", features = ["full"] }
|
||||||
|
ipnet = "2.11.0"
|
||||||
|
regex = "1.11.1"
|
||||||
|
reqwest = { version = "0.12.22", features = ["json"] }
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
serde_json = "1.0.142"
|
||||||
|
sled = "0.34.7"
|
||||||
|
thiserror = "2.0.12"
|
||||||
|
tokio = { version = "1.47.1", features = ["full"] }
|
||||||
|
tokio-cron-scheduler = "0.14.0"
|
||||||
|
tower = "0.5.2"
|
||||||
|
tower-http = { version = "0.6.6", features = ["cors", "trace"] }
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
|
uuid = { version = "1.17.0", features = ["serde", "v4"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4.4"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "silserv"
|
||||||
|
path = "src/main.rs"
|
||||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
FROM rustlang/rust:nightly-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt update && apt install -y \
|
||||||
|
musl-tools \
|
||||||
|
musl-dev \
|
||||||
|
build-essential \
|
||||||
|
libssl-dev \
|
||||||
|
curl \
|
||||||
|
pkg-config \
|
||||||
|
ca-certificates
|
||||||
|
|
||||||
|
# Copy dependency files first for better caching
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY ./src ./src
|
||||||
|
COPY config.toml .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
|
||||||
|
# Builder
|
||||||
|
FROM gcr.io/distroless/cc
|
||||||
|
|
||||||
|
# Copy the binary
|
||||||
|
COPY --from=builder /app/target/release/silserv /
|
||||||
|
COPY --from=builder /app/config.toml /
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
|
||||||
|
EXPOSE 36530
|
||||||
|
|
||||||
|
# Environment defaults
|
||||||
|
ENV SILSERV_LOG_LEVEL=debug
|
||||||
|
ENV SILSERV_STORAGE__PATH=/app/data/silserv.db
|
||||||
|
|
||||||
|
|
||||||
|
CMD ["./silserv"]
|
||||||
65
Makefile
Normal file
65
Makefile
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
.PHONY: build run test clean deploy logs
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
all: build
|
||||||
|
|
||||||
|
# Build the Rust application
|
||||||
|
build:
|
||||||
|
@echo "🦀 Building Rust Update Manager..."
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
docker-build:
|
||||||
|
@echo "🐳 Building Docker image..."
|
||||||
|
docker build -t rust-update-manager:latest .
|
||||||
|
|
||||||
|
# Run locally for development
|
||||||
|
dev:
|
||||||
|
@echo "🔧 Running in development mode..."
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test:
|
||||||
|
@echo "🧪 Running tests..."
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Deploy with Docker Compose
|
||||||
|
deploy:
|
||||||
|
@echo "🚀 Deploying with Docker Compose..."
|
||||||
|
chmod +x scripts/*.sh
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
|
# Run the demo
|
||||||
|
demo:
|
||||||
|
@echo "🎯 Running demo..."
|
||||||
|
chmod +x scripts/*.sh
|
||||||
|
./scripts/test-demo.sh
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
logs:
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
clean:
|
||||||
|
@echo "🧹 Cleaning up..."
|
||||||
|
cargo clean
|
||||||
|
docker-compose down -v
|
||||||
|
docker rmi rust-update-manager:latest 2>/dev/null || true
|
||||||
|
|
||||||
|
generate-config:
|
||||||
|
@echo "🔧 Generating configuration..."
|
||||||
|
cp config.toml.example ./$1/config.toml
|
||||||
|
|
||||||
|
# Show help
|
||||||
|
help:
|
||||||
|
@echo "Silserv - Make targets:"
|
||||||
|
@echo " build - Build the Rust application"
|
||||||
|
@echo " docker-build- Build Docker image"
|
||||||
|
@echo " dev - Run in development mode"
|
||||||
|
@echo " generate-config - Generate configuration file"
|
||||||
|
@echo " test - Run tests"
|
||||||
|
@echo " deploy - Deploy with Docker Compose"
|
||||||
|
@echo " demo - Run complete demo"
|
||||||
|
@echo " logs - View container logs"
|
||||||
|
@echo " clean - Clean up everything"
|
||||||
|
@echo " help - Show this help"
|
||||||
27
config.example.toml
Normal file
27
config.example.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
update_interval = 300
|
||||||
|
max_concurrent_updates = 5
|
||||||
|
health_check_timeout = 30
|
||||||
|
max_retries = 3
|
||||||
|
backup_retention_days = 7
|
||||||
|
bearer = ""
|
||||||
|
|
||||||
|
[discovery]
|
||||||
|
poll_interval = 30
|
||||||
|
|
||||||
|
[discovery.labels]
|
||||||
|
enable_updates = "silserv.enable"
|
||||||
|
registry_url = "silserv.registry"
|
||||||
|
image_name = "silserv.image"
|
||||||
|
health_check_path = "silserv.health-path"
|
||||||
|
update_strategy = "silserv.strategy"
|
||||||
|
|
||||||
|
[registry]
|
||||||
|
default_url = ""
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
[registry.auth]
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
path = "./data/silserv.db"
|
||||||
509
dashboard.html
Normal file
509
dashboard.html
Normal file
|
|
@ -0,0 +1,509 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Silserv Logs Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.filter-group label {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.filter-group input,
|
||||||
|
.filter-group select {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
border-left: 4px solid #ccc;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.log-entry.error {
|
||||||
|
border-left-color: #dc3545;
|
||||||
|
background-color: #f8d7da;
|
||||||
|
}
|
||||||
|
.log-entry.warn {
|
||||||
|
border-left-color: #ffc107;
|
||||||
|
background-color: #fff3cd;
|
||||||
|
}
|
||||||
|
.log-entry.info {
|
||||||
|
border-left-color: #17a2b8;
|
||||||
|
background-color: #d1ecf1;
|
||||||
|
}
|
||||||
|
.log-entry.debug {
|
||||||
|
border-left-color: #6c757d;
|
||||||
|
background-color: #e2e3e5;
|
||||||
|
}
|
||||||
|
.log-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.log-timestamp {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.log-level {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.log-level.error {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.log-level.warn {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.log-level.info {
|
||||||
|
background-color: #17a2b8;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.log-level.debug {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.log-message {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
.log-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pagination button:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.pagination button.active {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.auto-refresh {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.json-metadata {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 5px;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔍 Silserv Logs Dashboard</h1>
|
||||||
|
<div class="auto-refresh">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="autoRefresh" /> Auto-refresh
|
||||||
|
(30s)
|
||||||
|
</label>
|
||||||
|
<button onclick="loadLogs()">🔄 Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats" id="stats">
|
||||||
|
<!-- Stats will be populated here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Date:</label>
|
||||||
|
<select id="dateFilter">
|
||||||
|
<option value="">Today</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Level:</label>
|
||||||
|
<select id="levelFilter">
|
||||||
|
<option value="">All Levels</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="warn">Warning</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="debug">Debug</option>
|
||||||
|
<option value="trace">Trace</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Component:</label>
|
||||||
|
<select id="componentFilter">
|
||||||
|
<option value="">All Components</option>
|
||||||
|
<option value="updater">Updater</option>
|
||||||
|
<option value="discovery">Discovery</option>
|
||||||
|
<option value="api">API</option>
|
||||||
|
<option value="health">Health</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Container:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="containerFilter"
|
||||||
|
placeholder="Container name/ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Search:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="searchFilter"
|
||||||
|
placeholder="Search in messages"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Limit:</label>
|
||||||
|
<select id="limitFilter">
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100" selected>100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="logsContainer">
|
||||||
|
<div class="loading">Loading logs...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination" id="pagination">
|
||||||
|
<!-- Pagination will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = "http://localhost:36530/api";
|
||||||
|
let currentPage = 1;
|
||||||
|
let currentFilters = {};
|
||||||
|
let autoRefreshInterval = null;
|
||||||
|
|
||||||
|
// Initialize the dashboard
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
loadAvailableDates();
|
||||||
|
loadLogs();
|
||||||
|
setupEventListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
// Filter change listeners
|
||||||
|
document
|
||||||
|
.getElementById("dateFilter")
|
||||||
|
.addEventListener("change", applyFilters);
|
||||||
|
document
|
||||||
|
.getElementById("levelFilter")
|
||||||
|
.addEventListener("change", applyFilters);
|
||||||
|
document
|
||||||
|
.getElementById("componentFilter")
|
||||||
|
.addEventListener("change", applyFilters);
|
||||||
|
document
|
||||||
|
.getElementById("containerFilter")
|
||||||
|
.addEventListener("input", debounce(applyFilters, 500));
|
||||||
|
document
|
||||||
|
.getElementById("searchFilter")
|
||||||
|
.addEventListener("input", debounce(applyFilters, 500));
|
||||||
|
document
|
||||||
|
.getElementById("limitFilter")
|
||||||
|
.addEventListener("change", applyFilters);
|
||||||
|
|
||||||
|
// Auto-refresh toggle
|
||||||
|
document
|
||||||
|
.getElementById("autoRefresh")
|
||||||
|
.addEventListener("change", function (e) {
|
||||||
|
if (e.target.checked) {
|
||||||
|
autoRefreshInterval = setInterval(loadLogs, 30000);
|
||||||
|
} else {
|
||||||
|
clearInterval(autoRefreshInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableDates() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/logs/dates`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const dateSelect =
|
||||||
|
document.getElementById("dateFilter");
|
||||||
|
dateSelect.innerHTML =
|
||||||
|
'<option value="">Today</option>';
|
||||||
|
|
||||||
|
data.data.available_dates.forEach((date) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = date;
|
||||||
|
option.textContent = date;
|
||||||
|
dateSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load available dates:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
currentPage = 1;
|
||||||
|
currentFilters = {
|
||||||
|
date: document.getElementById("dateFilter").value,
|
||||||
|
level: document.getElementById("levelFilter").value,
|
||||||
|
component: document.getElementById("componentFilter").value,
|
||||||
|
container: document.getElementById("containerFilter").value,
|
||||||
|
search: document.getElementById("searchFilter").value,
|
||||||
|
limit: document.getElementById("limitFilter").value,
|
||||||
|
};
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs(page = 1) {
|
||||||
|
const container = document.getElementById("logsContainer");
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="loading">Loading logs...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page,
|
||||||
|
...currentFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove empty parameters
|
||||||
|
for (const [key, value] of [...params.entries()]) {
|
||||||
|
if (!value) {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/logs?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
displayLogs(data.data);
|
||||||
|
updatePagination(data.data);
|
||||||
|
updateStats(data.data);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<div class="loading">Error: ${data.message || "Failed to load logs"}</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = `<div class="loading">Error: ${error.message}</div>`;
|
||||||
|
console.error("Failed to load logs:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayLogs(logsData) {
|
||||||
|
const container = document.getElementById("logsContainer");
|
||||||
|
|
||||||
|
if (logsData.logs.length === 0) {
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="loading">No logs found for the selected filters.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logsHtml = logsData.logs
|
||||||
|
.map((log) => {
|
||||||
|
const timestamp = new Date(
|
||||||
|
log.timestamp,
|
||||||
|
).toLocaleString();
|
||||||
|
const metadata = log.metadata
|
||||||
|
? `<div class="json-metadata">${JSON.stringify(log.metadata, null, 2)}</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="log-entry ${log.level}">
|
||||||
|
<div class="log-meta">
|
||||||
|
<span class="log-timestamp">${timestamp}</span>
|
||||||
|
<span class="log-level ${log.level}">${log.level}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-message">${escapeHtml(log.message)}</div>
|
||||||
|
<div class="log-details">
|
||||||
|
<span><strong>Component:</strong> ${log.component}</span>
|
||||||
|
${log.container_name ? `<span><strong>Container:</strong> ${log.container_name}</span>` : ""}
|
||||||
|
${log.container_id ? `<span><strong>ID:</strong> ${log.container_id.substring(0, 12)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
${metadata}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
container.innerHTML = logsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination(logsData) {
|
||||||
|
const pagination = document.getElementById("pagination");
|
||||||
|
const totalPages = Math.ceil(logsData.total / logsData.limit);
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
pagination.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let paginationHtml = "";
|
||||||
|
|
||||||
|
// Previous button
|
||||||
|
if (logsData.page > 1) {
|
||||||
|
paginationHtml += `<button onclick="changePage(${logsData.page - 1})">« Previous</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page numbers
|
||||||
|
const startPage = Math.max(1, logsData.page - 2);
|
||||||
|
const endPage = Math.min(totalPages, logsData.page + 2);
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const activeClass = i === logsData.page ? "active" : "";
|
||||||
|
paginationHtml += `<button class="${activeClass}" onclick="changePage(${i})">${i}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
if (logsData.page < totalPages) {
|
||||||
|
paginationHtml += `<button onclick="changePage(${logsData.page + 1})">Next »</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
pagination.innerHTML = paginationHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(logsData) {
|
||||||
|
const stats = document.getElementById("stats");
|
||||||
|
|
||||||
|
// Count logs by level
|
||||||
|
const levelCounts = logsData.logs.reduce((acc, log) => {
|
||||||
|
acc[log.level] = (acc[log.level] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const statsHtml = `
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">${logsData.total}</div>
|
||||||
|
<div class="stat-label">Total Logs</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">${levelCounts.error || 0}</div>
|
||||||
|
<div class="stat-label">Errors</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">${levelCounts.warn || 0}</div>
|
||||||
|
<div class="stat-label">Warnings</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">${levelCounts.info || 0}</div>
|
||||||
|
<div class="stat-label">Info</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
stats.innerHTML = statsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(page) {
|
||||||
|
currentPage = page;
|
||||||
|
loadLogs(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
docker-compose.yml
Normal file
55
docker-compose.yml
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
services:
|
||||||
|
silserv:
|
||||||
|
build: .
|
||||||
|
container_name: silserv
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "36530:36530"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./config.toml:/app/config.toml:ro
|
||||||
|
environment:
|
||||||
|
- SILSERV_LOG_LEVEL=debug
|
||||||
|
- SILSERV_UPDATE_INTERVAL=300
|
||||||
|
- SILSERV_MAX_CONCURRENT_UPDATES=2
|
||||||
|
- SILSERV_REGISTRY__DEFAULT_URL=pakin-inspiron-15-3530.tail110d9.ts.net
|
||||||
|
networks:
|
||||||
|
- silserv-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:36530/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# Example managed application
|
||||||
|
demo-app:
|
||||||
|
image: pakin-inspiron-15-3530.tail110d9.ts.net/pakin/demo-app:1.0.0
|
||||||
|
container_name: demo-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
labels:
|
||||||
|
# Enable update management
|
||||||
|
- "silserv.enable=true"
|
||||||
|
- "silserv.registry=pakin-inspiron-15-3530.tail110d9.ts.net/pakin"
|
||||||
|
- "silserv.image=demo-app"
|
||||||
|
- "silserv.health-path=/health"
|
||||||
|
- "silserv.strategy=automatic"
|
||||||
|
networks:
|
||||||
|
- silserv-network
|
||||||
|
depends_on:
|
||||||
|
- silserv
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
silserv-network:
|
||||||
|
name: silserv-network
|
||||||
|
external: true
|
||||||
13
scripts/build.sh
Executable file
13
scripts/build.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🦀 Building Rust Update Manager"
|
||||||
|
|
||||||
|
# Build the Docker image
|
||||||
|
docker build -t silserv:latest .
|
||||||
|
|
||||||
|
# Tag for registry
|
||||||
|
docker tag silserv:latest pakin-inspiron-15-3530.tail110d9.ts.net/pakin/silserv:latest
|
||||||
|
|
||||||
|
echo "✅ Build completed"
|
||||||
|
echo "🚀 To run: docker-compose up -d"
|
||||||
42
scripts/deploy.sh
Executable file
42
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Deploying Silserv"
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "❌ Docker Compose is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
mkdir -p data logs
|
||||||
|
|
||||||
|
# Create network if it doesn't exist
|
||||||
|
docker network create silserv-network 2>/dev/null || echo "Network already exists"
|
||||||
|
|
||||||
|
# Build and start services
|
||||||
|
docker-compose down || true
|
||||||
|
docker-compose up --build -d
|
||||||
|
|
||||||
|
# Wait for services to be ready
|
||||||
|
echo "⏳ Waiting for services to start..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check health
|
||||||
|
if curl -f -s http://localhost:36530/health > /dev/null; then
|
||||||
|
echo "✅ Silserv is healthy"
|
||||||
|
else
|
||||||
|
echo "❌ Silserv health check failed"
|
||||||
|
docker compose logs silserv
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🎉 Deployment completed successfully!"
|
||||||
|
echo "📊 Dashboard: http://localhost:36530"
|
||||||
|
echo "🔍 API Status: http://localhost:36530/api/status"
|
||||||
93
scripts/test-demo.sh
Executable file
93
scripts/test-demo.sh
Executable file
|
|
@ -0,0 +1,93 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Test script to demonstrate the update manager functionality
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧪 Testing Rust Update Manager with Demo Application"
|
||||||
|
|
||||||
|
# Create a simple demo application
|
||||||
|
mkdir -p demo-app
|
||||||
|
|
||||||
|
cat > demo-app/Dockerfile << 'EOF'
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN echo -e 'const express = require("express");\nconst app = express();\nconst PORT = process.env.PORT || 3000;\nconst VERSION = process.env.APP_VERSION || "1.0.0";\n\napp.get("/health", (req, res) => {\nres.json({ status: "healthy", version: VERSION });\n});\napp.get("/", (req, res) => {\nres.json({\nmessage: "Demo App",\nversion: VERSION,\ntimestamp: new Date().toISOString()\n});\n});\n\napp.listen(PORT, () => {\nconsole.log(`Demo app v${VERSION} running on port ${PORT}`);\n});' > app.js
|
||||||
|
|
||||||
|
RUN npm init -y && npm install express
|
||||||
|
|
||||||
|
ENV APP_VERSION=1.0.0
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "app.js"]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > demo-app/package.json << 'EOF'
|
||||||
|
{
|
||||||
|
"name": "demo-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "app.js",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Build initial version
|
||||||
|
echo "📦 Building demo app v1.0.0..."
|
||||||
|
cd demo-app
|
||||||
|
docker build -t pakin-inspiron-15-3530.tail110d9.ts.net/pakin/demo-app:1.0.0 .
|
||||||
|
docker push pakin-inspiron-15-3530.tail110d9.ts.net/pakin/demo-app:1.0.0
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Deploy the update manager
|
||||||
|
echo "🚀 Deploying update manager..."
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
|
# Wait for everything to start
|
||||||
|
echo "⏳ Waiting for services to initialize..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
echo "📊 Checking system status..."
|
||||||
|
curl -s http://localhost:36530/api/status | jq .
|
||||||
|
|
||||||
|
echo "📋 Checking discovered containers..."
|
||||||
|
curl -s http://localhost:36530/api/containers | jq .
|
||||||
|
|
||||||
|
# Build and push v1.1.0
|
||||||
|
echo "📦 Building demo app v1.1.0..."
|
||||||
|
cd demo-app
|
||||||
|
|
||||||
|
# Update the version in Dockerfile
|
||||||
|
# sed -i 's/APP_VERSION=1.0.0/APP_VERSION=1.1.0/' Dockerfile
|
||||||
|
|
||||||
|
docker build -f ./Dockerfile.next -t pakin-inspiron-15-3530.tail110d9.ts.net/pakin/demo-app:1.1.0 .
|
||||||
|
docker push pakin-inspiron-15-3530.tail110d9.ts.net/pakin/demo-app:1.1.0
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "🔄 Triggering update check..."
|
||||||
|
curl -X POST http://localhost:36530/api/discovery/scan
|
||||||
|
|
||||||
|
# Wait a bit for the update to be detected and processed
|
||||||
|
sleep 60
|
||||||
|
|
||||||
|
# Check if update happened
|
||||||
|
echo "✅ Checking if update was applied..."
|
||||||
|
curl -s http://localhost:3000/ | jq .
|
||||||
|
|
||||||
|
echo "📊 Final system status..."
|
||||||
|
curl -s http://localhost:36530/api/stats | jq .
|
||||||
|
|
||||||
|
echo "🎉 Demo completed!"
|
||||||
|
echo ""
|
||||||
|
echo "What happened:"
|
||||||
|
echo "1. Built and deployed demo app v1.0.0"
|
||||||
|
echo "2. Update manager discovered the container"
|
||||||
|
echo "3. Built and pushed demo app v1.1.0 to registry"
|
||||||
|
echo "4. Update manager detected the new version"
|
||||||
|
echo "5. Automatically updated the container"
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Check the API at: http://localhost:36530"
|
||||||
|
echo "🔍 View logs with: docker-compose logs -f"
|
||||||
571
src/api.rs
Normal file
571
src/api.rs
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Json, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tower::ServiceBuilder;
|
||||||
|
use tower_http::{
|
||||||
|
cors::{Any, CorsLayer},
|
||||||
|
trace::{DefaultMakeSpan, TraceLayer},
|
||||||
|
};
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
discovery::ContainerDiscovery,
|
||||||
|
log_info,
|
||||||
|
logging::{LogDatesResponse, LogFilters, LogManager, LogResponse},
|
||||||
|
storage::Storage,
|
||||||
|
types::{ApiResponse, HealthResponse, ManagedContainer, StatsResponse},
|
||||||
|
updater::UpdateManager,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct ApiServer {
|
||||||
|
port: u16,
|
||||||
|
update_manager: Arc<Mutex<UpdateManager>>,
|
||||||
|
discovery: Arc<ContainerDiscovery>,
|
||||||
|
storage: Arc<Storage>,
|
||||||
|
log_manager: Arc<LogManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub update_manager: Arc<Mutex<UpdateManager>>,
|
||||||
|
pub discovery: Arc<ContainerDiscovery>,
|
||||||
|
pub storage: Arc<Storage>,
|
||||||
|
pub log_manager: Arc<LogManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiServer {
|
||||||
|
pub fn new(
|
||||||
|
port: u16,
|
||||||
|
update_manager: Arc<Mutex<UpdateManager>>,
|
||||||
|
discovery: Arc<ContainerDiscovery>,
|
||||||
|
storage: Arc<Storage>,
|
||||||
|
log_manager: Arc<LogManager>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
port,
|
||||||
|
update_manager,
|
||||||
|
discovery,
|
||||||
|
storage,
|
||||||
|
log_manager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let state = AppState {
|
||||||
|
update_manager: self.update_manager,
|
||||||
|
discovery: self.discovery,
|
||||||
|
storage: self.storage,
|
||||||
|
log_manager: self.log_manager.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
// Health and info endpoints
|
||||||
|
.route("/health", get(health_check))
|
||||||
|
.route("/", get(root))
|
||||||
|
// Container management endpoints
|
||||||
|
.route("/api/containers", get(list_containers))
|
||||||
|
.route("/api/containers/{id}", get(get_container))
|
||||||
|
.route("/api/containers/{id}/update", post(trigger_update))
|
||||||
|
.route("/api/containers/{id}/force-update", post(force_update))
|
||||||
|
// Discovery endpoints
|
||||||
|
.route("/api/discovery/scan", post(force_discovery))
|
||||||
|
.route("/api/discovery/containers", get(get_discovered_containers))
|
||||||
|
// System endpoints
|
||||||
|
.route("/api/status", get(system_status))
|
||||||
|
.route("/api/stats", get(get_stats))
|
||||||
|
.route("/api/logs", get(get_logs))
|
||||||
|
.route("/api/logs/dates", get(get_log_dates))
|
||||||
|
// Bulk operations
|
||||||
|
.route("/api/bulk/update-check", post(bulk_update_check))
|
||||||
|
.with_state(state)
|
||||||
|
.layer(
|
||||||
|
ServiceBuilder::new()
|
||||||
|
.layer(
|
||||||
|
TraceLayer::new_for_http()
|
||||||
|
.make_span_with(DefaultMakeSpan::new().include_headers(true)),
|
||||||
|
)
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_headers(Any)
|
||||||
|
.allow_methods(Any),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", self.port)).await?;
|
||||||
|
|
||||||
|
info!("🌐 API server listening on http://0.0.0.0:{}", self.port);
|
||||||
|
log_info!(
|
||||||
|
self.log_manager.clone(),
|
||||||
|
"init",
|
||||||
|
format!("🌐 API server listening on http://0.0.0.0:{}", self.port)
|
||||||
|
);
|
||||||
|
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler functions
|
||||||
|
|
||||||
|
async fn root() -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"service": "Docker Update Manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Automatic Docker container update management with service discovery",
|
||||||
|
"endpoints": {
|
||||||
|
"health": "/health",
|
||||||
|
"status": "/api/status",
|
||||||
|
"containers": "/api/containers",
|
||||||
|
"discovery": "/api/discovery/scan",
|
||||||
|
"stats": "/api/stats",
|
||||||
|
"bulk_update": "/api/bulk/update-check"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check(State(state): State<AppState>) -> Json<ApiResponse<HealthResponse>> {
|
||||||
|
log_info!(state.log_manager, "/health", "get health check...");
|
||||||
|
// let containers = match state.update_manager.lock().await.get_all_containers().await {
|
||||||
|
// Ok(containers) => containers,
|
||||||
|
// Err(_) => vec![],
|
||||||
|
// };
|
||||||
|
|
||||||
|
let containers = {
|
||||||
|
let mgr = state.update_manager.lock().await;
|
||||||
|
let _containers = mgr.get_all_containers().await;
|
||||||
|
drop(mgr);
|
||||||
|
match _containers {
|
||||||
|
Ok(containers) => containers,
|
||||||
|
Err(_) => vec![],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("containers: {:?}", containers);
|
||||||
|
|
||||||
|
let active_updates = {
|
||||||
|
let mgr = state.update_manager.lock().await;
|
||||||
|
let _active_updates = mgr.get_active_updates_count().await;
|
||||||
|
drop(mgr);
|
||||||
|
_active_updates
|
||||||
|
};
|
||||||
|
|
||||||
|
let update_queue_size = {
|
||||||
|
let mgr = state.update_manager.lock().await;
|
||||||
|
let _update_queue_size = mgr.get_update_queue_size().await;
|
||||||
|
drop(mgr);
|
||||||
|
_update_queue_size
|
||||||
|
};
|
||||||
|
|
||||||
|
let health = HealthResponse {
|
||||||
|
status: "healthy".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
uptime_seconds: 0, // TODO: Track actual uptime
|
||||||
|
managed_containers: containers.len(),
|
||||||
|
active_updates,
|
||||||
|
update_queue_size,
|
||||||
|
};
|
||||||
|
|
||||||
|
Json(ApiResponse::success(health))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn system_status(State(state): State<AppState>) -> Json<ApiResponse<SystemStatusResponse>> {
|
||||||
|
// let containers = state
|
||||||
|
// .update_manager
|
||||||
|
// .lock()
|
||||||
|
// .await
|
||||||
|
// .get_all_containers()
|
||||||
|
// .await
|
||||||
|
// .unwrap_or_default();
|
||||||
|
|
||||||
|
let containers = {
|
||||||
|
let mgr = state.update_manager.lock().await;
|
||||||
|
let _containers = mgr.get_all_containers().await.unwrap_or_default();
|
||||||
|
drop(mgr);
|
||||||
|
_containers
|
||||||
|
};
|
||||||
|
|
||||||
|
let discovered = state
|
||||||
|
.discovery
|
||||||
|
.get_discovered_containers()
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let active_updates = {
|
||||||
|
let mgr = state.update_manager.lock().await;
|
||||||
|
let _active_updates = mgr.get_active_updates_count().await;
|
||||||
|
drop(mgr);
|
||||||
|
_active_updates
|
||||||
|
};
|
||||||
|
|
||||||
|
let update_queue_size = {
|
||||||
|
let mgr = state.update_manager.lock().await;
|
||||||
|
let _update_queue_size = mgr.get_update_queue_size().await;
|
||||||
|
drop(mgr);
|
||||||
|
_update_queue_size
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = SystemStatusResponse {
|
||||||
|
service: "Docker Update Manager".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
managed_containers: containers.len(),
|
||||||
|
discovered_containers: discovered.len(),
|
||||||
|
active_updates,
|
||||||
|
update_queue_size,
|
||||||
|
containers: containers
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| ContainerSummary {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
image_name: c.image_name,
|
||||||
|
current_version: c.current_version,
|
||||||
|
status: c.status,
|
||||||
|
last_seen: c.last_seen,
|
||||||
|
update_available: false, // TODO: Check if update is available
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Json(ApiResponse::success(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_containers(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<ListContainersQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<ManagedContainer>>>, ApiError> {
|
||||||
|
let containers = {
|
||||||
|
let mgr = state.update_manager.lock().await;
|
||||||
|
let _containers = mgr.get_all_containers().await.unwrap_or_default();
|
||||||
|
drop(mgr);
|
||||||
|
_containers
|
||||||
|
};
|
||||||
|
|
||||||
|
let filtered_containers = if let Some(status_filter) = params.status {
|
||||||
|
containers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| format!("{:?}", c.status).to_lowercase() == status_filter.to_lowercase())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
containers
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(filtered_containers)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_container(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<ApiResponse<ManagedContainer>>, ApiError> {
|
||||||
|
let container = state.update_manager.lock().await.get_container(&id).await?;
|
||||||
|
Ok(Json(ApiResponse::success(container)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn trigger_update(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<ApiResponse<UpdateTriggeredResponse>>, ApiError> {
|
||||||
|
// state
|
||||||
|
// .update_manager
|
||||||
|
// .lock()
|
||||||
|
// .await
|
||||||
|
// .check_container_for_updates(id.clone())
|
||||||
|
// .await?;
|
||||||
|
|
||||||
|
info!("request update ...");
|
||||||
|
{
|
||||||
|
UpdateManager::check_container_for_updates(state.update_manager.clone(), id.clone())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = UpdateTriggeredResponse {
|
||||||
|
container_id: id.clone(),
|
||||||
|
message: "Update check triggered".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn force_update(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ForceUpdateRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<UpdateTriggeredResponse>>, ApiError> {
|
||||||
|
// state
|
||||||
|
// .update_manager
|
||||||
|
// .lock()
|
||||||
|
// .await
|
||||||
|
// .force_update(id.clone(), payload.target_version)
|
||||||
|
// .await?;
|
||||||
|
|
||||||
|
{
|
||||||
|
// let mgr = state.update_manager.lock().await;
|
||||||
|
UpdateManager::force_update(
|
||||||
|
state.update_manager.clone(),
|
||||||
|
id.clone(),
|
||||||
|
payload.target_version,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = UpdateTriggeredResponse {
|
||||||
|
container_id: id.clone(),
|
||||||
|
message: "Forced update queued".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn force_discovery(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<ApiResponse<DiscoveryResponse>>, ApiError> {
|
||||||
|
state.discovery.force_discovery().await?;
|
||||||
|
|
||||||
|
let response = DiscoveryResponse {
|
||||||
|
message: "Container discovery scan completed".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_discovered_containers(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<String>>>, ApiError> {
|
||||||
|
let containers = state.discovery.get_discovered_containers().await?;
|
||||||
|
Ok(Json(ApiResponse::success(containers)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<ApiResponse<crate::types::StatsResponse>>, ApiError> {
|
||||||
|
let mut error_flag = false;
|
||||||
|
let stats = {
|
||||||
|
let mgr = state.update_manager.lock().await;
|
||||||
|
let _stats = mgr.get_update_stats().await;
|
||||||
|
drop(mgr);
|
||||||
|
match _stats {
|
||||||
|
Ok(stats) => stats,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Failed to get update stats: {}", err);
|
||||||
|
error_flag = true;
|
||||||
|
StatsResponse::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(stats)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs endpoint handler
|
||||||
|
async fn get_logs(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
|
) -> Result<Json<ApiResponse<LogResponse>>, ApiError> {
|
||||||
|
let filters = LogFilters {
|
||||||
|
date: params.get("date").cloned(),
|
||||||
|
level: params.get("level").cloned(),
|
||||||
|
component: params.get("component").cloned(),
|
||||||
|
container: params.get("container").cloned(),
|
||||||
|
search: params.get("search").cloned(),
|
||||||
|
page: params
|
||||||
|
.get("page")
|
||||||
|
.and_then(|p| p.parse().ok())
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1),
|
||||||
|
limit: params
|
||||||
|
.get("limit")
|
||||||
|
.and_then(|l| l.parse().ok())
|
||||||
|
.unwrap_or(100)
|
||||||
|
.min(1000) // Max 1000 logs per request
|
||||||
|
.max(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
let logs = state.log_manager.get_logs(filters).await?;
|
||||||
|
Ok(Json(ApiResponse::success(logs)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_log_dates(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<ApiResponse<LogDatesResponse>>, ApiError> {
|
||||||
|
let available_dates = state.log_manager.get_available_dates().await?;
|
||||||
|
let current_date = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
let response = LogDatesResponse {
|
||||||
|
available_dates,
|
||||||
|
current_date,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bulk_update_check(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<BulkUpdateRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<BulkUpdateResponse>>, ApiError> {
|
||||||
|
let containers = if payload.container_ids.is_empty() {
|
||||||
|
state
|
||||||
|
.update_manager
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get_all_containers()
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
let mut selected = Vec::new();
|
||||||
|
for id in &payload.container_ids {
|
||||||
|
if let Ok(container) = state.update_manager.lock().await.get_container(id).await {
|
||||||
|
selected.push(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selected
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for container in containers {
|
||||||
|
let status = {
|
||||||
|
let update_manager = state.update_manager.clone();
|
||||||
|
let container_id = container.container_id.clone();
|
||||||
|
async move { UpdateManager::check_container_for_updates(update_manager, container_id).await }
|
||||||
|
};
|
||||||
|
|
||||||
|
match status.await {
|
||||||
|
Ok(_) => {
|
||||||
|
results.push(BulkUpdateResult {
|
||||||
|
container_id: container.container_id,
|
||||||
|
container_name: container.name,
|
||||||
|
status: "triggered".to_string(),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
results.push(BulkUpdateResult {
|
||||||
|
container_id: container.container_id,
|
||||||
|
container_name: container.name,
|
||||||
|
status: "failed".to_string(),
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = BulkUpdateResponse { results };
|
||||||
|
Ok(Json(ApiResponse::success(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request/Response types
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ListContainersQuery {
|
||||||
|
status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LogsQuery {
|
||||||
|
page: Option<u32>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
level: Option<String>,
|
||||||
|
container: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ForceUpdateRequest {
|
||||||
|
target_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BulkUpdateRequest {
|
||||||
|
container_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct SystemStatusResponse {
|
||||||
|
service: String,
|
||||||
|
version: String,
|
||||||
|
managed_containers: usize,
|
||||||
|
discovered_containers: usize,
|
||||||
|
active_updates: usize,
|
||||||
|
update_queue_size: usize,
|
||||||
|
containers: Vec<ContainerSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ContainerSummary {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
image_name: String,
|
||||||
|
current_version: String,
|
||||||
|
status: crate::types::ContainerStatus,
|
||||||
|
last_seen: chrono::DateTime<chrono::Utc>,
|
||||||
|
update_available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct UpdateTriggeredResponse {
|
||||||
|
container_id: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DiscoveryResponse {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct LogsResponse {
|
||||||
|
logs: Vec<LogEntry>,
|
||||||
|
total: u32,
|
||||||
|
page: u32,
|
||||||
|
limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct LogEntry {
|
||||||
|
timestamp: String,
|
||||||
|
level: String,
|
||||||
|
message: String,
|
||||||
|
container: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct BulkUpdateResponse {
|
||||||
|
results: Vec<BulkUpdateResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct BulkUpdateResult {
|
||||||
|
container_id: String,
|
||||||
|
container_name: String,
|
||||||
|
status: String,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ApiError(anyhow::Error);
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
error!("API Error: {}", self.0);
|
||||||
|
|
||||||
|
let error_response = ApiResponse::<()>::error(self.0.to_string());
|
||||||
|
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E> From<E> for ApiError
|
||||||
|
where
|
||||||
|
E: Into<anyhow::Error>,
|
||||||
|
{
|
||||||
|
fn from(err: E) -> Self {
|
||||||
|
Self(err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/config.rs
Normal file
106
src/config.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub update_interval: u64, // seconds
|
||||||
|
pub max_concurrent_updates: usize,
|
||||||
|
pub health_check_timeout: u64, // seconds
|
||||||
|
pub max_retries: usize,
|
||||||
|
pub backup_retention_days: u32,
|
||||||
|
pub discovery: DiscoveryConfig,
|
||||||
|
pub registry: RegistryConfig,
|
||||||
|
pub storage: StorageConfig,
|
||||||
|
bearer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DiscoveryConfig {
|
||||||
|
pub poll_interval: u64, // seconds
|
||||||
|
pub labels: DiscoveryLabels,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DiscoveryLabels {
|
||||||
|
pub enable_updates: String, // Label to enable updates
|
||||||
|
pub registry_url: String, // Registry URL label
|
||||||
|
pub image_name: String, // Image name label
|
||||||
|
pub health_check_path: String, // Health check path label
|
||||||
|
pub update_strategy: String, // Update strategy label
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegistryConfig {
|
||||||
|
pub default_url: String,
|
||||||
|
pub timeout: u64, // seconds
|
||||||
|
pub auth: Option<RegistryAuth>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegistryAuth {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StorageConfig {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
update_interval: 300, // 5 minutes
|
||||||
|
max_concurrent_updates: 2,
|
||||||
|
health_check_timeout: 30,
|
||||||
|
max_retries: 3,
|
||||||
|
backup_retention_days: 7,
|
||||||
|
discovery: DiscoveryConfig {
|
||||||
|
poll_interval: 30, // 30 seconds
|
||||||
|
labels: DiscoveryLabels {
|
||||||
|
enable_updates: "silserv.enable".to_string(),
|
||||||
|
registry_url: "silserv.registry".to_string(),
|
||||||
|
image_name: "silserv.image".to_string(),
|
||||||
|
health_check_path: "silserv.health-path".to_string(),
|
||||||
|
update_strategy: "silserv.strategy".to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
registry: RegistryConfig {
|
||||||
|
default_url: "localhost:32000".to_string(),
|
||||||
|
timeout: 30,
|
||||||
|
auth: None,
|
||||||
|
},
|
||||||
|
storage: StorageConfig {
|
||||||
|
path: "./data/silserv.db".to_string(),
|
||||||
|
},
|
||||||
|
bearer: "".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(path: &str) -> Result<Self, config::ConfigError> {
|
||||||
|
let settings = config::Config::builder()
|
||||||
|
.add_source(config::File::with_name(path).required(false))
|
||||||
|
.add_source(config::Environment::with_prefix("SILSERV"))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
settings.try_deserialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_interval_duration(&self) -> Duration {
|
||||||
|
Duration::from_secs(self.update_interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discovery_poll_duration(&self) -> Duration {
|
||||||
|
Duration::from_secs(self.discovery.poll_interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn health_check_timeout_duration(&self) -> Duration {
|
||||||
|
Duration::from_secs(self.health_check_timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bearer(&self) -> &str {
|
||||||
|
&self.bearer
|
||||||
|
}
|
||||||
|
}
|
||||||
545
src/discovery.rs
Normal file
545
src/discovery.rs
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use bollard::{
|
||||||
|
Docker,
|
||||||
|
container::{InspectContainerOptions, ListContainersOptions},
|
||||||
|
network::ListNetworksOptions,
|
||||||
|
};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::{
|
||||||
|
sync::Mutex,
|
||||||
|
time::{Duration, interval},
|
||||||
|
};
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::Config,
|
||||||
|
log_error, log_info,
|
||||||
|
logging::LogManager,
|
||||||
|
types::{
|
||||||
|
ContainerPort, ContainerStatus, ContainerVolume, ManagedContainer, NetworkSettings,
|
||||||
|
UpdateStrategy,
|
||||||
|
},
|
||||||
|
updater::UpdateManager,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct ContainerDiscovery {
|
||||||
|
docker: Docker,
|
||||||
|
network_name: String,
|
||||||
|
config: Config,
|
||||||
|
update_manager: Arc<Mutex<UpdateManager>>,
|
||||||
|
known_containers: Arc<tokio::sync::RwLock<HashSet<String>>>,
|
||||||
|
log: Arc<LogManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContainerDiscovery {
|
||||||
|
pub async fn new(
|
||||||
|
network_name: String,
|
||||||
|
config: Config,
|
||||||
|
update_manager: Arc<Mutex<UpdateManager>>,
|
||||||
|
log: Arc<LogManager>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let docker =
|
||||||
|
Docker::connect_with_socket_defaults().context("Failed to connect to Docker daemon")?;
|
||||||
|
|
||||||
|
// Verify network exists
|
||||||
|
let networks = docker
|
||||||
|
.list_networks(Some(ListNetworksOptions::<String> {
|
||||||
|
filters: HashMap::from([("name".to_string(), vec![network_name.clone()])]),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.context("Failed to list Docker networks")?;
|
||||||
|
|
||||||
|
if networks.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Network '{}' not found, will create it if needed",
|
||||||
|
network_name
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!("Found network '{}' for container discovery", network_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
docker,
|
||||||
|
network_name,
|
||||||
|
config,
|
||||||
|
update_manager,
|
||||||
|
known_containers: Arc::new(tokio::sync::RwLock::new(HashSet::new())),
|
||||||
|
log,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_monitoring(&self) -> Result<()> {
|
||||||
|
let mut discovery_interval = interval(self.config.discovery_poll_duration());
|
||||||
|
|
||||||
|
info!("🔍 Starting container discovery monitoring");
|
||||||
|
info!(" - Network: {}", self.network_name);
|
||||||
|
info!(
|
||||||
|
" - Poll interval: {}s",
|
||||||
|
self.config.discovery.poll_interval
|
||||||
|
);
|
||||||
|
|
||||||
|
log_info!(
|
||||||
|
self.log,
|
||||||
|
"discovery",
|
||||||
|
"Starting container discovery monitoring"
|
||||||
|
);
|
||||||
|
log_info!(
|
||||||
|
self.log,
|
||||||
|
"discovery",
|
||||||
|
format!(" - Network: {}", self.network_name)
|
||||||
|
);
|
||||||
|
log_info!(
|
||||||
|
self.log,
|
||||||
|
"discovery",
|
||||||
|
format!(
|
||||||
|
" - Poll interval: {}s",
|
||||||
|
self.config.discovery.poll_interval
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
discovery_interval.tick().await;
|
||||||
|
|
||||||
|
if let Err(e) = self.discover_containers().await {
|
||||||
|
error!("Container discovery failed: {}", e);
|
||||||
|
log_error!(
|
||||||
|
self.log,
|
||||||
|
"discover",
|
||||||
|
format!("Container discovery failed: {}", e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover_containers(&self) -> Result<()> {
|
||||||
|
debug!(
|
||||||
|
"🔍 Discovering containers in network '{}'",
|
||||||
|
self.network_name
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all containers in the target network
|
||||||
|
let containers = self.get_network_containers().await?;
|
||||||
|
let mut current_container_ids = HashSet::new();
|
||||||
|
|
||||||
|
for container in containers {
|
||||||
|
current_container_ids.insert(container.id.clone().unwrap_or_default());
|
||||||
|
|
||||||
|
if let Err(e) = self.process_container(&container).await {
|
||||||
|
warn!(
|
||||||
|
"Failed to process container {}: {}",
|
||||||
|
container
|
||||||
|
.names
|
||||||
|
.as_ref()
|
||||||
|
.unwrap_or(&vec!["unknown".to_string()])[0],
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for containers that disappeared
|
||||||
|
self.handle_disappeared_containers(¤t_container_ids)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update known containers
|
||||||
|
{
|
||||||
|
let mut known = self.known_containers.write().await;
|
||||||
|
*known = current_container_ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_network_containers(&self) -> Result<Vec<bollard::models::ContainerSummary>> {
|
||||||
|
let containers = self
|
||||||
|
.docker
|
||||||
|
.list_containers(Some(ListContainersOptions::<String> {
|
||||||
|
all: false,
|
||||||
|
limit: None,
|
||||||
|
size: false,
|
||||||
|
filters: HashMap::from([("status".to_string(), vec!["running".to_string()])]),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.context("Failed to list containers")?;
|
||||||
|
|
||||||
|
// Filter containers that are in our target network
|
||||||
|
let mut network_containers = Vec::new();
|
||||||
|
|
||||||
|
for container in containers {
|
||||||
|
if let Some(network_settings) = &container.network_settings {
|
||||||
|
if let Some(networks) = &network_settings.networks {
|
||||||
|
if networks.contains_key(&self.network_name) {
|
||||||
|
network_containers.push(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Found {} containers in network '{}'",
|
||||||
|
network_containers.len(),
|
||||||
|
self.network_name
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(network_containers)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_container(&self, container: &bollard::models::ContainerSummary) -> Result<()> {
|
||||||
|
let container_id = container.id.clone().unwrap_or_default();
|
||||||
|
let default_unknown = vec!["unknown".to_string()];
|
||||||
|
let container_names = container.names.as_ref().unwrap_or(&default_unknown);
|
||||||
|
let container_name = container_names[0].trim_start_matches('/').to_string();
|
||||||
|
|
||||||
|
// Get detailed container information
|
||||||
|
let inspect_result = self
|
||||||
|
.docker
|
||||||
|
.inspect_container(&container_id, None::<InspectContainerOptions>)
|
||||||
|
.await
|
||||||
|
.context("Failed to inspect container")?;
|
||||||
|
|
||||||
|
// Check if container has update management labels
|
||||||
|
let mut labels = inspect_result
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.labels.as_ref())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Check if updates are enabled for this container
|
||||||
|
let updates_enabled = labels
|
||||||
|
.get(&self.config.discovery.labels.enable_updates)
|
||||||
|
.map(|v| v == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !updates_enabled {
|
||||||
|
debug!(
|
||||||
|
"Container '{}' does not have updates enabled",
|
||||||
|
container_name
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("📦 Discovered manageable container: {}", container_name);
|
||||||
|
log_info!(
|
||||||
|
self.log,
|
||||||
|
"discovery",
|
||||||
|
format!("Discovered Container: {}", container_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
debug!("Labels: {:?}", labels);
|
||||||
|
|
||||||
|
self.override_label(&mut labels);
|
||||||
|
|
||||||
|
// Extract container information
|
||||||
|
let managed_container = self
|
||||||
|
.create_managed_container(
|
||||||
|
container_id.clone(),
|
||||||
|
container_name,
|
||||||
|
&inspect_result,
|
||||||
|
labels,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Register or update the container with the update manager
|
||||||
|
self.update_manager
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.register_container(managed_container)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Save final container state
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn override_label(&self, need_override_label: &mut HashMap<String, String>) {
|
||||||
|
// do check registry url
|
||||||
|
let registry_url = need_override_label.get("silserv.registry");
|
||||||
|
let strategy = need_override_label.get("silserv.strategy");
|
||||||
|
|
||||||
|
if let Some(url) = registry_url {
|
||||||
|
if url.starts_with("pakin-inspiron-15-3530.tail110d9.ts.net") {
|
||||||
|
// https://pakin-inspiron-15-3530.tail110d9.ts.net/api/v1/packages/pakin?type=container
|
||||||
|
if let Some(strategy) = strategy {
|
||||||
|
if strategy == "automatic" {
|
||||||
|
// Auto-update logic
|
||||||
|
// need_override_label
|
||||||
|
// .insert("silserv.registry".to_string(), "default".to_string());
|
||||||
|
let parts = url.split("/").collect::<Vec<&str>>();
|
||||||
|
|
||||||
|
match parts.len() {
|
||||||
|
2 => {
|
||||||
|
// expect domain/user
|
||||||
|
let new_format = format!("default.{}", parts[1]);
|
||||||
|
need_override_label
|
||||||
|
.insert("silserv.registry".to_string(), new_format);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strategy == "manual" {
|
||||||
|
// Manual-update logic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_managed_container(
|
||||||
|
&self,
|
||||||
|
container_id: String,
|
||||||
|
name: String,
|
||||||
|
inspect: &bollard::models::ContainerInspectResponse,
|
||||||
|
labels: HashMap<String, String>,
|
||||||
|
) -> Result<ManagedContainer> {
|
||||||
|
// Extract image information
|
||||||
|
let image = inspect
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.image
|
||||||
|
.as_ref()
|
||||||
|
.unwrap_or(&String::new())
|
||||||
|
.clone();
|
||||||
|
let image_parts: Vec<&str> = image.split(':').collect();
|
||||||
|
debug!("split image parts: {:?}", image_parts);
|
||||||
|
let (image_name, current_version) = if image_parts.len() >= 2 {
|
||||||
|
(image_parts[0].to_string(), image_parts[1].to_string())
|
||||||
|
} else {
|
||||||
|
(image.clone(), "latest".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get registry URL from labels or use default
|
||||||
|
let registry_url = labels
|
||||||
|
.get(&self.config.discovery.labels.registry_url)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| self.config.registry.default_url.clone());
|
||||||
|
|
||||||
|
// Create base managed container
|
||||||
|
let mut container =
|
||||||
|
ManagedContainer::new(container_id.clone(), name, image_name, registry_url);
|
||||||
|
|
||||||
|
container.current_version = current_version;
|
||||||
|
container.labels = labels.clone();
|
||||||
|
|
||||||
|
// Extract network settings
|
||||||
|
if let Some(network_settings) = &inspect.network_settings {
|
||||||
|
if let Some(networks) = &network_settings.networks {
|
||||||
|
if let Some(network) = networks.get(&self.network_name) {
|
||||||
|
container.network_settings = NetworkSettings {
|
||||||
|
ip_address: network.ip_address.clone().unwrap_or_default(),
|
||||||
|
network_name: self.network_name.clone(),
|
||||||
|
aliases: network.aliases.clone().unwrap_or_default(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ports
|
||||||
|
if let Some(host_config) = &inspect.host_config {
|
||||||
|
if let Some(port_bindings) = &host_config.port_bindings {
|
||||||
|
for (container_port, host_ports) in port_bindings {
|
||||||
|
let port_parts: Vec<&str> = container_port.split('/').collect();
|
||||||
|
let port_num = port_parts[0].parse::<u16>().unwrap_or(0);
|
||||||
|
let protocol = port_parts.get(1).unwrap_or(&"tcp").to_string();
|
||||||
|
|
||||||
|
let host_port = host_ports
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|ports| ports.first())
|
||||||
|
.and_then(|port| port.host_port.as_ref())
|
||||||
|
.and_then(|port| port.parse::<u16>().ok());
|
||||||
|
|
||||||
|
container.ports.push(ContainerPort {
|
||||||
|
container_port: port_num,
|
||||||
|
host_port,
|
||||||
|
protocol,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract volumes/mounts
|
||||||
|
if let Some(mounts) = &inspect.mounts {
|
||||||
|
for mount in mounts {
|
||||||
|
if let (Some(source), Some(destination)) = (&mount.source, &mount.destination) {
|
||||||
|
container.volumes.push(ContainerVolume {
|
||||||
|
source: source.clone(),
|
||||||
|
target: destination.clone(),
|
||||||
|
read_only: mount.rw.unwrap_or(true) == false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract environment variables
|
||||||
|
if let Some(config) = &inspect.config {
|
||||||
|
if let Some(env) = &config.env {
|
||||||
|
for env_var in env {
|
||||||
|
if let Some(eq_pos) = env_var.find('=') {
|
||||||
|
let key = env_var[..eq_pos].to_string();
|
||||||
|
let value = env_var[eq_pos + 1..].to_string();
|
||||||
|
|
||||||
|
// check key
|
||||||
|
if key.contains("registry") {
|
||||||
|
debug!("Registry: {}", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip sensitive environment variables
|
||||||
|
if !key.to_lowercase().contains("password")
|
||||||
|
&& !key.to_lowercase().contains("secret")
|
||||||
|
&& !key.to_lowercase().contains("token")
|
||||||
|
{
|
||||||
|
container.environment.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set health check URL
|
||||||
|
if let Some(health_path) = labels.get(&self.config.discovery.labels.health_check_path) {
|
||||||
|
// Try to determine the health check URL from container ports and network
|
||||||
|
if let Some(port) = container.ports.first() {
|
||||||
|
let base_port = port.host_port.unwrap_or(port.container_port);
|
||||||
|
container.health_check_url = Some(format!(
|
||||||
|
"http://{}:{}{}",
|
||||||
|
container.network_settings.ip_address, base_port, health_path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set update strategy
|
||||||
|
container.update_strategy = match labels
|
||||||
|
.get(&self.config.discovery.labels.update_strategy)
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
{
|
||||||
|
Some("automatic") => UpdateStrategy::Automatic,
|
||||||
|
Some("manual") => UpdateStrategy::Manual,
|
||||||
|
Some(cron) if cron.contains(' ') => UpdateStrategy::Scheduled(cron.to_string()),
|
||||||
|
_ => UpdateStrategy::Manual,
|
||||||
|
};
|
||||||
|
|
||||||
|
container.status = ContainerStatus::Healthy;
|
||||||
|
|
||||||
|
Ok(container)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_disappeared_containers(&self, current_ids: &HashSet<String>) -> Result<()> {
|
||||||
|
let known_ids = self.known_containers.read().await.clone();
|
||||||
|
|
||||||
|
for disappeared_id in known_ids.difference(current_ids) {
|
||||||
|
info!("📤 Container {} left the network", disappeared_id);
|
||||||
|
log_info!(
|
||||||
|
self.log,
|
||||||
|
"discovery",
|
||||||
|
format!("Container {} left the network", disappeared_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = self
|
||||||
|
.update_manager
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.mark_container_lost(disappeared_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("Failed to mark container {} as lost: {}", disappeared_id, e);
|
||||||
|
log_error!(
|
||||||
|
self.log,
|
||||||
|
"discovery",
|
||||||
|
format!("Failed to mark container {} as lost: {}", disappeared_id, e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_discovered_containers(&self) -> Result<Vec<String>> {
|
||||||
|
Ok(self.known_containers.read().await.iter().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn force_discovery(&self) -> Result<()> {
|
||||||
|
info!("🔍 Forcing container discovery");
|
||||||
|
log_info!(self.log, "discovery", "Forcing container discovery");
|
||||||
|
self.discover_containers().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional utility functions for container discovery
|
||||||
|
|
||||||
|
pub fn extract_image_info(full_image: &str) -> (String, String, String) {
|
||||||
|
// Parse full image string like "localhost:32000/myapp:v1.0.0"
|
||||||
|
// Returns (registry, image_name, version)
|
||||||
|
|
||||||
|
let parts: Vec<&str> = full_image.split('/').collect();
|
||||||
|
|
||||||
|
if parts.len() >= 2 {
|
||||||
|
let registry = parts[0];
|
||||||
|
let image_with_tag = parts.last().unwrap();
|
||||||
|
let tag_parts: Vec<&str> = image_with_tag.split(':').collect();
|
||||||
|
|
||||||
|
if tag_parts.len() >= 2 {
|
||||||
|
let image_name = parts[1..]
|
||||||
|
.join("/")
|
||||||
|
.replace(&format!(":{}", tag_parts[1]), "");
|
||||||
|
let version = tag_parts[1];
|
||||||
|
|
||||||
|
(registry.to_string(), image_name, version.to_string())
|
||||||
|
} else {
|
||||||
|
let image_name = parts[1..].join("/");
|
||||||
|
(registry.to_string(), image_name, "latest".to_string())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle simple image names like "nginx:alpine"
|
||||||
|
let tag_parts: Vec<&str> = full_image.split(':').collect();
|
||||||
|
if tag_parts.len() >= 2 {
|
||||||
|
(
|
||||||
|
"docker.io".to_string(),
|
||||||
|
tag_parts[0].to_string(),
|
||||||
|
tag_parts[1].to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"docker.io".to_string(),
|
||||||
|
full_image.to_string(),
|
||||||
|
"latest".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_semantic_version(version: &str) -> bool {
|
||||||
|
// Check if version follows semantic versioning pattern
|
||||||
|
regex::Regex::new(r"^v?\d+\.\d+\.\d+")
|
||||||
|
.unwrap()
|
||||||
|
.is_match(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compare_versions(v1: &str, v2: &str) -> std::cmp::Ordering {
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
// Simple semantic version comparison
|
||||||
|
let normalize = |v: &str| -> Vec<u32> {
|
||||||
|
v.trim_start_matches('v')
|
||||||
|
.split('.')
|
||||||
|
.take(3)
|
||||||
|
.map(|s| s.parse::<u32>().unwrap_or(0))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let version1 = normalize(v1);
|
||||||
|
let version2 = normalize(v2);
|
||||||
|
|
||||||
|
for i in 0..3 {
|
||||||
|
let num1 = version1.get(i).unwrap_or(&0);
|
||||||
|
let num2 = version2.get(i).unwrap_or(&0);
|
||||||
|
|
||||||
|
match num1.cmp(num2) {
|
||||||
|
Ordering::Equal => continue,
|
||||||
|
other => return other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ordering::Equal
|
||||||
|
}
|
||||||
43
src/health.rs
Normal file
43
src/health.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
pub struct HealthChecker {
|
||||||
|
client: Client,
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HealthChecker {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(config.health_check_timeout))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create health check HTTP client");
|
||||||
|
|
||||||
|
Self { client, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_health(&self, health_url: &str) -> Result<()> {
|
||||||
|
debug!("Performing health check: {}", health_url);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(health_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Health check request failed")?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
debug!("Health check passed: HTTP {}", response.status());
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Health check failed: HTTP {}",
|
||||||
|
response.status()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
480
src/logging.rs
Normal file
480
src/logging.rs
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::fs;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LogEntry {
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub level: LogLevel,
|
||||||
|
pub message: String,
|
||||||
|
pub component: String,
|
||||||
|
pub container_id: Option<String>,
|
||||||
|
pub container_name: Option<String>,
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum LogLevel {
|
||||||
|
Error,
|
||||||
|
Warn,
|
||||||
|
Info,
|
||||||
|
Debug,
|
||||||
|
Trace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for LogLevel {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
LogLevel::Error => write!(f, "error"),
|
||||||
|
LogLevel::Warn => write!(f, "warn"),
|
||||||
|
LogLevel::Info => write!(f, "info"),
|
||||||
|
LogLevel::Debug => write!(f, "debug"),
|
||||||
|
LogLevel::Trace => write!(f, "trace"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for LogLevel {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"error" => LogLevel::Error,
|
||||||
|
"warn" | "warning" => LogLevel::Warn,
|
||||||
|
"info" => LogLevel::Info,
|
||||||
|
"debug" => LogLevel::Debug,
|
||||||
|
"trace" => LogLevel::Trace,
|
||||||
|
_ => LogLevel::Info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LogManager {
|
||||||
|
in_memory_logs: Arc<RwLock<VecDeque<LogEntry>>>,
|
||||||
|
max_memory_logs: usize,
|
||||||
|
log_directory: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogManager {
|
||||||
|
pub fn new(log_directory: String, max_memory_logs: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
in_memory_logs: Arc::new(RwLock::new(VecDeque::with_capacity(max_memory_logs))),
|
||||||
|
max_memory_logs,
|
||||||
|
log_directory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init(&self) -> Result<()> {
|
||||||
|
// Create log directory if it doesn't exist
|
||||||
|
fs::create_dir_all(&self.log_directory).await?;
|
||||||
|
info!("📝 Log manager initialized at: {}", self.log_directory);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn log(
|
||||||
|
&self,
|
||||||
|
level: LogLevel,
|
||||||
|
message: String,
|
||||||
|
component: String,
|
||||||
|
container_id: Option<String>,
|
||||||
|
container_name: Option<String>,
|
||||||
|
metadata: Option<serde_json::Value>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let entry = LogEntry {
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
component,
|
||||||
|
container_id,
|
||||||
|
container_name,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to in-memory cache
|
||||||
|
self.add_to_memory_cache(entry.clone()).await;
|
||||||
|
|
||||||
|
// Write to file asynchronously
|
||||||
|
self.write_to_file(&entry).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_to_memory_cache(&self, entry: LogEntry) {
|
||||||
|
let mut logs = self.in_memory_logs.write().await;
|
||||||
|
|
||||||
|
// Remove oldest if at capacity
|
||||||
|
if logs.len() >= self.max_memory_logs {
|
||||||
|
logs.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_to_file(&self, entry: &LogEntry) -> Result<()> {
|
||||||
|
let date_str = entry.timestamp.format("%Y-%m-%d").to_string();
|
||||||
|
let log_file_path = format!("{}/silserv-{}.log", self.log_directory, date_str);
|
||||||
|
|
||||||
|
let log_line = serde_json::to_string(entry)? + "\n";
|
||||||
|
|
||||||
|
// Append to file
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&log_file_path)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
file.write_all(log_line.as_bytes()).await?;
|
||||||
|
file.flush().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_logs(&self, filters: LogFilters) -> Result<LogResponse> {
|
||||||
|
// First try memory cache for recent logs
|
||||||
|
if filters.date.is_none()
|
||||||
|
|| filters.date.as_ref().unwrap() == &Utc::now().format("%Y-%m-%d").to_string()
|
||||||
|
{
|
||||||
|
return self.get_logs_from_memory(filters).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For historical logs, read from file
|
||||||
|
self.get_logs_from_file(filters).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_logs_from_memory(&self, filters: LogFilters) -> Result<LogResponse> {
|
||||||
|
let logs = self.in_memory_logs.read().await;
|
||||||
|
let mut filtered_logs: Vec<LogEntry> = logs.iter().cloned().collect();
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if let Some(level) = &filters.level {
|
||||||
|
let filter_level = LogLevel::from(level.clone());
|
||||||
|
filtered_logs.retain(|log| {
|
||||||
|
matches!(
|
||||||
|
(log.level.clone(), filter_level.clone()),
|
||||||
|
(LogLevel::Error, LogLevel::Error)
|
||||||
|
| (LogLevel::Warn, LogLevel::Warn | LogLevel::Error)
|
||||||
|
| (
|
||||||
|
LogLevel::Info,
|
||||||
|
LogLevel::Info | LogLevel::Warn | LogLevel::Error
|
||||||
|
)
|
||||||
|
| (LogLevel::Debug, _)
|
||||||
|
| (LogLevel::Trace, _)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(component) = &filters.component {
|
||||||
|
filtered_logs.retain(|log| log.component.contains(component));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(container) = &filters.container {
|
||||||
|
filtered_logs.retain(|log| {
|
||||||
|
log.container_name
|
||||||
|
.as_ref()
|
||||||
|
.map(|name| name.contains(container))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| log
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.map(|id| id.contains(container))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(search) = &filters.search {
|
||||||
|
let search_lower = search.to_lowercase();
|
||||||
|
filtered_logs.retain(|log| log.message.to_lowercase().contains(&search_lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp (newest first)
|
||||||
|
filtered_logs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
let total = filtered_logs.len();
|
||||||
|
let start = (filters.page.saturating_sub(1)) * filters.limit;
|
||||||
|
let end = std::cmp::min(start + filters.limit, total);
|
||||||
|
|
||||||
|
let logs = if start < total {
|
||||||
|
filtered_logs[start..end].to_vec()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(LogResponse {
|
||||||
|
logs,
|
||||||
|
total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
has_more: end < total,
|
||||||
|
date: filters
|
||||||
|
.date
|
||||||
|
.unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_logs_from_file(&self, filters: LogFilters) -> Result<LogResponse> {
|
||||||
|
let date = filters.date.as_ref().unwrap();
|
||||||
|
let log_file_path = format!("{}/silserv-{}.log", self.log_directory, date);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if !fs::metadata(&log_file_path).await.is_ok() {
|
||||||
|
return Ok(LogResponse {
|
||||||
|
logs: Vec::new(),
|
||||||
|
total: 0,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
has_more: false,
|
||||||
|
date: date.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
let content = fs::read_to_string(&log_file_path).await?;
|
||||||
|
let mut logs = Vec::new();
|
||||||
|
|
||||||
|
// Parse each line as JSON
|
||||||
|
for line in content.lines() {
|
||||||
|
if let Ok(entry) = serde_json::from_str::<LogEntry>(line) {
|
||||||
|
logs.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters (same logic as memory)
|
||||||
|
if let Some(level) = &filters.level {
|
||||||
|
let filter_level = LogLevel::from(level.clone());
|
||||||
|
logs.retain(|log| self.level_matches(&log.level, &filter_level));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(component) = &filters.component {
|
||||||
|
logs.retain(|log| log.component.contains(component));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(container) = &filters.container {
|
||||||
|
logs.retain(|log| {
|
||||||
|
log.container_name
|
||||||
|
.as_ref()
|
||||||
|
.map(|name| name.contains(container))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| log
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.map(|id| id.contains(container))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(search) = &filters.search {
|
||||||
|
let search_lower = search.to_lowercase();
|
||||||
|
logs.retain(|log| log.message.to_lowercase().contains(&search_lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp (newest first)
|
||||||
|
logs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
let total = logs.len();
|
||||||
|
let start = (filters.page.saturating_sub(1)) * filters.limit;
|
||||||
|
let end = std::cmp::min(start + filters.limit, total);
|
||||||
|
|
||||||
|
let paginated_logs = if start < total {
|
||||||
|
logs[start..end].to_vec()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(LogResponse {
|
||||||
|
logs: paginated_logs,
|
||||||
|
total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
has_more: end < total,
|
||||||
|
date: date.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_matches(&self, log_level: &LogLevel, filter_level: &LogLevel) -> bool {
|
||||||
|
use LogLevel::*;
|
||||||
|
match filter_level {
|
||||||
|
Error => matches!(log_level, Error),
|
||||||
|
Warn => matches!(log_level, Error | Warn),
|
||||||
|
Info => matches!(log_level, Error | Warn | Info),
|
||||||
|
Debug => matches!(log_level, Error | Warn | Info | Debug),
|
||||||
|
Trace => true, // Trace shows everything
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_available_dates(&self) -> Result<Vec<String>> {
|
||||||
|
let mut dates = Vec::new();
|
||||||
|
|
||||||
|
let mut dir = fs::read_dir(&self.log_directory).await?;
|
||||||
|
while let Some(entry) = dir.next_entry().await? {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if name.starts_with("silserv-") && name.ends_with(".log") {
|
||||||
|
// Extract date from filename: silserv-2024-01-15.log
|
||||||
|
if let Some(date) = name
|
||||||
|
.strip_prefix("silserv-")
|
||||||
|
.and_then(|s| s.strip_suffix(".log"))
|
||||||
|
{
|
||||||
|
dates.push(date.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dates.sort();
|
||||||
|
dates.reverse(); // Most recent first
|
||||||
|
|
||||||
|
Ok(dates)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cleanup_old_logs(&self, retention_days: u32) -> Result<()> {
|
||||||
|
let cutoff_date = Utc::now() - chrono::Duration::days(retention_days as i64);
|
||||||
|
let cutoff_str = cutoff_date.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
let mut dir = fs::read_dir(&self.log_directory).await?;
|
||||||
|
while let Some(entry) = dir.next_entry().await? {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if name.starts_with("silserv-") && name.ends_with(".log") {
|
||||||
|
if let Some(date) = name
|
||||||
|
.strip_prefix("silserv-")
|
||||||
|
.and_then(|s| s.strip_suffix(".log"))
|
||||||
|
{
|
||||||
|
if cutoff_date < Utc::now() {
|
||||||
|
if let Err(e) = fs::remove_file(entry.path()).await {
|
||||||
|
warn!("Failed to remove old log file {}: {}", name, e);
|
||||||
|
} else {
|
||||||
|
info!("Removed old log file: {}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LogFilters {
|
||||||
|
pub date: Option<String>, // YYYY-MM-DD format
|
||||||
|
pub level: Option<String>, // error, warn, info, debug, trace
|
||||||
|
pub component: Option<String>, // updater, discovery, api, etc.
|
||||||
|
pub container: Option<String>, // container name or ID
|
||||||
|
pub search: Option<String>, // search in message content
|
||||||
|
pub page: usize, // page number (1-based)
|
||||||
|
pub limit: usize, // logs per page
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LogFilters {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
date: None,
|
||||||
|
level: None,
|
||||||
|
component: None,
|
||||||
|
container: None,
|
||||||
|
search: None,
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LogResponse {
|
||||||
|
pub logs: Vec<LogEntry>,
|
||||||
|
pub total: usize,
|
||||||
|
pub page: usize,
|
||||||
|
pub limit: usize,
|
||||||
|
pub has_more: bool,
|
||||||
|
pub date: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LogDatesResponse {
|
||||||
|
pub available_dates: Vec<String>,
|
||||||
|
pub current_date: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience macros for logging
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! log_info {
|
||||||
|
($log_manager:expr, $component:expr, $message:expr) => {
|
||||||
|
$log_manager
|
||||||
|
.log(
|
||||||
|
$crate::logging::LogLevel::Info,
|
||||||
|
$message.to_string(),
|
||||||
|
$component.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| tracing::warn!("Failed to log: {}", e));
|
||||||
|
};
|
||||||
|
($log_manager:expr, $component:expr, $message:expr, $container_id:expr, $container_name:expr) => {
|
||||||
|
$log_manager
|
||||||
|
.log(
|
||||||
|
$crate::logging::LogLevel::Info,
|
||||||
|
$message.to_string(),
|
||||||
|
$component.to_string(),
|
||||||
|
Some($container_id.to_string()),
|
||||||
|
Some($container_name.to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| tracing::warn!("Failed to log: {}", e));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! log_error {
|
||||||
|
($log_manager:expr, $component:expr, $message:expr) => {
|
||||||
|
$log_manager
|
||||||
|
.log(
|
||||||
|
$crate::logging::LogLevel::Error,
|
||||||
|
$message.to_string(),
|
||||||
|
$component.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| tracing::warn!("Failed to log: {}", e));
|
||||||
|
};
|
||||||
|
($log_manager:expr, $component:expr, $message:expr, $metadata:expr) => {
|
||||||
|
$log_manager
|
||||||
|
.log(
|
||||||
|
$crate::logging::LogLevel::Error,
|
||||||
|
$message.to_string(),
|
||||||
|
$component.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some($metadata),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| tracing::warn!("Failed to log: {}", e));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! log_warn {
|
||||||
|
($log_manager:expr, $component:expr, $message:expr) => {
|
||||||
|
$log_manager
|
||||||
|
.log(
|
||||||
|
$crate::logging::LogLevel::Warn,
|
||||||
|
$message.to_string(),
|
||||||
|
$component.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| tracing::warn!("Failed to log: {}", e));
|
||||||
|
};
|
||||||
|
}
|
||||||
147
src/main.rs
Normal file
147
src/main.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod config;
|
||||||
|
mod discovery;
|
||||||
|
mod health;
|
||||||
|
mod logging;
|
||||||
|
mod registry;
|
||||||
|
mod storage;
|
||||||
|
mod types;
|
||||||
|
mod updater;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::ApiServer, config::Config, discovery::ContainerDiscovery, logging::LogManager,
|
||||||
|
storage::Storage, updater::UpdateManager,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "silserv")]
|
||||||
|
#[command(about = "Automatic container update manager with service discovery")]
|
||||||
|
struct Args {
|
||||||
|
#[arg(short, long, default_value = "config.toml")]
|
||||||
|
config: String,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value = "36530")]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "info")]
|
||||||
|
log_level: String,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "silserv-network")]
|
||||||
|
network_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Initialize logging
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(&args.log_level)
|
||||||
|
.with_target(false)
|
||||||
|
.with_thread_ids(true)
|
||||||
|
.with_file(true)
|
||||||
|
.with_line_number(true)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
tracing::subscriber::set_global_default(subscriber).expect("Failed to set up logging");
|
||||||
|
|
||||||
|
info!("🚀 Starting Docker Update Manager");
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config = Config::load(&args.config).unwrap_or_else(|e| {
|
||||||
|
warn!("Failed to load config file: {}, using defaults", e);
|
||||||
|
Config::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("📋 Configuration loaded");
|
||||||
|
info!(" - Port: {}", args.port);
|
||||||
|
info!(" - Network: {}", args.network_name);
|
||||||
|
info!(" - Update interval: {}s", config.update_interval);
|
||||||
|
info!(
|
||||||
|
" - Max concurrent updates: {}",
|
||||||
|
config.max_concurrent_updates
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("Current config: {:?}", config);
|
||||||
|
|
||||||
|
// log manager
|
||||||
|
let log_manager = Arc::new(LogManager::new("./logs/".to_string(), 1000));
|
||||||
|
log_manager.init().await?;
|
||||||
|
|
||||||
|
// Initialize storage
|
||||||
|
let storage = Arc::new(Storage::new(&config.storage.path).await?);
|
||||||
|
info!("💾 Storage initialized at: {}", config.storage.path);
|
||||||
|
|
||||||
|
// Initialize update manager
|
||||||
|
let update_manager =
|
||||||
|
UpdateManager::new(config.clone(), storage.clone(), log_manager.clone()).await?;
|
||||||
|
info!("🔄 Update manager initialized");
|
||||||
|
|
||||||
|
// Initialize container discovery
|
||||||
|
let discovery = Arc::new(
|
||||||
|
ContainerDiscovery::new(
|
||||||
|
args.network_name.clone(),
|
||||||
|
config.clone(),
|
||||||
|
update_manager.clone(),
|
||||||
|
log_manager.clone(),
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
info!("🔍 Container discovery initialized");
|
||||||
|
|
||||||
|
// Start container discovery
|
||||||
|
let discovery_handle = {
|
||||||
|
let discovery = discovery.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = discovery.start_monitoring().await {
|
||||||
|
tracing::error!("Container discovery failed: {}", e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start update scheduler
|
||||||
|
let update_handle = {
|
||||||
|
let update_manager_mutex = update_manager.clone().lock().await.create_as_mutex();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = UpdateManager::start_scheduler(update_manager_mutex).await {
|
||||||
|
tracing::error!("Update scheduler failed: {}", e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start API server
|
||||||
|
let api_server = ApiServer::new(
|
||||||
|
args.port,
|
||||||
|
update_manager.clone(),
|
||||||
|
discovery.clone(),
|
||||||
|
storage.clone(),
|
||||||
|
log_manager.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("🌐 Starting API server on port {}", args.port);
|
||||||
|
info!("📡 Ready to discover and manage containers!");
|
||||||
|
|
||||||
|
// Run everything concurrently
|
||||||
|
tokio::select! {
|
||||||
|
result = api_server.run() => {
|
||||||
|
tracing::error!("API server stopped: {:?}", result);
|
||||||
|
}
|
||||||
|
result = discovery_handle => {
|
||||||
|
tracing::error!("Discovery stopped: {:?}", result);
|
||||||
|
}
|
||||||
|
result = update_handle => {
|
||||||
|
tracing::error!("Update scheduler stopped: {:?}", result);
|
||||||
|
}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
info!("🛑 Received shutdown signal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("👋 Silserv shutting down");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
236
src/registry.rs
Normal file
236
src/registry.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
use anyhow::{Context, Ok, Result};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
use crate::{config::Config, discovery::is_semantic_version};
|
||||||
|
|
||||||
|
pub struct RegistryClient {
|
||||||
|
client: Client,
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RegistryTagsResponse {
|
||||||
|
name: String,
|
||||||
|
tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
struct RegistriedContainerFromApi {
|
||||||
|
id: i64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
_type: String,
|
||||||
|
name: String,
|
||||||
|
version: String,
|
||||||
|
html_url: String,
|
||||||
|
created_at: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
extra_fields: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Semver {
|
||||||
|
major: u32,
|
||||||
|
minor: u32,
|
||||||
|
patch: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegistriedContainerFromApi {
|
||||||
|
fn get_semver(&self) -> Result<Semver> {
|
||||||
|
if !is_semantic_version(&self.version) {
|
||||||
|
// return Err(format!("Invalid semantic version: {}", self.version));
|
||||||
|
error!("Invalid semantic version: {}", self.version);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid semantic version: {}",
|
||||||
|
self.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let version_without_prefix = self.version.replace("v", "");
|
||||||
|
|
||||||
|
let parts: Vec<&str> = version_without_prefix.split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid semantic version: {}",
|
||||||
|
self.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let major = parts[0]
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("Invalid major version: {}", parts[0]))
|
||||||
|
.expect("");
|
||||||
|
let minor = parts[1]
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("Invalid minor version: {}", parts[1]))
|
||||||
|
.expect("");
|
||||||
|
let patch = parts[2]
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("Invalid patch version: {}", parts[2]))
|
||||||
|
.expect("");
|
||||||
|
|
||||||
|
Ok(Semver {
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
patch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegistryClient {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(config.registry.timeout))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
Self { client, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches all container images from user then filters by image_name
|
||||||
|
async fn fetch_by_user(&self, user: String, image_name: String) -> Result<String> {
|
||||||
|
// https://pakin-inspiron-15-3530.tail110d9.ts.net/api/v1/packages/pakin?type=container
|
||||||
|
|
||||||
|
let api_url = format!(
|
||||||
|
"https://pakin-inspiron-15-3530.tail110d9.ts.net/api/v1/packages/{}/?type=container",
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&api_url)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.header("authorization", format!("Basic {}", self.config.bearer()))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to registry")?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Registry responded with status: {}",
|
||||||
|
response.status()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let registry_responses: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse registry response")?;
|
||||||
|
|
||||||
|
let containers = registry_responses.as_array().unwrap();
|
||||||
|
let mut image_versions = Vec::new();
|
||||||
|
|
||||||
|
for registried_container in containers {
|
||||||
|
let container = RegistriedContainerFromApi::deserialize(registried_container).unwrap();
|
||||||
|
|
||||||
|
let pure_name = image_name.split('/').last().unwrap().to_string();
|
||||||
|
|
||||||
|
if is_semantic_version(&container.version) && container.name.eq(&pure_name) {
|
||||||
|
image_versions.push(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if image_versions.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("No image versions found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut latest_version = image_versions.get(0).unwrap().clone();
|
||||||
|
for image in image_versions {
|
||||||
|
if is_semantic_version(&latest_version.version) && is_semantic_version(&image.version) {
|
||||||
|
let latest_version_parts = latest_version.get_semver().unwrap();
|
||||||
|
let image_version_parts = image.get_semver().unwrap();
|
||||||
|
|
||||||
|
if latest_version_parts.major < image_version_parts.major {
|
||||||
|
latest_version = image;
|
||||||
|
} else if latest_version_parts.major.eq(&image_version_parts.major) {
|
||||||
|
if latest_version_parts.minor < image_version_parts.minor {
|
||||||
|
latest_version = image;
|
||||||
|
} else if latest_version_parts.minor.eq(&image_version_parts.minor) {
|
||||||
|
if latest_version_parts.patch < image_version_parts.patch {
|
||||||
|
latest_version = image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(latest_version.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_latest_version(&self, registry_url: &str, image_name: &str) -> Result<String> {
|
||||||
|
let mut url = format!(
|
||||||
|
"http://{}/v2/{}/tags/list",
|
||||||
|
registry_url.clone(),
|
||||||
|
image_name
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("Fetching tags from registry: {}", url);
|
||||||
|
|
||||||
|
if registry_url.starts_with("default") {
|
||||||
|
// get user
|
||||||
|
let reg_clone = registry_url.clone();
|
||||||
|
let _pre = reg_clone.split(".").collect::<Vec<&str>>();
|
||||||
|
let user = _pre.get(1).expect("invalid format of default");
|
||||||
|
|
||||||
|
self.fetch_by_user(user.to_string(), image_name.to_string())
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
let mut request = self.client.get(&url);
|
||||||
|
|
||||||
|
// Add authentication if configured
|
||||||
|
if let Some(auth) = &self.config.registry.auth {
|
||||||
|
request = request.basic_auth(&auth.username, Some(&auth.password));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to registry")?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Registry responded with status: {}",
|
||||||
|
response.status()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let registry_response: RegistryTagsResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse registry response")?;
|
||||||
|
|
||||||
|
// Filter and sort version tags
|
||||||
|
let mut version_tags: Vec<&String> = registry_response
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|tag| self.is_version_tag(tag))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
version_tags.sort_by(|a, b| self.compare_versions(a, b));
|
||||||
|
|
||||||
|
let latest = version_tags
|
||||||
|
.last()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No version tags found in registry"))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Latest version for {}/{}: {}",
|
||||||
|
registry_url, image_name, latest
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((*latest).clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_version_tag(&self, tag: &str) -> bool {
|
||||||
|
// Check if tag looks like a semantic version
|
||||||
|
regex::Regex::new(r"^v?\d+\.\d+\.\d+")
|
||||||
|
.unwrap()
|
||||||
|
.is_match(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_versions(&self, v1: &str, v2: &str) -> std::cmp::Ordering {
|
||||||
|
crate::discovery::compare_versions(v1, v2)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/storage.rs
Normal file
79
src/storage.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use crate::types::ManagedContainer;
|
||||||
|
|
||||||
|
pub struct Storage {
|
||||||
|
db: sled::Db,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Storage {
|
||||||
|
pub async fn new(db_path: &str) -> Result<Self> {
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = Path::new(db_path).parent() {
|
||||||
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.context("Failed to create storage directory")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = sled::open(db_path).context("Failed to open storage database")?;
|
||||||
|
|
||||||
|
info!("📁 Storage initialized at: {}", db_path);
|
||||||
|
|
||||||
|
Ok(Self { db })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_container(&self, container: &ManagedContainer) -> Result<()> {
|
||||||
|
let key = format!("container:{}", container.container_id);
|
||||||
|
let value = serde_json::to_vec(container).context("Failed to serialize container")?;
|
||||||
|
|
||||||
|
self.db
|
||||||
|
.insert(key.as_bytes(), value)
|
||||||
|
.context("Failed to save container to storage")?;
|
||||||
|
|
||||||
|
debug!("💾 Saved container: {}", container.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_container(&self, container_id: &str) -> Result<ManagedContainer> {
|
||||||
|
let key = format!("container:{}", container_id);
|
||||||
|
|
||||||
|
let value = self
|
||||||
|
.db
|
||||||
|
.get(key.as_bytes())
|
||||||
|
.context("Failed to read from storage")?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container not found: {}", container_id))?;
|
||||||
|
|
||||||
|
let container =
|
||||||
|
serde_json::from_slice(&value).context("Failed to deserialize container")?;
|
||||||
|
|
||||||
|
Ok(container)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_containers(&self) -> Result<Vec<ManagedContainer>> {
|
||||||
|
let mut containers = Vec::new();
|
||||||
|
|
||||||
|
for result in self.db.scan_prefix(b"container:") {
|
||||||
|
let (_key, value) = result.context("Failed to scan storage")?;
|
||||||
|
let container: ManagedContainer =
|
||||||
|
serde_json::from_slice(&value).context("Failed to deserialize container")?;
|
||||||
|
containers.push(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("📋 Retrieved {} containers from storage", containers.len());
|
||||||
|
Ok(containers)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_container(&self, container_id: &str) -> Result<()> {
|
||||||
|
let key = format!("container:{}", container_id);
|
||||||
|
|
||||||
|
self.db
|
||||||
|
.remove(key.as_bytes())
|
||||||
|
.context("Failed to delete container from storage")?;
|
||||||
|
|
||||||
|
debug!("🗑️ Deleted container: {}", container_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/types.rs
Normal file
235
src/types.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ManagedContainer {
|
||||||
|
pub id: String,
|
||||||
|
pub container_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub image_name: String,
|
||||||
|
pub current_version: String,
|
||||||
|
pub registry_url: String,
|
||||||
|
pub health_check_url: Option<String>,
|
||||||
|
pub labels: HashMap<String, String>,
|
||||||
|
pub ports: Vec<ContainerPort>,
|
||||||
|
pub volumes: Vec<ContainerVolume>,
|
||||||
|
pub environment: HashMap<String, String>,
|
||||||
|
pub network_settings: NetworkSettings,
|
||||||
|
pub status: ContainerStatus,
|
||||||
|
pub update_strategy: UpdateStrategy,
|
||||||
|
pub discovered_at: DateTime<Utc>,
|
||||||
|
pub last_seen: DateTime<Utc>,
|
||||||
|
pub last_update_check: Option<DateTime<Utc>>,
|
||||||
|
pub last_update: Option<DateTime<Utc>>,
|
||||||
|
pub update_history: Vec<UpdateRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ContainerPort {
|
||||||
|
pub container_port: u16,
|
||||||
|
pub host_port: Option<u16>,
|
||||||
|
pub protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ContainerVolume {
|
||||||
|
pub source: String,
|
||||||
|
pub target: String,
|
||||||
|
pub read_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NetworkSettings {
|
||||||
|
pub ip_address: String,
|
||||||
|
pub network_name: String,
|
||||||
|
pub aliases: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ContainerStatus {
|
||||||
|
Discovered,
|
||||||
|
Healthy,
|
||||||
|
Unhealthy,
|
||||||
|
Updating,
|
||||||
|
UpdateFailed,
|
||||||
|
Lost, // Container disappeared from network
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum UpdateStrategy {
|
||||||
|
Automatic, // Update as soon as new version is available
|
||||||
|
Manual, // Only update when manually triggered
|
||||||
|
Scheduled(String), // Cron expression for scheduled updates
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateRecord {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub from_version: String,
|
||||||
|
pub to_version: String,
|
||||||
|
pub status: UpdateStatus,
|
||||||
|
pub duration_ms: Option<u64>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum UpdateStatus {
|
||||||
|
Started,
|
||||||
|
PullingImage,
|
||||||
|
CreatingBackup,
|
||||||
|
StoppingContainer,
|
||||||
|
StartingContainer,
|
||||||
|
HealthChecking,
|
||||||
|
Success,
|
||||||
|
Failed,
|
||||||
|
RolledBack,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegistryImage {
|
||||||
|
pub registry_url: String,
|
||||||
|
pub image_name: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub latest_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateJob {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub container_id: String,
|
||||||
|
pub target_version: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
pub status: UpdateStatus,
|
||||||
|
pub retry_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Types
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiResponse<T> {
|
||||||
|
pub success: bool,
|
||||||
|
pub data: Option<T>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub version: String,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
pub managed_containers: usize,
|
||||||
|
pub active_updates: usize,
|
||||||
|
pub update_queue_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct StatsResponse {
|
||||||
|
pub total_containers: usize,
|
||||||
|
pub healthy_containers: usize,
|
||||||
|
pub updating_containers: usize,
|
||||||
|
pub failed_containers: usize,
|
||||||
|
pub total_updates_performed: usize,
|
||||||
|
pub successful_updates: usize,
|
||||||
|
pub failed_updates: usize,
|
||||||
|
pub average_update_duration_ms: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StatsResponse {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
total_containers: 0,
|
||||||
|
healthy_containers: 0,
|
||||||
|
updating_containers: 0,
|
||||||
|
failed_containers: 0,
|
||||||
|
total_updates_performed: 0,
|
||||||
|
successful_updates: 0,
|
||||||
|
failed_updates: 0,
|
||||||
|
average_update_duration_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> ApiResponse<T> {
|
||||||
|
pub fn success(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
data: Some(data),
|
||||||
|
error: None,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(message: String) -> Self {
|
||||||
|
Self {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(message),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UpdateStrategy {
|
||||||
|
fn default() -> Self {
|
||||||
|
UpdateStrategy::Manual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedContainer {
|
||||||
|
pub fn new(
|
||||||
|
container_id: String,
|
||||||
|
name: String,
|
||||||
|
image_name: String,
|
||||||
|
registry_url: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
container_id,
|
||||||
|
name,
|
||||||
|
image_name,
|
||||||
|
current_version: "unknown".to_string(),
|
||||||
|
registry_url,
|
||||||
|
health_check_url: None,
|
||||||
|
labels: HashMap::new(),
|
||||||
|
ports: Vec::new(),
|
||||||
|
volumes: Vec::new(),
|
||||||
|
environment: HashMap::new(),
|
||||||
|
network_settings: NetworkSettings {
|
||||||
|
ip_address: String::new(),
|
||||||
|
network_name: String::new(),
|
||||||
|
aliases: Vec::new(),
|
||||||
|
},
|
||||||
|
status: ContainerStatus::Discovered,
|
||||||
|
update_strategy: UpdateStrategy::default(),
|
||||||
|
discovered_at: Utc::now(),
|
||||||
|
last_seen: Utc::now(),
|
||||||
|
last_update_check: None,
|
||||||
|
last_update: None,
|
||||||
|
update_history: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_update_enabled(&self) -> bool {
|
||||||
|
self.labels
|
||||||
|
.get("update-manager.enable")
|
||||||
|
.map(|v| v == "true")
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_auto_update(&self) -> bool {
|
||||||
|
matches!(self.update_strategy, UpdateStrategy::Automatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_update_record(&mut self, record: UpdateRecord) {
|
||||||
|
self.update_history.push(record);
|
||||||
|
// Keep only last 10 records
|
||||||
|
if self.update_history.len() > 10 {
|
||||||
|
self.update_history.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1067
src/updater.rs
Normal file
1067
src/updater.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue