Finish M2: run lock, systemd units, discover; drop the web UI
Designing the configuration UI in full made the case against building it. Its audience would be people who find TOML hard, but with bring-your-own OAuth client settled, every user must first create a Google Cloud project, configure a consent screen and put a secret in a keyring — a far higher bar than editing thirty lines of config. Anyone who clears it can edit the file; anyone who cannot never reaches the file. Against that stood three dependencies, five modules, an auth.rs refactor and a security surface guarding something that reads the config and touches the keyring. `doctor` and `status` had already absorbed most of what it was for. The reasoning is recorded in TODO.md and SPECS.md rather than left as an apparent oversight. The run lock is not a UI feature and closes a gap that already existed: nothing stopped a timer firing into a hand-run cycle, and two cycles interleaving writes over the same vdirs is what the design otherwise avoids. flock is used rather than a pid file because the kernel releases it however the process ends, so a crash cannot leave a lock to clear by hand — which also means a lock we failed to take is held by a live process, so the pid in it is worth reporting. The one idea worth keeping from the UI design was collection discovery, which needed no web layer. `calcalist discover` prints a ready-to-paste endpoint block per calendar a server offers, removing the most error-prone field in the config. pimsync's discovery output is undocumented, so the format was established against a real server first. Two things it teaches: everything arrives on stdout including failures, and a pair has two storages, so pimsync reports the scratch vdir's contents too — parsing anchors on the heading naming the server, or a probe directory's leftovers would be offered as the user's calendars. Verified against Posteo as well as Radicale: all four calendars found, the first matching the URL already configured. Also fixes a real defect in the test harness rather than its symptom. Ports were chosen by binding one and letting go, so two tests could pick the same number — and the loser's readiness check then succeeded against the winner's server, silently sharing it. Startup now confirms the child we spawned is the one alive, retries on another port if not, and waits for a real HTTP response rather than an open socket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
47ad8b4c47
commit
ef6482ed62
13 changed files with 808 additions and 92 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -117,6 +117,7 @@ dependencies = [
|
|||
"blake3",
|
||||
"clap",
|
||||
"jiff",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ publish = false
|
|||
blake3 = "1.8"
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
jiff = "0.2"
|
||||
rustix = { version = "1.1", features = ["fs"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.11"
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -323,6 +323,7 @@ calcalist sync [--dry-run] [--force]
|
|||
calcalist status
|
||||
calcalist doctor
|
||||
calcalist prune [--force]
|
||||
calcalist discover <url> --username <name> [--secret-command <cmd>]
|
||||
calcalist google login <endpoint>
|
||||
calcalist aggregate retarget <id> --to <endpoint> [--keep-old | --purge-old]
|
||||
```
|
||||
|
|
@ -334,6 +335,10 @@ calcalist aggregate retarget <id> --to <endpoint> [--keep-old | --purge-old]
|
|||
- **`status`** lists endpoints and aggregates, how many events each aggregate is
|
||||
mirroring, and the routing markers that would work.
|
||||
- **`doctor`** checks the environment and configuration. Run it first.
|
||||
- **`discover`** asks a CalDAV server which calendars it has and prints an
|
||||
`[[endpoint]]` block for each, ready to paste. Give it the account or
|
||||
principal URL rather than a single calendar. This is the easy way to get the
|
||||
`url` field right.
|
||||
- **`prune`** reports local mirrors belonging to endpoints your configuration no
|
||||
longer names. It only lists them until you pass `--force`, since an endpoint
|
||||
may just have been renamed.
|
||||
|
|
@ -346,7 +351,7 @@ calcalist aggregate retarget <id> --to <endpoint> [--keep-old | --purge-old]
|
|||
events calcalist put there.
|
||||
|
||||
Exit codes: `0` all well, `1` something failed or an endpoint could not be
|
||||
reached, `2` not implemented.
|
||||
reached.
|
||||
|
||||
If a Google endpoint cannot be reached — a lapsed token, no network — the cycle
|
||||
does not stop. The endpoint is named, only the aggregates that depend on it
|
||||
|
|
@ -372,48 +377,37 @@ configuration between machines; leave this behind.
|
|||
|
||||
## Running it regularly
|
||||
|
||||
There are no packaged units yet. A systemd user timer does the job:
|
||||
|
||||
`~/.config/systemd/user/calcalist.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Synchronise calendars with calcalist
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/.local/bin/calcalist sync
|
||||
```
|
||||
|
||||
`~/.config/systemd/user/calcalist.timer`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Synchronise calendars every 15 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2m
|
||||
OnUnitActiveSec=15m
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
Units ship in [systemd/](systemd/):
|
||||
|
||||
```sh
|
||||
install -Dm644 systemd/calcalist.service ~/.config/systemd/user/calcalist.service
|
||||
install -Dm644 systemd/calcalist.timer ~/.config/systemd/user/calcalist.timer
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now calcalist.timer
|
||||
```
|
||||
|
||||
A cycle is one-shot, and systemd will not start the service again while a run is
|
||||
still going, so a timer is all the scheduling needed. calcalist takes no lock of
|
||||
its own, though, so avoid running `calcalist sync` by hand while the timer might
|
||||
fire. If your secrets come from a keyring that needs an unlocked session, the
|
||||
The service expects the binary at `~/.local/bin/calcalist`; edit `ExecStart` if
|
||||
yours is elsewhere. The timer runs every 15 minutes with a randomised delay of
|
||||
up to two minutes, which spreads requests instead of every installation calling
|
||||
Google on the quarter hour — its quota is enforced per minute, per project.
|
||||
|
||||
A cycle that could not reach an endpoint exits non-zero deliberately, so a
|
||||
lapsed token surfaces as a failed unit in `systemctl --user status calcalist`
|
||||
rather than passing unnoticed. `journalctl --user -u calcalist` has the report.
|
||||
|
||||
Only one cycle runs at a time: calcalist takes a lock on its state directory, so
|
||||
a timer firing while you are running `calcalist sync` by hand is refused with a
|
||||
message naming the process that holds it, rather than the two interleaving their
|
||||
writes. If your secrets come from a keyring that needs an unlocked session, the
|
||||
timer will only work while you are logged in.
|
||||
|
||||
## Not yet
|
||||
|
||||
- **The web configuration interface.** `calcalist serve` exits 2.
|
||||
- **Packaged systemd units**, as above.
|
||||
- **A failing `pimsync sync` stops the whole CalDAV leg.** Google endpoints are
|
||||
isolated from each other, but pimsync is a single process covering every
|
||||
CalDAV and feed pair, and a failure does not say which pair it belongs to.
|
||||
|
||||
There is deliberately no web interface. It was designed and then dropped: the
|
||||
Google OAuth setup, which no interface can remove, is a far higher bar than
|
||||
editing this file, so a UI would have served an audience that never gets as far
|
||||
as the config. `doctor`, `status` and `discover` cover what it was for.
|
||||
|
|
|
|||
4
SPECS.md
4
SPECS.md
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
Small Rust based commandline tool with utility systemd service file that can aggregate and sync events between CalDAV/Google Calendar/iCal. Users who have a Google Calendar can create a new calendar that syncs events from several CalDAV calendars. CalDAV users can sync several Google Calendars into one calendar. This should be doable both ways, so the aggregating calendar needs to be able to distinguish where its events came from and also have a way to create new events that get synced to the correct source. In addition iCal feeds can also be aggregated but they remain one way only.
|
||||
|
||||
Configuration via a simple web interface. Configuration is saved in a portable toml file that can be easily migrated.
|
||||
Configuration is saved in a portable toml file that can be easily migrated.
|
||||
|
||||
The web interface this originally called for was designed and then dropped. Setting up Google's OAuth client — a Cloud project, a consent screen, a secret in a keyring — is a far higher bar than editing thirty lines of TOML, and no interface can remove it, so a configuration UI would have served an audience that never reaches the configuration. `calcalist doctor` reports every problem in one pass, `calcalist status` names the routing markers an event can carry, and `calcalist discover` prints ready-to-paste endpoint blocks for a server's calendars — which is what the interface was actually for.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
|
|||
44
TODO.md
44
TODO.md
|
|
@ -131,15 +131,45 @@ calendar:
|
|||
- [x] A retired endpoint's mirror is reported by `prune` and removed under
|
||||
`--force`.
|
||||
|
||||
## M2 — packaging (done)
|
||||
|
||||
- [x] Run lock (`lock.rs`) — `flock` on the state directory, held by `sync`,
|
||||
`aggregate retarget` and `prune --force`. Not a UI feature: nothing
|
||||
previously stopped a timer firing into a hand-run cycle, and two cycles
|
||||
interleaving writes over the same vdirs is what the design otherwise
|
||||
avoids. The kernel releases it however the process ends, so a crash
|
||||
cannot leave a lock to clear by hand; the refusal names the holding pid.
|
||||
- [x] systemd user units in `systemd/` — `calcalist.service` (oneshot) and
|
||||
`calcalist.timer`, with `RandomizedDelaySec` so installations do not all
|
||||
call Google on the quarter hour, and no filesystem or IPC sandboxing
|
||||
because the secret commands need the session keyring over D-Bus.
|
||||
- [x] `calcalist discover` — asks a CalDAV server which calendars it has and
|
||||
prints a ready-to-paste `[[endpoint]]` block for each. The `url` field is
|
||||
the most error-prone thing in the config, and providers rarely show it.
|
||||
- [x] `serve` removed from the CLI, and with it the last `unimplemented`
|
||||
command, so every command the binary advertises now does something.
|
||||
|
||||
### The web interface, dropped
|
||||
|
||||
Specified from the start and designed in full before being dropped. The
|
||||
reasoning, so it is not rediscovered as an oversight:
|
||||
|
||||
- Its audience would be people who find TOML hard. But with bring-your-own
|
||||
OAuth client settled, every user must first create a Google Cloud project,
|
||||
configure a consent screen and put a secret in a keyring — a far higher bar
|
||||
than editing thirty lines of config. Anyone who clears it can edit the file;
|
||||
anyone who cannot never reaches the file.
|
||||
- The cost was three dependencies, five modules, an `auth.rs` refactor and a
|
||||
security surface (token, `Host` validation, CSRF, secrets through subprocess
|
||||
stdin) guarding something that reads the config and touches the keyring —
|
||||
against SPECS.md's own "no speculative features or dependencies".
|
||||
- `doctor`, `status` and `discover` had already absorbed what it was for.
|
||||
|
||||
The one idea worth keeping from the design was collection discovery, which
|
||||
needed no web layer at all.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- [ ] A `pimsync sync` that fails takes the whole CalDAV leg with it. Unlike the
|
||||
Google side this cannot be narrowed: pimsync is one process covering every
|
||||
pair, so a failure does not say which pair it belongs to.
|
||||
|
||||
## M2 — interface and packaging
|
||||
|
||||
- [ ] axum configuration UI, bound to 127.0.0.1
|
||||
- [ ] Trigger `google login` from the UI (the loopback handler itself already exists
|
||||
in `google/auth.rs`)
|
||||
- [ ] systemd user units: `calcalist.service` (oneshot) and `calcalist.timer`
|
||||
|
|
|
|||
15
src/cli.rs
15
src/cli.rs
|
|
@ -32,10 +32,17 @@ pub enum Command {
|
|||
},
|
||||
/// Show endpoints, aggregates and the last sync result
|
||||
Status,
|
||||
/// Serve the configuration web interface on localhost
|
||||
Serve {
|
||||
#[arg(long, default_value_t = 8723)]
|
||||
port: u16,
|
||||
/// List the calendars a CalDAV server offers, as endpoint blocks to paste
|
||||
Discover {
|
||||
/// The account or principal URL to enumerate, not a single calendar
|
||||
url: String,
|
||||
/// Username to authenticate as
|
||||
#[arg(long)]
|
||||
username: String,
|
||||
/// Command printing the password, exactly as it would appear in the
|
||||
/// configuration. Secrets are never passed as arguments.
|
||||
#[arg(long, value_name = "COMMAND")]
|
||||
secret_command: Option<String>,
|
||||
},
|
||||
/// Google account operations
|
||||
Google {
|
||||
|
|
|
|||
144
src/lock.rs
Normal file
144
src/lock.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
//! A lock held for the duration of anything that writes.
|
||||
//!
|
||||
//! A cycle rewrites vdirs and the state file, and two of them running at once
|
||||
//! would interleave those writes — the timer firing while someone runs `sync` by
|
||||
//! hand is the ordinary way that happens. `flock` is used rather than a pid file
|
||||
//! because the kernel releases it when the process ends, however it ends, so a
|
||||
//! crash cannot leave a lock behind for someone to clear by hand.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rustix::fs::{FlockOperation, flock};
|
||||
use thiserror::Error;
|
||||
|
||||
const FILE_NAME: &str = "lock";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LockError {
|
||||
#[error("could not open {path}: {source}")]
|
||||
Open {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error(
|
||||
"another calcalist is already running (pid {pid}); it holds {path}. \
|
||||
Wait for it to finish rather than running two cycles at once."
|
||||
)]
|
||||
Held { pid: String, path: PathBuf },
|
||||
#[error("could not lock {path}: {source}")]
|
||||
Failed {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// An exclusive claim on the state directory, released when dropped.
|
||||
///
|
||||
/// The lock lives in the open file descriptor, so there is nothing to undo: it
|
||||
/// goes when the `File` closes. The file itself is deliberately left behind —
|
||||
/// unlinking it would let a waiter take a lock on an inode nobody else can see.
|
||||
#[derive(Debug)]
|
||||
pub struct Lock {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
pub fn path(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(FILE_NAME)
|
||||
}
|
||||
|
||||
/// Claims the state directory, or reports who already has it.
|
||||
pub fn acquire(state_dir: &Path) -> Result<Lock, LockError> {
|
||||
let path = path(state_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|source| LockError::Open {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&path)
|
||||
.map_err(|source| LockError::Open {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
if let Err(error) = flock(&file, FlockOperation::NonBlockingLockExclusive) {
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock {
|
||||
// Whoever holds it is alive: the kernel would have released the lock
|
||||
// otherwise. So the pid in the file is worth reporting.
|
||||
return Err(LockError::Held {
|
||||
pid: holder(&mut file),
|
||||
path,
|
||||
});
|
||||
}
|
||||
return Err(LockError::Failed {
|
||||
path,
|
||||
source: error.into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Record who holds it, for the benefit of whoever is refused next.
|
||||
let _ = file.set_len(0);
|
||||
let _ = file.seek(SeekFrom::Start(0));
|
||||
let _ = write!(file, "{}", std::process::id());
|
||||
let _ = file.flush();
|
||||
Ok(Lock { _file: file })
|
||||
}
|
||||
|
||||
/// The pid recorded in the lock file, or a placeholder when it says nothing.
|
||||
fn holder(file: &mut File) -> String {
|
||||
let mut recorded = String::new();
|
||||
if file.seek(SeekFrom::Start(0)).is_ok()
|
||||
&& file.read_to_string(&mut recorded).is_ok()
|
||||
&& !recorded.trim().is_empty()
|
||||
{
|
||||
return recorded.trim().to_string();
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_second_claim_is_refused_while_the_first_is_held() {
|
||||
let dir = tempfile::tempdir().expect("temp");
|
||||
let first = acquire(dir.path()).expect("the first claim should succeed");
|
||||
|
||||
let error = acquire(dir.path()).expect_err("the second should be refused");
|
||||
assert!(matches!(error, LockError::Held { .. }), "{error:?}");
|
||||
// The refusal has to name the holder, or it is not actionable.
|
||||
assert!(
|
||||
error.to_string().contains(&std::process::id().to_string()),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
drop(first);
|
||||
acquire(dir.path()).expect("the lock should be free again");
|
||||
}
|
||||
|
||||
/// The file has to survive, so the next run locks the same inode.
|
||||
#[test]
|
||||
fn the_lock_file_outlives_the_lock() {
|
||||
let dir = tempfile::tempdir().expect("temp");
|
||||
drop(acquire(dir.path()).expect("claim"));
|
||||
assert!(path(dir.path()).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_state_directory_is_created() {
|
||||
let dir = tempfile::tempdir().expect("temp");
|
||||
let nested = dir.path().join("not").join("there");
|
||||
acquire(&nested).expect("claim");
|
||||
assert!(path(&nested).exists());
|
||||
}
|
||||
}
|
||||
102
src/main.rs
102
src/main.rs
|
|
@ -5,6 +5,7 @@ mod config;
|
|||
mod doctor;
|
||||
mod google;
|
||||
mod ical;
|
||||
mod lock;
|
||||
mod mirror;
|
||||
mod paths;
|
||||
mod pimsync;
|
||||
|
|
@ -43,6 +44,15 @@ fn main() -> ExitCode {
|
|||
command: Command::Prune { force },
|
||||
..
|
||||
} => run_prune(cli, *force),
|
||||
Cli {
|
||||
command:
|
||||
Command::Discover {
|
||||
url,
|
||||
username,
|
||||
secret_command,
|
||||
},
|
||||
..
|
||||
} => run_discover(url, username, secret_command.as_deref()),
|
||||
cli @ Cli {
|
||||
command:
|
||||
Command::Google {
|
||||
|
|
@ -60,7 +70,6 @@ fn main() -> ExitCode {
|
|||
},
|
||||
..
|
||||
} => run_retarget(cli, id, to, *purge_old),
|
||||
Cli { command, .. } => unimplemented(command),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +94,12 @@ fn run_sync(cli: &Cli, dry_run: bool, force: bool) -> ExitCode {
|
|||
Ok(dir) => dir,
|
||||
Err(error) => return fail(&error),
|
||||
};
|
||||
// Held for the whole cycle: a timer firing mid-run would otherwise interleave
|
||||
// its writes with this one over the same vdirs.
|
||||
let _lock = match lock::acquire(&state_dir) {
|
||||
Ok(lock) => lock,
|
||||
Err(error) => return fail(&error),
|
||||
};
|
||||
match sync::run(&config, &state_dir, dry_run, force) {
|
||||
Ok(report) => {
|
||||
print_report(&report);
|
||||
|
|
@ -101,6 +116,62 @@ fn run_sync(cli: &Cli, dry_run: bool, force: bool) -> ExitCode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Lists a server's calendars as configuration ready to paste.
|
||||
///
|
||||
/// The URL of a CalDAV calendar is the most error-prone thing in the config: it
|
||||
/// must name the collection exactly, and providers rarely show it. Asking the
|
||||
/// server is more reliable than reading a web interface.
|
||||
fn run_discover(url: &str, username: &str, secret_command: Option<&str>) -> ExitCode {
|
||||
let state_dir = match paths::state_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(error) => return fail(&error),
|
||||
};
|
||||
let found = match pimsync::discover(&state_dir, url, username, secret_command) {
|
||||
Ok(found) => found,
|
||||
Err(error) => return fail(&error),
|
||||
};
|
||||
if found.is_empty() {
|
||||
eprintln!("calcalist: {url} offered no calendars");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let base = url.split_once("://").and_then(|(scheme, rest)| {
|
||||
rest.split_once('/')
|
||||
.map(|(authority, _)| format!("{scheme}://{authority}"))
|
||||
});
|
||||
println!(
|
||||
"# {} calendar(s) found. Paste what you want into calcalist.toml.",
|
||||
found.len()
|
||||
);
|
||||
// Some servers name calendars with opaque ids — Posteo's look like
|
||||
// `cydhlw` — and the id is what an event carries after `@` to be routed
|
||||
// here, so it is worth renaming before it ends up in anyone's notes.
|
||||
println!("# Ids come from the URL. Rename them: an id is what you type after `@`.");
|
||||
for collection in &found {
|
||||
let full = match &base {
|
||||
Some(base) => format!("{base}{}", collection.href),
|
||||
// pimsync reported something that is not a path; show it as it came
|
||||
// rather than gluing it onto an origin and inventing a URL.
|
||||
None => collection.href.clone(),
|
||||
};
|
||||
println!("\n[[endpoint]]");
|
||||
println!("id = {}", quoted(&collection.id));
|
||||
println!("type = \"caldav\"");
|
||||
println!("url = {}", quoted(&full));
|
||||
println!("username = {}", quoted(username));
|
||||
match secret_command {
|
||||
Some(command) => println!("secret_command = {}", quoted(command)),
|
||||
None => println!("# secret_command = \"secret-tool lookup ...\""),
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// A TOML basic string, so a value containing quotes or backslashes survives.
|
||||
fn quoted(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
/// Removes local mirrors of endpoints the configuration no longer names.
|
||||
fn run_prune(cli: &Cli, force: bool) -> ExitCode {
|
||||
let (config, _) = match Config::load(cli.config.as_deref()) {
|
||||
|
|
@ -111,6 +182,16 @@ fn run_prune(cli: &Cli, force: bool) -> ExitCode {
|
|||
Ok(dir) => dir,
|
||||
Err(error) => return fail(&error),
|
||||
};
|
||||
// Only the destructive half needs the lock; listing reads nothing a cycle
|
||||
// could be part-way through changing.
|
||||
let _lock = if force {
|
||||
match lock::acquire(&state_dir) {
|
||||
Ok(lock) => Some(lock),
|
||||
Err(error) => return fail(&error),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let orphans = sync::orphan_vdirs(&config, &state_dir);
|
||||
if orphans.is_empty() {
|
||||
println!("nothing to prune: every local mirror belongs to a configured endpoint");
|
||||
|
|
@ -184,6 +265,10 @@ fn run_retarget(cli: &Cli, id: &str, to: &str, purge_old: bool) -> ExitCode {
|
|||
Ok(dir) => dir,
|
||||
Err(error) => return fail(&error),
|
||||
};
|
||||
let _lock = match lock::acquire(&state_dir) {
|
||||
Ok(lock) => lock,
|
||||
Err(error) => return fail(&error),
|
||||
};
|
||||
match retarget::retarget(&config, &state_dir, id, to, purge_old) {
|
||||
Ok(outcome) => {
|
||||
println!(
|
||||
|
|
@ -329,18 +414,3 @@ fn fail(error: &dyn std::error::Error) -> ExitCode {
|
|||
}
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
/// M1 is still landing: `serve`, `status`, `google` and `aggregate` come later.
|
||||
fn unimplemented(command: &Command) -> ExitCode {
|
||||
let name = match command {
|
||||
Command::Sync { .. } => "sync",
|
||||
Command::Status => "status",
|
||||
Command::Serve { .. } => "serve",
|
||||
Command::Google { .. } => "google",
|
||||
Command::Aggregate { .. } => "aggregate",
|
||||
Command::Prune { .. } => "prune",
|
||||
Command::Doctor => "doctor",
|
||||
};
|
||||
eprintln!("calcalist: `{name}` is not implemented yet");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
|
|
|
|||
255
src/pimsync.rs
255
src/pimsync.rs
|
|
@ -276,6 +276,172 @@ pub fn write_config_for(
|
|||
Ok(path)
|
||||
}
|
||||
|
||||
/// The storage name the probe config gives the server being examined. Parsing
|
||||
/// anchors on it, because pimsync discovers *both* storages in a pair and prints
|
||||
/// their collections under one heading each.
|
||||
const PROBE_REMOTE: &str = "probe_remote";
|
||||
|
||||
/// One collection found on a server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Collection {
|
||||
/// The path pimsync reported, e.g. `/calendars/me/work/`.
|
||||
pub href: String,
|
||||
/// A usable endpoint id, taken from the last meaningful path segment.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Asks pimsync which calendars a server offers.
|
||||
///
|
||||
/// `url` is a container to enumerate — an account or principal path — rather
|
||||
/// than a single calendar, so `split_collection_url` does not apply here.
|
||||
///
|
||||
/// `pimsync discover` is documented as informational and as not altering
|
||||
/// pimsync's state, so pointing it at a server the user has not configured yet
|
||||
/// is safe. The probe configuration it needs is written under a name of its own
|
||||
/// so it can never be mistaken for, or overwrite, the generated `pimsync.conf`.
|
||||
pub fn discover(
|
||||
state_dir: &Path,
|
||||
url: &str,
|
||||
username: &str,
|
||||
secret_command: Option<&str>,
|
||||
) -> Result<Vec<Collection>, PimsyncError> {
|
||||
let config_path = state_dir.join("discover.conf");
|
||||
let scratch = state_dir.join("discover-vdir");
|
||||
fs::create_dir_all(&scratch).map_err(|source| PimsyncError::Write {
|
||||
path: scratch.clone(),
|
||||
source,
|
||||
})?;
|
||||
fs::write(
|
||||
&config_path,
|
||||
probe_config(state_dir, &scratch, url, username, secret_command),
|
||||
)
|
||||
.map_err(|source| PimsyncError::Write {
|
||||
path: config_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let output = Command::new(BINARY)
|
||||
.arg("-c")
|
||||
.arg(&config_path)
|
||||
.arg("discover")
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
if error.kind() == io::ErrorKind::NotFound {
|
||||
PimsyncError::NotFound
|
||||
} else {
|
||||
PimsyncError::Spawn(error)
|
||||
}
|
||||
})?;
|
||||
|
||||
// Everything, including the failures, comes back on stdout.
|
||||
let text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
if !output.status.success() {
|
||||
return Err(PimsyncError::Command {
|
||||
command: "discover".to_string(),
|
||||
message: strip_ansi(&text).trim().to_string(),
|
||||
});
|
||||
}
|
||||
Ok(parse_discovery(&text))
|
||||
}
|
||||
|
||||
fn probe_config(
|
||||
state_dir: &Path,
|
||||
scratch: &Path,
|
||||
url: &str,
|
||||
username: &str,
|
||||
secret_command: Option<&str>,
|
||||
) -> String {
|
||||
let password = match secret_command {
|
||||
Some(command) => format!("\tpassword {{\n\t\tshell {command}\n\t}}\n"),
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
"# Generated by `calcalist discover`. Rewritten on every run.\n\
|
||||
status_path {}\n\n\
|
||||
storage local {{\n\ttype vdir/icalendar\n\tpath {}\n\tfileext ics\n}}\n\n\
|
||||
storage {PROBE_REMOTE} {{\n\ttype caldav\n\turl {}\n\tdiscovery collections\n\
|
||||
\tusername {}\n{password}}}\n\n\
|
||||
pair probe {{\n\tstorage_a {PROBE_REMOTE}\n\tstorage_b local\n\
|
||||
\tcollections from a\n}}\n",
|
||||
quote(&state_dir.join("discover-status").display().to_string()),
|
||||
quote(&scratch.display().to_string()),
|
||||
quote(url),
|
||||
quote(username),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads the collections out of `pimsync discover` output.
|
||||
///
|
||||
/// The format is not documented, so this was established against a real server
|
||||
/// and is deliberately forgiving. The one thing it must get right: a pair has
|
||||
/// two storages and pimsync reports both, so `Found` lines are only taken after
|
||||
/// the heading naming the server. Otherwise the scratch directory's own
|
||||
/// contents would be offered as if they were the user's calendars.
|
||||
fn parse_discovery(output: &str) -> Vec<Collection> {
|
||||
let mut collections = Vec::new();
|
||||
let mut in_remote = false;
|
||||
for line in output.lines() {
|
||||
let line = strip_ansi(line);
|
||||
let line = line.trim();
|
||||
if line.starts_with("==>") {
|
||||
in_remote = line.contains(PROBE_REMOTE);
|
||||
continue;
|
||||
}
|
||||
let Some(href) = line.strip_prefix("Found ") else {
|
||||
continue;
|
||||
};
|
||||
let href = href.trim();
|
||||
if !in_remote || href.is_empty() {
|
||||
continue;
|
||||
}
|
||||
collections.push(Collection {
|
||||
id: collection_id(href),
|
||||
href: href.to_string(),
|
||||
});
|
||||
}
|
||||
collections
|
||||
}
|
||||
|
||||
/// A plausible endpoint id: the last non-empty path segment, restricted to
|
||||
/// characters that are unambiguous in a configuration file.
|
||||
fn collection_id(href: &str) -> String {
|
||||
let segment = href
|
||||
.rsplit('/')
|
||||
.find(|segment| !segment.is_empty())
|
||||
.unwrap_or(href);
|
||||
let id: String = segment
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => ch,
|
||||
'A'..='Z' => ch.to_ascii_lowercase(),
|
||||
_ => '-',
|
||||
})
|
||||
.collect();
|
||||
let id = id.trim_matches('-').to_string();
|
||||
if id.is_empty() { "calendar".into() } else { id }
|
||||
}
|
||||
|
||||
/// Removes ANSI colour, which pimsync emits even when its output is a pipe and
|
||||
/// even under NO_COLOR.
|
||||
fn strip_ansi(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut chars = text.chars();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '\u{1b}' {
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
// Skip up to and including the terminating letter of the escape.
|
||||
for next in chars.by_ref() {
|
||||
if next.is_ascii_alphabetic() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs one pimsync cycle.
|
||||
///
|
||||
/// Deliberately one-shot: `pimsync daemon` would write the same vdirs the
|
||||
|
|
@ -518,4 +684,93 @@ url_command = "secret-tool lookup service calcalist account gcal-ics"
|
|||
assert!(supported.is_supported());
|
||||
assert!(!next_series.is_supported());
|
||||
}
|
||||
|
||||
/// Established against a real server: everything arrives on stdout, the
|
||||
/// headings are colour-coded even through a pipe, and both storages of the
|
||||
/// pair are reported.
|
||||
const DISCOVERY: &str = "\u{1b}[1m\u{1b}[32m==>\u{1b}[0m Discovering collections in local\u{2026}\n\
|
||||
Found leftover-one\n\
|
||||
Found leftover-two\n\
|
||||
\u{1b}[1m\u{1b}[32m==>\u{1b}[0m Discovering collections in probe_remote\u{2026}\n\
|
||||
Found /calendars/me/work/\n\
|
||||
Found /calendars/me/Private Stuff/\n";
|
||||
|
||||
/// The trap: pimsync reports the scratch directory's contents too, and
|
||||
/// offering those as the user's calendars would be nonsense.
|
||||
#[test]
|
||||
fn only_the_servers_collections_are_taken() {
|
||||
let found = parse_discovery(DISCOVERY);
|
||||
|
||||
assert_eq!(
|
||||
found.iter().map(|c| c.href.as_str()).collect::<Vec<_>>(),
|
||||
vec!["/calendars/me/work/", "/calendars/me/Private Stuff/"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_is_taken_from_the_last_segment() {
|
||||
let found = parse_discovery(DISCOVERY);
|
||||
assert_eq!(found[0].id, "work");
|
||||
// Spaces and capitals cannot appear in an id used as a routing marker.
|
||||
assert_eq!(found[1].id, "private-stuff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_offering_nothing_yields_nothing() {
|
||||
let empty = "\u{1b}[1m==>\u{1b}[0m Discovering collections in probe_remote\u{2026}\n";
|
||||
assert!(parse_discovery(empty).is_empty());
|
||||
}
|
||||
|
||||
/// A failure is reported in place of the collections, so nothing is found.
|
||||
#[test]
|
||||
fn an_unreachable_server_yields_nothing() {
|
||||
let failed = "\u{1b}[1m==>\u{1b}[0m Discovering collections in probe_remote\u{2026}\n\
|
||||
input/output error: client error executing request: client error (Connect)\n";
|
||||
assert!(parse_discovery(failed).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_is_stripped_from_headings() {
|
||||
assert_eq!(
|
||||
strip_ansi("\u{1b}[1m\u{1b}[32m==>\u{1b}[0m Discovering"),
|
||||
"==> Discovering"
|
||||
);
|
||||
}
|
||||
|
||||
/// The probe must never be mistaken for the real configuration, and must
|
||||
/// name the storage the parser anchors on.
|
||||
#[test]
|
||||
fn the_probe_config_is_separate_and_parseable() {
|
||||
let text = probe_config(
|
||||
Path::new("/var/state/calcalist"),
|
||||
Path::new("/var/state/calcalist/discover-vdir"),
|
||||
"https://dav.example.com/calendars/me/",
|
||||
"me@example.com",
|
||||
Some("secret-tool lookup service dav"),
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format!("storage {PROBE_REMOTE} {{")),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("collections from a"), "{text}");
|
||||
assert!(text.contains("discovery collections"), "{text}");
|
||||
assert!(
|
||||
text.contains("shell secret-tool lookup service dav"),
|
||||
"{text}"
|
||||
);
|
||||
// Its status path is its own, so a probe cannot disturb a real sync.
|
||||
assert!(text.contains("discover-status"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_needing_no_password_gets_no_password_directive() {
|
||||
let text = probe_config(
|
||||
Path::new("/var/state/calcalist"),
|
||||
Path::new("/tmp/scratch"),
|
||||
"https://dav.example.com/calendars/me/",
|
||||
"me",
|
||||
None,
|
||||
);
|
||||
assert!(!text.contains("password"), "{text}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
systemd/calcalist.service
Normal file
15
systemd/calcalist.service
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[Unit]
|
||||
Description=Synchronise calendars with calcalist
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/.local/bin/calcalist sync
|
||||
NoNewPrivileges=true
|
||||
|
||||
# Deliberately no ProtectSystem, PrivateTmp or IPC sandboxing. Credentials come
|
||||
# from commands like `secret-tool`, which need the session keyring over D-Bus,
|
||||
# and those options break them in ways that surface as unexplained auth
|
||||
# failures rather than as anything pointing at the sandbox.
|
||||
|
||||
# A cycle that could not reach an endpoint exits non-zero on purpose, so a
|
||||
# lapsed token shows up as a failed unit rather than passing unnoticed.
|
||||
15
systemd/calcalist.timer
Normal file
15
systemd/calcalist.timer
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[Unit]
|
||||
Description=Synchronise calendars with calcalist every 15 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2m
|
||||
OnUnitActiveSec=15m
|
||||
Persistent=true
|
||||
|
||||
# Spreads requests instead of every installation calling Google on the quarter
|
||||
# hour. Google's Calendar API quota is per project and enforced per minute, and
|
||||
# its own guidance is to randomise timing rather than to burst.
|
||||
RandomizedDelaySec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
114
tests/caldav.rs
114
tests/caldav.rs
|
|
@ -383,3 +383,117 @@ fn the_local_marker_keeps_an_event_out_of_a_configured_sink() {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovery, against a server that really has two calendars.
|
||||
///
|
||||
/// The command exists because the CalDAV `url` must name the collection exactly
|
||||
/// and providers rarely show it. What it prints therefore has to be usable
|
||||
/// as-is, which is what the parse-back below actually checks.
|
||||
#[test]
|
||||
fn discover_lists_the_servers_calendars_as_pasteable_config() {
|
||||
let Some(fixture) = fixture() else { return };
|
||||
|
||||
let run = fixture.calcalist.run(&[
|
||||
"discover",
|
||||
&format!(
|
||||
"http://127.0.0.1:{}/{}/",
|
||||
fixture.server.port,
|
||||
support::USER
|
||||
),
|
||||
"--username",
|
||||
support::USER,
|
||||
"--secret-command",
|
||||
"printf password",
|
||||
]);
|
||||
assert!(
|
||||
run.succeeded(),
|
||||
"discover failed\n{}\n{}",
|
||||
run.stdout,
|
||||
run.stderr
|
||||
);
|
||||
|
||||
assert!(run.stdout.contains("id = \"work\""), "{}", run.stdout);
|
||||
assert!(run.stdout.contains("id = \"unified\""), "{}", run.stdout);
|
||||
// The URL has to come back absolute, not as the bare path pimsync reports.
|
||||
assert!(
|
||||
run.stdout
|
||||
.contains(&format!("http://127.0.0.1:{}/", fixture.server.port)),
|
||||
"{}",
|
||||
run.stdout
|
||||
);
|
||||
|
||||
// The point of the command: what it prints is valid configuration.
|
||||
let config = format!("version = 1\n{}", run.stdout);
|
||||
let parsed: toml::Value = toml::from_str(&config).expect("the output should be valid TOML");
|
||||
let endpoints = parsed["endpoint"].as_array().expect("endpoints");
|
||||
assert_eq!(endpoints.len(), 2, "{}", run.stdout);
|
||||
assert_eq!(endpoints[0]["type"].as_str(), Some("caldav"));
|
||||
assert_eq!(
|
||||
endpoints[0]["secret_command"].as_str(),
|
||||
Some("printf password")
|
||||
);
|
||||
}
|
||||
|
||||
/// An unreachable server has to say so rather than print an empty result that
|
||||
/// looks like "this server has no calendars".
|
||||
#[test]
|
||||
fn discover_reports_a_server_it_cannot_reach() {
|
||||
if !support::missing_binaries().is_empty() {
|
||||
eprintln!("skipped: run under devbox");
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("temp");
|
||||
let calcalist = Calcalist::new(root.path(), "version = 1\n");
|
||||
|
||||
// Port 9 is discard: it refuses or blackholes, and nothing listens for DAV.
|
||||
let run = calcalist.run(&[
|
||||
"discover",
|
||||
"http://127.0.0.1:9/calendars/me/",
|
||||
"--username",
|
||||
"me",
|
||||
]);
|
||||
|
||||
assert!(
|
||||
!run.succeeded(),
|
||||
"it should not claim success\n{}",
|
||||
run.stdout
|
||||
);
|
||||
assert!(
|
||||
!run.stderr.is_empty(),
|
||||
"the failure should be explained: {run:?}",
|
||||
run = run.stderr
|
||||
);
|
||||
}
|
||||
|
||||
/// The lock is what stops a timer firing into a hand-run cycle.
|
||||
///
|
||||
/// The lock is taken here rather than by racing two real syncs, which would be
|
||||
/// timing-dependent and would pass by accident most of the time.
|
||||
#[test]
|
||||
fn a_second_cycle_is_refused_while_one_is_running() {
|
||||
let Some(fixture) = fixture() else { return };
|
||||
assert!(fixture.calcalist.run(&["sync"]).succeeded());
|
||||
|
||||
let lock_path = fixture.calcalist.state.join("calcalist/lock");
|
||||
let held = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&lock_path)
|
||||
.expect("the lock file should exist after a cycle");
|
||||
rustix::fs::flock(&held, rustix::fs::FlockOperation::NonBlockingLockExclusive)
|
||||
.expect("the test should be able to take the lock");
|
||||
|
||||
let refused = fixture.calcalist.run(&["sync"]);
|
||||
assert!(!refused.succeeded(), "{}", refused.stdout);
|
||||
assert!(
|
||||
refused.stderr.contains("already running"),
|
||||
"the refusal should say why: {}",
|
||||
refused.stderr
|
||||
);
|
||||
|
||||
drop(held);
|
||||
assert!(
|
||||
fixture.calcalist.run(&["sync"]).succeeded(),
|
||||
"the lock should be free again"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ use std::time::{Duration, Instant};
|
|||
/// How long to wait for a server to start answering.
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// How many ports to try before giving up on starting a server.
|
||||
const ATTEMPTS: usize = 8;
|
||||
|
||||
/// The account Radicale files everything under. With authentication disabled it
|
||||
/// accepts whatever name arrives, and `owner_only` rights then grant that name
|
||||
/// its own tree — so the URLs below all live beneath it.
|
||||
|
|
@ -46,29 +49,40 @@ impl Radicale {
|
|||
pub fn start(root: &Path) -> Radicale {
|
||||
let storage = root.join("radicale");
|
||||
std::fs::create_dir_all(&storage).expect("create storage");
|
||||
let port = free_port();
|
||||
|
||||
let process = Command::new("radicale")
|
||||
.arg("--config")
|
||||
.arg("")
|
||||
.arg("--server-hosts")
|
||||
.arg(format!("127.0.0.1:{port}"))
|
||||
.arg("--auth-type")
|
||||
.arg("none")
|
||||
.arg("--storage-filesystem-folder")
|
||||
.arg(&storage)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("radicale should start");
|
||||
// A free port is chosen by binding one and letting go, so two tests
|
||||
// running at once can pick the same number. The loser's radicale then
|
||||
// fails to bind and exits — and, worse, waiting for the port to answer
|
||||
// succeeds anyway, because the winner is listening on it. Tests would
|
||||
// quietly share a server. So: confirm the child we spawned is the one
|
||||
// still alive, and take a different port if it is not.
|
||||
for _ in 0..ATTEMPTS {
|
||||
let port = free_port();
|
||||
let mut process = Command::new("radicale")
|
||||
.arg("--config")
|
||||
.arg("")
|
||||
.arg("--server-hosts")
|
||||
.arg(format!("127.0.0.1:{port}"))
|
||||
.arg("--auth-type")
|
||||
.arg("none")
|
||||
.arg("--storage-filesystem-folder")
|
||||
.arg(&storage)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("radicale should start");
|
||||
|
||||
let server = Radicale {
|
||||
process,
|
||||
port,
|
||||
storage,
|
||||
};
|
||||
wait_until_listening(port, "radicale");
|
||||
server
|
||||
if listening(&mut process, port) {
|
||||
return Radicale {
|
||||
process,
|
||||
port,
|
||||
storage,
|
||||
};
|
||||
}
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
}
|
||||
panic!("radicale did not come up on a port of its own after {ATTEMPTS} attempts");
|
||||
}
|
||||
|
||||
pub fn url(&self, calendar: &str) -> String {
|
||||
|
|
@ -138,8 +152,34 @@ impl Radicale {
|
|||
}
|
||||
|
||||
fn request(&self, method: &str, path: &str, content_type: &str, body: &str) -> String {
|
||||
// Retried because a server under parallel test load occasionally resets
|
||||
// a connection it has accepted; that says nothing about the request.
|
||||
let mut last = String::new();
|
||||
for attempt in 0..ATTEMPTS {
|
||||
match self.try_request(method, path, content_type, body) {
|
||||
Ok(response) => return response,
|
||||
Err(error) => {
|
||||
last = error;
|
||||
std::thread::sleep(Duration::from_millis(50 * (attempt as u64 + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("{method} {path} never got a response: {last}");
|
||||
}
|
||||
|
||||
fn try_request(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
content_type: &str,
|
||||
body: &str,
|
||||
) -> Result<String, String> {
|
||||
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, self.port));
|
||||
let mut stream = TcpStream::connect(address).expect("connect to radicale");
|
||||
let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2))
|
||||
.map_err(|error| error.to_string())?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.map_err(|error| error.to_string())?;
|
||||
// With authentication disabled the credentials only pick the account.
|
||||
let authorization = base64(format!("{USER}:password").as_bytes());
|
||||
let request = format!(
|
||||
|
|
@ -149,12 +189,14 @@ impl Radicale {
|
|||
self.port,
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(request.as_bytes()).expect("send request");
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut response = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response)
|
||||
.expect("read the response");
|
||||
response
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -279,16 +321,42 @@ fn free_port() -> u16 {
|
|||
listener.local_addr().expect("port").port()
|
||||
}
|
||||
|
||||
fn wait_until_listening(port: u16, what: &str) {
|
||||
/// Whether a real HTTP request gets a real HTTP response.
|
||||
fn answers(address: SocketAddr) -> bool {
|
||||
let Ok(mut stream) = TcpStream::connect_timeout(&address, Duration::from_millis(200)) else {
|
||||
return false;
|
||||
};
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
|
||||
let request = format!("OPTIONS / HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n");
|
||||
if stream.write_all(request.as_bytes()).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response).is_ok() && response.starts_with("HTTP/")
|
||||
}
|
||||
|
||||
/// Waits for our own radicale to answer, giving up as soon as it has exited.
|
||||
///
|
||||
/// The liveness check is the point: without it a process that lost a port race
|
||||
/// looks healthy, because something else is answering on that port.
|
||||
fn listening(process: &mut Child, port: u16) -> bool {
|
||||
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||
while Instant::now() < deadline {
|
||||
if TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok() {
|
||||
return;
|
||||
match process.try_wait() {
|
||||
Ok(Some(_)) => return false,
|
||||
Ok(None) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
// An open port is not readiness: the socket listens before the
|
||||
// application behind it can answer, and under parallel tests that gap
|
||||
// is wide enough to get a connection reset instead of a response.
|
||||
if answers(address) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
panic!("{what} did not start listening on {port} within {STARTUP_TIMEOUT:?}");
|
||||
false
|
||||
}
|
||||
|
||||
fn base64(input: &[u8]) -> String {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue