v3 : publication robuste et visible dans trustinWallet

- Clé de publication ed25519 générée au démarrage dans le volume ; sa partie
  publique et l'état de la file d'envoi s'affichent dans /acces.
- Publication initiale à l'instanciation d'un document seedé publiable.
- Reprise au démarrage : chaque document publié repasse une fois dans la file ;
  la date publiée est celle du dernier changement, un document inchangé ne
  fait pas de commit (testé contre un dépôt nu).
- Le travailleur fait un premier passage sans attendre ; journaux sans codes
  couleur hors terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 03:50:22 +02:00
co-authored by Claude Opus 5.5
parent 7a022dd35e
commit c2669a3d0b
10 changed files with 210 additions and 15 deletions
+5
View File
@@ -41,7 +41,12 @@ pub async fn overview(State(state): State<AppState>, User(user): User) -> AppRes
.bind(util::now()) .bind(util::now())
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .await?;
let publication = json!({
"remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()),
"publicKey": crate::publish::public_key(&state),
});
Ok(Json(json!({ Ok(Json(json!({
"publication": publication,
"groups": groups.iter().map(|(slug, name, policy, hidden)| json!({ "groups": groups.iter().map(|(slug, name, policy, hidden)| json!({
"slug": slug, "name": name, "policy": policy, "hidden": hidden, "slug": slug, "name": name, "policy": policy, "hidden": hidden,
"members": members.iter().filter(|m| &m.group_slug == slug) "members": members.iter().filter(|m| &m.group_slug == slug)
+5
View File
@@ -160,6 +160,7 @@ pub async fn run() -> anyhow::Result<()> {
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,sqlx=warn,tower_http=warn".into()), .unwrap_or_else(|_| "info,sqlx=warn,tower_http=warn".into()),
) )
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout()))
.init(); .init();
let cfg = Config::from_env(); let cfg = Config::from_env();
tokio::fs::create_dir_all(&cfg.data_dir).await?; tokio::fs::create_dir_all(&cfg.data_dir).await?;
@@ -169,6 +170,10 @@ pub async fn run() -> anyhow::Result<()> {
seed::ensure(&state).await?; seed::ensure(&state).await?;
auth::bootstrap(&state).await?; auth::bootstrap(&state).await?;
if let Err(e) = publish::ensure_key(&state).await {
tracing::warn!(error = %e, "publication key");
}
publish::resync(&state).await?;
tokio::spawn(wot::refresher(state.clone())); tokio::spawn(wot::refresher(state.clone()));
tokio::spawn(publish::worker(state.clone())); tokio::spawn(publish::worker(state.clone()));
tokio::spawn(adoption::sweeper(state.clone())); tokio::spawn(adoption::sweeper(state.clone()));
+8 -1
View File
@@ -789,7 +789,14 @@ mod tests {
use super::*; use super::*;
fn unit(id: &str, position: f64) -> UnitRow { fn unit(id: &str, position: f64) -> UnitRow {
UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "standard".into(), in_force_id: None } UnitRow {
id: id.into(),
code: id.into(),
section: None,
position,
inertia: "standard".into(),
in_force_id: None,
}
} }
#[test] #[test]
+75 -8
View File
@@ -19,18 +19,46 @@ use tokio::process::Command;
const IDLE: Duration = Duration::from_secs(60); const IDLE: Duration = Duration::from_secs(60);
const MAX_BACKOFF_SECS: i64 = 6 * 3600; const MAX_BACKOFF_SECS: i64 = 6 * 3600;
/// The deploy key of the server: generated on first start, never leaves the
/// data volume. Its public half is shown to access administrators, who add it
/// as a deploy key (write) on the texts repository.
pub async fn ensure_key(state: &AppState) -> anyhow::Result<()> {
let Some(publish) = &state.cfg.publish else { return Ok(()) };
if publish.key.exists() {
return Ok(());
}
if let Some(dir) = publish.key.parent() {
tokio::fs::create_dir_all(dir).await?;
}
let out = Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-C", "libredecision-publication", "-f"])
.arg(&publish.key)
.output()
.await?;
anyhow::ensure!(out.status.success(), "ssh-keygen: {}", String::from_utf8_lossy(&out.stderr).trim());
tracing::info!(key = %publish.key.display(), "publication key generated");
Ok(())
}
pub fn public_key(state: &AppState) -> Option<String> {
let publish = state.cfg.publish.as_ref()?;
let mut path = publish.key.clone().into_os_string();
path.push(".pub");
std::fs::read_to_string(path).ok().map(|k| k.trim().to_owned())
}
pub async fn worker(state: AppState) { pub async fn worker(state: AppState) {
loop { loop {
// A pass first: what was queued before the start goes out without waiting.
if state.cfg.publish.is_some()
&& let Err(e) = pass(&state).await
{
tracing::warn!(error = %e, "publication pass");
}
tokio::select! { tokio::select! {
() = state.publish_wake.notified() => {} () = state.publish_wake.notified() => {}
() = tokio::time::sleep(IDLE) => {} () = tokio::time::sleep(IDLE) => {}
} }
if state.cfg.publish.is_none() {
continue;
}
if let Err(e) = pass(&state).await {
tracing::warn!(error = %e, "publication pass");
}
} }
} }
@@ -41,7 +69,8 @@ struct Due {
attempts: i64, attempts: i64,
} }
async fn pass(state: &AppState) -> anyhow::Result<()> { /// One publication pass, now (tests, and the worker).
pub async fn pass(state: &AppState) -> anyhow::Result<()> {
let due = sqlx::query_as::<_, Due>( let due = sqlx::query_as::<_, Due>(
"SELECT id, doc_id, attempts FROM outbox WHERE done_at IS NULL AND next_at <= ? ORDER BY id", "SELECT id, doc_id, attempts FROM outbox WHERE done_at IS NULL AND next_at <= ? ORDER BY id",
) )
@@ -177,6 +206,44 @@ vigueur est republié ici. Les applications clientes le lisent pour se mettre à
- `document.json` : le même contenu, structuré, avec l'état du vote de chaque article.\n\n\ - `document.json` : le même contenu, structuré, avec l'état du vote de chaque article.\n\n\
Ne pas modifier à la main : le prochain passage de libreDecision réécrit ces fichiers.\n"; Ne pas modifier à la main : le prochain passage de libreDecision réécrit ces fichiers.\n";
/// The date of the last change to what is published (an entry into force, a
/// typo fix, a translation) — never "now", so that publishing an unchanged
/// document changes nothing and commits nothing.
async fn last_change(state: &AppState, doc_id: &str) -> anyhow::Result<String> {
let at: Option<String> = sqlx::query_scalar(
"SELECT MAX(d) FROM (
SELECT MAX(at) AS d FROM adoptions WHERE doc_id = ?1
UNION ALL SELECT MAX(p.updated_at) FROM proposals p JOIN units u ON u.in_force_id = p.id WHERE u.doc_id = ?1
UNION ALL SELECT MAX(t.updated_at) FROM translations t JOIN units u ON u.in_force_id = t.proposal_id WHERE u.doc_id = ?1
UNION ALL SELECT created_at FROM documents WHERE id = ?1)",
)
.bind(doc_id)
.fetch_one(&state.db)
.await?;
Ok(at.unwrap_or_else(util::now))
}
/// On start, every published document is queued once more: a change that
/// entered into force just before a restart, or a new rendering format, reaches
/// the repository. Unchanged documents produce no commit.
pub async fn resync(state: &AppState) -> anyhow::Result<()> {
if state.cfg.publish.is_none() {
return Ok(());
}
let now = util::now();
sqlx::query(
"INSERT INTO outbox (doc_id, reason, created_at, next_at)
SELECT d.id, 'reprise au démarrage', ?1, ?1 FROM documents d
WHERE d.publish_path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM outbox o WHERE o.doc_id = d.id AND o.done_at IS NULL)",
)
.bind(&now)
.execute(&state.db)
.await?;
state.publish_wake.notify_one();
Ok(())
}
/// Markdown per language + JSON. Only the units in force, in order. /// Markdown per language + JSON. Only the units in force, in order.
pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(String, String)>> { pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(String, String)>> {
let titles = util::parse_lang(&st.doc.title); let titles = util::parse_lang(&st.doc.title);
@@ -191,7 +258,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
.collect(); .collect();
let refs: Vec<&docs::ProposalRow> = in_force.iter().map(|(_, p)| *p).collect(); let refs: Vec<&docs::ProposalRow> = in_force.iter().map(|(_, p)| *p).collect();
let tr = docs::translations_for(state, &refs).await.map_err(|e| anyhow::anyhow!("{e}"))?; let tr = docs::translations_for(state, &refs).await.map_err(|e| anyhow::anyhow!("{e}"))?;
let updated = util::now(); let updated = last_change(state, &st.doc.id).await?;
let source_url = format!("{}/g/{}/docs/{}", state.cfg.public_url, st.group.slug, st.doc.slug); let source_url = format!("{}/g/{}/docs/{}", state.cfg.public_url, st.group.slug, st.doc.slug);
let mut files = Vec::new(); let mut files = Vec::new();
for lang in util::LANGS { for lang in util::LANGS {
+10
View File
@@ -204,6 +204,16 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
} }
} }
} }
if inst.publish_path.is_some() {
sqlx::query(
"INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)",
)
.bind(&doc_id)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
}
tx.commit().await?; tx.commit().await?;
Ok(()) Ok(())
} }
+67 -1
View File
@@ -151,7 +151,11 @@ async fn adoption_in_the_test_group_and_publication_queued_only_where_published(
assert_eq!(e3["inForce"]["id"], p.as_str()); assert_eq!(e3["inForce"]["id"], p.as_str());
assert!(e3["inForce"]["enteredAt"].is_string()); assert!(e3["inForce"]["enteredAt"].is_string());
// The test group publishes nothing. // The test group publishes nothing.
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox").fetch_one(&state.db).await.expect("count"); let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE doc_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(n, 0); assert_eq!(n, 0);
} }
@@ -386,3 +390,65 @@ async fn feedback_reaches_administrators() {
let (_, v) = admin.call("GET", "/api/access/feedback", None).await; let (_, v) = admin.call("GET", "/api/access/feedback", None).await;
assert_eq!(v["feedback"][0]["body"], "Le bouton est trop discret."); assert_eq!(v["feedback"][0]["body"], "Le bouton est trop discret.");
} }
#[tokio::test]
async fn publication_writes_the_texts_in_force_to_the_repository() {
use std::process::Command;
let dir = std::env::temp_dir().join(format!("ld-pub-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let remote = dir.join("textes.git");
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
let mut cfg = Config::for_tests();
cfg.data_dir = dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: remote.display().to_string(),
branch: "main".into(),
key: dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
});
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(cfg, db).await.expect("state");
seed::ensure(&state).await.expect("seed");
ld_server::publish::pass(&state).await.expect("pass");
let show = |path: &str| {
let out = Command::new("git")
.arg("-C")
.arg(&remote)
.args(["show", &format!("main:{path}")])
.output()
.expect("git show");
String::from_utf8(out.stdout).expect("utf8")
};
let fr = show("monnaie-libre/acte-engagement-certification/fr.md");
assert!(fr.contains("# Acte d'engagement Certification"), "{fr}");
assert!(fr.contains("## Préambule") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
assert!(!fr.contains("Connaissance suffisante"), "only the texts in force are published");
assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale"));
let json: Value =
serde_json::from_str(&show("monnaie-libre/acte-engagement-certification/document.json")).expect("json");
assert_eq!(json["units"].as_array().expect("units").len(), 3);
assert_eq!(json["units"][0]["status"], "seed-minimal");
assert!(show("README.md").contains("libreDecision"));
let done: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(done, 0);
// A restart queues the document again; nothing changed, so nothing is committed.
let head = || {
let out = Command::new("git").arg("-C").arg(&remote).args(["rev-parse", "main"]).output().expect("rev-parse");
String::from_utf8(out.stdout).expect("utf8")
};
let before = head();
ld_server::publish::resync(&state).await.expect("resync");
ld_server::publish::pass(&state).await.expect("pass");
assert_eq!(head(), before, "an unchanged document produces no commit");
let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(pending, 0);
let _ = std::fs::remove_dir_all(&dir);
}
+4
View File
@@ -286,6 +286,10 @@ export const en: Messages = {
'access.feedback.none': 'No feedback.', 'access.feedback.none': 'No feedback.',
'access.feedback.seen': 'Seen', 'access.feedback.seen': 'Seen',
'access.feedback.done': 'Handled', 'access.feedback.done': 'Handled',
'access.publication': "Publishing the texts",
'access.publication.remote': "Repository: {remote}",
'access.publication.key': "The server's deploy key. Add it with write access on this repository (Settings › Repository › Deploy keys): until then, publications wait.",
'access.publication.none': "Publishing is not configured on this server.",
'access.denied': 'Access management is for trustinWallet administrators only.', 'access.denied': 'Access management is for trustinWallet administrators only.',
'soon.title': 'Coming soon', 'soon.title': 'Coming soon',
+4
View File
@@ -286,6 +286,10 @@ export const es: Messages = {
'access.feedback.none': 'Ningún comentario.', 'access.feedback.none': 'Ningún comentario.',
'access.feedback.seen': 'Visto', 'access.feedback.seen': 'Visto',
'access.feedback.done': 'Atendido', 'access.feedback.done': 'Atendido',
'access.publication': "Publicación de los textos",
'access.publication.remote': "Repositorio: {remote}",
'access.publication.key': "Clave de despliegue del servidor. Añádela con permiso de escritura en este repositorio (Configuración › Repositorio › Claves de despliegue): sin ella, las publicaciones esperan.",
'access.publication.none': "La publicación no está configurada en este servidor.",
'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.', 'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.',
'soon.title': 'Próximamente', 'soon.title': 'Próximamente',
+4
View File
@@ -289,6 +289,10 @@ export const fr = {
'access.feedback.none': 'Aucun retour.', 'access.feedback.none': 'Aucun retour.',
'access.feedback.seen': 'Vu', 'access.feedback.seen': 'Vu',
'access.feedback.done': 'Traité', 'access.feedback.done': 'Traité',
'access.publication': "Publication des textes",
'access.publication.remote': "Dépôt : {remote}",
'access.publication.key': "Clé de déploiement du serveur. Ajoute-la avec le droit d'écriture sur ce dépôt (Paramètres › Dépôt › Clés de déploiement) : sans elle, les publications attendent.",
'access.publication.none': "La publication n'est pas configurée sur ce serveur.",
'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.', 'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.',
'soon.title': 'À venir', 'soon.title': 'À venir',
+23
View File
@@ -8,6 +8,7 @@
import { toasts } from '$lib/toast.svelte'; import { toasts } from '$lib/toast.svelte';
interface Overview { interface Overview {
publication: { remote: string | null; publicKey: string | null };
groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[]; groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null }[]; identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null }[];
invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[]; invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[];
@@ -153,6 +154,21 @@
</ul> </ul>
</section> </section>
<section>
<h2 class="eyebrow">{t('access.publication')}</h2>
<div class="card block">
{#if data.value.publication.remote}
<p class="small">{t('access.publication.remote', { remote: data.value.publication.remote })}</p>
{#if data.value.publication.publicKey}
<p class="small muted">{t('access.publication.key')}</p>
<code class="key">{data.value.publication.publicKey}</code>
{/if}
{:else}
<p class="small muted">{t('access.publication.none')}</p>
{/if}
</div>
</section>
<section> <section>
<h2 class="eyebrow">{t('access.feedback')}</h2> <h2 class="eyebrow">{t('access.feedback')}</h2>
{#each fb.value?.feedback ?? [] as f (f.id)} {#each fb.value?.feedback ?? [] as f (f.id)}
@@ -258,6 +274,13 @@
color: var(--ochre); color: var(--ochre);
font-weight: 700; font-weight: 700;
} }
.key {
word-break: break-all;
font-size: 0.8rem;
background: var(--surface-2);
padding: 0.6rem 0.8rem;
border-radius: var(--r-input);
}
.fb .body { .fb .body {
white-space: pre-wrap; white-space: pre-wrap;
} }