Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

480 linesCodeBlameRaw
1//! Transferring a repository to another workspace.
2//!
3//! A repository keeps its id, its name and its git store key; only its
4//! namespace changes, so its git data does not move at all. The path it
5//! left is kept in `repo_redirects`, pointing at its id, so old links, git
6//! remotes and API calls resolve to wherever it is now, however many times
7//! it has moved since. A redirect stops when a repository is made at its
8//! path.
9//!
10//! Everything other services keep under the repository's path or its
11//! workspace's slug follows on `repo.transferred` (see
12//! `g1t_kit::transfer`); the tokens of agents at work on it are moved here,
13//! with identity, before the event goes out, so a run under way keeps
14//! working.
15
16use g1t_contracts::audit::{
17 AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface,
18};
19use g1t_contracts::events::{NewEvent, RepoTransferred};
20use g1t_contracts::identity::TransferRepoScopesArgs;
21use g1t_contracts::repos::{Repo, RepoPath, TransferArgs};
22use g1t_contracts::time::rfc3339;
23use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, new_id};
24use g1t_kit::now_ms;
25use serde::Deserialize;
26use worker::Result;
27
28use crate::registry::Registry;
29use crate::store::GitStore;
30use crate::{Repos, SOURCE, UNVERIFIED, git_ops, not_found};
31
32/// Everything about a transfer that decides whether it may happen, as read
33/// from the request and the database.
34#[derive(Debug, Default)]
35pub struct Facts {
36 /// The actor is a person, not a workspace's or an agent's token.
37 pub person: bool,
38 pub verified: bool,
39 pub role_in_source: Option<Role>,
40 pub role_in_destination: Option<Role>,
41 /// A member (not an owner) of the source with the Admin role on the
42 /// repository, whose member privileges let admins delete and transfer.
43 pub admin_may_transfer: bool,
44 /// The destination's member privileges let its members create a
45 /// repository of this one's visibility.
46 pub may_create_in_destination: bool,
47 pub source: String,
48 pub destination: String,
49 pub name: String,
50 /// Another repository has the name in the destination.
51 pub name_taken: bool,
52 pub is_private: bool,
53 /// The repository's measured bytes.
54 pub bytes: i64,
55 /// The destination is on no plan, so its private storage is capped.
56 pub destination_free: bool,
57 /// What the destination's private repositories hold now.
58 pub destination_private_bytes: i64,
59 /// What a free workspace's private repositories may hold.
60 pub free_private_bytes: i64,
61}
62
63/// Whether the transfer `facts` describe may happen: `Ok`, or why not, in
64/// words for the person asking.
65pub fn check(facts: &Facts) -> std::result::Result<(), (FailureCode, String)> {
66 let refuse = |code, message: String| Err((code, message));
67 if !facts.person {
68 return refuse(
69 FailureCode::Forbidden,
70 "Only a person can transfer a repository. Sign in, or use a personal access token.".into(),
71 );
72 }
73 let source_ok = facts.role_in_source == Some(Role::Owner)
74 || (facts.role_in_source == Some(Role::Member) && facts.admin_may_transfer);
75 if !source_ok {
76 return refuse(
77 FailureCode::Forbidden,
78 format!(
79 "Only an owner of {} can transfer its repositories, unless its member privileges let repository admins.",
80 facts.source
81 ),
82 );
83 }
84 if !facts.verified {
85 return refuse(FailureCode::Forbidden, UNVERIFIED.into());
86 }
87 if facts.destination.is_empty() {
88 return refuse(FailureCode::Invalid, "Say which workspace to transfer it to.".into());
89 }
90 if facts.destination == facts.source {
91 return refuse(
92 FailureCode::Invalid,
93 format!("{}/{} is already in {}.", facts.source, facts.name, facts.destination),
94 );
95 }
96 let destination_ok = facts.role_in_destination == Some(Role::Owner)
97 || (facts.role_in_destination == Some(Role::Member) && facts.may_create_in_destination);
98 if !destination_ok {
99 return refuse(
100 FailureCode::Forbidden,
101 format!(
102 "You can transfer a repository only to a workspace where you can create one, and you cannot in {}.",
103 facts.destination
104 ),
105 );
106 }
107 if facts.name_taken {
108 return refuse(
109 FailureCode::Conflict,
110 format!(
111 "{} already has a repository named {}. Rename or remove that one first.",
112 facts.destination, facts.name
113 ),
114 );
115 }
116 if facts.is_private
117 && facts.destination_free
118 && git_ops::storage_full(facts.destination_private_bytes + facts.bytes, facts.free_private_bytes)
119 {
120 return refuse(
121 FailureCode::PaymentRequired,
122 format!(
123 "{}'s private repositories would hold {:.2} GB, more than the {:.0} GB a free workspace has. Start the g1t plan in {}, or make the repository public first.",
124 facts.destination,
125 (facts.destination_private_bytes + facts.bytes) as f64 / 1e9,
126 facts.free_private_bytes as f64 / 1e9,
127 facts.destination
128 ),
129 );
130 }
131 Ok(())
132}
133
134/// The redirect of an old path, as read with where its repository is now.
135#[derive(Debug, Deserialize)]
136struct Moved {
137 namespace: String,
138 name: String,
139}
140
141impl Registry {
142 /// Moves a repository to `to`, keeping its old path as a redirect. Any
143 /// redirect held by the path it moves to gives way: a repository is
144 /// there now.
145 pub async fn transfer(&self, repo: &Repo, to: &str, cap: i64) -> Result<bool> {
146 let now = rfc3339(now_ms());
147 let results = self.db
148 .batch(vec![
149 self.db
150 .prepare(crate::public_storage::TRANSFER)
151 .bind(&[repo.id.as_str().into(), to.into(), repo.namespace.as_str().into(), (cap as f64).into()])?,
152 self.db
153 .prepare("DELETE FROM repo_redirects WHERE namespace = ?1 AND name = ?2 AND EXISTS (SELECT 1 FROM repos WHERE id = ?3 AND namespace = ?1)")
154 .bind(&[to.into(), repo.name.as_str().into(), repo.id.as_str().into()])?,
155 self.db
156 .prepare(
157 "INSERT OR REPLACE INTO repo_redirects (namespace, name, repo_id, created_at)
158 SELECT ?1, ?2, ?3, ?4 WHERE EXISTS (SELECT 1 FROM repos WHERE id = ?3 AND namespace = ?5)",
159 )
160 .bind(&[
161 repo.namespace.as_str().into(),
162 repo.name.as_str().into(),
163 repo.id.as_str().into(),
164 now.as_str().into(),
165 to.into(),
166 ])?,
167 ])
168 .await?;
169 #[derive(Deserialize)]
170 struct Moved { #[serde(rename = "id")] _id: String }
171 Ok(!results[0].results::<Moved>()?.is_empty())
172 }
173
174 /// Where the repository that left `path` is now, unless a repository
175 /// is at `path` itself.
176 pub async fn resolve_moved(&self, path: &RepoPath) -> Result<Option<RepoPath>> {
177 Ok(self
178 .db
179 .prepare(
180 "SELECT repos.namespace, repos.name FROM repo_redirects
181 JOIN repos ON repos.id = repo_redirects.repo_id AND repos.deleted_at IS NULL
182 WHERE repo_redirects.namespace = ?1 AND repo_redirects.name = ?2
183 AND NOT EXISTS (SELECT 1 FROM repos AS here WHERE here.namespace = ?1 AND here.name = ?2)",
184 )
185 .bind(&[
186 path.namespace.to_lowercase().into(),
187 path.name.to_lowercase().into(),
188 ])?
189 .first::<Moved>(None)
190 .await?
191 .map(|moved| RepoPath {
192 namespace: moved.namespace,
193 name: moved.name,
194 }))
195 }
196
197 /// A repository made at `path` ends any redirect there.
198 pub async fn drop_redirect(&self, path: &RepoPath) -> Result<()> {
199 self.db
200 .prepare("DELETE FROM repo_redirects WHERE namespace = ? AND name = ?")
201 .bind(&[path.namespace.as_str().into(), path.name.as_str().into()])?
202 .run()
203 .await?;
204 Ok(())
205 }
206
207 /// How many repositories (not working copies) a workspace holds.
208 pub async fn count_in(&self, namespace: &str) -> Result<u32> {
209 #[derive(Deserialize)]
210 struct Count {
211 n: u32,
212 }
213 Ok(self
214 .db
215 .prepare("SELECT count(*) AS n FROM repos WHERE namespace = ? AND fork_of IS NULL AND deleted_at IS NULL")
216 .bind(&[namespace.to_lowercase().into()])?
217 .first::<Count>(None)
218 .await?
219 .map_or(0, |count| count.n))
220 }
221
222 /// The repository's measured bytes.
223 pub async fn stored_bytes(&self, id: &str) -> Result<i64> {
224 #[derive(Deserialize)]
225 struct Bytes {
226 stored_bytes: Option<f64>,
227 }
228 Ok(self
229 .db
230 .prepare("SELECT stored_bytes FROM repos WHERE id = ? AND deleted_at IS NULL")
231 .bind(&[id.into()])?
232 .first::<Bytes>(None)
233 .await?
234 .and_then(|row| row.stored_bytes)
235 .unwrap_or(0.0) as i64)
236 }
237}
238
239impl<S: GitStore> Repos<S> {
240 /// `transfer`: see `g1t_contracts::repos::TransferArgs`.
241 pub(crate) async fn transfer(&self, a: TransferArgs) -> Result<Outcome<Repo>> {
242 let viewer = Some(a.actor.clone());
243 let Some(repo) = self.readable(&a.path, &viewer).await? else {
244 return Ok(not_found());
245 };
246 if repo.fork_of.is_some() {
247 return Ok(not_found());
248 }
249 let destination = a.to.trim().to_lowercase();
250 let source = repo.namespace.clone();
251 // A repository deleted there but not yet purged holds the name too.
252 let taken = !destination.is_empty()
253 && self
254 .registry
255 .by_path_any(&RepoPath {
256 namespace: destination.clone(),
257 name: repo.name.clone(),
258 })
259 .await?
260 .is_some();
261 let private_checks = repo.is_private && !destination.is_empty() && destination != source;
262 let (bytes, held, free) = if private_checks {
263 let free = git_ops::is_free(self.billing.as_ref(), &destination).await;
264 (
265 self.registry.stored_bytes(&repo.id).await?,
266 self.registry.private_bytes(&destination).await.unwrap_or(0),
267 free,
268 )
269 } else {
270 (0, 0, false)
271 };
272 let viewer = Some(a.actor.clone());
273 let admin_may_transfer = crate::registry::role(&repo, &viewer) == Some(g1t_contracts::access::RepoRole::Admin)
274 && a.actor.privileges_in(&source).members_can_delete_repositories;
275 let may_create_in_destination = a
276 .actor
277 .role_in(&destination)
278 .is_some_and(|role| a.actor.privileges_in(&destination).may_create(role, repo.is_private));
279 let facts = Facts {
280 person: a.actor.kind == PrincipalKind::User,
281 verified: a.actor.verified,
282 role_in_source: a.actor.role_in(&source),
283 role_in_destination: a.actor.role_in(&destination),
284 admin_may_transfer,
285 may_create_in_destination,
286 source: source.clone(),
287 destination: destination.clone(),
288 name: repo.name.clone(),
289 name_taken: taken,
290 is_private: repo.is_private,
291 bytes,
292 destination_free: free,
293 destination_private_bytes: held,
294 free_private_bytes: self.free_private_bytes,
295 };
296 if let Err((code, message)) = check(&facts) {
297 return Ok(Outcome::fail(code, message));
298 }
299
300 if !self.registry.transfer(&repo, &destination, self.free_public_bytes).await? {
301 return Ok(Outcome::fail(FailureCode::Forbidden, "This transfer would exceed the destination workspace's public storage limit."));
302 }
303 let moved = Repo {
304 namespace: destination.clone(),
305 ..repo.clone()
306 };
307 let from = RepoPath {
308 namespace: source.clone(),
309 name: repo.name.clone(),
310 };
311 let to = RepoPath {
312 namespace: destination.clone(),
313 name: repo.name.clone(),
314 };
315 // Agents at work on it keep their scope, which names it by path.
316 if let Some(identity) = &self.identity {
317 let moved_scopes: Result<bool> = g1t_kit::call(
318 identity,
319 "transfer_repo_scopes",
320 &TransferRepoScopesArgs {
321 from: from.clone(),
322 to: to.clone(),
323 },
324 )
325 .await;
326 if let Err(error) = moved_scopes {
327 worker::console_error!("agent scopes for {} not moved: {error}", repo.id);
328 }
329 }
330 self.publish(NewEvent {
331 kind: "repo.transferred",
332 source: SOURCE,
333 repo_id: Some(repo.id.clone()),
334 actor: Some(a.actor.id.clone()),
335 data: RepoTransferred {
336 repo_id: repo.id.clone(),
337 name: repo.name.clone(),
338 from: source.clone(),
339 to: destination.clone(),
340 },
341 })
342 .await?;
343 self.record_transfer(&a, &from, &to).await;
344 Ok(Outcome::Ok(moved))
345 }
346
347 /// One entry in each workspace's audit log: the one it left and the
348 /// one it joined.
349 async fn record_transfer(&self, a: &TransferArgs, from: &RepoPath, to: &RepoPath) {
350 let request_id = new_id("req", now_ms());
351 let entry = |workspace: &str, repo: &RepoPath, message: String| NewAuditEntry {
352 actor: AuditActor::of(&a.actor),
353 action: "repo.transferred".to_owned(),
354 surface: a.surface.unwrap_or(Surface::Web),
355 target: AuditTarget {
356 workspace: workspace.to_owned(),
357 repo: Some(format!("{}/{}", repo.namespace, repo.name)),
358 ..AuditTarget::default()
359 },
360 outcome: AuditOutcome::Allowed,
361 rule: "owner".to_owned(),
362 result: Some("ok".to_owned()),
363 message: Some(message),
364 request_id: request_id.clone(),
365 };
366 let entries = vec![
367 entry(
368 &from.namespace,
369 from,
370 format!("Transferred to {}/{}", to.namespace, to.name),
371 ),
372 entry(
373 &to.namespace,
374 to,
375 format!("Transferred from {}/{}", from.namespace, from.name),
376 ),
377 ];
378 let recorded: Result<u32> =
379 g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries }).await;
380 if let Err(error) = recorded {
381 worker::console_error!("transfer audit entries not recorded: {error}");
382 }
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn facts() -> Facts {
391 Facts {
392 person: true,
393 verified: true,
394 role_in_source: Some(Role::Owner),
395 role_in_destination: Some(Role::Owner),
396 source: "syntaqx".into(),
397 destination: "flagon-io".into(),
398 name: "g1t".into(),
399 free_private_bytes: 1_000_000_000,
400 ..Facts::default()
401 }
402 }
403
404 fn refused(facts: &Facts) -> FailureCode {
405 check(facts).unwrap_err().0
406 }
407
408 #[test]
409 fn an_owner_of_both_may_transfer() {
410 assert!(check(&facts()).is_ok());
411 }
412
413 #[test]
414 fn member_privileges_let_an_admin_transfer_where_they_may_create() {
415 let member = Facts { role_in_source: Some(Role::Member), role_in_destination: Some(Role::Member), ..facts() };
416 assert_eq!(refused(&member), FailureCode::Forbidden);
417 let allowed = Facts { admin_may_transfer: true, may_create_in_destination: true, ..member };
418 assert!(check(&allowed).is_ok());
419 assert_eq!(
420 refused(&Facts { may_create_in_destination: false, admin_may_transfer: true, role_in_source: Some(Role::Member), role_in_destination: Some(Role::Member), ..facts() }),
421 FailureCode::Forbidden
422 );
423 }
424
425 #[test]
426 fn needs_a_verified_person_who_owns_both() {
427 assert_eq!(refused(&Facts { person: false, ..facts() }), FailureCode::Forbidden);
428 assert_eq!(refused(&Facts { verified: false, ..facts() }), FailureCode::Forbidden);
429 assert_eq!(
430 refused(&Facts { role_in_source: Some(Role::Member), ..facts() }),
431 FailureCode::Forbidden
432 );
433 assert_eq!(
434 refused(&Facts { role_in_destination: Some(Role::Member), ..facts() }),
435 FailureCode::Forbidden
436 );
437 assert_eq!(refused(&Facts { role_in_destination: None, ..facts() }), FailureCode::Forbidden);
438 }
439
440 #[test]
441 fn needs_somewhere_else_with_the_name_free() {
442 assert_eq!(refused(&Facts { destination: String::new(), ..facts() }), FailureCode::Invalid);
443 assert_eq!(
444 refused(&Facts { destination: "syntaqx".into(), role_in_destination: Some(Role::Owner), ..facts() }),
445 FailureCode::Invalid
446 );
447 let (code, message) = check(&Facts { name_taken: true, ..facts() }).unwrap_err();
448 assert_eq!(code, FailureCode::Conflict);
449 assert!(message.contains("flagon-io already has a repository named g1t"));
450 }
451
452 #[test]
453 fn a_free_destination_takes_private_repositories_within_its_storage() {
454 let heavy = Facts {
455 is_private: true,
456 destination_free: true,
457 destination_private_bytes: 900_000_000,
458 bytes: 200_000_000,
459 ..facts()
460 };
461 assert_eq!(refused(&heavy), FailureCode::PaymentRequired);
462 // Public, or a destination on the plan: no cap.
463 assert!(check(&Facts { is_private: false, ..heavy }).is_ok());
464 let heavy = Facts {
465 is_private: true,
466 destination_free: true,
467 destination_private_bytes: 900_000_000,
468 bytes: 200_000_000,
469 ..facts()
470 };
471 assert!(check(&Facts { destination_free: false, ..heavy }).is_ok());
472 let light = Facts {
473 is_private: true,
474 destination_free: true,
475 bytes: 10_000,
476 ..facts()
477 };
478 assert!(check(&light).is_ok());
479 }
480}