Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ comenq del 1a2b3c4d # remove from the queue
```

The daemon also keeps a posting-history log, recording every successful and
failed posting attempt. `hist` prints it, oldest first:
failed posting attempt. `hist` prints it, oldest first, including a short
eight-character hash identifying which token made the attempt:

```bash
comenq hist -n 20 # the twenty most recent posting attempts
Expand All @@ -72,3 +73,17 @@ supplied through systemd's credential system
(`LoadCredential=token:%h/pandalump-token` with
`github_token_file = "${CREDENTIALS_DIRECTORY}/token"`), keeping the secret out
of the unit file and process environment.

### Rotating multiple GitHub tokens

To spread posting across several personal access tokens, set
`github_token_files` instead of `github_token`/`github_token_file`:

```toml
github_token_files = ["/home/user/pandalump-token", "/home/user/buzzybee-token"]
```

Each file must exist and hold a non-empty token; this is checked at startup.
The daemon then posts each comment with the successor of whichever token
posted last, wrapping back to the first token at the end of the list, so the
configured tokens are used in round-robin rotation.
1 change: 1 addition & 0 deletions crates/comenq/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ mod tests {
posted_at: 1_000,
success: true,
error: None,
token_hash: "ab".repeat(32),
owner: "octocat".into(),
repo: "hello-world".into(),
pr_number: 1,
Expand Down
12 changes: 8 additions & 4 deletions crates/comenq/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,19 @@ pub fn format_age(seconds: u64) -> String {

/// Render one `hist` line for a past posting attempt.
///
/// `now` is the current Unix time, used to show the attempt's age. Failed
/// `now` is the current Unix time, used to show the attempt's age. The
/// posting token appears as the first eight characters of its hash. Failed
/// attempts carry the failure description, collapsed to one line.
pub(crate) fn render_history(entry: &HistoryEntry, now: u64) -> String {
let age = format_age(now.saturating_sub(entry.posted_at));
let status = if entry.success { "ok" } else { "FAIL" };
let token: String = entry.token_hash.chars().take(8).collect();
let mut line = format!(
"{} {:>11} {:<4} {}/{}#{} {}",
"{} {:>11} {:<4} {:<8} {}/{}#{} {}",
entry.id,
age,
status,
token,
entry.owner,
entry.repo,
entry.pr_number,
Expand Down Expand Up @@ -203,6 +206,7 @@ mod tests {
posted_at: 1_000,
success,
error: error.map(str::to_owned),
token_hash: "cafe0123".repeat(8),
owner: "octocat".into(),
repo: "hello-world".into(),
pr_number: 7,
Expand All @@ -224,7 +228,7 @@ mod tests {
let line = render_history(&history(true, None), 1_150);
assert_eq!(
line,
"1a2b3c4d 2m 30s ago ok octocat/hello-world#7 Hi there"
"1a2b3c4d 2m 30s ago ok cafe0123 octocat/hello-world#7 Hi there"
);
}

Expand All @@ -233,7 +237,7 @@ mod tests {
let line = render_history(&history(false, Some("timeout\nafter 10s")), 1_045);
assert_eq!(
line,
"1a2b3c4d 45s ago FAIL octocat/hello-world#7 Hi there — timeout after 10s"
"1a2b3c4d 45s ago FAIL cafe0123 octocat/hello-world#7 Hi there — timeout after 10s"
);
}

Expand Down
1 change: 1 addition & 0 deletions crates/comenqd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ figment = { version = "0.10", default-features = false, features = ["env", "toml
test-support = { workspace = true, optional = true }
backon = { workspace = true }
rand = { workspace = true }
sha2 = "0.10"
uuid = { workspace = true, features = ["v4"] }

[dev-dependencies]
Expand Down
41 changes: 36 additions & 5 deletions crates/comenqd/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};

mod tokens;
pub use tokens::NamedToken;

/// Default queue directory when none is provided.
const DEFAULT_QUEUE_PATH: &str = "/var/lib/comenq/queue";
/// Default cooldown in seconds between comment posts.
Expand Down Expand Up @@ -41,6 +44,14 @@ pub struct Config {
/// `github_token_file = "${CREDENTIALS_DIRECTORY}/token"`.
#[serde(default)]
pub github_token_file: Option<PathBuf>,
/// Paths to files each containing one GitHub Personal Access Token.
///
/// When non-empty, the daemon posts comments with these tokens in
/// round-robin rotation: each post uses the successor of the token whose
/// hash was recorded against the most recent history record. The files
/// are read at startup; each must hold a non-empty token.
#[serde(default)]
pub github_token_files: Vec<PathBuf>,
/// Path to the Unix Domain Socket.
///
/// Defaults to `$XDG_RUNTIME_DIR/comenq/comenq.sock` when a user runtime
Expand Down Expand Up @@ -98,6 +109,7 @@ impl From<test_support::daemon::TestConfig> for Config {
Self {
github_token,
github_token_file: None,
github_token_files: Vec::new(),
socket_path,
queue_path,
cooldown_period_seconds,
Expand Down Expand Up @@ -137,6 +149,7 @@ impl From<&test_support::daemon::TestConfig> for Config {
Self {
github_token: value.github_token.clone(),
github_token_file: None,
github_token_files: Vec::new(),
socket_path: value.socket_path.clone(),
queue_path: value.queue_path.clone(),
cooldown_period_seconds: value.cooldown_period_seconds,
Expand Down Expand Up @@ -298,13 +311,31 @@ impl Config {
}
self.github_token = token.to_owned();
}
if self.github_token.is_empty() {
return Err(ortho_config::OrthoError::Validation {
if !self.github_token_files.is_empty() {
// Fail fast: every rotation token must be readable at startup.
self.github_tokens()
.map(|_| ())
.map_err(|e| ortho_config::OrthoError::Validation {
key: "github_token_files".into(),
message: e.to_string(),
})
} else if self.github_token.is_empty() {
Err(ortho_config::OrthoError::Validation {
key: "github_token".into(),
message: "provide github_token or github_token_file".into(),
});
message: "provide github_token, github_token_file, or github_token_files".into(),
})
} else {
Ok(())
}
Ok(())
}

/// The rotation pool of GitHub tokens, in configured order.
///
/// Reads each path in [`Config::github_token_files`], naming the token
/// after its file. When no rotation files are configured, the single
/// [`Config::github_token`] forms a pool of one named `default`.
pub fn github_tokens(&self) -> io::Result<Vec<NamedToken>> {
tokens::resolve_pool(&self.github_token_files, &self.github_token)
}
}

Expand Down
88 changes: 88 additions & 0 deletions crates/comenqd/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,91 @@ fn converts_from_test_config(#[case] conv: fn(&test_support::daemon::TestConfig)
test_cfg.github_api_timeout_secs
);
}

#[rstest]
#[serial_test::serial]
fn token_files_form_the_rotation_pool() {
let dir = tempdir().expect("create tempdir");
let first = dir.path().join("pandalump-token");
let second = dir.path().join("buzzybee-token");
fs::write(&first, "alpha-token\n").expect("write first token");
fs::write(&second, "bravo-token\n").expect("write second token");
let path = dir.path().join("config.toml");
fs::write(
&path,
format!(
"github_token_files=['{}','{}']",
first.display(),
second.display()
),
)
.expect("write config fixture");
let cfg = Config::from_file(&path).expect("load config");
let tokens = cfg.github_tokens().expect("resolve tokens");
let summary: Vec<(&str, &str)> = tokens
.iter()
.map(|named| (named.name.as_str(), named.token.as_str()))
.collect();
assert_eq!(
summary,
vec![
("pandalump-token", "alpha-token"),
("buzzybee-token", "bravo-token"),
]
);
}

#[rstest]
#[serial_test::serial]
fn token_files_suffice_without_an_inline_token() {
let dir = tempdir().expect("create tempdir");
let file = dir.path().join("only-token");
fs::write(&file, "solo\n").expect("write token");
let path = dir.path().join("config.toml");
fs::write(&path, format!("github_token_files=['{}']", file.display()))
.expect("write config fixture");
let cfg = Config::from_file(&path).expect("token files alone should satisfy validation");
assert!(cfg.github_token.is_empty());
}

#[rstest]
#[serial_test::serial]
fn empty_rotation_token_file_errors() {
let dir = tempdir().expect("create tempdir");
let file = dir.path().join("empty-token");
fs::write(&file, " \n").expect("write blank token");
let path = dir.path().join("config.toml");
fs::write(&path, format!("github_token_files=['{}']", file.display()))
.expect("write config fixture");
assert!(Config::from_file(&path).is_err());
}

#[rstest]
#[serial_test::serial]
fn missing_rotation_token_file_errors() {
let dir = tempdir().expect("create tempdir");
let path = dir.path().join("config.toml");
fs::write(
&path,
format!(
"github_token_files=['{}']",
dir.path().join("nope-token").display()
),
)
.expect("write config fixture");
assert!(Config::from_file(&path).is_err());
}

#[rstest]
#[serial_test::serial]
fn single_inline_token_forms_a_default_pool() {
let dir = tempdir().expect("create tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, "github_token='abc'").expect("write config fixture");
let cfg = Config::from_file(&path).expect("load config");
let tokens = cfg.github_tokens().expect("resolve tokens");
assert_eq!(tokens.len(), 1);
let named = tokens.first().expect("single token");
assert_eq!(named.name, "default");
assert_eq!(named.token, "abc");
}
Loading
Loading