flagon-io/g1t

public

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

g1t/services/repos/src/transfer.rs

441 lines16,358 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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