detached 5dbd27f

#24 feat(releases): GitHub-like releases and S3 blob storage

state
merged · 5dbd27f · fast-forward
branch
feat/releases → main
opened
bnema ·
updated

GitHub-like releases for ermite, plus a pluggable blob store (ADR 0034).

What it adds

  • Releases bound to a Git tag: title, Markdown notes, assets, draft/prerelease, latest (make_latest pin). Deleting the tag turns the release back into a draft.
  • Interfaces: GitHub-compatible REST under /-/github/api (also /api/v3 for go-github/GoReleaser), ConnectRPC ReleaseService, ermite release CLI, read-only web pages.
  • Stable links: //releases/latest/download/ and //releases/download//. Downloads are always relayed by ermite (Range/If-Range supported).
  • Scope release:write (in the cli preset), or a job token with contents: write. Branch-limited tokens are refused.
  • Blob store: disk ($ERMITE_DATA_DIR/blobs) or any S3-compatible provider via ERMITE_S3_* (common S3 subset, ermite never creates buckets). CI artifacts move onto it; legacy disk artifacts stay readable.
  • Storage schema 6: unrestricted tokens holding every schema-5 scope gain release:write.
  • Requires ermite-api v0.16.0 (SCOPE_RELEASE_WRITE = 13, release events 27-30).

Checks

  • go build, vet, hexcheck, go test -race ./... green.
  • e2e on the local dev stack: disk and MinIO backends, Firecracker runner with action-gh-release and upload-artifact (20 MiB), Playwright on the web pages. S3 contract suite against MinIO and a real Scaleway bucket.
  • Wrap-up review: 13 findings, all fixed with regression tests.

Deployment (after merge): Gordon secrets and S3 config for staging then prod, re-enable release.yml workflows.

CommitSubjectAuthorDateSignatureStatus
5dbd27ffix(lint): check ignored close and remove errors, simplify release conditionsbriceGPG Verified
b869944chore(deps): require ermite-api v0.16.0 and drop the local replacebriceGPG Verified
1428b20fix(releases): hide draft events and cursors, honour update targets, share range handlingbriceGPG Verified
97e2b33fix(blobs): reclaim crashed temp files, skip broken multipart entries, sync new directoriesbriceGPG Verified
8361f60fix(ci): bound concurrent artifact block uploads and reuse their buffersbriceGPG Verified
6abe390test(blobs): run the blob contract on MinIO, which lists unfinished uploads by keybriceGPG Verified
27d9aa4fix(githubapi): accept the api/v3 base that GoReleaser appends to the API URLbriceGPG Verified
d50b7cdfeat(app): wire releases and the blob store, S3 configuration, MinIO compose profile and docsbriceGPG Verified
0933a48feat(cli): ermite release commands and release:write in the cli presetbriceGPG Verified
f67dc6afeat(githubapi): GitHub-compatible REST API for releases and assetsbriceGPG Verified
eab40eefeat(web): releases tab, release pages and stable asset download linksbriceGPG Verified
bfabb0cfeat(rpc): ConnectRPC ReleaseService with streamed asset upload and downloadbriceGPG Verified
b8fe0a4fix(rpc): map the release:write scope to the contractbriceGPG Verified
f9e6941feat(ci): store CI artifacts on the blob store, keep legacy disk artifacts readablebriceGPG Verified
eb72c2ffeat(releases): release, asset and sweep use casesbriceGPG Verified
0c4e1e3feat(releases): git tag writer and release hooks on pushes and repository deletionbriceGPG Verified
8fd2b19feat(blobs): blob store port with disk and provider-agnostic S3 adaptersbriceGPG Verified
c57f9ecfeat(releases): release domain, kv storage, ports and schema 6 with release:writebriceGPG Verified

Checks 8

