git-buckets 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_buckets-0.3.0/PKG-INFO +137 -0
- git_buckets-0.3.0/README.md +122 -0
- {git_buckets-0.2.0 → git_buckets-0.3.0}/pyproject.toml +8 -6
- {git_buckets-0.2.0 → git_buckets-0.3.0}/pyproject.toml.orig +10 -6
- git_buckets-0.3.0/src/git_buckets/cli.py +240 -0
- git_buckets-0.3.0/src/git_buckets/credential.py +83 -0
- git_buckets-0.3.0/src/git_buckets/hosts.py +140 -0
- git_buckets-0.3.0/src/git_buckets/keyring/backend.py +213 -0
- git_buckets-0.3.0/src/git_buckets/lfs_agent.py +681 -0
- git_buckets-0.3.0/src/git_buckets/login.py +175 -0
- git_buckets-0.3.0/src/git_buckets/net.py +131 -0
- git_buckets-0.3.0/src/git_buckets/oauth.py +251 -0
- git_buckets-0.3.0/src/git_buckets/repo.py +118 -0
- git_buckets-0.3.0/src/git_buckets/secretstore.py +146 -0
- git_buckets-0.3.0/src/git_buckets/session.py +97 -0
- git_buckets-0.3.0/src/git_buckets/termio.py +103 -0
- git_buckets-0.3.0/src/git_buckets/wake.py +127 -0
- git_buckets-0.2.0/PKG-INFO +0 -20
- git_buckets-0.2.0/README.md +0 -5
- git_buckets-0.2.0/src/git_buckets/cli.py +0 -58
- git_buckets-0.2.0/src/git_buckets/keyring/backend.py +0 -120
- git_buckets-0.2.0/src/git_buckets/publish.py +0 -352
- {git_buckets-0.2.0 → git_buckets-0.3.0}/src/git_buckets/__init__.py +0 -0
- {git_buckets-0.2.0 → git_buckets-0.3.0}/src/git_buckets/keyring/__init__.py +0 -0
- {git_buckets-0.2.0 → git_buckets-0.3.0}/src/git_buckets/keyring/proof.py +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: git-buckets
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Git over S3: clone, fetch and push repositories backed by an S3 bucket.
|
|
5
|
+
Author: Full Duplex Media
|
|
6
|
+
Author-email: Full Duplex Media <contact@fullduplex.media>
|
|
7
|
+
License: Apache 2.0
|
|
8
|
+
Requires-Dist: boto3>=1.43.77
|
|
9
|
+
Requires-Dist: click>=8.4.2
|
|
10
|
+
Requires-Dist: keyring>=25.7.0
|
|
11
|
+
Requires-Dist: keyring-pass>=0.9.3
|
|
12
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
13
|
+
Requires-Python: >=3.14
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# git-buckets
|
|
17
|
+
|
|
18
|
+
`gb` is the end-user CLI for a git-buckets deployment: git repos and Python package indexes over HTTPS, backed by S3.
|
|
19
|
+
Someone else runs the deployment. You log in once and then use stock git and uv.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Prereqs:
|
|
24
|
+
- git (≥ 2.13, ≥ 2.40 for bundle-accelerated clones)
|
|
25
|
+
- [git-lfs](https://git-lfs.com/)
|
|
26
|
+
- Python ≥ 3.14
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
uv tool install git-buckets
|
|
30
|
+
# or: pipx install git-buckets
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## First login
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
gb login git.example.com
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Once per deployment, never per bucket. This signs you in through the browser, stores the session, writes the
|
|
40
|
+
credential-helper stanza for `https://*.git.example.com` plus `transfer.bundleURI = true` into your global gitconfig,
|
|
41
|
+
and records the deployment.
|
|
42
|
+
|
|
43
|
+
On a box with no browser, `--no-browser` prints a URL to open elsewhere and reads the code back at a masked prompt.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
gb auth status # deployments known, the user on each, where each secret lives
|
|
47
|
+
gb logout git.example.com # revoke the session token, erase the stored credentials
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Daily use
|
|
51
|
+
|
|
52
|
+
Remote URLs are `https://<bucket>.<deployment-domain>/<repo>`: the first hostname label is the bucket's public label,
|
|
53
|
+
the path is the repo name, as deep as you like. `git clone https://my-bucket.git.example.com/tools/cli/my-project`, then
|
|
54
|
+
fetch, pull and push are stock git as well.
|
|
55
|
+
|
|
56
|
+
**New repos are made by pushing.** There is no create command, simply push to a new path on an existing bucket and
|
|
57
|
+
you've created a new repo.
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
git init && git remote add origin https://my-bucket.git.example.com/tools/gui/my-new-project
|
|
61
|
+
git push -u origin main
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Signed commits.** Buckets require them by default: every commit a push introduces is checked, so set up signing before
|
|
65
|
+
the first push (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign` is the short route).
|
|
66
|
+
|
|
67
|
+
**LFS.** Downloads at any size and uploads up to 5 GB per file need nothing installed: objects come from the same host
|
|
68
|
+
under the same credential. Above 5 GB an upload needs gb's transfer agent, which also makes both directions parallel and
|
|
69
|
+
resumable. Once per repo:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
gb lfs install [--remote <name>]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Packages.** Each bucket is one Python index. In the consuming project's `pyproject.toml`:
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
[[tool.uv.index]]
|
|
79
|
+
name = "my-bucket-index"
|
|
80
|
+
url = "https://gb@my-bucket.git.example.com/packages/pypi/"
|
|
81
|
+
explicit = true
|
|
82
|
+
|
|
83
|
+
[tool.uv.sources]
|
|
84
|
+
my-package = { index = "my-bucket-index" }
|
|
85
|
+
|
|
86
|
+
[tool.uv]
|
|
87
|
+
keyring-provider = "subprocess"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then just `uv sync` as normal. The `gb@` is **required**: uv only does keyring discovery when the index URL carries a
|
|
91
|
+
username.
|
|
92
|
+
|
|
93
|
+
Publishing needs one more line on the index, in the publishing project rather than the consuming one, naming the repo
|
|
94
|
+
the package belongs to:
|
|
95
|
+
|
|
96
|
+
```toml
|
|
97
|
+
[[tool.uv.index]]
|
|
98
|
+
name = "my-bucket-index"
|
|
99
|
+
url = "https://gb@my-bucket.git.example.com/packages/pypi/"
|
|
100
|
+
publish-url = "https://gb@my-bucket.git.example.com/packages/pypi/upload/tools/cli/my-project/"
|
|
101
|
+
explicit = true
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Then `uv build && uv publish --index my-bucket-index`. Push rights are publish rights: the repo in the upload URL is the
|
|
105
|
+
one your token must reach with `rw`.
|
|
106
|
+
|
|
107
|
+
**CI.** A runner has no login session: it assumes the team's token role via OIDC and sets
|
|
108
|
+
`UV_KEYRING_PROVIDER=subprocess` and `GB_KEYRING_HOSTS="*.git.example.com"`. `GB_KEYRING_HOSTS` is CI only, never needed
|
|
109
|
+
on a user machine.
|
|
110
|
+
|
|
111
|
+
## Repo lifecycle
|
|
112
|
+
|
|
113
|
+
Every subcommand takes `<bucket>/<repo>`, plus `-H/--host <domain>` when more than one deployment is registered.
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
gb repo list
|
|
117
|
+
gb repo info my-bucket/tools/cli/my-project # head, refs, size, last push; -v lists every ref
|
|
118
|
+
gb repo protect my-bucket/tools/cli/my-project main # blocks delete while the ref exists
|
|
119
|
+
gb repo unprotect my-bucket/tools/cli/my-project main
|
|
120
|
+
gb repo delete my-bucket/tools/cli/my-project --yes # without --yes it only prints what would go
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Delete takes the repo's LFS objects, locks, bundles, packages and rendered web tree with it. The undo path is S3
|
|
124
|
+
versioning on the operator's side, not a `gb` command. There is no rename.
|
|
125
|
+
|
|
126
|
+
## Where the secrets live
|
|
127
|
+
|
|
128
|
+
The refresh token (one year, fixed from sign-in) goes in your OS keyring, or in `~/.config/gb/hosts.yml` at 0600 when no
|
|
129
|
+
keyring works: gb says which, once. On a headless Linux box, `pass` + gpg-agent is the supported keyring. The hourly ID
|
|
130
|
+
and session tokens are just caches under `~/.local/state/gb/<domain>/`.
|
|
131
|
+
|
|
132
|
+
## Troubleshooting
|
|
133
|
+
|
|
134
|
+
- git prompts for a username and password: the host isn't registered. `gb auth status`, then `gb login`.
|
|
135
|
+
- `the session for <domain> has expired`: the refresh token is past its year, or was revoked. Log in again.
|
|
136
|
+
- A push is refused but a fetch works: your grant on that prefix is read-only.
|
|
137
|
+
- A package index won't authenticate: `GB_KEYRING_DEBUG=1 uv sync` prints why the keyring backend declined.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# git-buckets
|
|
2
|
+
|
|
3
|
+
`gb` is the end-user CLI for a git-buckets deployment: git repos and Python package indexes over HTTPS, backed by S3.
|
|
4
|
+
Someone else runs the deployment. You log in once and then use stock git and uv.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
Prereqs:
|
|
9
|
+
- git (≥ 2.13, ≥ 2.40 for bundle-accelerated clones)
|
|
10
|
+
- [git-lfs](https://git-lfs.com/)
|
|
11
|
+
- Python ≥ 3.14
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
uv tool install git-buckets
|
|
15
|
+
# or: pipx install git-buckets
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## First login
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
gb login git.example.com
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Once per deployment, never per bucket. This signs you in through the browser, stores the session, writes the
|
|
25
|
+
credential-helper stanza for `https://*.git.example.com` plus `transfer.bundleURI = true` into your global gitconfig,
|
|
26
|
+
and records the deployment.
|
|
27
|
+
|
|
28
|
+
On a box with no browser, `--no-browser` prints a URL to open elsewhere and reads the code back at a masked prompt.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
gb auth status # deployments known, the user on each, where each secret lives
|
|
32
|
+
gb logout git.example.com # revoke the session token, erase the stored credentials
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Daily use
|
|
36
|
+
|
|
37
|
+
Remote URLs are `https://<bucket>.<deployment-domain>/<repo>`: the first hostname label is the bucket's public label,
|
|
38
|
+
the path is the repo name, as deep as you like. `git clone https://my-bucket.git.example.com/tools/cli/my-project`, then
|
|
39
|
+
fetch, pull and push are stock git as well.
|
|
40
|
+
|
|
41
|
+
**New repos are made by pushing.** There is no create command, simply push to a new path on an existing bucket and
|
|
42
|
+
you've created a new repo.
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
git init && git remote add origin https://my-bucket.git.example.com/tools/gui/my-new-project
|
|
46
|
+
git push -u origin main
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Signed commits.** Buckets require them by default: every commit a push introduces is checked, so set up signing before
|
|
50
|
+
the first push (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign` is the short route).
|
|
51
|
+
|
|
52
|
+
**LFS.** Downloads at any size and uploads up to 5 GB per file need nothing installed: objects come from the same host
|
|
53
|
+
under the same credential. Above 5 GB an upload needs gb's transfer agent, which also makes both directions parallel and
|
|
54
|
+
resumable. Once per repo:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
gb lfs install [--remote <name>]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Packages.** Each bucket is one Python index. In the consuming project's `pyproject.toml`:
|
|
61
|
+
|
|
62
|
+
```toml
|
|
63
|
+
[[tool.uv.index]]
|
|
64
|
+
name = "my-bucket-index"
|
|
65
|
+
url = "https://gb@my-bucket.git.example.com/packages/pypi/"
|
|
66
|
+
explicit = true
|
|
67
|
+
|
|
68
|
+
[tool.uv.sources]
|
|
69
|
+
my-package = { index = "my-bucket-index" }
|
|
70
|
+
|
|
71
|
+
[tool.uv]
|
|
72
|
+
keyring-provider = "subprocess"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then just `uv sync` as normal. The `gb@` is **required**: uv only does keyring discovery when the index URL carries a
|
|
76
|
+
username.
|
|
77
|
+
|
|
78
|
+
Publishing needs one more line on the index, in the publishing project rather than the consuming one, naming the repo
|
|
79
|
+
the package belongs to:
|
|
80
|
+
|
|
81
|
+
```toml
|
|
82
|
+
[[tool.uv.index]]
|
|
83
|
+
name = "my-bucket-index"
|
|
84
|
+
url = "https://gb@my-bucket.git.example.com/packages/pypi/"
|
|
85
|
+
publish-url = "https://gb@my-bucket.git.example.com/packages/pypi/upload/tools/cli/my-project/"
|
|
86
|
+
explicit = true
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Then `uv build && uv publish --index my-bucket-index`. Push rights are publish rights: the repo in the upload URL is the
|
|
90
|
+
one your token must reach with `rw`.
|
|
91
|
+
|
|
92
|
+
**CI.** A runner has no login session: it assumes the team's token role via OIDC and sets
|
|
93
|
+
`UV_KEYRING_PROVIDER=subprocess` and `GB_KEYRING_HOSTS="*.git.example.com"`. `GB_KEYRING_HOSTS` is CI only, never needed
|
|
94
|
+
on a user machine.
|
|
95
|
+
|
|
96
|
+
## Repo lifecycle
|
|
97
|
+
|
|
98
|
+
Every subcommand takes `<bucket>/<repo>`, plus `-H/--host <domain>` when more than one deployment is registered.
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
gb repo list
|
|
102
|
+
gb repo info my-bucket/tools/cli/my-project # head, refs, size, last push; -v lists every ref
|
|
103
|
+
gb repo protect my-bucket/tools/cli/my-project main # blocks delete while the ref exists
|
|
104
|
+
gb repo unprotect my-bucket/tools/cli/my-project main
|
|
105
|
+
gb repo delete my-bucket/tools/cli/my-project --yes # without --yes it only prints what would go
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Delete takes the repo's LFS objects, locks, bundles, packages and rendered web tree with it. The undo path is S3
|
|
109
|
+
versioning on the operator's side, not a `gb` command. There is no rename.
|
|
110
|
+
|
|
111
|
+
## Where the secrets live
|
|
112
|
+
|
|
113
|
+
The refresh token (one year, fixed from sign-in) goes in your OS keyring, or in `~/.config/gb/hosts.yml` at 0600 when no
|
|
114
|
+
keyring works: gb says which, once. On a headless Linux box, `pass` + gpg-agent is the supported keyring. The hourly ID
|
|
115
|
+
and session tokens are just caches under `~/.local/state/gb/<domain>/`.
|
|
116
|
+
|
|
117
|
+
## Troubleshooting
|
|
118
|
+
|
|
119
|
+
- git prompts for a username and password: the host isn't registered. `gb auth status`, then `gb login`.
|
|
120
|
+
- `the session for <domain> has expired`: the refresh token is past its year, or was revoked. Log in again.
|
|
121
|
+
- A push is refused but a fetch works: your grant on that prefix is read-only.
|
|
122
|
+
- A package index won't authenticate: `GB_KEYRING_DEBUG=1 uv sync` prints why the keyring backend declined.
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "git-buckets"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Git over S3: clone, fetch and push repositories backed by an S3 bucket."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
7
7
|
dependencies = [
|
|
8
|
-
"boto3",
|
|
9
|
-
"click",
|
|
10
|
-
"
|
|
11
|
-
"keyring",
|
|
12
|
-
"
|
|
8
|
+
"boto3>=1.43.77",
|
|
9
|
+
"click>=8.4.2",
|
|
10
|
+
"keyring>=25.7.0",
|
|
11
|
+
"keyring-pass>=0.9.3",
|
|
12
|
+
"pyyaml>=6.0.3",
|
|
13
13
|
]
|
|
14
14
|
|
|
15
15
|
[[project.authors]]
|
|
@@ -24,6 +24,8 @@ git-buckets = "git_buckets.keyring.backend"
|
|
|
24
24
|
|
|
25
25
|
[project.scripts]
|
|
26
26
|
gb = "git_buckets.cli:cli"
|
|
27
|
+
git-credential-gb = "git_buckets.credential:main"
|
|
28
|
+
git-lfs-gb = "git_buckets.lfs_agent:main"
|
|
27
29
|
keyring = "keyring.cli:main"
|
|
28
30
|
|
|
29
31
|
[build-system]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "git-buckets"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Git over S3: clone, fetch and push repositories backed by an S3 bucket."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = 'Full Duplex Media', email = 'contact@fullduplex.media' }
|
|
@@ -9,11 +9,11 @@ readme = 'README.md'
|
|
|
9
9
|
license = { text = 'Apache 2.0' }
|
|
10
10
|
requires-python = ">=3.14"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"boto3",
|
|
13
|
-
"click",
|
|
14
|
-
"
|
|
15
|
-
"keyring",
|
|
16
|
-
"
|
|
12
|
+
"boto3>=1.43.77",
|
|
13
|
+
"click>=8.4.2",
|
|
14
|
+
"keyring>=25.7.0",
|
|
15
|
+
"keyring-pass>=0.9.3",
|
|
16
|
+
"pyyaml>=6.0.3",
|
|
17
17
|
]
|
|
18
18
|
|
|
19
19
|
[project.entry-points."keyring.backends"]
|
|
@@ -21,6 +21,10 @@ git-buckets = "git_buckets.keyring.backend"
|
|
|
21
21
|
|
|
22
22
|
[project.scripts]
|
|
23
23
|
gb = "git_buckets.cli:cli"
|
|
24
|
+
# `helper = gb` in a gitconfig makes git exec `git credential-gb`, i.e. this script.
|
|
25
|
+
git-credential-gb = "git_buckets.credential:main"
|
|
26
|
+
# lfs.customtransfer.gb.path in a repo's config points at this; `gb lfs install` writes it.
|
|
27
|
+
git-lfs-gb = "git_buckets.lfs_agent:main"
|
|
24
28
|
keyring = "keyring.cli:main"
|
|
25
29
|
|
|
26
30
|
[build-system]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@click.group(context_settings=dict(help_option_names=['-h', '--help']))
|
|
5
|
+
@click.version_option(package_name='git-buckets')
|
|
6
|
+
def cli() -> None:
|
|
7
|
+
"""git-buckets: git repos and Python package indexes over HTTPS."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@cli.command(name='login')
|
|
11
|
+
@click.option('--no-browser', is_flag=True, help='Skip the loopback redirect; print a URL and read back a code.')
|
|
12
|
+
@click.option('-v', '--verbose', is_flag=True, help='Print the full summary, not just the confirmation line.')
|
|
13
|
+
@click.argument('domain')
|
|
14
|
+
def login_command(domain: str, no_browser: bool, verbose: bool) -> None:
|
|
15
|
+
"""Connect to a git-buckets deployment: gb login git.example.com.
|
|
16
|
+
|
|
17
|
+
Runs the browser sign-in once, stores the session, writes the credential-helper stanza into your global
|
|
18
|
+
gitconfig and records the deployment. One command per deployment, never per bucket.
|
|
19
|
+
"""
|
|
20
|
+
from . import login as login_module
|
|
21
|
+
from .termio import masked_prompt
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
result = login_module.login(domain, no_browser=no_browser, echo=click.echo, prompt=masked_prompt)
|
|
25
|
+
except Exception as x:
|
|
26
|
+
raise click.ClickException(str(x)) from None
|
|
27
|
+
login_module.report(result, click.echo, verbose=verbose)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@cli.command(name='logout')
|
|
31
|
+
@click.argument('domain')
|
|
32
|
+
def logout_command(domain: str) -> None:
|
|
33
|
+
"""Revoke the current session token and erase the stored credentials for a deployment."""
|
|
34
|
+
from . import login as login_module
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
outcome = login_module.logout(domain)
|
|
38
|
+
except Exception as x:
|
|
39
|
+
raise click.ClickException(str(x)) from None
|
|
40
|
+
click.echo(f'Revoked the session token for {domain}.' if outcome['revoked'] else 'No session token to revoke.')
|
|
41
|
+
if outcome['error']:
|
|
42
|
+
click.echo(f'Warning: the revocation call failed ({outcome["error"]}); the local session is erased anyway.')
|
|
43
|
+
click.echo(f'Erased the stored refresh token and caches for {domain}.')
|
|
44
|
+
key = outcome['helper_key']
|
|
45
|
+
click.echo('The gitconfig stanza is left in place; remove it with:')
|
|
46
|
+
click.echo(f' git config --global --unset-all {key}')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cli.group(name='auth')
|
|
50
|
+
def auth_group() -> None:
|
|
51
|
+
"""Inspect the deployments gb knows about."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@auth_group.command(name='status')
|
|
55
|
+
def auth_status_command() -> None:
|
|
56
|
+
"""The deployments known, the user on each, and where each secret is stored."""
|
|
57
|
+
from . import login as login_module
|
|
58
|
+
|
|
59
|
+
rows = login_module.status()
|
|
60
|
+
if not rows:
|
|
61
|
+
click.echo('No deployments. Run: gb login <deployment-domain>')
|
|
62
|
+
return
|
|
63
|
+
for row in rows:
|
|
64
|
+
click.echo(row['domain'])
|
|
65
|
+
click.echo(f' user {row["user"] or "(unknown)"}')
|
|
66
|
+
click.echo(f' storage {row["storage"]}')
|
|
67
|
+
click.echo(f' session {row["session"]}')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cli.group(name='lfs')
|
|
71
|
+
def lfs_group() -> None:
|
|
72
|
+
"""Git LFS: register gb's transfer agent for large files."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@lfs_group.command(name='install')
|
|
76
|
+
@click.option('--remote', default='origin', show_default=True, help='Remote to check the deployment against.')
|
|
77
|
+
def lfs_install_command(remote: str) -> None:
|
|
78
|
+
"""Register gb's LFS transfer agent in this repo.
|
|
79
|
+
|
|
80
|
+
Nothing is required for downloads or for uploads up to 5 GB: stock git-lfs handles both. Above 5 GB
|
|
81
|
+
git-lfs has no multipart upload of its own, so the agent is the only way to push such a file at all --
|
|
82
|
+
and once it is installed both directions go through it, in parallel parts that resume rather than one
|
|
83
|
+
stream that restarts. Repo-local by design: the agent only knows how to talk to a git-buckets deployment.
|
|
84
|
+
"""
|
|
85
|
+
from .lfs_agent import AgentError, install
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
settings = install(remote, echo=click.echo)
|
|
89
|
+
except AgentError as x:
|
|
90
|
+
raise click.ClickException(str(x)) from None
|
|
91
|
+
click.echo(f'Registered the gb LFS transfer agent in this repo ({settings["lfs.customtransfer.gb.path"]}).')
|
|
92
|
+
click.echo('Uploads and downloads now go through gb: parallel parts, resumable, and no 5 GB upload limit.')
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@cli.command(name='lfs-agent', hidden=True)
|
|
96
|
+
def lfs_agent_command() -> None:
|
|
97
|
+
"""The git-lfs custom transfer process, for a config that spells it out as `gb lfs-agent`."""
|
|
98
|
+
from .lfs_agent import main
|
|
99
|
+
|
|
100
|
+
raise SystemExit(main([]))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _human_bytes(count: int) -> str:
|
|
104
|
+
size = float(count)
|
|
105
|
+
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
|
|
106
|
+
if size < 1024 or unit == 'TB':
|
|
107
|
+
return f'{size:.0f} {unit}' if unit == 'B' else f'{size:.1f} {unit}'
|
|
108
|
+
size /= 1024
|
|
109
|
+
return f'{size:.1f} TB'
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_HOST_OPTION = click.option('-H', '--host', default=None, help='Deployment domain; the sole gb login if omitted.')
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@cli.group(name='repo')
|
|
116
|
+
def repo_group() -> None:
|
|
117
|
+
"""Manage repos: list them, inspect one, protect refs, delete."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@repo_group.command(name='list')
|
|
121
|
+
@_HOST_OPTION
|
|
122
|
+
def repo_list_command(host: str | None) -> None:
|
|
123
|
+
"""List the buckets and repos your token can see."""
|
|
124
|
+
from . import repo as repo_module
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
domain, entry = repo_module.select_host(host)
|
|
128
|
+
result = repo_module.list_repos(domain, entry)
|
|
129
|
+
except Exception as x:
|
|
130
|
+
raise click.ClickException(str(x)) from None
|
|
131
|
+
for bucket in result.get('buckets', []):
|
|
132
|
+
click.echo(bucket['alias'])
|
|
133
|
+
if not bucket['repos']:
|
|
134
|
+
click.echo(' (no repos)')
|
|
135
|
+
for name in bucket['repos']:
|
|
136
|
+
click.echo(f' {name}')
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@repo_group.command(name='info')
|
|
140
|
+
@_HOST_OPTION
|
|
141
|
+
@click.option('-v', '--verbose', is_flag=True, help='Also list every ref with its sha.')
|
|
142
|
+
@click.argument('name')
|
|
143
|
+
def repo_info_command(name: str, host: str | None, verbose: bool) -> None:
|
|
144
|
+
"""Show one repo: gb repo info <bucket>/<repo>."""
|
|
145
|
+
from . import repo as repo_module
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
bucket, path = repo_module.parse_name(name)
|
|
149
|
+
domain, entry = repo_module.select_host(host)
|
|
150
|
+
result = repo_module.info(domain, entry, bucket, path)
|
|
151
|
+
except Exception as x:
|
|
152
|
+
raise click.ClickException(str(x)) from None
|
|
153
|
+
click.echo(f'{result["bucket"]}/{result["repo"]}')
|
|
154
|
+
click.echo(f' alias {result["alias"]}')
|
|
155
|
+
click.echo(f' web url {result["web_url"]}')
|
|
156
|
+
click.echo(f' seq {result["seq"]}')
|
|
157
|
+
click.echo(f' head {result["head"]}')
|
|
158
|
+
click.echo(f' refs {len(result["refs"])}')
|
|
159
|
+
protected = result.get('protected') or []
|
|
160
|
+
click.echo(f' protected {", ".join(protected) if protected else "none"}')
|
|
161
|
+
click.echo(f' entries {result["entries"]}')
|
|
162
|
+
click.echo(f' bytes {_human_bytes(result["bytes"])}')
|
|
163
|
+
last_push = result.get('last_push')
|
|
164
|
+
if last_push:
|
|
165
|
+
click.echo(f' last push {last_push["by"]} at {last_push["at"]}')
|
|
166
|
+
else:
|
|
167
|
+
click.echo(' last push (none)')
|
|
168
|
+
if verbose:
|
|
169
|
+
click.echo(' refs:')
|
|
170
|
+
for refname, sha in result['refs'].items():
|
|
171
|
+
click.echo(f' {refname} {sha}')
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@repo_group.command(name='delete')
|
|
175
|
+
@_HOST_OPTION
|
|
176
|
+
@click.option('--yes', is_flag=True, help='Actually delete; without it, nothing is touched.')
|
|
177
|
+
@click.argument('name')
|
|
178
|
+
def repo_delete_command(name: str, host: str | None, yes: bool) -> None:
|
|
179
|
+
"""Delete a repo and everything it published: gb repo delete <bucket>/<repo>."""
|
|
180
|
+
from . import repo as repo_module
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
bucket, path = repo_module.parse_name(name)
|
|
184
|
+
domain, entry = repo_module.select_host(host)
|
|
185
|
+
except Exception as x:
|
|
186
|
+
raise click.ClickException(str(x)) from None
|
|
187
|
+
if not yes:
|
|
188
|
+
click.echo(f'This deletes {name}: its LFS objects, locks, bundles, packages and rendered web tree.')
|
|
189
|
+
click.echo('S3 versioning is the undo path; a re-push re-creates the repo from nothing.')
|
|
190
|
+
raise click.ClickException('refusing to delete without confirmation; re-run with --yes')
|
|
191
|
+
try:
|
|
192
|
+
result = repo_module.delete(domain, entry, bucket, path)
|
|
193
|
+
except Exception as x:
|
|
194
|
+
raise click.ClickException(str(x)) from None
|
|
195
|
+
deleted = result['deleted']
|
|
196
|
+
click.echo(
|
|
197
|
+
f'Deleted {name}: {deleted["objects"]} object(s), {deleted["web_objects"]} web object(s), '
|
|
198
|
+
f'{deleted["pins"]} pin(s).'
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _protect(operation: str, name: str, ref: str, host: str | None) -> None:
|
|
203
|
+
from . import repo as repo_module
|
|
204
|
+
|
|
205
|
+
call = repo_module.protect if operation == 'protect' else repo_module.unprotect
|
|
206
|
+
try:
|
|
207
|
+
bucket, path = repo_module.parse_name(name)
|
|
208
|
+
domain, entry = repo_module.select_host(host)
|
|
209
|
+
result = call(domain, entry, bucket, path, ref)
|
|
210
|
+
except Exception as x:
|
|
211
|
+
raise click.ClickException(str(x)) from None
|
|
212
|
+
protected = result.get('protected') or []
|
|
213
|
+
click.echo(f'protected: {", ".join(protected) if protected else "none"}')
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@repo_group.command(name='protect')
|
|
217
|
+
@_HOST_OPTION
|
|
218
|
+
@click.argument('name')
|
|
219
|
+
@click.argument('ref')
|
|
220
|
+
def repo_protect_command(name: str, ref: str, host: str | None) -> None:
|
|
221
|
+
"""Protect a ref against deletion: gb repo protect <bucket>/<repo> <ref>."""
|
|
222
|
+
_protect('protect', name, ref, host)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@repo_group.command(name='unprotect')
|
|
226
|
+
@_HOST_OPTION
|
|
227
|
+
@click.argument('name')
|
|
228
|
+
@click.argument('ref')
|
|
229
|
+
def repo_unprotect_command(name: str, ref: str, host: str | None) -> None:
|
|
230
|
+
"""Remove a ref's protection: gb repo unprotect <bucket>/<repo> <ref>."""
|
|
231
|
+
_protect('unprotect', name, ref, host)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@cli.command(name='credential', hidden=True)
|
|
235
|
+
@click.argument('operation')
|
|
236
|
+
def credential_command(operation: str) -> None:
|
|
237
|
+
"""The git credential helper, for a gitconfig that spells it out as `helper = "gb credential"`."""
|
|
238
|
+
from .credential import main
|
|
239
|
+
|
|
240
|
+
raise SystemExit(main([operation]))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The git credential helper: ``git credential-gb``, which is what ``helper = gb`` in a gitconfig runs.
|
|
2
|
+
|
|
3
|
+
Deliberately importless beyond the client's own small modules: this runs on every git operation against the
|
|
4
|
+
deployment. A warm session token is one cached-file read; a live MicroVM behind it is not something the cache
|
|
5
|
+
can promise, which is what ``wake.probe`` is for -- one cheap authenticated request, silent unless the bucket
|
|
6
|
+
turns out to be cold. ``GB_NO_WAKE_PROBE=1`` drops back to the old zero-network warm path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import hosts, session, wake
|
|
12
|
+
|
|
13
|
+
_USERNAME = 'gb'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse(stream) -> dict[str, str]: # noqa: ANN001
|
|
17
|
+
"""Read git's key=value block, terminated by a blank line or EOF."""
|
|
18
|
+
fields: dict[str, str] = {}
|
|
19
|
+
for raw in stream:
|
|
20
|
+
line = raw.rstrip('\n')
|
|
21
|
+
if not line:
|
|
22
|
+
break
|
|
23
|
+
key, _, value = line.partition('=')
|
|
24
|
+
if key:
|
|
25
|
+
fields[key] = value
|
|
26
|
+
return fields
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get(fields: dict[str, str]) -> str | None:
|
|
30
|
+
"""The credential block for a registered host, or None to decline.
|
|
31
|
+
|
|
32
|
+
Declining is the whole point of the host match: installing gb can never change what git sends to
|
|
33
|
+
somebody else's server.
|
|
34
|
+
"""
|
|
35
|
+
if fields.get('protocol', 'https') != 'https':
|
|
36
|
+
return None
|
|
37
|
+
resolved = hosts.resolve(fields.get('host', ''))
|
|
38
|
+
if resolved is None:
|
|
39
|
+
return None
|
|
40
|
+
domain, entry = resolved
|
|
41
|
+
token = session.token(domain, entry)
|
|
42
|
+
# The bucket's own host, not the deployment domain the token was minted against: that is what git is
|
|
43
|
+
# about to talk to, and the only host the launcher's cold path answers for.
|
|
44
|
+
wake.probe(fields.get('host', domain).partition(':')[0], token)
|
|
45
|
+
return f'username={_USERNAME}\npassword={token}\n'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def erase(fields: dict[str, str]) -> None:
|
|
49
|
+
"""Drop the cached session token for a registered host, so the next ``get`` mints fresh instead of
|
|
50
|
+
handing back the same rejected value.
|
|
51
|
+
|
|
52
|
+
git-lfs runs ``reject`` (our ``erase``) then ``fill`` (our ``get``) on a 401 and retries with whatever
|
|
53
|
+
``get`` returns, with no backoff. If ``get`` kept answering from a warm cache, that would loop forever on a
|
|
54
|
+
revoked or skewed token. This is why erase has to actually do something, unlike plain git's store/erase.
|
|
55
|
+
"""
|
|
56
|
+
if fields.get('protocol', 'https') != 'https':
|
|
57
|
+
return
|
|
58
|
+
resolved = hosts.resolve(fields.get('host', ''))
|
|
59
|
+
if resolved is None:
|
|
60
|
+
return
|
|
61
|
+
domain, _entry = resolved
|
|
62
|
+
session.forget_token(domain)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv: list[str] | None = None) -> int:
|
|
66
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
67
|
+
operation = argv[0] if argv else ''
|
|
68
|
+
try:
|
|
69
|
+
fields = parse(sys.stdin)
|
|
70
|
+
if operation == 'get':
|
|
71
|
+
answer = get(fields)
|
|
72
|
+
elif operation == 'erase':
|
|
73
|
+
erase(fields)
|
|
74
|
+
answer = None
|
|
75
|
+
else:
|
|
76
|
+
# store is a no-op: gb owns its own storage and git has nothing to tell it about the session.
|
|
77
|
+
answer = None
|
|
78
|
+
except Exception as x:
|
|
79
|
+
print(f'gb: {x}', file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
if answer:
|
|
82
|
+
sys.stdout.write(answer)
|
|
83
|
+
return 0
|