1use std::sync::{Arc, Mutex};
29use std::time::Duration;
30
31use super::client::{LicenceClient, LicenceClientError};
32use super::relay::{GrantFlood, install_grant};
33use super::{CheckoutGrant, CheckoutGrantStore, Outcome, SignedGrant};
34use crate::chain::ChainManager;
35use crate::mesh_artifact::ArtifactExchange;
36
37pub const EVENT_KIND_CHECKOUT_RENEWED: &str = "cog.checkout.renewed";
39pub const EVENT_KIND_CHECKOUT_LAPSED: &str = "cog.checkout.lapsed";
41
42#[derive(Debug, Clone)]
44pub struct RenewalConfig {
45 pub period: Duration,
47 pub jitter: Duration,
49 pub first_delay: Duration,
51 pub backoff_min: Duration,
53 pub backoff_max: Duration,
55 pub max_pages: u32,
57 pub skip_poll: Duration,
60}
61
62impl Default for RenewalConfig {
63 fn default() -> Self {
64 Self {
65 period: Duration::from_secs(12 * 3600),
66 jitter: Duration::from_secs(30 * 60),
67 first_delay: Duration::from_secs(5 * 60),
68 backoff_min: Duration::from_secs(60),
69 backoff_max: Duration::from_secs(3600),
70 max_pages: 16,
71 skip_poll: Duration::from_secs(5 * 60),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct RenewalReport {
79 pub skipped: bool,
81 pub renewed: u32,
83 pub withdrawn: u32,
85 pub unchanged: u32,
87 pub refused: u32,
89}
90
91pub struct Renewer {
93 store: Arc<CheckoutGrantStore>,
94 exchange: Arc<ArtifactExchange>,
95 client: Arc<dyn LicenceClient>,
96 flood: Arc<dyn GrantFlood>,
97 chain: Option<Arc<ChainManager>>,
98 cfg: RenewalConfig,
99 cursor: Mutex<u64>,
100 cursor_for: Mutex<Option<(u64, String)>>,
102}
103
104fn skip(e: &LicenceClientError) -> bool {
105 matches!(e, LicenceClientError::Refused { status: 0, code } if code == "not_steward" || code == "seed_not_bound" || code == "not_holder")
106}
107
108impl Renewer {
109 pub fn new(
111 store: Arc<CheckoutGrantStore>,
112 exchange: Arc<ArtifactExchange>,
113 client: Arc<dyn LicenceClient>,
114 flood: Arc<dyn GrantFlood>,
115 chain: Option<Arc<ChainManager>>,
116 cfg: RenewalConfig,
117 ) -> Arc<Self> {
118 Arc::new(Self { store, exchange, client, flood, chain, cfg, cursor: Mutex::new(0), cursor_for: Mutex::new(None) })
119 }
120
121 pub fn cursor(&self) -> u64 {
123 *self.cursor.lock().unwrap_or_else(|p| p.into_inner())
124 }
125
126 fn set_cursor(&self, v: u64) {
127 *self.cursor.lock().unwrap_or_else(|p| p.into_inner()) = v;
128 }
129
130 fn reset_cursor_on_rebind(&self) {
133 let now = self.store.held_binding().map(|b| (b.seq, b.device_id));
134 let mut f = self.cursor_for.lock().unwrap_or_else(|p| p.into_inner());
135 if *f != now {
136 *f = now;
137 self.set_cursor(0);
138 }
139 }
140
141 pub async fn run_once(&self) -> Result<RenewalReport, LicenceClientError> {
143 let mut report = RenewalReport::default();
144 self.reset_cursor_on_rebind();
145 let renewed = match self.client.renew().await {
146 Ok(g) => g,
147 Err(e) if skip(&e) => return Ok(RenewalReport { skipped: true, ..report }),
148 Err(e) => return Err(e),
149 };
150 let seed_ctr = renewed.next;
153 self.apply_all(renewed.grants, &mut report).await;
154 for _ in 0..self.cfg.max_pages {
155 let since = self.cursor();
156 let page = self.client.grants_page(since).await?;
157 let bound = seed_ctr.max(since).saturating_add(page.grants.len() as u64);
158 if page.next > bound {
159 return Err(LicenceClientError::BadResponse(format!(
161 "grants page moves the cursor to {} past the bound {bound}",
162 page.next
163 )));
164 }
165 self.apply_all(page.grants, &mut report).await;
166 self.set_cursor(page.next.max(since));
167 if !page.more || page.next <= since {
168 break;
169 }
170 }
171 Ok(report)
172 }
173
174 pub async fn release(&self, cog_id: &str, version: &str) -> Result<RenewalReport, LicenceClientError> {
179 let mut report = RenewalReport::default();
180 let page = match self.client.release(cog_id, version).await {
181 Ok(p) => p,
182 Err(e) if skip(&e) => return Ok(RenewalReport { skipped: true, ..report }),
183 Err(e) => return Err(e),
184 };
185 self.apply_all(page.grants, &mut report).await;
186 Ok(report)
187 }
188
189 async fn apply_all(&self, grants: Vec<SignedGrant>, report: &mut RenewalReport) {
191 let parsed: Vec<(SignedGrant, Option<CheckoutGrant>)> =
192 grants.into_iter().map(|s| { let g = serde_json::from_str(&s.payload).ok(); (s, g) }).collect();
193 let (mut first, rest): (Vec<_>, Vec<_>) =
194 parsed.into_iter().partition(|(_, g)| g.as_ref().is_some_and(CheckoutGrant::is_withdrawal));
195 first.extend(rest);
196 for (s, g) in first {
197 self.apply(&s, g.as_ref(), report).await;
198 }
199 }
200
201 async fn apply(&self, signed: &SignedGrant, g: Option<&CheckoutGrant>, report: &mut RenewalReport) {
202 match install_grant(&self.store, &self.exchange, signed) {
203 Ok(Outcome::Applied | Outcome::AppliedUnsaved) => {
204 let withdrawn = g.is_some_and(CheckoutGrant::is_withdrawal);
205 self.flood.flood(signed).await;
206 if withdrawn {
207 report.withdrawn += 1;
208 } else {
209 report.renewed += 1;
210 }
211 if let (Some(cm), Some(g)) = (&self.chain, g) {
212 let kind = if withdrawn { EVENT_KIND_CHECKOUT_LAPSED } else { EVENT_KIND_CHECKOUT_RENEWED };
213 cm.append("licence", kind, Some(serde_json::json!({
214 "cog_id": g.cog_id, "version": g.version, "seq": g.seq, "grant_id": g.grant_id,
215 "expires_at": g.expires_at, "reason": if withdrawn { "withdrawn by the Seed" } else { "renewed" },
216 })));
217 }
218 }
219 Ok(_) => report.unchanged += 1,
220 Err(e) => {
221 report.refused += 1;
222 tracing::warn!(error = %e, "renewed grant refused by the store");
223 }
224 }
225 }
226
227 pub fn next_delay(&self, failures: u32) -> Duration {
229 if failures == 0 {
230 let j = self.cfg.jitter.as_millis() as u64;
231 let extra = if j == 0 { 0 } else { rand::random::<u64>() % j };
232 return self.cfg.period + Duration::from_millis(extra);
233 }
234 let exp = self.cfg.backoff_min.saturating_mul(1u32 << (failures - 1).min(16));
235 exp.min(self.cfg.backoff_max)
236 }
237
238 pub fn spawn(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
241 tokio::spawn(async move {
242 tokio::time::sleep(self.cfg.first_delay).await;
243 let mut failures = 0u32;
244 loop {
245 let delay = match self.run_once().await {
246 Ok(r) if r.skipped => {
247 failures = 0;
248 self.cfg.skip_poll
249 }
250 Ok(r) => {
251 failures = 0;
252 tracing::info!(renewed = r.renewed, withdrawn = r.withdrawn, unchanged = r.unchanged,
253 refused = r.refused, "checkout renewal pass");
254 self.next_delay(0)
255 }
256 Err(e) => {
257 failures = failures.saturating_add(1);
258 tracing::warn!(error = %e, failures, "checkout renewal failed; backing off");
259 self.next_delay(failures)
260 }
261 };
262 tokio::time::sleep(delay).await;
263 }
264 })
265 }
266}