[O] Better webhook error handling
This commit is contained in:
@@ -102,6 +102,8 @@ struct WebhookInstallCommand {
|
|||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
#[arg(long, value_name = "PATH")]
|
#[arg(long, value_name = "PATH")]
|
||||||
work_dir: Option<PathBuf>,
|
work_dir: Option<PathBuf>,
|
||||||
|
#[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")]
|
||||||
|
jobs: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
@@ -112,6 +114,8 @@ struct WebhookUninstallCommand {
|
|||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
#[arg(long, value_name = "PATH")]
|
#[arg(long, value_name = "PATH")]
|
||||||
work_dir: Option<PathBuf>,
|
work_dir: Option<PathBuf>,
|
||||||
|
#[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")]
|
||||||
|
jobs: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -176,6 +180,7 @@ fn main() -> Result<()> {
|
|||||||
repo_pattern: command.repo_pattern,
|
repo_pattern: command.repo_pattern,
|
||||||
dry_run: command.dry_run,
|
dry_run: command.dry_run,
|
||||||
work_dir: command.work_dir,
|
work_dir: command.work_dir,
|
||||||
|
jobs: command.jobs,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -187,6 +192,7 @@ fn main() -> Result<()> {
|
|||||||
group: command.group,
|
group: command.group,
|
||||||
dry_run: command.dry_run,
|
dry_run: command.dry_run,
|
||||||
work_dir: command.work_dir,
|
work_dir: command.work_dir,
|
||||||
|
jobs: command.jobs,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -169,6 +169,7 @@ fn sync_group(
|
|||||||
mirror,
|
mirror,
|
||||||
&all_endpoint_repos,
|
&all_endpoint_repos,
|
||||||
context.work_dir,
|
context.work_dir,
|
||||||
|
context.options.jobs,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +332,13 @@ fn sync_group(
|
|||||||
|
|
||||||
if create_missing && !context.options.dry_run {
|
if create_missing && !context.options.dry_run {
|
||||||
let repos = list_group_repos(context.config, mirror)?;
|
let repos = list_group_repos(context.config, mirror)?;
|
||||||
webhook::ensure_configured_webhooks(context.config, mirror, &repos, context.work_dir)?;
|
webhook::ensure_configured_webhooks(
|
||||||
|
context.config,
|
||||||
|
mirror,
|
||||||
|
&repos,
|
||||||
|
context.work_dir,
|
||||||
|
context.options.jobs,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(failures)
|
Ok(failures)
|
||||||
|
|||||||
+324
-98
@@ -43,6 +43,7 @@ pub struct WebhookInstallOptions {
|
|||||||
pub repo_pattern: Option<String>,
|
pub repo_pattern: Option<String>,
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
pub work_dir: Option<PathBuf>,
|
pub work_dir: Option<PathBuf>,
|
||||||
|
pub jobs: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -50,6 +51,7 @@ pub struct WebhookUninstallOptions {
|
|||||||
pub group: Option<String>,
|
pub group: Option<String>,
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
pub work_dir: Option<PathBuf>,
|
pub work_dir: Option<PathBuf>,
|
||||||
|
pub jobs: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
@@ -163,8 +165,11 @@ fn reachability_timer_loop(url: String, minutes: u64) {
|
|||||||
|
|
||||||
pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Result<()> {
|
pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Result<()> {
|
||||||
validate_config(config)?;
|
validate_config(config)?;
|
||||||
|
if options.jobs == 0 {
|
||||||
|
bail!("--jobs must be at least 1");
|
||||||
|
}
|
||||||
let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir);
|
let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir);
|
||||||
let mut state = load_webhook_state(&work_dir)?;
|
let state = Arc::new(Mutex::new(load_webhook_state(&work_dir)?));
|
||||||
let repo_pattern = options
|
let repo_pattern = options
|
||||||
.repo_pattern
|
.repo_pattern
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -186,6 +191,7 @@ pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Resu
|
|||||||
style("Webhook group").cyan().bold(),
|
style("Webhook group").cyan().bold(),
|
||||||
style(&mirror.name).bold()
|
style(&mirror.name).bold()
|
||||||
);
|
);
|
||||||
|
let mut tasks = Vec::new();
|
||||||
for endpoint in &mirror.endpoints {
|
for endpoint in &mirror.endpoints {
|
||||||
let site = config.site(&endpoint.site).unwrap();
|
let site = config.site(&endpoint.site).unwrap();
|
||||||
let client = ProviderClient::new(site)?;
|
let client = ProviderClient::new(site)?;
|
||||||
@@ -204,22 +210,23 @@ pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Resu
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
install_repo_webhook(
|
tasks.push(WebhookInstallTask {
|
||||||
&WebhookInstallRequest {
|
site: site.clone(),
|
||||||
client: &client,
|
group: mirror.name.clone(),
|
||||||
group: &mirror.name,
|
endpoint: endpoint.clone(),
|
||||||
endpoint,
|
repo,
|
||||||
repo: &repo,
|
url: options.url.clone(),
|
||||||
url: &options.url,
|
secret: options.secret.clone(),
|
||||||
secret: &options.secret,
|
dry_run: options.dry_run,
|
||||||
dry_run: options.dry_run,
|
});
|
||||||
},
|
|
||||||
&mut state,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run_install_tasks(tasks, options.jobs, Arc::clone(&state))?;
|
||||||
}
|
}
|
||||||
if !options.dry_run {
|
if !options.dry_run {
|
||||||
|
let state = state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
save_webhook_state(&work_dir, &state)?;
|
save_webhook_state(&work_dir, &state)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -227,6 +234,9 @@ pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Resu
|
|||||||
|
|
||||||
pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) -> Result<()> {
|
pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) -> Result<()> {
|
||||||
validate_config(config)?;
|
validate_config(config)?;
|
||||||
|
if options.jobs == 0 {
|
||||||
|
bail!("--jobs must be at least 1");
|
||||||
|
}
|
||||||
let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir);
|
let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir);
|
||||||
let mut state = load_webhook_state(&work_dir)?;
|
let mut state = load_webhook_state(&work_dir)?;
|
||||||
if state.installations.is_empty() {
|
if state.installations.is_empty() {
|
||||||
@@ -237,7 +247,7 @@ pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) ->
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut removed_keys = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
for (key, installation) in &state.installations {
|
for (key, installation) in &state.installations {
|
||||||
if options
|
if options
|
||||||
.group
|
.group
|
||||||
@@ -246,46 +256,14 @@ pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) ->
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
crate::logln!(
|
tasks.push(WebhookUninstallTask {
|
||||||
" {} {} {}",
|
key: key.clone(),
|
||||||
style(if options.dry_run {
|
site: config.site(&installation.endpoint.site).cloned(),
|
||||||
"would uninstall"
|
installation: installation.clone(),
|
||||||
} else {
|
dry_run: options.dry_run,
|
||||||
"uninstall"
|
});
|
||||||
})
|
|
||||||
.red()
|
|
||||||
.bold(),
|
|
||||||
style(&installation.repo).cyan(),
|
|
||||||
style(format!("from {}", installation.endpoint.label())).dim()
|
|
||||||
);
|
|
||||||
if options.dry_run {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(site) = config.site(&installation.endpoint.site) else {
|
|
||||||
crate::logln!(
|
|
||||||
" {} {} {}",
|
|
||||||
style("skip").yellow().bold(),
|
|
||||||
style(&installation.repo).cyan(),
|
|
||||||
style(format!("unknown site {}", installation.endpoint.site)).dim()
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let client = ProviderClient::new(site)?;
|
|
||||||
client
|
|
||||||
.uninstall_webhook(
|
|
||||||
&installation.endpoint,
|
|
||||||
&installation.repo,
|
|
||||||
&installation.url,
|
|
||||||
)
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"failed to uninstall webhook for {} from {}",
|
|
||||||
installation.repo,
|
|
||||||
installation.endpoint.label()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
removed_keys.push(key.clone());
|
|
||||||
}
|
}
|
||||||
|
let removed_keys = run_uninstall_tasks(tasks, options.jobs)?;
|
||||||
|
|
||||||
if !options.dry_run {
|
if !options.dry_run {
|
||||||
for key in removed_keys {
|
for key in removed_keys {
|
||||||
@@ -301,6 +279,7 @@ pub fn ensure_configured_webhooks(
|
|||||||
mirror: &MirrorConfig,
|
mirror: &MirrorConfig,
|
||||||
repos: &[EndpointRepo],
|
repos: &[EndpointRepo],
|
||||||
work_dir: &Path,
|
work_dir: &Path,
|
||||||
|
jobs: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let Some(webhook) = &config.webhook else {
|
let Some(webhook) = &config.webhook else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -308,26 +287,30 @@ pub fn ensure_configured_webhooks(
|
|||||||
if !webhook.install {
|
if !webhook.install {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if jobs == 0 {
|
||||||
|
bail!("--jobs must be at least 1");
|
||||||
|
}
|
||||||
let secret = webhook.secret()?;
|
let secret = webhook.secret()?;
|
||||||
let mut state = load_webhook_state(work_dir)?;
|
let state = Arc::new(Mutex::new(load_webhook_state(work_dir)?));
|
||||||
|
let mut tasks = Vec::new();
|
||||||
for endpoint_repo in repos {
|
for endpoint_repo in repos {
|
||||||
let Some(site) = config.site(&endpoint_repo.endpoint.site) else {
|
let Some(site) = config.site(&endpoint_repo.endpoint.site) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let client = ProviderClient::new(site)?;
|
tasks.push(WebhookInstallTask {
|
||||||
install_repo_webhook(
|
site: site.clone(),
|
||||||
&WebhookInstallRequest {
|
group: mirror.name.clone(),
|
||||||
client: &client,
|
endpoint: endpoint_repo.endpoint.clone(),
|
||||||
group: &mirror.name,
|
repo: endpoint_repo.repo.clone(),
|
||||||
endpoint: &endpoint_repo.endpoint,
|
url: webhook.url.clone(),
|
||||||
repo: &endpoint_repo.repo,
|
secret: secret.clone(),
|
||||||
url: &webhook.url,
|
dry_run: false,
|
||||||
secret: &secret,
|
});
|
||||||
dry_run: false,
|
|
||||||
},
|
|
||||||
&mut state,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
run_install_tasks(tasks, jobs, Arc::clone(&state))?;
|
||||||
|
let state = state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
save_webhook_state(work_dir, &state)
|
save_webhook_state(work_dir, &state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +388,8 @@ fn worker_loop(
|
|||||||
struct WebhookState {
|
struct WebhookState {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
installations: BTreeMap<String, WebhookInstallation>,
|
installations: BTreeMap<String, WebhookInstallation>,
|
||||||
|
#[serde(default)]
|
||||||
|
skipped: BTreeMap<String, SkippedWebhookInstallation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
@@ -415,65 +400,306 @@ struct WebhookInstallation {
|
|||||||
url: String,
|
url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WebhookInstallRequest<'a> {
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
client: &'a ProviderClient<'a>,
|
struct SkippedWebhookInstallation {
|
||||||
group: &'a str,
|
group: String,
|
||||||
endpoint: &'a EndpointConfig,
|
endpoint: EndpointConfig,
|
||||||
repo: &'a RemoteRepo,
|
repo: String,
|
||||||
url: &'a str,
|
url: String,
|
||||||
secret: &'a str,
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct WebhookInstallTask {
|
||||||
|
site: crate::config::SiteConfig,
|
||||||
|
group: String,
|
||||||
|
endpoint: EndpointConfig,
|
||||||
|
repo: RemoteRepo,
|
||||||
|
url: String,
|
||||||
|
secret: String,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_repo_webhook(
|
#[derive(Clone, Debug)]
|
||||||
request: &WebhookInstallRequest<'_>,
|
struct WebhookUninstallTask {
|
||||||
state: &mut WebhookState,
|
key: String,
|
||||||
|
site: Option<crate::config::SiteConfig>,
|
||||||
|
installation: WebhookInstallation,
|
||||||
|
dry_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_install_tasks(
|
||||||
|
tasks: Vec<WebhookInstallTask>,
|
||||||
|
jobs: usize,
|
||||||
|
state: Arc<Mutex<WebhookState>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let key = webhook_installation_key(request.group, request.endpoint, &request.repo.name);
|
if tasks.is_empty() {
|
||||||
if state
|
|
||||||
.installations
|
|
||||||
.get(&key)
|
|
||||||
.is_some_and(|installation| installation.url == request.url)
|
|
||||||
{
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let worker_count = jobs.min(tasks.len());
|
||||||
|
let (task_sender, task_receiver) = mpsc::channel();
|
||||||
|
let (result_sender, result_receiver) = mpsc::channel();
|
||||||
|
for task in tasks {
|
||||||
|
task_sender.send(task)?;
|
||||||
|
}
|
||||||
|
drop(task_sender);
|
||||||
|
let task_receiver = Arc::new(Mutex::new(task_receiver));
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..worker_count {
|
||||||
|
let task_receiver = Arc::clone(&task_receiver);
|
||||||
|
let result_sender = result_sender.clone();
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
handles.push(thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
let task = {
|
||||||
|
let task_receiver = task_receiver
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
task_receiver.recv()
|
||||||
|
};
|
||||||
|
let Ok(task) = task else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if result_sender
|
||||||
|
.send(install_webhook_task(task, &state))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
drop(result_sender);
|
||||||
|
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for result in result_receiver {
|
||||||
|
if let Err(error) = result {
|
||||||
|
crate::logln!(" {} {error:#}", style("fail").red().bold());
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for handle in handles {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
if !failures.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"webhook installation completed with {} failure(s)",
|
||||||
|
failures.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_uninstall_tasks(tasks: Vec<WebhookUninstallTask>, jobs: usize) -> Result<Vec<String>> {
|
||||||
|
if tasks.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let worker_count = jobs.min(tasks.len());
|
||||||
|
let (task_sender, task_receiver) = mpsc::channel();
|
||||||
|
let (result_sender, result_receiver) = mpsc::channel();
|
||||||
|
for task in tasks {
|
||||||
|
task_sender.send(task)?;
|
||||||
|
}
|
||||||
|
drop(task_sender);
|
||||||
|
let task_receiver = Arc::new(Mutex::new(task_receiver));
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..worker_count {
|
||||||
|
let task_receiver = Arc::clone(&task_receiver);
|
||||||
|
let result_sender = result_sender.clone();
|
||||||
|
handles.push(thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
let task = {
|
||||||
|
let task_receiver = task_receiver
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
task_receiver.recv()
|
||||||
|
};
|
||||||
|
let Ok(task) = task else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if result_sender.send(uninstall_webhook_task(task)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
drop(result_sender);
|
||||||
|
|
||||||
|
let mut removed_keys = Vec::new();
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for result in result_receiver {
|
||||||
|
match result {
|
||||||
|
Ok(Some(key)) => removed_keys.push(key),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(error) => {
|
||||||
|
crate::logln!(" {} {error:#}", style("fail").red().bold());
|
||||||
|
failures.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for handle in handles {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
if !failures.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"webhook uninstall completed with {} failure(s)",
|
||||||
|
failures.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(removed_keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_webhook_task(task: WebhookInstallTask, state: &Arc<Mutex<WebhookState>>) -> Result<()> {
|
||||||
|
let key = webhook_installation_key(&task.group, &task.endpoint, &task.repo.name);
|
||||||
|
{
|
||||||
|
let state = state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
if state
|
||||||
|
.installations
|
||||||
|
.get(&key)
|
||||||
|
.is_some_and(|installation| installation.url == task.url)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if state
|
||||||
|
.skipped
|
||||||
|
.get(&key)
|
||||||
|
.is_some_and(|skipped| skipped.url == task.url)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
crate::logln!(
|
crate::logln!(
|
||||||
" {} {} {}",
|
" {} {} {}",
|
||||||
style(if request.dry_run {
|
style(if task.dry_run {
|
||||||
"would install"
|
"would install"
|
||||||
} else {
|
} else {
|
||||||
"install"
|
"install"
|
||||||
})
|
})
|
||||||
.green()
|
.green()
|
||||||
.bold(),
|
.bold(),
|
||||||
style(&request.repo.name).cyan(),
|
style(&task.repo.name).cyan(),
|
||||||
style(format!("webhook on {}", request.endpoint.label())).dim()
|
style(format!("webhook on {}", task.endpoint.label())).dim()
|
||||||
);
|
);
|
||||||
if request.dry_run {
|
if task.dry_run {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
request
|
let client = ProviderClient::new(&task.site)?;
|
||||||
.client
|
if let Err(error) = client.install_webhook(&task.endpoint, &task.repo, &task.url, &task.secret)
|
||||||
.install_webhook(request.endpoint, request.repo, request.url, request.secret)
|
{
|
||||||
.with_context(|| {
|
if let Some(reason) = non_actionable_webhook_failure_reason(&error) {
|
||||||
|
crate::logln!(
|
||||||
|
" {} {} {}",
|
||||||
|
style("skip").yellow().bold(),
|
||||||
|
style(&task.repo.name).cyan(),
|
||||||
|
style(format!("webhook on {}: {reason}", task.endpoint.label())).dim()
|
||||||
|
);
|
||||||
|
let mut state = state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
state.skipped.insert(
|
||||||
|
key,
|
||||||
|
SkippedWebhookInstallation {
|
||||||
|
group: task.group,
|
||||||
|
endpoint: task.endpoint,
|
||||||
|
repo: task.repo.name,
|
||||||
|
url: task.url,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(error).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"failed to install webhook for {} on {}",
|
"failed to install webhook for {} on {}",
|
||||||
request.repo.name,
|
task.repo.name,
|
||||||
request.endpoint.label()
|
task.endpoint.label()
|
||||||
)
|
)
|
||||||
})?;
|
});
|
||||||
|
}
|
||||||
|
let mut state = state
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
state.skipped.remove(&key);
|
||||||
state.installations.insert(
|
state.installations.insert(
|
||||||
key,
|
key,
|
||||||
WebhookInstallation {
|
WebhookInstallation {
|
||||||
group: request.group.to_string(),
|
group: task.group,
|
||||||
endpoint: request.endpoint.clone(),
|
endpoint: task.endpoint,
|
||||||
repo: request.repo.name.clone(),
|
repo: task.repo.name,
|
||||||
url: request.url.to_string(),
|
url: task.url,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn uninstall_webhook_task(task: WebhookUninstallTask) -> Result<Option<String>> {
|
||||||
|
crate::logln!(
|
||||||
|
" {} {} {}",
|
||||||
|
style(if task.dry_run {
|
||||||
|
"would uninstall"
|
||||||
|
} else {
|
||||||
|
"uninstall"
|
||||||
|
})
|
||||||
|
.red()
|
||||||
|
.bold(),
|
||||||
|
style(&task.installation.repo).cyan(),
|
||||||
|
style(format!("from {}", task.installation.endpoint.label())).dim()
|
||||||
|
);
|
||||||
|
if task.dry_run {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(site) = task.site else {
|
||||||
|
crate::logln!(
|
||||||
|
" {} {} {}",
|
||||||
|
style("skip").yellow().bold(),
|
||||||
|
style(&task.installation.repo).cyan(),
|
||||||
|
style(format!("unknown site {}", task.installation.endpoint.site)).dim()
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let client = ProviderClient::new(&site)?;
|
||||||
|
client
|
||||||
|
.uninstall_webhook(
|
||||||
|
&task.installation.endpoint,
|
||||||
|
&task.installation.repo,
|
||||||
|
&task.installation.url,
|
||||||
|
)
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to uninstall webhook for {} from {}",
|
||||||
|
task.installation.repo,
|
||||||
|
task.installation.endpoint.label()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Some(task.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_actionable_webhook_failure_reason(error: &anyhow::Error) -> Option<String> {
|
||||||
|
let text = error
|
||||||
|
.chain()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if text.contains("repository access blocked")
|
||||||
|
|| text.contains("access to this repository has been disabled")
|
||||||
|
|| text.contains("repository has been disabled")
|
||||||
|
|| text.contains("disabled by github staff")
|
||||||
|
|| text.contains("github.com/tos")
|
||||||
|
{
|
||||||
|
return Some("provider blocked access".to_string());
|
||||||
|
}
|
||||||
|
if text.contains("repository was archived")
|
||||||
|
|| text.contains("archived so is read-only")
|
||||||
|
|| text.contains("repository is archived")
|
||||||
|
{
|
||||||
|
return Some("repository is archived/read-only".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn webhook_installation_key(group: &str, endpoint: &EndpointConfig, repo: &str) -> String {
|
fn webhook_installation_key(group: &str, endpoint: &EndpointConfig, repo: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}\t{}\t{:?}\t{}\t{}",
|
"{}\t{}\t{:?}\t{}\t{}",
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ fn cli_accepts_webhook_install() {
|
|||||||
"sync-1",
|
"sync-1",
|
||||||
"--repo-pattern",
|
"--repo-pattern",
|
||||||
"^repo$",
|
"^repo$",
|
||||||
|
"--jobs",
|
||||||
|
"6",
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ fn cli_accepts_webhook_install() {
|
|||||||
assert_eq!(args.secret, Some("secret".to_string()));
|
assert_eq!(args.secret, Some("secret".to_string()));
|
||||||
assert_eq!(args.group, Some("sync-1".to_string()));
|
assert_eq!(args.group, Some("sync-1".to_string()));
|
||||||
assert_eq!(args.repo_pattern, Some("^repo$".to_string()));
|
assert_eq!(args.repo_pattern, Some("^repo$".to_string()));
|
||||||
|
assert_eq!(args.jobs, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -121,6 +124,8 @@ fn cli_accepts_webhook_uninstall() {
|
|||||||
"--group",
|
"--group",
|
||||||
"sync-1",
|
"sync-1",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
|
"--jobs",
|
||||||
|
"3",
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -129,4 +134,5 @@ fn cli_accepts_webhook_uninstall() {
|
|||||||
};
|
};
|
||||||
assert_eq!(args.group, Some("sync-1".to_string()));
|
assert_eq!(args.group, Some("sync-1".to_string()));
|
||||||
assert!(args.dry_run);
|
assert!(args.dry_run);
|
||||||
|
assert_eq!(args.jobs, 3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ use super::*;
|
|||||||
use crate::config::{
|
use crate::config::{
|
||||||
EndpointConfig, MirrorConfig, NamespaceKind, SiteConfig, TokenConfig, Visibility,
|
EndpointConfig, MirrorConfig, NamespaceKind, SiteConfig, TokenConfig, Visibility,
|
||||||
};
|
};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verifies_github_hmac_signature() {
|
fn verifies_github_hmac_signature() {
|
||||||
@@ -150,6 +153,60 @@ fn webhook_state_persists_installations() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocked_webhook_install_is_skipped_and_recorded() {
|
||||||
|
let (api_url, handle) = one_request_server(
|
||||||
|
"403 Forbidden",
|
||||||
|
r#"{"message":"Repository access blocked","block":{"html_url":"https://github.com/tos"}}"#,
|
||||||
|
|request| assert!(request.starts_with("GET /repos/alice/repo/hooks ")),
|
||||||
|
);
|
||||||
|
let site = SiteConfig {
|
||||||
|
api_url: Some(api_url),
|
||||||
|
..site("github", ProviderKind::Github)
|
||||||
|
};
|
||||||
|
let state = Arc::new(Mutex::new(WebhookState::default()));
|
||||||
|
|
||||||
|
install_webhook_task(
|
||||||
|
WebhookInstallTask {
|
||||||
|
site,
|
||||||
|
group: "sync-1".to_string(),
|
||||||
|
endpoint: endpoint("github", NamespaceKind::User, "alice"),
|
||||||
|
repo: RemoteRepo {
|
||||||
|
name: "repo".to_string(),
|
||||||
|
clone_url: "https://github.com/alice/repo.git".to_string(),
|
||||||
|
private: true,
|
||||||
|
description: None,
|
||||||
|
},
|
||||||
|
url: "https://mirror.example.test/webhook".to_string(),
|
||||||
|
secret: "secret".to_string(),
|
||||||
|
dry_run: false,
|
||||||
|
},
|
||||||
|
&state,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let state = state.lock().unwrap();
|
||||||
|
assert!(state.installations.is_empty());
|
||||||
|
assert_eq!(state.skipped.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
state.skipped.values().next().unwrap().reason,
|
||||||
|
"provider blocked access"
|
||||||
|
);
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archived_webhook_failure_is_non_actionable() {
|
||||||
|
let error = anyhow::anyhow!(
|
||||||
|
"POST https://api.github.com/repos/acme/repo/hooks returned 403 Forbidden: Repository was archived so is read-only."
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
non_actionable_webhook_failure_reason(&error).as_deref(),
|
||||||
|
Some("repository is archived/read-only")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn site(name: &str, provider: ProviderKind) -> SiteConfig {
|
fn site(name: &str, provider: ProviderKind) -> SiteConfig {
|
||||||
SiteConfig {
|
SiteConfig {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
@@ -168,3 +225,30 @@ fn endpoint(site: &str, kind: NamespaceKind, namespace: &str) -> EndpointConfig
|
|||||||
namespace: namespace.to_string(),
|
namespace: namespace.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn one_request_server<F>(
|
||||||
|
status: &'static str,
|
||||||
|
body: &'static str,
|
||||||
|
assert_request: F,
|
||||||
|
) -> (String, thread::JoinHandle<()>)
|
||||||
|
where
|
||||||
|
F: FnOnce(&str) + Send + 'static,
|
||||||
|
{
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut buffer = [0_u8; 4096];
|
||||||
|
let bytes = stream.read(&mut buffer).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&buffer[..bytes]).to_string();
|
||||||
|
assert_request(&request);
|
||||||
|
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
(format!("http://{address}"), handle)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user