149 files · +17035 -809
8/8
M .hexcheck.yaml +12 -2
@@ -21,6 +21,11 @@ components:
2121 role: adapter
2222 paths:
2323 - internal/clientlimit/**
24+ httprange:
25+ # Byte-range selection: infrastructure shared by the HTTP adapters.
26+ role: adapter
27+ paths:
28+ - internal/httprange/**
2429 entrypoints:
2530 # cmd/ermite only calls app.Run.
2631 role: entrypoint
@@ -68,6 +73,11 @@ mocking:
6873 generatedMockNamePatterns:
6974 - '{{Interface}}'
7075 allow:
76+ # Secret-source exclusivity and file decoding are configuration validation,
77+ # not domain policy; config is intentionally a composition-root helper.
78+ - rule: suspicious-business-logic-in-adapter
79+ path: internal/config/config.go
80+ reason: validates explicit environment and secret-file sources at startup
7181 # clientlimit is a shared infrastructure library, not a sibling adapter: the
7282 # HTTP adapters that rate-limit clients import it by design.
7383 - rule: no-adapter-to-adapter-imports
@@ -75,7 +85,7 @@ allow:
7585 reason: rate-limits Git clients with internal/clientlimit
7686 - rule: no-adapter-to-adapter-imports
7787 path: internal/adapters/githubapi/**
78- reason: rate-limits the clients of the REST subset with internal/clientlimit
88+ reason: rate-limits the clients of the REST subset with internal/clientlimit and selects byte ranges with internal/httprange
7989 - rule: no-adapter-to-adapter-imports
8090 path: internal/adapters/oidchttp/**
8191 reason: rate-limits the clients of the OIDC token endpoint with internal/clientlimit
@@ -84,4 +94,4 @@ allow:
8494 reason: rate-limits API clients with internal/clientlimit
8595 - rule: no-adapter-to-adapter-imports
8696 path: internal/adapters/web/**
87- reason: rate-limits login attempts with internal/clientlimit
97+ reason: rate-limits login attempts with internal/clientlimit and selects byte ranges with internal/httprange
@@ -21,6 +21,11 @@ components:
21 role: adapter21 role: adapter
22 paths:22 paths:
23 - internal/clientlimit/**23 - internal/clientlimit/**
24+ httprange:
25+ # Byte-range selection: infrastructure shared by the HTTP adapters.
26+ role: adapter
27+ paths:
28+ - internal/httprange/**
24 entrypoints:29 entrypoints:
25 # cmd/ermite only calls app.Run.30 # cmd/ermite only calls app.Run.
26 role: entrypoint31 role: entrypoint
@@ -68,6 +73,11 @@ mocking:
68 generatedMockNamePatterns:73 generatedMockNamePatterns:
69 - '{{Interface}}'74 - '{{Interface}}'
70 allow:75 allow:
76+ # Secret-source exclusivity and file decoding are configuration validation,
77+ # not domain policy; config is intentionally a composition-root helper.
78+ - rule: suspicious-business-logic-in-adapter
79+ path: internal/config/config.go
80+ reason: validates explicit environment and secret-file sources at startup
71 # clientlimit is a shared infrastructure library, not a sibling adapter: the81 # clientlimit is a shared infrastructure library, not a sibling adapter: the
72 # HTTP adapters that rate-limit clients import it by design.82 # HTTP adapters that rate-limit clients import it by design.
73 - rule: no-adapter-to-adapter-imports83 - rule: no-adapter-to-adapter-imports
@@ -75,7 +85,7 @@ allow:
75 reason: rate-limits Git clients with internal/clientlimit85 reason: rate-limits Git clients with internal/clientlimit
76 - rule: no-adapter-to-adapter-imports86 - rule: no-adapter-to-adapter-imports
77 path: internal/adapters/githubapi/**87 path: internal/adapters/githubapi/**
78- reason: rate-limits the clients of the REST subset with internal/clientlimit88+ reason: rate-limits the clients of the REST subset with internal/clientlimit and selects byte ranges with internal/httprange
79 - rule: no-adapter-to-adapter-imports89 - rule: no-adapter-to-adapter-imports
80 path: internal/adapters/oidchttp/**90 path: internal/adapters/oidchttp/**
81 reason: rate-limits the clients of the OIDC token endpoint with internal/clientlimit91 reason: rate-limits the clients of the OIDC token endpoint with internal/clientlimit
@@ -84,4 +94,4 @@ allow:
84 reason: rate-limits API clients with internal/clientlimit94 reason: rate-limits API clients with internal/clientlimit
85 - rule: no-adapter-to-adapter-imports95 - rule: no-adapter-to-adapter-imports
86 path: internal/adapters/web/**96 path: internal/adapters/web/**
87- reason: rate-limits login attempts with internal/clientlimit97+ reason: rate-limits login attempts with internal/clientlimit and selects byte ranges with internal/httprange
M README.md +1 -0
@@ -18,6 +18,7 @@
1818 - **Isolated runner**: [ermite-runner](https://github.com/bnema/ermite-runner) runs each job in a Firecracker microVM when KVM is available, else in a container
1919 - **CLI-first**: repositories, merge requests, reviews and CI all work from the `ermite` CLI, for you and for AI agents
2020 - **Safe for agents**: an agent token can only push its own branches; you review and merge
21+- **[Releases](docs/releases.md)** with a GitHub-compatible API, CLI and web downloads; assets and CI artifacts on [disk or S3-compatible storage](docs/storage.md)
2122 - Git over HTTPS, web interface with live updates
2223
2324 Status: early development.
@@ -18,6 +18,7 @@
18 - **Isolated runner**: [ermite-runner](https://github.com/bnema/ermite-runner) runs each job in a Firecracker microVM when KVM is available, else in a container18 - **Isolated runner**: [ermite-runner](https://github.com/bnema/ermite-runner) runs each job in a Firecracker microVM when KVM is available, else in a container
19 - **CLI-first**: repositories, merge requests, reviews and CI all work from the `ermite` CLI, for you and for AI agents19 - **CLI-first**: repositories, merge requests, reviews and CI all work from the `ermite` CLI, for you and for AI agents
20 - **Safe for agents**: an agent token can only push its own branches; you review and merge20 - **Safe for agents**: an agent token can only push its own branches; you review and merge
21+- **[Releases](docs/releases.md)** with a GitHub-compatible API, CLI and web downloads; assets and CI artifacts on [disk or S3-compatible storage](docs/storage.md)
21 - Git over HTTPS, web interface with live updates22 - Git over HTTPS, web interface with live updates
22 23
23 Status: early development.24 Status: early development.
M compose.yaml +39 -0
@@ -24,13 +24,52 @@ services:
2424 ERMITE_ADMIN_TOKEN: dev-only-admin-token-do-not-use-0123456789
2525 ERMITE_LOG_FORMAT: console
2626 ERMITE_LOG_LEVEL: debug
27+ # Empty bucket keeps the disk backend, even with the s3 profile active.
28+ ERMITE_S3_BUCKET: ${ERMITE_S3_BUCKET:-}
29+ ERMITE_S3_ENDPOINT: ${ERMITE_S3_ENDPOINT:-}
30+ ERMITE_S3_REGION: ${ERMITE_S3_REGION:-}
31+ ERMITE_S3_ACCESS_KEY_ID: ${ERMITE_S3_ACCESS_KEY_ID:-}
32+ ERMITE_S3_SECRET_ACCESS_KEY: ${ERMITE_S3_SECRET_ACCESS_KEY:-}
33+ ERMITE_S3_FORCE_PATH_STYLE: ${ERMITE_S3_FORCE_PATH_STYLE:-0}
34+ ERMITE_S3_PREFIX: ${ERMITE_S3_PREFIX:-}
2735 volumes:
2836 - .:/src
2937 - go-mod:/go/pkg/mod
3038 - go-build:/root/.cache/go-build
3139 - data:/data
3240
41+ minio:
42+ image: minio/minio:RELEASE.2025-09-07T16-13-09Z
43+ profiles: [s3]
44+ command: server /data --console-address :9001
45+ environment:
46+ MINIO_ROOT_USER: minioadmin
47+ MINIO_ROOT_PASSWORD: minioadmin
48+ ports:
49+ - "127.0.0.1:9000:9000"
50+ - "127.0.0.1:9001:9001"
51+ volumes:
52+ - minio-data:/data
53+ healthcheck:
54+ test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
55+ interval: 2s
56+ timeout: 2s
57+ retries: 30
58+
59+ # Development provisioning only. The ermite process never creates buckets.
60+ minio-init:
61+ image: minio/mc:RELEASE.2025-08-13T08-35-41Z
62+ profiles: [s3]
63+ depends_on:
64+ minio:
65+ condition: service_healthy
66+ entrypoint: ["/bin/sh", "-c"]
67+ command:
68+ - "mc alias set dev http://minio:9000 minioadmin minioadmin && mc mb --ignore-existing dev/ermite && mc mb --ignore-existing dev/ermite-test"
69+ restart: "no"
70+
3371 volumes:
72+ minio-data:
3473 go-mod:
3574 go-build:
3675 data:
@@ -24,13 +24,52 @@ services:
24 ERMITE_ADMIN_TOKEN: dev-only-admin-token-do-not-use-012345678924 ERMITE_ADMIN_TOKEN: dev-only-admin-token-do-not-use-0123456789
25 ERMITE_LOG_FORMAT: console25 ERMITE_LOG_FORMAT: console
26 ERMITE_LOG_LEVEL: debug26 ERMITE_LOG_LEVEL: debug
27+ # Empty bucket keeps the disk backend, even with the s3 profile active.
28+ ERMITE_S3_BUCKET: ${ERMITE_S3_BUCKET:-}
29+ ERMITE_S3_ENDPOINT: ${ERMITE_S3_ENDPOINT:-}
30+ ERMITE_S3_REGION: ${ERMITE_S3_REGION:-}
31+ ERMITE_S3_ACCESS_KEY_ID: ${ERMITE_S3_ACCESS_KEY_ID:-}
32+ ERMITE_S3_SECRET_ACCESS_KEY: ${ERMITE_S3_SECRET_ACCESS_KEY:-}
33+ ERMITE_S3_FORCE_PATH_STYLE: ${ERMITE_S3_FORCE_PATH_STYLE:-0}
34+ ERMITE_S3_PREFIX: ${ERMITE_S3_PREFIX:-}
27 volumes:35 volumes:
28 - .:/src36 - .:/src
29 - go-mod:/go/pkg/mod37 - go-mod:/go/pkg/mod
30 - go-build:/root/.cache/go-build38 - go-build:/root/.cache/go-build
31 - data:/data39 - data:/data
32 40
41+ minio:
42+ image: minio/minio:RELEASE.2025-09-07T16-13-09Z
43+ profiles: [s3]
44+ command: server /data --console-address :9001
45+ environment:
46+ MINIO_ROOT_USER: minioadmin
47+ MINIO_ROOT_PASSWORD: minioadmin
48+ ports:
49+ - "127.0.0.1:9000:9000"
50+ - "127.0.0.1:9001:9001"
51+ volumes:
52+ - minio-data:/data
53+ healthcheck:
54+ test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
55+ interval: 2s
56+ timeout: 2s
57+ retries: 30
58+
59+ # Development provisioning only. The ermite process never creates buckets.
60+ minio-init:
61+ image: minio/mc:RELEASE.2025-08-13T08-35-41Z
62+ profiles: [s3]
63+ depends_on:
64+ minio:
65+ condition: service_healthy
66+ entrypoint: ["/bin/sh", "-c"]
67+ command:
68+ - "mc alias set dev http://minio:9000 minioadmin minioadmin && mc mb --ignore-existing dev/ermite && mc mb --ignore-existing dev/ermite-test"
69+ restart: "no"
70+
33 volumes:71 volumes:
72+ minio-data:
34 go-mod:73 go-mod:
35 go-build:74 go-build:
36 data:75 data:
M docs/ci-artifacts.md +2 -1
@@ -13,7 +13,8 @@ The run page lists them with download links.
1313
1414 - A job sees only the artifacts of its own run. A re-run that uploads again replaces the earlier one.
1515 - Never served anonymously, even for a public repository: `repo:read` to list and download, `ci:write` to delete.
16-- Stored under `$ERMITE_DATA_DIR/ci/artifacts`. Removed when they expire, or with their run or repository.
16+- Contents use the shared [blob store](storage.md): `$ERMITE_DATA_DIR/blobs/objects/ci/` by default, or the configured S3 bucket/prefix. Metadata stays in kv. Removed when they expire, or with their run or repository.
17+- Legacy artifacts under `$ERMITE_DATA_DIR/ci/artifacts` remain readable until expiry, with no copy or migration. Keep that tree until its last artifact expires. Changing backends does not move blob-backed artifacts; keep the backend stable while they are live.
1718 - An upload not finished when its job ends is dropped.
1819
1920 ## Limits
@@ -13,7 +13,8 @@ The run page lists them with download links.
13 13
14 - A job sees only the artifacts of its own run. A re-run that uploads again replaces the earlier one.14 - A job sees only the artifacts of its own run. A re-run that uploads again replaces the earlier one.
15 - Never served anonymously, even for a public repository: `repo:read` to list and download, `ci:write` to delete.15 - Never served anonymously, even for a public repository: `repo:read` to list and download, `ci:write` to delete.
16-- Stored under `$ERMITE_DATA_DIR/ci/artifacts`. Removed when they expire, or with their run or repository.16+- Contents use the shared [blob store](storage.md): `$ERMITE_DATA_DIR/blobs/objects/ci/` by default, or the configured S3 bucket/prefix. Metadata stays in kv. Removed when they expire, or with their run or repository.
17+- Legacy artifacts under `$ERMITE_DATA_DIR/ci/artifacts` remain readable until expiry, with no copy or migration. Keep that tree until its last artifact expires. Changing backends does not move blob-backed artifacts; keep the backend stable while they are live.
17 - An upload not finished when its job ends is dropped.18 - An upload not finished when its job ends is dropped.
18 19
19 ## Limits20 ## Limits
M docs/cli.md +45 -0
@@ -96,6 +96,51 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
9696
9797 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).
9898
99+## Releases
100+
101+Releases need a server advertising the `releases` feature. Published releases follow
102+repository visibility; drafts are visible only to release writers. Writes need
103+`release:write` (included in the `cli` token preset); branch-limited tokens cannot
104+write releases. On an older server the preset drops this scope with a note, while
105+an explicit `--scope release:write` fails before creating a token.
106+
107+```sh
108+ermite release create <repo> <tag> [--title T] [--notes TEXT | --notes-file F] [--target REF] [--draft] [--prerelease] [--latest] [--asset FILE[#LABEL]]… [--json]
109+ermite release list <repo> [--limit N] [--json]
110+ermite release view <repo> <tag|id|latest> [--json | --notes]
111+ermite release edit <repo> <tag|id> [--title T] [--notes TEXT | --notes-file F] [--tag NEW] [--target REF] [--draft | --publish] [--prerelease | --no-prerelease] [--latest]
112+ermite release delete <repo> <tag|id> [--yes]
113+ermite release upload <repo> <tag|id> FILE[#LABEL]… [--clobber] [--content-type T]
114+ermite release download <repo> <tag|id|latest> [--pattern GLOB]… [--name N]… [--dir D]
115+```
116+
117+All-digit selectors are release ids; other selectors are tags. `latest` selects
118+the latest release for view/download. `--latest` pins a release as latest.
119+Non-draft releases require an existing tag, or `--target` to create one; an
120+existing tag is never moved. `--notes-file -` reads Markdown from standard input.
121+Edit changes only supplied fields: `--title=''` and `--notes=''` clear them;
122+`--publish` and `--no-prerelease` clear the corresponding flags.
123+
124+Create uploads all `--asset` files, then prints the release web URL and id.
125+Each asset's name is the file basename; an optional `#LABEL` sets its display
126+label (quote it). Upload accepts multiple file arguments, including shell-expanded
127+globs. For create, repeat `--asset` for each file. Content type is detected from
128+the extension, or sniffed from the first bytes; upload's `--content-type` overrides
129+it. A duplicate name fails with a hint to use `--clobber`. A failed upload after
130+create leaves the release in place and reports its id.
131+
132+List defaults to 30 entries and follows server pagination up to `--limit`.
133+View shows fields, notes and an asset table; `--notes` prints Markdown alone.
134+Create, list and view support stable JSON (fields are listed in their help).
135+Delete removes the release and assets, not its Git tag; it asks for confirmation
136+only on a terminal unless `--yes` is given.
137+
138+Download defaults to all assets, into `--dir` (default `.`). Repeat `--name` or
139+quote and repeat `--pattern` to select a union of names and globs. Files are never
140+overwritten. One interrupted stream is resumed automatically from the bytes
141+already received; size and SHA-256 digest are verified. Failed or mismatching
142+downloads are removed. Resume is within the command, not across invocations.
143+
99144 ## Output
100145
101146 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
@@ -96,6 +96,51 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
96 96
97 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).97 Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).
98 98
99+## Releases
100+
101+Releases need a server advertising the `releases` feature. Published releases follow
102+repository visibility; drafts are visible only to release writers. Writes need
103+`release:write` (included in the `cli` token preset); branch-limited tokens cannot
104+write releases. On an older server the preset drops this scope with a note, while
105+an explicit `--scope release:write` fails before creating a token.
106+
107+```sh
108+ermite release create <repo> <tag> [--title T] [--notes TEXT | --notes-file F] [--target REF] [--draft] [--prerelease] [--latest] [--asset FILE[#LABEL]]… [--json]
109+ermite release list <repo> [--limit N] [--json]
110+ermite release view <repo> <tag|id|latest> [--json | --notes]
111+ermite release edit <repo> <tag|id> [--title T] [--notes TEXT | --notes-file F] [--tag NEW] [--target REF] [--draft | --publish] [--prerelease | --no-prerelease] [--latest]
112+ermite release delete <repo> <tag|id> [--yes]
113+ermite release upload <repo> <tag|id> FILE[#LABEL]… [--clobber] [--content-type T]
114+ermite release download <repo> <tag|id|latest> [--pattern GLOB]… [--name N]… [--dir D]
115+```
116+
117+All-digit selectors are release ids; other selectors are tags. `latest` selects
118+the latest release for view/download. `--latest` pins a release as latest.
119+Non-draft releases require an existing tag, or `--target` to create one; an
120+existing tag is never moved. `--notes-file -` reads Markdown from standard input.
121+Edit changes only supplied fields: `--title=''` and `--notes=''` clear them;
122+`--publish` and `--no-prerelease` clear the corresponding flags.
123+
124+Create uploads all `--asset` files, then prints the release web URL and id.
125+Each asset's name is the file basename; an optional `#LABEL` sets its display
126+label (quote it). Upload accepts multiple file arguments, including shell-expanded
127+globs. For create, repeat `--asset` for each file. Content type is detected from
128+the extension, or sniffed from the first bytes; upload's `--content-type` overrides
129+it. A duplicate name fails with a hint to use `--clobber`. A failed upload after
130+create leaves the release in place and reports its id.
131+
132+List defaults to 30 entries and follows server pagination up to `--limit`.
133+View shows fields, notes and an asset table; `--notes` prints Markdown alone.
134+Create, list and view support stable JSON (fields are listed in their help).
135+Delete removes the release and assets, not its Git tag; it asks for confirmation
136+only on a terminal unless `--yes` is given.
137+
138+Download defaults to all assets, into `--dir` (default `.`). Repeat `--name` or
139+quote and repeat `--pattern` to select a union of names and globs. Files are never
140+overwritten. One interrupted stream is resumed automatically from the bytes
141+already received; size and SHA-256 digest are verified. Failed or mismatching
142+downloads are removed. Resume is within the command, not across invocations.
143+
99 ## Output144 ## Output
100 145
101 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.146 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
M docs/configuration.md +29 -1
@@ -60,10 +60,38 @@ head -c 32 /dev/urandom | base64
6060 - The server refuses to start with a different key than the one the secrets were sealed with.
6161 - Losing the key loses every secret. A backup without the key reveals none.
6262
63+## Blob storage and releases
64+
65+| Variable | Default | Notes |
66+|---|---|---|
67+| `ERMITE_S3_BUCKET` | empty | selects S3 when nonempty; otherwise disk under `$ERMITE_DATA_DIR/blobs` |
68+| `ERMITE_S3_ENDPOINT` | empty | http(s) endpoint; empty uses AWS region resolution; no credentials, path, query or fragment |
69+| `ERMITE_S3_REGION` | empty | required with a bucket, including custom endpoints |
70+| `ERMITE_S3_ACCESS_KEY_ID` | empty | explicit key, required with a bucket |
71+| `ERMITE_S3_SECRET_ACCESS_KEY` | empty | explicit secret, required with a bucket |
72+| `ERMITE_S3_FORCE_PATH_STYLE` | `0` | `0`/`1` or `false`/`true`; `1` for MinIO without wildcard DNS |
73+| `ERMITE_S3_PREFIX` | empty | ASCII key prefix, normalized to a trailing `/`; no empty, `.` or `..` segments |
74+| `ERMITE_RELEASE_ASSET_MAX` | `2GiB` | one release asset |
75+| `ERMITE_RELEASE_ASSETS_MAX` | `1000` | positive integer, per release |
76+| `ERMITE_RELEASE_QUOTA` | `20GiB` | per repository, including uploads in progress |
77+
78+Sizes accept bytes or `KiB`, `MiB`, `GiB`, `TiB`, from 1 MiB to 1 PiB.
79+See [storage](storage.md) for AWS, Scaleway, R2, MinIO, permissions and backups,
80+and [releases](releases.md) for publishing.
81+
82+### Secret files
83+
84+`ERMITE_ADMIN_TOKEN`, `ERMITE_SECRETS_KEY`, `ERMITE_S3_ACCESS_KEY_ID` and
85+`ERMITE_S3_SECRET_ACCESS_KEY` each support a `_FILE` variant, for example
86+`ERMITE_ADMIN_TOKEN_FILE=/run/secrets/admin-token`. Set either the direct value
87+or its file, never both. Files are read once at startup; trailing CR/LF is
88+removed. Empty or unreadable files fail validation. Secret contents and file
89+paths are not included in file-resolution errors. No AWS credential chain is used.
90+
6391 ## Runtime files
6492
6593 The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run/`. The socket path must stay under about 100 bytes, so keep `ERMITE_DATA_DIR` short.
6694
6795 ## Storage schema
6896
69-This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
97+This build uses storage schema 6. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
@@ -60,10 +60,38 @@ head -c 32 /dev/urandom | base64
60 - The server refuses to start with a different key than the one the secrets were sealed with.60 - The server refuses to start with a different key than the one the secrets were sealed with.
61 - Losing the key loses every secret. A backup without the key reveals none.61 - Losing the key loses every secret. A backup without the key reveals none.
62 62
63+## Blob storage and releases
64+
65+| Variable | Default | Notes |
66+|---|---|---|
67+| `ERMITE_S3_BUCKET` | empty | selects S3 when nonempty; otherwise disk under `$ERMITE_DATA_DIR/blobs` |
68+| `ERMITE_S3_ENDPOINT` | empty | http(s) endpoint; empty uses AWS region resolution; no credentials, path, query or fragment |
69+| `ERMITE_S3_REGION` | empty | required with a bucket, including custom endpoints |
70+| `ERMITE_S3_ACCESS_KEY_ID` | empty | explicit key, required with a bucket |
71+| `ERMITE_S3_SECRET_ACCESS_KEY` | empty | explicit secret, required with a bucket |
72+| `ERMITE_S3_FORCE_PATH_STYLE` | `0` | `0`/`1` or `false`/`true`; `1` for MinIO without wildcard DNS |
73+| `ERMITE_S3_PREFIX` | empty | ASCII key prefix, normalized to a trailing `/`; no empty, `.` or `..` segments |
74+| `ERMITE_RELEASE_ASSET_MAX` | `2GiB` | one release asset |
75+| `ERMITE_RELEASE_ASSETS_MAX` | `1000` | positive integer, per release |
76+| `ERMITE_RELEASE_QUOTA` | `20GiB` | per repository, including uploads in progress |
77+
78+Sizes accept bytes or `KiB`, `MiB`, `GiB`, `TiB`, from 1 MiB to 1 PiB.
79+See [storage](storage.md) for AWS, Scaleway, R2, MinIO, permissions and backups,
80+and [releases](releases.md) for publishing.
81+
82+### Secret files
83+
84+`ERMITE_ADMIN_TOKEN`, `ERMITE_SECRETS_KEY`, `ERMITE_S3_ACCESS_KEY_ID` and
85+`ERMITE_S3_SECRET_ACCESS_KEY` each support a `_FILE` variant, for example
86+`ERMITE_ADMIN_TOKEN_FILE=/run/secrets/admin-token`. Set either the direct value
87+or its file, never both. Files are read once at startup; trailing CR/LF is
88+removed. Empty or unreadable files fail validation. Secret contents and file
89+paths are not included in file-resolution errors. No AWS credential chain is used.
90+
63 ## Runtime files91 ## Runtime files
64 92
65 The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run/`. The socket path must stay under about 100 bytes, so keep `ERMITE_DATA_DIR` short.93 The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run/`. The socket path must stay under about 100 bytes, so keep `ERMITE_DATA_DIR` short.
66 94
67 ## Storage schema95 ## Storage schema
68 96
69-This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.97+This build uses storage schema 6. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
A docs/releases.md +99 -0
@@ -0,0 +1,99 @@
1+# Releases
2+
3+A release binds a Git tag to a title, Markdown notes and named assets. There is
4+one release per tag. Published releases follow repository visibility; drafts
5+are visible only to writers. The web Releases tab is read-only.
6+
7+```sh
8+ermite release create app v1.0.0 --target main --title 'Version 1' --notes-file notes.md --asset dist/app
9+ermite release list app --json
10+ermite release view app latest
11+ermite release edit app v1.0.0 --notes 'Updated notes'
12+ermite release upload app v1.0.0 dist/app --clobber
13+ermite release download app latest --pattern 'app*' --dir downloads
14+ermite release delete app v1.0.0 --yes
15+```
16+
17+Create/edit accept `--draft`, `--prerelease`, `--latest`, `--target` and notes.
18+Use `edit --publish` to publish a draft, `--no-prerelease` to clear that flag.
19+An existing tag never moves; `--target` creates a missing tag. A draft can name a
20+missing tag. Deleting a tag turns its release into a draft and keeps its assets;
21+recreating the tag does not republish it. Deleting a release does not delete the tag.
22+
23+`latest` is the newest published non-prerelease by creation time and id, unless
24+pinned with `--latest` / REST `make_latest: "true"`. Drafting or deleting the
25+pinned release clears the pin. Asset names are unique within a release, 1–255
26+characters, without slashes, backslashes or control characters. CLI `--clobber`
27+replaces a same-named asset; REST publishers delete it then upload again.
28+
29+Writes require `release:write` (included in the `cli` preset), or a CI job token
30+with `permissions: contents: write` for that repository. Branch-limited tokens
31+cannot write releases. Read access to a private repository needs `repo:read`;
32+unreadable releases, including drafts, answer not found. Older servers without
33+the `releases` feature are refused by the CLI.
34+
35+## Limits and storage
36+
37+| Variable | Default |
38+|---|---|
39+| `ERMITE_RELEASE_ASSET_MAX` | `2GiB` per asset |
40+| `ERMITE_RELEASE_ASSETS_MAX` | `1000` per release |
41+| `ERMITE_RELEASE_QUOTA` | `20GiB` per repository |
42+
43+Quota includes uploads in progress. Exceeding limits gives REST 422 or RPC
44+`resource_exhausted`. Assets use the shared [blob store](storage.md), disk by
45+default or S3. Downloads pass through ermite, never redirect to the bucket, and
46+support single byte ranges with 206, Content-Length and Content-Disposition.
47+Stable links:
48+
49+- `/<repo>/releases/download/<tag>/<asset>`
50+- `/<repo>/releases/latest/download/<asset>`
51+
52+## GitHub-compatible publishers
53+
54+REST is at `<public URL>/-/github/api`, uploads at
55+`<public URL>/-/github/api/uploads`. Use the external `ERMITE_PUBLIC_URL`, not
56+the S3 endpoint. Stored-token publishers need `release:write` and, for private
57+repository reads, `repo:read`. The owner component is `ERMITE_OWNER` (`owner` by
58+default).
59+
60+GoReleaser configuration (replace the host and repository):
61+
62+```yaml
63+version: 2
64+github_urls:
65+ api: https://git.example.org/-/github/api/
66+ upload: https://git.example.org/-/github/api/uploads/
67+ download: https://git.example.org/
68+release:
69+ github:
70+ owner: owner
71+ name: app
72+```
73+
74+Run `GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout.
75+
76+On an ermite runner, the job supplies `GITHUB_API_URL`, `GITHUB_SERVER_URL` and
77+`GITHUB_TOKEN`; no endpoint override is needed for action-gh-release:
78+
79+```yaml
80+name: release
81+on:
82+ push:
83+ tags: ['v*']
84+permissions:
85+ contents: write
86+jobs:
87+ publish:
88+ runs-on: ubuntu-latest
89+ steps:
90+ - uses: actions/checkout@v4
91+ - run: mkdir -p dist && printf 'hello\n' > dist/app.txt
92+ - uses: softprops/action-gh-release@v2
93+ with:
94+ files: dist/*
95+ body: Release built by CI
96+```
97+
98+`contents: read` is insufficient and gets 403. Automatic release-note generation,
99+discussions, reactions, immutability and attestations are not supported.
@@ -0,0 +1,99 @@
1+# Releases
2+
3+A release binds a Git tag to a title, Markdown notes and named assets. There is
4+one release per tag. Published releases follow repository visibility; drafts
5+are visible only to writers. The web Releases tab is read-only.
6+
7+```sh
8+ermite release create app v1.0.0 --target main --title 'Version 1' --notes-file notes.md --asset dist/app
9+ermite release list app --json
10+ermite release view app latest
11+ermite release edit app v1.0.0 --notes 'Updated notes'
12+ermite release upload app v1.0.0 dist/app --clobber
13+ermite release download app latest --pattern 'app*' --dir downloads
14+ermite release delete app v1.0.0 --yes
15+```
16+
17+Create/edit accept `--draft`, `--prerelease`, `--latest`, `--target` and notes.
18+Use `edit --publish` to publish a draft, `--no-prerelease` to clear that flag.
19+An existing tag never moves; `--target` creates a missing tag. A draft can name a
20+missing tag. Deleting a tag turns its release into a draft and keeps its assets;
21+recreating the tag does not republish it. Deleting a release does not delete the tag.
22+
23+`latest` is the newest published non-prerelease by creation time and id, unless
24+pinned with `--latest` / REST `make_latest: "true"`. Drafting or deleting the
25+pinned release clears the pin. Asset names are unique within a release, 1–255
26+characters, without slashes, backslashes or control characters. CLI `--clobber`
27+replaces a same-named asset; REST publishers delete it then upload again.
28+
29+Writes require `release:write` (included in the `cli` preset), or a CI job token
30+with `permissions: contents: write` for that repository. Branch-limited tokens
31+cannot write releases. Read access to a private repository needs `repo:read`;
32+unreadable releases, including drafts, answer not found. Older servers without
33+the `releases` feature are refused by the CLI.
34+
35+## Limits and storage
36+
37+| Variable | Default |
38+|---|---|
39+| `ERMITE_RELEASE_ASSET_MAX` | `2GiB` per asset |
40+| `ERMITE_RELEASE_ASSETS_MAX` | `1000` per release |
41+| `ERMITE_RELEASE_QUOTA` | `20GiB` per repository |
42+
43+Quota includes uploads in progress. Exceeding limits gives REST 422 or RPC
44+`resource_exhausted`. Assets use the shared [blob store](storage.md), disk by
45+default or S3. Downloads pass through ermite, never redirect to the bucket, and
46+support single byte ranges with 206, Content-Length and Content-Disposition.
47+Stable links:
48+
49+- `/<repo>/releases/download/<tag>/<asset>`
50+- `/<repo>/releases/latest/download/<asset>`
51+
52+## GitHub-compatible publishers
53+
54+REST is at `<public URL>/-/github/api`, uploads at
55+`<public URL>/-/github/api/uploads`. Use the external `ERMITE_PUBLIC_URL`, not
56+the S3 endpoint. Stored-token publishers need `release:write` and, for private
57+repository reads, `repo:read`. The owner component is `ERMITE_OWNER` (`owner` by
58+default).
59+
60+GoReleaser configuration (replace the host and repository):
61+
62+```yaml
63+version: 2
64+github_urls:
65+ api: https://git.example.org/-/github/api/
66+ upload: https://git.example.org/-/github/api/uploads/
67+ download: https://git.example.org/
68+release:
69+ github:
70+ owner: owner
71+ name: app
72+```
73+
74+Run `GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout.
75+
76+On an ermite runner, the job supplies `GITHUB_API_URL`, `GITHUB_SERVER_URL` and
77+`GITHUB_TOKEN`; no endpoint override is needed for action-gh-release:
78+
79+```yaml
80+name: release
81+on:
82+ push:
83+ tags: ['v*']
84+permissions:
85+ contents: write
86+jobs:
87+ publish:
88+ runs-on: ubuntu-latest
89+ steps:
90+ - uses: actions/checkout@v4
91+ - run: mkdir -p dist && printf 'hello\n' > dist/app.txt
92+ - uses: softprops/action-gh-release@v2
93+ with:
94+ files: dist/*
95+ body: Release built by CI
96+```
97+
98+`contents: read` is insufficient and gets 403. Automatic release-note generation,
99+discussions, reactions, immutability and attestations are not supported.
A docs/storage.md +92 -0
@@ -0,0 +1,92 @@
1+# Blob storage
2+
3+Release assets and CI artifact contents share one blob store. Metadata stays in
4+`$ERMITE_DATA_DIR/kv`; Git repositories and CI logs stay on local disk.
5+
6+With no `ERMITE_S3_BUCKET`, objects live under
7+`$ERMITE_DATA_DIR/blobs/objects/`. Directories are private (0700), files 0600.
8+Back up the entire data directory while the server is stopped. With S3, back up
9+both the data directory and the bucket prefix consistently.
10+
11+## S3-compatible storage
12+
13+Create a bucket yourself, then configure ermite. All examples use explicit keys:
14+
15+```sh
16+export ERMITE_S3_BUCKET=forge-assets
17+export ERMITE_S3_PREFIX=ermite/blobs
18+export ERMITE_S3_ACCESS_KEY_ID_FILE=/run/secrets/s3-id
19+export ERMITE_S3_SECRET_ACCESS_KEY_FILE=/run/secrets/s3-secret
20+```
21+
22+Select the endpoint and signing region for your provider:
23+
24+| Service | `ERMITE_S3_ENDPOINT` | `ERMITE_S3_REGION` | `ERMITE_S3_FORCE_PATH_STYLE` |
25+|---|---|---|---|
26+| AWS S3 | unset (SDK resolves from region) | bucket region, e.g. `eu-west-1` | `0` |
27+| Scaleway | `https://s3.fr-par.scw.cloud` | `fr-par` | `0` |
28+| Cloudflare R2 | `https://<account-id>.r2.cloudflarestorage.com` | `auto` | `0` |
29+| MinIO (compose network) | `http://minio:9000` | `us-east-1` | `1` |
30+
31+Any S3-compatible endpoint implementing the subset below can be used. There is
32+no ambient AWS credential chain: `AWS_*`, shared credential files and instance
33+roles are not consulted. Region and both keys are required with a bucket.
34+Each key supports `_FILE`; direct value and file are mutually exclusive.
35+
36+On startup, ermite probes the store (disk write probe; S3 `HeadBucket`, falling
37+back to `ListObjectsV2` with one key on 403). Failure stops startup and names
38+`ERMITE_S3_BUCKET`. Success logs `blob store ready`, never credential values.
39+The S3 probe checks reachability/read permission, not every write permission.
40+
41+Grant access to objects under the configured prefix: put, get/head, delete,
42+list, and create/upload/complete/abort/list multipart uploads. On AWS these are
43+`s3:PutObject`, `s3:GetObject`, `s3:DeleteObject`,
44+`s3:AbortMultipartUpload`, `s3:ListMultipartUploadParts` on the object resource,
45+and `s3:ListBucket`, `s3:ListBucketMultipartUploads` on the bucket resource.
46+Constrain listing to the prefix where supported. ermite never creates buckets or
47+calls bucket administration APIs (policy, ACL, tagging, lifecycle, versioning).
48+The client uses checksums only when required, not automatic CRC32 on every PUT.
49+
50+Downloads are relayed through ermite with repository authorization and Range
51+support: no redirects or presigned URLs. Multipart uploads use 8 MiB parts.
52+The daily sweep removes unreferenced blobs and unfinished uploads older than
53+24 hours. Release metadata with a missing blob is removed only after two sweeps.
54+MinIO lists unfinished uploads only for an exact object key, so the sweep does
55+not see them there; MinIO removes them itself (`stale_uploads_expiry`, 24 hours
56+by default).
57+
58+## Backend changes
59+
60+Legacy artifacts under `$ERMITE_DATA_DIR/ci/artifacts` remain readable until
61+expiry; there is no automatic copy. New artifacts and all release assets use the
62+currently selected backend. **Do not switch backends with live blob-backed data**:
63+records identify blobs, not their backend. Switching disk to S3, changing bucket
64+or prefix, or switching back makes earlier blob-backed contents unavailable.
65+Keep the selected backend stable, or arrange a complete offline copy yourself.
66+
67+## Development compose
68+
69+Disk (no MinIO services):
70+
71+```sh
72+ERMITE_S3_BUCKET= docker compose up
73+```
74+
75+MinIO provisioning must finish before the forge starts its probe:
76+
77+```sh
78+docker compose --profile s3 up -d minio
79+docker compose --profile s3 run --rm minio-init
80+ERMITE_S3_BUCKET=ermite ERMITE_S3_ENDPOINT=http://minio:9000 \
81+ERMITE_S3_REGION=us-east-1 ERMITE_S3_ACCESS_KEY_ID=minioadmin \
82+ERMITE_S3_SECRET_ACCESS_KEY=minioadmin ERMITE_S3_FORCE_PATH_STYLE=1 \
83+ERMITE_S3_PREFIX=dev/ docker compose --profile s3 up ermite
84+```
85+
86+Console: <http://localhost:9001>, development credentials `minioadmin` /
87+`minioadmin`. The init service creates `ermite` and `ermite-test`; only that
88+provisioning service has bucket creation duties. For Scaleway or another external
89+service, use the same environment variables with its existing bucket, endpoint,
90+region and keys, and `docker compose up ermite` without the profile. These are
91+development settings; never use the placeholder admin token or MinIO keys in
92+production.
@@ -0,0 +1,92 @@
1+# Blob storage
2+
3+Release assets and CI artifact contents share one blob store. Metadata stays in
4+`$ERMITE_DATA_DIR/kv`; Git repositories and CI logs stay on local disk.
5+
6+With no `ERMITE_S3_BUCKET`, objects live under
7+`$ERMITE_DATA_DIR/blobs/objects/`. Directories are private (0700), files 0600.
8+Back up the entire data directory while the server is stopped. With S3, back up
9+both the data directory and the bucket prefix consistently.
10+
11+## S3-compatible storage
12+
13+Create a bucket yourself, then configure ermite. All examples use explicit keys:
14+
15+```sh
16+export ERMITE_S3_BUCKET=forge-assets
17+export ERMITE_S3_PREFIX=ermite/blobs
18+export ERMITE_S3_ACCESS_KEY_ID_FILE=/run/secrets/s3-id
19+export ERMITE_S3_SECRET_ACCESS_KEY_FILE=/run/secrets/s3-secret
20+```
21+
22+Select the endpoint and signing region for your provider:
23+
24+| Service | `ERMITE_S3_ENDPOINT` | `ERMITE_S3_REGION` | `ERMITE_S3_FORCE_PATH_STYLE` |
25+|---|---|---|---|
26+| AWS S3 | unset (SDK resolves from region) | bucket region, e.g. `eu-west-1` | `0` |
27+| Scaleway | `https://s3.fr-par.scw.cloud` | `fr-par` | `0` |
28+| Cloudflare R2 | `https://<account-id>.r2.cloudflarestorage.com` | `auto` | `0` |
29+| MinIO (compose network) | `http://minio:9000` | `us-east-1` | `1` |
30+
31+Any S3-compatible endpoint implementing the subset below can be used. There is
32+no ambient AWS credential chain: `AWS_*`, shared credential files and instance
33+roles are not consulted. Region and both keys are required with a bucket.
34+Each key supports `_FILE`; direct value and file are mutually exclusive.
35+
36+On startup, ermite probes the store (disk write probe; S3 `HeadBucket`, falling
37+back to `ListObjectsV2` with one key on 403). Failure stops startup and names
38+`ERMITE_S3_BUCKET`. Success logs `blob store ready`, never credential values.
39+The S3 probe checks reachability/read permission, not every write permission.
40+
41+Grant access to objects under the configured prefix: put, get/head, delete,
42+list, and create/upload/complete/abort/list multipart uploads. On AWS these are
43+`s3:PutObject`, `s3:GetObject`, `s3:DeleteObject`,
44+`s3:AbortMultipartUpload`, `s3:ListMultipartUploadParts` on the object resource,
45+and `s3:ListBucket`, `s3:ListBucketMultipartUploads` on the bucket resource.
46+Constrain listing to the prefix where supported. ermite never creates buckets or
47+calls bucket administration APIs (policy, ACL, tagging, lifecycle, versioning).
48+The client uses checksums only when required, not automatic CRC32 on every PUT.
49+
50+Downloads are relayed through ermite with repository authorization and Range
51+support: no redirects or presigned URLs. Multipart uploads use 8 MiB parts.
52+The daily sweep removes unreferenced blobs and unfinished uploads older than
53+24 hours. Release metadata with a missing blob is removed only after two sweeps.
54+MinIO lists unfinished uploads only for an exact object key, so the sweep does
55+not see them there; MinIO removes them itself (`stale_uploads_expiry`, 24 hours
56+by default).
57+
58+## Backend changes
59+
60+Legacy artifacts under `$ERMITE_DATA_DIR/ci/artifacts` remain readable until
61+expiry; there is no automatic copy. New artifacts and all release assets use the
62+currently selected backend. **Do not switch backends with live blob-backed data**:
63+records identify blobs, not their backend. Switching disk to S3, changing bucket
64+or prefix, or switching back makes earlier blob-backed contents unavailable.
65+Keep the selected backend stable, or arrange a complete offline copy yourself.
66+
67+## Development compose
68+
69+Disk (no MinIO services):
70+
71+```sh
72+ERMITE_S3_BUCKET= docker compose up
73+```
74+
75+MinIO provisioning must finish before the forge starts its probe:
76+
77+```sh
78+docker compose --profile s3 up -d minio
79+docker compose --profile s3 run --rm minio-init
80+ERMITE_S3_BUCKET=ermite ERMITE_S3_ENDPOINT=http://minio:9000 \
81+ERMITE_S3_REGION=us-east-1 ERMITE_S3_ACCESS_KEY_ID=minioadmin \
82+ERMITE_S3_SECRET_ACCESS_KEY=minioadmin ERMITE_S3_FORCE_PATH_STYLE=1 \
83+ERMITE_S3_PREFIX=dev/ docker compose --profile s3 up ermite
84+```
85+
86+Console: <http://localhost:9001>, development credentials `minioadmin` /
87+`minioadmin`. The init service creates `ermite` and `ermite-test`; only that
88+provisioning service has bucket creation duties. For Scaleway or another external
89+service, use the same environment variables with its existing bucket, endpoint,
90+region and keys, and `docker compose up ermite` without the profile. These are
91+development settings; never use the placeholder admin token or MinIO keys in
92+production.
M docs/tokens.md +9 -0
@@ -15,6 +15,14 @@ The secret is printed once. Presets and `--scope` add up (`issue:write` lets a t
1515 | `ci` | `repo:read` |
1616 | `runner` | `runner:register`: a runner host. It can read the code and secrets of every repository, so give it only to runners you operate |
1717
18+## Release publishing
19+
20+`release:write` permits creating, editing and deleting releases and their assets;
21+it is included in the `cli` preset, not `agent` or `ci`. Private repository reads
22+also need `repo:read`. Branch-limited tokens cannot write releases, even with
23+that scope. CI jobs publish using their job token with `contents: write` under
24+`permissions`, not a stored-token scope. See [releases](releases.md).
25+
1826 ## Branch-limited tokens
1927
2028 For an AI agent that should propose changes, not publish them:
@@ -38,6 +46,7 @@ The token **cannot**:
3846
3947 - push any other ref (`main`, other branches, tags, notes). The whole push is refused and the message names the ref;
4048 - create, delete or configure a repository;
49+- create, edit or delete releases or upload assets;
4150 - open web sessions or get owner rights, even with every scope;
4251 - manage runners, even with `runner:register`;
4352 - get trusted CI runs (no secrets). See [CI trust](ci.md#trust).
@@ -15,6 +15,14 @@ The secret is printed once. Presets and `--scope` add up (`issue:write` lets a t
15 | `ci` | `repo:read` |15 | `ci` | `repo:read` |
16 | `runner` | `runner:register`: a runner host. It can read the code and secrets of every repository, so give it only to runners you operate |16 | `runner` | `runner:register`: a runner host. It can read the code and secrets of every repository, so give it only to runners you operate |
17 17
18+## Release publishing
19+
20+`release:write` permits creating, editing and deleting releases and their assets;
21+it is included in the `cli` preset, not `agent` or `ci`. Private repository reads
22+also need `repo:read`. Branch-limited tokens cannot write releases, even with
23+that scope. CI jobs publish using their job token with `contents: write` under
24+`permissions`, not a stored-token scope. See [releases](releases.md).
25+
18 ## Branch-limited tokens26 ## Branch-limited tokens
19 27
20 For an AI agent that should propose changes, not publish them:28 For an AI agent that should propose changes, not publish them:
@@ -38,6 +46,7 @@ The token **cannot**:
38 46
39 - push any other ref (`main`, other branches, tags, notes). The whole push is refused and the message names the ref;47 - push any other ref (`main`, other branches, tags, notes). The whole push is refused and the message names the ref;
40 - create, delete or configure a repository;48 - create, delete or configure a repository;
49+- create, edit or delete releases or upload assets;
41 - open web sessions or get owner rights, even with every scope;50 - open web sessions or get owner rights, even with every scope;
42 - manage runners, even with `runner:register`;51 - manage runners, even with `runner:register`;
43 - get trusted CI runs (no secrets). See [CI trust](ci.md#trust).52 - get trusted CI runs (no secrets). See [CI trust](ci.md#trust).
M gen/ermite/storage/ci_artifact.pb.go +15 -5 generated file: collapsed
A gen/ermite/storage/release.pb.go +463 -0 generated file: collapsed
M go.mod +16 -1
@@ -13,18 +13,22 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.15.0
16+ bnema.dev/ermite-api v0.16.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
2020 github.com/ProtonMail/go-crypto v1.4.1
2121 github.com/a-h/templ v0.3.1020
2222 github.com/alecthomas/chroma/v2 v2.27.0
23+ github.com/aws/aws-sdk-go-v2 v1.47.3
24+ github.com/aws/aws-sdk-go-v2/credentials v1.20.9
25+ github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
2326 github.com/bnema/kvconf v0.1.2
2427 github.com/bnema/zerowrap v1.4.1
2528 github.com/descope/virtualwebauthn v1.0.5
2629 github.com/go-webauthn/webauthn v0.18.2
2730 github.com/golang-jwt/jwt/v5 v5.3.1
31+ github.com/johannesboyne/gofakes3 v1.2.0
2832 github.com/spf13/cobra v1.10.2
2933 github.com/stretchr/testify v1.12.1
3034 github.com/yuin/goldmark v1.8.6
@@ -61,6 +65,15 @@ require (
6165 github.com/air-verse/air v1.67.4 // indirect
6266 github.com/andybalholm/brotli v1.2.0 // indirect
6367 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
68+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect
69+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect
70+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect
71+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
72+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
73+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
74+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
75+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
76+ github.com/aws/smithy-go v1.28.5 // indirect
6477 github.com/bep/godartsass/v2 v2.5.0 // indirect
6578 github.com/bep/golibsass v1.2.0 // indirect
6679 github.com/bits-and-blooms/bitset v1.24.5 // indirect
@@ -134,6 +147,7 @@ require (
134147 github.com/rs/cors v1.11.1 // indirect
135148 github.com/rs/zerolog v1.35.1 // indirect
136149 github.com/russross/blackfriday/v2 v2.1.0 // indirect
150+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
137151 github.com/segmentio/asm v1.2.1 // indirect
138152 github.com/segmentio/encoding v0.5.4 // indirect
139153 github.com/sirupsen/logrus v1.10.2 // indirect
@@ -159,6 +173,7 @@ require (
159173 go.opentelemetry.io/otel v1.46.0 // indirect
160174 go.opentelemetry.io/otel/metric v1.46.0 // indirect
161175 go.opentelemetry.io/otel/trace v1.46.0 // indirect
176+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
162177 go.uber.org/multierr v1.11.0 // indirect
163178 go.uber.org/zap v1.28.0 // indirect
164179 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
@@ -13,18 +13,22 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.15.016+ bnema.dev/ermite-api v0.16.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
20 github.com/ProtonMail/go-crypto v1.4.120 github.com/ProtonMail/go-crypto v1.4.1
21 github.com/a-h/templ v0.3.102021 github.com/a-h/templ v0.3.1020
22 github.com/alecthomas/chroma/v2 v2.27.022 github.com/alecthomas/chroma/v2 v2.27.0
23+ github.com/aws/aws-sdk-go-v2 v1.47.3
24+ github.com/aws/aws-sdk-go-v2/credentials v1.20.9
25+ github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
23 github.com/bnema/kvconf v0.1.226 github.com/bnema/kvconf v0.1.2
24 github.com/bnema/zerowrap v1.4.127 github.com/bnema/zerowrap v1.4.1
25 github.com/descope/virtualwebauthn v1.0.528 github.com/descope/virtualwebauthn v1.0.5
26 github.com/go-webauthn/webauthn v0.18.229 github.com/go-webauthn/webauthn v0.18.2
27 github.com/golang-jwt/jwt/v5 v5.3.130 github.com/golang-jwt/jwt/v5 v5.3.1
31+ github.com/johannesboyne/gofakes3 v1.2.0
28 github.com/spf13/cobra v1.10.232 github.com/spf13/cobra v1.10.2
29 github.com/stretchr/testify v1.12.133 github.com/stretchr/testify v1.12.1
30 github.com/yuin/goldmark v1.8.634 github.com/yuin/goldmark v1.8.6
@@ -61,6 +65,15 @@ require (
61 github.com/air-verse/air v1.67.4 // indirect65 github.com/air-verse/air v1.67.4 // indirect
62 github.com/andybalholm/brotli v1.2.0 // indirect66 github.com/andybalholm/brotli v1.2.0 // indirect
63 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect67 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
68+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect
69+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect
70+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect
71+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
72+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
73+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
74+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
75+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
76+ github.com/aws/smithy-go v1.28.5 // indirect
64 github.com/bep/godartsass/v2 v2.5.0 // indirect77 github.com/bep/godartsass/v2 v2.5.0 // indirect
65 github.com/bep/golibsass v1.2.0 // indirect78 github.com/bep/golibsass v1.2.0 // indirect
66 github.com/bits-and-blooms/bitset v1.24.5 // indirect79 github.com/bits-and-blooms/bitset v1.24.5 // indirect
@@ -134,6 +147,7 @@ require (
134 github.com/rs/cors v1.11.1 // indirect147 github.com/rs/cors v1.11.1 // indirect
135 github.com/rs/zerolog v1.35.1 // indirect148 github.com/rs/zerolog v1.35.1 // indirect
136 github.com/russross/blackfriday/v2 v2.1.0 // indirect149 github.com/russross/blackfriday/v2 v2.1.0 // indirect
150+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
137 github.com/segmentio/asm v1.2.1 // indirect151 github.com/segmentio/asm v1.2.1 // indirect
138 github.com/segmentio/encoding v0.5.4 // indirect152 github.com/segmentio/encoding v0.5.4 // indirect
139 github.com/sirupsen/logrus v1.10.2 // indirect153 github.com/sirupsen/logrus v1.10.2 // indirect
@@ -159,6 +173,7 @@ require (
159 go.opentelemetry.io/otel v1.46.0 // indirect173 go.opentelemetry.io/otel v1.46.0 // indirect
160 go.opentelemetry.io/otel/metric v1.46.0 // indirect174 go.opentelemetry.io/otel/metric v1.46.0 // indirect
161 go.opentelemetry.io/otel/trace v1.46.0 // indirect175 go.opentelemetry.io/otel/trace v1.46.0 // indirect
176+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
162 go.uber.org/multierr v1.11.0 // indirect177 go.uber.org/multierr v1.11.0 // indirect
163 go.uber.org/zap v1.28.0 // indirect178 go.uber.org/zap v1.28.0 // indirect
164 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect179 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
M go.sum +40 -2 generated file: collapsed
A internal/adapters/blobdisk/blobdisk.go +572 -0
@@ -0,0 +1,572 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobdisk stores objects under root/objects, temporary writes under
4+// root/tmp, and unfinished multipart uploads under root/multipart. Files are
5+// 0600 in 0700 directories; completed objects appear by synced atomic rename.
6+package blobdisk
7+
8+import (
9+ "context"
10+ "crypto/rand"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "io"
17+ "io/fs"
18+ "os"
19+ "path/filepath"
20+ "sort"
21+ "strconv"
22+ "strings"
23+ "time"
24+
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports"
27+ "github.com/bnema/zerowrap"
28+)
29+
30+// Store is the disk implementation of ports.BlobStore.
31+type Store struct{ root string }
32+
33+var _ ports.BlobStore = (*Store)(nil)
34+
35+// Dir returns the blob root under dataDir.
36+func Dir(dataDir string) string { return filepath.Join(dataDir, "blobs") }
37+
38+// New returns a disk store without performing I/O.
39+func New(root string) *Store { return &Store{root: root} }
40+func (s *Store) object(key string) string {
41+ return filepath.Join(s.root, "objects", filepath.FromSlash(key))
42+}
43+func valid(key string) error {
44+ if !ports.ValidBlobKey(key) {
45+ return domain.ErrInvalid
46+ }
47+ return nil
48+}
49+func translate(err error) error {
50+ if errors.Is(err, fs.ErrNotExist) {
51+ return domain.ErrNotFound
52+ }
53+ return err
54+}
55+
56+// tmpGrace is how long leftovers in root/tmp (and malformed multipart dirs)
57+// are kept before reclamation.
58+const tmpGrace = time.Hour
59+
60+// now is the clock used for reclamation; tests may replace it.
61+var now = time.Now
62+
63+// syncDir fsyncs a directory; tests may replace it to record calls.
64+var syncDir = func(path string) error {
65+ f, e := os.Open(path)
66+ if e != nil {
67+ return e
68+ }
69+ return errors.Join(f.Sync(), f.Close())
70+}
71+
72+// mkdirAllSync creates dir (under root) with 0700, fsyncing the parent of
73+// every directory it creates so the new entries are durable.
74+func mkdirAllSync(root, dir string) error {
75+ var missing []string
76+ for d := filepath.Clean(dir); d != filepath.Clean(root) && d != filepath.Dir(d); d = filepath.Dir(d) {
77+ if fi, e := os.Stat(d); e == nil {
78+ if !fi.IsDir() {
79+ return fmt.Errorf("%s: not a directory", d)
80+ }
81+ break
82+ } else if !errors.Is(e, fs.ErrNotExist) {
83+ return e
84+ }
85+ missing = append(missing, d)
86+ }
87+ for i := len(missing) - 1; i >= 0; i-- {
88+ if e := os.Mkdir(missing[i], 0700); e != nil && !errors.Is(e, fs.ErrExist) {
89+ return e
90+ }
91+ if e := syncDir(filepath.Dir(missing[i])); e != nil {
92+ return e
93+ }
94+ }
95+ return nil
96+}
97+
98+// reclaimTmp removes entries (files or directories) in root/tmp whose mtime
99+// is older than olderThan.
100+func (s *Store) reclaimTmp(ctx context.Context, olderThan time.Duration) error {
101+ dir := filepath.Join(s.root, "tmp")
102+ entries, e := os.ReadDir(dir)
103+ if errors.Is(e, fs.ErrNotExist) {
104+ return nil
105+ }
106+ if e != nil {
107+ return e
108+ }
109+ cutoff := now().Add(-olderThan)
110+ var errs []error
111+ for _, d := range entries {
112+ if e = ctx.Err(); e != nil {
113+ return e
114+ }
115+ fi, e := d.Info()
116+ if e != nil || !fi.ModTime().Before(cutoff) {
117+ continue
118+ }
119+ if e = os.RemoveAll(filepath.Join(dir, d.Name())); e != nil {
120+ errs = append(errs, e)
121+ continue
122+ }
123+ logEntry := zerowrap.FromCtx(ctx)
124+ logEntry.Info().Str("entry", d.Name()).Msg("blobdisk: reclaimed stale tmp entry")
125+ }
126+ return errors.Join(errs...)
127+}
128+
129+type ctxReader struct {
130+ ctx context.Context
131+ r io.Reader
132+}
133+
134+func (r ctxReader) Read(p []byte) (int, error) {
135+ if e := r.ctx.Err(); e != nil {
136+ return 0, e
137+ }
138+ return r.r.Read(p)
139+}
140+func (s *Store) init(ctx context.Context) error {
141+ if e := ctx.Err(); e != nil {
142+ return e
143+ }
144+ for _, d := range []string{"objects", "tmp", "multipart"} {
145+ if e := os.MkdirAll(filepath.Join(s.root, d), 0700); e != nil {
146+ return e
147+ }
148+ }
149+ return nil
150+}
151+
152+// Check verifies that the root is writable.
153+func (s *Store) Check(ctx context.Context) error {
154+ logEntry := zerowrap.FromCtx(ctx)
155+ logEntry.Debug().Msg("blobdisk: Check")
156+ if e := s.init(ctx); e != nil {
157+ return e
158+ }
159+ if e := s.reclaimTmp(ctx, tmpGrace); e != nil {
160+ logEntry.Warn().Err(e).Msg("blobdisk: tmp reclamation failed")
161+ }
162+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".probe-*")
163+ if e != nil {
164+ return e
165+ }
166+ return errors.Join(f.Close(), os.Remove(f.Name()))
167+}
168+
169+// Put atomically replaces key with exactly size bytes.
170+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
171+ logEntry := zerowrap.FromCtx(ctx)
172+ logEntry.Debug().Msg("blobdisk: Put")
173+ if e := valid(key); e != nil {
174+ return ports.BlobInfo{}, e
175+ }
176+ if size < 0 {
177+ return ports.BlobInfo{}, domain.ErrInvalid
178+ }
179+ if e := s.init(ctx); e != nil {
180+ return ports.BlobInfo{}, e
181+ }
182+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".tmp-*")
183+ if e != nil {
184+ return ports.BlobInfo{}, e
185+ }
186+ defer func() { _ = os.Remove(f.Name()) }()
187+ h := sha256.New()
188+ n, e := io.Copy(io.MultiWriter(f, h), ctxReader{ctx, r})
189+ if e == nil && n != size {
190+ e = domain.ErrInvalid
191+ }
192+ if e == nil {
193+ e = ctx.Err()
194+ }
195+ if e == nil {
196+ e = f.Sync()
197+ }
198+ e = errors.Join(e, f.Close())
199+ if e != nil {
200+ return ports.BlobInfo{}, e
201+ }
202+ path := s.object(key)
203+ if e = mkdirAllSync(filepath.Join(s.root, "objects"), filepath.Dir(path)); e == nil {
204+ e = os.Rename(f.Name(), path)
205+ }
206+ if e == nil {
207+ e = syncDir(filepath.Dir(path))
208+ }
209+ if e != nil {
210+ return ports.BlobInfo{}, e
211+ }
212+ info, e := s.Stat(ctx, key)
213+ info.Digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
214+ return info, e
215+}
216+
217+// Stat returns object metadata.
218+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
219+ if e := valid(key); e != nil {
220+ return ports.BlobInfo{}, e
221+ }
222+ if e := ctx.Err(); e != nil {
223+ return ports.BlobInfo{}, e
224+ }
225+ f, e := os.Stat(s.object(key))
226+ if e != nil {
227+ return ports.BlobInfo{}, translate(e)
228+ }
229+ if !f.Mode().IsRegular() {
230+ return ports.BlobInfo{}, domain.ErrNotFound
231+ }
232+ return ports.BlobInfo{Key: key, Size: f.Size(), ModTime: f.ModTime()}, nil
233+}
234+
235+type limitedCloser struct {
236+ io.Reader
237+ io.Closer
238+}
239+
240+// Open reads an object range.
241+//
242+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into a file seek; range rules belong to the port
243+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
244+ info, e := s.Stat(ctx, key)
245+ if e != nil {
246+ return nil, info, e
247+ }
248+ if offset < 0 {
249+ return nil, info, domain.ErrInvalid
250+ }
251+ if offset >= info.Size {
252+ return nil, info, domain.ErrOutOfRange
253+ }
254+ f, e := os.Open(s.object(key))
255+ if e != nil {
256+ return nil, info, translate(e)
257+ }
258+ if _, e = f.Seek(offset, io.SeekStart); e != nil {
259+ _ = f.Close()
260+ return nil, info, e
261+ }
262+ var r io.Reader = ctxReader{ctx, f}
263+ if length >= 0 {
264+ r = io.LimitReader(r, length)
265+ }
266+ return limitedCloser{r, f}, info, nil
267+}
268+
269+// Delete removes an object, treating absence as success.
270+func (s *Store) Delete(ctx context.Context, key string) error {
271+ logEntry := zerowrap.FromCtx(ctx)
272+ logEntry.Debug().Msg("blobdisk: Delete")
273+ if e := valid(key); e != nil {
274+ return e
275+ }
276+ if e := ctx.Err(); e != nil {
277+ return e
278+ }
279+ e := os.Remove(s.object(key))
280+ if errors.Is(e, fs.ErrNotExist) {
281+ return nil
282+ }
283+ return e
284+}
285+
286+// List visits matching objects in lexical order.
287+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
288+ logEntry := zerowrap.FromCtx(ctx)
289+ logEntry.Debug().Msg("blobdisk: List")
290+ root := filepath.Join(s.root, "objects")
291+ var infos []ports.BlobInfo
292+ e := filepath.WalkDir(root, func(path string, d fs.DirEntry, e error) error {
293+ if e != nil {
294+ return e
295+ }
296+ if e = ctx.Err(); e != nil {
297+ return e
298+ }
299+ if !d.Type().IsRegular() {
300+ return nil
301+ }
302+ key, e := filepath.Rel(root, path)
303+ if e != nil {
304+ return e
305+ }
306+ key = filepath.ToSlash(key)
307+ if !strings.HasPrefix(key, prefix) {
308+ return nil
309+ }
310+ i, e := d.Info()
311+ if e != nil {
312+ return e
313+ }
314+ infos = append(infos, ports.BlobInfo{Key: key, Size: i.Size(), ModTime: i.ModTime()})
315+ return nil
316+ })
317+ if errors.Is(e, fs.ErrNotExist) {
318+ return nil
319+ }
320+ if e != nil {
321+ return e
322+ }
323+ sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key })
324+ for _, i := range infos {
325+ if e = ctx.Err(); e != nil {
326+ return e
327+ }
328+ if e = fn(i); e != nil {
329+ return e
330+ }
331+ }
332+ return nil
333+}
334+
335+type upload struct {
336+ s *Store
337+ key, id string
338+ done bool
339+}
340+type metadata struct {
341+ Key string
342+ Started time.Time
343+}
344+
345+func (u *upload) dir() string { return filepath.Join(u.s.root, "multipart", u.id) }
346+
347+// NewMultipart starts an isolated staging upload.
348+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
349+ logEntry := zerowrap.FromCtx(ctx)
350+ logEntry.Debug().Msg("blobdisk: NewMultipart")
351+ if e := valid(key); e != nil {
352+ return nil, e
353+ }
354+ if e := s.init(ctx); e != nil {
355+ return nil, e
356+ }
357+ b := make([]byte, 16)
358+ if _, e := rand.Read(b); e != nil {
359+ return nil, e
360+ }
361+ u := &upload{s: s, key: key, id: hex.EncodeToString(b)}
362+ stage := filepath.Join(s.root, "tmp", "mp-"+u.id)
363+ if e := os.Mkdir(stage, 0700); e != nil {
364+ return nil, e
365+ }
366+ if e := writeMetadata(stage, metadata{key, time.Now().UTC()}); e != nil {
367+ _ = os.RemoveAll(stage)
368+ return nil, e
369+ }
370+ if e := os.Rename(stage, u.dir()); e != nil {
371+ _ = os.RemoveAll(stage)
372+ return nil, e
373+ }
374+ if e := syncDir(filepath.Join(s.root, "multipart")); e != nil {
375+ _ = os.RemoveAll(u.dir())
376+ return nil, e
377+ }
378+ return u, nil
379+}
380+
381+// writeMetadata durably writes dir/metadata via temp file and rename.
382+func writeMetadata(dir string, m metadata) error {
383+ data, e := json.Marshal(m)
384+ if e != nil {
385+ return e
386+ }
387+ f, e := os.CreateTemp(dir, ".meta-*")
388+ if e != nil {
389+ return e
390+ }
391+ _, e = f.Write(data)
392+ if e == nil {
393+ e = f.Sync()
394+ }
395+ if e = errors.Join(e, f.Close()); e == nil {
396+ e = os.Rename(f.Name(), filepath.Join(dir, "metadata"))
397+ }
398+ if e != nil {
399+ _ = os.Remove(f.Name())
400+ return e
401+ }
402+ return syncDir(dir)
403+}
404+func (u *upload) ID() string { return u.id }
405+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
406+ logEntry := zerowrap.FromCtx(ctx)
407+ logEntry.Debug().Msg("blobdisk: UploadPart")
408+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 {
409+ return domain.ErrInvalid
410+ }
411+ f, e := os.CreateTemp(u.dir(), ".tmp-*")
412+ if e != nil {
413+ return e
414+ }
415+ defer func() { _ = os.Remove(f.Name()) }()
416+ count, e := io.Copy(f, ctxReader{ctx, r})
417+ if e == nil && count != size {
418+ e = domain.ErrInvalid
419+ }
420+ if e == nil {
421+ e = f.Sync()
422+ }
423+ e = errors.Join(e, f.Close())
424+ if e != nil {
425+ return e
426+ }
427+ return os.Rename(f.Name(), filepath.Join(u.dir(), fmt.Sprintf("%05d", n)))
428+}
429+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
430+ logEntry := zerowrap.FromCtx(ctx)
431+ logEntry.Debug().Msg("blobdisk: Complete")
432+ if u.done {
433+ return ports.BlobInfo{}, domain.ErrInvalid
434+ }
435+ u.done = true
436+ defer func() { _ = os.RemoveAll(u.dir()) }()
437+ entries, e := os.ReadDir(u.dir())
438+ if e != nil {
439+ return ports.BlobInfo{}, e
440+ }
441+ var names []string
442+ for _, d := range entries {
443+ if n, e := strconv.Atoi(d.Name()); e == nil && n > 0 {
444+ names = append(names, d.Name())
445+ }
446+ }
447+ sort.Strings(names)
448+ if len(names) == 0 {
449+ return ports.BlobInfo{}, domain.ErrInvalid
450+ }
451+ var size int64
452+ var readers []io.Reader
453+ var files []*os.File
454+ defer func() {
455+ for _, f := range files {
456+ _ = f.Close()
457+ }
458+ }()
459+ for i, n := range names {
460+ f, e := os.Open(filepath.Join(u.dir(), n))
461+ if e != nil {
462+ return ports.BlobInfo{}, e
463+ }
464+ files = append(files, f)
465+ info, e := f.Stat()
466+ if e != nil {
467+ return ports.BlobInfo{}, e
468+ }
469+ if i < len(names)-1 && info.Size() < ports.BlobMinPartSize {
470+ return ports.BlobInfo{}, domain.ErrInvalid
471+ }
472+ size += info.Size()
473+ readers = append(readers, f)
474+ }
475+ info, e := u.s.Put(ctx, u.key, io.MultiReader(readers...), size)
476+ info.Digest = ""
477+ return info, e
478+}
479+func (u *upload) Abort(ctx context.Context) error {
480+ logEntry := zerowrap.FromCtx(ctx)
481+ logEntry.Debug().Msg("blobdisk: Abort")
482+ if u.done {
483+ return domain.ErrInvalid
484+ }
485+ u.done = true
486+ return u.s.AbortMultipart(ctx, u.key, u.id)
487+}
488+func validID(id string) bool {
489+ b, e := hex.DecodeString(id)
490+ return e == nil && len(b) == 16 && id == strings.ToLower(id)
491+}
492+
493+// AbortMultipart removes a listed upload by identity.
494+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
495+ logEntry := zerowrap.FromCtx(ctx)
496+ logEntry.Debug().Msg("blobdisk: AbortMultipart")
497+ if e := valid(key); e != nil {
498+ return e
499+ }
500+ if !validID(id) {
501+ return domain.ErrInvalid
502+ }
503+ if e := ctx.Err(); e != nil {
504+ return e
505+ }
506+ dir := filepath.Join(s.root, "multipart", id)
507+ b, e := os.ReadFile(filepath.Join(dir, "metadata"))
508+ if errors.Is(e, fs.ErrNotExist) {
509+ return nil
510+ }
511+ if e != nil {
512+ return e
513+ }
514+ var m metadata
515+ if e = json.Unmarshal(b, &m); e != nil {
516+ return e
517+ }
518+ if m.Key != key {
519+ return domain.ErrInvalid
520+ }
521+ return os.RemoveAll(dir)
522+}
523+
524+// ListMultipart visits unfinished uploads matching prefix.
525+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
526+ logEntry := zerowrap.FromCtx(ctx)
527+ logEntry.Debug().Msg("blobdisk: ListMultipart")
528+ entries, e := os.ReadDir(filepath.Join(s.root, "multipart"))
529+ if errors.Is(e, fs.ErrNotExist) {
530+ return nil
531+ }
532+ if e != nil {
533+ return e
534+ }
535+ if e = s.reclaimTmp(ctx, tmpGrace); e != nil {
536+ logEntry.Warn().Err(e).Msg("blobdisk: tmp reclamation failed")
537+ }
538+ for _, d := range entries {
539+ if e = ctx.Err(); e != nil {
540+ return e
541+ }
542+ if !d.IsDir() || !validID(d.Name()) {
543+ continue
544+ }
545+ dir := filepath.Join(s.root, "multipart", d.Name())
546+ var m metadata
547+ b, e := os.ReadFile(filepath.Join(dir, "metadata"))
548+ if e == nil {
549+ e = json.Unmarshal(b, &m)
550+ }
551+ if e == nil && !ports.ValidBlobKey(m.Key) {
552+ e = domain.ErrInvalid
553+ }
554+ if e != nil {
555+ logEntry.Warn().Err(e).Str("id", d.Name()).Msg("blobdisk: skipping malformed multipart upload")
556+ if fi, ie := d.Info(); ie == nil && fi.ModTime().Before(now().Add(-tmpGrace)) {
557+ if re := os.RemoveAll(dir); re != nil {
558+ logEntry.Warn().Err(re).Str("id", d.Name()).Msg("blobdisk: remove malformed multipart failed")
559+ } else {
560+ logEntry.Info().Str("id", d.Name()).Msg("blobdisk: removed malformed multipart upload")
561+ }
562+ }
563+ continue
564+ }
565+ if strings.HasPrefix(m.Key, prefix) {
566+ if e = fn(ports.MultipartInfo{Key: m.Key, ID: d.Name(), Started: m.Started}); e != nil {
567+ return e
568+ }
569+ }
570+ }
571+ return nil
572+}
@@ -0,0 +1,572 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobdisk stores objects under root/objects, temporary writes under
4+// root/tmp, and unfinished multipart uploads under root/multipart. Files are
5+// 0600 in 0700 directories; completed objects appear by synced atomic rename.
6+package blobdisk
7+
8+import (
9+ "context"
10+ "crypto/rand"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "io"
17+ "io/fs"
18+ "os"
19+ "path/filepath"
20+ "sort"
21+ "strconv"
22+ "strings"
23+ "time"
24+
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports"
27+ "github.com/bnema/zerowrap"
28+)
29+
30+// Store is the disk implementation of ports.BlobStore.
31+type Store struct{ root string }
32+
33+var _ ports.BlobStore = (*Store)(nil)
34+
35+// Dir returns the blob root under dataDir.
36+func Dir(dataDir string) string { return filepath.Join(dataDir, "blobs") }
37+
38+// New returns a disk store without performing I/O.
39+func New(root string) *Store { return &Store{root: root} }
40+func (s *Store) object(key string) string {
41+ return filepath.Join(s.root, "objects", filepath.FromSlash(key))
42+}
43+func valid(key string) error {
44+ if !ports.ValidBlobKey(key) {
45+ return domain.ErrInvalid
46+ }
47+ return nil
48+}
49+func translate(err error) error {
50+ if errors.Is(err, fs.ErrNotExist) {
51+ return domain.ErrNotFound
52+ }
53+ return err
54+}
55+
56+// tmpGrace is how long leftovers in root/tmp (and malformed multipart dirs)
57+// are kept before reclamation.
58+const tmpGrace = time.Hour
59+
60+// now is the clock used for reclamation; tests may replace it.
61+var now = time.Now
62+
63+// syncDir fsyncs a directory; tests may replace it to record calls.
64+var syncDir = func(path string) error {
65+ f, e := os.Open(path)
66+ if e != nil {
67+ return e
68+ }
69+ return errors.Join(f.Sync(), f.Close())
70+}
71+
72+// mkdirAllSync creates dir (under root) with 0700, fsyncing the parent of
73+// every directory it creates so the new entries are durable.
74+func mkdirAllSync(root, dir string) error {
75+ var missing []string
76+ for d := filepath.Clean(dir); d != filepath.Clean(root) && d != filepath.Dir(d); d = filepath.Dir(d) {
77+ if fi, e := os.Stat(d); e == nil {
78+ if !fi.IsDir() {
79+ return fmt.Errorf("%s: not a directory", d)
80+ }
81+ break
82+ } else if !errors.Is(e, fs.ErrNotExist) {
83+ return e
84+ }
85+ missing = append(missing, d)
86+ }
87+ for i := len(missing) - 1; i >= 0; i-- {
88+ if e := os.Mkdir(missing[i], 0700); e != nil && !errors.Is(e, fs.ErrExist) {
89+ return e
90+ }
91+ if e := syncDir(filepath.Dir(missing[i])); e != nil {
92+ return e
93+ }
94+ }
95+ return nil
96+}
97+
98+// reclaimTmp removes entries (files or directories) in root/tmp whose mtime
99+// is older than olderThan.
100+func (s *Store) reclaimTmp(ctx context.Context, olderThan time.Duration) error {
101+ dir := filepath.Join(s.root, "tmp")
102+ entries, e := os.ReadDir(dir)
103+ if errors.Is(e, fs.ErrNotExist) {
104+ return nil
105+ }
106+ if e != nil {
107+ return e
108+ }
109+ cutoff := now().Add(-olderThan)
110+ var errs []error
111+ for _, d := range entries {
112+ if e = ctx.Err(); e != nil {
113+ return e
114+ }
115+ fi, e := d.Info()
116+ if e != nil || !fi.ModTime().Before(cutoff) {
117+ continue
118+ }
119+ if e = os.RemoveAll(filepath.Join(dir, d.Name())); e != nil {
120+ errs = append(errs, e)
121+ continue
122+ }
123+ logEntry := zerowrap.FromCtx(ctx)
124+ logEntry.Info().Str("entry", d.Name()).Msg("blobdisk: reclaimed stale tmp entry")
125+ }
126+ return errors.Join(errs...)
127+}
128+
129+type ctxReader struct {
130+ ctx context.Context
131+ r io.Reader
132+}
133+
134+func (r ctxReader) Read(p []byte) (int, error) {
135+ if e := r.ctx.Err(); e != nil {
136+ return 0, e
137+ }
138+ return r.r.Read(p)
139+}
140+func (s *Store) init(ctx context.Context) error {
141+ if e := ctx.Err(); e != nil {
142+ return e
143+ }
144+ for _, d := range []string{"objects", "tmp", "multipart"} {
145+ if e := os.MkdirAll(filepath.Join(s.root, d), 0700); e != nil {
146+ return e
147+ }
148+ }
149+ return nil
150+}
151+
152+// Check verifies that the root is writable.
153+func (s *Store) Check(ctx context.Context) error {
154+ logEntry := zerowrap.FromCtx(ctx)
155+ logEntry.Debug().Msg("blobdisk: Check")
156+ if e := s.init(ctx); e != nil {
157+ return e
158+ }
159+ if e := s.reclaimTmp(ctx, tmpGrace); e != nil {
160+ logEntry.Warn().Err(e).Msg("blobdisk: tmp reclamation failed")
161+ }
162+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".probe-*")
163+ if e != nil {
164+ return e
165+ }
166+ return errors.Join(f.Close(), os.Remove(f.Name()))
167+}
168+
169+// Put atomically replaces key with exactly size bytes.
170+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
171+ logEntry := zerowrap.FromCtx(ctx)
172+ logEntry.Debug().Msg("blobdisk: Put")
173+ if e := valid(key); e != nil {
174+ return ports.BlobInfo{}, e
175+ }
176+ if size < 0 {
177+ return ports.BlobInfo{}, domain.ErrInvalid
178+ }
179+ if e := s.init(ctx); e != nil {
180+ return ports.BlobInfo{}, e
181+ }
182+ f, e := os.CreateTemp(filepath.Join(s.root, "tmp"), ".tmp-*")
183+ if e != nil {
184+ return ports.BlobInfo{}, e
185+ }
186+ defer func() { _ = os.Remove(f.Name()) }()
187+ h := sha256.New()
188+ n, e := io.Copy(io.MultiWriter(f, h), ctxReader{ctx, r})
189+ if e == nil && n != size {
190+ e = domain.ErrInvalid
191+ }
192+ if e == nil {
193+ e = ctx.Err()
194+ }
195+ if e == nil {
196+ e = f.Sync()
197+ }
198+ e = errors.Join(e, f.Close())
199+ if e != nil {
200+ return ports.BlobInfo{}, e
201+ }
202+ path := s.object(key)
203+ if e = mkdirAllSync(filepath.Join(s.root, "objects"), filepath.Dir(path)); e == nil {
204+ e = os.Rename(f.Name(), path)
205+ }
206+ if e == nil {
207+ e = syncDir(filepath.Dir(path))
208+ }
209+ if e != nil {
210+ return ports.BlobInfo{}, e
211+ }
212+ info, e := s.Stat(ctx, key)
213+ info.Digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
214+ return info, e
215+}
216+
217+// Stat returns object metadata.
218+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
219+ if e := valid(key); e != nil {
220+ return ports.BlobInfo{}, e
221+ }
222+ if e := ctx.Err(); e != nil {
223+ return ports.BlobInfo{}, e
224+ }
225+ f, e := os.Stat(s.object(key))
226+ if e != nil {
227+ return ports.BlobInfo{}, translate(e)
228+ }
229+ if !f.Mode().IsRegular() {
230+ return ports.BlobInfo{}, domain.ErrNotFound
231+ }
232+ return ports.BlobInfo{Key: key, Size: f.Size(), ModTime: f.ModTime()}, nil
233+}
234+
235+type limitedCloser struct {
236+ io.Reader
237+ io.Closer
238+}
239+
240+// Open reads an object range.
241+//
242+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into a file seek; range rules belong to the port
243+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
244+ info, e := s.Stat(ctx, key)
245+ if e != nil {
246+ return nil, info, e
247+ }
248+ if offset < 0 {
249+ return nil, info, domain.ErrInvalid
250+ }
251+ if offset >= info.Size {
252+ return nil, info, domain.ErrOutOfRange
253+ }
254+ f, e := os.Open(s.object(key))
255+ if e != nil {
256+ return nil, info, translate(e)
257+ }
258+ if _, e = f.Seek(offset, io.SeekStart); e != nil {
259+ _ = f.Close()
260+ return nil, info, e
261+ }
262+ var r io.Reader = ctxReader{ctx, f}
263+ if length >= 0 {
264+ r = io.LimitReader(r, length)
265+ }
266+ return limitedCloser{r, f}, info, nil
267+}
268+
269+// Delete removes an object, treating absence as success.
270+func (s *Store) Delete(ctx context.Context, key string) error {
271+ logEntry := zerowrap.FromCtx(ctx)
272+ logEntry.Debug().Msg("blobdisk: Delete")
273+ if e := valid(key); e != nil {
274+ return e
275+ }
276+ if e := ctx.Err(); e != nil {
277+ return e
278+ }
279+ e := os.Remove(s.object(key))
280+ if errors.Is(e, fs.ErrNotExist) {
281+ return nil
282+ }
283+ return e
284+}
285+
286+// List visits matching objects in lexical order.
287+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
288+ logEntry := zerowrap.FromCtx(ctx)
289+ logEntry.Debug().Msg("blobdisk: List")
290+ root := filepath.Join(s.root, "objects")
291+ var infos []ports.BlobInfo
292+ e := filepath.WalkDir(root, func(path string, d fs.DirEntry, e error) error {
293+ if e != nil {
294+ return e
295+ }
296+ if e = ctx.Err(); e != nil {
297+ return e
298+ }
299+ if !d.Type().IsRegular() {
300+ return nil
301+ }
302+ key, e := filepath.Rel(root, path)
303+ if e != nil {
304+ return e
305+ }
306+ key = filepath.ToSlash(key)
307+ if !strings.HasPrefix(key, prefix) {
308+ return nil
309+ }
310+ i, e := d.Info()
311+ if e != nil {
312+ return e
313+ }
314+ infos = append(infos, ports.BlobInfo{Key: key, Size: i.Size(), ModTime: i.ModTime()})
315+ return nil
316+ })
317+ if errors.Is(e, fs.ErrNotExist) {
318+ return nil
319+ }
320+ if e != nil {
321+ return e
322+ }
323+ sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key })
324+ for _, i := range infos {
325+ if e = ctx.Err(); e != nil {
326+ return e
327+ }
328+ if e = fn(i); e != nil {
329+ return e
330+ }
331+ }
332+ return nil
333+}
334+
335+type upload struct {
336+ s *Store
337+ key, id string
338+ done bool
339+}
340+type metadata struct {
341+ Key string
342+ Started time.Time
343+}
344+
345+func (u *upload) dir() string { return filepath.Join(u.s.root, "multipart", u.id) }
346+
347+// NewMultipart starts an isolated staging upload.
348+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
349+ logEntry := zerowrap.FromCtx(ctx)
350+ logEntry.Debug().Msg("blobdisk: NewMultipart")
351+ if e := valid(key); e != nil {
352+ return nil, e
353+ }
354+ if e := s.init(ctx); e != nil {
355+ return nil, e
356+ }
357+ b := make([]byte, 16)
358+ if _, e := rand.Read(b); e != nil {
359+ return nil, e
360+ }
361+ u := &upload{s: s, key: key, id: hex.EncodeToString(b)}
362+ stage := filepath.Join(s.root, "tmp", "mp-"+u.id)
363+ if e := os.Mkdir(stage, 0700); e != nil {
364+ return nil, e
365+ }
366+ if e := writeMetadata(stage, metadata{key, time.Now().UTC()}); e != nil {
367+ _ = os.RemoveAll(stage)
368+ return nil, e
369+ }
370+ if e := os.Rename(stage, u.dir()); e != nil {
371+ _ = os.RemoveAll(stage)
372+ return nil, e
373+ }
374+ if e := syncDir(filepath.Join(s.root, "multipart")); e != nil {
375+ _ = os.RemoveAll(u.dir())
376+ return nil, e
377+ }
378+ return u, nil
379+}
380+
381+// writeMetadata durably writes dir/metadata via temp file and rename.
382+func writeMetadata(dir string, m metadata) error {
383+ data, e := json.Marshal(m)
384+ if e != nil {
385+ return e
386+ }
387+ f, e := os.CreateTemp(dir, ".meta-*")
388+ if e != nil {
389+ return e
390+ }
391+ _, e = f.Write(data)
392+ if e == nil {
393+ e = f.Sync()
394+ }
395+ if e = errors.Join(e, f.Close()); e == nil {
396+ e = os.Rename(f.Name(), filepath.Join(dir, "metadata"))
397+ }
398+ if e != nil {
399+ _ = os.Remove(f.Name())
400+ return e
401+ }
402+ return syncDir(dir)
403+}
404+func (u *upload) ID() string { return u.id }
405+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
406+ logEntry := zerowrap.FromCtx(ctx)
407+ logEntry.Debug().Msg("blobdisk: UploadPart")
408+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 {
409+ return domain.ErrInvalid
410+ }
411+ f, e := os.CreateTemp(u.dir(), ".tmp-*")
412+ if e != nil {
413+ return e
414+ }
415+ defer func() { _ = os.Remove(f.Name()) }()
416+ count, e := io.Copy(f, ctxReader{ctx, r})
417+ if e == nil && count != size {
418+ e = domain.ErrInvalid
419+ }
420+ if e == nil {
421+ e = f.Sync()
422+ }
423+ e = errors.Join(e, f.Close())
424+ if e != nil {
425+ return e
426+ }
427+ return os.Rename(f.Name(), filepath.Join(u.dir(), fmt.Sprintf("%05d", n)))
428+}
429+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
430+ logEntry := zerowrap.FromCtx(ctx)
431+ logEntry.Debug().Msg("blobdisk: Complete")
432+ if u.done {
433+ return ports.BlobInfo{}, domain.ErrInvalid
434+ }
435+ u.done = true
436+ defer func() { _ = os.RemoveAll(u.dir()) }()
437+ entries, e := os.ReadDir(u.dir())
438+ if e != nil {
439+ return ports.BlobInfo{}, e
440+ }
441+ var names []string
442+ for _, d := range entries {
443+ if n, e := strconv.Atoi(d.Name()); e == nil && n > 0 {
444+ names = append(names, d.Name())
445+ }
446+ }
447+ sort.Strings(names)
448+ if len(names) == 0 {
449+ return ports.BlobInfo{}, domain.ErrInvalid
450+ }
451+ var size int64
452+ var readers []io.Reader
453+ var files []*os.File
454+ defer func() {
455+ for _, f := range files {
456+ _ = f.Close()
457+ }
458+ }()
459+ for i, n := range names {
460+ f, e := os.Open(filepath.Join(u.dir(), n))
461+ if e != nil {
462+ return ports.BlobInfo{}, e
463+ }
464+ files = append(files, f)
465+ info, e := f.Stat()
466+ if e != nil {
467+ return ports.BlobInfo{}, e
468+ }
469+ if i < len(names)-1 && info.Size() < ports.BlobMinPartSize {
470+ return ports.BlobInfo{}, domain.ErrInvalid
471+ }
472+ size += info.Size()
473+ readers = append(readers, f)
474+ }
475+ info, e := u.s.Put(ctx, u.key, io.MultiReader(readers...), size)
476+ info.Digest = ""
477+ return info, e
478+}
479+func (u *upload) Abort(ctx context.Context) error {
480+ logEntry := zerowrap.FromCtx(ctx)
481+ logEntry.Debug().Msg("blobdisk: Abort")
482+ if u.done {
483+ return domain.ErrInvalid
484+ }
485+ u.done = true
486+ return u.s.AbortMultipart(ctx, u.key, u.id)
487+}
488+func validID(id string) bool {
489+ b, e := hex.DecodeString(id)
490+ return e == nil && len(b) == 16 && id == strings.ToLower(id)
491+}
492+
493+// AbortMultipart removes a listed upload by identity.
494+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
495+ logEntry := zerowrap.FromCtx(ctx)
496+ logEntry.Debug().Msg("blobdisk: AbortMultipart")
497+ if e := valid(key); e != nil {
498+ return e
499+ }
500+ if !validID(id) {
501+ return domain.ErrInvalid
502+ }
503+ if e := ctx.Err(); e != nil {
504+ return e
505+ }
506+ dir := filepath.Join(s.root, "multipart", id)
507+ b, e := os.ReadFile(filepath.Join(dir, "metadata"))
508+ if errors.Is(e, fs.ErrNotExist) {
509+ return nil
510+ }
511+ if e != nil {
512+ return e
513+ }
514+ var m metadata
515+ if e = json.Unmarshal(b, &m); e != nil {
516+ return e
517+ }
518+ if m.Key != key {
519+ return domain.ErrInvalid
520+ }
521+ return os.RemoveAll(dir)
522+}
523+
524+// ListMultipart visits unfinished uploads matching prefix.
525+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
526+ logEntry := zerowrap.FromCtx(ctx)
527+ logEntry.Debug().Msg("blobdisk: ListMultipart")
528+ entries, e := os.ReadDir(filepath.Join(s.root, "multipart"))
529+ if errors.Is(e, fs.ErrNotExist) {
530+ return nil
531+ }
532+ if e != nil {
533+ return e
534+ }
535+ if e = s.reclaimTmp(ctx, tmpGrace); e != nil {
536+ logEntry.Warn().Err(e).Msg("blobdisk: tmp reclamation failed")
537+ }
538+ for _, d := range entries {
539+ if e = ctx.Err(); e != nil {
540+ return e
541+ }
542+ if !d.IsDir() || !validID(d.Name()) {
543+ continue
544+ }
545+ dir := filepath.Join(s.root, "multipart", d.Name())
546+ var m metadata
547+ b, e := os.ReadFile(filepath.Join(dir, "metadata"))
548+ if e == nil {
549+ e = json.Unmarshal(b, &m)
550+ }
551+ if e == nil && !ports.ValidBlobKey(m.Key) {
552+ e = domain.ErrInvalid
553+ }
554+ if e != nil {
555+ logEntry.Warn().Err(e).Str("id", d.Name()).Msg("blobdisk: skipping malformed multipart upload")
556+ if fi, ie := d.Info(); ie == nil && fi.ModTime().Before(now().Add(-tmpGrace)) {
557+ if re := os.RemoveAll(dir); re != nil {
558+ logEntry.Warn().Err(re).Str("id", d.Name()).Msg("blobdisk: remove malformed multipart failed")
559+ } else {
560+ logEntry.Info().Str("id", d.Name()).Msg("blobdisk: removed malformed multipart upload")
561+ }
562+ }
563+ continue
564+ }
565+ if strings.HasPrefix(m.Key, prefix) {
566+ if e = fn(ports.MultipartInfo{Key: m.Key, ID: d.Name(), Started: m.Started}); e != nil {
567+ return e
568+ }
569+ }
570+ }
571+ return nil
572+}
A internal/adapters/blobdisk/blobdisk_internal_test.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobdisk
3+
4+import (
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func age(t *testing.T, p string) {
17+ t.Helper()
18+ old := time.Now().Add(-2 * tmpGrace)
19+ require.NoError(t, os.Chtimes(p, old, old))
20+}
21+
22+func TestCheckReclaimsOldTmp(t *testing.T) {
23+ root := t.TempDir()
24+ s := New(root)
25+ ctx := context.Background()
26+ require.NoError(t, s.Check(ctx))
27+ oldF, freshF := filepath.Join(root, "tmp", ".tmp-old"), filepath.Join(root, "tmp", ".tmp-fresh")
28+ oldD := filepath.Join(root, "tmp", "mp-old")
29+ require.NoError(t, os.WriteFile(oldF, []byte("x"), 0600))
30+ require.NoError(t, os.WriteFile(freshF, []byte("x"), 0600))
31+ require.NoError(t, os.MkdirAll(oldD, 0700))
32+ require.NoError(t, os.WriteFile(filepath.Join(oldD, "metadata"), []byte("x"), 0600))
33+ age(t, oldF)
34+ age(t, oldD)
35+ require.NoError(t, s.Check(ctx))
36+ require.NoFileExists(t, oldF)
37+ require.NoDirExists(t, oldD)
38+ require.FileExists(t, freshF)
39+}
40+
41+func TestListMultipartSkipsAndReclaimsMalformed(t *testing.T) {
42+ root := t.TempDir()
43+ s := New(root)
44+ ctx := context.Background()
45+ a, err := s.NewMultipart(ctx, "a")
46+ require.NoError(t, err)
47+ c, err := s.NewMultipart(ctx, "c")
48+ require.NoError(t, err)
49+ mk := func(id string, meta *string) string {
50+ d := filepath.Join(root, "multipart", id)
51+ require.NoError(t, os.Mkdir(d, 0700))
52+ if meta != nil {
53+ require.NoError(t, os.WriteFile(filepath.Join(d, "metadata"), []byte(*meta), 0600))
54+ }
55+ return d
56+ }
57+ trunc := `{"Key":`
58+ freshBad := mk(strings.Repeat("1", 32), &trunc)
59+ oldBad := mk(strings.Repeat("2", 32), nil)
60+ oldTrunc := mk(strings.Repeat("3", 32), &trunc)
61+ age(t, oldBad)
62+ age(t, oldTrunc)
63+ var got []string
64+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { got = append(got, i.ID); return nil }))
65+ require.ElementsMatch(t, []string{a.ID(), c.ID()}, got)
66+ require.DirExists(t, freshBad)
67+ require.NoDirExists(t, oldBad)
68+ require.NoDirExists(t, oldTrunc)
69+ entries, err := os.ReadDir(filepath.Join(root, "tmp"))
70+ require.NoError(t, err)
71+ require.Empty(t, entries)
72+}
73+
74+func TestMkdirAllSyncSyncsParents(t *testing.T) {
75+ root := t.TempDir()
76+ s := New(root)
77+ var synced []string
78+ orig := syncDir
79+ syncDir = func(p string) error { synced = append(synced, p); return orig(p) }
80+ t.Cleanup(func() { syncDir = orig })
81+ _, err := s.Put(context.Background(), "a/b/c/obj", strings.NewReader("hi"), 2)
82+ require.NoError(t, err)
83+ obj := filepath.Join(root, "objects")
84+ for _, want := range []string{obj, filepath.Join(obj, "a"), filepath.Join(obj, "a/b"), filepath.Join(obj, "a/b/c")} {
85+ require.Contains(t, synced, want)
86+ }
87+}
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobdisk
3+
4+import (
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func age(t *testing.T, p string) {
17+ t.Helper()
18+ old := time.Now().Add(-2 * tmpGrace)
19+ require.NoError(t, os.Chtimes(p, old, old))
20+}
21+
22+func TestCheckReclaimsOldTmp(t *testing.T) {
23+ root := t.TempDir()
24+ s := New(root)
25+ ctx := context.Background()
26+ require.NoError(t, s.Check(ctx))
27+ oldF, freshF := filepath.Join(root, "tmp", ".tmp-old"), filepath.Join(root, "tmp", ".tmp-fresh")
28+ oldD := filepath.Join(root, "tmp", "mp-old")
29+ require.NoError(t, os.WriteFile(oldF, []byte("x"), 0600))
30+ require.NoError(t, os.WriteFile(freshF, []byte("x"), 0600))
31+ require.NoError(t, os.MkdirAll(oldD, 0700))
32+ require.NoError(t, os.WriteFile(filepath.Join(oldD, "metadata"), []byte("x"), 0600))
33+ age(t, oldF)
34+ age(t, oldD)
35+ require.NoError(t, s.Check(ctx))
36+ require.NoFileExists(t, oldF)
37+ require.NoDirExists(t, oldD)
38+ require.FileExists(t, freshF)
39+}
40+
41+func TestListMultipartSkipsAndReclaimsMalformed(t *testing.T) {
42+ root := t.TempDir()
43+ s := New(root)
44+ ctx := context.Background()
45+ a, err := s.NewMultipart(ctx, "a")
46+ require.NoError(t, err)
47+ c, err := s.NewMultipart(ctx, "c")
48+ require.NoError(t, err)
49+ mk := func(id string, meta *string) string {
50+ d := filepath.Join(root, "multipart", id)
51+ require.NoError(t, os.Mkdir(d, 0700))
52+ if meta != nil {
53+ require.NoError(t, os.WriteFile(filepath.Join(d, "metadata"), []byte(*meta), 0600))
54+ }
55+ return d
56+ }
57+ trunc := `{"Key":`
58+ freshBad := mk(strings.Repeat("1", 32), &trunc)
59+ oldBad := mk(strings.Repeat("2", 32), nil)
60+ oldTrunc := mk(strings.Repeat("3", 32), &trunc)
61+ age(t, oldBad)
62+ age(t, oldTrunc)
63+ var got []string
64+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { got = append(got, i.ID); return nil }))
65+ require.ElementsMatch(t, []string{a.ID(), c.ID()}, got)
66+ require.DirExists(t, freshBad)
67+ require.NoDirExists(t, oldBad)
68+ require.NoDirExists(t, oldTrunc)
69+ entries, err := os.ReadDir(filepath.Join(root, "tmp"))
70+ require.NoError(t, err)
71+ require.Empty(t, entries)
72+}
73+
74+func TestMkdirAllSyncSyncsParents(t *testing.T) {
75+ root := t.TempDir()
76+ s := New(root)
77+ var synced []string
78+ orig := syncDir
79+ syncDir = func(p string) error { synced = append(synced, p); return orig(p) }
80+ t.Cleanup(func() { syncDir = orig })
81+ _, err := s.Put(context.Background(), "a/b/c/obj", strings.NewReader("hi"), 2)
82+ require.NoError(t, err)
83+ obj := filepath.Join(root, "objects")
84+ for _, want := range []string{obj, filepath.Join(obj, "a"), filepath.Join(obj, "a/b"), filepath.Join(obj, "a/b/c")} {
85+ require.Contains(t, synced, want)
86+ }
87+}
A internal/adapters/blobdisk/blobdisk_test.go +29 -0
@@ -0,0 +1,29 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobdisk_test
3+
4+import (
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "bnema.dev/ermite/internal/adapters/blobdisk"
11+ "bnema.dev/ermite/internal/adapters/blobtest"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestContract(t *testing.T) {
18+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { return blobdisk.New(t.TempDir()) })
19+}
20+func TestCrashTempInvisible(t *testing.T) {
21+ root := t.TempDir()
22+ s := blobdisk.New(root)
23+ ctx := context.Background()
24+ require.NoError(t, s.Check(ctx))
25+ require.NoError(t, os.WriteFile(filepath.Join(root, "tmp", ".tmp-crashed"), []byte("partial"), 0600))
26+ require.NoError(t, s.List(ctx, "", func(ports.BlobInfo) error { t.Fatal("temporary object listed"); return nil }))
27+ _, e := s.Stat(ctx, ".tmp-crashed")
28+ require.ErrorIs(t, e, domain.ErrNotFound)
29+}
@@ -0,0 +1,29 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobdisk_test
3+
4+import (
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "bnema.dev/ermite/internal/adapters/blobdisk"
11+ "bnema.dev/ermite/internal/adapters/blobtest"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestContract(t *testing.T) {
18+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { return blobdisk.New(t.TempDir()) })
19+}
20+func TestCrashTempInvisible(t *testing.T) {
21+ root := t.TempDir()
22+ s := blobdisk.New(root)
23+ ctx := context.Background()
24+ require.NoError(t, s.Check(ctx))
25+ require.NoError(t, os.WriteFile(filepath.Join(root, "tmp", ".tmp-crashed"), []byte("partial"), 0600))
26+ require.NoError(t, s.List(ctx, "", func(ports.BlobInfo) error { t.Fatal("temporary object listed"); return nil }))
27+ _, e := s.Stat(ctx, ".tmp-crashed")
28+ require.ErrorIs(t, e, domain.ErrNotFound)
29+}
A internal/adapters/blobs3/blobs3.go +461 -0
@@ -0,0 +1,461 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobs3 implements the blob port with the object, multipart and
4+// listing S3 APIs. Checksums are WhenRequired, credentials are explicit, and
5+// no bucket creation, policy, ACL, tagging or lifecycle API is ever called.
6+package blobs3
7+
8+import (
9+ "bytes"
10+ "context"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "errors"
14+ "fmt"
15+ "io"
16+ "net/http"
17+ "sort"
18+ "strconv"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
25+ "github.com/aws/aws-sdk-go-v2/aws"
26+ "github.com/aws/aws-sdk-go-v2/credentials"
27+ "github.com/aws/aws-sdk-go-v2/service/s3"
28+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
29+ "github.com/bnema/zerowrap"
30+)
31+
32+// Store is a provider-independent S3 blob store.
33+type Store struct {
34+ client *s3.Client
35+ cfg Config
36+}
37+
38+var _ ports.BlobStore = (*Store)(nil)
39+
40+// New builds a client without consulting ambient configuration or probing.
41+func New(ctx context.Context, cfg Config) (*Store, error) {
42+ if e := ctx.Err(); e != nil {
43+ return nil, e
44+ }
45+ if cfg.Bucket == "" || cfg.Region == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
46+ return nil, fmt.Errorf("bucket, region and explicit credentials are required: %w", domain.ErrInvalid)
47+ }
48+ cfg.Prefix = strings.Trim(cfg.Prefix, "/")
49+ if cfg.Prefix != "" {
50+ cfg.Prefix += "/"
51+ }
52+ client := cfg.HTTPClient
53+ if client == nil {
54+ transport := http.DefaultTransport.(*http.Transport).Clone()
55+ transport.IdleConnTimeout = 60 * time.Second
56+ transport.ResponseHeaderTimeout = 60 * time.Second
57+ client = &http.Client{Transport: transport}
58+ }
59+ ac := aws.Config{Region: cfg.Region, Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), HTTPClient: client, RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, RetryMaxAttempts: 3}
60+ c := s3.NewFromConfig(ac, func(o *s3.Options) {
61+ if cfg.Endpoint != "" {
62+ o.BaseEndpoint = aws.String(cfg.Endpoint)
63+ }
64+ o.UsePathStyle = cfg.ForcePathStyle
65+ })
66+ return &Store{client: c, cfg: cfg}, nil
67+}
68+func valid(key string) error {
69+ if !ports.ValidBlobKey(key) {
70+ return domain.ErrInvalid
71+ }
72+ return nil
73+}
74+func (s *Store) key(key string) *string { return aws.String(s.cfg.Prefix + key) }
75+
76+type statusError interface{ HTTPStatusCode() int }
77+type codeError interface{ ErrorCode() string }
78+
79+func status(err error) int {
80+ var e statusError
81+ if errors.As(err, &e) {
82+ return e.HTTPStatusCode()
83+ }
84+ return 0
85+}
86+func translate(err error) error {
87+ if err == nil {
88+ return nil
89+ }
90+ var c codeError
91+ if errors.As(err, &c) {
92+ switch c.ErrorCode() {
93+ case "InvalidRange":
94+ return domain.ErrOutOfRange
95+ case "NoSuchKey", "NotFound":
96+ return domain.ErrNotFound
97+ case "EntityTooSmall":
98+ return domain.ErrInvalid
99+ }
100+ }
101+ switch status(err) {
102+ case 404:
103+ return domain.ErrNotFound
104+ case 416:
105+ return domain.ErrOutOfRange
106+ }
107+ return err
108+}
109+
110+type ctxReader struct {
111+ ctx context.Context
112+ r io.Reader
113+}
114+
115+func (r ctxReader) Read(p []byte) (int, error) {
116+ if e := r.ctx.Err(); e != nil {
117+ return 0, e
118+ }
119+ return r.r.Read(p)
120+}
121+
122+// One pooled buffer per in-flight upload bounds staging to BlobPartSize.
123+var buffers = sync.Pool{New: func() any { b := make([]byte, ports.BlobPartSize); return &b }}
124+
125+func exactEnd(ctx context.Context, r io.Reader) error {
126+ var b [1]byte
127+ n, e := io.ReadFull(ctxReader{ctx, r}, b[:])
128+ if n != 0 {
129+ return domain.ErrInvalid
130+ }
131+ if e == io.EOF {
132+ return nil
133+ }
134+ return e
135+}
136+func readPart(ctx context.Context, r io.Reader, b []byte) error {
137+ _, e := io.ReadFull(ctxReader{ctx, r}, b)
138+ if e == io.EOF || e == io.ErrUnexpectedEOF {
139+ return domain.ErrInvalid
140+ }
141+ return e
142+}
143+
144+// Put uses signed, seekable bodies, multiparting objects larger than one buffer.
145+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
146+ logEntry := zerowrap.FromCtx(ctx)
147+ logEntry.Debug().Msg("blobs3: Put")
148+ if e := valid(key); e != nil {
149+ return ports.BlobInfo{}, e
150+ }
151+ if size < 0 || size > int64(ports.BlobPartSize)*ports.BlobMaxParts {
152+ return ports.BlobInfo{}, domain.ErrInvalid
153+ }
154+ bp := buffers.Get().(*[]byte)
155+ defer buffers.Put(bp)
156+ h := sha256.New()
157+ source := io.TeeReader(r, h)
158+ if size <= ports.BlobPartSize {
159+ b := (*bp)[:int(size)]
160+ if e := readPart(ctx, source, b); e != nil {
161+ return ports.BlobInfo{}, e
162+ }
163+ if e := exactEnd(ctx, r); e != nil {
164+ return ports.BlobInfo{}, e
165+ }
166+ _, e := s.client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Body: bytes.NewReader(b), ContentLength: aws.Int64(size)})
167+ if e != nil {
168+ return ports.BlobInfo{}, translate(e)
169+ }
170+ } else {
171+ u, e := s.NewMultipart(ctx, key)
172+ if e != nil {
173+ return ports.BlobInfo{}, e
174+ }
175+ completed := false
176+ defer func() {
177+ if !completed {
178+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
179+ defer cancel()
180+ _ = s.AbortMultipart(cleanup, key, u.ID())
181+ }
182+ }()
183+ remaining := size
184+ for n := 1; remaining > 0; n++ {
185+ count := min(remaining, int64(ports.BlobPartSize))
186+ b := (*bp)[:int(count)]
187+ if e = readPart(ctx, source, b); e != nil {
188+ return ports.BlobInfo{}, e
189+ }
190+ if e = u.UploadPart(ctx, n, bytes.NewReader(b), count); e != nil {
191+ return ports.BlobInfo{}, e
192+ }
193+ remaining -= count
194+ }
195+ if e = exactEnd(ctx, r); e != nil {
196+ return ports.BlobInfo{}, e
197+ }
198+ if _, e = u.Complete(ctx); e != nil {
199+ return ports.BlobInfo{}, e
200+ }
201+ completed = true
202+ }
203+ log := zerowrap.FromCtx(ctx)
204+ log.Debug().Str("key", key).Int64("size", size).Msg("blob stored")
205+ return ports.BlobInfo{Key: key, Size: size, Digest: "sha256:" + hex.EncodeToString(h.Sum(nil)), ModTime: time.Now().UTC()}, nil
206+}
207+
208+// Stat retrieves metadata without downloading content.
209+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
210+ if e := valid(key); e != nil {
211+ return ports.BlobInfo{}, e
212+ }
213+ o, e := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
214+ if e != nil {
215+ return ports.BlobInfo{}, translate(e)
216+ }
217+ return ports.BlobInfo{Key: key, Size: aws.ToInt64(o.ContentLength), ModTime: aws.ToTime(o.LastModified)}, nil
218+}
219+
220+// Open streams a single range of an object.
221+//
222+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into an HTTP Range header; range rules belong to the port
223+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
224+ if e := valid(key); e != nil {
225+ return nil, ports.BlobInfo{}, e
226+ }
227+ if offset < 0 {
228+ return nil, ports.BlobInfo{}, domain.ErrInvalid
229+ }
230+ if length == 0 {
231+ info, e := s.Stat(ctx, key)
232+ if e != nil {
233+ return nil, info, e
234+ }
235+ if offset >= info.Size {
236+ return nil, info, domain.ErrOutOfRange
237+ }
238+ return io.NopCloser(strings.NewReader("")), info, nil
239+ }
240+ rg := fmt.Sprintf("bytes=%d-", offset)
241+ if length > 0 && length-1 <= int64(^uint64(0)>>1)-offset {
242+ rg += strconv.FormatInt(offset+length-1, 10)
243+ }
244+ o, e := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Range: aws.String(rg)})
245+ if e != nil {
246+ return nil, ports.BlobInfo{}, translate(e)
247+ }
248+ size := aws.ToInt64(o.ContentLength)
249+ if _, total, ok := strings.Cut(aws.ToString(o.ContentRange), "/"); ok {
250+ if n, e := strconv.ParseInt(total, 10, 64); e == nil {
251+ size = n
252+ }
253+ }
254+ return o.Body, ports.BlobInfo{Key: key, Size: size, ModTime: aws.ToTime(o.LastModified)}, nil
255+}
256+
257+// Delete removes a key; absence is success.
258+func (s *Store) Delete(ctx context.Context, key string) error {
259+ logEntry := zerowrap.FromCtx(ctx)
260+ logEntry.Debug().Msg("blobs3: Delete")
261+ if e := valid(key); e != nil {
262+ return e
263+ }
264+ _, e := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
265+ if status(e) == 404 {
266+ return nil
267+ }
268+ return translate(e)
269+}
270+
271+// List visits matching objects lexicographically.
272+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
273+ logEntry := zerowrap.FromCtx(ctx)
274+ logEntry.Debug().Msg("blobs3: List")
275+ p := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)})
276+ for p.HasMorePages() {
277+ o, e := p.NextPage(ctx)
278+ if e != nil {
279+ return translate(e)
280+ }
281+ for _, v := range o.Contents {
282+ if e = fn(ports.BlobInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), Size: aws.ToInt64(v.Size), ModTime: aws.ToTime(v.LastModified)}); e != nil {
283+ return e
284+ }
285+ }
286+ }
287+ return nil
288+}
289+
290+// Check probes the existing bucket without modifying it.
291+func (s *Store) Check(ctx context.Context) error {
292+ logEntry := zerowrap.FromCtx(ctx)
293+ logEntry.Debug().Msg("blobs3: Check")
294+ _, e := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.cfg.Bucket)})
295+ if status(e) == 403 {
296+ _, e = s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: aws.String(s.cfg.Prefix), MaxKeys: aws.Int32(1)})
297+ }
298+ if e == nil {
299+ return nil
300+ }
301+ where := s.cfg.Endpoint
302+ if where == "" {
303+ where = s.cfg.Region
304+ }
305+ return fmt.Errorf("bucket %q is not reachable at %s: %w", s.cfg.Bucket, where, e)
306+}
307+
308+type part struct {
309+ etag string
310+ size int64
311+}
312+type upload struct {
313+ s *Store
314+ key, id string
315+ parts map[int]part
316+ done bool
317+}
318+
319+// NewMultipart initiates an object upload.
320+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
321+ logEntry := zerowrap.FromCtx(ctx)
322+ logEntry.Debug().Msg("blobs3: NewMultipart")
323+ if e := valid(key); e != nil {
324+ return nil, e
325+ }
326+ o, e := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
327+ if e != nil {
328+ return nil, translate(e)
329+ }
330+ return &upload{s: s, key: key, id: aws.ToString(o.UploadId), parts: map[int]part{}}, nil
331+}
332+func (u *upload) ID() string { return u.id }
333+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
334+ logEntry := zerowrap.FromCtx(ctx)
335+ logEntry.Debug().Msg("blobs3: UploadPart")
336+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 || size > ports.BlobPartSize {
337+ return domain.ErrInvalid
338+ }
339+ if e := ctx.Err(); e != nil {
340+ return e
341+ }
342+ var body io.ReadSeeker
343+ if seeker, ok := r.(io.ReadSeeker); ok {
344+ start, e := seeker.Seek(0, io.SeekCurrent)
345+ if e != nil {
346+ return e
347+ }
348+ end, e := seeker.Seek(0, io.SeekEnd)
349+ if e != nil {
350+ return e
351+ }
352+ if _, e = seeker.Seek(start, io.SeekStart); e != nil {
353+ return e
354+ }
355+ if end-start != size {
356+ return domain.ErrInvalid
357+ }
358+ body = seeker
359+ } else {
360+ bp := buffers.Get().(*[]byte)
361+ defer buffers.Put(bp)
362+ b := (*bp)[:int(size)]
363+ if e := readPart(ctx, r, b); e != nil {
364+ return e
365+ }
366+ if e := exactEnd(ctx, r); e != nil {
367+ return e
368+ }
369+ body = bytes.NewReader(b)
370+ }
371+ o, e := u.s.client.UploadPart(ctx, &s3.UploadPartInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), PartNumber: aws.Int32(int32(n)), Body: body, ContentLength: aws.Int64(size)})
372+ if e != nil {
373+ return translate(e)
374+ }
375+ u.parts[n] = part{aws.ToString(o.ETag), size}
376+ log := zerowrap.FromCtx(ctx)
377+ log.Debug().Int("part", n).Msg("blob part uploaded")
378+ return nil
379+}
380+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
381+ logEntry := zerowrap.FromCtx(ctx)
382+ logEntry.Debug().Msg("blobs3: Complete")
383+ if u.done {
384+ return ports.BlobInfo{}, domain.ErrInvalid
385+ }
386+ u.done = true
387+ var nums []int
388+ for n := range u.parts {
389+ nums = append(nums, n)
390+ }
391+ sort.Ints(nums)
392+ var parts []types.CompletedPart
393+ var size int64
394+ invalid := len(nums) == 0
395+ for i, n := range nums {
396+ p := u.parts[n]
397+ if i < len(nums)-1 && p.size < ports.BlobMinPartSize {
398+ invalid = true
399+ }
400+ size += p.size
401+ parts = append(parts, types.CompletedPart{PartNumber: aws.Int32(int32(n)), ETag: aws.String(p.etag)})
402+ }
403+ if invalid {
404+ _ = u.s.AbortMultipart(ctx, u.key, u.id)
405+ return ports.BlobInfo{}, domain.ErrInvalid
406+ }
407+ _, e := u.s.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), MultipartUpload: &types.CompletedMultipartUpload{Parts: parts}})
408+ if e != nil {
409+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
410+ defer cancel()
411+ _ = u.s.AbortMultipart(cleanup, u.key, u.id)
412+ return ports.BlobInfo{}, translate(e)
413+ }
414+ return ports.BlobInfo{Key: u.key, Size: size, ModTime: time.Now().UTC()}, nil
415+}
416+func (u *upload) Abort(ctx context.Context) error {
417+ logEntry := zerowrap.FromCtx(ctx)
418+ logEntry.Debug().Msg("blobs3: Abort")
419+ if u.done {
420+ return domain.ErrInvalid
421+ }
422+ u.done = true
423+ return u.s.AbortMultipart(ctx, u.key, u.id)
424+}
425+
426+// AbortMultipart discards a listed upload.
427+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
428+ logEntry := zerowrap.FromCtx(ctx)
429+ logEntry.Debug().Msg("blobs3: AbortMultipart")
430+ if e := valid(key); e != nil {
431+ return e
432+ }
433+ _, e := s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), UploadId: aws.String(id)})
434+ if status(e) == 404 {
435+ return nil
436+ }
437+ return translate(e)
438+}
439+
440+// ListMultipart visits unfinished uploads matching prefix.
441+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
442+ logEntry := zerowrap.FromCtx(ctx)
443+ logEntry.Debug().Msg("blobs3: ListMultipart")
444+ input := &s3.ListMultipartUploadsInput{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)}
445+ for {
446+ o, e := s.client.ListMultipartUploads(ctx, input)
447+ if e != nil {
448+ return translate(e)
449+ }
450+ for _, v := range o.Uploads {
451+ if e = fn(ports.MultipartInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), ID: aws.ToString(v.UploadId), Started: aws.ToTime(v.Initiated)}); e != nil {
452+ return e
453+ }
454+ }
455+ if !aws.ToBool(o.IsTruncated) {
456+ return nil
457+ }
458+ input.KeyMarker = o.NextKeyMarker
459+ input.UploadIdMarker = o.NextUploadIdMarker
460+ }
461+}
@@ -0,0 +1,461 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobs3 implements the blob port with the object, multipart and
4+// listing S3 APIs. Checksums are WhenRequired, credentials are explicit, and
5+// no bucket creation, policy, ACL, tagging or lifecycle API is ever called.
6+package blobs3
7+
8+import (
9+ "bytes"
10+ "context"
11+ "crypto/sha256"
12+ "encoding/hex"
13+ "errors"
14+ "fmt"
15+ "io"
16+ "net/http"
17+ "sort"
18+ "strconv"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
25+ "github.com/aws/aws-sdk-go-v2/aws"
26+ "github.com/aws/aws-sdk-go-v2/credentials"
27+ "github.com/aws/aws-sdk-go-v2/service/s3"
28+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
29+ "github.com/bnema/zerowrap"
30+)
31+
32+// Store is a provider-independent S3 blob store.
33+type Store struct {
34+ client *s3.Client
35+ cfg Config
36+}
37+
38+var _ ports.BlobStore = (*Store)(nil)
39+
40+// New builds a client without consulting ambient configuration or probing.
41+func New(ctx context.Context, cfg Config) (*Store, error) {
42+ if e := ctx.Err(); e != nil {
43+ return nil, e
44+ }
45+ if cfg.Bucket == "" || cfg.Region == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
46+ return nil, fmt.Errorf("bucket, region and explicit credentials are required: %w", domain.ErrInvalid)
47+ }
48+ cfg.Prefix = strings.Trim(cfg.Prefix, "/")
49+ if cfg.Prefix != "" {
50+ cfg.Prefix += "/"
51+ }
52+ client := cfg.HTTPClient
53+ if client == nil {
54+ transport := http.DefaultTransport.(*http.Transport).Clone()
55+ transport.IdleConnTimeout = 60 * time.Second
56+ transport.ResponseHeaderTimeout = 60 * time.Second
57+ client = &http.Client{Transport: transport}
58+ }
59+ ac := aws.Config{Region: cfg.Region, Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), HTTPClient: client, RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, RetryMaxAttempts: 3}
60+ c := s3.NewFromConfig(ac, func(o *s3.Options) {
61+ if cfg.Endpoint != "" {
62+ o.BaseEndpoint = aws.String(cfg.Endpoint)
63+ }
64+ o.UsePathStyle = cfg.ForcePathStyle
65+ })
66+ return &Store{client: c, cfg: cfg}, nil
67+}
68+func valid(key string) error {
69+ if !ports.ValidBlobKey(key) {
70+ return domain.ErrInvalid
71+ }
72+ return nil
73+}
74+func (s *Store) key(key string) *string { return aws.String(s.cfg.Prefix + key) }
75+
76+type statusError interface{ HTTPStatusCode() int }
77+type codeError interface{ ErrorCode() string }
78+
79+func status(err error) int {
80+ var e statusError
81+ if errors.As(err, &e) {
82+ return e.HTTPStatusCode()
83+ }
84+ return 0
85+}
86+func translate(err error) error {
87+ if err == nil {
88+ return nil
89+ }
90+ var c codeError
91+ if errors.As(err, &c) {
92+ switch c.ErrorCode() {
93+ case "InvalidRange":
94+ return domain.ErrOutOfRange
95+ case "NoSuchKey", "NotFound":
96+ return domain.ErrNotFound
97+ case "EntityTooSmall":
98+ return domain.ErrInvalid
99+ }
100+ }
101+ switch status(err) {
102+ case 404:
103+ return domain.ErrNotFound
104+ case 416:
105+ return domain.ErrOutOfRange
106+ }
107+ return err
108+}
109+
110+type ctxReader struct {
111+ ctx context.Context
112+ r io.Reader
113+}
114+
115+func (r ctxReader) Read(p []byte) (int, error) {
116+ if e := r.ctx.Err(); e != nil {
117+ return 0, e
118+ }
119+ return r.r.Read(p)
120+}
121+
122+// One pooled buffer per in-flight upload bounds staging to BlobPartSize.
123+var buffers = sync.Pool{New: func() any { b := make([]byte, ports.BlobPartSize); return &b }}
124+
125+func exactEnd(ctx context.Context, r io.Reader) error {
126+ var b [1]byte
127+ n, e := io.ReadFull(ctxReader{ctx, r}, b[:])
128+ if n != 0 {
129+ return domain.ErrInvalid
130+ }
131+ if e == io.EOF {
132+ return nil
133+ }
134+ return e
135+}
136+func readPart(ctx context.Context, r io.Reader, b []byte) error {
137+ _, e := io.ReadFull(ctxReader{ctx, r}, b)
138+ if e == io.EOF || e == io.ErrUnexpectedEOF {
139+ return domain.ErrInvalid
140+ }
141+ return e
142+}
143+
144+// Put uses signed, seekable bodies, multiparting objects larger than one buffer.
145+func (s *Store) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) {
146+ logEntry := zerowrap.FromCtx(ctx)
147+ logEntry.Debug().Msg("blobs3: Put")
148+ if e := valid(key); e != nil {
149+ return ports.BlobInfo{}, e
150+ }
151+ if size < 0 || size > int64(ports.BlobPartSize)*ports.BlobMaxParts {
152+ return ports.BlobInfo{}, domain.ErrInvalid
153+ }
154+ bp := buffers.Get().(*[]byte)
155+ defer buffers.Put(bp)
156+ h := sha256.New()
157+ source := io.TeeReader(r, h)
158+ if size <= ports.BlobPartSize {
159+ b := (*bp)[:int(size)]
160+ if e := readPart(ctx, source, b); e != nil {
161+ return ports.BlobInfo{}, e
162+ }
163+ if e := exactEnd(ctx, r); e != nil {
164+ return ports.BlobInfo{}, e
165+ }
166+ _, e := s.client.PutObject(ctx, &s3.PutObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Body: bytes.NewReader(b), ContentLength: aws.Int64(size)})
167+ if e != nil {
168+ return ports.BlobInfo{}, translate(e)
169+ }
170+ } else {
171+ u, e := s.NewMultipart(ctx, key)
172+ if e != nil {
173+ return ports.BlobInfo{}, e
174+ }
175+ completed := false
176+ defer func() {
177+ if !completed {
178+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
179+ defer cancel()
180+ _ = s.AbortMultipart(cleanup, key, u.ID())
181+ }
182+ }()
183+ remaining := size
184+ for n := 1; remaining > 0; n++ {
185+ count := min(remaining, int64(ports.BlobPartSize))
186+ b := (*bp)[:int(count)]
187+ if e = readPart(ctx, source, b); e != nil {
188+ return ports.BlobInfo{}, e
189+ }
190+ if e = u.UploadPart(ctx, n, bytes.NewReader(b), count); e != nil {
191+ return ports.BlobInfo{}, e
192+ }
193+ remaining -= count
194+ }
195+ if e = exactEnd(ctx, r); e != nil {
196+ return ports.BlobInfo{}, e
197+ }
198+ if _, e = u.Complete(ctx); e != nil {
199+ return ports.BlobInfo{}, e
200+ }
201+ completed = true
202+ }
203+ log := zerowrap.FromCtx(ctx)
204+ log.Debug().Str("key", key).Int64("size", size).Msg("blob stored")
205+ return ports.BlobInfo{Key: key, Size: size, Digest: "sha256:" + hex.EncodeToString(h.Sum(nil)), ModTime: time.Now().UTC()}, nil
206+}
207+
208+// Stat retrieves metadata without downloading content.
209+func (s *Store) Stat(ctx context.Context, key string) (ports.BlobInfo, error) {
210+ if e := valid(key); e != nil {
211+ return ports.BlobInfo{}, e
212+ }
213+ o, e := s.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
214+ if e != nil {
215+ return ports.BlobInfo{}, translate(e)
216+ }
217+ return ports.BlobInfo{Key: key, Size: aws.ToInt64(o.ContentLength), ModTime: aws.ToTime(o.LastModified)}, nil
218+}
219+
220+// Open streams a single range of an object.
221+//
222+//hexcheck:ignore suspicious-business-logic-in-adapter translates the port offset/length contract into an HTTP Range header; range rules belong to the port
223+func (s *Store) Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) {
224+ if e := valid(key); e != nil {
225+ return nil, ports.BlobInfo{}, e
226+ }
227+ if offset < 0 {
228+ return nil, ports.BlobInfo{}, domain.ErrInvalid
229+ }
230+ if length == 0 {
231+ info, e := s.Stat(ctx, key)
232+ if e != nil {
233+ return nil, info, e
234+ }
235+ if offset >= info.Size {
236+ return nil, info, domain.ErrOutOfRange
237+ }
238+ return io.NopCloser(strings.NewReader("")), info, nil
239+ }
240+ rg := fmt.Sprintf("bytes=%d-", offset)
241+ if length > 0 && length-1 <= int64(^uint64(0)>>1)-offset {
242+ rg += strconv.FormatInt(offset+length-1, 10)
243+ }
244+ o, e := s.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), Range: aws.String(rg)})
245+ if e != nil {
246+ return nil, ports.BlobInfo{}, translate(e)
247+ }
248+ size := aws.ToInt64(o.ContentLength)
249+ if _, total, ok := strings.Cut(aws.ToString(o.ContentRange), "/"); ok {
250+ if n, e := strconv.ParseInt(total, 10, 64); e == nil {
251+ size = n
252+ }
253+ }
254+ return o.Body, ports.BlobInfo{Key: key, Size: size, ModTime: aws.ToTime(o.LastModified)}, nil
255+}
256+
257+// Delete removes a key; absence is success.
258+func (s *Store) Delete(ctx context.Context, key string) error {
259+ logEntry := zerowrap.FromCtx(ctx)
260+ logEntry.Debug().Msg("blobs3: Delete")
261+ if e := valid(key); e != nil {
262+ return e
263+ }
264+ _, e := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
265+ if status(e) == 404 {
266+ return nil
267+ }
268+ return translate(e)
269+}
270+
271+// List visits matching objects lexicographically.
272+func (s *Store) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error {
273+ logEntry := zerowrap.FromCtx(ctx)
274+ logEntry.Debug().Msg("blobs3: List")
275+ p := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)})
276+ for p.HasMorePages() {
277+ o, e := p.NextPage(ctx)
278+ if e != nil {
279+ return translate(e)
280+ }
281+ for _, v := range o.Contents {
282+ if e = fn(ports.BlobInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), Size: aws.ToInt64(v.Size), ModTime: aws.ToTime(v.LastModified)}); e != nil {
283+ return e
284+ }
285+ }
286+ }
287+ return nil
288+}
289+
290+// Check probes the existing bucket without modifying it.
291+func (s *Store) Check(ctx context.Context) error {
292+ logEntry := zerowrap.FromCtx(ctx)
293+ logEntry.Debug().Msg("blobs3: Check")
294+ _, e := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.cfg.Bucket)})
295+ if status(e) == 403 {
296+ _, e = s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(s.cfg.Bucket), Prefix: aws.String(s.cfg.Prefix), MaxKeys: aws.Int32(1)})
297+ }
298+ if e == nil {
299+ return nil
300+ }
301+ where := s.cfg.Endpoint
302+ if where == "" {
303+ where = s.cfg.Region
304+ }
305+ return fmt.Errorf("bucket %q is not reachable at %s: %w", s.cfg.Bucket, where, e)
306+}
307+
308+type part struct {
309+ etag string
310+ size int64
311+}
312+type upload struct {
313+ s *Store
314+ key, id string
315+ parts map[int]part
316+ done bool
317+}
318+
319+// NewMultipart initiates an object upload.
320+func (s *Store) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) {
321+ logEntry := zerowrap.FromCtx(ctx)
322+ logEntry.Debug().Msg("blobs3: NewMultipart")
323+ if e := valid(key); e != nil {
324+ return nil, e
325+ }
326+ o, e := s.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key)})
327+ if e != nil {
328+ return nil, translate(e)
329+ }
330+ return &upload{s: s, key: key, id: aws.ToString(o.UploadId), parts: map[int]part{}}, nil
331+}
332+func (u *upload) ID() string { return u.id }
333+func (u *upload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error {
334+ logEntry := zerowrap.FromCtx(ctx)
335+ logEntry.Debug().Msg("blobs3: UploadPart")
336+ if u.done || n < 1 || n > ports.BlobMaxParts || size < 0 || size > ports.BlobPartSize {
337+ return domain.ErrInvalid
338+ }
339+ if e := ctx.Err(); e != nil {
340+ return e
341+ }
342+ var body io.ReadSeeker
343+ if seeker, ok := r.(io.ReadSeeker); ok {
344+ start, e := seeker.Seek(0, io.SeekCurrent)
345+ if e != nil {
346+ return e
347+ }
348+ end, e := seeker.Seek(0, io.SeekEnd)
349+ if e != nil {
350+ return e
351+ }
352+ if _, e = seeker.Seek(start, io.SeekStart); e != nil {
353+ return e
354+ }
355+ if end-start != size {
356+ return domain.ErrInvalid
357+ }
358+ body = seeker
359+ } else {
360+ bp := buffers.Get().(*[]byte)
361+ defer buffers.Put(bp)
362+ b := (*bp)[:int(size)]
363+ if e := readPart(ctx, r, b); e != nil {
364+ return e
365+ }
366+ if e := exactEnd(ctx, r); e != nil {
367+ return e
368+ }
369+ body = bytes.NewReader(b)
370+ }
371+ o, e := u.s.client.UploadPart(ctx, &s3.UploadPartInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), PartNumber: aws.Int32(int32(n)), Body: body, ContentLength: aws.Int64(size)})
372+ if e != nil {
373+ return translate(e)
374+ }
375+ u.parts[n] = part{aws.ToString(o.ETag), size}
376+ log := zerowrap.FromCtx(ctx)
377+ log.Debug().Int("part", n).Msg("blob part uploaded")
378+ return nil
379+}
380+func (u *upload) Complete(ctx context.Context) (ports.BlobInfo, error) {
381+ logEntry := zerowrap.FromCtx(ctx)
382+ logEntry.Debug().Msg("blobs3: Complete")
383+ if u.done {
384+ return ports.BlobInfo{}, domain.ErrInvalid
385+ }
386+ u.done = true
387+ var nums []int
388+ for n := range u.parts {
389+ nums = append(nums, n)
390+ }
391+ sort.Ints(nums)
392+ var parts []types.CompletedPart
393+ var size int64
394+ invalid := len(nums) == 0
395+ for i, n := range nums {
396+ p := u.parts[n]
397+ if i < len(nums)-1 && p.size < ports.BlobMinPartSize {
398+ invalid = true
399+ }
400+ size += p.size
401+ parts = append(parts, types.CompletedPart{PartNumber: aws.Int32(int32(n)), ETag: aws.String(p.etag)})
402+ }
403+ if invalid {
404+ _ = u.s.AbortMultipart(ctx, u.key, u.id)
405+ return ports.BlobInfo{}, domain.ErrInvalid
406+ }
407+ _, e := u.s.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{Bucket: aws.String(u.s.cfg.Bucket), Key: u.s.key(u.key), UploadId: aws.String(u.id), MultipartUpload: &types.CompletedMultipartUpload{Parts: parts}})
408+ if e != nil {
409+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
410+ defer cancel()
411+ _ = u.s.AbortMultipart(cleanup, u.key, u.id)
412+ return ports.BlobInfo{}, translate(e)
413+ }
414+ return ports.BlobInfo{Key: u.key, Size: size, ModTime: time.Now().UTC()}, nil
415+}
416+func (u *upload) Abort(ctx context.Context) error {
417+ logEntry := zerowrap.FromCtx(ctx)
418+ logEntry.Debug().Msg("blobs3: Abort")
419+ if u.done {
420+ return domain.ErrInvalid
421+ }
422+ u.done = true
423+ return u.s.AbortMultipart(ctx, u.key, u.id)
424+}
425+
426+// AbortMultipart discards a listed upload.
427+func (s *Store) AbortMultipart(ctx context.Context, key, id string) error {
428+ logEntry := zerowrap.FromCtx(ctx)
429+ logEntry.Debug().Msg("blobs3: AbortMultipart")
430+ if e := valid(key); e != nil {
431+ return e
432+ }
433+ _, e := s.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{Bucket: aws.String(s.cfg.Bucket), Key: s.key(key), UploadId: aws.String(id)})
434+ if status(e) == 404 {
435+ return nil
436+ }
437+ return translate(e)
438+}
439+
440+// ListMultipart visits unfinished uploads matching prefix.
441+func (s *Store) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error {
442+ logEntry := zerowrap.FromCtx(ctx)
443+ logEntry.Debug().Msg("blobs3: ListMultipart")
444+ input := &s3.ListMultipartUploadsInput{Bucket: aws.String(s.cfg.Bucket), Prefix: s.key(prefix)}
445+ for {
446+ o, e := s.client.ListMultipartUploads(ctx, input)
447+ if e != nil {
448+ return translate(e)
449+ }
450+ for _, v := range o.Uploads {
451+ if e = fn(ports.MultipartInfo{Key: strings.TrimPrefix(aws.ToString(v.Key), s.cfg.Prefix), ID: aws.ToString(v.UploadId), Started: aws.ToTime(v.Initiated)}); e != nil {
452+ return e
453+ }
454+ }
455+ if !aws.ToBool(o.IsTruncated) {
456+ return nil
457+ }
458+ input.KeyMarker = o.NextKeyMarker
459+ input.UploadIdMarker = o.NextUploadIdMarker
460+ }
461+}
A internal/adapters/blobs3/blobs3_test.go +85 -0
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/johannesboyne/gofakes3"
15+ "github.com/johannesboyne/gofakes3/backend/s3mem"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func fake(t *testing.T) (*blobs3.Store, *s3mem.Backend) {
20+ t.Helper()
21+ backend := s3mem.New()
22+ require.NoError(t, backend.CreateBucket("b"))
23+ handler := gofakes3.New(backend).Server()
24+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+ t.Logf("S3 operation: %s %s", r.Method, r.URL.RequestURI())
26+ for _, q := range []string{"policy", "acl", "tagging", "lifecycle", "versioning"} {
27+ if r.URL.Query().Has(q) {
28+ t.Errorf("unexpected admin operation %s", r.URL)
29+ }
30+ }
31+ if r.Method == http.MethodPut && strings.Trim(r.URL.Path, "/") == "b" {
32+ t.Error("unexpected bucket creation")
33+ }
34+ if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/b/") {
35+ if r.Header.Get("X-Amz-Content-Sha256") == "UNSIGNED-PAYLOAD" || r.Header.Get("X-Amz-Content-Sha256") == "" {
36+ t.Error("payload must be signed")
37+ }
38+ }
39+ handler.ServeHTTP(w, r)
40+ }))
41+ t.Cleanup(srv.Close)
42+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "/pfx/", Endpoint: srv.URL, Region: "us-east-1", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
43+ require.NoError(t, e)
44+ return s, backend
45+}
46+func TestContract(t *testing.T) {
47+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { s, _ := fake(t); return s })
48+}
49+func TestPrefix(t *testing.T) {
50+ s, b := fake(t)
51+ _, e := s.Put(context.Background(), "ci/one", strings.NewReader("x"), 1)
52+ require.NoError(t, e)
53+ objects, e := b.ListBucket("b", nil, gofakes3.ListBucketPage{})
54+ require.NoError(t, e)
55+ require.Len(t, objects.Contents, 1)
56+ require.Equal(t, "pfx/ci/one", objects.Contents[0].Key)
57+}
58+func TestMissingBucket(t *testing.T) {
59+ s, b := fake(t)
60+ require.NoError(t, b.DeleteBucket("b"))
61+ e := s.Check(context.Background())
62+ require.ErrorContains(t, e, `bucket "b" is not reachable at http://`)
63+ require.NotContains(t, e.Error(), "test-secret")
64+}
65+
66+func TestCheckDeniedHeadFallsBack(t *testing.T) {
67+ var listed bool
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ if r.Method == http.MethodHead {
70+ w.WriteHeader(http.StatusForbidden)
71+ return
72+ }
73+ if r.Method != http.MethodGet || r.URL.Query().Get("list-type") != "2" || r.URL.Query().Get("max-keys") != "1" || r.URL.Query().Get("prefix") != "pfx/" {
74+ t.Errorf("unexpected probe: %s %s", r.Method, r.URL)
75+ }
76+ listed = true
77+ w.Header().Set("Content-Type", "application/xml")
78+ _, _ = w.Write([]byte(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><IsTruncated>false</IsTruncated></ListBucketResult>`))
79+ }))
80+ defer srv.Close()
81+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "pfx", Endpoint: srv.URL, Region: "test-region", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
82+ require.NoError(t, e)
83+ require.NoError(t, s.Check(context.Background()))
84+ require.True(t, listed)
85+}
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/johannesboyne/gofakes3"
15+ "github.com/johannesboyne/gofakes3/backend/s3mem"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func fake(t *testing.T) (*blobs3.Store, *s3mem.Backend) {
20+ t.Helper()
21+ backend := s3mem.New()
22+ require.NoError(t, backend.CreateBucket("b"))
23+ handler := gofakes3.New(backend).Server()
24+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+ t.Logf("S3 operation: %s %s", r.Method, r.URL.RequestURI())
26+ for _, q := range []string{"policy", "acl", "tagging", "lifecycle", "versioning"} {
27+ if r.URL.Query().Has(q) {
28+ t.Errorf("unexpected admin operation %s", r.URL)
29+ }
30+ }
31+ if r.Method == http.MethodPut && strings.Trim(r.URL.Path, "/") == "b" {
32+ t.Error("unexpected bucket creation")
33+ }
34+ if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/b/") {
35+ if r.Header.Get("X-Amz-Content-Sha256") == "UNSIGNED-PAYLOAD" || r.Header.Get("X-Amz-Content-Sha256") == "" {
36+ t.Error("payload must be signed")
37+ }
38+ }
39+ handler.ServeHTTP(w, r)
40+ }))
41+ t.Cleanup(srv.Close)
42+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "/pfx/", Endpoint: srv.URL, Region: "us-east-1", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
43+ require.NoError(t, e)
44+ return s, backend
45+}
46+func TestContract(t *testing.T) {
47+ blobtest.Run(t, func(t *testing.T) ports.BlobStore { s, _ := fake(t); return s })
48+}
49+func TestPrefix(t *testing.T) {
50+ s, b := fake(t)
51+ _, e := s.Put(context.Background(), "ci/one", strings.NewReader("x"), 1)
52+ require.NoError(t, e)
53+ objects, e := b.ListBucket("b", nil, gofakes3.ListBucketPage{})
54+ require.NoError(t, e)
55+ require.Len(t, objects.Contents, 1)
56+ require.Equal(t, "pfx/ci/one", objects.Contents[0].Key)
57+}
58+func TestMissingBucket(t *testing.T) {
59+ s, b := fake(t)
60+ require.NoError(t, b.DeleteBucket("b"))
61+ e := s.Check(context.Background())
62+ require.ErrorContains(t, e, `bucket "b" is not reachable at http://`)
63+ require.NotContains(t, e.Error(), "test-secret")
64+}
65+
66+func TestCheckDeniedHeadFallsBack(t *testing.T) {
67+ var listed bool
68+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+ if r.Method == http.MethodHead {
70+ w.WriteHeader(http.StatusForbidden)
71+ return
72+ }
73+ if r.Method != http.MethodGet || r.URL.Query().Get("list-type") != "2" || r.URL.Query().Get("max-keys") != "1" || r.URL.Query().Get("prefix") != "pfx/" {
74+ t.Errorf("unexpected probe: %s %s", r.Method, r.URL)
75+ }
76+ listed = true
77+ w.Header().Set("Content-Type", "application/xml")
78+ _, _ = w.Write([]byte(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><IsTruncated>false</IsTruncated></ListBucketResult>`))
79+ }))
80+ defer srv.Close()
81+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: "b", Prefix: "pfx", Endpoint: srv.URL, Region: "test-region", AccessKeyID: "test", SecretAccessKey: "test-secret", ForcePathStyle: true})
82+ require.NoError(t, e)
83+ require.NoError(t, s.Check(context.Background()))
84+ require.True(t, listed)
85+}
A internal/adapters/blobs3/config.go +13 -0
@@ -0,0 +1,13 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3
3+
4+import "net/http"
5+
6+// Config supplies explicit credentials and endpoint settings; no ambient
7+// credential source is consulted.
8+type Config struct {
9+ Bucket, Prefix, Endpoint, Region, AccessKeyID, SecretAccessKey string
10+ ForcePathStyle bool
11+ // HTTPClient optionally overrides the transport (primarily for tests).
12+ HTTPClient *http.Client
13+}
@@ -0,0 +1,13 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3
3+
4+import "net/http"
5+
6+// Config supplies explicit credentials and endpoint settings; no ambient
7+// credential source is consulted.
8+type Config struct {
9+ Bucket, Prefix, Endpoint, Region, AccessKeyID, SecretAccessKey string
10+ ForcePathStyle bool
11+ // HTTPClient optionally overrides the transport (primarily for tests).
12+ HTTPClient *http.Client
13+}
A internal/adapters/blobs3/integration_test.go +44 -0
@@ -0,0 +1,44 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "os"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestIntegration_Contract(t *testing.T) {
18+ get := func(k string) string { return os.Getenv("ERMITE_TEST_S3_" + k) }
19+ if get("BUCKET") == "" || get("ACCESS_KEY_ID") == "" || get("SECRET_ACCESS_KEY") == "" {
20+ t.Skip("set ERMITE_TEST_S3_* to run")
21+ }
22+ region := get("REGION")
23+ if region == "" {
24+ region = "us-east-1"
25+ }
26+ var opts []blobtest.Option
27+ // MinIO lists unfinished uploads by whole key only.
28+ if get("EXACT_KEY_UPLOAD_LIST") == "1" {
29+ opts = append(opts, blobtest.ExactKeyUploadList())
30+ }
31+ blobtest.Run(t, func(t *testing.T) ports.BlobStore {
32+ b := make([]byte, 16)
33+ _, e := rand.Read(b)
34+ require.NoError(t, e)
35+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: get("BUCKET"), Endpoint: get("ENDPOINT"), Region: region, AccessKeyID: get("ACCESS_KEY_ID"), SecretAccessKey: get("SECRET_ACCESS_KEY"), ForcePathStyle: get("FORCE_PATH_STYLE") == "1", Prefix: "ermite-test/" + hex.EncodeToString(b) + "/"})
36+ require.NoError(t, e)
37+ t.Cleanup(func() {
38+ ctx := context.Background()
39+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { return s.Delete(ctx, i.Key) }))
40+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) }))
41+ })
42+ return s
43+ }, opts...)
44+}
@@ -0,0 +1,44 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package blobs3_test
3+
4+import (
5+ "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "os"
9+ "testing"
10+
11+ "bnema.dev/ermite/internal/adapters/blobs3"
12+ "bnema.dev/ermite/internal/adapters/blobtest"
13+ "bnema.dev/ermite/internal/ports"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestIntegration_Contract(t *testing.T) {
18+ get := func(k string) string { return os.Getenv("ERMITE_TEST_S3_" + k) }
19+ if get("BUCKET") == "" || get("ACCESS_KEY_ID") == "" || get("SECRET_ACCESS_KEY") == "" {
20+ t.Skip("set ERMITE_TEST_S3_* to run")
21+ }
22+ region := get("REGION")
23+ if region == "" {
24+ region = "us-east-1"
25+ }
26+ var opts []blobtest.Option
27+ // MinIO lists unfinished uploads by whole key only.
28+ if get("EXACT_KEY_UPLOAD_LIST") == "1" {
29+ opts = append(opts, blobtest.ExactKeyUploadList())
30+ }
31+ blobtest.Run(t, func(t *testing.T) ports.BlobStore {
32+ b := make([]byte, 16)
33+ _, e := rand.Read(b)
34+ require.NoError(t, e)
35+ s, e := blobs3.New(context.Background(), blobs3.Config{Bucket: get("BUCKET"), Endpoint: get("ENDPOINT"), Region: region, AccessKeyID: get("ACCESS_KEY_ID"), SecretAccessKey: get("SECRET_ACCESS_KEY"), ForcePathStyle: get("FORCE_PATH_STYLE") == "1", Prefix: "ermite-test/" + hex.EncodeToString(b) + "/"})
36+ require.NoError(t, e)
37+ t.Cleanup(func() {
38+ ctx := context.Background()
39+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { return s.Delete(ctx, i.Key) }))
40+ require.NoError(t, s.ListMultipart(ctx, "", func(i ports.MultipartInfo) error { return s.AbortMultipart(ctx, i.Key, i.ID) }))
41+ })
42+ return s
43+ }, opts...)
44+}
A internal/adapters/blobtest/suite.go +256 -0
@@ -0,0 +1,256 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobtest provides the shared BlobStore contract suite.
4+package blobtest
5+
6+import (
7+ "bytes"
8+ "context"
9+ "crypto/sha256"
10+ "encoding/hex"
11+ "errors"
12+ "io"
13+ "strings"
14+ "testing"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+// Option adapts Run to a provider quirk.
22+type Option func(*options)
23+
24+type options struct{ exactKeyUploadList bool }
25+
26+// ExactKeyUploadList is for providers (MinIO) that list unfinished multipart
27+// uploads only when the prefix is a whole object key: the suite then lists by
28+// key instead of by directory prefix.
29+func ExactKeyUploadList() Option { return func(o *options) { o.exactKeyUploadList = true } }
30+
31+// Run checks the blob contract against a fresh store for each subtest.
32+func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore, opts ...Option) {
33+ ctx := context.Background()
34+ var o options
35+ for _, opt := range opts {
36+ opt(&o)
37+ }
38+ // uploadPrefix is the ListMultipart prefix for key: dir, or key itself
39+ // on a provider that lists by whole key only.
40+ uploadPrefix := func(dir, key string) string {
41+ if o.exactKeyUploadList {
42+ return key
43+ }
44+ return dir
45+ }
46+ t.Run("roundtrip", func(t *testing.T) {
47+ s := newStore(t)
48+ require.NoError(t, s.Check(ctx))
49+ data := []byte("abcdefghij")
50+ info, e := s.Put(ctx, "ci/roundtrip", bytes.NewReader(data), int64(len(data)))
51+ require.NoError(t, e)
52+ h := sha256.Sum256(data)
53+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
54+ info, e = s.Stat(ctx, "ci/roundtrip")
55+ require.NoError(t, e)
56+ require.EqualValues(t, len(data), info.Size)
57+ require.False(t, info.ModTime.IsZero())
58+ for _, r := range []struct {
59+ off, len int64
60+ want string
61+ }{{0, -1, "abcdefghij"}, {0, 3, "abc"}, {3, 4, "defg"}, {9, -1, "j"}, {3, 0, ""}} {
62+ rc, meta, e := s.Open(ctx, "ci/roundtrip", r.off, r.len)
63+ require.NoError(t, e)
64+ b, e := io.ReadAll(rc)
65+ require.NoError(t, e)
66+ require.NoError(t, rc.Close())
67+ require.Equal(t, r.want, string(b))
68+ require.EqualValues(t, 10, meta.Size)
69+ }
70+ for _, off := range []int64{10, 11} {
71+ _, _, e := s.Open(ctx, "ci/roundtrip", off, -1)
72+ require.ErrorIs(t, e, domain.ErrOutOfRange)
73+ }
74+ _, e = s.Put(ctx, "ci/roundtrip", strings.NewReader("new"), 3)
75+ require.NoError(t, e)
76+ rc, _, e := s.Open(ctx, "ci/roundtrip", 0, -1)
77+ require.NoError(t, e)
78+ b, e := io.ReadAll(rc)
79+ require.NoError(t, e)
80+ _ = rc.Close()
81+ require.Equal(t, "new", string(b))
82+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
83+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
84+ _, e = s.Stat(ctx, "ci/roundtrip")
85+ require.ErrorIs(t, e, domain.ErrNotFound)
86+ _, _, e = s.Open(ctx, "ci/absent", 0, -1)
87+ require.ErrorIs(t, e, domain.ErrNotFound)
88+ })
89+ t.Run("list_and_reserved_names", func(t *testing.T) {
90+ s := newStore(t)
91+ keys := []string{"a.parts/b", "a/.tmp-object", "ci/z", "ci/a"}
92+ for _, k := range keys {
93+ _, e := s.Put(ctx, k, strings.NewReader(k), int64(len(k)))
94+ require.NoError(t, e)
95+ r, _, e := s.Open(ctx, k, 0, -1)
96+ require.NoError(t, e)
97+ b, e := io.ReadAll(r)
98+ _ = r.Close()
99+ require.NoError(t, e)
100+ require.Equal(t, k, string(b))
101+ }
102+ var got []string
103+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
104+ require.Equal(t, []string{"a.parts/b", "a/.tmp-object", "ci/a", "ci/z"}, got)
105+ got = nil
106+ require.NoError(t, s.List(ctx, "ci/", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
107+ require.Equal(t, []string{"ci/a", "ci/z"}, got)
108+ stop := errors.New("stop")
109+ calls := 0
110+ require.ErrorIs(t, s.List(ctx, "ci/", func(ports.BlobInfo) error { calls++; return stop }), stop)
111+ require.Equal(t, 1, calls)
112+ })
113+ t.Run("invalid", func(t *testing.T) {
114+ s := newStore(t)
115+ for _, key := range []string{"", "/a", "a//b", "a/../b", "a/./b", "é", strings.Repeat("a", 513)} {
116+ _, e := s.Put(ctx, key, strings.NewReader("x"), 1)
117+ require.ErrorIs(t, e, domain.ErrInvalid)
118+ _, e = s.Stat(ctx, key)
119+ require.ErrorIs(t, e, domain.ErrInvalid)
120+ _, _, e = s.Open(ctx, key, 0, -1)
121+ require.ErrorIs(t, e, domain.ErrInvalid)
122+ require.ErrorIs(t, s.Delete(ctx, key), domain.ErrInvalid)
123+ _, e = s.NewMultipart(ctx, key)
124+ require.ErrorIs(t, e, domain.ErrInvalid)
125+ require.ErrorIs(t, s.AbortMultipart(ctx, key, "id"), domain.ErrInvalid)
126+ }
127+ _, e := s.Put(ctx, "ci/negative", strings.NewReader(""), -1)
128+ require.ErrorIs(t, e, domain.ErrInvalid)
129+ })
130+ t.Run("multipart", func(t *testing.T) {
131+ s := newStore(t)
132+ u, e := s.NewMultipart(ctx, "ci/multi")
133+ require.NoError(t, e)
134+ var all []byte
135+ for n := 1; n <= 4; n++ {
136+ size := ports.BlobMinPartSize
137+ if n == 4 {
138+ size = 1024
139+ }
140+ data := bytes.Repeat([]byte{byte(n)}, size)
141+ all = append(all, data...)
142+ require.NoError(t, u.UploadPart(ctx, n, bytes.NewReader(data), int64(size)))
143+ }
144+ info, e := u.Complete(ctx)
145+ require.NoError(t, e)
146+ require.Empty(t, info.Digest)
147+ r, _, e := s.Open(ctx, "ci/multi", 0, -1)
148+ require.NoError(t, e)
149+ b, e := io.ReadAll(r)
150+ _ = r.Close()
151+ require.NoError(t, e)
152+ require.Equal(t, all, b)
153+ })
154+ t.Run("abort", func(t *testing.T) {
155+ s := newStore(t)
156+ for _, byID := range []bool{false, true} {
157+ u, e := s.NewMultipart(ctx, "ci/abort")
158+ require.NoError(t, e)
159+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
160+ var uploads []ports.MultipartInfo
161+ require.NoError(t, s.ListMultipart(ctx, uploadPrefix("ci/", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
162+ require.Len(t, uploads, 1)
163+ require.Equal(t, u.ID(), uploads[0].ID)
164+ require.Equal(t, "ci/abort", uploads[0].Key)
165+ require.False(t, uploads[0].Started.IsZero())
166+ if byID {
167+ require.NoError(t, s.AbortMultipart(ctx, "ci/abort", u.ID()))
168+ } else {
169+ require.NoError(t, u.Abort(ctx))
170+ }
171+ _, e = s.Stat(ctx, "ci/abort")
172+ require.ErrorIs(t, e, domain.ErrNotFound)
173+ uploads = nil
174+ require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
175+ require.Empty(t, uploads)
176+ }
177+ })
178+ t.Run("undersized_part", func(t *testing.T) {
179+ s := newStore(t)
180+ u, e := s.NewMultipart(ctx, "ci/small")
181+ require.NoError(t, e)
182+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
183+ require.NoError(t, u.UploadPart(ctx, 2, strings.NewReader("y"), 1))
184+ _, e = u.Complete(ctx)
185+ require.ErrorIs(t, e, domain.ErrInvalid)
186+ _, e = s.Stat(ctx, "ci/small")
187+ require.ErrorIs(t, e, domain.ErrNotFound)
188+ })
189+ t.Run("failed_put", func(t *testing.T) {
190+ s := newStore(t)
191+ _, e := s.Put(ctx, "ci/fail", io.MultiReader(strings.NewReader("abc"), failReader{}), 10)
192+ require.Error(t, e)
193+ _, e = s.Stat(ctx, "ci/fail")
194+ require.ErrorIs(t, e, domain.ErrNotFound)
195+ for _, v := range []struct {
196+ data string
197+ size int64
198+ }{{"a", 2}, {"abc", 2}} {
199+ _, e = s.Put(ctx, "ci/size", strings.NewReader(v.data), v.size)
200+ require.ErrorIs(t, e, domain.ErrInvalid)
201+ _, e = s.Stat(ctx, "ci/size")
202+ require.ErrorIs(t, e, domain.ErrNotFound)
203+ }
204+ })
205+ t.Run("large_put", func(t *testing.T) {
206+ s := newStore(t)
207+ data := bytes.Repeat([]byte("large"), ports.BlobPartSize/5+100)
208+ info, e := s.Put(ctx, "ci/large", bytes.NewBuffer(data), int64(len(data)))
209+ require.NoError(t, e)
210+ h := sha256.Sum256(data)
211+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
212+ r, _, e := s.Open(ctx, "ci/large", 0, -1)
213+ require.NoError(t, e)
214+ got, e := io.ReadAll(r)
215+ require.NoError(t, e)
216+ require.NoError(t, r.Close())
217+ require.Equal(t, data, got)
218+ for _, size := range []int64{int64(len(data) - 1), int64(len(data) + 1)} {
219+ _, e = s.Put(ctx, "ci/badlarge", bytes.NewBuffer(data), size)
220+ require.ErrorIs(t, e, domain.ErrInvalid)
221+ _, e = s.Stat(ctx, "ci/badlarge")
222+ require.ErrorIs(t, e, domain.ErrNotFound)
223+ }
224+ require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/badlarge"), func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil }))
225+ })
226+
227+ t.Run("cancel_put", func(t *testing.T) {
228+ s := newStore(t)
229+ c, cancel := context.WithCancel(ctx)
230+ defer cancel()
231+ r := &cancelReader{cancel: cancel}
232+ _, e := s.Put(c, "ci/cancel", r, 1024)
233+ require.ErrorIs(t, e, context.Canceled)
234+ _, e = s.Stat(ctx, "ci/cancel")
235+ require.ErrorIs(t, e, domain.ErrNotFound)
236+ })
237+}
238+
239+type failReader struct{}
240+
241+func (failReader) Read([]byte) (int, error) { return 0, errors.New("reader failed") }
242+
243+type cancelReader struct {
244+ cancel context.CancelFunc
245+ done bool
246+}
247+
248+func (r *cancelReader) Read(p []byte) (int, error) {
249+ if r.done {
250+ return 0, context.Canceled
251+ }
252+ r.done = true
253+ r.cancel()
254+ p[0] = 'x'
255+ return 1, nil
256+}
@@ -0,0 +1,256 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package blobtest provides the shared BlobStore contract suite.
4+package blobtest
5+
6+import (
7+ "bytes"
8+ "context"
9+ "crypto/sha256"
10+ "encoding/hex"
11+ "errors"
12+ "io"
13+ "strings"
14+ "testing"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+// Option adapts Run to a provider quirk.
22+type Option func(*options)
23+
24+type options struct{ exactKeyUploadList bool }
25+
26+// ExactKeyUploadList is for providers (MinIO) that list unfinished multipart
27+// uploads only when the prefix is a whole object key: the suite then lists by
28+// key instead of by directory prefix.
29+func ExactKeyUploadList() Option { return func(o *options) { o.exactKeyUploadList = true } }
30+
31+// Run checks the blob contract against a fresh store for each subtest.
32+func Run(t *testing.T, newStore func(*testing.T) ports.BlobStore, opts ...Option) {
33+ ctx := context.Background()
34+ var o options
35+ for _, opt := range opts {
36+ opt(&o)
37+ }
38+ // uploadPrefix is the ListMultipart prefix for key: dir, or key itself
39+ // on a provider that lists by whole key only.
40+ uploadPrefix := func(dir, key string) string {
41+ if o.exactKeyUploadList {
42+ return key
43+ }
44+ return dir
45+ }
46+ t.Run("roundtrip", func(t *testing.T) {
47+ s := newStore(t)
48+ require.NoError(t, s.Check(ctx))
49+ data := []byte("abcdefghij")
50+ info, e := s.Put(ctx, "ci/roundtrip", bytes.NewReader(data), int64(len(data)))
51+ require.NoError(t, e)
52+ h := sha256.Sum256(data)
53+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
54+ info, e = s.Stat(ctx, "ci/roundtrip")
55+ require.NoError(t, e)
56+ require.EqualValues(t, len(data), info.Size)
57+ require.False(t, info.ModTime.IsZero())
58+ for _, r := range []struct {
59+ off, len int64
60+ want string
61+ }{{0, -1, "abcdefghij"}, {0, 3, "abc"}, {3, 4, "defg"}, {9, -1, "j"}, {3, 0, ""}} {
62+ rc, meta, e := s.Open(ctx, "ci/roundtrip", r.off, r.len)
63+ require.NoError(t, e)
64+ b, e := io.ReadAll(rc)
65+ require.NoError(t, e)
66+ require.NoError(t, rc.Close())
67+ require.Equal(t, r.want, string(b))
68+ require.EqualValues(t, 10, meta.Size)
69+ }
70+ for _, off := range []int64{10, 11} {
71+ _, _, e := s.Open(ctx, "ci/roundtrip", off, -1)
72+ require.ErrorIs(t, e, domain.ErrOutOfRange)
73+ }
74+ _, e = s.Put(ctx, "ci/roundtrip", strings.NewReader("new"), 3)
75+ require.NoError(t, e)
76+ rc, _, e := s.Open(ctx, "ci/roundtrip", 0, -1)
77+ require.NoError(t, e)
78+ b, e := io.ReadAll(rc)
79+ require.NoError(t, e)
80+ _ = rc.Close()
81+ require.Equal(t, "new", string(b))
82+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
83+ require.NoError(t, s.Delete(ctx, "ci/roundtrip"))
84+ _, e = s.Stat(ctx, "ci/roundtrip")
85+ require.ErrorIs(t, e, domain.ErrNotFound)
86+ _, _, e = s.Open(ctx, "ci/absent", 0, -1)
87+ require.ErrorIs(t, e, domain.ErrNotFound)
88+ })
89+ t.Run("list_and_reserved_names", func(t *testing.T) {
90+ s := newStore(t)
91+ keys := []string{"a.parts/b", "a/.tmp-object", "ci/z", "ci/a"}
92+ for _, k := range keys {
93+ _, e := s.Put(ctx, k, strings.NewReader(k), int64(len(k)))
94+ require.NoError(t, e)
95+ r, _, e := s.Open(ctx, k, 0, -1)
96+ require.NoError(t, e)
97+ b, e := io.ReadAll(r)
98+ _ = r.Close()
99+ require.NoError(t, e)
100+ require.Equal(t, k, string(b))
101+ }
102+ var got []string
103+ require.NoError(t, s.List(ctx, "", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
104+ require.Equal(t, []string{"a.parts/b", "a/.tmp-object", "ci/a", "ci/z"}, got)
105+ got = nil
106+ require.NoError(t, s.List(ctx, "ci/", func(i ports.BlobInfo) error { got = append(got, i.Key); return nil }))
107+ require.Equal(t, []string{"ci/a", "ci/z"}, got)
108+ stop := errors.New("stop")
109+ calls := 0
110+ require.ErrorIs(t, s.List(ctx, "ci/", func(ports.BlobInfo) error { calls++; return stop }), stop)
111+ require.Equal(t, 1, calls)
112+ })
113+ t.Run("invalid", func(t *testing.T) {
114+ s := newStore(t)
115+ for _, key := range []string{"", "/a", "a//b", "a/../b", "a/./b", "é", strings.Repeat("a", 513)} {
116+ _, e := s.Put(ctx, key, strings.NewReader("x"), 1)
117+ require.ErrorIs(t, e, domain.ErrInvalid)
118+ _, e = s.Stat(ctx, key)
119+ require.ErrorIs(t, e, domain.ErrInvalid)
120+ _, _, e = s.Open(ctx, key, 0, -1)
121+ require.ErrorIs(t, e, domain.ErrInvalid)
122+ require.ErrorIs(t, s.Delete(ctx, key), domain.ErrInvalid)
123+ _, e = s.NewMultipart(ctx, key)
124+ require.ErrorIs(t, e, domain.ErrInvalid)
125+ require.ErrorIs(t, s.AbortMultipart(ctx, key, "id"), domain.ErrInvalid)
126+ }
127+ _, e := s.Put(ctx, "ci/negative", strings.NewReader(""), -1)
128+ require.ErrorIs(t, e, domain.ErrInvalid)
129+ })
130+ t.Run("multipart", func(t *testing.T) {
131+ s := newStore(t)
132+ u, e := s.NewMultipart(ctx, "ci/multi")
133+ require.NoError(t, e)
134+ var all []byte
135+ for n := 1; n <= 4; n++ {
136+ size := ports.BlobMinPartSize
137+ if n == 4 {
138+ size = 1024
139+ }
140+ data := bytes.Repeat([]byte{byte(n)}, size)
141+ all = append(all, data...)
142+ require.NoError(t, u.UploadPart(ctx, n, bytes.NewReader(data), int64(size)))
143+ }
144+ info, e := u.Complete(ctx)
145+ require.NoError(t, e)
146+ require.Empty(t, info.Digest)
147+ r, _, e := s.Open(ctx, "ci/multi", 0, -1)
148+ require.NoError(t, e)
149+ b, e := io.ReadAll(r)
150+ _ = r.Close()
151+ require.NoError(t, e)
152+ require.Equal(t, all, b)
153+ })
154+ t.Run("abort", func(t *testing.T) {
155+ s := newStore(t)
156+ for _, byID := range []bool{false, true} {
157+ u, e := s.NewMultipart(ctx, "ci/abort")
158+ require.NoError(t, e)
159+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
160+ var uploads []ports.MultipartInfo
161+ require.NoError(t, s.ListMultipart(ctx, uploadPrefix("ci/", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
162+ require.Len(t, uploads, 1)
163+ require.Equal(t, u.ID(), uploads[0].ID)
164+ require.Equal(t, "ci/abort", uploads[0].Key)
165+ require.False(t, uploads[0].Started.IsZero())
166+ if byID {
167+ require.NoError(t, s.AbortMultipart(ctx, "ci/abort", u.ID()))
168+ } else {
169+ require.NoError(t, u.Abort(ctx))
170+ }
171+ _, e = s.Stat(ctx, "ci/abort")
172+ require.ErrorIs(t, e, domain.ErrNotFound)
173+ uploads = nil
174+ require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/abort"), func(i ports.MultipartInfo) error { uploads = append(uploads, i); return nil }))
175+ require.Empty(t, uploads)
176+ }
177+ })
178+ t.Run("undersized_part", func(t *testing.T) {
179+ s := newStore(t)
180+ u, e := s.NewMultipart(ctx, "ci/small")
181+ require.NoError(t, e)
182+ require.NoError(t, u.UploadPart(ctx, 1, strings.NewReader("x"), 1))
183+ require.NoError(t, u.UploadPart(ctx, 2, strings.NewReader("y"), 1))
184+ _, e = u.Complete(ctx)
185+ require.ErrorIs(t, e, domain.ErrInvalid)
186+ _, e = s.Stat(ctx, "ci/small")
187+ require.ErrorIs(t, e, domain.ErrNotFound)
188+ })
189+ t.Run("failed_put", func(t *testing.T) {
190+ s := newStore(t)
191+ _, e := s.Put(ctx, "ci/fail", io.MultiReader(strings.NewReader("abc"), failReader{}), 10)
192+ require.Error(t, e)
193+ _, e = s.Stat(ctx, "ci/fail")
194+ require.ErrorIs(t, e, domain.ErrNotFound)
195+ for _, v := range []struct {
196+ data string
197+ size int64
198+ }{{"a", 2}, {"abc", 2}} {
199+ _, e = s.Put(ctx, "ci/size", strings.NewReader(v.data), v.size)
200+ require.ErrorIs(t, e, domain.ErrInvalid)
201+ _, e = s.Stat(ctx, "ci/size")
202+ require.ErrorIs(t, e, domain.ErrNotFound)
203+ }
204+ })
205+ t.Run("large_put", func(t *testing.T) {
206+ s := newStore(t)
207+ data := bytes.Repeat([]byte("large"), ports.BlobPartSize/5+100)
208+ info, e := s.Put(ctx, "ci/large", bytes.NewBuffer(data), int64(len(data)))
209+ require.NoError(t, e)
210+ h := sha256.Sum256(data)
211+ require.Equal(t, "sha256:"+hex.EncodeToString(h[:]), info.Digest)
212+ r, _, e := s.Open(ctx, "ci/large", 0, -1)
213+ require.NoError(t, e)
214+ got, e := io.ReadAll(r)
215+ require.NoError(t, e)
216+ require.NoError(t, r.Close())
217+ require.Equal(t, data, got)
218+ for _, size := range []int64{int64(len(data) - 1), int64(len(data) + 1)} {
219+ _, e = s.Put(ctx, "ci/badlarge", bytes.NewBuffer(data), size)
220+ require.ErrorIs(t, e, domain.ErrInvalid)
221+ _, e = s.Stat(ctx, "ci/badlarge")
222+ require.ErrorIs(t, e, domain.ErrNotFound)
223+ }
224+ require.NoError(t, s.ListMultipart(ctx, uploadPrefix("", "ci/badlarge"), func(ports.MultipartInfo) error { t.Error("unfinished upload after Put"); return nil }))
225+ })
226+
227+ t.Run("cancel_put", func(t *testing.T) {
228+ s := newStore(t)
229+ c, cancel := context.WithCancel(ctx)
230+ defer cancel()
231+ r := &cancelReader{cancel: cancel}
232+ _, e := s.Put(c, "ci/cancel", r, 1024)
233+ require.ErrorIs(t, e, context.Canceled)
234+ _, e = s.Stat(ctx, "ci/cancel")
235+ require.ErrorIs(t, e, domain.ErrNotFound)
236+ })
237+}
238+
239+type failReader struct{}
240+
241+func (failReader) Read([]byte) (int, error) { return 0, errors.New("reader failed") }
242+
243+type cancelReader struct {
244+ cancel context.CancelFunc
245+ done bool
246+}
247+
248+func (r *cancelReader) Read(p []byte) (int, error) {
249+ if r.done {
250+ return 0, context.Canceled
251+ }
252+ r.done = true
253+ r.cancel()
254+ p[0] = 'x'
255+ return 1, nil
256+}
M internal/adapters/ciartifacts/ciartifacts.go +5 -171
@@ -1,23 +1,12 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-// Package ciartifacts implements ports.CIArtifactFiles under
4-// $ERMITE_DATA_DIR/ci/artifacts, keyed by the global run id like the logs (a
5-// repository rename never moves them):
6-//
7-// <run:020d>/<id:020d> the content of a committed artifact
8-// <run:020d>/<id:020d>.blocks/<f> the staged blocks of a pending one
9-// <run:020d>/.tmp-<id:020d>-* a content being assembled
10-//
11-// Paths are made of ids and of block file names the use case builds from
12-// hexadecimal characters: an artifact name never reaches the file system.
13-// Files are 0600 in 0700 directories, synced before they count, and a content
14-// appears by rename, whole or not at all.
3+// Package ciartifacts reads and removes legacy artifacts recorded before the
4+// blob store (ADR 0034), under $ERMITE_DATA_DIR/ci/artifacts. Paths use ids
5+// only. The adapter is removed once all legacy artifacts have expired.
156 package ciartifacts
167
178 import (
189 "context"
19- "crypto/sha256"
20- "encoding/hex"
2110 "errors"
2211 "fmt"
2312 "io"
@@ -39,9 +28,6 @@ const (
3928 idFormat = "%020d"
4029 blocksSuffix = ".blocks"
4130 tmpPrefix = ".tmp-"
42- // maxFileName bounds a block file name (well under the 255 bytes of a
43- // name on Linux).
44- maxFileName = 200
4531 )
4632
4733 // Store is the file-backed artifact store.
@@ -54,15 +40,6 @@ func New(dir string) *Store { return &Store{root: dir} }
5440
5541 func validRef(ref ports.CIArtifactRef) bool { return ref.RunID > 0 && ref.ID > 0 }
5642
57-// validFile accepts the block file names of the use case: lowercase
58-// hexadecimal characters and dots, not starting with a dot.
59-func validFile(name string) bool {
60- if name == "" || len(name) > maxFileName || name[0] == '.' {
61- return false
62- }
63- return strings.Trim(name, "0123456789abcdef.") == ""
64-}
65-
6643 func (s *Store) runDir(runID int64) string {
6744 return filepath.Join(s.root, fmt.Sprintf(idFormat, runID))
6845 }
@@ -75,138 +52,6 @@ func (s *Store) blocksDir(ref ports.CIArtifactRef) string {
7552 return s.contentPath(ref) + blocksSuffix
7653 }
7754
78-// syncDir makes the entries of a directory durable.
79-func syncDir(dir string) error {
80- d, err := os.Open(dir)
81- if err != nil {
82- return err
83- }
84- serr := d.Sync()
85- return errors.Join(serr, d.Close())
86-}
87-
88-// ctxReader stops a copy when its context ends.
89-type ctxReader struct {
90- ctx context.Context
91- r io.Reader
92-}
93-
94-func (c ctxReader) Read(p []byte) (int, error) {
95- if err := c.ctx.Err(); err != nil {
96- return 0, err
97- }
98- return c.r.Read(p)
99-}
100-
101-// PutBlock writes a new block file, created exclusively.
102-func (s *Store) PutBlock(ctx context.Context, ref ports.CIArtifactRef, file string, src io.Reader) (int64, error) {
103- if !validRef(ref) || !validFile(file) {
104- return 0, fmt.Errorf("%w: block file %q", domain.ErrInvalid, file)
105- }
106- dir := s.blocksDir(ref)
107- if err := os.MkdirAll(dir, 0o700); err != nil {
108- return 0, fmt.Errorf("create block directory: %w", err)
109- }
110- path := filepath.Join(dir, file)
111- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
112- if err != nil {
113- return 0, fmt.Errorf("create block: %w", err)
114- }
115- n, werr := io.Copy(f, ctxReader{ctx, src})
116- var serr error
117- if werr == nil {
118- serr = f.Sync()
119- }
120- cerr := f.Close()
121- if err := errors.Join(werr, serr, cerr); err != nil {
122- _ = os.Remove(path)
123- if werr != nil {
124- return 0, werr // the source's error, as it is
125- }
126- return 0, fmt.Errorf("write block: %w", err)
127- }
128- if err := syncDir(dir); err != nil {
129- _ = os.Remove(path)
130- return 0, fmt.Errorf("sync block directory: %w", err)
131- }
132- return n, nil
133-}
134-
135-// RemoveBlock deletes one block file.
136-func (s *Store) RemoveBlock(_ context.Context, ref ports.CIArtifactRef, file string) error {
137- if !validRef(ref) || !validFile(file) {
138- return nil
139- }
140- if err := os.Remove(filepath.Join(s.blocksDir(ref), file)); err != nil && !errors.Is(err, fs.ErrNotExist) {
141- return fmt.Errorf("remove block: %w", err)
142- }
143- return nil
144-}
145-
146-// Assemble writes the blocks into a temporary file, syncs it and renames it
147-// over the content.
148-func (s *Store) Assemble(ctx context.Context, ref ports.CIArtifactRef, files []string) (int64, string, error) {
149- if !validRef(ref) {
150- return 0, "", domain.ErrNotFound
151- }
152- for _, f := range files {
153- if !validFile(f) {
154- return 0, "", fmt.Errorf("%w: block file %q", domain.ErrInvalid, f)
155- }
156- }
157- dir := s.runDir(ref.RunID)
158- if err := os.MkdirAll(dir, 0o700); err != nil {
159- return 0, "", fmt.Errorf("create artifact directory: %w", err)
160- }
161- out, err := os.CreateTemp(dir, tmpPrefix+fmt.Sprintf(idFormat, ref.ID)+"-*")
162- if err != nil {
163- return 0, "", fmt.Errorf("create artifact: %w", err)
164- }
165- tmp := out.Name()
166- h := sha256.New()
167- size, err := s.concat(ctx, ref, files, io.MultiWriter(out, h))
168- if err == nil {
169- err = out.Sync()
170- }
171- if cerr := out.Close(); err == nil {
172- err = cerr
173- }
174- if err == nil {
175- err = os.Rename(tmp, s.contentPath(ref))
176- }
177- if err != nil {
178- _ = os.Remove(tmp)
179- if errors.Is(err, domain.ErrNotFound) || ctx.Err() != nil {
180- return 0, "", err
181- }
182- return 0, "", fmt.Errorf("assemble artifact: %w", err)
183- }
184- if err := syncDir(dir); err != nil {
185- return 0, "", fmt.Errorf("sync artifact directory: %w", err)
186- }
187- return size, "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
188-}
189-
190-func (s *Store) concat(ctx context.Context, ref ports.CIArtifactRef, files []string, w io.Writer) (int64, error) {
191- var size int64
192- for _, name := range files {
193- f, err := os.Open(filepath.Join(s.blocksDir(ref), name))
194- if errors.Is(err, fs.ErrNotExist) {
195- return 0, fmt.Errorf("%w: block file %s", domain.ErrNotFound, name)
196- }
197- if err != nil {
198- return 0, err
199- }
200- n, err := io.Copy(w, ctxReader{ctx, f})
201- _ = f.Close()
202- if err != nil {
203- return 0, err
204- }
205- size += n
206- }
207- return size, nil
208-}
209-
21055 // Open opens the content at offset.
21156 func (s *Store) Open(_ context.Context, ref ports.CIArtifactRef, offset int64) (io.ReadCloser, error) {
21257 if !validRef(ref) || offset < 0 {
@@ -227,7 +72,7 @@ func (s *Store) Open(_ context.Context, ref ports.CIArtifactRef, offset int64) (
22772 }
22873
22974 // Remove deletes the content, the blocks and any assembly in progress.
230-func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error {
75+func (s *Store) Remove(_ context.Context, ref ports.CIArtifactRef) error {
23176 if !validRef(ref) {
23277 return nil
23378 }
@@ -235,7 +80,7 @@ func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error {
23580 if err := os.Remove(s.contentPath(ref)); err != nil && !errors.Is(err, fs.ErrNotExist) {
23681 errs = append(errs, err)
23782 }
238- errs = append(errs, s.RemoveBlocks(ctx, ref))
83+ errs = append(errs, os.RemoveAll(s.blocksDir(ref)))
23984 tmps, _ := filepath.Glob(filepath.Join(s.runDir(ref.RunID), tmpPrefix+fmt.Sprintf(idFormat, ref.ID)+"-*"))
24085 for _, t := range tmps {
24186 if err := os.Remove(t); err != nil && !errors.Is(err, fs.ErrNotExist) {
@@ -248,17 +93,6 @@ func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error {
24893 return nil
24994 }
25095
251-// RemoveBlocks deletes the staging directory of the artifact.
252-func (s *Store) RemoveBlocks(_ context.Context, ref ports.CIArtifactRef) error {
253- if !validRef(ref) {
254- return nil
255- }
256- if err := os.RemoveAll(s.blocksDir(ref)); err != nil {
257- return fmt.Errorf("remove blocks: %w", err)
258- }
259- return nil
260-}
261-
26296 // RemoveRuns deletes the directories of the runs.
26397 func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
26498 var errs []error
@@ -1,23 +1,12 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-// Package ciartifacts implements ports.CIArtifactFiles under3+// Package ciartifacts reads and removes legacy artifacts recorded before the
4-// $ERMITE_DATA_DIR/ci/artifacts, keyed by the global run id like the logs (a4+// blob store (ADR 0034), under $ERMITE_DATA_DIR/ci/artifacts. Paths use ids
5-// repository rename never moves them):5+// only. The adapter is removed once all legacy artifacts have expired.
6-//
7-// <run:020d>/<id:020d> the content of a committed artifact
8-// <run:020d>/<id:020d>.blocks/<f> the staged blocks of a pending one
9-// <run:020d>/.tmp-<id:020d>-* a content being assembled
10-//
11-// Paths are made of ids and of block file names the use case builds from
12-// hexadecimal characters: an artifact name never reaches the file system.
13-// Files are 0600 in 0700 directories, synced before they count, and a content
14-// appears by rename, whole or not at all.
15 package ciartifacts6 package ciartifacts
16 7
17 import (8 import (
18 "context"9 "context"
19- "crypto/sha256"
20- "encoding/hex"
21 "errors"10 "errors"
22 "fmt"11 "fmt"
23 "io"12 "io"
@@ -39,9 +28,6 @@ const (
39 idFormat = "%020d"28 idFormat = "%020d"
40 blocksSuffix = ".blocks"29 blocksSuffix = ".blocks"
41 tmpPrefix = ".tmp-"30 tmpPrefix = ".tmp-"
42- // maxFileName bounds a block file name (well under the 255 bytes of a
43- // name on Linux).
44- maxFileName = 200
45 )31 )
46 32
47 // Store is the file-backed artifact store.33 // Store is the file-backed artifact store.
@@ -54,15 +40,6 @@ func New(dir string) *Store { return &Store{root: dir} }
54 40
55 func validRef(ref ports.CIArtifactRef) bool { return ref.RunID > 0 && ref.ID > 0 }41 func validRef(ref ports.CIArtifactRef) bool { return ref.RunID > 0 && ref.ID > 0 }
56 42
57-// validFile accepts the block file names of the use case: lowercase
58-// hexadecimal characters and dots, not starting with a dot.
59-func validFile(name string) bool {
60- if name == "" || len(name) > maxFileName || name[0] == '.' {
61- return false
62- }
63- return strings.Trim(name, "0123456789abcdef.") == ""
64-}
65-
66 func (s *Store) runDir(runID int64) string {43 func (s *Store) runDir(runID int64) string {
67 return filepath.Join(s.root, fmt.Sprintf(idFormat, runID))44 return filepath.Join(s.root, fmt.Sprintf(idFormat, runID))
68 }45 }
@@ -75,138 +52,6 @@ func (s *Store) blocksDir(ref ports.CIArtifactRef) string {
75 return s.contentPath(ref) + blocksSuffix52 return s.contentPath(ref) + blocksSuffix
76 }53 }
77 54
78-// syncDir makes the entries of a directory durable.
79-func syncDir(dir string) error {
80- d, err := os.Open(dir)
81- if err != nil {
82- return err
83- }
84- serr := d.Sync()
85- return errors.Join(serr, d.Close())
86-}
87-
88-// ctxReader stops a copy when its context ends.
89-type ctxReader struct {
90- ctx context.Context
91- r io.Reader
92-}
93-
94-func (c ctxReader) Read(p []byte) (int, error) {
95- if err := c.ctx.Err(); err != nil {
96- return 0, err
97- }
98- return c.r.Read(p)
99-}
100-
101-// PutBlock writes a new block file, created exclusively.
102-func (s *Store) PutBlock(ctx context.Context, ref ports.CIArtifactRef, file string, src io.Reader) (int64, error) {
103- if !validRef(ref) || !validFile(file) {
104- return 0, fmt.Errorf("%w: block file %q", domain.ErrInvalid, file)
105- }
106- dir := s.blocksDir(ref)
107- if err := os.MkdirAll(dir, 0o700); err != nil {
108- return 0, fmt.Errorf("create block directory: %w", err)
109- }
110- path := filepath.Join(dir, file)
111- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
112- if err != nil {
113- return 0, fmt.Errorf("create block: %w", err)
114- }
115- n, werr := io.Copy(f, ctxReader{ctx, src})
116- var serr error
117- if werr == nil {
118- serr = f.Sync()
119- }
120- cerr := f.Close()
121- if err := errors.Join(werr, serr, cerr); err != nil {
122- _ = os.Remove(path)
123- if werr != nil {
124- return 0, werr // the source's error, as it is
125- }
126- return 0, fmt.Errorf("write block: %w", err)
127- }
128- if err := syncDir(dir); err != nil {
129- _ = os.Remove(path)
130- return 0, fmt.Errorf("sync block directory: %w", err)
131- }
132- return n, nil
133-}
134-
135-// RemoveBlock deletes one block file.
136-func (s *Store) RemoveBlock(_ context.Context, ref ports.CIArtifactRef, file string) error {
137- if !validRef(ref) || !validFile(file) {
138- return nil
139- }
140- if err := os.Remove(filepath.Join(s.blocksDir(ref), file)); err != nil && !errors.Is(err, fs.ErrNotExist) {
141- return fmt.Errorf("remove block: %w", err)
142- }
143- return nil
144-}
145-
146-// Assemble writes the blocks into a temporary file, syncs it and renames it
147-// over the content.
148-func (s *Store) Assemble(ctx context.Context, ref ports.CIArtifactRef, files []string) (int64, string, error) {
149- if !validRef(ref) {
150- return 0, "", domain.ErrNotFound
151- }
152- for _, f := range files {
153- if !validFile(f) {
154- return 0, "", fmt.Errorf("%w: block file %q", domain.ErrInvalid, f)
155- }
156- }
157- dir := s.runDir(ref.RunID)
158- if err := os.MkdirAll(dir, 0o700); err != nil {
159- return 0, "", fmt.Errorf("create artifact directory: %w", err)
160- }
161- out, err := os.CreateTemp(dir, tmpPrefix+fmt.Sprintf(idFormat, ref.ID)+"-*")
162- if err != nil {
163- return 0, "", fmt.Errorf("create artifact: %w", err)
164- }
165- tmp := out.Name()
166- h := sha256.New()
167- size, err := s.concat(ctx, ref, files, io.MultiWriter(out, h))
168- if err == nil {
169- err = out.Sync()
170- }
171- if cerr := out.Close(); err == nil {
172- err = cerr
173- }
174- if err == nil {
175- err = os.Rename(tmp, s.contentPath(ref))
176- }
177- if err != nil {
178- _ = os.Remove(tmp)
179- if errors.Is(err, domain.ErrNotFound) || ctx.Err() != nil {
180- return 0, "", err
181- }
182- return 0, "", fmt.Errorf("assemble artifact: %w", err)
183- }
184- if err := syncDir(dir); err != nil {
185- return 0, "", fmt.Errorf("sync artifact directory: %w", err)
186- }
187- return size, "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
188-}
189-
190-func (s *Store) concat(ctx context.Context, ref ports.CIArtifactRef, files []string, w io.Writer) (int64, error) {
191- var size int64
192- for _, name := range files {
193- f, err := os.Open(filepath.Join(s.blocksDir(ref), name))
194- if errors.Is(err, fs.ErrNotExist) {
195- return 0, fmt.Errorf("%w: block file %s", domain.ErrNotFound, name)
196- }
197- if err != nil {
198- return 0, err
199- }
200- n, err := io.Copy(w, ctxReader{ctx, f})
201- _ = f.Close()
202- if err != nil {
203- return 0, err
204- }
205- size += n
206- }
207- return size, nil
208-}
209-
210 // Open opens the content at offset.55 // Open opens the content at offset.
211 func (s *Store) Open(_ context.Context, ref ports.CIArtifactRef, offset int64) (io.ReadCloser, error) {56 func (s *Store) Open(_ context.Context, ref ports.CIArtifactRef, offset int64) (io.ReadCloser, error) {
212 if !validRef(ref) || offset < 0 {57 if !validRef(ref) || offset < 0 {
@@ -227,7 +72,7 @@ func (s *Store) Open(_ context.Context, ref ports.CIArtifactRef, offset int64) (
227 }72 }
228 73
229 // Remove deletes the content, the blocks and any assembly in progress.74 // Remove deletes the content, the blocks and any assembly in progress.
230-func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error {75+func (s *Store) Remove(_ context.Context, ref ports.CIArtifactRef) error {
231 if !validRef(ref) {76 if !validRef(ref) {
232 return nil77 return nil
233 }78 }
@@ -235,7 +80,7 @@ func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error {
235 if err := os.Remove(s.contentPath(ref)); err != nil && !errors.Is(err, fs.ErrNotExist) {80 if err := os.Remove(s.contentPath(ref)); err != nil && !errors.Is(err, fs.ErrNotExist) {
236 errs = append(errs, err)81 errs = append(errs, err)
237 }82 }
238- errs = append(errs, s.RemoveBlocks(ctx, ref))83+ errs = append(errs, os.RemoveAll(s.blocksDir(ref)))
239 tmps, _ := filepath.Glob(filepath.Join(s.runDir(ref.RunID), tmpPrefix+fmt.Sprintf(idFormat, ref.ID)+"-*"))84 tmps, _ := filepath.Glob(filepath.Join(s.runDir(ref.RunID), tmpPrefix+fmt.Sprintf(idFormat, ref.ID)+"-*"))
240 for _, t := range tmps {85 for _, t := range tmps {
241 if err := os.Remove(t); err != nil && !errors.Is(err, fs.ErrNotExist) {86 if err := os.Remove(t); err != nil && !errors.Is(err, fs.ErrNotExist) {
@@ -248,17 +93,6 @@ func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error {
248 return nil93 return nil
249 }94 }
250 95
251-// RemoveBlocks deletes the staging directory of the artifact.
252-func (s *Store) RemoveBlocks(_ context.Context, ref ports.CIArtifactRef) error {
253- if !validRef(ref) {
254- return nil
255- }
256- if err := os.RemoveAll(s.blocksDir(ref)); err != nil {
257- return fmt.Errorf("remove blocks: %w", err)
258- }
259- return nil
260-}
261-
262 // RemoveRuns deletes the directories of the runs.96 // RemoveRuns deletes the directories of the runs.
263 func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {97 func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
264 var errs []error98 var errs []error
M internal/adapters/ciartifacts/ciartifacts_test.go +8 -105
@@ -3,13 +3,10 @@
33 package ciartifacts_test
44
55 import (
6- "crypto/sha256"
7- "encoding/hex"
86 "errors"
97 "io"
108 "os"
119 "path/filepath"
12- "strings"
1310 "testing"
1411
1512 "github.com/stretchr/testify/assert"
@@ -22,11 +19,6 @@ import (
2219
2320 var ref = ports.CIArtifactRef{RunID: 7, ID: 42}
2421
25-func digestOf(s string) string {
26- sum := sha256.Sum256([]byte(s))
27- return "sha256:" + hex.EncodeToString(sum[:])
28-}
29-
3022 func entries(t *testing.T, dir string) []string {
3123 t.Helper()
3224 es, err := os.ReadDir(dir)
@@ -41,121 +33,32 @@ func entries(t *testing.T, dir string) []string {
4133 return out
4234 }
4335
44-func TestBlocksAssembleInTheOrderOfTheList(t *testing.T) {
36+func TestLegacyReadAndRemoval(t *testing.T) {
4537 ctx := t.Context()
4638 root := t.TempDir()
4739 s := ciartifacts.New(root)
48-
49- // Uploaded out of order; the list decides.
50- for file, data := range map[string]string{"02.aa": "world", "01.bb": "hello ", "03.cc": "!"} {
51- n, err := s.PutBlock(ctx, ref, file, strings.NewReader(data))
52- require.NoError(t, err)
53- assert.EqualValues(t, len(data), n)
54- }
55- size, digest, err := s.Assemble(ctx, ref, []string{"01.bb", "02.aa", "03.cc", "03.cc"})
56- require.NoError(t, err)
57- assert.EqualValues(t, len("hello world!!"), size)
58- assert.Equal(t, digestOf("hello world!!"), digest, "a repeated id is repeated content")
59-
60- // The documented layout, named by ids only.
61- b, err := os.ReadFile(filepath.Join(root, "00000000000000000007", "00000000000000000042"))
62- require.NoError(t, err)
63- assert.Equal(t, "hello world!!", string(b))
64- for _, e := range entries(t, filepath.Join(root, "00000000000000000007")) {
65- assert.False(t, strings.HasPrefix(e, ".tmp-"), "no temporary file left: %s", e)
66- }
67-
68- // Offset reads.
40+ run := filepath.Join(root, "00000000000000000007")
41+ require.NoError(t, os.MkdirAll(run, 0o700))
42+ require.NoError(t, os.WriteFile(filepath.Join(run, "00000000000000000042"), []byte("hello world"), 0o600))
6943 r, err := s.Open(ctx, ref, 6)
7044 require.NoError(t, err)
71- rest, err := io.ReadAll(r)
45+ data, err := io.ReadAll(r)
7246 require.NoError(t, err)
7347 require.NoError(t, r.Close())
74- assert.Equal(t, "world!!", string(rest))
75-
76- // A new assembly replaces the content whole.
77- size, digest, err = s.Assemble(ctx, ref, nil)
78- require.NoError(t, err)
79- assert.Zero(t, size, "an empty list is an empty content")
80- assert.Equal(t, digestOf(""), digest)
81-
48+ assert.Equal(t, "world", string(data))
8249 ids, err := s.ArtifactIDs(ctx, 7)
8350 require.NoError(t, err)
8451 assert.Equal(t, []int64{42}, ids)
85- runs, err := s.RunIDs(ctx)
86- require.NoError(t, err)
87- assert.Equal(t, []int64{7}, runs)
88-
89- require.NoError(t, s.RemoveBlocks(ctx, ref))
90- assert.Equal(t, []string{"00000000000000000042"}, entries(t, filepath.Join(root, "00000000000000000007")))
9152 require.NoError(t, s.Remove(ctx, ref))
92- assert.Empty(t, entries(t, filepath.Join(root, "00000000000000000007")))
53+ require.NoError(t, s.Remove(ctx, ref))
9354 _, err = s.Open(ctx, ref, 0)
9455 assert.ErrorIs(t, err, domain.ErrNotFound)
9556 require.NoError(t, s.RemoveRuns(ctx, []int64{7}))
9657 assert.Empty(t, entries(t, root))
97-}
98-
99-func TestAMissingBlockIsNotFoundAndLeavesNoContent(t *testing.T) {
100- ctx := t.Context()
101- root := t.TempDir()
102- s := ciartifacts.New(root)
103- _, err := s.PutBlock(ctx, ref, "01.aa", strings.NewReader("x"))
104- require.NoError(t, err)
105- _, _, err = s.Assemble(ctx, ref, []string{"01.aa", "02.aa"})
106- assert.ErrorIs(t, err, domain.ErrNotFound)
107- _, err = s.Open(ctx, ref, 0)
108- assert.ErrorIs(t, err, domain.ErrNotFound, "no content appears")
109- for _, e := range entries(t, filepath.Join(root, "00000000000000000007")) {
110- assert.False(t, strings.HasPrefix(e, ".tmp-"), e)
111- }
112-}
113-
114-type failingReader struct{ n int }
115-
116-func (f *failingReader) Read(p []byte) (int, error) {
117- if f.n == 0 {
118- return 0, errors.New("stream broken")
119- }
120- f.n--
121- p[0] = 'x'
122- return 1, nil
123-}
124-
125-func TestABrokenSourceLeavesNoBlock(t *testing.T) {
126- ctx := t.Context()
127- root := t.TempDir()
128- s := ciartifacts.New(root)
129- _, err := s.PutBlock(ctx, ref, "01.aa", &failingReader{n: 3})
130- require.EqualError(t, err, "stream broken", "the source's error as it is")
131- assert.Empty(t, entries(t, filepath.Join(root, "00000000000000000007", "00000000000000000042.blocks")))
132-
133- // A file name is never reused: the second put of a name fails.
134- _, err = s.PutBlock(ctx, ref, "01.aa", strings.NewReader("a"))
135- require.NoError(t, err)
136- _, err = s.PutBlock(ctx, ref, "01.aa", strings.NewReader("b"))
137- assert.Error(t, err)
138-}
139-
140-func TestNamesOutsideTheRulesNeverReachTheFileSystem(t *testing.T) {
141- ctx := t.Context()
142- root := t.TempDir()
143- s := ciartifacts.New(filepath.Join(root, "store"))
144- for _, name := range []string{"", "../x", "a/b", ".hidden", "..", "ZZ", "01.aa/../../x", strings.Repeat("a", 201)} {
145- _, err := s.PutBlock(ctx, ref, name, strings.NewReader("x"))
146- assert.ErrorIs(t, err, domain.ErrInvalid, "%q", name)
147- _, _, err = s.Assemble(ctx, ref, []string{name})
148- assert.Error(t, err, "%q", name)
149- }
15058 for _, bad := range []ports.CIArtifactRef{{RunID: 0, ID: 1}, {RunID: 1, ID: -1}} {
151- _, err := s.PutBlock(ctx, bad, "01.aa", strings.NewReader("x"))
152- assert.ErrorIs(t, err, domain.ErrInvalid)
153- _, err = s.Open(ctx, bad, 0)
59+ _, err := s.Open(ctx, bad, 0)
15460 assert.ErrorIs(t, err, domain.ErrNotFound)
15561 }
156- for _, e := range entries(t, root) {
157- assert.Equal(t, "store", e, "nothing written beside the store")
158- }
15962 }
16063
16164 func TestOrphanListingSeesEveryKindOfFile(t *testing.T) {
@@ -3,13 +3,10 @@
3 package ciartifacts_test3 package ciartifacts_test
4 4
5 import (5 import (
6- "crypto/sha256"
7- "encoding/hex"
8 "errors"6 "errors"
9 "io"7 "io"
10 "os"8 "os"
11 "path/filepath"9 "path/filepath"
12- "strings"
13 "testing"10 "testing"
14 11
15 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
@@ -22,11 +19,6 @@ import (
22 19
23 var ref = ports.CIArtifactRef{RunID: 7, ID: 42}20 var ref = ports.CIArtifactRef{RunID: 7, ID: 42}
24 21
25-func digestOf(s string) string {
26- sum := sha256.Sum256([]byte(s))
27- return "sha256:" + hex.EncodeToString(sum[:])
28-}
29-
30 func entries(t *testing.T, dir string) []string {22 func entries(t *testing.T, dir string) []string {
31 t.Helper()23 t.Helper()
32 es, err := os.ReadDir(dir)24 es, err := os.ReadDir(dir)
@@ -41,121 +33,32 @@ func entries(t *testing.T, dir string) []string {
41 return out33 return out
42 }34 }
43 35
44-func TestBlocksAssembleInTheOrderOfTheList(t *testing.T) {36+func TestLegacyReadAndRemoval(t *testing.T) {
45 ctx := t.Context()37 ctx := t.Context()
46 root := t.TempDir()38 root := t.TempDir()
47 s := ciartifacts.New(root)39 s := ciartifacts.New(root)
48-40+ run := filepath.Join(root, "00000000000000000007")
49- // Uploaded out of order; the list decides.41+ require.NoError(t, os.MkdirAll(run, 0o700))
50- for file, data := range map[string]string{"02.aa": "world", "01.bb": "hello ", "03.cc": "!"} {42+ require.NoError(t, os.WriteFile(filepath.Join(run, "00000000000000000042"), []byte("hello world"), 0o600))
51- n, err := s.PutBlock(ctx, ref, file, strings.NewReader(data))
52- require.NoError(t, err)
53- assert.EqualValues(t, len(data), n)
54- }
55- size, digest, err := s.Assemble(ctx, ref, []string{"01.bb", "02.aa", "03.cc", "03.cc"})
56- require.NoError(t, err)
57- assert.EqualValues(t, len("hello world!!"), size)
58- assert.Equal(t, digestOf("hello world!!"), digest, "a repeated id is repeated content")
59-
60- // The documented layout, named by ids only.
61- b, err := os.ReadFile(filepath.Join(root, "00000000000000000007", "00000000000000000042"))
62- require.NoError(t, err)
63- assert.Equal(t, "hello world!!", string(b))
64- for _, e := range entries(t, filepath.Join(root, "00000000000000000007")) {
65- assert.False(t, strings.HasPrefix(e, ".tmp-"), "no temporary file left: %s", e)
66- }
67-
68- // Offset reads.
69 r, err := s.Open(ctx, ref, 6)43 r, err := s.Open(ctx, ref, 6)
70 require.NoError(t, err)44 require.NoError(t, err)
71- rest, err := io.ReadAll(r)45+ data, err := io.ReadAll(r)
72 require.NoError(t, err)46 require.NoError(t, err)
73 require.NoError(t, r.Close())47 require.NoError(t, r.Close())
74- assert.Equal(t, "world!!", string(rest))48+ assert.Equal(t, "world", string(data))
75-
76- // A new assembly replaces the content whole.
77- size, digest, err = s.Assemble(ctx, ref, nil)
78- require.NoError(t, err)
79- assert.Zero(t, size, "an empty list is an empty content")
80- assert.Equal(t, digestOf(""), digest)
81-
82 ids, err := s.ArtifactIDs(ctx, 7)49 ids, err := s.ArtifactIDs(ctx, 7)
83 require.NoError(t, err)50 require.NoError(t, err)
84 assert.Equal(t, []int64{42}, ids)51 assert.Equal(t, []int64{42}, ids)
85- runs, err := s.RunIDs(ctx)
86- require.NoError(t, err)
87- assert.Equal(t, []int64{7}, runs)
88-
89- require.NoError(t, s.RemoveBlocks(ctx, ref))
90- assert.Equal(t, []string{"00000000000000000042"}, entries(t, filepath.Join(root, "00000000000000000007")))
91 require.NoError(t, s.Remove(ctx, ref))52 require.NoError(t, s.Remove(ctx, ref))
92- assert.Empty(t, entries(t, filepath.Join(root, "00000000000000000007")))53+ require.NoError(t, s.Remove(ctx, ref))
93 _, err = s.Open(ctx, ref, 0)54 _, err = s.Open(ctx, ref, 0)
94 assert.ErrorIs(t, err, domain.ErrNotFound)55 assert.ErrorIs(t, err, domain.ErrNotFound)
95 require.NoError(t, s.RemoveRuns(ctx, []int64{7}))56 require.NoError(t, s.RemoveRuns(ctx, []int64{7}))
96 assert.Empty(t, entries(t, root))57 assert.Empty(t, entries(t, root))
97-}
98-
99-func TestAMissingBlockIsNotFoundAndLeavesNoContent(t *testing.T) {
100- ctx := t.Context()
101- root := t.TempDir()
102- s := ciartifacts.New(root)
103- _, err := s.PutBlock(ctx, ref, "01.aa", strings.NewReader("x"))
104- require.NoError(t, err)
105- _, _, err = s.Assemble(ctx, ref, []string{"01.aa", "02.aa"})
106- assert.ErrorIs(t, err, domain.ErrNotFound)
107- _, err = s.Open(ctx, ref, 0)
108- assert.ErrorIs(t, err, domain.ErrNotFound, "no content appears")
109- for _, e := range entries(t, filepath.Join(root, "00000000000000000007")) {
110- assert.False(t, strings.HasPrefix(e, ".tmp-"), e)
111- }
112-}
113-
114-type failingReader struct{ n int }
115-
116-func (f *failingReader) Read(p []byte) (int, error) {
117- if f.n == 0 {
118- return 0, errors.New("stream broken")
119- }
120- f.n--
121- p[0] = 'x'
122- return 1, nil
123-}
124-
125-func TestABrokenSourceLeavesNoBlock(t *testing.T) {
126- ctx := t.Context()
127- root := t.TempDir()
128- s := ciartifacts.New(root)
129- _, err := s.PutBlock(ctx, ref, "01.aa", &failingReader{n: 3})
130- require.EqualError(t, err, "stream broken", "the source's error as it is")
131- assert.Empty(t, entries(t, filepath.Join(root, "00000000000000000007", "00000000000000000042.blocks")))
132-
133- // A file name is never reused: the second put of a name fails.
134- _, err = s.PutBlock(ctx, ref, "01.aa", strings.NewReader("a"))
135- require.NoError(t, err)
136- _, err = s.PutBlock(ctx, ref, "01.aa", strings.NewReader("b"))
137- assert.Error(t, err)
138-}
139-
140-func TestNamesOutsideTheRulesNeverReachTheFileSystem(t *testing.T) {
141- ctx := t.Context()
142- root := t.TempDir()
143- s := ciartifacts.New(filepath.Join(root, "store"))
144- for _, name := range []string{"", "../x", "a/b", ".hidden", "..", "ZZ", "01.aa/../../x", strings.Repeat("a", 201)} {
145- _, err := s.PutBlock(ctx, ref, name, strings.NewReader("x"))
146- assert.ErrorIs(t, err, domain.ErrInvalid, "%q", name)
147- _, _, err = s.Assemble(ctx, ref, []string{name})
148- assert.Error(t, err, "%q", name)
149- }
150 for _, bad := range []ports.CIArtifactRef{{RunID: 0, ID: 1}, {RunID: 1, ID: -1}} {58 for _, bad := range []ports.CIArtifactRef{{RunID: 0, ID: 1}, {RunID: 1, ID: -1}} {
151- _, err := s.PutBlock(ctx, bad, "01.aa", strings.NewReader("x"))59+ _, err := s.Open(ctx, bad, 0)
152- assert.ErrorIs(t, err, domain.ErrInvalid)
153- _, err = s.Open(ctx, bad, 0)
154 assert.ErrorIs(t, err, domain.ErrNotFound)60 assert.ErrorIs(t, err, domain.ErrNotFound)
155 }61 }
156- for _, e := range entries(t, root) {
157- assert.Equal(t, "store", e, "nothing written beside the store")
158- }
159 }62 }
160 63
161 func TestOrphanListingSeesEveryKindOfFile(t *testing.T) {64 func TestOrphanListingSeesEveryKindOfFile(t *testing.T) {
M internal/adapters/client/client.go +5 -0
@@ -80,6 +80,11 @@ func (c *Client) Artifacts() ermiteconnect.ArtifactServiceClient {
8080 return ermiteconnect.NewArtifactServiceClient(c.http, c.base, c.opts...)
8181 }
8282
83+// Releases returns the ReleaseService client.
84+func (c *Client) Releases() ermiteconnect.ReleaseServiceClient {
85+ return ermiteconnect.NewReleaseServiceClient(c.http, c.base, c.opts...)
86+}
87+
8388 // Tokens returns the TokenService client.
8489 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
8590 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
@@ -80,6 +80,11 @@ func (c *Client) Artifacts() ermiteconnect.ArtifactServiceClient {
80 return ermiteconnect.NewArtifactServiceClient(c.http, c.base, c.opts...)80 return ermiteconnect.NewArtifactServiceClient(c.http, c.base, c.opts...)
81 }81 }
82 82
83+// Releases returns the ReleaseService client.
84+func (c *Client) Releases() ermiteconnect.ReleaseServiceClient {
85+ return ermiteconnect.NewReleaseServiceClient(c.http, c.base, c.opts...)
86+}
87+
83 // Tokens returns the TokenService client.88 // Tokens returns the TokenService client.
84 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {89 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
85 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)90 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +3 -3
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
142+ []string{client.FeatureReleases, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
145+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
150+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},142+ []string{client.FeatureReleases, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,145+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,
148 }))148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,150+ client.FeatureReleases, client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,
152 }))152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
M internal/adapters/client/features.go +3 -1
A internal/adapters/client/release_test.go +43 -0
M internal/adapters/client/scopes_test.go +1 -0
A internal/adapters/gitcli/tags.go +42 -0
A internal/adapters/gitcli/tags_test.go +31 -0
M internal/adapters/githubapi/handler.go +23 -2
A internal/adapters/githubapi/releases.go +413 -0
A internal/adapters/githubapi/releases_test.go +466 -0
A internal/adapters/githubapi/testdata/releases/draft.json +40 -0
A internal/adapters/githubapi/testdata/releases/release.json +113 -0
M internal/adapters/kvstore/ci_artifact.go +2 -1
M internal/adapters/kvstore/ci_artifact_test.go +5 -0
M internal/adapters/kvstore/issues_test.go +16 -8
A internal/adapters/kvstore/releases.go +408 -0
A internal/adapters/kvstore/releases_test.go +131 -0
M internal/adapters/kvstore/repos.go +1 -0
M internal/adapters/kvstore/store.go +25 -4
M internal/adapters/kvstore/store_test.go +57 -0
M internal/adapters/rpc/api.go +9 -0
M internal/adapters/rpc/convert.go +28 -3
M internal/adapters/rpc/errors.go +2 -0
M internal/adapters/rpc/event.go +26 -0
A internal/adapters/rpc/release.go +358 -0
A internal/adapters/rpc/release_test.go +472 -0
M internal/adapters/web/fragments.go +8 -2
M internal/adapters/web/fragments_test.go +20 -0
A internal/adapters/web/releases.go +244 -0
A internal/adapters/web/releases.templ +67 -0
A internal/adapters/web/releases_templ.go +439 -0
A internal/adapters/web/releases_test.go +298 -0
M internal/adapters/web/repo.go +3 -0
M internal/adapters/web/web.go +11 -0
M internal/app/api_test.go +1 -1
M internal/app/ci_artifacts_test.go +110 -12
M internal/app/ci_e2e_test.go +2 -0
M internal/app/cli_e2e_test.go +4 -3
A internal/app/cli_release.go +603 -0
A internal/app/cli_release_e2e_test.go +65 -0
A internal/app/cli_release_test.go +388 -0
M internal/app/cli_token.go +9 -0
M internal/app/cli_token_test.go +40 -1
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/forge.go +49 -4
A internal/app/release_e2e_test.go +160 -0
M internal/app/root.go +1 -1
M internal/app/testdata/web/blob-binary.html +15 -5
M internal/app/testdata/web/blob-code.html +15 -5
M internal/app/testdata/web/blob-empty.html +15 -5
M internal/app/testdata/web/blob-image.html +15 -5
M internal/app/testdata/web/blob-markdown.html +15 -5
M internal/app/testdata/web/blob-svg.html +15 -5
M internal/app/testdata/web/blob-text.html +15 -5
M internal/app/testdata/web/blob-too-large.html +15 -5
M internal/app/testdata/web/blob-trojan.html +15 -5
M internal/app/testdata/web/commit-big.html +15 -5
M internal/app/testdata/web/commit-binary.html +15 -5
M internal/app/testdata/web/commit-merge.html +15 -5
M internal/app/testdata/web/commit-modify-split.html +15 -5
M internal/app/testdata/web/commit-modify.html +15 -5
M internal/app/testdata/web/commit-rename.html +15 -5
M internal/app/testdata/web/commit-root.html +15 -5
M internal/app/testdata/web/commit-trojan.html +15 -5
M internal/app/testdata/web/commits-page2.html +14 -4
M internal/app/testdata/web/commits-path.html +14 -4
M internal/app/testdata/web/commits-root.html +14 -4
M internal/app/testdata/web/commits-small.html +16 -6
M internal/app/testdata/web/refs.html +17 -7
M internal/app/testdata/web/summary.html +17 -7
M internal/app/testdata/web/tree-nested.html +15 -5
M internal/app/testdata/web/tree-root.html +15 -5
M internal/app/testdata/web/tree-slash-ref.html +15 -5
M internal/app/testdata/web/tree-subdir.html +15 -5
M internal/app/web.go +1 -0
A internal/app/web_release_test.go +111 -0
M internal/boundary/boundary_test.go +14 -2
M internal/config/config.go +117 -0
A internal/config/storage_test.go +111 -0
M internal/domain/ci_artifact.go +21 -0
M internal/domain/ci_artifact_test.go +7 -0
M internal/domain/event.go +44 -0
A internal/domain/release.go +165 -0
A internal/domain/release_test.go +59 -0
M internal/domain/token.go +4 -2
M internal/domain/token_test.go +5 -1
A internal/httprange/httprange.go +77 -0
A internal/httprange/httprange_test.go +72 -0
A internal/ports/blob.go +72 -0
A internal/ports/blob_test.go +19 -0
M internal/ports/ci_artifact.go +3 -13
A internal/ports/mocks/BlobStore.go +644 -0
M internal/ports/mocks/CIArtifactFiles.go +0 -276
A internal/ports/mocks/GitHubReleaseAPI.go +1044 -0
A internal/ports/mocks/MultipartUpload.go +273 -0
A internal/ports/mocks/ReleaseRepoCleaner.go +168 -0
A internal/ports/mocks/ReleaseService.go +997 -0
A internal/ports/mocks/ReleaseStore.go +796 -0
A internal/ports/mocks/ReleaseSweeper.go +87 -0
A internal/ports/mocks/ReleaseTagTrigger.go +100 -0
A internal/ports/mocks/ReleaseTagWriter.go +117 -0
A internal/ports/release.go +119 -0
M internal/ports/services.go +17 -0
M internal/usecase/ci.go +15 -7
M internal/usecase/ci_artifacts.go +325 -12
A internal/usecase/ci_artifacts_blob_test.go +301 -0
A internal/usecase/ci_artifacts_slots_test.go +179 -0
M internal/usecase/ci_watch.go +4 -3
M internal/usecase/events.go +25 -0
M internal/usecase/events_test.go +95 -0
M internal/usecase/pushes.go +21 -14
A internal/usecase/releases.go +494 -0
A internal/usecase/releases_assets.go +441 -0
A internal/usecase/releases_assets_test.go +308 -0
A internal/usecase/releases_github.go +387 -0
A internal/usecase/releases_github_test.go +360 -0
A internal/usecase/releases_integration_test.go +336 -0
A internal/usecase/releases_sweep.go +106 -0
A internal/usecase/releases_sweep_test.go +147 -0
A internal/usecase/releases_test.go +57 -0
M internal/usecase/repos.go +9 -1
M internal/usecase/repos_write.go +11 -0
M proto/ermite/storage/ci_artifact.proto +4 -2
A proto/ermite/storage/release.proto +46 -0
M skills/ermite/CLI.md +20 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close