Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member
Packages (docs/PACKAGES.md, phase 1) - services/packages: a new service and D1 (g1t-packages). Files by SHA-256 through a BlobStore port: R2 in production, S3-compatible (MinIO in the self-host compose file) self-hosted. Uploads in fixed 10 MiB multipart parts with the SHA-256 state kept between requests; refused uploads clean up after themselves. - An OCI Distribution 1.1 registry on g1t.sh/v2: token exchange (docker login with a g1t token), pull, push (chunked, monolithic, mounts), manifests, indexes, artifacts and referrers, tags, deletes. Access is the linked repository's, else the workspace's; G1T_TOKEN and agents push their own repository's images. Scopes packages:read, write and delete. Anonymous and signed-in rate limits (optional bindings). - Events (package.*), audit, webhooks (workspace-level packages too), an hourly sweep of unused files, and workspace renames, deletes and soft deletes followed. - Free amounts from billing: a free workspace's pushes past 10 GB public or 500 MB private are refused; the plan pays past them at R2's price plus the margin (package_storage, measured daily, billing 0031). - Site: Packages for a workspace and each package (pull commands, versions, tags, platforms, attached signatures and attestations, settings); the sidebar link is real. Docs: packages and containers guides, self-hosting. - Self-host: a scheduler that runs services' crons, which workerd does not. Workspace deletion - An owner deletes a workspace in one step, typing its name: its repositories, projects, apps and packages go with it. It is kept 30 days (WORKSPACE_RESTORE_DAYS) and staff can restore it from sudo; then it is purged as deleting always did. workspace.deleting and workspace.restored events; each service hides and restores exactly what went with the workspace. - flagon-io can never be deleted or purged, by anyone: compiled in, flagged on its row (so a rename keeps it) and PROTECTED_WORKSPACES. Members - Members is in the main sidebar for every member, with Teams (soon) beside it; it is no longer a settings page.
| 985 | 985 | ] | |
| 986 | 986 | ||
| 987 | 987 | [[package]] | |
| 988 | + | name = "g1t-packages" | |
| 989 | + | version = "0.1.0" | |
| 990 | + | dependencies = [ | |
| 991 | + | "base64 0.22.1", | |
| 992 | + | "futures-util", | |
| 993 | + | "g1t-contracts", | |
| 994 | + | "g1t-kit", | |
| 995 | + | "hex", | |
| 996 | + | "hmac 0.12.1", | |
| 997 | + | "serde", | |
| 998 | + | "serde_json", | |
| 999 | + | "sha2 0.10.9", | |
| 1000 | + | "worker", | |
| 1001 | + | ] | |
| 1002 | + | ||
| 1003 | + | [[package]] | |
| 988 | 1004 | name = "g1t-repos" | |
| 989 | 1005 | version = "0.1.0" | |
| 990 | 1006 | dependencies = [ |
| 1 | 1 | [workspace] | |
| 2 | 2 | resolver = "3" | |
| 3 | − | members = ["apps/api", "crates/*", "services/actions", "services/billing", "services/events", "services/identity", "services/integrations", "services/webhooks", "services/repos", "services/search", "services/security", "services/work"] | |
| 3 | + | members = ["apps/api", "crates/*", "services/actions", "services/billing", "services/events", "services/identity", "services/integrations", "services/packages", "services/webhooks", "services/repos", "services/search", "services/security", "services/work"] | |
| 4 | 4 | ||
| 5 | 5 | [workspace.package] | |
| 6 | 6 | edition = "2024" |
| 702 | 702 | } | |
| 703 | 703 | Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.", | |
| 704 | 704 | Op::DeleteWorkspace => { | |
| 705 | − | "Delete a workspace. Owners only, signed in as a person, and confirm must be the workspace's slug. It must hold no repositories (move them with transfer_repo first) and no projects, and billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once. Its members, access tokens, webhooks, integrations and workspace secrets are removed; its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again." | |
| 705 | + | "Delete a workspace and everything in it. Owners only, signed in as a person, and confirm must be the workspace's slug. Billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once and its plan ends. Its repositories, projects and apps go with it at once, nobody can reach it, and its access tokens stop working. It is kept for 30 days, when g1t's support can restore it as it was; then it is purged, with its webhooks, integrations and workspace secrets. Its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again once it is purged. Some workspaces, such as Flagon's, can never be deleted." | |
| 706 | 706 | } | |
| 707 | 707 | Op::UpdateWorkspace => { | |
| 708 | 708 | "Change a workspace's display name and description, and what every member gets on each of its repositories (base_permission: none, read, write or admin). Only the fields given are changed; give at least one. An empty name falls back to the slug, which this never changes (that is a rename, on Settings); an empty description clears it. Owners only, signed in as a person. Returns the workspace as it is now." |
| 88 | 88 | "confirm": "acme-labs" | |
| 89 | 89 | }, | |
| 90 | 90 | "response": true, | |
| 91 | − | "notes": "Refused with `409` while the workspace holds repositories or projects, and with `402` while billing cannot settle it, each with a message that says what to do. See [Delete a workspace](/guides/workspaces/#delete-a-workspace)." | |
| 91 | + | "notes": "Refused with `402` while billing cannot settle it, with a message that says what to do, and with `403` for anyone but an owner signed in as a person, or for a protected workspace. Its repositories, projects and apps go with it; g1t's support can restore it for 30 days. See [Delete a workspace](/guides/workspaces/#delete-a-workspace)." | |
| 92 | 92 | }, | |
| 93 | 93 | "list_emails": { | |
| 94 | 94 | "response": { |
| 231 | 231 | default_action: None, | |
| 232 | 232 | actions: &[ | |
| 233 | 233 | a("create", Op::CreateWorkspace, "Create a workspace"), | |
| 234 | − | a("delete", Op::DeleteWorkspace, "Delete an empty workspace"), | |
| 234 | + | a("delete", Op::DeleteWorkspace, "Delete a workspace and everything in it (support can restore it for 30 days)"), | |
| 235 | 235 | a("update", Op::UpdateWorkspace, "Change its name, description or base permission"), | |
| 236 | 236 | a("list_invites", Op::ListWorkspaceInvites, "Its invites"), | |
| 237 | 237 | a("invite_member", Op::InviteMember, "Invite an email address"), |
| 73 | 73 | items: [ | |
| 74 | 74 | { label: 'Projects', slug: 'guides/projects' }, | |
| 75 | 75 | { label: 'Deployments', slug: 'guides/deployments' }, | |
| 76 | + | { label: 'Packages', slug: 'guides/packages' }, | |
| 77 | + | { label: 'Container images', slug: 'guides/containers' }, | |
| 76 | 78 | { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' }, | |
| 77 | 79 | { label: 'Security', slug: 'guides/security' }, | |
| 78 | 80 | ], |
| 105 | 105 | ||
| 106 | 106 | Owners always have Admin, whatever it says. Only owners can change it: | |
| 107 | 107 | ||
| 108 | − | 1. Open the workspace's **Settings → Members**, `g1t.sh/<workspace>/-/people`. | |
| 108 | + | 1. Open the workspace's **Members** in the sidebar, `g1t.sh/<workspace>/-/people`. Every member can see it; owners manage it. | |
| 109 | 109 | 2. Under **Base permission**, choose one. | |
| 110 | 110 | ||
| 111 | 111 | It takes effect on everyone's next request. To give one member more on | |
| 178 | 178 | ||
| 179 | 179 | Owners see every outside collaborator, and the repositories and roles each | |
| 180 | 180 | has, on the **Outside collaborators** tab of the workspace's | |
| 181 | − | **Settings → Members**. **Convert to member** adds one to the workspace | |
| 181 | + | **Members**. **Convert to member** adds one to the workspace | |
| 182 | 182 | (see [members and roles](/guides/workspaces/#members-and-roles)); the | |
| 183 | 183 | roles they have stay, and the base permission adds to them. | |
| 184 | 184 |
| 13 | 13 | ||
| 14 | 14 | - **A repository's lifecycle**, wherever the change was made, g1t.sh | |
| 15 | 15 | included. A transfer is recorded in both workspaces' logs, and a | |
| 16 | − | workspace's deletion as `workspace.deleted`, its log's last entry. | |
| 16 | + | workspace's deletion as `workspace.deleted`. A restore by g1t's support | |
| 17 | + | is recorded as `workspace.restored`, and the purge 30 days after a | |
| 18 | + | deletion as `workspace.purged`, its log's last entry. | |
| 17 | 19 | ||
| 18 | 20 | | Action | Recorded when | | |
| 19 | 21 | | --- | --- | | |
| 27 | 29 | | `repo.collaborator_added`, `repo.collaborator_role_changed`, `repo.collaborator_removed` | Someone was given a role on it, had it changed, or lost it. See [access and roles](/guides/access-and-roles/). | | |
| 28 | 30 | | `repo.invitation_created`, `repo.invitation_revoked` | Someone was invited to it, or an invitation was withdrawn. | | |
| 29 | 31 | | `workspace.base_permission_changed` | An owner changed what members get on every repository. | | |
| 32 | + | | `workspace.deleted`, `workspace.restored`, `workspace.purged` | An owner deleted the workspace, g1t's support restored it, or it was removed for good. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). | | |
| 30 | 33 | ||
| 31 | 34 | Through the API and the MCP server, the call itself is recorded under its | |
| 32 | 35 | operation's name too, such as `delete_repo`. See |
| 317 | 317 | | Group | Scopes | | |
| 318 | 318 | | --- | --- | | |
| 319 | 319 | | Repositories & code | `repo:read`, `repo:write`, `code:read`, `code:write` | | |
| 320 | + | | Packages | `packages:read`, `packages:write` | | |
| 320 | 321 | | Issues & pull requests | `issues:read`, `issues:write`, `pull_requests:read`, `pull_requests:write` | | |
| 321 | 322 | | Agents | `agents:run` | | |
| 322 | 323 | | Workflows | `workflows:read`, `workflows:write` | | |
| 324 | 325 | | Account | `account:read`, `account:write` | | |
| 325 | 326 | | Workspace | `workspace:read`, `access:read`, `webhooks:read`, `secrets:read` | | |
| 326 | 327 | | Runners | `runners:read` | | |
| 327 | − | | Dangerous | `repo:admin`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` | | |
| 328 | + | | Dangerous | `repo:admin`, `packages:delete`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` | | |
| 328 | 329 | ||
| 329 | 330 | Ticking a higher level ticks the lower ones of its resource and greys | |
| 330 | 331 | them out: tick `issues:write` and `issues:read` is ticked too. Untick | |
| 337 | 338 | | `repo:admin` | Rename, archive, transfer, delete or change who can see a repository, and dismiss security alerts | | |
| 338 | 339 | | `code:read` | Clone and fetch private repositories with git | | |
| 339 | 340 | | `code:write` | Push commits with git | | |
| 341 | + | | `packages:read` | Pull container images and install private [packages](/guides/packages/). Public ones need no scope. | | |
| 342 | + | | `packages:write` | Push container images and publish packages | | |
| 343 | + | | `packages:delete` | Delete packages and their versions | | |
| 340 | 344 | | `issues:read` | Read issues, comments and plans | | |
| 341 | 345 | | `issues:write` | Open, edit, close and comment on issues | | |
| 342 | 346 | | `pull_requests:read` | Read pull requests, their changes, sessions and merge queues | | |
| 395 | 399 | | --- | --- | | |
| 396 | 400 | | Read only | Every `read` scope. Changes nothing. | | |
| 397 | 401 | | Agent | Every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Reads everything, works on issues and pull requests, pushes code and puts g1t to work. No admin scope. | | |
| 398 | − | | CI | `repo:read`, `code:read`, `code:write`, `workflows:read` and `workflows:write`. Clones and pushes code, and runs workflows. | | |
| 402 | + | | CI | `repo:read`, `code:read`, `code:write`, `packages:read`, `packages:write`, `workflows:read` and `workflows:write`. Clones and pushes code, pushes and pulls packages, and runs workflows. | | |
| 399 | 403 | | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. | | |
| 400 | 404 | ||
| 401 | 405 | Admin scopes change things that are hard to undo, or decide who can reach |
| 1 | + | --- | |
| 2 | + | title: Container images | |
| 3 | + | description: Push and pull container images on g1t.sh with docker, in workflows with G1T_TOKEN, and what to do about large layers. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | g1t.sh is a container registry. Images are named after their workspace, | |
| 7 | + | pushed and pulled with `docker` or any client of the OCI Distribution | |
| 8 | + | protocol, and have the access of the repository they are linked to (see | |
| 9 | + | [who can see and publish a package](/guides/packages/#who-can-see-and-publish-a-package)). | |
| 10 | + | ||
| 11 | + | ```text | |
| 12 | + | g1t.sh/<workspace>/<name>[:<tag>] | |
| 13 | + | ``` | |
| 14 | + | ||
| 15 | + | ## Sign in | |
| 16 | + | ||
| 17 | + | Use your username, and an [access token](https://g1t.sh/settings/tokens) as | |
| 18 | + | the password: one with full access, or with `packages:write` (to push) or | |
| 19 | + | `packages:read` (to pull private images). | |
| 20 | + | ||
| 21 | + | ```sh | |
| 22 | + | echo "$G1T_TOKEN" | docker login g1t.sh -u <you> --password-stdin | |
| 23 | + | ``` | |
| 24 | + | ||
| 25 | + | Public images pull without signing in. | |
| 26 | + | ||
| 27 | + | ## Push | |
| 28 | + | ||
| 29 | + | Tag the image with its address and push it: | |
| 30 | + | ||
| 31 | + | ```sh | |
| 32 | + | docker build -t g1t.sh/acme/web:1.4.0 . | |
| 33 | + | docker push g1t.sh/acme/web:1.4.0 | |
| 34 | + | ``` | |
| 35 | + | ||
| 36 | + | The first push makes the package. When its name starts with a repository | |
| 37 | + | of the workspace, here `acme/web`, it is linked to that repository and | |
| 38 | + | follows its visibility and roles; pushing needs Write on it. Otherwise it is | |
| 39 | + | the workspace's, private, and needs the workspace's Write base permission. | |
| 40 | + | ||
| 41 | + | Pushing a tag again moves it to the new image. Layers already on g1t are | |
| 42 | + | not uploaded again, and images in the same workspace share them. | |
| 43 | + | ||
| 44 | + | Multi-platform images (`docker buildx build --platform linux/amd64,linux/arm64 --push`), | |
| 45 | + | OCI image indexes, and artifacts attached to an image with a `subject` | |
| 46 | + | (signatures, SBOMs, attestations) are all kept; the registry lists an image's | |
| 47 | + | attached artifacts at `/v2/<name>/referrers/<digest>`. | |
| 48 | + | ||
| 49 | + | ## Pull | |
| 50 | + | ||
| 51 | + | ```sh | |
| 52 | + | docker pull g1t.sh/acme/web:1.4.0 | |
| 53 | + | docker pull g1t.sh/acme/web@sha256:… | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | ## In workflows | |
| 57 | + | ||
| 58 | + | A workflow's `G1T_TOKEN` is the workspace's own token for the run, and can | |
| 59 | + | push and pull the workspace's images: | |
| 60 | + | ||
| 61 | + | ```yaml | |
| 62 | + | jobs: | |
| 63 | + | image: | |
| 64 | + | runs-on: ubuntu-latest | |
| 65 | + | steps: | |
| 66 | + | - uses: actions/checkout@v4 | |
| 67 | + | - name: Sign in to g1t.sh | |
| 68 | + | run: echo "${{ secrets.G1T_TOKEN }}" | docker login g1t.sh -u g1t --password-stdin | |
| 69 | + | - name: Build and push | |
| 70 | + | run: | | |
| 71 | + | docker build -t g1t.sh/${{ github.repository }}:${{ github.sha }} . | |
| 72 | + | docker push g1t.sh/${{ github.repository }}:${{ github.sha }} | |
| 73 | + | ``` | |
| 74 | + | ||
| 75 | + | Runs that get no secrets (a pull request from someone without Write) get an | |
| 76 | + | empty token, and cannot push. See | |
| 77 | + | [secrets and variables](/guides/actions/#secrets-and-variables). | |
| 78 | + | ||
| 79 | + | ## The 100 MB limit | |
| 80 | + | ||
| 81 | + | A single request to g1t.sh may carry at most 100 MB. `docker push` sends | |
| 82 | + | each layer whole, in one request, and so do the other common clients | |
| 83 | + | (`crane push` and `oras push` included), so a layer over 100 MB, as | |
| 84 | + | compressed for the push, is refused. | |
| 85 | + | ||
| 86 | + | What you see depends on where it is refused. Usually it is before the | |
| 87 | + | request reaches g1t, and `docker push` stops with a bare status: | |
| 88 | + | ||
| 89 | + | ```text | |
| 90 | + | unknown: failed commit on ref "layer-sha256:…": unexpected status from PUT request to https://g1t.sh/v2/acme/web/blobs/uploads/…?digest=sha256%3A…: 413 Request Entity Too Large | |
| 91 | + | ``` | |
| 92 | + | ||
| 93 | + | (the request may be a `PATCH` instead of a `PUT`, and some versions of | |
| 94 | + | Docker show the HTML page that came with the 413 instead). When g1t sees | |
| 95 | + | the request itself, the error is `SIZE_INVALID`, with a message naming the | |
| 96 | + | limit and this page. | |
| 97 | + | ||
| 98 | + | A client that uploads a layer in chunks, each its own request under | |
| 99 | + | 100 MB, is not limited: the layer can then be any size. | |
| 100 | + | ||
| 101 | + | To stay under the limit, keep each layer under 100 MB: | |
| 102 | + | ||
| 103 | + | - build in stages, and copy only what the image needs into the last one; | |
| 104 | + | - split a large `RUN` or `COPY` into several, so each makes its own layer; | |
| 105 | + | - leave caches, build tools and test data out of the image (`.dockerignore`). | |
| 106 | + | ||
| 107 | + | An installation you [run yourself](/guides/self-hosting/) has no such | |
| 108 | + | limit. | |
| 109 | + | ||
| 110 | + | ## Storage and pull limits | |
| 111 | + | ||
| 112 | + | Without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), a | |
| 113 | + | workspace's public packages may hold 10 GB and its private ones 500 MB, | |
| 114 | + | each file counted once. A push that would go past either is refused with | |
| 115 | + | `DENIED` and a message saying how much is used; layers the workspace | |
| 116 | + | already holds add nothing. On the plan nothing is refused: storage past | |
| 117 | + | the free amounts is charged. | |
| 118 | + | ||
| 119 | + | Anonymous pulls are limited to 300 requests a minute from each address, and | |
| 120 | + | signed-in ones to 5,000 a minute for each person, workspace or agent. | |
| 121 | + | Past the limit, requests are answered `429` with `TOOMANYREQUESTS` and a | |
| 122 | + | `Retry-After`; docker waits and tries again. Signing in raises the limit. | |
| 123 | + | ||
| 124 | + | ## Delete | |
| 125 | + | ||
| 126 | + | Deleting needs Admin on the linked repository, or for an unlinked image, an | |
| 127 | + | owner of the workspace; a token needs `packages:delete`. | |
| 128 | + | ||
| 129 | + | The registry protocol's `DELETE` removes a tag, or a whole version by its | |
| 130 | + | digest (with every tag that points to it): | |
| 131 | + | ||
| 132 | + | ```sh | |
| 133 | + | TOKEN=$(curl -s -u <you>:<token> "https://g1t.sh/v2/token?scope=repository:acme/web:delete" | jq -r .token) | |
| 134 | + | curl -X DELETE -H "Authorization: Bearer $TOKEN" https://g1t.sh/v2/acme/web/manifests/1.4.0 | |
| 135 | + | curl -X DELETE -H "Authorization: Bearer $TOKEN" https://g1t.sh/v2/acme/web/manifests/sha256:… | |
| 136 | + | ``` | |
| 137 | + | ||
| 138 | + | Layers no version uses any more are deleted from storage a day later. | |
| 139 | + | ||
| 140 | + | ## Errors | |
| 141 | + | ||
| 142 | + | | Error | Means | | |
| 143 | + | | --- | --- | | |
| 144 | + | | `UNAUTHORIZED` | Not signed in, or the token is wrong or expired. `docker login g1t.sh` again. | | |
| 145 | + | | `DENIED` | Signed in, but your role or your token's scopes do not allow it. The message says which. | | |
| 146 | + | | `NAME_UNKNOWN` | No such image, or one you cannot see. | | |
| 147 | + | | `MANIFEST_UNKNOWN`, `BLOB_UNKNOWN` | No such tag, digest or layer in that image. | | |
| 148 | + | | `NAME_INVALID` | Names are lowercase letters and digits, separated by `.`, `_`, `__`, `-` or `/`, and start with a workspace. | | |
| 149 | + | | `SIZE_INVALID`, or a bare `413` | A request over [the 100 MB limit](#the-100-mb-limit). | | |
| 150 | + | | `TOOMANYREQUESTS` | Too many requests in a minute; see [the limits](#storage-and-pull-limits). | | |
| 151 | + | | `DIGEST_INVALID` | What was uploaded does not have the digest the client said. Push again. | |
| 1 | + | --- | |
| 2 | + | title: Packages | |
| 3 | + | description: Publish and install packages beside your code, with the same people, roles and tokens. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | A workspace can publish packages to g1t and install them from it, beside | |
| 7 | + | the code they are built from. Container images come first; npm, Composer, | |
| 8 | + | Cargo and Go follow. Each registry speaks its tool's own protocol, so | |
| 9 | + | `docker` works with nothing but a login and an address. | |
| 10 | + | ||
| 11 | + | | Registry | Address | Guide | | |
| 12 | + | | --- | --- | --- | | |
| 13 | + | | Container images | `g1t.sh/<workspace>/<name>` | [Container images](/guides/containers/) | | |
| 14 | + | ||
| 15 | + | ## Names | |
| 16 | + | ||
| 17 | + | Every package's name starts with its workspace: `g1t.sh/acme/web` is the | |
| 18 | + | `web` image of the `acme` workspace. A name may have more parts after it, | |
| 19 | + | such as `g1t.sh/acme/web/worker`. | |
| 20 | + | ||
| 21 | + | ## Who can see and publish a package | |
| 22 | + | ||
| 23 | + | A package is linked to a repository, or belongs to its workspace. | |
| 24 | + | ||
| 25 | + | - **Linked.** The first push of a package whose name starts with a | |
| 26 | + | repository's name (`acme/web`, `acme/web/worker` for the repository | |
| 27 | + | `acme/web`) links it to that repository. It then has the repository's | |
| 28 | + | visibility and [roles](/guides/access-and-roles/): | |
| 29 | + | ||
| 30 | + | | | Needs | | |
| 31 | + | | --- | --- | | |
| 32 | + | | Pull | Read: on a public repository, anyone, signed in or not | | |
| 33 | + | | Push a new version or tag | Write | | |
| 34 | + | | Delete versions and the package, change its settings | Admin | | |
| 35 | + | ||
| 36 | + | - **Unlinked.** A package whose name matches no repository is the | |
| 37 | + | workspace's. It is private: members pull and push it by the workspace's | |
| 38 | + | [base permission](/guides/access-and-roles/#the-base-permission) (Read pulls, | |
| 39 | + | Write pushes), and only owners delete it or change its settings. An owner | |
| 40 | + | can make it public, and then anyone can pull it. | |
| 41 | + | ||
| 42 | + | A linked package can be unlinked, and an unlinked one linked to a | |
| 43 | + | repository of its workspace by someone with Admin on that repository. | |
| 44 | + | ||
| 45 | + | Private packages look exactly like ones that do not exist to anyone who may | |
| 46 | + | not pull them. | |
| 47 | + | ||
| 48 | + | ## Tokens | |
| 49 | + | ||
| 50 | + | Sign in to a registry with your username and an | |
| 51 | + | [access token](/guides/authentication/#access-tokens) as the password. | |
| 52 | + | A token with scopes needs the package ones: | |
| 53 | + | ||
| 54 | + | | Scope | Lets a token | | |
| 55 | + | | --- | --- | | |
| 56 | + | | `packages:read` | Pull private packages. Public ones need no scope. | | |
| 57 | + | | `packages:write` | Push and publish. Includes `packages:read`. | | |
| 58 | + | | `packages:delete` | Delete versions and packages | | |
| 59 | + | ||
| 60 | + | Tokens with full access, and tokens made before scopes, have all three. A | |
| 61 | + | token never does more than its owner could: `packages:delete` alone does not | |
| 62 | + | let a member delete an owner's package. | |
| 63 | + | ||
| 64 | + | In [workflows](/guides/actions/#secrets-and-variables), `G1T_TOKEN` is the | |
| 65 | + | workspace's own token for the run: it pushes and pulls the workspace's | |
| 66 | + | packages with no setup. A g1t agent at work on a repository may push the | |
| 67 | + | packages of that repository, as it may push its code, and never deletes | |
| 68 | + | them. | |
| 69 | + | ||
| 70 | + | ## Storage | |
| 71 | + | ||
| 72 | + | Every file is kept once, by its content: two images that share a layer | |
| 73 | + | store it once, and a layer pushed again is not stored again. A workspace's | |
| 74 | + | storage counts each file once, as public when any public package uses it. | |
| 75 | + | ||
| 76 | + | Files no version uses any more are deleted a day after the last version | |
| 77 | + | that used them goes. | |
| 78 | + | ||
| 79 | + | Without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), public | |
| 80 | + | packages may hold 10 GB and private ones 500 MB per workspace; a push past | |
| 81 | + | either is refused, with a message saying how much is used. On the plan, | |
| 82 | + | storage past those amounts is charged instead. See | |
| 83 | + | [storage and pull limits](/guides/containers/#storage-and-pull-limits). | |
| 84 | + | ||
| 85 | + | ## Events and the audit log | |
| 86 | + | ||
| 87 | + | Publishing a version, deleting a version and deleting a package are | |
| 88 | + | [audit log](/guides/audit-log/) entries, and the events | |
| 89 | + | `package.published`, `package.version_deleted`, `package.deleted` and | |
| 90 | + | `package.visibility_changed`, which [webhooks](/guides/webhooks/) can be | |
| 91 | + | sent: a linked package's go to its repository's webhooks and its | |
| 92 | + | workspace's, an unlinked package's to its workspace's webhooks. |
| 19 | 19 | | Workspaces, members, access tokens | Works | | |
| 20 | 20 | | Repositories: create, push and clone over HTTP, browse code, commits | Works | | |
| 21 | 21 | | Issues, comments, labels | Works | | |
| 22 | + | | [Container images](/guides/containers/): `docker login`, push and pull at your `PUBLIC_URL` | Works, kept in the bundled MinIO, with no limit on a layer's size or on pulls | | |
| 22 | 23 | | Site search | Works | | |
| 23 | 24 | | A status page of your own | Works, at `http://localhost:8788` ([below](#the-status-page)) | | |
| 24 | − | | Webhooks, integrations | Run, but scheduled retries do not (see below) | | |
| 25 | + | | Webhooks, integrations | Work, retries included | | |
| 25 | 26 | | Sign in with GitHub, import from GitHub | Off until you register a GitHub App of your own ([below](#sign-in-with-github-and-import-from-github)). Mirrors sync with **Sync now**: GitHub's webhook needs the REST API. | | |
| 26 | 27 | | g1t's agent: changes, plans and reviews | Off | | |
| 27 | 28 | | Context hub search | Off | | |
| 28 | 29 | | Deployments on `g1t.page` | Off | | |
| 29 | 30 | | Billing | Off. Nothing is charged, and no usage limit stops work. | | |
| 30 | 31 | | Git over SSH, the REST API, MCP and the `g1t` CLI | Not available yet | | |
| 31 | − | | Scheduled jobs (webhook retries, Actions schedules) | Not run yet | | |
| 32 | + | | Scheduled jobs | Run once a minute inside the g1t container: webhook retries, purging deleted repositories, the packages sweep, security sweeps, audit log retention and access request summaries. Actions schedules (`on: schedule`) are not run. | | |
| 32 | 33 | ||
| 33 | 34 | What hosted g1t cannot do yet either is on | |
| 34 | 35 | [What g1t can't do yet](/about/limitations/). | |
| 105 | 106 | | `REGISTRATION_MODE` | `open` | `open`: anyone can make an account. `invite`: every new account needs an [invite](/guides/authentication/#invites), as on g1t.sh. | | |
| 106 | 107 | | `INVITES_PER_USER` | `5` | How many invites each person can have out, while `REGISTRATION_MODE` is `invite` | | |
| 107 | 108 | | `WAITLIST_NOTIFY_EMAIL` | (none) | Where a summary of new access requests goes, at most every 15 minutes. Empty sends none; requests still wait for you in the database. | | |
| 109 | + | | `S3_ENDPOINT`, `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | the bundled MinIO, bucket `g1t-packages` | Where packages' files are kept: any S3-compatible store. Change the two keys before first start; MinIO is made with them. | | |
| 110 | + | | `S3_PUBLIC_ENDPOINT` | (none) | The store's address as clients reach it. When set, large layers are downloaded from it directly with a signed URL. | | |
| 108 | 111 | | `STATUS_PORT` | `8788` | The port the status page is published on | | |
| 109 | 112 | | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. | | |
| 110 | 113 | | `INVITE_STAFF_WORKSPACES` | (none) | Workspace slugs, comma separated, whose owners can make invites without a limit. Set it to your own workspace before you switch to `invite`, so someone can invite the first people. | | |
| 183 | 186 | | --- | --- | | |
| 184 | 187 | | `g1t_g1t-data` | Accounts, workspaces, issues and every other record, as SQLite files; the keys that seal stored secrets (`keys.env`) | | |
| 185 | 188 | | `g1t_g1t-git` | Your repositories, one bare git repository each | | |
| 189 | + | | `g1t_g1t-packages` | Container images' layers and other package files (MinIO) | | |
| 186 | 190 | | `g1t_g1t-secrets` | The key the site and the git store share | | |
| 187 | 191 | ||
| 188 | 192 | To back up, stop g1t and copy the volumes: |
| 13 | 13 | | | Sent the events of | Managed by | Where | | |
| 14 | 14 | | --- | --- | --- | --- | | |
| 15 | 15 | | A repository's | That repository | People with the Admin [role](/guides/access-and-roles/) on it | The project's **Settings → Webhooks** | | |
| 16 | − | | A workspace's | Every repository in the workspace | Owners | The workspace's **Settings → Webhooks** | | |
| 16 | + | | A workspace's | Every repository in the workspace, and the workspace's own [packages](/guides/packages/) | Owners | The workspace's **Settings → Webhooks** | | |
| 17 | 17 | ||
| 18 | 18 | Seeing a repository's webhooks, and their deliveries, needs Admin too: the | |
| 19 | 19 | page is not shown to anyone else, since a webhook's address and secret are |
| 143 | 143 | ||
| 144 | 144 | ## Delete a workspace | |
| 145 | 145 | ||
| 146 | − | Deleting a workspace removes it for good. Only an owner can, signed in as a | |
| 147 | − | person, and only once nothing is left in it. | |
| 146 | + | Deleting a workspace takes everything in it with it, in one step: its | |
| 147 | + | repositories, projects, apps, members' access and tokens. Only an owner can, | |
| 148 | + | signed in as a person, typing the workspace's slug to confirm. | |
| 148 | 149 | ||
| 149 | − | Before you start: | |
| 150 | − | ||
| 151 | − | 1. **Move or delete its repositories.** [Transfer](/guides/transferring-repositories/) | |
| 152 | − | each one you want to keep to another workspace you own; their old | |
| 153 | − | addresses keep redirecting after the workspace is gone. | |
| 154 | − | [Delete](/guides/managing-repositories/#delete-a-repository) the ones | |
| 155 | − | you do not. Recently deleted repositories do not stand in the way: they | |
| 156 | − | are purged with the workspace, and cannot be restored afterwards. | |
| 157 | − | 2. **Move its projects out.** A project that builds from a repository in | |
| 158 | − | another workspace does not move with a transfer; it stops the deletion | |
| 159 | − | until it is gone. | |
| 160 | − | 3. **Settle billing.** See [what billing needs](#what-billing-needs). | |
| 161 | − | ||
| 162 | − | Then: | |
| 150 | + | It is not gone at once. For **30 days** g1t keeps all of it, so that a | |
| 151 | + | deletion you did not mean, or did not make, can be undone: an owner writes | |
| 152 | + | to support@g1t.sh, and support restores the workspace as it was. After 30 | |
| 153 | + | days it is purged for good. | |
| 163 | 154 | ||
| 164 | 155 | 1. Open the workspace's **Settings → General** and go to **Danger zone**. | |
| 165 | − | It says what is still in the way, if anything. | |
| 156 | + | It lists what will go with the workspace: its repositories, projects, | |
| 157 | + | live apps and members. | |
| 166 | 158 | 2. Choose **Delete workspace**, read what happens, type the workspace's | |
| 167 | − | slug to confirm, and choose **Delete workspace** again. | |
| 159 | + | slug to confirm, and choose **Delete workspace** again. You are taken | |
| 160 | + | back to your own home. | |
| 168 | 161 | ||
| 162 | + | The one thing that can stand in the way is billing: see | |
| 163 | + | [what billing needs](#what-billing-needs). Repositories you want to keep in | |
| 164 | + | another workspace, [transfer](/guides/transferring-repositories/) first; | |
| 165 | + | their old addresses keep redirecting after the workspace is gone. | |
| 166 | + | ||
| 169 | 167 | From the API, call | |
| 170 | 168 | [`DELETE /workspaces/{workspace}`](/reference/api/workspaces/delete-workspace/) | |
| 171 | 169 | with the slug in `confirm`; over MCP, the `workspace` tool's `delete` | |
| 172 | 170 | action. | |
| 173 | 171 | ||
| 172 | + | Some workspaces can never be deleted, by anyone, such as Flagon's, which | |
| 173 | + | runs g1t. Their Danger zone says so instead of offering the button. | |
| 174 | + | ||
| 174 | 175 | ### What billing needs | |
| 175 | 176 | ||
| 176 | 177 | | | | | |
| 186 | 187 | ||
| 187 | 188 | ### What happens | |
| 188 | 189 | ||
| 190 | + | At once, when an owner deletes it: | |
| 191 | + | ||
| 189 | 192 | | | | | |
| 190 | 193 | | --- | --- | | |
| 191 | − | | Members | Lose access. Their own accounts are not touched: a person with no workspace left can still sign in, and create or join one. | | |
| 192 | − | | Access tokens | The workspace's own tokens stop working at once. Personal tokens are not affected. | | |
| 194 | + | | Members | Lose access, and the workspace leaves their list. Their own accounts are not touched: a person with no workspace left can still sign in, and create or join one. | | |
| 195 | + | | Access tokens | The workspace's own tokens stop working. Personal tokens are not affected. | | |
| 196 | + | | Repositories | Deleted with it: git refuses them, and their pages answer 404. Agents and workflow runs stop. Ones deleted on their own earlier stay deleted. | | |
| 197 | + | | Projects and apps | Hidden. Its apps are taken offline and nothing builds. Custom domains are kept for a restore. | | |
| 198 | + | | Its pages | Answer 404, and it drops out of search. | | |
| 199 | + | | Billing | What it owes is charged, and its plan ends, as [billing needs](#what-billing-needs). Nothing more is charged. | | |
| 200 | + | | The audit log | Records the deletion. | | |
| 201 | + | ||
| 202 | + | Within 30 days, support can restore it: its members, tokens, repositories, | |
| 203 | + | projects and apps come back as they were, and its apps go back up as its | |
| 204 | + | limit allows. Its plan does not come back by itself: an owner starts it | |
| 205 | + | again from **Billing**. A repository deleted on its own before the | |
| 206 | + | workspace was stays in **Recently deleted**. | |
| 207 | + | ||
| 208 | + | After 30 days it is purged: | |
| 209 | + | ||
| 210 | + | | | | | |
| 211 | + | | --- | --- | | |
| 212 | + | | Repositories | Purged, their git data with them, including any that were in Recently deleted. | | |
| 213 | + | | Projects, apps and custom domains | Removed. | | |
| 193 | 214 | | Webhooks, integrations, secrets and variables | The workspace's own are removed. | | |
| 194 | 215 | | Memory and guardrails | The workspace's own are removed. | | |
| 195 | 216 | | Statements, invoices and the ledger | Kept, for accounting. | | |
| 196 | − | | The audit log | Kept as [long as its account keeps it](/guides/audit-log/#how-long-it-is-kept), with the deletion as its last entry: once the plan ends with the workspace, that is 7 days, unless an enterprise pays for it or longer was arranged. With no owners left, ask support@g1t.sh for an export. | | |
| 197 | − | | Recently deleted repositories | Purged with it, their git data with them. | | |
| 217 | + | | The audit log | Kept as [long as its account keeps it](/guides/audit-log/#how-long-it-is-kept), with the purge as its last entry: once the plan ends with the workspace, that is 7 days, unless an enterprise pays for it or longer was arranged. With no owners left, ask support@g1t.sh for an export. | | |
| 198 | 218 | | Old addresses | Redirects for repositories transferred out keep working. The workspace's own pages answer 404. | | |
| 199 | 219 | ||
| 200 | 220 | ### The name afterwards | |
| 201 | 221 | ||
| 202 | 222 | A deleted workspace's slug is never given to another workspace or used as | |
| 203 | − | someone else's username. Links and git remotes that still use it keep | |
| 223 | + | someone else's username. While it can still be restored, the slug is held | |
| 224 | + | for it. Links and git remotes that still use it keep | |
| 204 | 225 | meaning what they meant: a transferred repository's old address keeps | |
| 205 | 226 | redirecting to it, and nobody can take the name in the meantime. | |
| 206 | 227 | ||
| 207 | 228 | The one exception: when the slug is your own username, you may create a | |
| 208 | − | workspace with that name again. It starts empty, on standard billing terms, | |
| 229 | + | workspace with that name again once the old one is purged. It starts empty, on standard billing terms, | |
| 209 | 230 | and a repository made in it at an old address ends that address's redirect. | |
| 210 | 231 | ||
| 211 | 232 | ## Members and roles | |
| 216 | 237 | | Owner | Everything a member can, and manage members, the base permission, the workspace's access tokens, its details, and billing: the plan, card checks, prepayment and limits. Admin on every repository, and the only ones who can transfer and delete them; see [access and roles](/guides/access-and-roles/). | | |
| 217 | 238 | ||
| 218 | 239 | Whoever creates a workspace is its owner. An owner adds people on the | |
| 219 | − | workspace's **Settings → Members**, `g1t.sh/<workspace>/-/people`: | |
| 240 | + | workspace's **Members** (in the sidebar), `g1t.sh/<workspace>/-/people`: | |
| 220 | 241 | ||
| 221 | 242 | - **By username**: someone already on g1t joins at once, as a member. | |
| 222 | 243 | - **By email address**: g1t emails an invite that only that address can |
Binary or large file; its contents are not shown.
| 51 | 51 | with Stripe's page and PDF), its terms, who it is billed to (move it onto or off an | |
| 52 | 52 | enterprise), a credit form, a Stripe billing link, its ledger and its | |
| 53 | 53 | audit log. If billing does not answer for sales or invoices, the page | |
| 54 | − | still opens and says so in those sections. | |
| 54 | + | still opens and says so in those sections. A protected workspace (one | |
| 55 | + | nobody can ever delete: identity's `PROTECTED_WORKSPACES`, and | |
| 56 | + | flagon-io always) says so beside its name. | |
| 57 | + | - **Deleted workspaces** (`/workspaces/deleted`, linked from Workspaces): | |
| 58 | + | workspaces their owners deleted, newest first (`admin_deleted_workspaces`), | |
| 59 | + | each with who deleted it and when, when it is purged, what went with it | |
| 60 | + | (repositories, projects, members, counted at the deletion) and the days | |
| 61 | + | left. An owner deletes a workspace with everything in it in one step, and | |
| 62 | + | identity keeps it 30 days (`WORKSPACE_RESTORE_DAYS`) so support can undo a | |
| 63 | + | deletion that was a mistake or not theirs to make. **Restore** | |
| 64 | + | (`admin_restore_workspace`) brings it back with its members and tokens, | |
| 65 | + | and its repositories, projects and apps with `workspace.restored`; its | |
| 66 | + | plan stays ended, so its owners start it again from Billing. Check that | |
| 67 | + | whoever asks is an owner of it before restoring. **Purge now** | |
| 68 | + | (`admin_purge_workspace`, the slug typed to confirm) removes it at once, | |
| 69 | + | as the sweep does every 15 minutes once its 30 days are up; never for a | |
| 70 | + | protected workspace. Both go in the workspace's audit log, as g1t, and in | |
| 71 | + | sudo's (`workspace_restored`, `workspace_purged`), naming the staff | |
| 72 | + | member. | |
| 55 | 73 | - **Enterprises**: customers that pay for several workspaces with one | |
| 56 | 74 | bill, one limit and one set of terms. Each has its workspaces (add or | |
| 57 | 75 | remove them), combined usage, terms, credits, ledger and audit log, and |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { confirmsPurge, daysLeft, wentSummary } from "./deleted-workspaces.ts"; | |
| 5 | + | ||
| 6 | + | test("what went reads as one line", () => { | |
| 7 | + | assert.equal( | |
| 8 | + | wentSummary({ repositories: 3, projects: 1, members: 2, billing: null, protected: false }), | |
| 9 | + | "3 repositories, 1 project, 2 members", | |
| 10 | + | ); | |
| 11 | + | assert.equal( | |
| 12 | + | wentSummary({ repositories: 1, projects: 0, members: 1, billing: null, protected: false }), | |
| 13 | + | "1 repository, 0 projects, 1 member", | |
| 14 | + | ); | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | test("days left round up, and none once it is due", () => { | |
| 18 | + | const now = Date.parse("2026-10-06T12:00:00Z"); | |
| 19 | + | assert.equal(daysLeft("2026-11-05T12:00:00Z", now), 30); | |
| 20 | + | assert.equal(daysLeft("2026-10-06T13:00:00Z", now), 1); | |
| 21 | + | assert.equal(daysLeft("2026-10-06T12:00:00Z", now), 0); | |
| 22 | + | assert.equal(daysLeft("2026-10-01T00:00:00Z", now), 0); | |
| 23 | + | assert.equal(daysLeft("not a date", now), 0); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | test("a purge is confirmed by the slug alone", () => { | |
| 27 | + | assert.ok(confirmsPurge("acme", " Acme ")); | |
| 28 | + | assert.ok(!confirmsPurge("acme", "")); | |
| 29 | + | assert.ok(!confirmsPurge("acme", "acme-inc")); | |
| 30 | + | }); |
| 1 | + | /** | |
| 2 | + | * Deleted workspaces, as the Deleted workspaces page shows them: what went | |
| 3 | + | * with each, how long is left to restore it, and what staff must type to | |
| 4 | + | * purge one now. No Workers imports, so it can be tested under Node. | |
| 5 | + | */ | |
| 6 | + | import type { WorkspaceDeletion } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; | |
| 9 | + | ||
| 10 | + | /** What went with a workspace, in a line: "3 repositories, 1 project, 2 members". */ | |
| 11 | + | export function wentSummary(went: WorkspaceDeletion): string { | |
| 12 | + | const parts = [ | |
| 13 | + | plural(went.repositories, "repository", "repositories"), | |
| 14 | + | plural(went.projects, "project", "projects"), | |
| 15 | + | plural(went.members, "member", "members"), | |
| 16 | + | ]; | |
| 17 | + | return parts.join(", "); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /** Whole days left before `purgeAfter` (RFC 3339), from `now`; 0 once it is due. */ | |
| 21 | + | export function daysLeft(purgeAfter: string, now: number): number { | |
| 22 | + | const left = Date.parse(purgeAfter) - now; | |
| 23 | + | if (!Number.isFinite(left) || left <= 0) return 0; | |
| 24 | + | return Math.ceil(left / 86_400_000); | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /** Whether what staff typed to purge a workspace now is its slug. Identity checks it again. */ | |
| 28 | + | export function confirmsPurge(slug: string, typed: string): boolean { | |
| 29 | + | return typed.trim().toLowerCase() === slug.toLowerCase(); | |
| 30 | + | } |
| 105 | 105 | note: "Sales note added", | |
| 106 | 106 | stripe: "From Stripe", | |
| 107 | 107 | webhook: "Stripe webhook registered", | |
| 108 | + | close: "Billing closed", | |
| 109 | + | // From identity: deleted workspaces staff restored or purged. | |
| 110 | + | workspace_restored: "Workspace restored", | |
| 111 | + | workspace_purged: "Workspace purged", | |
| 108 | 112 | // From the status page (apps/status), merged in by the Audit log page. | |
| 109 | 113 | incident_declared: "Incident declared", | |
| 110 | 114 | incident_detected: "Incident detected", |
| 5 | 5 | export default [ | |
| 6 | 6 | index("routes/overview.tsx"), | |
| 7 | 7 | route("workspaces", "routes/workspaces.tsx"), | |
| 8 | + | route("workspaces/deleted", "routes/deleted-workspaces.tsx"), | |
| 8 | 9 | route("workspaces/:slug", "routes/workspace.tsx"), | |
| 9 | 10 | route("users/:username", "routes/user.tsx"), | |
| 10 | 11 | route("enterprises", "routes/enterprises.tsx"), |
| 1 | + | import { ArrowLeft } from "lucide-react"; | |
| 2 | + | import { Link, data, redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { WORKSPACE_RESTORE_DAYS, type DeletedWorkspace } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/deleted-workspaces"; | |
| 7 | + | import { Badge, Button, EmptyState, Input, Notice, PageHeader, When } from "~/components/ui"; | |
| 8 | + | import { confirmsPurge, daysLeft, wentSummary } from "~/lib/deleted-workspaces"; | |
| 9 | + | import { text } from "~/lib/forms"; | |
| 10 | + | import { identity } from "~/lib/services.server"; | |
| 11 | + | import { settle } from "~/lib/settle"; | |
| 12 | + | import { requireStaff } from "~/lib/staff"; | |
| 13 | + | ||
| 14 | + | export const meta: Route.MetaFunction = () => [ | |
| 15 | + | { title: "Deleted workspaces · sudo" }, | |
| 16 | + | { name: "robots", content: "noindex, nofollow" }, | |
| 17 | + | ]; | |
| 18 | + | ||
| 19 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 20 | + | requireStaff(context); | |
| 21 | + | const url = new URL(request.url); | |
| 22 | + | const deleted = await settle(identity.deletedWorkspaces()); | |
| 23 | + | const done = url.searchParams.get("done"); | |
| 24 | + | const slug = url.searchParams.get("slug") ?? ""; | |
| 25 | + | return { | |
| 26 | + | workspaces: deleted.ok ? deleted.value : [], | |
| 27 | + | error: deleted.ok ? null : deleted.error, | |
| 28 | + | done: done === "restored" ? `Restored ${slug}.` : done === "purged" ? `Purged ${slug}.` : null, | |
| 29 | + | now: Date.now(), | |
| 30 | + | }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** | |
| 34 | + | * Restoring and purging. Identity checks the window, the typed slug and | |
| 35 | + | * protection again, and records each in the workspace's audit log and in | |
| 36 | + | * sudo's, naming the staff member. | |
| 37 | + | */ | |
| 38 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 39 | + | const staff = requireStaff(context); | |
| 40 | + | const form = await request.formData(); | |
| 41 | + | const id = text(form, "id"); | |
| 42 | + | const slug = text(form, "slug"); | |
| 43 | + | const back = (done: string) => redirect(`/workspaces/deleted?done=${done}&slug=${encodeURIComponent(slug)}`); | |
| 44 | + | switch (text(form, "intent")) { | |
| 45 | + | case "restore": { | |
| 46 | + | const result = await identity.restoreWorkspace(id, staff.email); | |
| 47 | + | if (!result.ok) return data({ error: result.error.message, id }, { status: 422 }); | |
| 48 | + | throw back("restored"); | |
| 49 | + | } | |
| 50 | + | case "purge": { | |
| 51 | + | const confirm = text(form, "confirm"); | |
| 52 | + | if (!confirmsPurge(slug, confirm)) return data({ error: `Type ${slug} to confirm.`, id }, { status: 422 }); | |
| 53 | + | const result = await identity.purgeWorkspace(id, staff.email, confirm); | |
| 54 | + | if (!result.ok) return data({ error: result.error.message, id }, { status: 422 }); | |
| 55 | + | throw back("purged"); | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | return data({ error: "Unknown action.", id }, { status: 400 }); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | export default function DeletedWorkspaces({ loaderData, actionData }: Route.ComponentProps) { | |
| 62 | + | const { workspaces, error, done, now } = loaderData; | |
| 63 | + | const errorFor = (id: string) => (actionData && "id" in actionData && actionData.id === id ? actionData.error : null); | |
| 64 | + | return ( | |
| 65 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 66 | + | <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 67 | + | <ArrowLeft size={14} /> | |
| 68 | + | Workspaces | |
| 69 | + | </Link> | |
| 70 | + | <div className="mt-4"> | |
| 71 | + | <PageHeader | |
| 72 | + | title="Deleted workspaces" | |
| 73 | + | description={`Workspaces their owners deleted, with everything in them. Each is kept for ${WORKSPACE_RESTORE_DAYS} days: restore one an owner asks back, as after a deletion they did not mean or did not make. Then it is purged for good.`} | |
| 74 | + | /> | |
| 75 | + | </div> | |
| 76 | + | <div className="mt-6 space-y-3"> | |
| 77 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 78 | + | {error && <Notice tone="error">Identity did not answer: {error}</Notice>} | |
| 79 | + | </div> | |
| 80 | + | {workspaces.length === 0 ? ( | |
| 81 | + | <div className="mt-6"> | |
| 82 | + | <EmptyState title="No deleted workspaces">Workspaces appear here when an owner deletes one, until they are purged.</EmptyState> | |
| 83 | + | </div> | |
| 84 | + | ) : ( | |
| 85 | + | <ul className="mt-6 space-y-3"> | |
| 86 | + | {workspaces.map((workspace) => ( | |
| 87 | + | <DeletedRow key={workspace.workspaceId} workspace={workspace} now={now} error={errorFor(workspace.workspaceId)} /> | |
| 88 | + | ))} | |
| 89 | + | </ul> | |
| 90 | + | )} | |
| 91 | + | </main> | |
| 92 | + | ); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | function DeletedRow({ workspace, now, error }: { workspace: DeletedWorkspace; now: number; error: string | null }) { | |
| 96 | + | const left = daysLeft(workspace.purgeAfter, now); | |
| 97 | + | return ( | |
| 98 | + | <li className="rounded-lg border border-line bg-surface p-4 sm:p-5"> | |
| 99 | + | <div className="flex flex-wrap items-start justify-between gap-3"> | |
| 100 | + | <div className="min-w-0"> | |
| 101 | + | <p className="font-medium"> | |
| 102 | + | {workspace.name} <span className="font-mono text-sm text-muted">{workspace.slug}</span> | |
| 103 | + | </p> | |
| 104 | + | <p className="mt-1 text-sm text-muted"> | |
| 105 | + | Deleted by <span className="text-fg-soft">{workspace.deletedBy || "an owner"}</span> <When at={workspace.deletedAt} time /> | |
| 106 | + | {" · "}purged <When at={workspace.purgeAfter} time /> | |
| 107 | + | </p> | |
| 108 | + | <p className="mt-1 text-sm text-muted">Went with it: {wentSummary(workspace.went)}</p> | |
| 109 | + | </div> | |
| 110 | + | <div className="flex flex-wrap gap-1.5"> | |
| 111 | + | {workspace.went.protected && <Badge tone="info">Protected</Badge>} | |
| 112 | + | {workspace.restorable ? ( | |
| 113 | + | <Badge tone="warn"> | |
| 114 | + | {left} day{left === 1 ? "" : "s"} left | |
| 115 | + | </Badge> | |
| 116 | + | ) : ( | |
| 117 | + | <Badge tone="danger">Being purged</Badge> | |
| 118 | + | )} | |
| 119 | + | </div> | |
| 120 | + | </div> | |
| 121 | + | {error && ( | |
| 122 | + | <div className="mt-3"> | |
| 123 | + | <Notice tone="error">{error}</Notice> | |
| 124 | + | </div> | |
| 125 | + | )} | |
| 126 | + | <div className="mt-4 flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between"> | |
| 127 | + | <form method="post"> | |
| 128 | + | <input type="hidden" name="intent" value="restore" /> | |
| 129 | + | <input type="hidden" name="id" value={workspace.workspaceId} /> | |
| 130 | + | <input type="hidden" name="slug" value={workspace.slug} /> | |
| 131 | + | <Button type="submit" variant="lavender" disabled={!workspace.restorable}> | |
| 132 | + | Restore | |
| 133 | + | </Button> | |
| 134 | + | </form> | |
| 135 | + | {workspace.went.protected ? ( | |
| 136 | + | <p className="text-sm text-muted">Protected: it can never be purged.</p> | |
| 137 | + | ) : ( | |
| 138 | + | <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 139 | + | <input type="hidden" name="intent" value="purge" /> | |
| 140 | + | <input type="hidden" name="id" value={workspace.workspaceId} /> | |
| 141 | + | <input type="hidden" name="slug" value={workspace.slug} /> | |
| 142 | + | <label className="grid gap-1 text-xs text-muted"> | |
| 143 | + | <span> | |
| 144 | + | Type <span className="font-mono text-fg">{workspace.slug}</span> to purge it now | |
| 145 | + | </span> | |
| 146 | + | <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" aria-label={`Type ${workspace.slug} to purge it now`} /> | |
| 147 | + | </label> | |
| 148 | + | <Button type="submit" variant="danger"> | |
| 149 | + | Purge now | |
| 150 | + | </Button> | |
| 151 | + | </form> | |
| 152 | + | )} | |
| 153 | + | </div> | |
| 154 | + | </li> | |
| 155 | + | ); | |
| 156 | + | } |
| 208 | 208 | </p> | |
| 209 | 209 | {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>} | |
| 210 | 210 | <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 211 | + | {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>} | |
| 211 | 212 | {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>} | |
| 212 | 213 | <TermsBadge terms={terms} /> | |
| 213 | 214 | {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />} |
| 94 | 94 | ||
| 95 | 95 | return ( | |
| 96 | 96 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 97 | − | <div> | |
| 98 | − | <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1> | |
| 99 | − | <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p> | |
| 97 | + | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| 98 | + | <div> | |
| 99 | + | <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1> | |
| 100 | + | <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p> | |
| 101 | + | </div> | |
| 102 | + | <Link | |
| 103 | + | to="/workspaces/deleted" | |
| 104 | + | className="rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg" | |
| 105 | + | > | |
| 106 | + | Deleted workspaces | |
| 107 | + | </Link> | |
| 100 | 108 | </div> | |
| 101 | 109 | ||
| 102 | 110 | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> |
| 1 | − | import { | |
| 2 | − | Activity, | |
| 3 | − | ServerCog, | |
| 4 | − | GanttChart, | |
| 5 | − | KanbanSquare, | |
| 6 | − | Package, | |
| 7 | − | Sparkles, | |
| 8 | − | BarChart3, | |
| 9 | − | BookMarked, | |
| 10 | − | BookOpen, | |
| 11 | − | Check, | |
| 12 | − | ChevronLeft, | |
| 13 | − | ChevronRight, | |
| 14 | − | ChevronsUpDown, | |
| 15 | − | CircleUserRound, | |
| 16 | − | Mail, | |
| 17 | − | Ticket, | |
| 18 | − | CircleDot, | |
| 19 | − | Code2, | |
| 20 | − | Compass, | |
| 21 | − | CreditCard, | |
| 22 | − | GitPullRequest, | |
| 23 | − | History, | |
| 24 | − | House, | |
| 25 | − | Fingerprint, | |
| 26 | − | Gauge, | |
| 27 | − | KeyRound, | |
| 28 | − | LifeBuoy, | |
| 29 | − | Box, | |
| 30 | − | LayoutGrid, | |
| 31 | − | ListTree, | |
| 32 | − | Lock, | |
| 33 | − | LogIn, | |
| 34 | − | LogOut, | |
| 35 | − | Menu, | |
| 36 | − | Plus, | |
| 37 | − | Search, | |
| 38 | − | Plug, | |
| 39 | − | Settings, | |
| 40 | − | Users, | |
| 41 | − | Webhook, | |
| 42 | − | Globe, | |
| 43 | − | GitBranch, | |
| 44 | − | PlayCircle, | |
| 45 | − | Bot, | |
| 46 | − | Brain, | |
| 47 | − | Network, | |
| 48 | − | ShieldCheck, | |
| 49 | − | Rocket, | |
| 50 | − | X, | |
| 51 | − | } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 52 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 53 | 3 | import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 54 | 4 | ||
| 232 | 182 | const WORKSPACE_ICONS: Record<string, ReactNode> = { | |
| 233 | 183 | board: <KanbanSquare size={15} />, | |
| 234 | 184 | roadmap: <GanttChart size={15} />, | |
| 185 | + | teams: <UsersRound size={15} />, | |
| 235 | 186 | packages: <Package size={15} />, | |
| 236 | 187 | fleet: <Bot size={15} />, | |
| 237 | 188 | }; | |
| 488 | 439 | } | |
| 489 | 440 | ||
| 490 | 441 | /** | |
| 491 | − | * A workspace's settings pages, which the sidebar drills into: who belongs, | |
| 492 | − | * what it pays, and its record. What it builds and runs with (secrets, | |
| 493 | − | * integrations, webhooks, guardrails) sits in the main list. | |
| 442 | + | * A workspace's settings pages, which the sidebar drills into: what it | |
| 443 | + | * pays, its repositories, tokens and record. Who belongs (Members, Teams) | |
| 444 | + | * and what it builds and runs with (secrets, integrations, webhooks, | |
| 445 | + | * guardrails) sit in the main list, for every member to see. | |
| 494 | 446 | */ | |
| 495 | − | const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|repositories|tokens|billing|audit)(\/|$)/; | |
| 447 | + | const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|repositories|tokens|billing|audit)(\/|$)/; | |
| 496 | 448 | /** A project's settings pages, which the project's menu drills into. */ | |
| 497 | 449 | const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/; | |
| 498 | 450 | ||
| 683 | 635 | <> | |
| 684 | 636 | <Rule /> | |
| 685 | 637 | <div className="space-y-px"> | |
| 638 | + | {/* Who belongs, for every member to see; owners invite and manage there. */} | |
| 639 | + | <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}> | |
| 640 | + | Members | |
| 641 | + | </SidebarLink> | |
| 642 | + | {roadmapIn("Workspace") | |
| 643 | + | .filter((item) => item.key === "teams") | |
| 644 | + | .map((item) => ( | |
| 645 | + | <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}> | |
| 646 | + | {item.title} | |
| 647 | + | </SidebarSoonLink> | |
| 648 | + | ))} | |
| 649 | + | </div> | |
| 650 | + | <Rule /> | |
| 651 | + | <div className="space-y-px"> | |
| 686 | 652 | <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}> | |
| 687 | 653 | Agent fleet | |
| 688 | 654 | </SidebarLink> | |
| 704 | 670 | <SidebarLink to={`/${ws.slug}/-/runners`} icon={<ServerCog size={15} />}> | |
| 705 | 671 | Runners | |
| 706 | 672 | </SidebarLink> | |
| 673 | + | <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}> | |
| 674 | + | Packages | |
| 675 | + | </SidebarLink> | |
| 707 | 676 | <SidebarLink to={`/${ws.slug}/-/integrations`} icon={<Plug size={15} />}> | |
| 708 | 677 | Integrations | |
| 709 | 678 | </SidebarLink> | |
| 710 | 679 | <SidebarLink to={`/${ws.slug}/-/webhooks`} icon={<Webhook size={15} />}> | |
| 711 | 680 | Webhooks | |
| 712 | 681 | </SidebarLink> | |
| 713 | − | {roadmapIn("Workspace").map((item) => ( | |
| 682 | + | {roadmapIn("Workspace").filter((item) => item.key !== "teams").map((item) => ( | |
| 714 | 683 | <SidebarSoonLink | |
| 715 | 684 | key={item.key} | |
| 716 | 685 | to={`/${ws.slug}/-/soon/${item.key}`} | |
| 732 | 701 | </SidebarLink> | |
| 733 | 702 | {/* Who belongs, what it pays and its record: a list of their own. */} | |
| 734 | 703 | <SidebarLink | |
| 735 | − | to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`} | |
| 704 | + | to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/repositories`} | |
| 736 | 705 | icon={<Settings size={15} />} | |
| 737 | 706 | drill | |
| 738 | 707 | > | |
| 756 | 725 | General | |
| 757 | 726 | </SidebarLink> | |
| 758 | 727 | )} | |
| 759 | − | <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}> | |
| 760 | − | Members | |
| 761 | − | </SidebarLink> | |
| 762 | 728 | <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}> | |
| 763 | 729 | Repositories | |
| 764 | 730 | </SidebarLink> |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { formatBytes, installCommands, shortDigest } from "./packages.ts"; | |
| 5 | + | ||
| 6 | + | test("sizes read as registries show them", () => { | |
| 7 | + | assert.equal(formatBytes(0), "0 B"); | |
| 8 | + | assert.equal(formatBytes(812), "812 B"); | |
| 9 | + | assert.equal(formatBytes(12_340_000), "12.3 MB"); | |
| 10 | + | assert.equal(formatBytes(1_400_000_000), "1.4 GB"); | |
| 11 | + | assert.equal(formatBytes(250_000_000), "250 MB"); | |
| 12 | + | }); | |
| 13 | + | ||
| 14 | + | test("digests shorten for lists", () => { | |
| 15 | + | assert.equal(shortDigest("sha256:3f2a9c1b7d0e55aa"), "3f2a9c1b7d0e"); | |
| 16 | + | }); | |
| 17 | + | ||
| 18 | + | test("a container image is pulled by its address and tag", () => { | |
| 19 | + | const pkg = { ecosystem: "container" as const, address: "g1t.sh/acme/web", name: "web", workspace: "acme" }; | |
| 20 | + | assert.deepEqual(installCommands(pkg, "latest", "ada"), { | |
| 21 | + | login: "docker login g1t.sh -u ada", | |
| 22 | + | install: "docker pull g1t.sh/acme/web:latest", | |
| 23 | + | }); | |
| 24 | + | assert.equal(installCommands(pkg, null, "ada").install, "docker pull g1t.sh/acme/web"); | |
| 25 | + | }); |
| 1 | + | /** | |
| 2 | + | * Words and commands for the Packages pages: sizes, digests, and how each | |
| 3 | + | * registry's own tool logs in and installs. | |
| 4 | + | */ | |
| 5 | + | import type { Ecosystem, PackageSummary } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | /** What each registry is called on the pages. */ | |
| 8 | + | export const ECOSYSTEM_LABEL: Record<Ecosystem, string> = { | |
| 9 | + | container: "Container", | |
| 10 | + | npm: "npm", | |
| 11 | + | composer: "Composer", | |
| 12 | + | cargo: "Cargo", | |
| 13 | + | go: "Go", | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | /** "0 B", "812 KB", "12.3 MB", "1.4 GB", in powers of 1,000 as registries show them. */ | |
| 17 | + | export function formatBytes(bytes: number): string { | |
| 18 | + | if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; | |
| 19 | + | const units = ["B", "KB", "MB", "GB", "TB"]; | |
| 20 | + | let value = bytes; | |
| 21 | + | let unit = 0; | |
| 22 | + | while (value >= 1000 && unit < units.length - 1) { | |
| 23 | + | value /= 1000; | |
| 24 | + | unit++; | |
| 25 | + | } | |
| 26 | + | const shown = unit === 0 || value >= 100 ? Math.round(value) : Number(value.toFixed(1)); | |
| 27 | + | return `${shown} ${units[unit]}`; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** `sha256:3f2a…` shortened to `3f2a9c1b7d0e` for lists. */ | |
| 31 | + | export function shortDigest(digest: string): string { | |
| 32 | + | return digest.replace(/^sha256:/, "").slice(0, 12); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** The host packages are published to and installed from. */ | |
| 36 | + | export function registryHost(address: string): string { | |
| 37 | + | return address.split("/")[0] || "g1t.sh"; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** | |
| 41 | + | * How to log in and install `pkg` at `version` (a tag or version) with its | |
| 42 | + | * tool. `you` stands in the username a login takes. | |
| 43 | + | */ | |
| 44 | + | export function installCommands(pkg: Pick<PackageSummary, "ecosystem" | "address" | "name" | "workspace">, version: string | null, you: string): { login: string; install: string } { | |
| 45 | + | const host = registryHost(pkg.address); | |
| 46 | + | switch (pkg.ecosystem) { | |
| 47 | + | case "container": | |
| 48 | + | return { | |
| 49 | + | login: `docker login ${host} -u ${you}`, | |
| 50 | + | install: `docker pull ${pkg.address}${version ? `:${version}` : ""}`, | |
| 51 | + | }; | |
| 52 | + | case "npm": | |
| 53 | + | return { | |
| 54 | + | login: `npm config set @${pkg.workspace}:registry https://${host}/-/npm/`, | |
| 55 | + | install: `npm install @${pkg.workspace}/${pkg.name}${version ? `@${version}` : ""}`, | |
| 56 | + | }; | |
| 57 | + | case "composer": | |
| 58 | + | return { | |
| 59 | + | login: `composer config repositories.${pkg.workspace} composer https://${host}/-/composer/${pkg.workspace}/`, | |
| 60 | + | install: `composer require ${pkg.name}${version ? `:${version}` : ""}`, | |
| 61 | + | }; | |
| 62 | + | case "cargo": | |
| 63 | + | return { | |
| 64 | + | login: `cargo login --registry ${pkg.workspace}`, | |
| 65 | + | install: `cargo add ${pkg.name} --registry ${pkg.workspace}${version ? ` --vers ${version}` : ""}`, | |
| 66 | + | }; | |
| 67 | + | case "go": | |
| 68 | + | return { | |
| 69 | + | login: `go env -w GOPRIVATE=${host}/${pkg.workspace}`, | |
| 70 | + | install: `go get ${pkg.address}${version ? `@${version}` : ""}`, | |
| 71 | + | }; | |
| 72 | + | } | |
| 73 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { servicePath } from "./registry-paths.ts"; | |
| 5 | + | ||
| 6 | + | test("the container registry's paths go to the packages service", () => { | |
| 7 | + | for (const path of [ | |
| 8 | + | "/v2", | |
| 9 | + | "/v2/", | |
| 10 | + | "/v2/token", | |
| 11 | + | "/v2/acme/web/manifests/latest", | |
| 12 | + | "/v2/acme/web/api/blobs/uploads/upl_1", | |
| 13 | + | "/v2/acme/web/tags/list", | |
| 14 | + | "/v2/acme/web/referrers/sha256:abc", | |
| 15 | + | ]) { | |
| 16 | + | assert.equal(servicePath(path), "packages", path); | |
| 17 | + | } | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | test("git goes to repos, and everything else is the site's", () => { | |
| 21 | + | assert.equal(servicePath("/acme/web.git/info/refs"), "git"); | |
| 22 | + | assert.equal(servicePath("/acme/web/git-receive-pack"), "git"); | |
| 23 | + | // A registry image named like a git endpoint is still the registry's. | |
| 24 | + | assert.equal(servicePath("/v2/acme/info/refs"), "packages"); | |
| 25 | + | for (const path of ["/", "/acme", "/acme/web", "/v2x", "/acme/v2", "/acme/-/packages"]) { | |
| 26 | + | assert.equal(servicePath(path), null, path); | |
| 27 | + | } | |
| 28 | + | }); |
| 1 | + | /** | |
| 2 | + | * Which requests to g1t.sh belong to a service other than the site: git | |
| 3 | + | * over HTTPS (the repos service) and the package registries (the packages | |
| 4 | + | * service). `workers/app.ts` hands each to its binding as it is. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | const GIT_PATH = /\/(info\/refs|git-upload-pack|git-receive-pack)$/; | |
| 8 | + | /** The container registry: OCI Distribution's `/v2/`, and its token endpoint at `/v2/token`. */ | |
| 9 | + | const REGISTRY_PATH = /^\/v2(?:\/|$)/; | |
| 10 | + | ||
| 11 | + | export type ServicePath = "git" | "packages" | null; | |
| 12 | + | ||
| 13 | + | export function servicePath(pathname: string): ServicePath { | |
| 14 | + | if (REGISTRY_PATH.test(pathname)) return "packages"; | |
| 15 | + | if (GIT_PATH.test(pathname)) return "git"; | |
| 16 | + | return null; | |
| 17 | + | } |
| 29 | 29 | export const ROADMAP: RoadmapItem[] = [ | |
| 30 | 30 | // --- Work --------------------------------------------------------------- | |
| 31 | 31 | { | |
| 32 | + | key: "teams", | |
| 33 | + | title: "Teams", | |
| 34 | + | section: "Workspace", | |
| 35 | + | summary: "Groups of members, given roles on repositories together.", | |
| 36 | + | why: "Give a group a role on many repositories at once, mention it, and request its review, instead of adding people one by one.", | |
| 37 | + | plans: ["Teams with members and maintainers", "Repository roles for a team", "@team mentions and review requests"], | |
| 38 | + | }, | |
| 39 | + | { | |
| 32 | 40 | key: "board", | |
| 33 | 41 | title: "Board", | |
| 34 | 42 | section: "Workspace", | |
| 161 | 169 | plans: ["Notes drafted from merged work", "Assets and checksums attached", "Published to the project's page and a feed"], | |
| 162 | 170 | today: { label: "Commits", path: "commits" }, | |
| 163 | 171 | }, | |
| 164 | − | { | |
| 165 | − | key: "packages", | |
| 166 | − | title: "Packages", | |
| 167 | − | section: "Workspace", | |
| 168 | − | summary: "The workspace's package registry: npm, containers and more.", | |
| 169 | − | why: "Publish packages from workflows to g1t's registry, with the same access as the code.", | |
| 170 | − | plans: ["npm, OCI containers, Cargo, PyPI and Go modules", "Published from any project's workflows", "Private packages for the workspace"], | |
| 171 | − | }, | |
| 172 | + | ||
| 172 | 173 | { | |
| 173 | 174 | key: "flags", | |
| 174 | 175 | title: "Feature flags", |
| 8 | 8 | contextClient, | |
| 9 | 9 | deploymentsClient, | |
| 10 | 10 | memoryReviewClient, | |
| 11 | + | packagesClient, | |
| 11 | 12 | projectsClient, | |
| 12 | 13 | eventsClient, | |
| 13 | 14 | guardrailsClient, | |
| 39 | 40 | const SECURITY = instrumented("security", env.SECURITY); | |
| 40 | 41 | const CONTEXT = instrumented("context", env.CONTEXT); | |
| 41 | 42 | const SEARCH = instrumented("search", env.SEARCH); | |
| 43 | + | const PACKAGES = instrumented("packages", env.PACKAGES); | |
| 42 | 44 | ||
| 43 | 45 | export const identity = identityClient(IDENTITY); | |
| 44 | 46 | /** A person's email addresses and account security: methods of identity. */ | |
| 61 | 63 | export const context = contextClient(CONTEXT); | |
| 62 | 64 | /** Search across all of g1t, and Explore. */ | |
| 63 | 65 | export const search = searchClient(SEARCH); | |
| 66 | + | /** The workspace's registries: container images, and more to come. */ | |
| 67 | + | export const packages = packagesClient(PACKAGES); | |
| 64 | 68 | /** Memory candidates and their review: methods of the work service. */ | |
| 65 | 69 | export const memoryReview = memoryReviewClient(WORK); |
| 34 | 34 | const listed = [...SCOPE_GROUPS.flatMap((group) => group.scopes), ...DANGEROUS_SCOPES]; | |
| 35 | 35 | assert.deepEqual([...listed].sort(), SCOPES.map((row) => row.scope).sort()); | |
| 36 | 36 | assert.equal(new Set(listed).size, listed.length); | |
| 37 | − | assert.ok(DANGEROUS_SCOPES.every((scope) => scope.endsWith(":admin"))); | |
| 37 | + | assert.ok(DANGEROUS_SCOPES.every((scope) => scope.endsWith(":admin") || scope.endsWith(":delete"))); | |
| 38 | 38 | }); | |
| 39 | 39 | ||
| 40 | 40 | test("ticked boxes store the highest level of each resource", () => { | |
| 70 | 70 | ||
| 71 | 71 | test("presets are recognised however their scopes are written", () => { | |
| 72 | 72 | assert.equal(matchingPreset(null), "full"); | |
| 73 | − | assert.equal(matchingPreset(["repo:read", "code:write", "workflows:write"]), "ci"); | |
| 73 | + | assert.equal(matchingPreset(["repo:read", "code:write", "packages:write", "workflows:write"]), "ci"); | |
| 74 | 74 | assert.equal(matchingPreset([...OAUTH_DEFAULT_SCOPES]), "agent"); | |
| 75 | 75 | assert.equal(matchingPreset(["issues:read"]), null); | |
| 76 | 76 | }); | |
| 78 | 78 | test("a token's access reads plainly", () => { | |
| 79 | 79 | assert.equal(accessSummary({ scopes: null, legacy: true }), "Legacy · full access"); | |
| 80 | 80 | assert.equal(accessSummary({ scopes: null, legacy: false }), "Full access"); | |
| 81 | − | assert.equal(accessSummary({ scopes: ["code:read", "code:write", "workflows:write", "repo:read"], legacy: false }), "CI"); | |
| 81 | + | assert.equal(accessSummary({ scopes: ["code:read", "code:write", "packages:write", "workflows:write", "repo:read"], legacy: false }), "CI"); | |
| 82 | 82 | assert.equal(accessSummary({ scopes: ["issues:read", "issues:write", "memory:read"], legacy: false }), "2 scopes"); | |
| 83 | 83 | assert.equal(accessSummary({ scopes: [], legacy: false }), "No scopes"); | |
| 84 | 84 | }); |
| 27 | 27 | type ScopeResource, | |
| 28 | 28 | } from "@g1t/contracts/scopes"; | |
| 29 | 29 | ||
| 30 | − | const RANK: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, admin: 3 }; | |
| 30 | + | const RANK: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, delete: 3, admin: 4 }; | |
| 31 | 31 | ||
| 32 | 32 | /** Anything with FormData's getters, so tests can pass a plain map. */ | |
| 33 | 33 | export type FormLike = { get(name: string): unknown; getAll(name: string): unknown[] }; |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { confirmsSlug, deletionRefusal, whatGoes } from "./workspace-deletion.ts"; | |
| 5 | + | ||
| 6 | + | const nothing = { repositories: 0, projects: 0, members: 0, billing: null, protected: false }; | |
| 7 | + | ||
| 8 | + | test("what goes lists only what the workspace holds", () => { | |
| 9 | + | assert.deepEqual(whatGoes(nothing, null), []); | |
| 10 | + | assert.deepEqual(whatGoes({ ...nothing, repositories: 1, projects: 2, members: 1 }, 3), [ | |
| 11 | + | "1 repository, with its issues, pull requests and workflow runs", | |
| 12 | + | "2 projects", | |
| 13 | + | "3 live apps, taken offline", | |
| 14 | + | "Access for 1 member; their own accounts stay", | |
| 15 | + | ]); | |
| 16 | + | assert.deepEqual(whatGoes({ ...nothing, repositories: 4 }, 0), [ | |
| 17 | + | "4 repositories, with their issues, pull requests and workflow runs", | |
| 18 | + | ]); | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | test("only billing or protection stands in the way", () => { | |
| 22 | + | assert.equal(deletionRefusal("acme", null), null); | |
| 23 | + | assert.equal(deletionRefusal("acme", { ...nothing, repositories: 9, projects: 2 }), null); | |
| 24 | + | assert.equal(deletionRefusal("acme", { ...nothing, billing: "Pay first." }), "Pay first."); | |
| 25 | + | assert.equal( | |
| 26 | + | deletionRefusal("flagon-io", { ...nothing, billing: "Pay first.", protected: true }), | |
| 27 | + | "flagon-io is protected and can never be deleted.", | |
| 28 | + | ); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test("the slug confirms in any case, and nothing else does", () => { | |
| 32 | + | assert.ok(confirmsSlug("acme", " ACME ")); | |
| 33 | + | assert.ok(!confirmsSlug("acme", "")); | |
| 34 | + | assert.ok(!confirmsSlug("acme", "acme-inc")); | |
| 35 | + | }); |
| 1 | + | /** | |
| 2 | + | * What the danger zone of a workspace's settings says about deleting it: | |
| 3 | + | * what goes with it, and why it cannot go. No Workers or React imports, so | |
| 4 | + | * it can be tested under Node. | |
| 5 | + | */ | |
| 6 | + | import type { WorkspaceDeletion } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; | |
| 9 | + | ||
| 10 | + | /** | |
| 11 | + | * What deleting the workspace takes with it, one line each, from what | |
| 12 | + | * identity counted and the apps deployments has up (null when unknown). | |
| 13 | + | * Nothing it does not hold is listed. | |
| 14 | + | */ | |
| 15 | + | export function whatGoes(deletion: WorkspaceDeletion, apps: number | null): string[] { | |
| 16 | + | const lines: string[] = []; | |
| 17 | + | if (deletion.repositories > 0) { | |
| 18 | + | const their = deletion.repositories === 1 ? "its" : "their"; | |
| 19 | + | lines.push(`${plural(deletion.repositories, "repository", "repositories")}, with ${their} issues, pull requests and workflow runs`); | |
| 20 | + | } | |
| 21 | + | if (deletion.projects > 0) lines.push(plural(deletion.projects, "project", "projects")); | |
| 22 | + | if (apps && apps > 0) lines.push(`${plural(apps, "live app", "live apps")}, taken offline`); | |
| 23 | + | if (deletion.members > 0) { | |
| 24 | + | lines.push(`Access for ${plural(deletion.members, "member", "members")}; their own accounts stay`); | |
| 25 | + | } | |
| 26 | + | return lines; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** Why the workspace cannot be deleted, as a sentence, or null. */ | |
| 30 | + | export function deletionRefusal(slug: string, deletion: WorkspaceDeletion | null): string | null { | |
| 31 | + | if (!deletion) return null; | |
| 32 | + | if (deletion.protected) return `${slug} is protected and can never be deleted.`; | |
| 33 | + | return deletion.billing; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /** Whether what was typed confirms the slug: the slug itself, in any case. */ | |
| 37 | + | export function confirmsSlug(slug: string, typed: string): boolean { | |
| 38 | + | return typed.trim().toLowerCase() === slug.toLowerCase(); | |
| 39 | + | } |
| 68 | 68 | route("-/webhooks", "routes/workspace/webhooks.tsx"), | |
| 69 | 69 | route("-/secrets", "routes/workspace/secrets.tsx"), | |
| 70 | 70 | route("-/runners", "routes/workspace/runners.tsx"), | |
| 71 | + | route("-/packages", "routes/workspace/packages.tsx"), | |
| 72 | + | route("-/packages/:ecosystem/*", "routes/workspace/package.tsx"), | |
| 71 | 73 | route("-/settings", "routes/workspace/settings.tsx"), | |
| 72 | 74 | route("-/repositories", "routes/workspace/repositories.tsx"), | |
| 73 | 75 | route("-/agents", "routes/workspace/agents.tsx"), |
| 49 | 49 | title: "Access tokens", | |
| 50 | 50 | about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team.", | |
| 51 | 51 | }, | |
| 52 | + | packages: { | |
| 53 | + | title: "Packages", | |
| 54 | + | about: "What the workspace publishes and installs: container images today, with the same people, tokens and access as its code.", | |
| 55 | + | }, | |
| 52 | 56 | usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." }, | |
| 53 | 57 | billing: { title: "Billing and plans", about: "The g1t plan, the trial, your spend limit and caps, prepaying, and every charge." }, | |
| 54 | 58 | agents: { | |
| 93 | 97 | const { workspace, role, welcome } = loaderData; | |
| 94 | 98 | // The sidebar finds the workspace's pages, for everyone, so its pages | |
| 95 | 99 | // need a title, not the workspace's whole header again. | |
| 96 | − | const page = PAGES[useLocation().pathname.split("/-/")[1]?.split("/")[0] ?? ""]; | |
| 100 | + | const parts = (useLocation().pathname.split("/-/")[1] ?? "").split("/").filter(Boolean); | |
| 101 | + | const page = PAGES[parts[0] ?? ""]; | |
| 102 | + | // A page within one (a single package) has its own heading. | |
| 103 | + | if (page && parts.length > 1) { | |
| 104 | + | return ( | |
| 105 | + | <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> | |
| 106 | + | <Outlet /> | |
| 107 | + | </div> | |
| 108 | + | ); | |
| 109 | + | } | |
| 97 | 110 | if (page) { | |
| 98 | 111 | return ( | |
| 99 | 112 | <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> |
| 1 | + | import { Box, Lock, Package, Trash2 } from "lucide-react"; | |
| 2 | + | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { ECOSYSTEMS, type Ecosystem, type PackageVersion } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/package"; | |
| 7 | + | import { ConfirmDialog } from "../../components/repo-lifecycle"; | |
| 8 | + | import { Button, CopyLine, ErrorText, TimeAgo } from "../../components/ui"; | |
| 9 | + | import { Badge } from "../../components/ui/badge"; | |
| 10 | + | import { page } from "../../lib/meta"; | |
| 11 | + | import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages"; | |
| 12 | + | import { packages } from "../../lib/services.server"; | |
| 13 | + | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | function ecosystemOf(value: string | undefined): Ecosystem { | |
| 16 | + | if (value && (ECOSYSTEMS as readonly string[]).includes(value)) return value as Ecosystem; | |
| 17 | + | throw data(null, { status: 404 }); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 21 | + | return page(args, { title: `${params["*"]} · Packages · ${params.owner} · g1t` }); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 25 | + | const viewer = getViewer(context); | |
| 26 | + | const ecosystem = ecosystemOf(params.ecosystem); | |
| 27 | + | const name = params["*"] ?? ""; | |
| 28 | + | const found = await packages.get(params.owner, ecosystem, name, viewer); | |
| 29 | + | // Not found and not allowed look the same. | |
| 30 | + | if (!found.ok) throw data(null, { status: 404 }); | |
| 31 | + | return { detail: found.value, username: viewer?.username ?? "you" }; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type Outcome = { error: string | null; message: string | null }; | |
| 35 | + | ||
| 36 | + | export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome | Response> { | |
| 37 | + | assertSameOrigin(request); | |
| 38 | + | const user = requireUser(context, request); | |
| 39 | + | const ecosystem = ecosystemOf(params.ecosystem); | |
| 40 | + | const name = params["*"] ?? ""; | |
| 41 | + | const form = await request.formData(); | |
| 42 | + | const intent = String(form.get("intent") ?? ""); | |
| 43 | + | const fail = (message: string): Outcome => ({ error: message, message: null }); | |
| 44 | + | if (intent === "delete-version") { | |
| 45 | + | const version = String(form.get("version") ?? ""); | |
| 46 | + | const done = await packages.deleteVersion(user, params.owner, ecosystem, name, version, "web"); | |
| 47 | + | return done.ok ? { error: null, message: `Deleted ${shortDigest(version)} and its tags.` } : fail(done.error.message); | |
| 48 | + | } | |
| 49 | + | if (intent === "delete-package") { | |
| 50 | + | const done = await packages.deletePackage(user, params.owner, ecosystem, name, "web"); | |
| 51 | + | if (!done.ok) return fail(done.error.message); | |
| 52 | + | return redirect(`/${params.owner}/-/packages`); | |
| 53 | + | } | |
| 54 | + | if (intent === "visibility") { | |
| 55 | + | const visibility = form.get("visibility") === "public" ? "public" : "private"; | |
| 56 | + | const done = await packages.set(user, params.owner, ecosystem, name, { visibility }, "web"); | |
| 57 | + | return done.ok ? { error: null, message: `It is ${visibility} now.` } : fail(done.error.message); | |
| 58 | + | } | |
| 59 | + | if (intent === "link") { | |
| 60 | + | const repo = String(form.get("repo") ?? "").trim(); | |
| 61 | + | const done = repo | |
| 62 | + | ? await packages.set(user, params.owner, ecosystem, name, { link: repo }, "web") | |
| 63 | + | : await packages.set(user, params.owner, ecosystem, name, { unlink: true }, "web"); | |
| 64 | + | return done.ok ? { error: null, message: repo ? `Linked to ${repo}.` : "Unlinked." } : fail(done.error.message); | |
| 65 | + | } | |
| 66 | + | return fail("That is not something this page does."); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | export default function PackagePage({ loaderData, actionData }: Route.ComponentProps) { | |
| 70 | + | const { detail, username } = loaderData; | |
| 71 | + | const { package: pkg, versions, tags, permissions } = detail; | |
| 72 | + | const outcome = actionData as Outcome | undefined; | |
| 73 | + | const latest = tags.find((tag) => tag.tag === "latest")?.tag ?? tags[0]?.tag ?? null; | |
| 74 | + | const commands = installCommands(pkg, latest, username); | |
| 75 | + | // Signatures and attestations hang off the images they describe. | |
| 76 | + | const images = versions.filter((version) => !version.subject); | |
| 77 | + | const attached = (digest: string) => versions.filter((version) => version.subject === digest); | |
| 78 | + | return ( | |
| 79 | + | <div className="space-y-8"> | |
| 80 | + | <header className="space-y-3"> | |
| 81 | + | <Link to={`/${pkg.workspace}/-/packages`} className="text-sm text-muted hover:text-fg"> | |
| 82 | + | Packages | |
| 83 | + | </Link> | |
| 84 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 85 | + | <span className="text-faint">{pkg.visibility === "private" ? <Lock size={18} /> : <Package size={18} />}</span> | |
| 86 | + | <h1 className="text-2xl font-semibold tracking-tight">{pkg.name}</h1> | |
| 87 | + | <Badge>{ECOSYSTEM_LABEL[pkg.ecosystem]}</Badge> | |
| 88 | + | <Badge>{pkg.visibility === "private" ? "Private" : "Public"}</Badge> | |
| 89 | + | </div> | |
| 90 | + | <p className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted tabular-nums"> | |
| 91 | + | {pkg.repo && ( | |
| 92 | + | <Link to={`/${pkg.repo.namespace}/${pkg.repo.name}`} className="inline-flex items-center gap-1 hover:text-fg"> | |
| 93 | + | <Box size={13} /> | |
| 94 | + | {pkg.repo.namespace}/{pkg.repo.name} | |
| 95 | + | </Link> | |
| 96 | + | )} | |
| 97 | + | <span>{formatBytes(pkg.size)}</span> | |
| 98 | + | <span> | |
| 99 | + | {pkg.downloads.toLocaleString("en-US")} {pkg.downloads === 1 ? "pull" : "pulls"} | |
| 100 | + | </span> | |
| 101 | + | <span> | |
| 102 | + | Updated <TimeAgo at={pkg.updated_at} /> | |
| 103 | + | </span> | |
| 104 | + | </p> | |
| 105 | + | {pkg.description && <p className="max-w-2xl text-sm text-fg-soft">{pkg.description}</p>} | |
| 106 | + | </header> | |
| 107 | + | ||
| 108 | + | {outcome?.error && <ErrorText>{outcome.error}</ErrorText>} | |
| 109 | + | {outcome?.message && <p className="text-sm text-accent">{outcome.message}</p>} | |
| 110 | + | ||
| 111 | + | <section className="space-y-2"> | |
| 112 | + | <h2 className="text-sm font-semibold">Pull it</h2> | |
| 113 | + | {pkg.visibility === "private" && <CopyLine prompt text={commands.login} />} | |
| 114 | + | <CopyLine prompt text={commands.install} /> | |
| 115 | + | </section> | |
| 116 | + | ||
| 117 | + | <section className="space-y-3"> | |
| 118 | + | <h2 className="text-sm font-semibold"> | |
| 119 | + | Versions <span className="font-normal text-faint">{images.length}</span> | |
| 120 | + | </h2> | |
| 121 | + | {images.length === 0 ? ( | |
| 122 | + | <p className="text-sm text-muted">No versions are left.</p> | |
| 123 | + | ) : ( | |
| 124 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 125 | + | {images.map((version) => ( | |
| 126 | + | <VersionRow key={version.id} version={version} attached={attached(version.digest)} canDelete={permissions.delete} /> | |
| 127 | + | ))} | |
| 128 | + | </ul> | |
| 129 | + | )} | |
| 130 | + | </section> | |
| 131 | + | ||
| 132 | + | {permissions.admin && <Settings detail={detail} />} | |
| 133 | + | </div> | |
| 134 | + | ); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | function VersionRow({ version, attached, canDelete }: { version: PackageVersion; attached: PackageVersion[]; canDelete: boolean }) { | |
| 138 | + | return ( | |
| 139 | + | <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3"> | |
| 140 | + | <div className="min-w-0 grow space-y-1"> | |
| 141 | + | <div className="flex flex-wrap items-center gap-1.5"> | |
| 142 | + | {version.tags.length > 0 ? ( | |
| 143 | + | version.tags.map((tag) => ( | |
| 144 | + | <Badge key={tag} tone={tag === "latest" ? "accent" : "neutral"} className="font-mono"> | |
| 145 | + | {tag} | |
| 146 | + | </Badge> | |
| 147 | + | )) | |
| 148 | + | ) : ( | |
| 149 | + | <span className="text-xs text-faint">Untagged</span> | |
| 150 | + | )} | |
| 151 | + | <code className="font-mono text-xs text-muted" title={version.digest}> | |
| 152 | + | {shortDigest(version.digest)} | |
| 153 | + | </code> | |
| 154 | + | </div> | |
| 155 | + | <p className="flex flex-wrap gap-x-3 text-xs text-faint tabular-nums"> | |
| 156 | + | <span>{formatBytes(version.size)}</span> | |
| 157 | + | {version.platforms.length > 0 && <span>{version.platforms.join(", ")}</span>} | |
| 158 | + | {attached.length > 0 && ( | |
| 159 | + | <span title={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}> | |
| 160 | + | {attached.length} attached ({attached.map((a) => artifactWord(a)).join(", ")}) | |
| 161 | + | </span> | |
| 162 | + | )} | |
| 163 | + | <span> | |
| 164 | + | {version.published_by ? `${version.published_by} · ` : ""} | |
| 165 | + | <TimeAgo at={version.published_at} /> | |
| 166 | + | </span> | |
| 167 | + | </p> | |
| 168 | + | </div> | |
| 169 | + | {canDelete && ( | |
| 170 | + | <ConfirmDialog | |
| 171 | + | intent="delete-version" | |
| 172 | + | fields={{ version: version.digest }} | |
| 173 | + | title={`Delete ${version.tags[0] ?? shortDigest(version.digest)}?`} | |
| 174 | + | description="Anyone pulling it by this tag or digest gets an error from then on." | |
| 175 | + | submit="Delete version" | |
| 176 | + | busy="Deleting…" | |
| 177 | + | trigger={(open) => ( | |
| 178 | + | <button type="button" onClick={open} aria-label="Delete version" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-danger"> | |
| 179 | + | <Trash2 size={14} /> | |
| 180 | + | </button> | |
| 181 | + | )} | |
| 182 | + | > | |
| 183 | + | <li>Its tags go with it.</li> | |
| 184 | + | <li>Files no other version uses are removed within a day.</li> | |
| 185 | + | </ConfirmDialog> | |
| 186 | + | )} | |
| 187 | + | </li> | |
| 188 | + | ); | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /** "signature", "SBOM", "attestation", or what the artifact says it is. */ | |
| 192 | + | function artifactWord(version: PackageVersion): string { | |
| 193 | + | const type = version.artifact_type ?? version.media_type ?? ""; | |
| 194 | + | if (/signature|cosign|notary/i.test(type)) return "signature"; | |
| 195 | + | if (/spdx|cyclonedx|sbom/i.test(type)) return "SBOM"; | |
| 196 | + | if (/in-toto|attestation|provenance/i.test(type)) return "attestation"; | |
| 197 | + | return "artifact"; | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | function Settings({ detail }: { detail: Route.ComponentProps["loaderData"]["detail"] }) { | |
| 201 | + | const { package: pkg } = detail; | |
| 202 | + | const busy = useNavigation().state !== "idle"; | |
| 203 | + | return ( | |
| 204 | + | <section className="space-y-4 rounded-xl border border-line bg-surface p-5"> | |
| 205 | + | <h2 className="text-sm font-semibold">Settings</h2> | |
| 206 | + | {pkg.repo ? ( | |
| 207 | + | <p className="text-sm text-muted"> | |
| 208 | + | Linked to{" "} | |
| 209 | + | <Link to={`/${pkg.repo.namespace}/${pkg.repo.name}`} className="text-fg-soft hover:text-fg"> | |
| 210 | + | {pkg.repo.namespace}/{pkg.repo.name} | |
| 211 | + | </Link> | |
| 212 | + | : it has the repository's visibility and roles. | |
| 213 | + | </p> | |
| 214 | + | ) : ( | |
| 215 | + | <Form method="post" className="flex flex-wrap items-center gap-2 text-sm"> | |
| 216 | + | <input type="hidden" name="intent" value="visibility" /> | |
| 217 | + | <span className="text-muted">Visibility</span> | |
| 218 | + | <select | |
| 219 | + | name="visibility" | |
| 220 | + | defaultValue={pkg.visibility} | |
| 221 | + | className="h-8 rounded-md border border-line bg-bg px-2 text-sm" | |
| 222 | + | aria-label="Visibility" | |
| 223 | + | > | |
| 224 | + | <option value="private">Private: workspace members</option> | |
| 225 | + | <option value="public">Public: anyone can pull</option> | |
| 226 | + | </select> | |
| 227 | + | <Button type="submit" variant="quiet" disabled={busy}> | |
| 228 | + | Save | |
| 229 | + | </Button> | |
| 230 | + | </Form> | |
| 231 | + | )} | |
| 232 | + | <Form method="post" className="flex flex-wrap items-center gap-2 text-sm"> | |
| 233 | + | <input type="hidden" name="intent" value="link" /> | |
| 234 | + | <label className="text-muted" htmlFor="link-repo"> | |
| 235 | + | Repository | |
| 236 | + | </label> | |
| 237 | + | <input | |
| 238 | + | id="link-repo" | |
| 239 | + | name="repo" | |
| 240 | + | defaultValue={pkg.repo?.name ?? ""} | |
| 241 | + | placeholder="none" | |
| 242 | + | className="h-8 w-56 rounded-md border border-line bg-bg px-2 font-mono text-sm placeholder:text-faint" | |
| 243 | + | /> | |
| 244 | + | <Button type="submit" variant="quiet" disabled={busy}> | |
| 245 | + | {pkg.repo ? "Change link" : "Link"} | |
| 246 | + | </Button> | |
| 247 | + | <span className="text-xs text-faint">Empty to unlink. A linked package takes the repository's access.</span> | |
| 248 | + | </Form> | |
| 249 | + | {detail.permissions.delete && ( | |
| 250 | + | <div className="border-t border-line pt-4"> | |
| 251 | + | <ConfirmDialog | |
| 252 | + | intent="delete-package" | |
| 253 | + | title={`Delete ${pkg.name}?`} | |
| 254 | + | description="Every version and tag goes, and anyone pulling it gets an error." | |
| 255 | + | confirm={pkg.name} | |
| 256 | + | submit="Delete package" | |
| 257 | + | busy="Deleting…" | |
| 258 | + | trigger={(open) => ( | |
| 259 | + | <Button type="button" variant="danger" onClick={open}> | |
| 260 | + | Delete package | |
| 261 | + | </Button> | |
| 262 | + | )} | |
| 263 | + | > | |
| 264 | + | <li>{detail.versions.length} versions and their tags.</li> | |
| 265 | + | <li>The name can be pushed again afterwards.</li> | |
| 266 | + | </ConfirmDialog> | |
| 267 | + | </div> | |
| 268 | + | )} | |
| 269 | + | </section> | |
| 270 | + | ); | |
| 271 | + | } |
| 1 | + | import { Box, Lock, Package, Search } from "lucide-react"; | |
| 2 | + | import { Form, Link, data } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { ECOSYSTEMS, type Ecosystem, type PackageSummary } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/packages"; | |
| 7 | + | import { CopyLine, EmptyState, TimeAgo } from "../../components/ui"; | |
| 8 | + | import { Badge } from "../../components/ui/badge"; | |
| 9 | + | import { cn } from "../../lib/cn"; | |
| 10 | + | import { page } from "../../lib/meta"; | |
| 11 | + | import { ECOSYSTEM_LABEL, formatBytes } from "../../lib/packages"; | |
| 12 | + | import { packages } from "../../lib/services.server"; | |
| 13 | + | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `Packages · ${params.owner} · g1t` }); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 20 | + | const viewer = getViewer(context); | |
| 21 | + | // Members only for now; a workspace's public packages are found through | |
| 22 | + | // their repositories and search. | |
| 23 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 24 | + | const url = new URL(request.url); | |
| 25 | + | const asked = url.searchParams.get("type"); | |
| 26 | + | const ecosystem = asked && (ECOSYSTEMS as readonly string[]).includes(asked) ? (asked as Ecosystem) : null; | |
| 27 | + | const query = url.searchParams.get("q")?.trim() || null; | |
| 28 | + | const list = unwrap(await packages.list(params.owner, viewer, { ecosystem, query })); | |
| 29 | + | return { list, ecosystem, query, workspace: params.owner.toLowerCase(), username: viewer?.username ?? "you" }; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** The packages a workspace publishes, by registry, with how to start one. */ | |
| 33 | + | export default function Packages({ loaderData }: Route.ComponentProps) { | |
| 34 | + | const { list, ecosystem, query, workspace, username } = loaderData; | |
| 35 | + | const filtered = ecosystem != null || query != null; | |
| 36 | + | return ( | |
| 37 | + | <div className="space-y-6"> | |
| 38 | + | <div className="flex justify-end"> | |
| 39 | + | <a href="https://docs.g1t.sh/guides/packages/" className="text-sm text-muted hover:text-fg"> | |
| 40 | + | How packages work | |
| 41 | + | </a> | |
| 42 | + | </div> | |
| 43 | + | ||
| 44 | + | <Form method="get" className="flex flex-wrap items-center gap-2"> | |
| 45 | + | <label className="relative min-w-0 grow"> | |
| 46 | + | <span className="sr-only">Search packages</span> | |
| 47 | + | <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 48 | + | <input | |
| 49 | + | name="q" | |
| 50 | + | defaultValue={query ?? ""} | |
| 51 | + | placeholder="Find a package" | |
| 52 | + | className="h-9 w-full rounded-lg border border-line bg-surface pr-3 pl-8 text-sm placeholder:text-faint focus:border-line-strong focus:outline-none" | |
| 53 | + | /> | |
| 54 | + | </label> | |
| 55 | + | {ecosystem && <input type="hidden" name="type" value={ecosystem} />} | |
| 56 | + | <nav aria-label="Registry" className="flex flex-wrap gap-1 text-sm"> | |
| 57 | + | {[null, ...ECOSYSTEMS].map((option) => { | |
| 58 | + | const params = new URLSearchParams(); | |
| 59 | + | if (option) params.set("type", option); | |
| 60 | + | if (query) params.set("q", query); | |
| 61 | + | const href = `/${workspace}/-/packages${params.size ? `?${params}` : ""}`; | |
| 62 | + | return ( | |
| 63 | + | <Link | |
| 64 | + | key={option ?? "all"} | |
| 65 | + | to={href} | |
| 66 | + | aria-current={ecosystem === option ? "page" : undefined} | |
| 67 | + | className={cn( | |
| 68 | + | "rounded-md px-2.5 py-1.5 transition-colors", | |
| 69 | + | ecosystem === option ? "bg-raised text-fg" : "text-muted hover:text-fg", | |
| 70 | + | )} | |
| 71 | + | > | |
| 72 | + | {option ? ECOSYSTEM_LABEL[option] : "All"} | |
| 73 | + | </Link> | |
| 74 | + | ); | |
| 75 | + | })} | |
| 76 | + | </nav> | |
| 77 | + | </Form> | |
| 78 | + | ||
| 79 | + | {list.length === 0 ? ( | |
| 80 | + | filtered ? ( | |
| 81 | + | <EmptyState title="No packages match">Try another registry, or clear the search.</EmptyState> | |
| 82 | + | ) : ( | |
| 83 | + | <FirstPackage workspace={workspace} username={username} /> | |
| 84 | + | ) | |
| 85 | + | ) : ( | |
| 86 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 87 | + | {list.map((pkg) => ( | |
| 88 | + | <PackageRow key={pkg.id} pkg={pkg} /> | |
| 89 | + | ))} | |
| 90 | + | </ul> | |
| 91 | + | )} | |
| 92 | + | </div> | |
| 93 | + | ); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | function PackageRow({ pkg }: { pkg: PackageSummary }) { | |
| 97 | + | return ( | |
| 98 | + | <li> | |
| 99 | + | <Link | |
| 100 | + | to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`} | |
| 101 | + | className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 transition-colors hover:bg-raised/50" | |
| 102 | + | > | |
| 103 | + | <span className="text-faint">{pkg.visibility === "private" ? <Lock size={15} /> : <Package size={15} />}</span> | |
| 104 | + | <span className="min-w-0 grow"> | |
| 105 | + | <span className="flex flex-wrap items-center gap-2"> | |
| 106 | + | <span className="truncate font-medium">{pkg.name}</span> | |
| 107 | + | <Badge>{ECOSYSTEM_LABEL[pkg.ecosystem]}</Badge> | |
| 108 | + | {pkg.visibility === "private" && <Badge>Private</Badge>} | |
| 109 | + | </span> | |
| 110 | + | <span className="mt-0.5 block truncate font-mono text-xs text-faint">{pkg.address}</span> | |
| 111 | + | </span> | |
| 112 | + | <span className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted tabular-nums"> | |
| 113 | + | {pkg.repo && ( | |
| 114 | + | <span className="inline-flex items-center gap-1"> | |
| 115 | + | <Box size={12} /> | |
| 116 | + | {pkg.repo.name} | |
| 117 | + | </span> | |
| 118 | + | )} | |
| 119 | + | {pkg.latest && <span className="font-mono">{pkg.latest}</span>} | |
| 120 | + | <span> | |
| 121 | + | {pkg.versions} {pkg.versions === 1 ? "version" : "versions"} | |
| 122 | + | </span> | |
| 123 | + | <span>{formatBytes(pkg.size)}</span> | |
| 124 | + | <span> | |
| 125 | + | Updated <TimeAgo at={pkg.updated_at} /> | |
| 126 | + | </span> | |
| 127 | + | </span> | |
| 128 | + | </Link> | |
| 129 | + | </li> | |
| 130 | + | ); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /** With no packages yet: how to push the first image. */ | |
| 134 | + | function FirstPackage({ workspace, username }: { workspace: string; username: string }) { | |
| 135 | + | return ( | |
| 136 | + | <section className="rounded-xl border border-line bg-surface p-6"> | |
| 137 | + | <h2 className="font-medium">Push the workspace's first image</h2> | |
| 138 | + | <p className="mt-1 text-sm text-muted"> | |
| 139 | + | Log in with a token that may write packages, tag an image under the workspace, and push it. An image named after one of the | |
| 140 | + | workspace's repositories is linked to it and has its access. | |
| 141 | + | </p> | |
| 142 | + | <div className="mt-4 space-y-2"> | |
| 143 | + | <CopyLine prompt text={`docker login g1t.sh -u ${username}`} /> | |
| 144 | + | <CopyLine prompt text={`docker tag web g1t.sh/${workspace}/web:latest`} /> | |
| 145 | + | <CopyLine prompt text={`docker push g1t.sh/${workspace}/web:latest`} /> | |
| 146 | + | </div> | |
| 147 | + | <p className="mt-4 text-xs text-faint"> | |
| 148 | + | In a workflow, log in with <code className="font-mono">G1T_TOKEN</code>. See{" "} | |
| 149 | + | <a href="https://docs.g1t.sh/guides/containers/" className="underline hover:text-fg"> | |
| 150 | + | Containers | |
| 151 | + | </a> | |
| 152 | + | . | |
| 153 | + | </p> | |
| 154 | + | </section> | |
| 155 | + | ); | |
| 156 | + | } |
| 27 | 27 | } from "../../lib/session.server"; | |
| 28 | 28 | ||
| 29 | 29 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 30 | − | return page(args, { title: `People · ${params.owner} · g1t` }); | |
| 30 | + | return page(args, { title: `Members · ${params.owner} · g1t` }); | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 4 | 4 | import { | |
| 5 | 5 | RENAME_COOLDOWN_HOURS, | |
| 6 | 6 | SLUG_HOLD_DAYS, | |
| 7 | + | WORKSPACE_RESTORE_DAYS, | |
| 7 | 8 | type Workspace, | |
| 8 | 9 | type WorkspaceDeletion, | |
| 9 | 10 | isValidNamespace, | |
| 26 | 27 | import { FieldDescription, FieldLabel, Field as FormField } from "../../components/ui/field"; | |
| 27 | 28 | import { InputAddon, InputGroup, Input as TextInput } from "../../components/ui/input"; | |
| 28 | 29 | import { readAvatarUpload } from "../../lib/avatar-upload"; | |
| 29 | − | import { identity } from "../../lib/services.server"; | |
| 30 | + | import { deployments, identity } from "../../lib/services.server"; | |
| 30 | 31 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 31 | 32 | import { forgetWorkspace } from "../../lib/workspace-choice"; | |
| 33 | + | import { confirmsSlug, deletionRefusal, whatGoes } from "../../lib/workspace-deletion"; | |
| 32 | 34 | ||
| 33 | 35 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 34 | 36 | return page(args, { title: `Settings · ${params.owner} · g1t` }); | |
| 56 | 58 | ? { slug, available: result.value, message: result.value ? null : "That address is not available." } | |
| 57 | 59 | : { slug, available: false, message: result.error.message }; | |
| 58 | 60 | } | |
| 59 | − | // What stands in the way of deleting it, shown before anyone types. | |
| 61 | + | // What deleting it would take with it, and anything in the way, shown | |
| 62 | + | // before anyone types. Its apps are counted by deployments. | |
| 60 | 63 | let deletion: WorkspaceDeletion | null = null; | |
| 64 | + | let apps: number | null = null; | |
| 61 | 65 | if (viewer) { | |
| 62 | − | const found = await identity.checkWorkspaceDeletion(viewer, params.owner).catch(() => null); | |
| 66 | + | const [found, usage] = await Promise.all([ | |
| 67 | + | identity.checkWorkspaceDeletion(viewer, params.owner).catch(() => null), | |
| 68 | + | deployments.usage(params.owner, viewer).catch(() => null), | |
| 69 | + | ]); | |
| 63 | 70 | deletion = found?.ok ? found.value : null; | |
| 71 | + | apps = usage?.ok ? usage.value.apps : null; | |
| 64 | 72 | } | |
| 65 | − | return { workspace, check, deletion }; | |
| 73 | + | return { workspace, check, deletion, apps }; | |
| 66 | 74 | } | |
| 67 | 75 | ||
| 68 | 76 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 84 | 92 | } | |
| 85 | 93 | // A new address: identity checks the owner, the name and the cooldown, | |
| 86 | 94 | // and keeps the old one as a redirect. | |
| 87 | − | // Deletion: identity checks the owner, the typed name, that nothing is | |
| 88 | − | // left in it and that billing has settled it. | |
| 95 | + | // Deletion: identity checks the owner, the typed name, that it is not | |
| 96 | + | // protected and that billing has settled it. Everything in it goes with | |
| 97 | + | // it; the owner goes back to their own home, where it is no longer listed. | |
| 89 | 98 | if (intent === "delete") { | |
| 90 | 99 | const result = await identity.deleteWorkspace(user, params.owner, String(form.get("confirm") ?? "")); | |
| 91 | 100 | if (!result.ok) return { deleteError: result.error.message }; | |
| 149 | 158 | <DeleteAction | |
| 150 | 159 | workspace={workspace} | |
| 151 | 160 | deletion={loaderData.deletion} | |
| 161 | + | apps={loaderData.apps} | |
| 152 | 162 | error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined} | |
| 153 | 163 | /> | |
| 154 | 164 | </DangerZone> | |
| 156 | 166 | ); | |
| 157 | 167 | } | |
| 158 | 168 | ||
| 159 | − | /** Why a workspace cannot be deleted yet, as a sentence, or null. */ | |
| 160 | − | function blockedBy(slug: string, deletion: WorkspaceDeletion | null): string | null { | |
| 161 | − | if (!deletion) return null; | |
| 162 | − | const held: string[] = []; | |
| 163 | − | const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; | |
| 164 | − | if (deletion.repositories > 0) held.push(plural(deletion.repositories, "repository", "repositories")); | |
| 165 | − | if (deletion.projects > 0) held.push(plural(deletion.projects, "project", "projects")); | |
| 166 | − | if (held.length) { | |
| 167 | − | return `${slug} still holds ${held.join(" and ")}. Transfer each repository to another workspace or delete it first, under Repositories. Recently deleted repositories are removed with the workspace.`; | |
| 168 | − | } | |
| 169 | − | return deletion.billing; | |
| 170 | − | } | |
| 171 | − | ||
| 172 | 169 | /** | |
| 173 | − | * Deleting the workspace, once it holds nothing and billing has settled | |
| 174 | − | * it. Owners only, as the whole page is; typed out to confirm. | |
| 170 | + | * Deleting the workspace, with everything in it, in one step. Owners only, | |
| 171 | + | * as the whole page is; typed out to confirm. It is kept for | |
| 172 | + | * `WORKSPACE_RESTORE_DAYS`, when support can restore it. A protected | |
| 173 | + | * workspace says so instead of offering the button. | |
| 175 | 174 | */ | |
| 176 | 175 | function DeleteAction({ | |
| 177 | 176 | workspace, | |
| 178 | 177 | deletion, | |
| 178 | + | apps, | |
| 179 | 179 | error, | |
| 180 | 180 | }: { | |
| 181 | 181 | workspace: Workspace; | |
| 182 | 182 | deletion: WorkspaceDeletion | null; | |
| 183 | + | apps: number | null; | |
| 183 | 184 | error?: string; | |
| 184 | 185 | }) { | |
| 185 | 186 | const [open, setOpen] = useState(Boolean(error)); | |
| 186 | 187 | const [confirm, setConfirm] = useState(""); | |
| 187 | 188 | const navigation = useNavigation(); | |
| 188 | 189 | const deleting = navigation.state !== "idle" && navigation.formData?.get("intent") === "delete"; | |
| 189 | − | const blocked = blockedBy(workspace.slug, deletion); | |
| 190 | + | const refusal = deletionRefusal(workspace.slug, deletion); | |
| 191 | + | const goes = deletion ? whatGoes(deletion, apps) : []; | |
| 192 | + | if (deletion?.protected) { | |
| 193 | + | return ( | |
| 194 | + | <DangerAction title="Delete this workspace" action={null}> | |
| 195 | + | <span role="status">{refusal}</span> | |
| 196 | + | </DangerAction> | |
| 197 | + | ); | |
| 198 | + | } | |
| 190 | 199 | return ( | |
| 191 | 200 | <DangerAction | |
| 192 | 201 | title="Delete this workspace" | |
| 193 | 202 | action={ | |
| 194 | − | <Button type="button" variant="danger" disabled={Boolean(blocked)} onClick={() => setOpen(true)}> | |
| 203 | + | <Button type="button" variant="danger" disabled={Boolean(refusal)} onClick={() => setOpen(true)}> | |
| 195 | 204 | Delete workspace | |
| 196 | 205 | </Button> | |
| 197 | 206 | } | |
| 198 | 207 | > | |
| 199 | − | {blocked ? ( | |
| 200 | − | <span role="status">{blocked}</span> | |
| 208 | + | {refusal ? ( | |
| 209 | + | <span role="status">{refusal}</span> | |
| 201 | 210 | ) : ( | |
| 202 | − | <>Its members, tokens, webhooks, integrations and secrets are removed. Statements and the audit log are kept.</> | |
| 211 | + | <> | |
| 212 | + | Everything in it goes with it: repositories, projects, apps, members' access and tokens. It is kept for{" "} | |
| 213 | + | {WORKSPACE_RESTORE_DAYS} days, and support can restore it until then. | |
| 214 | + | </> | |
| 203 | 215 | )} | |
| 204 | 216 | <AlertDialog | |
| 205 | 217 | open={open} | |
| 213 | 225 | <input type="hidden" name="intent" value="delete" /> | |
| 214 | 226 | <AlertDialogHeader> | |
| 215 | 227 | <AlertDialogTitle>Delete {workspace.slug}?</AlertDialogTitle> | |
| 216 | − | <AlertDialogDescription>This cannot be undone.</AlertDialogDescription> | |
| 228 | + | <AlertDialogDescription> | |
| 229 | + | Everything in it goes now. For {WORKSPACE_RESTORE_DAYS} days, support can restore all of it; after | |
| 230 | + | that it is gone for good. | |
| 231 | + | </AlertDialogDescription> | |
| 217 | 232 | </AlertDialogHeader> | |
| 233 | + | {goes.length > 0 ? ( | |
| 234 | + | <div className="grid gap-1.5"> | |
| 235 | + | <p className="text-sm font-medium">What goes with it</p> | |
| 236 | + | <ul className="list-disc space-y-1 pl-5 text-sm text-muted"> | |
| 237 | + | {goes.map((line) => ( | |
| 238 | + | <li key={line}>{line}</li> | |
| 239 | + | ))} | |
| 240 | + | </ul> | |
| 241 | + | </div> | |
| 242 | + | ) : null} | |
| 218 | 243 | <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted"> | |
| 219 | − | <li>Everyone loses access to it, and its access tokens stop working at once.</li> | |
| 220 | − | <li>Its webhooks, integrations, workspace secrets and memory are removed.</li> | |
| 244 | + | <li>Everyone loses access to it at once, and its access tokens stop working.</li> | |
| 245 | + | <li>Its pages, repositories and apps are no longer found, and nothing builds or runs for it.</li> | |
| 221 | 246 | <li> | |
| 222 | 247 | Anything it owes is charged to its card now, and its plan ends today. Its statements, invoices and | |
| 223 | 248 | audit log are kept. | |
| 224 | 249 | </li> | |
| 225 | 250 | <li> | |
| 251 | + | To get it back within {WORKSPACE_RESTORE_DAYS} days, an owner writes to support. After that its | |
| 252 | + | repositories, projects, apps, webhooks, integrations, secrets and memory are removed for good. | |
| 253 | + | </li> | |
| 254 | + | <li> | |
| 226 | 255 | The name <span className="font-mono text-fg">{workspace.slug}</span> is never given to another | |
| 227 | 256 | workspace. Repositories that were transferred out keep redirecting from it. | |
| 228 | 257 | </li> | |
| 246 | 275 | <ErrorText>{error}</ErrorText> | |
| 247 | 276 | <AlertDialogFooter> | |
| 248 | 277 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 249 | − | <Button | |
| 250 | − | type="submit" | |
| 251 | − | variant="danger" | |
| 252 | − | disabled={confirm.trim().toLowerCase() !== workspace.slug || deleting} | |
| 253 | − | > | |
| 278 | + | <Button type="submit" variant="danger" disabled={!confirmsSlug(workspace.slug, confirm) || deleting}> | |
| 254 | 279 | {deleting ? "Deleting…" : "Delete workspace"} | |
| 255 | 280 | </Button> | |
| 256 | 281 | </AlertDialogFooter> |
| 1 | 1 | import { createRequestHandler } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import { finishResponse, withRequestPerf } from "../app/lib/perf.server"; | |
| 4 | + | import { servicePath } from "../app/lib/registry-paths"; | |
| 4 | 5 | ||
| 5 | 6 | const requestHandler = createRequestHandler( | |
| 6 | 7 | () => import("virtual:react-router/server-build"), | |
| 7 | 8 | import.meta.env.MODE, | |
| 8 | 9 | ); | |
| 9 | 10 | ||
| 10 | − | const GIT_PATH = /\/(info\/refs|git-upload-pack|git-receive-pack)$/; | |
| 11 | 11 | /** An uploaded avatar, by the SHA-256 of its bytes. */ | |
| 12 | 12 | const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/; | |
| 13 | 13 | /** The only types identity stores, having checked each image's bytes. */ | |
| 33 | 33 | // Its answer goes back to the git client as it is: a repository under a | |
| 34 | 34 | // renamed workspace's old name answers with a 301, which git follows and | |
| 35 | 35 | // must see, so the redirect is never followed here. | |
| 36 | − | if (GIT_PATH.test(pathname)) { | |
| 36 | + | // The container registry (`docker login g1t.sh`) is the packages | |
| 37 | + | // service's, handed over the same way. | |
| 38 | + | const service = servicePath(pathname); | |
| 39 | + | if (service === "git") { | |
| 37 | 40 | return proxyGit(env, request); | |
| 38 | 41 | } | |
| 42 | + | if (service === "packages") { | |
| 43 | + | return proxyPackages(env, request); | |
| 44 | + | } | |
| 39 | 45 | const avatar = AVATAR_PATH.exec(pathname); | |
| 40 | 46 | if (avatar) { | |
| 41 | 47 | return serveAvatar(env, ctx, request, avatar[1]); | |
| 121 | 127 | } | |
| 122 | 128 | ||
| 123 | 129 | /** | |
| 130 | + | * A registry request, answered by the packages service as it is: its | |
| 131 | + | * redirects (a large blob sent to storage) go back to the client, which | |
| 132 | + | * follows them itself. | |
| 133 | + | */ | |
| 134 | + | async function proxyPackages(env: Env, request: Request): Promise<Response> { | |
| 135 | + | const started = Date.now(); | |
| 136 | + | const answer = await env.PACKAGES.fetch(new Request(request, { redirect: "manual" })); | |
| 137 | + | const response = new Response(answer.body, answer); | |
| 138 | + | response.headers.append("server-timing", `packages;dur=${Date.now() - started}`); | |
| 139 | + | return response; | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /** | |
| 124 | 143 | * An uploaded avatar. Its address is its hash, so it never changes and is | |
| 125 | 144 | * kept for good. It is served as nothing but an image: the stored type, | |
| 126 | 145 | * no sniffing, and a policy that lets nothing in it run. |
| 6 | 6 | IDENTITY: ServiceBinding; | |
| 7 | 7 | /** Also serves git over HTTPS through `fetch`. */ | |
| 8 | 8 | REPOS: ServiceBinding & { fetch(request: Request): Promise<Response> }; | |
| 9 | + | /** Also serves the container registry (`/v2/`) through `fetch`. */ | |
| 10 | + | PACKAGES: ServiceBinding & { fetch(request: Request): Promise<Response> }; | |
| 9 | 11 | WORK: ServiceBinding; | |
| 10 | 12 | RUNNER: RunnerApi; | |
| 11 | 13 | BILLING: ServiceBinding; |
| 26 | 26 | "services": [ | |
| 27 | 27 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 28 | 28 | { "binding": "REPOS", "service": "g1t-repos" }, | |
| 29 | + | // The package registries, on g1t.sh/v2/ (workers/app.ts). | |
| 30 | + | { "binding": "PACKAGES", "service": "g1t-packages" }, | |
| 29 | 31 | { "binding": "WORK", "service": "g1t-work" }, | |
| 30 | 32 | { "binding": "RUNNER", "service": "g1t-runner" }, | |
| 31 | 33 | { "binding": "BILLING", "service": "g1t-billing" }, |
| 72 | 72 | Git, | |
| 73 | 73 | /// g1t.sh itself: settings changed on its pages. | |
| 74 | 74 | Web, | |
| 75 | + | /// The package registries: `docker push`, `npm publish` and the like. | |
| 76 | + | Registry, | |
| 75 | 77 | } | |
| 76 | 78 | ||
| 77 | 79 | /// The actor of an entry, from whoever made the request. |
| 1080 | 1080 | pub free_private_storage_bytes: i64, | |
| 1081 | 1081 | /// The last daily measure of the workspace's private repositories. | |
| 1082 | 1082 | pub private_storage_bytes: i64, | |
| 1083 | + | /// On a paid plan (not Free): storage past the free amounts below is | |
| 1084 | + | /// charged, so nothing is refused for it. | |
| 1085 | + | #[serde(default)] | |
| 1086 | + | pub has_plan: bool, | |
| 1087 | + | /// Package storage free for every workspace, public and private: past | |
| 1088 | + | /// it, the plan pays for it and a free workspace's pushes are refused. | |
| 1089 | + | #[serde(default)] | |
| 1090 | + | pub package_public_free_bytes: i64, | |
| 1091 | + | #[serde(default)] | |
| 1092 | + | pub package_private_free_bytes: i64, | |
| 1083 | 1093 | /// What g1t's open-source pool paid for the workspace this month. | |
| 1084 | 1094 | pub oss_paid_micros: i64, | |
| 1085 | 1095 | /// Deploy build time this month, every second of it metered. | |
| 2245 | 2255 | pub session: String, | |
| 2246 | 2256 | } | |
| 2247 | 2257 | ||
| 2258 | + | /// `admin_log`: a staff change another service made to a workspace, kept | |
| 2259 | + | /// in sudo's audit log with billing's own (`admin_audit`). For identity's | |
| 2260 | + | /// restores and purges of deleted workspaces. Returns `bool`. | |
| 2261 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 2262 | + | pub struct AdminLogArgs { | |
| 2263 | + | pub workspace: String, | |
| 2264 | + | pub action: String, | |
| 2265 | + | pub detail: String, | |
| 2266 | + | /// The staff member's email. | |
| 2267 | + | pub by: String, | |
| 2268 | + | } | |
| 2269 | + | ||
| 2248 | 2270 | /// `close_workspace`: settles a workspace that is about to be deleted. | |
| 2249 | 2271 | /// Owners only. Refused while it has an invoice that failed, while it | |
| 2250 | 2272 | /// holds prepaid credit, or while it owes money it cannot be charged for |
| 365 | 365 | /// RFC 3339: when it is purged unless restored first. | |
| 366 | 366 | #[serde(default)] | |
| 367 | 367 | pub purge_after: String, | |
| 368 | + | /// It went with its workspace (`workspace.deleting`). Services that | |
| 369 | + | /// handle the workspace as a whole (deployments pauses its apps rather | |
| 370 | + | /// than taking them down) leave this one to that. | |
| 371 | + | #[serde(default)] | |
| 372 | + | pub with_workspace: bool, | |
| 368 | 373 | } | |
| 369 | 374 | ||
| 370 | 375 | /// `repo.restored`: a deleted repository is back, at its path, as it was. | |
| 377 | 382 | pub namespace: String, | |
| 378 | 383 | pub name: String, | |
| 379 | 384 | pub is_private: bool, | |
| 385 | + | /// It came back with its workspace (`workspace.restored`). | |
| 386 | + | #[serde(default)] | |
| 387 | + | pub with_workspace: bool, | |
| 380 | 388 | } | |
| 381 | 389 | ||
| 382 | 390 | /// `repo.purged`: a deleted repository is gone for good, its git data | |
| 475 | 483 | pub slug: String, | |
| 476 | 484 | } | |
| 477 | 485 | ||
| 486 | + | /// `workspace.deleting`: an owner deleted a workspace, and it can be | |
| 487 | + | /// restored by g1t's staff until `purge_after`. Nobody can reach it in the | |
| 488 | + | /// meantime. Services hide what they keep for it and stop what runs for it, | |
| 489 | + | /// keeping their rows: repos deletes its repositories softly (each with a | |
| 490 | + | /// `repo.deleted` whose `with_workspace` is set), deployments pauses its | |
| 491 | + | /// apps, search drops it from results. `workspace.restored` undoes exactly | |
| 492 | + | /// that; once `purge_after` passes, `workspace.deleted` follows and | |
| 493 | + | /// services purge as for any deleted workspace. | |
| 494 | + | #[derive(Clone, Debug, Serialize, serde::Deserialize)] | |
| 495 | + | #[serde(rename_all = "camelCase")] | |
| 496 | + | pub struct WorkspaceDeleting { | |
| 497 | + | pub workspace_id: String, | |
| 498 | + | pub slug: String, | |
| 499 | + | /// The username of the owner who deleted it. | |
| 500 | + | pub by: String, | |
| 501 | + | /// RFC 3339: when it is purged unless restored first. | |
| 502 | + | pub purge_after: String, | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | /// `workspace.restored`: staff brought a deleted workspace back, with its | |
| 506 | + | /// members and tokens. Services undo what they did on `workspace.deleting`, | |
| 507 | + | /// and only that: a repository deleted on its own before stays deleted. | |
| 508 | + | #[derive(Clone, Debug, Serialize, serde::Deserialize)] | |
| 509 | + | #[serde(rename_all = "camelCase")] | |
| 510 | + | pub struct WorkspaceRestored { | |
| 511 | + | pub workspace_id: String, | |
| 512 | + | pub slug: String, | |
| 513 | + | } | |
| 514 | + | ||
| 478 | 515 | /// `user.updated`: an account was made, or changed what its profile shows | |
| 479 | 516 | /// (name, bio, avatar). Nothing private: ask identity for the profile. | |
| 480 | 517 | #[derive(Debug, Serialize, serde::Deserialize)] | |
| 539 | 576 | pub entry_id: String, | |
| 540 | 577 | } | |
| 541 | 578 | ||
| 579 | + | /// The payload of `package.published`, `package.version_deleted`, | |
| 580 | + | /// `package.deleted` and `package.visibility_changed`; each uses the fields | |
| 581 | + | /// that apply to it. `repo_id` is the repository the package is linked to. | |
| 582 | + | #[derive(Clone, Debug, Default, Serialize)] | |
| 583 | + | #[serde(rename_all = "camelCase")] | |
| 584 | + | pub struct PackageEvent { | |
| 585 | + | pub package_id: String, | |
| 586 | + | pub workspace: String, | |
| 587 | + | pub ecosystem: String, | |
| 588 | + | pub name: String, | |
| 589 | + | pub repo_id: Option<String>, | |
| 590 | + | /// The version published or deleted: for a container image, its | |
| 591 | + | /// manifest's digest. | |
| 592 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 593 | + | pub version: Option<String>, | |
| 594 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 595 | + | pub digest: Option<String>, | |
| 596 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 597 | + | pub size: Option<u64>, | |
| 598 | + | /// On publish: the tags that now point to the version. | |
| 599 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 600 | + | pub tags: Option<Vec<String>>, | |
| 601 | + | /// On `package.visibility_changed`: `public` or `private`. | |
| 602 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 603 | + | pub visibility: Option<String>, | |
| 604 | + | } | |
| 605 | + | ||
| 542 | 606 | /// `queue.changed`: a repository's merge queue gained, lost or settled an | |
| 543 | 607 | /// entry, so the next batch may be ready to test. | |
| 544 | 608 | #[derive(Debug, Serialize)] |
| 375 | 375 | } | |
| 376 | 376 | ||
| 377 | 377 | /// `delete_workspace`: owners only, and only a person. `confirm` must be | |
| 378 | − | /// the workspace's slug, typed out. Refused while the workspace still | |
| 379 | − | /// holds repositories or projects, or while billing cannot settle it | |
| 380 | − | /// (`close_workspace`). Removes its memberships, its access tokens and its | |
| 381 | − | /// old-slug redirects; billing's ledger and the audit log keep its | |
| 382 | − | /// history. The slug is never given to another workspace; the person | |
| 383 | − | /// whose username it is may make a workspace of that name again. | |
| 384 | − | /// Publishes `workspace.deleted`. Returns `Outcome<bool>`. | |
| 378 | + | /// the workspace's slug, typed out. Refused for a protected workspace | |
| 379 | + | /// ([`protected_names`]), whoever asks, and while billing cannot settle it | |
| 380 | + | /// (`close_workspace`). Everything in it goes with it at once: nobody can | |
| 381 | + | /// reach it, its tokens stop working, its pages are not found, and its | |
| 382 | + | /// repositories, projects and apps are deleted with it. It is kept for | |
| 383 | + | /// [`WORKSPACE_RESTORE_DAYS`] so g1t's staff can restore it, then purged: | |
| 384 | + | /// its memberships, access tokens and old-slug redirects go, and billing's | |
| 385 | + | /// ledger and the audit log keep its history. The slug is never given to | |
| 386 | + | /// another workspace; the person whose username it is may make a workspace | |
| 387 | + | /// of that name again once it is purged. Publishes `workspace.deleting`, | |
| 388 | + | /// and `workspace.deleted` at the purge. Returns `Outcome<bool>`. | |
| 385 | 389 | /// | |
| 386 | 390 | /// `check_workspace_deletion` takes the same arguments (with `confirm` | |
| 387 | − | /// ignored) and says what stands in the way, changing nothing. Returns | |
| 388 | − | /// `Outcome<WorkspaceDeletion>`. | |
| 391 | + | /// ignored) and says what would go and whether anything stands in the way, | |
| 392 | + | /// changing nothing. Returns `Outcome<WorkspaceDeletion>`. | |
| 389 | 393 | #[derive(Debug, Serialize, Deserialize)] | |
| 390 | 394 | pub struct DeleteWorkspaceArgs { | |
| 391 | 395 | pub actor: User, | |
| 397 | 401 | pub surface: Option<crate::audit::Surface>, | |
| 398 | 402 | } | |
| 399 | 403 | ||
| 400 | − | /// What stands between a workspace and its deletion. Nothing does when | |
| 401 | − | /// both counts are zero and `billing` is null. | |
| 404 | + | /// What deleting a workspace takes with it, and what stands in the way. | |
| 405 | + | /// Nothing does when `billing` is null and it is not `protected`. | |
| 402 | 406 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 403 | 407 | pub struct WorkspaceDeletion { | |
| 408 | + | /// Its live repositories, which are deleted with it. | |
| 404 | 409 | pub repositories: u32, | |
| 410 | + | /// Its projects, hidden with it. | |
| 405 | 411 | pub projects: u32, | |
| 412 | + | #[serde(default)] | |
| 413 | + | pub members: u32, | |
| 406 | 414 | /// Why billing cannot close the workspace yet, in words for its owner. | |
| 407 | 415 | pub billing: Option<String>, | |
| 416 | + | /// It can never be deleted, by anyone ([`protected_names`]). | |
| 417 | + | #[serde(default)] | |
| 418 | + | pub protected: bool, | |
| 408 | 419 | } | |
| 409 | 420 | ||
| 410 | 421 | impl WorkspaceDeletion { | |
| 411 | 422 | pub fn blocked(&self) -> bool { | |
| 412 | − | self.repositories > 0 || self.projects > 0 || self.billing.is_some() | |
| 423 | + | self.protected || self.billing.is_some() | |
| 413 | 424 | } | |
| 414 | 425 | ||
| 415 | − | /// Why the workspace cannot be deleted yet, as one sentence, or `None`. | |
| 426 | + | /// Why the workspace cannot be deleted, as one sentence, or `None`. | |
| 416 | 427 | pub fn reason(&self, slug: &str) -> Option<String> { | |
| 417 | − | let plural = |n: u32, one: &str, many: &str| { | |
| 418 | − | format!("{n} {}", if n == 1 { one } else { many }) | |
| 419 | − | }; | |
| 420 | − | let mut held = Vec::new(); | |
| 421 | − | if self.repositories > 0 { | |
| 422 | − | held.push(plural(self.repositories, "repository", "repositories")); | |
| 423 | − | } | |
| 424 | − | if self.projects > 0 { | |
| 425 | − | held.push(plural(self.projects, "project", "projects")); | |
| 426 | − | } | |
| 427 | − | if !held.is_empty() { | |
| 428 | − | return Some(format!( | |
| 429 | − | "{slug} still holds {}. Transfer them to another workspace first.", | |
| 430 | − | held.join(" and ") | |
| 431 | − | )); | |
| 428 | + | if self.protected { | |
| 429 | + | return Some(protected_refusal(slug)); | |
| 432 | 430 | } | |
| 433 | 431 | self.billing.clone() | |
| 434 | 432 | } | |
| 435 | 433 | } | |
| 436 | 434 | ||
| 435 | + | /// How long a deleted workspace is kept, for staff to restore, before it is | |
| 436 | + | /// purged. | |
| 437 | + | pub const WORKSPACE_RESTORE_DAYS: u64 = 30; | |
| 438 | + | ||
| 439 | + | /// Workspaces nobody can delete, whatever identity's `PROTECTED_WORKSPACES` | |
| 440 | + | /// says: Flagon's, which runs g1t. | |
| 441 | + | pub const ALWAYS_PROTECTED: &[&str] = &["flagon-io"]; | |
| 442 | + | ||
| 443 | + | /// The protected workspaces: `configured` (comma-separated slugs or | |
| 444 | + | /// workspace ids, as identity's `PROTECTED_WORKSPACES` holds them), and | |
| 445 | + | /// [`ALWAYS_PROTECTED`] whatever it says, so an empty or missing variable | |
| 446 | + | /// still protects them. Lowercased, without duplicates. | |
| 447 | + | pub fn protected_names(configured: Option<&str>) -> Vec<String> { | |
| 448 | + | let mut names: Vec<String> = Vec::new(); | |
| 449 | + | let given = configured.unwrap_or_default().split(','); | |
| 450 | + | for name in ALWAYS_PROTECTED.iter().copied().chain(given) { | |
| 451 | + | let name = name.trim().to_lowercase(); | |
| 452 | + | if !name.is_empty() && !names.contains(&name) { | |
| 453 | + | names.push(name); | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | names | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | /// Why a protected workspace is not deleted, purged or acted on. | |
| 460 | + | pub fn protected_refusal(slug: &str) -> String { | |
| 461 | + | format!("{slug} is protected and can never be deleted.") | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | /// `admin_deleted_workspaces` takes no arguments (`{}`) and returns | |
| 465 | + | /// `Vec<DeletedWorkspace>`, newest first. Staff only. | |
| 466 | + | /// | |
| 467 | + | /// A workspace an owner deleted, kept until `purge_after` for staff to | |
| 468 | + | /// restore. | |
| 469 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 470 | + | #[serde(rename_all = "camelCase")] | |
| 471 | + | pub struct DeletedWorkspace { | |
| 472 | + | pub workspace_id: String, | |
| 473 | + | pub slug: String, | |
| 474 | + | pub name: String, | |
| 475 | + | /// RFC 3339. | |
| 476 | + | pub deleted_at: String, | |
| 477 | + | /// The username of the owner who deleted it. | |
| 478 | + | pub deleted_by: String, | |
| 479 | + | /// RFC 3339: when it is purged unless restored first. | |
| 480 | + | pub purge_after: String, | |
| 481 | + | /// What went with it, counted when it was deleted. | |
| 482 | + | pub went: WorkspaceDeletion, | |
| 483 | + | /// Whether staff can still restore it. | |
| 484 | + | pub restorable: bool, | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | /// `admin_restore_workspace` and `admin_purge_workspace`: staff restore a | |
| 488 | + | /// deleted workspace within [`WORKSPACE_RESTORE_DAYS`], or purge it now. | |
| 489 | + | /// `staff` is who, for the audit logs. Purging needs `confirm`, the slug | |
| 490 | + | /// typed out, and is refused for a protected workspace. Restoring publishes | |
| 491 | + | /// `workspace.restored`; purging, `workspace.deleted`. Both return | |
| 492 | + | /// `Outcome<bool>`. | |
| 493 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 494 | + | #[serde(rename_all = "camelCase")] | |
| 495 | + | pub struct AdminDeletedWorkspaceArgs { | |
| 496 | + | pub workspace_id: String, | |
| 497 | + | pub staff: String, | |
| 498 | + | #[serde(default)] | |
| 499 | + | pub confirm: String, | |
| 500 | + | } | |
| 501 | + | ||
| 437 | 502 | /// `transfer_repo_scopes`: a repository moved from `from` to `to`; the | |
| 438 | 503 | /// tokens of agents at work on it are kept pointing at it. For repos' | |
| 439 | 504 | /// `transfer`. Returns `bool`. | |
| 700 | 765 | pub created_at: String, | |
| 701 | 766 | /// Owners first, then by username. | |
| 702 | 767 | pub members: Vec<AdminMember>, | |
| 768 | + | /// It can never be deleted ([`protected_names`]). | |
| 769 | + | #[serde(default)] | |
| 770 | + | pub protected: bool, | |
| 703 | 771 | } | |
| 704 | 772 | ||
| 705 | 773 | /// A member of a workspace, as staff see them. | |
| 1305 | 1373 | ||
| 1306 | 1374 | #[cfg(test)] | |
| 1307 | 1375 | mod deletion_tests { | |
| 1308 | − | use super::WorkspaceDeletion; | |
| 1376 | + | use super::{WorkspaceDeletion, protected_names}; | |
| 1309 | 1377 | ||
| 1310 | 1378 | #[test] | |
| 1311 | − | fn says_what_is_left_to_move() { | |
| 1312 | − | let clear = WorkspaceDeletion::default(); | |
| 1313 | − | assert!(!clear.blocked()); | |
| 1314 | − | assert_eq!(clear.reason("acme"), None); | |
| 1315 | − | let held = WorkspaceDeletion { | |
| 1379 | + | fn only_billing_or_protection_stands_in_the_way() { | |
| 1380 | + | let clear = WorkspaceDeletion { | |
| 1316 | 1381 | repositories: 2, | |
| 1317 | 1382 | projects: 1, | |
| 1318 | − | billing: Some("Pay first.".into()), | |
| 1383 | + | members: 3, | |
| 1384 | + | ..WorkspaceDeletion::default() | |
| 1319 | 1385 | }; | |
| 1320 | − | assert!(held.blocked()); | |
| 1321 | − | assert_eq!( | |
| 1322 | − | held.reason("acme").as_deref(), | |
| 1323 | − | Some("acme still holds 2 repositories and 1 project. Transfer them to another workspace first.") | |
| 1324 | − | ); | |
| 1386 | + | assert!(!clear.blocked()); | |
| 1387 | + | assert_eq!(clear.reason("acme"), None); | |
| 1325 | 1388 | let owing = WorkspaceDeletion { | |
| 1326 | 1389 | billing: Some("Pay first.".into()), | |
| 1327 | 1390 | ..WorkspaceDeletion::default() | |
| 1328 | 1391 | }; | |
| 1392 | + | assert!(owing.blocked()); | |
| 1329 | 1393 | assert_eq!(owing.reason("acme").as_deref(), Some("Pay first.")); | |
| 1394 | + | let protected = WorkspaceDeletion { | |
| 1395 | + | billing: Some("Pay first.".into()), | |
| 1396 | + | protected: true, | |
| 1397 | + | ..WorkspaceDeletion::default() | |
| 1398 | + | }; | |
| 1399 | + | assert!(protected.blocked()); | |
| 1400 | + | assert_eq!( | |
| 1401 | + | protected.reason("flagon-io").as_deref(), | |
| 1402 | + | Some("flagon-io is protected and can never be deleted.") | |
| 1403 | + | ); | |
| 1404 | + | } | |
| 1405 | + | ||
| 1406 | + | #[test] | |
| 1407 | + | fn flagon_is_protected_whatever_the_variable_says() { | |
| 1408 | + | assert_eq!(protected_names(None), ["flagon-io"]); | |
| 1409 | + | assert_eq!(protected_names(Some("")), ["flagon-io"]); | |
| 1410 | + | assert_eq!(protected_names(Some(" , ")), ["flagon-io"]); | |
| 1411 | + | assert_eq!( | |
| 1412 | + | protected_names(Some("Flagon-IO, acme ,wsp_1")), | |
| 1413 | + | ["flagon-io", "acme", "wsp_1"] | |
| 1414 | + | ); | |
| 1330 | 1415 | } | |
| 1331 | 1416 | } |
| 20 | 20 | mod ids; | |
| 21 | 21 | mod names; | |
| 22 | 22 | mod outcome; | |
| 23 | + | pub mod packages; | |
| 23 | 24 | pub mod projects; | |
| 24 | 25 | pub mod repos; | |
| 25 | 26 | pub mod runners; |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.