Added config reload
Build and Publish / Build and Publish Docker Image (push) Successful in 3m7s

This commit is contained in:
Ultradesu
2026-06-29 20:49:41 +03:00
parent 311a05789e
commit 0be4512293
4 changed files with 133 additions and 8 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "amnezia-fellow"
version = "0.1.1"
version = "0.1.2"
edition = "2024"
description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync"
+7
View File
@@ -93,16 +93,23 @@ translations! {
configs_loading_status: "Loading status..." , "Загрузка статуса...";
configs_no_pods: "No AmneziaWG pods." , "Нет подов AmneziaWG.";
configs_server: "Server" , "Сервер";
configs_endpoint: "Endpoint" , "Endpoint";
configs_message: "Message" , "Сообщение";
configs_pod: "Pod" , "Pod";
configs_node: "Node" , "Нода";
configs_rollout: "Rollout" , "Применение";
configs_ready: "Ready" , "Готов";
configs_not_ready: "Not ready" , "Не готов";
configs_phase: "Phase" , "Фаза";
configs_uptime: "Uptime" , "Аптайм";
configs_restarts: "Restarts" , "Рестарты";
configs_config_applied: "Config applied" , "Конфиг применён";
configs_reload_status: "Reload status" , "Статус reload";
configs_config_hash: "Config hash" , "Хэш конфига";
configs_status_applied: "applied" , "применён";
configs_status_starting: "starting" , "стартует";
configs_status_pending_restart: "pending restart" , "ждёт рестарт";
configs_status_error: "reload error" , "ошибка reload";
configs_status_unknown: "unknown" , "неизвестно";
configs_never: "n/a" , "н/д";
configs_servers: "Servers" , "Серверы";
+57 -2
View File
@@ -399,6 +399,12 @@ fn qcompress(data: &[u8]) -> Result<Vec<u8>, String> {
const CLIENT_SECRET_UPDATED_AT_ANNOTATION: &str =
"amnezia-fellow.hexor.cy/client-secret-updated-at-ms";
const CLIENT_SECRET_APPLIED_AT_ANNOTATION: &str =
"amnezia-fellow.hexor.cy/client-secret-applied-at-ms";
const CLIENT_SECRET_APPLIED_HASH_ANNOTATION: &str =
"amnezia-fellow.hexor.cy/client-secret-applied-hash";
const CLIENT_SECRET_RELOAD_STATUS_ANNOTATION: &str =
"amnezia-fellow.hexor.cy/client-secret-reload-status";
const AMNEZIAWG_POD_LABEL_SELECTOR: &str = "app=amneziawg";
#[derive(Debug, Clone, Serialize, JsonSchema)]
@@ -613,6 +619,9 @@ pub struct VpnPodRolloutStatus {
pub ready: bool,
pub restart_count: i32,
pub started_at_ms: Option<i64>,
pub config_applied_at_ms: Option<i64>,
pub config_applied_hash: Option<String>,
pub config_reload_status: Option<String>,
pub uptime_seconds: Option<u64>,
pub rollout_status: String,
pub message: Option<String>,
@@ -705,6 +714,7 @@ fn pod_rollout_status(
config_updated_at_ms: Option<i64>,
now_ms: i64,
) -> VpnPodRolloutStatus {
let annotations = pod.metadata.annotations.clone().unwrap_or_default();
let name = pod.metadata.name.unwrap_or_default();
let node_name = pod
.spec
@@ -728,11 +738,26 @@ fn pod_rollout_status(
let started_at_ms = status
.and_then(|status| status.start_time.as_ref())
.map(time_to_millis);
let config_applied_at_ms = annotation_i64(&annotations, CLIENT_SECRET_APPLIED_AT_ANNOTATION);
let config_applied_hash = annotations
.get(CLIENT_SECRET_APPLIED_HASH_ANNOTATION)
.filter(|value| !value.is_empty())
.cloned();
let config_reload_status = annotations
.get(CLIENT_SECRET_RELOAD_STATUS_ANNOTATION)
.filter(|value| !value.is_empty())
.cloned();
let uptime_seconds = started_at_ms.map(|started| {
let elapsed_ms = now_ms.saturating_sub(started);
(elapsed_ms / 1000) as u64
});
let rollout_status = rollout_status(config_updated_at_ms, started_at_ms, ready);
let rollout_status = rollout_status(
config_updated_at_ms,
started_at_ms,
config_applied_at_ms,
config_reload_status.as_deref(),
ready,
);
let message = status.and_then(|status| status.message.clone().or(status.reason.clone()));
VpnPodRolloutStatus {
@@ -744,6 +769,9 @@ fn pod_rollout_status(
ready,
restart_count,
started_at_ms,
config_applied_at_ms,
config_applied_hash,
config_reload_status,
uptime_seconds,
rollout_status,
message,
@@ -781,9 +809,16 @@ fn amneziawg_restart_count(status: &k8s_openapi::api::core::v1::PodStatus) -> i3
fn rollout_status(
config_updated_at_ms: Option<i64>,
started_at_ms: Option<i64>,
config_applied_at_ms: Option<i64>,
config_reload_status: Option<&str>,
ready: bool,
) -> String {
match (config_updated_at_ms, started_at_ms) {
if config_reload_status == Some("error") {
return "error".to_owned();
}
let effective_applied_at_ms = started_at_ms.into_iter().chain(config_applied_at_ms).max();
match (config_updated_at_ms, effective_applied_at_ms) {
(None, _) => "unknown".to_owned(),
(Some(_), None) => "pending_restart".to_owned(),
(Some(updated), Some(started)) if started >= updated && ready => "applied".to_owned(),
@@ -802,6 +837,10 @@ fn client_secret_updated_at_ms(secret: &Secret) -> Option<i64> {
.ok()
}
fn annotation_i64(annotations: &BTreeMap<String, String>, key: &str) -> Option<i64> {
annotations.get(key)?.parse().ok()
}
fn time_to_millis(time: &Time) -> i64 {
time.0.as_millisecond()
}
@@ -1037,6 +1076,22 @@ mod tests {
assert!(!overrides.contains_key("empty"));
}
#[test]
fn rollout_status_uses_hot_reload_annotations() {
assert_eq!(
rollout_status(Some(2_000), Some(1_000), Some(3_000), Some("applied"), true),
"applied"
);
assert_eq!(
rollout_status(Some(2_000), Some(1_000), None, Some("error"), true),
"error"
);
assert_eq!(
rollout_status(Some(2_000), Some(1_000), None, None, true),
"pending_restart"
);
}
#[test]
fn qcompress_uses_qt_wire_format() {
let payload = br#"{"description":"de-fsn1"}"#;
+68 -5
View File
@@ -45,24 +45,28 @@
.rollout { margin: 1rem 0 1.25rem; }
.rollout-meta { color: #53606d; font-size: .88rem; margin-bottom: .5rem; }
.rollout-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .55rem; }
.rollout-tile { position: relative; min-height: 86px; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .65rem .7rem; display: grid; gap: .45rem; align-content: space-between; }
.rollout-tile { position: relative; min-height: 86px; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .65rem .7rem; display: grid; gap: .45rem; align-content: space-between; cursor: pointer; }
.rollout-tile:focus { outline: 2px solid #7aa7c7; outline-offset: 2px; }
.rollout-tile::after { content: attr(data-tooltip); position: absolute; left: .25rem; top: calc(100% + .45rem); z-index: 5; width: max-content; max-width: min(380px, 80vw); padding: .55rem .65rem; border-radius: 4px; background: #17202a; color: #fff; font-size: .78rem; line-height: 1.35; white-space: pre-line; box-shadow: 0 8px 24px rgba(23, 32, 42, .22); opacity: 0; pointer-events: none; transform: translateY(-3px); transition: opacity .12s ease, transform .12s ease; }
.rollout-tile:hover::after, .rollout-tile:focus-within::after { opacity: 1; transform: translateY(0); }
.rollout-tile-applied { border-left-color: #2f8f4e; }
.rollout-tile-starting { border-left-color: #d39a00; }
.rollout-tile-pending_restart { border-left-color: #c83f31; }
.rollout-tile-error { border-left-color: #c83f31; }
.rollout-tile-unknown { border-left-color: #7b8793; }
.rollout-tile-head { display: flex; align-items: center; min-width: 0; gap: .45rem; }
.rollout-dot { width: .55rem; height: .55rem; border-radius: 999px; flex: 0 0 auto; background: #7b8793; }
.rollout-dot-applied { background: #2f8f4e; }
.rollout-dot-starting { background: #d39a00; }
.rollout-dot-pending_restart { background: #c83f31; }
.rollout-dot-error { background: #c83f31; }
.rollout-dot-unknown { background: #7b8793; }
.rollout-server { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; color: #1d252d; }
.rollout-badge { display: inline-flex; align-items: center; min-height: 24px; padding: .15rem .45rem; border-radius: 4px; font-size: .82rem; font-weight: 700; white-space: nowrap; }
.rollout-applied { background: #dcefe4; color: #175c31; }
.rollout-starting { background: #fff0c2; color: #6f4e00; }
.rollout-pending_restart { background: #ffe3df; color: #8b2116; }
.rollout-error { background: #ffe3df; color: #8b2116; }
.rollout-unknown { background: #e8ecef; color: #53606d; }
.rollout-foot { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: #53606d; font-size: .82rem; }
.server-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
@@ -80,6 +84,11 @@
.server-card { border: 1px solid #dde2e6; border-radius: 6px; padding: .7rem; display: grid; grid-template-columns: minmax(160px, 1fr) auto; gap: .65rem; align-items: center; }
.server-card-main { display: grid; gap: .25rem; min-width: 0; }
.server-card-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.detail-list { display: grid; gap: .55rem; overflow: auto; }
.detail-row { display: grid; grid-template-columns: minmax(120px, .45fr) minmax(0, 1fr); gap: .75rem; align-items: start; padding: .55rem 0; border-bottom: 1px solid #edf0f2; }
.detail-row:last-child { border-bottom: 0; }
.detail-label { color: #53606d; font-size: .84rem; }
.detail-value { color: #1d252d; overflow-wrap: anywhere; }
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #dde2e6; border-radius: 6px; background: #fff; }
.qr-box svg { width: min(280px, 100%); height: auto; display: block; }
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: .75rem; flex-wrap: wrap; }
@@ -98,6 +107,7 @@
.server-card { grid-template-columns: 1fr; }
.server-card-actions { justify-content: stretch; }
.server-card-actions button { flex: 1 1 100%; }
.detail-row { grid-template-columns: 1fr; gap: .2rem; }
.rollout-grid { grid-template-columns: 1fr; }
}
</style>
@@ -161,7 +171,7 @@
<template x-if="rollout && rollout.pods.length > 0">
<div class="rollout-grid">
<template x-for="pod in rollout.pods" :key="pod.name">
<div class="rollout-tile" :class="`rollout-tile-${pod.rollout_status}`" :data-tooltip="podTooltip(pod)" :title="podTooltip(pod)" tabindex="0">
<div class="rollout-tile" :class="`rollout-tile-${pod.rollout_status}`" :data-tooltip="podTooltip(pod)" :title="podTooltip(pod)" tabindex="0" role="button" @click="openRollout(pod)" @keydown.enter.prevent="openRollout(pod)" @keydown.space.prevent="openRollout(pod)">
<div class="rollout-tile-head">
<span class="rollout-dot" :class="`rollout-dot-${pod.rollout_status}`"></span>
<span class="rollout-server" x-text="pod.endpoint_name || pod.node_name || pod.name"></span>
@@ -230,6 +240,37 @@
</main>
</div>
<template x-if="rolloutModal">
<div class="modal-backdrop" @click.self="closeRollout()">
<div class="modal servers-modal">
<div class="modal-head">
<div class="modal-title">
<h3 x-text="podServerName(rolloutModal)"></h3>
<div class="modal-subtitle" x-text="rolloutModal.endpoint || rolloutModal.node_name || rolloutModal.name"></div>
</div>
<button class="secondary" @click="closeRollout()">{{ t.admin_close }}</button>
</div>
<div class="detail-list">
<div class="detail-row"><span class="detail-label">{{ t.configs_rollout }}</span><span class="detail-value"><span class="rollout-badge" :class="`rollout-${rolloutModal.rollout_status}`" x-text="rolloutStatusLabel(rolloutModal.rollout_status)"></span></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_pod }}</span><span class="detail-value"><code x-text="rolloutModal.name"></code></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_node }}</span><span class="detail-value"><code x-text="rolloutModal.node_name || '{{ t.configs_never }}'"></code></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_endpoint }}</span><span class="detail-value"><code x-text="rolloutModal.endpoint || '{{ t.configs_never }}'"></code></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_ready }}</span><span class="detail-value" x-text="rolloutModal.ready ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_phase }}</span><span class="detail-value" x-text="rolloutModal.phase || '{{ t.configs_status_unknown }}'"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_uptime }}</span><span class="detail-value" x-text="formatDuration(rolloutModal.uptime_seconds)"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_restarts }}</span><span class="detail-value" x-text="rolloutModal.restart_count"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_config_updated }}</span><span class="detail-value" x-text="formatTime(rollout && rollout.config_updated_at_ms)"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_config_applied }}</span><span class="detail-value" x-text="formatTime(rolloutModal.config_applied_at_ms)"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_reload_status }}</span><span class="detail-value" x-text="reloadStatusLabel(rolloutModal.config_reload_status)"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_config_hash }}</span><span class="detail-value"><code x-text="shortHash(rolloutModal.config_applied_hash) || '{{ t.configs_never }}'"></code></span></div>
<template x-if="rolloutModal.message">
<div class="detail-row"><span class="detail-label">{{ t.configs_message }}</span><span class="detail-value" x-text="rolloutModal.message"></span></div>
</template>
</div>
</div>
</div>
</template>
<template x-if="serverModal">
<div class="modal-backdrop" @click.self="closeServers()">
<div class="modal servers-modal">
@@ -294,6 +335,7 @@ function configsPage() {
rolloutBusy: false,
rolloutError: '',
rolloutTimer: null,
rolloutModal: null,
serverConfigs: {},
serverModal: null,
qrModal: null,
@@ -514,6 +556,12 @@ function configsPage() {
this.rolloutBusy = false;
}
},
openRollout(pod) {
this.rolloutModal = pod;
},
closeRollout() {
this.rolloutModal = null;
},
clientServers(client) {
return (this.serverConfigs[client.id] && this.serverConfigs[client.id].servers) || [];
},
@@ -542,6 +590,11 @@ function configsPage() {
if (!key || key.length <= 16) return key || '';
return `${key.slice(0, 8)}...${key.slice(-8)}`;
},
shortHash(hash) {
if (!hash) return '';
if (hash.length <= 16) return hash;
return `${hash.slice(0, 12)}...${hash.slice(-8)}`;
},
fileSlug(value) {
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
},
@@ -550,22 +603,32 @@ function configsPage() {
applied: '{{ t.configs_status_applied }}',
starting: '{{ t.configs_status_starting }}',
pending_restart: '{{ t.configs_status_pending_restart }}',
error: '{{ t.configs_status_error }}',
unknown: '{{ t.configs_status_unknown }}',
};
return labels[value] || value || '{{ t.configs_status_unknown }}';
},
reloadStatusLabel(value) {
if (!value) return '{{ t.configs_never }}';
return this.rolloutStatusLabel(value);
},
podServerName(pod) {
return pod.endpoint_name || pod.node_name || pod.name || '{{ t.configs_status_unknown }}';
},
podTooltip(pod) {
return [
`{{ t.configs_server }}: ${pod.endpoint_name || pod.node_name || pod.name}`,
`{{ t.configs_server }}: ${this.podServerName(pod)}`,
`{{ t.configs_pod }}: ${pod.name}`,
pod.endpoint ? `Endpoint: ${pod.endpoint}` : null,
pod.endpoint ? `{{ t.configs_endpoint }}: ${pod.endpoint}` : null,
`{{ t.configs_rollout }}: ${this.rolloutStatusLabel(pod.rollout_status)}`,
`{{ t.configs_ready }}: ${pod.ready ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'}`,
`{{ t.configs_phase }}: ${pod.phase || '{{ t.configs_status_unknown }}'}`,
`{{ t.configs_uptime }}: ${this.formatDuration(pod.uptime_seconds)}`,
`{{ t.configs_restarts }}: ${pod.restart_count}`,
`{{ t.configs_config_updated }}: ${this.formatTime(this.rollout && this.rollout.config_updated_at_ms)}`,
pod.message ? `Message: ${pod.message}` : null,
`{{ t.configs_config_applied }}: ${this.formatTime(pod.config_applied_at_ms)}`,
pod.config_reload_status ? `{{ t.configs_reload_status }}: ${this.reloadStatusLabel(pod.config_reload_status)}` : null,
pod.message ? `{{ t.configs_message }}: ${pod.message}` : null,
].filter(Boolean).join('\n');
},
formatTime(ms) {