Merge b869944009400099e6fe8932fecbdf470c52dcef into f61e6a68e9c866c042a8c5e4f64cae2b1290a363
149 files · +17034 -809
M .hexcheck.yaml +12 -2
| @@ -21,6 +21,11 @@ components: | |||
| 21 | 21 | role: adapter | |
| 22 | 22 | paths: | |
| 23 | 23 | - internal/clientlimit/** | |
| 24 | + | httprange: | |
| 25 | + | # Byte-range selection: infrastructure shared by the HTTP adapters. | |
| 26 | + | role: adapter | |
| 27 | + | paths: | |
| 28 | + | - internal/httprange/** | |
| 24 | 29 | entrypoints: | |
| 25 | 30 | # cmd/ermite only calls app.Run. | |
| 26 | 31 | role: entrypoint | |
| @@ -68,6 +73,11 @@ mocking: | |||
| 68 | 73 | generatedMockNamePatterns: | |
| 69 | 74 | - '{{Interface}}' | |
| 70 | 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 | 81 | # clientlimit is a shared infrastructure library, not a sibling adapter: the | |
| 72 | 82 | # HTTP adapters that rate-limit clients import it by design. | |
| 73 | 83 | - rule: no-adapter-to-adapter-imports | |
| @@ -75,7 +85,7 @@ allow: | |||
| 75 | 85 | reason: rate-limits Git clients with internal/clientlimit | |
| 76 | 86 | - rule: no-adapter-to-adapter-imports | |
| 77 | 87 | 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 | |
| 79 | 89 | - rule: no-adapter-to-adapter-imports | |
| 80 | 90 | path: internal/adapters/oidchttp/** | |
| 81 | 91 | reason: rate-limits the clients of the OIDC token endpoint with internal/clientlimit | |
| @@ -84,4 +94,4 @@ allow: | |||
| 84 | 94 | reason: rate-limits API clients with internal/clientlimit | |
| 85 | 95 | - rule: no-adapter-to-adapter-imports | |
| 86 | 96 | 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 | |
M README.md +1 -0
| @@ -18,6 +18,7 @@ | |||
| 18 | 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 container | |
| 19 | 19 | - **CLI-first**: repositories, merge requests, reviews and CI all work from the `ermite` CLI, for you and for AI agents | |
| 20 | 20 | - **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 | 22 | - Git over HTTPS, web interface with live updates | |
| 22 | 23 | ||
| 23 | 24 | Status: early development. | |
M compose.yaml +39 -0
| @@ -24,13 +24,52 @@ services: | |||
| 24 | 24 | ERMITE_ADMIN_TOKEN: dev-only-admin-token-do-not-use-0123456789 | |
| 25 | 25 | ERMITE_LOG_FORMAT: console | |
| 26 | 26 | 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 | 35 | volumes: | |
| 28 | 36 | - .:/src | |
| 29 | 37 | - go-mod:/go/pkg/mod | |
| 30 | 38 | - go-build:/root/.cache/go-build | |
| 31 | 39 | - 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 | 71 | volumes: | |
| 72 | + | minio-data: | |
| 34 | 73 | go-mod: | |
| 35 | 74 | go-build: | |
| 36 | 75 | data: | |
M docs/ci-artifacts.md +2 -1
| @@ -13,7 +13,8 @@ The run page lists them with download links. | |||
| 13 | 13 | ||
| 14 | 14 | - A job sees only the artifacts of its own run. A re-run that uploads again replaces the earlier one. | |
| 15 | 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 | 18 | - An upload not finished when its job ends is dropped. | |
| 18 | 19 | ||
| 19 | 20 | ## 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 | |||
| 96 | 96 | ||
| 97 | 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 | 144 | ## Output | |
| 100 | 145 | ||
| 101 | 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 | |||
| 60 | 60 | - The server refuses to start with a different key than the one the secrets were sealed with. | |
| 61 | 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 | 91 | ## Runtime files | |
| 64 | 92 | ||
| 65 | 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 | 95 | ## 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. | |
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. | |
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 | |||
| 15 | 15 | | `ci` | `repo:read` | | |
| 16 | 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 | 26 | ## Branch-limited tokens | |
| 19 | 27 | ||
| 20 | 28 | For an AI agent that should propose changes, not publish them: | |
| @@ -38,6 +46,7 @@ The token **cannot**: | |||
| 38 | 46 | ||
| 39 | 47 | - push any other ref (`main`, other branches, tags, notes). The whole push is refused and the message names the ref; | |
| 40 | 48 | - create, delete or configure a repository; | |
| 49 | + | - create, edit or delete releases or upload assets; | |
| 41 | 50 | - open web sessions or get owner rights, even with every scope; | |
| 42 | 51 | - manage runners, even with `runner:register`; | |
| 43 | 52 | - get trusted CI runs (no secrets). See [CI trust](ci.md#trust). | |
M gen/ermite/storage/ci_artifact.pb.go +15 -5
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | 3 | // Internal, unversioned storage schema of the CI artifacts (artifacts v4, | |
| 4 | - | // CI-4 D3). The contents are not stored here: they are files under | |
| 5 | - | // $ERMITE_DATA_DIR/ci/artifacts. | |
| 4 | + | // CI-4 D3). Contents are stored separately, in the blob store or (for | |
| 5 | + | // legacy records) under $ERMITE_DATA_DIR/ci/artifacts. | |
| 6 | 6 | ||
| 7 | 7 | // Code generated by protoc-gen-go. DO NOT EDIT. | |
| 8 | 8 | // versions: | |
| @@ -110,7 +110,9 @@ type CIArtifact struct { | |||
| 110 | 110 | Charged int64 `protobuf:"varint,14,opt,name=charged,proto3" json:"charged,omitempty"` | |
| 111 | 111 | // list_digest: the SHA-256 of the block list of the commit, to recognize a | |
| 112 | 112 | // repeated commit. | |
| 113 | - | ListDigest []byte `protobuf:"bytes,15,opt,name=list_digest,json=listDigest,proto3" json:"list_digest,omitempty"` | |
| 113 | + | ListDigest []byte `protobuf:"bytes,15,opt,name=list_digest,json=listDigest,proto3" json:"list_digest,omitempty"` | |
| 114 | + | // "" or "disk": legacy files under ci/artifacts; "blob": the blob store. | |
| 115 | + | Store string `protobuf:"bytes,16,opt,name=store,proto3" json:"store,omitempty"` | |
| 114 | 116 | unknownFields protoimpl.UnknownFields | |
| 115 | 117 | sizeCache protoimpl.SizeCache | |
| 116 | 118 | } | |
| @@ -250,6 +252,13 @@ func (x *CIArtifact) GetListDigest() []byte { | |||
| 250 | 252 | return nil | |
| 251 | 253 | } | |
| 252 | 254 | ||
| 255 | + | func (x *CIArtifact) GetStore() string { | |
| 256 | + | if x != nil { | |
| 257 | + | return x.Store | |
| 258 | + | } | |
| 259 | + | return "" | |
| 260 | + | } | |
| 261 | + | ||
| 253 | 262 | // CIArtifactBlock is stored under "artifactblk/<artifact:020d>/<hex(block id)>": | |
| 254 | 263 | // a staged block of a pending artifact. | |
| 255 | 264 | type CIArtifactBlock struct { | |
| @@ -358,7 +367,7 @@ var File_ermite_storage_ci_artifact_proto protoreflect.FileDescriptor | |||
| 358 | 367 | ||
| 359 | 368 | const file_ermite_storage_ci_artifact_proto_rawDesc = "" + | |
| 360 | 369 | "\n" + | |
| 361 | - | " ermite/storage/ci_artifact.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xdf\x03\n" + | |
| 370 | + | " ermite/storage/ci_artifact.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf5\x03\n" + | |
| 362 | 371 | "\n" + | |
| 363 | 372 | "CIArtifact\x12\x0e\n" + | |
| 364 | 373 | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" + | |
| @@ -382,7 +391,8 @@ const file_ermite_storage_ci_artifact_proto_rawDesc = "" + | |||
| 382 | 391 | "expires_at\x18\r \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12\x18\n" + | |
| 383 | 392 | "\acharged\x18\x0e \x01(\x03R\acharged\x12\x1f\n" + | |
| 384 | 393 | "\vlist_digest\x18\x0f \x01(\fR\n" + | |
| 385 | - | "listDigest\"9\n" + | |
| 394 | + | "listDigest\x12\x14\n" + | |
| 395 | + | "\x05store\x18\x10 \x01(\tR\x05store\"9\n" + | |
| 386 | 396 | "\x0fCIArtifactBlock\x12\x12\n" + | |
| 387 | 397 | "\x04size\x18\x01 \x01(\x03R\x04size\x12\x12\n" + | |
| 388 | 398 | "\x04file\x18\x02 \x01(\tR\x04file\"%\n" + | |
A gen/ermite/storage/release.pb.go +463 -0 generated file: collapsed
M go.mod +16 -1
| @@ -13,18 +13,22 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.2.0 | |
| 16 | - | bnema.dev/ermite-api v0.15.0 | |
| 16 | + | bnema.dev/ermite-api v0.16.0 | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.1.0 | |
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
| 20 | 20 | github.com/ProtonMail/go-crypto v1.4.1 | |
| 21 | 21 | github.com/a-h/templ v0.3.1020 | |
| 22 | 22 | 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 | 26 | github.com/bnema/kvconf v0.1.2 | |
| 24 | 27 | github.com/bnema/zerowrap v1.4.1 | |
| 25 | 28 | github.com/descope/virtualwebauthn v1.0.5 | |
| 26 | 29 | github.com/go-webauthn/webauthn v0.18.2 | |
| 27 | 30 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 31 | + | github.com/johannesboyne/gofakes3 v1.2.0 | |
| 28 | 32 | github.com/spf13/cobra v1.10.2 | |
| 29 | 33 | github.com/stretchr/testify v1.12.1 | |
| 30 | 34 | github.com/yuin/goldmark v1.8.6 | |
| @@ -61,6 +65,15 @@ require ( | |||
| 61 | 65 | github.com/air-verse/air v1.67.4 // indirect | |
| 62 | 66 | github.com/andybalholm/brotli v1.2.0 // indirect | |
| 63 | 67 | 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 | 77 | github.com/bep/godartsass/v2 v2.5.0 // indirect | |
| 65 | 78 | github.com/bep/golibsass v1.2.0 // indirect | |
| 66 | 79 | github.com/bits-and-blooms/bitset v1.24.5 // indirect | |
| @@ -134,6 +147,7 @@ require ( | |||
| 134 | 147 | github.com/rs/cors v1.11.1 // indirect | |
| 135 | 148 | github.com/rs/zerolog v1.35.1 // indirect | |
| 136 | 149 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | |
| 150 | + | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect | |
| 137 | 151 | github.com/segmentio/asm v1.2.1 // indirect | |
| 138 | 152 | github.com/segmentio/encoding v0.5.4 // indirect | |
| 139 | 153 | github.com/sirupsen/logrus v1.10.2 // indirect | |
| @@ -159,6 +173,7 @@ require ( | |||
| 159 | 173 | go.opentelemetry.io/otel v1.46.0 // indirect | |
| 160 | 174 | go.opentelemetry.io/otel/metric v1.46.0 // indirect | |
| 161 | 175 | go.opentelemetry.io/otel/trace v1.46.0 // indirect | |
| 176 | + | go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect | |
| 162 | 177 | go.uber.org/multierr v1.11.0 // indirect | |
| 163 | 178 | go.uber.org/zap v1.28.0 // indirect | |
| 164 | 179 | 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 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 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 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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
M internal/adapters/ciartifacts/ciartifacts.go +5 -171
| @@ -1,23 +1,12 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 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. | |
| 15 | 6 | package ciartifacts | |
| 16 | 7 | ||
| 17 | 8 | import ( | |
| 18 | 9 | "context" | |
| 19 | - | "crypto/sha256" | |
| 20 | - | "encoding/hex" | |
| 21 | 10 | "errors" | |
| 22 | 11 | "fmt" | |
| 23 | 12 | "io" | |
| @@ -39,9 +28,6 @@ const ( | |||
| 39 | 28 | idFormat = "%020d" | |
| 40 | 29 | blocksSuffix = ".blocks" | |
| 41 | 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 | 33 | // Store is the file-backed artifact store. | |
| @@ -54,15 +40,6 @@ func New(dir string) *Store { return &Store{root: dir} } | |||
| 54 | 40 | ||
| 55 | 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 | 43 | func (s *Store) runDir(runID int64) string { | |
| 67 | 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 | 52 | 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 | 55 | // Open opens the content at offset. | |
| 211 | 56 | func (s *Store) Open(_ context.Context, ref ports.CIArtifactRef, offset int64) (io.ReadCloser, error) { | |
| 212 | 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 | 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 | 76 | if !validRef(ref) { | |
| 232 | 77 | return nil | |
| 233 | 78 | } | |
| @@ -235,7 +80,7 @@ func (s *Store) Remove(ctx context.Context, ref ports.CIArtifactRef) error { | |||
| 235 | 80 | if err := os.Remove(s.contentPath(ref)); err != nil && !errors.Is(err, fs.ErrNotExist) { | |
| 236 | 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 | 84 | tmps, _ := filepath.Glob(filepath.Join(s.runDir(ref.RunID), tmpPrefix+fmt.Sprintf(idFormat, ref.ID)+"-*")) | |
| 240 | 85 | for _, t := range tmps { | |
| 241 | 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 | 93 | 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 | 96 | // RemoveRuns deletes the directories of the runs. | |
| 263 | 97 | func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error { | |
| 264 | 98 | var errs []error | |
M internal/adapters/ciartifacts/ciartifacts_test.go +8 -105
| @@ -3,13 +3,10 @@ | |||
| 3 | 3 | package ciartifacts_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "crypto/sha256" | |
| 7 | - | "encoding/hex" | |
| 8 | 6 | "errors" | |
| 9 | 7 | "io" | |
| 10 | 8 | "os" | |
| 11 | 9 | "path/filepath" | |
| 12 | - | "strings" | |
| 13 | 10 | "testing" | |
| 14 | 11 | ||
| 15 | 12 | "github.com/stretchr/testify/assert" | |
| @@ -22,11 +19,6 @@ import ( | |||
| 22 | 19 | ||
| 23 | 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 | 22 | func entries(t *testing.T, dir string) []string { | |
| 31 | 23 | t.Helper() | |
| 32 | 24 | es, err := os.ReadDir(dir) | |
| @@ -41,121 +33,32 @@ func entries(t *testing.T, dir string) []string { | |||
| 41 | 33 | return out | |
| 42 | 34 | } | |
| 43 | 35 | ||
| 44 | - | func TestBlocksAssembleInTheOrderOfTheList(t *testing.T) { | |
| 36 | + | func TestLegacyReadAndRemoval(t *testing.T) { | |
| 45 | 37 | ctx := t.Context() | |
| 46 | 38 | root := t.TempDir() | |
| 47 | 39 | 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)) | |
| 69 | 43 | r, err := s.Open(ctx, ref, 6) | |
| 70 | 44 | require.NoError(t, err) | |
| 71 | - | rest, err := io.ReadAll(r) | |
| 45 | + | data, err := io.ReadAll(r) | |
| 72 | 46 | require.NoError(t, err) | |
| 73 | 47 | 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)) | |
| 82 | 49 | ids, err := s.ArtifactIDs(ctx, 7) | |
| 83 | 50 | require.NoError(t, err) | |
| 84 | 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 | 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 | 54 | _, err = s.Open(ctx, ref, 0) | |
| 94 | 55 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 95 | 56 | require.NoError(t, s.RemoveRuns(ctx, []int64{7})) | |
| 96 | 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 | 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")) | |
| 152 | - | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 153 | - | _, err = s.Open(ctx, bad, 0) | |
| 59 | + | _, err := s.Open(ctx, bad, 0) | |
| 154 | 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 | 64 | func TestOrphanListingSeesEveryKindOfFile(t *testing.T) { | |
M internal/adapters/client/client.go +5 -0
| @@ -80,6 +80,11 @@ func (c *Client) Artifacts() ermiteconnect.ArtifactServiceClient { | |||
| 80 | 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 | 88 | // Tokens returns the TokenService client. | |
| 84 | 89 | func (c *Client) Tokens() ermiteconnect.TokenServiceClient { | |
| 85 | 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) { | |||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 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 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 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 | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | |
| 147 | 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge, | |
| 148 | 148 | })) | |
| 149 | 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 | 151 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge, | |
| 152 | 152 | })) | |
| 153 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
M internal/adapters/client/features.go +3 -1
| @@ -12,6 +12,8 @@ const ( | |||
| 12 | 12 | FeatureMarkReady = "merge_requests.ready" | |
| 13 | 13 | // FeatureIgnoreChecks permits merging with checks bypassed. | |
| 14 | 14 | FeatureIgnoreChecks = "merge_requests.ignore_checks" | |
| 15 | + | // FeatureReleases is ReleaseService (`ermite release`). | |
| 16 | + | FeatureReleases = "releases" | |
| 15 | 17 | // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`). | |
| 16 | 18 | FeatureRepoUpdate = "repo.update" | |
| 17 | 19 | // FeatureEventsResume is EventService.Watch with resume_after, which | |
| @@ -69,7 +71,7 @@ const ( | |||
| 69 | 71 | ||
| 70 | 72 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 71 | 73 | func UsedFeatures() []string { | |
| 72 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge} | |
| 74 | + | return []string{FeatureReleases, FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge} | |
| 73 | 75 | } | |
| 74 | 76 | ||
| 75 | 77 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
A internal/adapters/client/release_test.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package client_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "connectrpc.com/connect" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 17 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | type releaseAPI struct { | |
| 21 | + | ermiteconnect.UnimplementedReleaseServiceHandler | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (releaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) { | |
| 25 | + | if r.Header().Get("Authorization") != "Bearer secret" || r.Header().Get("User-Agent") != "ermite/test" { | |
| 26 | + | return nil, connect.NewError(connect.CodeUnauthenticated, nil) | |
| 27 | + | } | |
| 28 | + | return connect.NewResponse(&ermite.GetReleaseResponse{Release: &ermite.Release{Id: r.Msg.GetId(), Repo: r.Msg.GetRepo()}}), nil | |
| 29 | + | } | |
| 30 | + | func TestReleasesAccessor(t *testing.T) { | |
| 31 | + | mux := http.NewServeMux() | |
| 32 | + | p, h := ermiteconnect.NewReleaseServiceHandler(releaseAPI{}) | |
| 33 | + | mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h)) | |
| 34 | + | srv := httptest.NewServer(mux) | |
| 35 | + | t.Cleanup(srv.Close) | |
| 36 | + | c, err := client.New(client.Config{URL: srv.URL, Token: "secret", UserAgent: "ermite/test"}) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | res, err := c.Releases().GetRelease(t.Context(), connect.NewRequest(&ermite.GetReleaseRequest{Repo: "proj", Selector: &ermite.GetReleaseRequest_Id{Id: 7}})) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | assert.Equal(t, int64(7), res.Msg.GetRelease().GetId()) | |
| 41 | + | assert.Equal(t, "proj", res.Msg.GetRelease().GetRepo()) | |
| 42 | + | assert.Contains(t, client.UsedFeatures(), client.FeatureReleases) | |
| 43 | + | } | |
M internal/adapters/client/scopes_test.go +1 -0
| @@ -28,6 +28,7 @@ func TestScopeWireNames(t *testing.T) { | |||
| 28 | 28 | ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register", | |
| 29 | 29 | ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin", | |
| 30 | 30 | ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write", | |
| 31 | + | ermite.Scope_SCOPE_RELEASE_WRITE: "release:write", | |
| 31 | 32 | } | |
| 32 | 33 | for s, name := range want { | |
| 33 | 34 | assert.Equal(t, name, client.ScopeName(s)) | |
A internal/adapters/gitcli/tags.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | var _ ports.ReleaseTagWriter = (*Git)(nil) | |
| 16 | + | ||
| 17 | + | // CreateTag creates a lightweight tag without ever moving an existing ref. | |
| 18 | + | func (g *Git) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error { | |
| 19 | + | if _, err := domain.ParseReleaseTag(tag); err != nil { | |
| 20 | + | return err | |
| 21 | + | } | |
| 22 | + | dir, err := g.repoDir(repo) | |
| 23 | + | if err != nil { | |
| 24 | + | return err | |
| 25 | + | } | |
| 26 | + | ref := "refs/tags/" + tag | |
| 27 | + | _, err = g.run(ctx, "--git-dir="+dir, "update-ref", ref, target.String(), domain.ZeroOID) | |
| 28 | + | if err != nil { | |
| 29 | + | // Distinguish a concurrent creator from an unrelated Git failure. ResolveRevision | |
| 30 | + | // peels tags, so use show-ref to also recognize tags pointing at non-commits. | |
| 31 | + | if _, existsErr := g.run(ctx, "--git-dir="+dir, "show-ref", "--verify", "--", ref); existsErr == nil { | |
| 32 | + | return fmt.Errorf("tag %q in %q: %w", tag, repo, domain.ErrAlreadyExists) | |
| 33 | + | } | |
| 34 | + | if errors.Is(err, context.Canceled) { | |
| 35 | + | return err | |
| 36 | + | } | |
| 37 | + | return fmt.Errorf("create tag %q in %q: %w", tag, repo, err) | |
| 38 | + | } | |
| 39 | + | log := zerowrap.FromCtx(ctx) | |
| 40 | + | log.Info().Str("repo", repo.String()).Str("tag", tag).Msg("release tag created") | |
| 41 | + | return nil | |
| 42 | + | } | |
A internal/adapters/gitcli/tags_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCreateReleaseTag(t *testing.T) { | |
| 14 | + | g, data := newGit(t) | |
| 15 | + | ctx := context.Background() | |
| 16 | + | m := newMergeFixture(t, data) | |
| 17 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 18 | + | require.NoError(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.a)) | |
| 19 | + | rev, err := domain.ParseRevision("refs/tags/v1.0") | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | got, err := g.ResolveRevision(ctx, m.f.Name, rev) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | require.Equal(t, m.a, got.CommitID) | |
| 24 | + | require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.d), domain.ErrAlreadyExists) | |
| 25 | + | got, err = g.ResolveRevision(ctx, m.f.Name, rev) | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | require.Equal(t, m.a, got.CommitID) | |
| 28 | + | for _, tag := range []string{"", "../bad", "bad^{commit}", "bad\\name"} { | |
| 29 | + | require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, tag, m.d), domain.ErrInvalid, tag) | |
| 30 | + | } | |
| 31 | + | } | |
M internal/adapters/githubapi/handler.go +23 -2
| @@ -68,6 +68,10 @@ const ( | |||
| 68 | 68 | type Config struct { | |
| 69 | 69 | Auth ports.AuthService | |
| 70 | 70 | API ports.GitHubAPIService | |
| 71 | + | // Releases and ReleaseDownload enable the release REST routes. Nil services | |
| 72 | + | // leave their routes unavailable with GitHub's 404 JSON. | |
| 73 | + | Releases ports.GitHubReleaseAPI | |
| 74 | + | ReleaseDownload ports.ReleaseService | |
| 71 | 75 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 72 | 76 | TrustedProxies []netip.Prefix | |
| 73 | 77 | // Limiter counts failed authentications; nil builds a default one. | |
| @@ -124,8 +128,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 124 | 128 | notFound(w) | |
| 125 | 129 | return | |
| 126 | 130 | } | |
| 127 | - | if r.Method != http.MethodGet && r.Method != http.MethodHead { | |
| 128 | - | w.Header().Set("Allow", "GET, HEAD") | |
| 131 | + | if strings.HasPrefix(kind, "release") && h.cfg.Releases == nil { | |
| 132 | + | notFound(w) | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | allowed := releaseMethods(kind) | |
| 136 | + | if !methodAllowed(r.Method, allowed) { | |
| 137 | + | w.Header().Set("Allow", allowed) | |
| 129 | 138 | jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed") | |
| 130 | 139 | return | |
| 131 | 140 | } | |
| @@ -148,6 +157,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 148 | 157 | return | |
| 149 | 158 | } | |
| 150 | 159 | ||
| 160 | + | if strings.HasPrefix(kind, "release") { | |
| 161 | + | h.releaseRequest(w, r, p, kind, owner, repo, ref, clientlimit.Key(addr)) | |
| 162 | + | return | |
| 163 | + | } | |
| 151 | 164 | switch kind { | |
| 152 | 165 | case "repo": | |
| 153 | 166 | body, err := h.cfg.API.GetRepo(ctx, p, owner, repo) | |
| @@ -172,6 +185,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 172 | 185 | ||
| 173 | 186 | // route recognises the endpoints. ref is "" for /tarball. | |
| 174 | 187 | func route(rest string) (kind, owner, repo, ref string, ok bool) { | |
| 188 | + | // go-github's enterprise client (used by GoReleaser) appends api/v3 | |
| 189 | + | // to a custom API base. Keep the canonical URLs, accepting that alias too. | |
| 190 | + | if strings.HasPrefix(rest, "/api/v3/") { | |
| 191 | + | rest = strings.TrimPrefix(rest, "/api/v3") | |
| 192 | + | } | |
| 193 | + | if kind, owner, repo, ref, ok = releaseRoute(rest); ok { | |
| 194 | + | return | |
| 195 | + | } | |
| 175 | 196 | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | |
| 176 | 197 | if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" { | |
| 177 | 198 | return "", "", "", "", false | |
A internal/adapters/githubapi/releases.go +413 -0
| @@ -0,0 +1,413 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package githubapi | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "io" | |
| 10 | + | "mime" | |
| 11 | + | "net/http" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "unicode" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/httprange" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "github.com/bnema/zerowrap" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func releaseRoute(rest string) (kind, owner, repo, ref string, ok bool) { | |
| 23 | + | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | |
| 24 | + | upload := len(parts) > 0 && parts[0] == "uploads" | |
| 25 | + | if upload { | |
| 26 | + | parts = parts[1:] | |
| 27 | + | } | |
| 28 | + | if len(parts) < 4 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" || parts[3] != "releases" { | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | owner, repo = parts[1], parts[2] | |
| 32 | + | if upload { | |
| 33 | + | if len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets" { | |
| 34 | + | return "release-upload", owner, repo, parts[4], true | |
| 35 | + | } | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | switch { | |
| 39 | + | case len(parts) == 4: | |
| 40 | + | return "release-list", owner, repo, "", true | |
| 41 | + | case len(parts) == 5 && parts[4] == "latest": | |
| 42 | + | return "release-latest", owner, repo, "", true | |
| 43 | + | case len(parts) >= 6 && parts[4] == "tags" && strings.Join(parts[5:], "/") != "": | |
| 44 | + | return "release-tag", owner, repo, strings.Join(parts[5:], "/"), true | |
| 45 | + | case len(parts) == 5 && isNumber(parts[4]): | |
| 46 | + | return "release-id", owner, repo, parts[4], true | |
| 47 | + | case len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets": | |
| 48 | + | return "release-assets", owner, repo, parts[4], true | |
| 49 | + | case len(parts) == 6 && parts[4] == "assets" && isNumber(parts[5]): | |
| 50 | + | return "release-asset", owner, repo, parts[5], true | |
| 51 | + | } | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | func releaseMethods(kind string) string { | |
| 55 | + | switch kind { | |
| 56 | + | case "release-list": | |
| 57 | + | return "GET, HEAD, POST" | |
| 58 | + | case "release-id", "release-asset": | |
| 59 | + | return "GET, HEAD, PATCH, DELETE" | |
| 60 | + | case "release-upload": | |
| 61 | + | return "POST" | |
| 62 | + | default: | |
| 63 | + | return "GET, HEAD" | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | func methodAllowed(method, allow string) bool { | |
| 67 | + | for _, m := range strings.Split(allow, ", ") { | |
| 68 | + | if method == m { | |
| 69 | + | return true | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | func releaseJSON(w http.ResponseWriter, r *http.Request, status int, body []byte) { | |
| 75 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 76 | + | w.Header().Set("Content-Length", strconv.Itoa(len(body))) | |
| 77 | + | w.WriteHeader(status) | |
| 78 | + | if r.Method != http.MethodHead { | |
| 79 | + | _, _ = w.Write(body) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | func releaseMessage(w http.ResponseWriter, status int, message string) { | |
| 83 | + | b, _ := json.Marshal(struct { | |
| 84 | + | Message string `json:"message"` | |
| 85 | + | }{message}) | |
| 86 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 87 | + | w.WriteHeader(status) | |
| 88 | + | _, _ = w.Write(b) | |
| 89 | + | } | |
| 90 | + | func releaseBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { | |
| 91 | + | b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) | |
| 92 | + | if err != nil { | |
| 93 | + | var tooLarge *http.MaxBytesError | |
| 94 | + | if errors.As(err, &tooLarge) { | |
| 95 | + | releaseMessage(w, 422, "Body too large") | |
| 96 | + | } else { | |
| 97 | + | releaseMessage(w, 422, "Validation Failed") | |
| 98 | + | } | |
| 99 | + | return nil, false | |
| 100 | + | } | |
| 101 | + | return b, true | |
| 102 | + | } | |
| 103 | + | func (h *Handler) releaseRequest(w http.ResponseWriter, r *http.Request, p domain.Principal, kind, owner, repo, ref, client string) { | |
| 104 | + | ctx := r.Context() | |
| 105 | + | api := h.cfg.Releases | |
| 106 | + | id, _ := strconv.ParseInt(ref, 10, 64) | |
| 107 | + | q := r.URL.Query() | |
| 108 | + | page, _ := strconv.Atoi(q.Get("page")) | |
| 109 | + | perPage, _ := strconv.Atoi(q.Get("per_page")) | |
| 110 | + | var body []byte | |
| 111 | + | var err error | |
| 112 | + | var link string | |
| 113 | + | status := http.StatusOK | |
| 114 | + | if kind == "release-asset" && (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/octet-stream") { | |
| 115 | + | if h.cfg.ReleaseDownload == nil { | |
| 116 | + | notFound(w) | |
| 117 | + | return | |
| 118 | + | } | |
| 119 | + | h.downloadRelease(w, r, p, owner, repo, id, client) | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | switch kind { | |
| 123 | + | case "release-list": | |
| 124 | + | if r.Method == http.MethodPost { | |
| 125 | + | b, ok := releaseBody(w, r) | |
| 126 | + | if !ok { | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | body, err = api.CreateRelease(ctx, p, owner, repo, b) | |
| 130 | + | status = 201 | |
| 131 | + | } else { | |
| 132 | + | body, link, err = api.ListReleases(ctx, p, owner, repo, page, perPage) | |
| 133 | + | } | |
| 134 | + | case "release-latest": | |
| 135 | + | body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Latest: true}) | |
| 136 | + | case "release-tag": | |
| 137 | + | body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Tag: ref}) | |
| 138 | + | case "release-id": | |
| 139 | + | switch r.Method { | |
| 140 | + | case http.MethodPatch: | |
| 141 | + | b, ok := releaseBody(w, r) | |
| 142 | + | if !ok { | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | body, err = api.UpdateRelease(ctx, p, owner, repo, id, b) | |
| 146 | + | case http.MethodDelete: | |
| 147 | + | err = api.DeleteRelease(ctx, p, owner, repo, id) | |
| 148 | + | status = 204 | |
| 149 | + | default: | |
| 150 | + | body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{ID: id}) | |
| 151 | + | } | |
| 152 | + | case "release-assets": | |
| 153 | + | body, link, err = api.ListAssets(ctx, p, owner, repo, id, page, perPage) | |
| 154 | + | case "release-upload": | |
| 155 | + | if r.ContentLength < 0 || (r.ContentLength == 0 && r.Header.Get("Content-Length") == "") { | |
| 156 | + | releaseMessage(w, 411, "Length Required") | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | if q.Get("name") == "" { | |
| 160 | + | h.failRelease(ctx, w, p, domain.ErrInvalid, "release-upload", "name") | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | ct := r.Header.Get("Content-Type") | |
| 164 | + | if ct == "" { | |
| 165 | + | ct = "application/octet-stream" | |
| 166 | + | } | |
| 167 | + | body, err = api.UploadAsset(ctx, p, owner, repo, id, q.Get("name"), q.Get("label"), ct, r.ContentLength, io.LimitReader(r.Body, r.ContentLength)) | |
| 168 | + | status = 201 | |
| 169 | + | case "release-asset": | |
| 170 | + | switch r.Method { | |
| 171 | + | case http.MethodPatch: | |
| 172 | + | b, ok := releaseBody(w, r) | |
| 173 | + | if !ok { | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | body, err = api.UpdateAsset(ctx, p, owner, repo, id, b) | |
| 177 | + | case http.MethodDelete: | |
| 178 | + | err = api.DeleteAsset(ctx, p, owner, repo, id) | |
| 179 | + | status = 204 | |
| 180 | + | default: | |
| 181 | + | body, err = api.GetAsset(ctx, p, owner, repo, id) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | if err != nil { | |
| 185 | + | h.failRelease(ctx, w, p, err, kind, "") | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | if link != "" { | |
| 189 | + | w.Header().Set("Link", link) | |
| 190 | + | } | |
| 191 | + | if status == 204 { | |
| 192 | + | w.WriteHeader(status) | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | releaseJSON(w, r, status, body) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // failRelease translates use-case failures to GitHub's status and error JSON; | |
| 199 | + | // authorization and limits have already been decided by ReleaseService. | |
| 200 | + | // | |
| 201 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps domain failures and the principal kind to the GitHub HTTP error contract; no access or quota decision is made here | |
| 202 | + | func (h *Handler) failRelease(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error, kind, field string) { | |
| 203 | + | switch { | |
| 204 | + | case errors.Is(err, domain.ErrNotFound): | |
| 205 | + | notFound(w) | |
| 206 | + | case errors.Is(err, domain.ErrUnauthenticated): | |
| 207 | + | releaseMessage(w, 401, "Bad credentials") | |
| 208 | + | case errors.Is(err, domain.ErrForbidden): | |
| 209 | + | msg := "Forbidden" | |
| 210 | + | if p.ViaJobToken() { | |
| 211 | + | msg = "Resource not accessible by integration" | |
| 212 | + | } | |
| 213 | + | releaseMessage(w, 403, msg) | |
| 214 | + | case errors.Is(err, domain.ErrResourceExhausted): | |
| 215 | + | if strings.Contains(err.Error(), "too many uploads in progress") { | |
| 216 | + | w.Header().Set("Retry-After", retryAfter) | |
| 217 | + | releaseMessage(w, 429, err.Error()) | |
| 218 | + | } else { | |
| 219 | + | releaseMessage(w, 422, err.Error()) | |
| 220 | + | } | |
| 221 | + | case errors.Is(err, domain.ErrAlreadyExists), domain.IsInvalid(err), errors.Is(err, domain.ErrPreconditionFailed): | |
| 222 | + | writeReleaseValidation(w, err, kind, field) | |
| 223 | + | case errors.Is(err, context.Canceled): | |
| 224 | + | default: | |
| 225 | + | log := zerowrap.FromCtx(ctx) | |
| 226 | + | log.Error().Err(err).Msg("github release request failed") | |
| 227 | + | releaseMessage(w, 500, "Server Error") | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | type releaseValidationError struct { | |
| 232 | + | Resource string `json:"resource"` | |
| 233 | + | Code string `json:"code"` | |
| 234 | + | Field string `json:"field,omitempty"` | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func writeReleaseValidation(w http.ResponseWriter, err error, kind, field string) { | |
| 238 | + | resource, code, doc := "Release", "invalid", "" | |
| 239 | + | asset := kind == "release-upload" || kind == "release-asset" | |
| 240 | + | if asset { | |
| 241 | + | resource = "ReleaseAsset" | |
| 242 | + | } | |
| 243 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |
| 244 | + | code = "already_exists" | |
| 245 | + | field = "tag_name" | |
| 246 | + | if asset { | |
| 247 | + | field = "name" | |
| 248 | + | } | |
| 249 | + | if kind == "release-upload" { | |
| 250 | + | doc = "https://docs.github.com/rest/releases/assets#upload-a-release-asset" | |
| 251 | + | } | |
| 252 | + | } else if errors.Is(err, domain.ErrPreconditionFailed) { | |
| 253 | + | field = "target_commitish" | |
| 254 | + | } else if field == "" { | |
| 255 | + | field = releaseErrorField(err.Error()) | |
| 256 | + | } | |
| 257 | + | b, _ := json.Marshal(struct { | |
| 258 | + | Message string `json:"message"` | |
| 259 | + | Errors []releaseValidationError `json:"errors"` | |
| 260 | + | DocumentationURL string `json:"documentation_url,omitempty"` | |
| 261 | + | }{"Validation Failed", []releaseValidationError{{resource, code, field}}, doc}) | |
| 262 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 263 | + | w.WriteHeader(422) | |
| 264 | + | _, _ = w.Write(b) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // releaseErrorField recognises the wire fields and domain validation vocabulary. | |
| 268 | + | // Errors without a named field omit it rather than guessing. | |
| 269 | + | func releaseErrorField(text string) string { | |
| 270 | + | for _, f := range []string{"target_commitish", "make_latest", "tag_name", "prerelease", "draft", "content_type", "label", "body"} { | |
| 271 | + | if strings.Contains(text, f) { | |
| 272 | + | return f | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | if strings.Contains(text, "tag name") { | |
| 276 | + | return "tag_name" | |
| 277 | + | } | |
| 278 | + | if strings.Contains(text, "media type") { | |
| 279 | + | return "content_type" | |
| 280 | + | } | |
| 281 | + | if strings.Contains(text, "name") { | |
| 282 | + | return "name" | |
| 283 | + | } | |
| 284 | + | return "" | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func releaseContentDisposition(name string) string { | |
| 288 | + | name = strings.Map(func(r rune) rune { | |
| 289 | + | if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar { | |
| 290 | + | return -1 | |
| 291 | + | } | |
| 292 | + | return r | |
| 293 | + | }, name) | |
| 294 | + | if name == "" || name == "." || name == ".." { | |
| 295 | + | name = "asset" | |
| 296 | + | } | |
| 297 | + | ascii := true | |
| 298 | + | fallback := strings.Map(func(r rune) rune { | |
| 299 | + | if r > unicode.MaxASCII { | |
| 300 | + | ascii = false | |
| 301 | + | return '_' | |
| 302 | + | } | |
| 303 | + | return r | |
| 304 | + | }, name) | |
| 305 | + | // Quote even token-safe names, keeping a consistent RFC 6266 representation. | |
| 306 | + | cd := "attachment; filename=" + strconv.Quote(fallback) | |
| 307 | + | if !ascii { | |
| 308 | + | const digits = "0123456789ABCDEF" | |
| 309 | + | var b strings.Builder | |
| 310 | + | for i := 0; i < len(name); i++ { | |
| 311 | + | c := name[i] | |
| 312 | + | if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 { | |
| 313 | + | b.WriteByte(c) | |
| 314 | + | } else { | |
| 315 | + | b.WriteByte('%') | |
| 316 | + | b.WriteByte(digits[c>>4]) | |
| 317 | + | b.WriteByte(digits[c&15]) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | cd += "; filename*=UTF-8''" + b.String() | |
| 321 | + | } | |
| 322 | + | return cd | |
| 323 | + | } | |
| 324 | + | func setReleaseDownloadHeaders(w http.ResponseWriter, a domain.ReleaseAsset) string { | |
| 325 | + | ct := a.ContentType | |
| 326 | + | media, _, err := mime.ParseMediaType(ct) | |
| 327 | + | media = strings.ToLower(media) | |
| 328 | + | if err != nil || strings.HasPrefix(media, "text/html") || media == "application/javascript" || media == "image/svg+xml" { | |
| 329 | + | ct = "application/octet-stream" | |
| 330 | + | } | |
| 331 | + | w.Header().Set("Content-Type", ct) | |
| 332 | + | w.Header().Set("Content-Disposition", releaseContentDisposition(a.Name)) | |
| 333 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 334 | + | w.Header().Set("Content-Security-Policy", "sandbox") | |
| 335 | + | w.Header().Set("Accept-Ranges", "bytes") | |
| 336 | + | etag := `"sha256-` + strings.TrimPrefix(a.Digest, "sha256:") + `"` | |
| 337 | + | w.Header().Set("ETag", etag) | |
| 338 | + | w.Header().Set("Last-Modified", a.UpdatedAt.UTC().Format(http.TimeFormat)) | |
| 339 | + | return etag | |
| 340 | + | } | |
| 341 | + | func (h *Handler) downloadRelease(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo string, id int64, client string) { | |
| 342 | + | ctx := r.Context() | |
| 343 | + | a, err := h.cfg.Releases.AssetByID(ctx, p, owner, repo, id) | |
| 344 | + | if err != nil { | |
| 345 | + | h.failRelease(ctx, w, p, err, "release-asset", "") | |
| 346 | + | return | |
| 347 | + | } | |
| 348 | + | if r.Method != http.MethodHead { | |
| 349 | + | release, ok := h.acquire(w, client) | |
| 350 | + | if !ok { | |
| 351 | + | return | |
| 352 | + | } | |
| 353 | + | defer release() | |
| 354 | + | } | |
| 355 | + | etag := setReleaseDownloadHeaders(w, a) | |
| 356 | + | if r.Header.Get("If-None-Match") == etag { | |
| 357 | + | w.WriteHeader(304) | |
| 358 | + | return | |
| 359 | + | } | |
| 360 | + | offset, length, partial, err := httprange.Select(r, a.Size, etag) | |
| 361 | + | if err != nil { | |
| 362 | + | w.Header().Set("Content-Range", httprange.Unsatisfied(a.Size)) | |
| 363 | + | w.WriteHeader(416) | |
| 364 | + | return | |
| 365 | + | } | |
| 366 | + | status := 200 | |
| 367 | + | if partial { | |
| 368 | + | status = 206 | |
| 369 | + | w.Header().Set("Content-Range", httprange.ContentRange(offset, length, a.Size)) | |
| 370 | + | } | |
| 371 | + | w.Header().Set("Content-Length", strconv.FormatInt(length, 10)) | |
| 372 | + | if r.Method == http.MethodHead { | |
| 373 | + | w.WriteHeader(status) | |
| 374 | + | return | |
| 375 | + | } | |
| 376 | + | // Slot failures and pre-stream failures are JSON, not a binary response. | |
| 377 | + | clearHeaders := func() { | |
| 378 | + | for _, k := range []string{"Content-Length", "Content-Range", "Content-Disposition", "ETag", "Last-Modified", "Accept-Ranges", "Content-Security-Policy"} { | |
| 379 | + | w.Header().Del(k) | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | writer := &idleWriter{w: w, rc: http.NewResponseController(w), idle: h.idle} | |
| 383 | + | started := false | |
| 384 | + | err = h.cfg.ReleaseDownload.DownloadAsset(ctx, p, a.Repo, ports.AssetRef{ID: id}, offset, length, func(chunk ports.AssetChunk) error { | |
| 385 | + | if len(chunk.Data) == 0 { | |
| 386 | + | return nil | |
| 387 | + | } | |
| 388 | + | if !started { | |
| 389 | + | w.WriteHeader(status) | |
| 390 | + | started = true | |
| 391 | + | } | |
| 392 | + | _, e := writer.Write(chunk.Data) | |
| 393 | + | return e | |
| 394 | + | }) | |
| 395 | + | if err == nil { | |
| 396 | + | if !started { | |
| 397 | + | w.WriteHeader(status) | |
| 398 | + | } | |
| 399 | + | return | |
| 400 | + | } | |
| 401 | + | if started { | |
| 402 | + | log := zerowrap.FromCtx(ctx) | |
| 403 | + | log.Warn().Err(err).Msg("github release download cut short") | |
| 404 | + | panic(http.ErrAbortHandler) | |
| 405 | + | } | |
| 406 | + | clearHeaders() | |
| 407 | + | if errors.Is(err, domain.ErrOutOfRange) { | |
| 408 | + | w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10)) | |
| 409 | + | w.WriteHeader(416) | |
| 410 | + | return | |
| 411 | + | } | |
| 412 | + | h.failRelease(ctx, w, p, err, "release-asset", "") | |
| 413 | + | } | |
A internal/adapters/githubapi/releases_test.go +466 -0
| @@ -0,0 +1,466 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package githubapi | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "net/http" | |
| 12 | + | "net/http/httptest" | |
| 13 | + | "net/url" | |
| 14 | + | "os" | |
| 15 | + | "strings" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 22 | + | "github.com/stretchr/testify/mock" | |
| 23 | + | "github.com/stretchr/testify/require" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | const releasePath = "/repos/acme/proj/releases" | |
| 27 | + | const releaseAssetPath = releasePath + "/assets/7" | |
| 28 | + | const releaseUploadPath = "/uploads/repos/acme/proj/releases/4/assets" | |
| 29 | + | ||
| 30 | + | func releaseRequest(t *testing.T, h *Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { | |
| 31 | + | t.Helper() | |
| 32 | + | req := httptest.NewRequest(method, Prefix+path, strings.NewReader(body)) | |
| 33 | + | for k, v := range headers { | |
| 34 | + | req.Header.Set(k, v) | |
| 35 | + | } | |
| 36 | + | w := httptest.NewRecorder() | |
| 37 | + | h.ServeHTTP(w, req) | |
| 38 | + | return w | |
| 39 | + | } | |
| 40 | + | func TestReleaseRoutes(t *testing.T) { | |
| 41 | + | cases := []struct{ path, kind, ref, allow string }{ | |
| 42 | + | {releasePath, "release-list", "", "GET, HEAD, POST"}, | |
| 43 | + | {releasePath + "/latest", "release-latest", "", "GET, HEAD"}, | |
| 44 | + | {releasePath + "/tags/v1/patch", "release-tag", "v1/patch", "GET, HEAD"}, | |
| 45 | + | {releasePath + "/4", "release-id", "4", "GET, HEAD, PATCH, DELETE"}, | |
| 46 | + | {releasePath + "/4/assets", "release-assets", "4", "GET, HEAD"}, | |
| 47 | + | {releaseAssetPath, "release-asset", "7", "GET, HEAD, PATCH, DELETE"}, | |
| 48 | + | {releaseUploadPath, "release-upload", "4", "POST"}, | |
| 49 | + | } | |
| 50 | + | for _, tc := range cases { | |
| 51 | + | t.Run(tc.kind, func(t *testing.T) { | |
| 52 | + | kind, owner, repo, ref, ok := route(tc.path) | |
| 53 | + | require.True(t, ok) | |
| 54 | + | require.Equal(t, tc.kind, kind) | |
| 55 | + | require.Equal(t, "acme", owner) | |
| 56 | + | require.Equal(t, "proj", repo) | |
| 57 | + | require.Equal(t, tc.ref, ref) | |
| 58 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 59 | + | h := New(Config{Releases: api}) | |
| 60 | + | for _, method := range []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"} { | |
| 61 | + | if methodAllowed(method, tc.allow) { | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | w := releaseRequest(t, h, method, tc.path, "", nil) | |
| 65 | + | require.Equal(t, 405, w.Code) | |
| 66 | + | require.Equal(t, tc.allow, w.Header().Get("Allow")) | |
| 67 | + | } | |
| 68 | + | w := releaseRequest(t, New(Config{}), "GET", tc.path, "", nil) | |
| 69 | + | require.Equal(t, 404, w.Code) | |
| 70 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | for _, path := range []string{releasePath + "/0", releasePath + "/-1", releasePath + "/01", releasePath + "/9223372036854775808", releasePath + "/tags/", releasePath + "/4/extra", releaseUploadPath + "/extra", "/uploads" + releasePath} { | |
| 74 | + | _, _, _, _, ok := route(path) | |
| 75 | + | require.False(t, ok, path) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // GoReleaser's go-github enterprise client appends api/v3 to the documented | |
| 80 | + | // custom API URL. The alias must dispatch writes through the same auth/service. | |
| 81 | + | func TestReleaseEnterpriseBase(t *testing.T) { | |
| 82 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 83 | + | h := New(Config{Releases: api}) | |
| 84 | + | body := []byte(`{"tag_name":"v1"}`) | |
| 85 | + | api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", body).Return([]byte(`{"id":4}`), nil).Once() | |
| 86 | + | w := releaseRequest(t, h, "POST", "/api/v3"+releasePath, string(body), nil) | |
| 87 | + | require.Equal(t, 201, w.Code) | |
| 88 | + | require.JSONEq(t, `{"id":4}`, w.Body.String()) | |
| 89 | + | for _, path := range []string{releasePath, releasePath + "/4", releasePath + "/4/assets", releaseAssetPath, releasePath + "/latest", releasePath + "/tags/v1/patch"} { | |
| 90 | + | kind, owner, repo, ref, ok := route(path) | |
| 91 | + | aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK := route("/api/v3" + path) | |
| 92 | + | require.Equal(t, []any{kind, owner, repo, ref, ok}, []any{aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK}) | |
| 93 | + | } | |
| 94 | + | _, _, _, _, ok := route("/api/v3repos/acme/proj/releases") | |
| 95 | + | require.False(t, ok) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestReleaseDispatch(t *testing.T) { | |
| 99 | + | for _, method := range []string{"GET", "HEAD"} { | |
| 100 | + | t.Run(method, func(t *testing.T) { | |
| 101 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 102 | + | h := New(Config{Releases: api}) | |
| 103 | + | api.EXPECT().ListReleases(mock.Anything, domain.Anonymous(), "acme", "proj", 2, 10).Return([]byte(`[]`), `<next>; rel="next"`, nil).Once() | |
| 104 | + | w := releaseRequest(t, h, method, releasePath+"?page=2&per_page=10", "", nil) | |
| 105 | + | require.Equal(t, 200, w.Code) | |
| 106 | + | require.Equal(t, `<next>; rel="next"`, w.Header().Get("Link")) | |
| 107 | + | require.Equal(t, "2", w.Header().Get("Content-Length")) | |
| 108 | + | if method == "HEAD" { | |
| 109 | + | require.Empty(t, w.Body.String()) | |
| 110 | + | } else { | |
| 111 | + | require.Equal(t, "[]", w.Body.String()) | |
| 112 | + | } | |
| 113 | + | for _, tc := range []struct { | |
| 114 | + | path string | |
| 115 | + | ref ports.ReleaseRef | |
| 116 | + | }{{releasePath + "/latest", ports.ReleaseRef{Latest: true}}, {releasePath + "/tags/v1/x", ports.ReleaseRef{Tag: "v1/x"}}, {releasePath + "/4", ports.ReleaseRef{ID: 4}}} { | |
| 117 | + | api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", tc.ref).Return([]byte(`{"id":4}`), nil).Once() | |
| 118 | + | w = releaseRequest(t, h, method, tc.path, "", nil) | |
| 119 | + | require.Equal(t, 200, w.Code) | |
| 120 | + | } | |
| 121 | + | api.EXPECT().ListAssets(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), 0, 0).Return([]byte(`[]`), "asset-link", nil).Once() | |
| 122 | + | w = releaseRequest(t, h, method, releasePath+"/4/assets", "", nil) | |
| 123 | + | require.Equal(t, 200, w.Code) | |
| 124 | + | require.Equal(t, "asset-link", w.Header().Get("Link")) | |
| 125 | + | api.EXPECT().GetAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return([]byte(`{"id":7}`), nil).Once() | |
| 126 | + | w = releaseRequest(t, h, method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json"}) | |
| 127 | + | require.Equal(t, 200, w.Code) | |
| 128 | + | }) | |
| 129 | + | } | |
| 130 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 131 | + | h := New(Config{Releases: api}) | |
| 132 | + | b := []byte(`{"tag_name":"v1"}`) | |
| 133 | + | api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", b).Return([]byte(`{"id":4}`), nil).Once() | |
| 134 | + | w := releaseRequest(t, h, "POST", releasePath, string(b), nil) | |
| 135 | + | require.Equal(t, 201, w.Code) | |
| 136 | + | require.Equal(t, `{"id":4}`, w.Body.String()) | |
| 137 | + | api.EXPECT().UpdateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), b).Return([]byte(`{"id":4}`), nil).Once() | |
| 138 | + | w = releaseRequest(t, h, "PATCH", releasePath+"/4", string(b), nil) | |
| 139 | + | require.Equal(t, 200, w.Code) | |
| 140 | + | api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), b).Return([]byte(`{"id":7}`), nil).Once() | |
| 141 | + | w = releaseRequest(t, h, "PATCH", releaseAssetPath, string(b), nil) | |
| 142 | + | require.Equal(t, 200, w.Code) | |
| 143 | + | api.EXPECT().DeleteRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4)).Return(nil).Once() | |
| 144 | + | w = releaseRequest(t, h, "DELETE", releasePath+"/4", "", nil) | |
| 145 | + | require.Equal(t, 204, w.Code) | |
| 146 | + | require.Empty(t, w.Body.String()) | |
| 147 | + | api.EXPECT().DeleteAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(nil).Once() | |
| 148 | + | w = releaseRequest(t, h, "DELETE", releaseAssetPath, "", nil) | |
| 149 | + | require.Equal(t, 204, w.Code) | |
| 150 | + | require.Empty(t, w.Body.String()) | |
| 151 | + | } | |
| 152 | + | func TestReleaseUpload(t *testing.T) { | |
| 153 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 154 | + | h := New(Config{Releases: api}) | |
| 155 | + | req := httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a", strings.NewReader("abc")) | |
| 156 | + | req.ContentLength = -1 | |
| 157 | + | w := httptest.NewRecorder() | |
| 158 | + | h.ServeHTTP(w, req) | |
| 159 | + | require.Equal(t, 411, w.Code) | |
| 160 | + | require.Equal(t, `{"message":"Length Required"}`, w.Body.String()) | |
| 161 | + | // HTTP distinguishes an omitted length from an explicitly empty asset. | |
| 162 | + | w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", nil) | |
| 163 | + | require.Equal(t, 411, w.Code) | |
| 164 | + | api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "empty", "", "application/octet-stream", int64(0), mock.Anything).Return([]byte(`{"id":8}`), nil).Once() | |
| 165 | + | w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", map[string]string{"Content-Length": "0"}) | |
| 166 | + | require.Equal(t, 201, w.Code) | |
| 167 | + | w = releaseRequest(t, h, "POST", releaseUploadPath, "abc", nil) | |
| 168 | + | require.Equal(t, 422, w.Code) | |
| 169 | + | require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String()) | |
| 170 | + | for _, ct := range []string{"", "text/plain"} { | |
| 171 | + | expectedType := ct | |
| 172 | + | if ct == "" { | |
| 173 | + | expectedType = "application/octet-stream" | |
| 174 | + | } | |
| 175 | + | api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "a b", "label", expectedType, int64(3), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _, _ string, _ int64, _, _, _ string, size int64, r io.Reader) ([]byte, error) { | |
| 176 | + | got, err := io.ReadAll(r) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | require.Equal(t, "abc", string(got)) | |
| 179 | + | return []byte(`{"id":7}`), nil | |
| 180 | + | }).Once() | |
| 181 | + | req = httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a%20b&label=label", strings.NewReader("abcdef")) | |
| 182 | + | req.ContentLength = 3 | |
| 183 | + | req.Header.Set("Content-Type", ct) | |
| 184 | + | w = httptest.NewRecorder() | |
| 185 | + | h.ServeHTTP(w, req) | |
| 186 | + | require.Equal(t, 201, w.Code) | |
| 187 | + | require.Equal(t, `{"id":7}`, w.Body.String()) | |
| 188 | + | } | |
| 189 | + | // The real response template expands to precisely the upload route used above. | |
| 190 | + | b, err := os.ReadFile("testdata/releases/release.json") | |
| 191 | + | require.NoError(t, err) | |
| 192 | + | var object struct { | |
| 193 | + | UploadURL string `json:"upload_url"` | |
| 194 | + | } | |
| 195 | + | require.NoError(t, json.Unmarshal(b, &object)) | |
| 196 | + | expanded := strings.Split(object.UploadURL, "{")[0] + "?name=" + url.QueryEscape("archive.tar.gz") | |
| 197 | + | u, err := url.Parse(expanded) | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | kind, owner, repo, ref, ok := route(strings.TrimPrefix(u.Path, Prefix)) | |
| 200 | + | require.True(t, ok) | |
| 201 | + | require.Equal(t, "release-upload", kind) | |
| 202 | + | require.Equal(t, "acme", owner) | |
| 203 | + | require.Equal(t, "releases", repo) | |
| 204 | + | require.Equal(t, "1", ref) | |
| 205 | + | } | |
| 206 | + | func TestReleaseBodyLimit(t *testing.T) { | |
| 207 | + | h := New(Config{Releases: mocks.NewGitHubReleaseAPI(t)}) | |
| 208 | + | for _, tc := range []struct{ method, path string }{{"POST", releasePath}, {"PATCH", releasePath + "/4"}, {"PATCH", releaseAssetPath}} { | |
| 209 | + | w := releaseRequest(t, h, tc.method, tc.path, strings.Repeat("x", (1<<20)+1), nil) | |
| 210 | + | require.Equal(t, 422, w.Code) | |
| 211 | + | require.Equal(t, `{"message":"Body too large"}`, w.Body.String()) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | func TestReleaseErrors(t *testing.T) { | |
| 215 | + | rn, err := domain.ParseRepoName("proj") | |
| 216 | + | require.NoError(t, err) | |
| 217 | + | job := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: rn, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}}) | |
| 218 | + | cases := []struct { | |
| 219 | + | name, kind string | |
| 220 | + | p domain.Principal | |
| 221 | + | err error | |
| 222 | + | status int | |
| 223 | + | body string | |
| 224 | + | }{ | |
| 225 | + | {"hidden draft", "release-id", domain.Anonymous(), domain.ErrNotFound, 404, notFoundBody}, | |
| 226 | + | {"anonymous write", "release-list", domain.Anonymous(), domain.ErrUnauthenticated, 401, `{"message":"Bad credentials"}`}, | |
| 227 | + | {"job read only", "release-list", job, domain.ErrForbidden, 403, `{"message":"Resource not accessible by integration"}`}, | |
| 228 | + | {"stored token", "release-list", domain.Root(), domain.ErrForbidden, 403, `{"message":"Forbidden"}`}, | |
| 229 | + | {"duplicate release", "release-list", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}`}, | |
| 230 | + | {"duplicate upload", "release-upload", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"already_exists","field":"name"}],"documentation_url":"https://docs.github.com/rest/releases/assets#upload-a-release-asset"}`}, | |
| 231 | + | {"invalid field", "release-list", domain.Root(), fmt.Errorf("%w: make_latest", domain.ErrInvalid), 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"make_latest"}]}`}, | |
| 232 | + | {"invalid", "release-list", domain.Root(), domain.ErrInvalid, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid"}]}`}, | |
| 233 | + | {"tag missing", "release-id", domain.Root(), domain.ErrPreconditionFailed, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"target_commitish"}]}`}, | |
| 234 | + | {"size", "release-upload", domain.Root(), fmt.Errorf("%w: asset size limit", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: asset size limit"}`}, | |
| 235 | + | {"count", "release-upload", domain.Root(), fmt.Errorf("%w: release asset count limit is 1000", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release asset count limit is 1000"}`}, | |
| 236 | + | {"quota", "release-upload", domain.Root(), fmt.Errorf("%w: release quota is 20 GiB", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release quota is 20 GiB"}`}, | |
| 237 | + | {"upload cap", "release-upload", domain.Root(), fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted), 429, `{"message":"resource exhausted: too many uploads in progress, retry"}`}, | |
| 238 | + | {"internal", "release-id", domain.Root(), errors.New("secret internal error"), 500, `{"message":"Server Error"}`}, | |
| 239 | + | } | |
| 240 | + | for _, tc := range cases { | |
| 241 | + | t.Run(tc.name, func(t *testing.T) { | |
| 242 | + | w := httptest.NewRecorder() | |
| 243 | + | New(Config{}).failRelease(t.Context(), w, tc.p, tc.err, tc.kind, "") | |
| 244 | + | require.Equal(t, tc.status, w.Code) | |
| 245 | + | require.Equal(t, tc.body, w.Body.String()) | |
| 246 | + | if tc.status == 429 { | |
| 247 | + | require.Equal(t, retryAfter, w.Header().Get("Retry-After")) | |
| 248 | + | } | |
| 249 | + | }) | |
| 250 | + | } | |
| 251 | + | // Verify the route actually forwards a use-case visibility failure unchanged. | |
| 252 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 253 | + | api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", ports.ReleaseRef{ID: 4}).Return(nil, domain.ErrNotFound).Once() | |
| 254 | + | w := releaseRequest(t, New(Config{Releases: api}), "GET", releasePath+"/4", "", nil) | |
| 255 | + | require.Equal(t, 404, w.Code) | |
| 256 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 257 | + | } | |
| 258 | + | func downloadAsset(t *testing.T) domain.ReleaseAsset { | |
| 259 | + | t.Helper() | |
| 260 | + | rn, err := domain.ParseRepoName("proj") | |
| 261 | + | require.NoError(t, err) | |
| 262 | + | return domain.ReleaseAsset{ID: 7, ReleaseID: 4, Repo: rn, Name: "archive.tar.gz", ContentType: "application/gzip", Size: 10, Digest: "sha256:abcdef", UpdatedAt: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)} | |
| 263 | + | } | |
| 264 | + | func TestReleaseDownload(t *testing.T) { | |
| 265 | + | cases := []struct { | |
| 266 | + | name, method, rang, etag string | |
| 267 | + | status int | |
| 268 | + | offset, length int64 | |
| 269 | + | body, contentRange string | |
| 270 | + | }{ | |
| 271 | + | {"whole", "GET", "", "", 200, 0, 10, "0123456789", ""}, | |
| 272 | + | {"bounded", "GET", "bytes=2-5", "", 206, 2, 4, "2345", "bytes 2-5/10"}, | |
| 273 | + | {"open ended", "GET", "bytes=7-", "", 206, 7, 3, "789", "bytes 7-9/10"}, | |
| 274 | + | {"suffix", "GET", "bytes=-3", "", 206, 7, 3, "789", "bytes 7-9/10"}, | |
| 275 | + | {"large suffix", "GET", "bytes=-30", "", 206, 0, 10, "0123456789", "bytes 0-9/10"}, | |
| 276 | + | {"clamp", "GET", "bytes=7-100", "", 206, 7, 3, "789", "bytes 7-9/10"}, | |
| 277 | + | {"multiple ignored", "GET", "bytes=0-1,3-4", "", 200, 0, 10, "0123456789", ""}, | |
| 278 | + | {"unknown unit ignored", "GET", "items=1-2", "", 200, 0, 10, "0123456789", ""}, | |
| 279 | + | {"out of bounds", "GET", "bytes=10-", "", 416, 0, 0, "", "bytes */10"}, | |
| 280 | + | {"reverse", "GET", "bytes=4-2", "", 416, 0, 0, "", "bytes */10"}, | |
| 281 | + | {"invalid", "GET", "bytes=garbage", "", 416, 0, 0, "", "bytes */10"}, | |
| 282 | + | {"zero suffix", "GET", "bytes=-0", "", 416, 0, 0, "", "bytes */10"}, | |
| 283 | + | {"conditional", "GET", "", `"sha256-abcdef"`, 304, 0, 0, "", ""}, | |
| 284 | + | {"head", "HEAD", "", "", 200, 0, 10, "", ""}, | |
| 285 | + | {"head ignores range", "HEAD", "bytes=2-5", "", 200, 0, 10, "", ""}, | |
| 286 | + | } | |
| 287 | + | for _, tc := range cases { | |
| 288 | + | t.Run(tc.name, func(t *testing.T) { | |
| 289 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 290 | + | dl := mocks.NewReleaseService(t) | |
| 291 | + | a := downloadAsset(t) | |
| 292 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |
| 293 | + | if tc.method == "GET" && (tc.status == 200 || tc.status == 206) { | |
| 294 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error { | |
| 295 | + | require.NoError(t, send(ports.AssetChunk{Asset: &a})) | |
| 296 | + | return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]}) | |
| 297 | + | }).Once() | |
| 298 | + | } | |
| 299 | + | w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), tc.method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json, application/octet-stream", "Range": tc.rang, "If-None-Match": tc.etag}) | |
| 300 | + | require.Equal(t, tc.status, w.Code) | |
| 301 | + | require.Equal(t, tc.body, w.Body.String()) | |
| 302 | + | require.Equal(t, tc.contentRange, w.Header().Get("Content-Range")) | |
| 303 | + | require.Equal(t, "application/gzip", w.Header().Get("Content-Type")) | |
| 304 | + | require.Equal(t, `attachment; filename="archive.tar.gz"`, w.Header().Get("Content-Disposition")) | |
| 305 | + | require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options")) | |
| 306 | + | require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy")) | |
| 307 | + | require.Equal(t, "bytes", w.Header().Get("Accept-Ranges")) | |
| 308 | + | require.Equal(t, `"sha256-abcdef"`, w.Header().Get("ETag")) | |
| 309 | + | require.Equal(t, "Wed, 21 Oct 2026 12:00:00 GMT", w.Header().Get("Last-Modified")) | |
| 310 | + | if tc.status == 200 || tc.status == 206 { | |
| 311 | + | require.Equal(t, fmt.Sprint(tc.length), w.Header().Get("Content-Length")) | |
| 312 | + | } | |
| 313 | + | }) | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | func TestReleaseDownloadSafety(t *testing.T) { | |
| 317 | + | for _, ct := range []string{"text/html", "text/html; charset=utf-8", "APPLICATION/JAVASCRIPT", "image/svg+xml"} { | |
| 318 | + | a := downloadAsset(t) | |
| 319 | + | a.ContentType = ct | |
| 320 | + | a.Name = "é space.txt" | |
| 321 | + | w := httptest.NewRecorder() | |
| 322 | + | setReleaseDownloadHeaders(w, a) | |
| 323 | + | require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type")) | |
| 324 | + | require.Equal(t, `attachment; filename="_ space.txt"; filename*=UTF-8''%C3%A9%20space.txt`, w.Header().Get("Content-Disposition")) | |
| 325 | + | } | |
| 326 | + | require.Equal(t, `attachment; filename="evil.txt"`, releaseContentDisposition("\r\n/\\\"evil.txt")) | |
| 327 | + | } | |
| 328 | + | func TestReleaseDownloadFailures(t *testing.T) { | |
| 329 | + | for _, started := range []bool{false, true} { | |
| 330 | + | t.Run(fmt.Sprint(started), func(t *testing.T) { | |
| 331 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 332 | + | dl := mocks.NewReleaseService(t) | |
| 333 | + | a := downloadAsset(t) | |
| 334 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |
| 335 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), int64(10), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error { | |
| 336 | + | if started { | |
| 337 | + | require.NoError(t, send(ports.AssetChunk{Data: []byte("x")})) | |
| 338 | + | } | |
| 339 | + | return domain.ErrNotFound | |
| 340 | + | }).Once() | |
| 341 | + | h := New(Config{Releases: api, ReleaseDownload: dl}) | |
| 342 | + | run := func() { | |
| 343 | + | w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 344 | + | require.Equal(t, 404, w.Code) | |
| 345 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 346 | + | require.Empty(t, w.Header().Get("Content-Length")) | |
| 347 | + | require.Empty(t, w.Header().Get("Content-Range")) | |
| 348 | + | } | |
| 349 | + | if started { | |
| 350 | + | require.PanicsWithValue(t, http.ErrAbortHandler, run) | |
| 351 | + | } else { | |
| 352 | + | run() | |
| 353 | + | } | |
| 354 | + | require.Empty(t, h.archives) | |
| 355 | + | require.Empty(t, h.clients) | |
| 356 | + | }) | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | func TestReleaseDownloadSlots(t *testing.T) { | |
| 360 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 361 | + | a := downloadAsset(t) | |
| 362 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Times(2) | |
| 363 | + | h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1, MaxArchivesPerClient: 1}) | |
| 364 | + | release, ok := h.acquire(httptest.NewRecorder(), "192.0.2.1") | |
| 365 | + | require.True(t, ok) | |
| 366 | + | defer release() | |
| 367 | + | w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 368 | + | require.Equal(t, 429, w.Code) | |
| 369 | + | require.Equal(t, retryAfter, w.Header().Get("Retry-After")) | |
| 370 | + | require.Empty(t, w.Header().Get("Content-Length")) | |
| 371 | + | require.Contains(t, w.Body.String(), "Too many archives") | |
| 372 | + | // A HEAD uses metadata, even when every download slot is taken. | |
| 373 | + | w = releaseRequest(t, h, "HEAD", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 374 | + | require.Equal(t, 200, w.Code) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func TestReleaseEmptyAndMissingDownload(t *testing.T) { | |
| 378 | + | for _, tc := range []struct { | |
| 379 | + | name string | |
| 380 | + | size int64 | |
| 381 | + | err error | |
| 382 | + | want int | |
| 383 | + | }{ | |
| 384 | + | {"empty", 0, nil, 200}, | |
| 385 | + | {"missing empty", 0, domain.ErrNotFound, 404}, | |
| 386 | + | {"store range", 10, domain.ErrOutOfRange, 416}, | |
| 387 | + | } { | |
| 388 | + | t.Run(tc.name, func(t *testing.T) { | |
| 389 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 390 | + | dl := mocks.NewReleaseService(t) | |
| 391 | + | a := downloadAsset(t) | |
| 392 | + | a.Size = tc.size | |
| 393 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |
| 394 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), tc.size, mock.Anything).Return(tc.err).Once() | |
| 395 | + | w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 396 | + | require.Equal(t, tc.want, w.Code) | |
| 397 | + | switch tc.want { | |
| 398 | + | case 200: | |
| 399 | + | require.Equal(t, "0", w.Header().Get("Content-Length")) | |
| 400 | + | require.Empty(t, w.Body.String()) | |
| 401 | + | case 416: | |
| 402 | + | require.Equal(t, "bytes */10", w.Header().Get("Content-Range")) | |
| 403 | + | case 404: | |
| 404 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 405 | + | } | |
| 406 | + | }) | |
| 407 | + | } | |
| 408 | + | // Missing the stream service leaves JSON available but bytes disabled. | |
| 409 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 410 | + | w := releaseRequest(t, New(Config{Releases: api}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 411 | + | require.Equal(t, 404, w.Code) | |
| 412 | + | require.Equal(t, notFoundBody, w.Body.String()) | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func TestReleaseDownloadGlobalSlot(t *testing.T) { | |
| 416 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 417 | + | a := downloadAsset(t) | |
| 418 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |
| 419 | + | h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1}) | |
| 420 | + | release, ok := h.acquire(httptest.NewRecorder(), "another-client") | |
| 421 | + | require.True(t, ok) | |
| 422 | + | defer release() | |
| 423 | + | w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 424 | + | require.Equal(t, 503, w.Code) | |
| 425 | + | require.Equal(t, retryAfter, w.Header().Get("Retry-After")) | |
| 426 | + | require.Empty(t, w.Header().Get("Content-Length")) | |
| 427 | + | require.Contains(t, w.Body.String(), "Server busy") | |
| 428 | + | require.NotContains(t, h.clients, "192.0.2.1") | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | func TestReleaseDownloadIfRange(t *testing.T) { | |
| 432 | + | for _, tc := range []struct { | |
| 433 | + | name, ifRange string | |
| 434 | + | status int | |
| 435 | + | offset int64 | |
| 436 | + | length int64 | |
| 437 | + | body, cr string | |
| 438 | + | }{ | |
| 439 | + | {"matching", `"sha256-abcdef"`, 206, 2, 4, "2345", "bytes 2-5/10"}, | |
| 440 | + | {"stale", `"sha256-other"`, 200, 0, 10, "0123456789", ""}, | |
| 441 | + | {"date", "Wed, 21 Oct 2026 12:00:00 GMT", 200, 0, 10, "0123456789", ""}, | |
| 442 | + | } { | |
| 443 | + | t.Run(tc.name, func(t *testing.T) { | |
| 444 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 445 | + | dl := mocks.NewReleaseService(t) | |
| 446 | + | a := downloadAsset(t) | |
| 447 | + | api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once() | |
| 448 | + | dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error { | |
| 449 | + | require.NoError(t, send(ports.AssetChunk{Asset: &a})) | |
| 450 | + | return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]}) | |
| 451 | + | }).Once() | |
| 452 | + | w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5", "If-Range": tc.ifRange}) | |
| 453 | + | require.Equal(t, tc.status, w.Code) | |
| 454 | + | require.Equal(t, tc.body, w.Body.String()) | |
| 455 | + | require.Equal(t, tc.cr, w.Header().Get("Content-Range")) | |
| 456 | + | }) | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func TestReleaseAssetValidationResource(t *testing.T) { | |
| 461 | + | api := mocks.NewGitHubReleaseAPI(t) | |
| 462 | + | api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), mock.Anything).Return(nil, fmt.Errorf("%w: asset name is empty", domain.ErrInvalid)).Once() | |
| 463 | + | w := releaseRequest(t, New(Config{Releases: api}), "PATCH", releaseAssetPath, `{"name":""}`, nil) | |
| 464 | + | require.Equal(t, 422, w.Code) | |
| 465 | + | require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String()) | |
| 466 | + | } | |
A internal/adapters/githubapi/testdata/releases/draft.json +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | { | |
| 2 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4", | |
| 3 | + | "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4/assets", | |
| 4 | + | "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/4/assets{?name,label}", | |
| 5 | + | "html_url": "http://localhost:7341/releases/releases/tag/next", | |
| 6 | + | "id": 4, | |
| 7 | + | "author": { | |
| 8 | + | "avatar_url": "", | |
| 9 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |
| 10 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |
| 11 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |
| 12 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |
| 13 | + | "gravatar_id": "", | |
| 14 | + | "html_url": "http://localhost:7341/acme", | |
| 15 | + | "id": 13112345534056, | |
| 16 | + | "login": "acme", | |
| 17 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |
| 18 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |
| 19 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |
| 20 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |
| 21 | + | "site_admin": false, | |
| 22 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |
| 23 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |
| 24 | + | "type": "User", | |
| 25 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |
| 26 | + | "user_view_type": "public" | |
| 27 | + | }, | |
| 28 | + | "node_id": "", | |
| 29 | + | "tag_name": "next", | |
| 30 | + | "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", | |
| 31 | + | "name": "", | |
| 32 | + | "draft": true, | |
| 33 | + | "prerelease": false, | |
| 34 | + | "created_at": "2026-10-10T08:00:00Z", | |
| 35 | + | "published_at": null, | |
| 36 | + | "assets": [], | |
| 37 | + | "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/next", | |
| 38 | + | "zipball_url": null, | |
| 39 | + | "body": "" | |
| 40 | + | } | |
A internal/adapters/githubapi/testdata/releases/release.json +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | { | |
| 2 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1", | |
| 3 | + | "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1/assets", | |
| 4 | + | "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/1/assets{?name,label}", | |
| 5 | + | "html_url": "http://localhost:7341/releases/releases/tag/v1", | |
| 6 | + | "id": 1, | |
| 7 | + | "author": { | |
| 8 | + | "avatar_url": "", | |
| 9 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |
| 10 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |
| 11 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |
| 12 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |
| 13 | + | "gravatar_id": "", | |
| 14 | + | "html_url": "http://localhost:7341/acme", | |
| 15 | + | "id": 13112345534056, | |
| 16 | + | "login": "acme", | |
| 17 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |
| 18 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |
| 19 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |
| 20 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |
| 21 | + | "site_admin": false, | |
| 22 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |
| 23 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |
| 24 | + | "type": "User", | |
| 25 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |
| 26 | + | "user_view_type": "public" | |
| 27 | + | }, | |
| 28 | + | "node_id": "", | |
| 29 | + | "tag_name": "v1", | |
| 30 | + | "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", | |
| 31 | + | "name": "", | |
| 32 | + | "draft": false, | |
| 33 | + | "prerelease": false, | |
| 34 | + | "created_at": "2026-10-10T08:00:00Z", | |
| 35 | + | "published_at": "2026-10-10T08:00:00Z", | |
| 36 | + | "assets": [ | |
| 37 | + | { | |
| 38 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/3", | |
| 39 | + | "id": 3, | |
| 40 | + | "node_id": "", | |
| 41 | + | "name": "checksums.txt", | |
| 42 | + | "label": "", | |
| 43 | + | "uploader": { | |
| 44 | + | "avatar_url": "", | |
| 45 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |
| 46 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |
| 47 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |
| 48 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |
| 49 | + | "gravatar_id": "", | |
| 50 | + | "html_url": "http://localhost:7341/acme", | |
| 51 | + | "id": 13112345534056, | |
| 52 | + | "login": "acme", | |
| 53 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |
| 54 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |
| 55 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |
| 56 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |
| 57 | + | "site_admin": false, | |
| 58 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |
| 59 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |
| 60 | + | "type": "User", | |
| 61 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |
| 62 | + | "user_view_type": "public" | |
| 63 | + | }, | |
| 64 | + | "content_type": "application/octet-stream", | |
| 65 | + | "state": "uploaded", | |
| 66 | + | "size": 3, | |
| 67 | + | "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", | |
| 68 | + | "download_count": 0, | |
| 69 | + | "created_at": "2026-10-10T08:00:00Z", | |
| 70 | + | "updated_at": "2026-10-10T08:00:00Z", | |
| 71 | + | "browser_download_url": "http://localhost:7341/releases/releases/download/v1/checksums.txt" | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/2", | |
| 75 | + | "id": 2, | |
| 76 | + | "node_id": "", | |
| 77 | + | "name": "ermite.tar.gz", | |
| 78 | + | "label": "", | |
| 79 | + | "uploader": { | |
| 80 | + | "avatar_url": "", | |
| 81 | + | "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}", | |
| 82 | + | "followers_url": "http://localhost:7341/-/github/api/users/acme/followers", | |
| 83 | + | "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}", | |
| 84 | + | "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}", | |
| 85 | + | "gravatar_id": "", | |
| 86 | + | "html_url": "http://localhost:7341/acme", | |
| 87 | + | "id": 13112345534056, | |
| 88 | + | "login": "acme", | |
| 89 | + | "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2", | |
| 90 | + | "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs", | |
| 91 | + | "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events", | |
| 92 | + | "repos_url": "http://localhost:7341/-/github/api/users/acme/repos", | |
| 93 | + | "site_admin": false, | |
| 94 | + | "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}", | |
| 95 | + | "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions", | |
| 96 | + | "type": "User", | |
| 97 | + | "url": "http://localhost:7341/-/github/api/users/acme", | |
| 98 | + | "user_view_type": "public" | |
| 99 | + | }, | |
| 100 | + | "content_type": "application/octet-stream", | |
| 101 | + | "state": "uploaded", | |
| 102 | + | "size": 3, | |
| 103 | + | "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", | |
| 104 | + | "download_count": 0, | |
| 105 | + | "created_at": "2026-10-10T08:00:00Z", | |
| 106 | + | "updated_at": "2026-10-10T08:00:00Z", | |
| 107 | + | "browser_download_url": "http://localhost:7341/releases/releases/download/v1/ermite.tar.gz" | |
| 108 | + | } | |
| 109 | + | ], | |
| 110 | + | "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/v1", | |
| 111 | + | "zipball_url": null, | |
| 112 | + | "body": "" | |
| 113 | + | } | |
M internal/adapters/kvstore/ci_artifact.go +2 -1
| @@ -93,7 +93,7 @@ func encodeArtifact(a domain.CIArtifact) ([]byte, error) { | |||
| 93 | 93 | Id: a.ID, Repo: a.Repo.String(), RunId: a.RunID, Attempt: a.Attempt, JobId: a.JobID, | |
| 94 | 94 | Name: a.Name, MediaType: a.MediaType, Size: a.Size, Digest: a.Digest, | |
| 95 | 95 | CreatedAt: encodeTime(a.CreatedAt), ExpiresAt: encodeTime(a.ExpiresAt), | |
| 96 | - | Charged: a.Charged, ListDigest: a.ListDigest, | |
| 96 | + | Charged: a.Charged, ListDigest: a.ListDigest, Store: string(a.Store), | |
| 97 | 97 | } | |
| 98 | 98 | if a.Assignment != (domain.TokenHash{}) { | |
| 99 | 99 | pb.Assignment = a.Assignment[:] | |
| @@ -123,6 +123,7 @@ func decodeArtifact(item kv.Item) (domain.CIArtifact, error) { | |||
| 123 | 123 | Name: pb.GetName(), MediaType: pb.GetMediaType(), Size: pb.GetSize(), Digest: pb.GetDigest(), | |
| 124 | 124 | CreatedAt: decodeTime(pb.GetCreatedAt()), ExpiresAt: decodeTime(pb.GetExpiresAt()), | |
| 125 | 125 | Charged: pb.GetCharged(), ListDigest: pb.GetListDigest(), Version: item.Version, | |
| 126 | + | Store: domain.CIArtifactStore(pb.GetStore()), | |
| 126 | 127 | } | |
| 127 | 128 | copy(a.Assignment[:], pb.GetAssignment()) | |
| 128 | 129 | for _, s := range artifactStates { | |
M internal/adapters/kvstore/ci_artifact_test.go +5 -0
| @@ -40,12 +40,17 @@ func TestCIArtifactsRoundTripAndIndexes(t *testing.T) { | |||
| 40 | 40 | assert.Greater(t, id2, id1, "ids increase") | |
| 41 | 41 | ||
| 42 | 42 | a := pendingArtifact(n, 7, id1, "dist") | |
| 43 | + | a.Store = domain.CIArtifactStoreBlob | |
| 43 | 44 | b := pendingArtifact(n, 7, id2, "logs") | |
| 44 | 45 | require.NoError(t, arts.ApplyArtifacts(ctx, ports.CIArtifactWrite{Repo: n, Put: []domain.CIArtifact{a, b}})) | |
| 45 | 46 | ||
| 46 | 47 | got, err := arts.GetArtifact(ctx, id1) | |
| 47 | 48 | require.NoError(t, err) | |
| 48 | 49 | assert.Equal(t, "dist", got.Name) | |
| 50 | + | assert.Equal(t, domain.CIArtifactStoreBlob, got.Store) | |
| 51 | + | legacy, err := arts.GetArtifact(ctx, id2) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | assert.Equal(t, domain.CIArtifactStoreLegacy, legacy.Store, "an absent proto field keeps the legacy layout") | |
| 49 | 54 | assert.Equal(t, a.Assignment, got.Assignment) | |
| 50 | 55 | assert.Equal(t, domain.CIArtifactPending, got.State) | |
| 51 | 56 | assert.True(t, got.ExpiresAt.Equal(a.ExpiresAt)) | |
M internal/adapters/kvstore/issues_test.go +16 -8
| @@ -271,7 +271,8 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) { | |||
| 271 | 271 | ||
| 272 | 272 | // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every | |
| 273 | 273 | // original scope keep their owner rights, gaining mr:review and issue:write | |
| 274 | - | // (each only if missing), then mr:force-merge in schema 5; no other token changes. | |
| 274 | + | // (each only if missing), then mr:force-merge in schema 5 and release:write in | |
| 275 | + | // schema 6; no other token changes. | |
| 275 | 276 | func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) { | |
| 276 | 277 | ctx := context.Background() | |
| 277 | 278 | dir := t.TempDir() | |
| @@ -309,9 +310,14 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) { | |||
| 309 | 310 | full := get("full") | |
| 310 | 311 | assert.Contains(t, full.Scopes, domain.ScopeIssueWrite) | |
| 311 | 312 | assert.Contains(t, full.Scopes, domain.ScopeMRReview) | |
| 312 | - | assert.Len(t, full.Scopes, 12) | |
| 313 | + | assert.Len(t, full.Scopes, 13) | |
| 314 | + | assert.Contains(t, full.Scopes, domain.ScopeReleaseWrite) | |
| 313 | 315 | full10 := get("full10") | |
| 314 | - | assert.Len(t, full10.Scopes, 12) | |
| 316 | + | assert.Len(t, full10.Scopes, 13) | |
| 317 | + | assert.Contains(t, full10.Scopes, domain.ScopeReleaseWrite) | |
| 318 | + | for _, id := range []string{"repos", "prefix", "missing", "reposmr", "small"} { | |
| 319 | + | assert.NotContains(t, get(id).Scopes, domain.ScopeReleaseWrite) | |
| 320 | + | } | |
| 315 | 321 | assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview)) | |
| 316 | 322 | assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite) | |
| 317 | 323 | assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights()) | |
| @@ -338,7 +344,7 @@ func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) { | |||
| 338 | 344 | assert.Equal(t, "full", full.ID) | |
| 339 | 345 | ||
| 340 | 346 | require.NoError(t, s.Close(ctx)) | |
| 341 | - | assert.Equal(t, uint32(5), readSchema(t, dir)) | |
| 347 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | |
| 342 | 348 | ||
| 343 | 349 | // A second open changes nothing. | |
| 344 | 350 | s2, err := kvstore.Open(ctx, dir) | |
| @@ -358,7 +364,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) { | |||
| 358 | 364 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1})) | |
| 359 | 365 | _, err := kvstore.Open(ctx, dir) | |
| 360 | 366 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | |
| 361 | - | assert.Equal(t, uint32(5), uint32(kvstore.SchemaVersion)) | |
| 367 | + | assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion)) | |
| 362 | 368 | } | |
| 363 | 369 | ||
| 364 | 370 | // A reopen racing the delete of the repository either lands before the delete | |
| @@ -571,7 +577,7 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) { | |||
| 571 | 577 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 4})) | |
| 572 | 578 | var previous []string | |
| 573 | 579 | for _, sc := range domain.AllScopes() { | |
| 574 | - | if sc != domain.ScopeMRForceMerge { | |
| 580 | + | if sc != domain.ScopeMRForceMerge && sc != domain.ScopeReleaseWrite { | |
| 575 | 581 | previous = append(previous, sc.String()) | |
| 576 | 582 | } | |
| 577 | 583 | } | |
| @@ -594,9 +600,11 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) { | |||
| 594 | 600 | if id == "owner" { | |
| 595 | 601 | assert.True(t, domain.TokenPrincipal(tok).HasOwnerRights()) | |
| 596 | 602 | assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeMRForceMerge)) | |
| 597 | - | assert.Len(t, tok.Scopes, 12) | |
| 603 | + | assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeReleaseWrite)) | |
| 604 | + | assert.Len(t, tok.Scopes, 13) | |
| 598 | 605 | } else { | |
| 599 | 606 | assert.NotContains(t, tok.Scopes, domain.ScopeMRForceMerge) | |
| 607 | + | assert.NotContains(t, tok.Scopes, domain.ScopeReleaseWrite) | |
| 600 | 608 | var got []string | |
| 601 | 609 | for _, sc := range tok.Scopes { | |
| 602 | 610 | got = append(got, sc.String()) | |
| @@ -605,6 +613,6 @@ func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) { | |||
| 605 | 613 | } | |
| 606 | 614 | } | |
| 607 | 615 | require.NoError(t, s.Close(ctx)) | |
| 608 | - | assert.Equal(t, uint32(5), readSchema(t, dir)) | |
| 616 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | |
| 609 | 617 | } | |
| 610 | 618 | } | |
A internal/adapters/kvstore/releases.go +408 -0
| @@ -0,0 +1,408 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package kvstore | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | kv "bnema.dev/ermite-kv" | |
| 6 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 7 | + | "bnema.dev/ermite/internal/domain" | |
| 8 | + | "bnema.dev/ermite/internal/ports" | |
| 9 | + | "context" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | "google.golang.org/protobuf/proto" | |
| 14 | + | "slices" | |
| 15 | + | "strconv" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Releases implements ports.ReleaseStore over the shared metadata database. | |
| 19 | + | type Releases struct{ db *kv.DB } | |
| 20 | + | ||
| 21 | + | var _ ports.ReleaseStore = (*Releases)(nil) | |
| 22 | + | ||
| 23 | + | // Releases returns the release metadata store. | |
| 24 | + | func (s *Store) Releases() *Releases { return &Releases{db: s.db} } | |
| 25 | + | func relKey(repo domain.RepoName, id int64) []byte { | |
| 26 | + | return []byte("rel/" + repo.String() + "/" + fmt.Sprintf(idFormat, id)) | |
| 27 | + | } | |
| 28 | + | func relIDKey(id int64) []byte { return []byte("relid/" + fmt.Sprintf(idFormat, id)) } | |
| 29 | + | func relTagKey(repo domain.RepoName, tag string) []byte { | |
| 30 | + | return []byte("reltag/" + repo.String() + "/" + tag) | |
| 31 | + | } | |
| 32 | + | func relAssetKey(id int64) []byte { return []byte("relasset/" + fmt.Sprintf(idFormat, id)) } | |
| 33 | + | func relNamePrefix(repo domain.RepoName, id int64) string { | |
| 34 | + | return "relassets/" + repo.String() + "/" + fmt.Sprintf(idFormat, id) + "/" | |
| 35 | + | } | |
| 36 | + | func relNameKey(a domain.ReleaseAsset) []byte { | |
| 37 | + | return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name) | |
| 38 | + | } | |
| 39 | + | func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) } | |
| 40 | + | func encodeRelease(r domain.Release) []byte { | |
| 41 | + | return mustMarshal(&storagepb.Release{ | |
| 42 | + | Id: r.ID, | |
| 43 | + | Repo: r.Repo.String(), | |
| 44 | + | Tag: r.Tag, | |
| 45 | + | Title: r.Title, | |
| 46 | + | Notes: r.Notes, | |
| 47 | + | Draft: r.Draft, | |
| 48 | + | Prerelease: r.Prerelease, | |
| 49 | + | MakeLatest: r.MakeLatest, | |
| 50 | + | TargetCommitish: r.TargetCommitish, | |
| 51 | + | Author: r.Author, | |
| 52 | + | CreatedAt: encodeTime(r.CreatedAt), | |
| 53 | + | PublishedAt: encodeTime(r.PublishedAt), | |
| 54 | + | UpdatedAt: encodeTime(r.UpdatedAt), | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | func decodeRelease(item kv.Item) (domain.Release, error) { | |
| 58 | + | var pb storagepb.Release | |
| 59 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 60 | + | return domain.Release{}, err | |
| 61 | + | } | |
| 62 | + | repo, err := domain.ParseRepoName(pb.GetRepo()) | |
| 63 | + | if err != nil { | |
| 64 | + | return domain.Release{}, err | |
| 65 | + | } | |
| 66 | + | return domain.Release{Version: item.Version, | |
| 67 | + | ID: pb.GetId(), | |
| 68 | + | Repo: repo, | |
| 69 | + | Tag: pb.GetTag(), | |
| 70 | + | Title: pb.GetTitle(), | |
| 71 | + | Notes: pb.GetNotes(), | |
| 72 | + | Draft: pb.GetDraft(), | |
| 73 | + | Prerelease: pb.GetPrerelease(), | |
| 74 | + | MakeLatest: pb.GetMakeLatest(), | |
| 75 | + | TargetCommitish: pb.GetTargetCommitish(), | |
| 76 | + | Author: pb.GetAuthor(), | |
| 77 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 78 | + | PublishedAt: decodeTime(pb.GetPublishedAt()), | |
| 79 | + | UpdatedAt: decodeTime(pb.GetUpdatedAt()), | |
| 80 | + | }, nil | |
| 81 | + | } | |
| 82 | + | func encodeReleaseAsset(r domain.ReleaseAsset) []byte { | |
| 83 | + | return mustMarshal(&storagepb.ReleaseAsset{ | |
| 84 | + | Id: r.ID, | |
| 85 | + | ReleaseId: r.ReleaseID, | |
| 86 | + | Repo: r.Repo.String(), | |
| 87 | + | Name: r.Name, | |
| 88 | + | Label: r.Label, | |
| 89 | + | ContentType: r.ContentType, | |
| 90 | + | Size: r.Size, | |
| 91 | + | Digest: r.Digest, | |
| 92 | + | State: string(r.State), | |
| 93 | + | Uploader: r.Uploader, | |
| 94 | + | Charged: r.Charged, | |
| 95 | + | MultipartId: r.MultipartID, | |
| 96 | + | CreatedAt: encodeTime(r.CreatedAt), | |
| 97 | + | UpdatedAt: encodeTime(r.UpdatedAt), | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | func decodeReleaseAsset(item kv.Item) (domain.ReleaseAsset, error) { | |
| 101 | + | var pb storagepb.ReleaseAsset | |
| 102 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 103 | + | return domain.ReleaseAsset{}, err | |
| 104 | + | } | |
| 105 | + | repo, err := domain.ParseRepoName(pb.GetRepo()) | |
| 106 | + | if err != nil { | |
| 107 | + | return domain.ReleaseAsset{}, err | |
| 108 | + | } | |
| 109 | + | return domain.ReleaseAsset{Version: item.Version, | |
| 110 | + | ID: pb.GetId(), | |
| 111 | + | ReleaseID: pb.GetReleaseId(), | |
| 112 | + | Repo: repo, | |
| 113 | + | Name: pb.GetName(), | |
| 114 | + | Label: pb.GetLabel(), | |
| 115 | + | ContentType: pb.GetContentType(), | |
| 116 | + | Size: pb.GetSize(), | |
| 117 | + | Digest: pb.GetDigest(), | |
| 118 | + | State: domain.ReleaseAssetState(pb.GetState()), | |
| 119 | + | Uploader: pb.GetUploader(), | |
| 120 | + | Charged: pb.GetCharged(), | |
| 121 | + | MultipartID: pb.GetMultipartId(), | |
| 122 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 123 | + | UpdatedAt: decodeTime(pb.GetUpdatedAt()), | |
| 124 | + | }, nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // NextReleaseID allocates a global monotonically increasing id. | |
| 128 | + | func (c *Releases) NextReleaseID(ctx context.Context) (int64, error) { | |
| 129 | + | for range artifactIDTries { | |
| 130 | + | last, item, ok, err := (&CI{db: c.db}).readSeq(ctx, []byte("relseq")) | |
| 131 | + | if err != nil { | |
| 132 | + | return 0, err | |
| 133 | + | } | |
| 134 | + | var b kv.Batch | |
| 135 | + | stageSeq(&b, []byte("relseq"), item, ok, last+1) | |
| 136 | + | _, err = c.db.Apply(ctx, &b) | |
| 137 | + | if errors.Is(err, kv.ErrConflict) { | |
| 138 | + | continue | |
| 139 | + | } | |
| 140 | + | if err != nil { | |
| 141 | + | return 0, err | |
| 142 | + | } | |
| 143 | + | return last + 1, nil | |
| 144 | + | } | |
| 145 | + | return 0, domain.ErrConflict | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // ReleaseUsage reads the repository's quota gate. | |
| 149 | + | func (c *Releases) ReleaseUsage(ctx context.Context, repo domain.RepoName) (int64, uint64, error) { | |
| 150 | + | item, ok := c.db.Get(ctx, relUseKey(repo)) | |
| 151 | + | if !ok { | |
| 152 | + | return 0, 0, nil | |
| 153 | + | } | |
| 154 | + | var pb storagepb.ReleaseUsage | |
| 155 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 156 | + | return 0, 0, err | |
| 157 | + | } | |
| 158 | + | return pb.GetUsed(), item.Version, nil | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // GetRelease reads a release in its repository. | |
| 162 | + | func (c *Releases) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) { | |
| 163 | + | item, ok := c.db.Get(ctx, relKey(repo, id)) | |
| 164 | + | if !ok { | |
| 165 | + | return domain.Release{}, domain.ErrNotFound | |
| 166 | + | } | |
| 167 | + | return decodeRelease(item) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // ReleaseRepo resolves the global id index. | |
| 171 | + | func (c *Releases) ReleaseRepo(ctx context.Context, id int64) (domain.RepoName, error) { | |
| 172 | + | item, ok := c.db.Get(ctx, relIDKey(id)) | |
| 173 | + | if !ok { | |
| 174 | + | return domain.RepoName{}, domain.ErrNotFound | |
| 175 | + | } | |
| 176 | + | return domain.ParseRepoName(string(item.Value)) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // GetReleaseByTag resolves a repository tag. | |
| 180 | + | func (c *Releases) GetReleaseByTag(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error) { | |
| 181 | + | snap := c.db.Snapshot(ctx) | |
| 182 | + | idx, ok := snap.Get(ctx, relTagKey(repo, tag)) | |
| 183 | + | if !ok { | |
| 184 | + | return domain.Release{}, domain.ErrNotFound | |
| 185 | + | } | |
| 186 | + | id, err := strconv.ParseInt(string(idx.Value), 10, 64) | |
| 187 | + | if err != nil { | |
| 188 | + | return domain.Release{}, err | |
| 189 | + | } | |
| 190 | + | item, ok := snap.Get(ctx, relKey(repo, id)) | |
| 191 | + | if !ok { | |
| 192 | + | return domain.Release{}, domain.ErrNotFound | |
| 193 | + | } | |
| 194 | + | return decodeRelease(item) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // ListReleases returns all releases by descending id. | |
| 198 | + | func (c *Releases) ListReleases(ctx context.Context, repo domain.RepoName) ([]domain.Release, error) { | |
| 199 | + | var out []domain.Release | |
| 200 | + | for _, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte("rel/"+repo.String()+"/")) { | |
| 201 | + | r, err := decodeRelease(item) | |
| 202 | + | if err != nil { | |
| 203 | + | return nil, err | |
| 204 | + | } | |
| 205 | + | out = append(out, r) | |
| 206 | + | } | |
| 207 | + | slices.Reverse(out) | |
| 208 | + | return out, nil | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // GetAsset reads asset metadata in every state. | |
| 212 | + | func (c *Releases) GetAsset(ctx context.Context, id int64) (domain.ReleaseAsset, error) { | |
| 213 | + | item, ok := c.db.Get(ctx, relAssetKey(id)) | |
| 214 | + | if !ok { | |
| 215 | + | return domain.ReleaseAsset{}, domain.ErrNotFound | |
| 216 | + | } | |
| 217 | + | return decodeReleaseAsset(item) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // GetAssetByName resolves the unique name index. | |
| 221 | + | func (c *Releases) GetAssetByName(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error) { | |
| 222 | + | snap := c.db.Snapshot(ctx) | |
| 223 | + | idx, ok := snap.Get(ctx, relNameKey(domain.ReleaseAsset{Repo: repo, ReleaseID: releaseID, Name: name})) | |
| 224 | + | if !ok { | |
| 225 | + | return domain.ReleaseAsset{}, domain.ErrNotFound | |
| 226 | + | } | |
| 227 | + | id, err := strconv.ParseInt(string(idx.Value), 10, 64) | |
| 228 | + | if err != nil { | |
| 229 | + | return domain.ReleaseAsset{}, err | |
| 230 | + | } | |
| 231 | + | item, ok := snap.Get(ctx, relAssetKey(id)) | |
| 232 | + | if !ok { | |
| 233 | + | return domain.ReleaseAsset{}, domain.ErrNotFound | |
| 234 | + | } | |
| 235 | + | return decodeReleaseAsset(item) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // ListAssets returns every state in name order. | |
| 239 | + | func (c *Releases) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) { | |
| 240 | + | snap := c.db.Snapshot(ctx) | |
| 241 | + | var out []domain.ReleaseAsset | |
| 242 | + | for _, idx := range snap.Prefix(ctx, []byte(relNamePrefix(repo, releaseID))) { | |
| 243 | + | id, err := strconv.ParseInt(string(idx.Value), 10, 64) | |
| 244 | + | if err != nil { | |
| 245 | + | return nil, err | |
| 246 | + | } | |
| 247 | + | item, ok := snap.Get(ctx, relAssetKey(id)) | |
| 248 | + | if !ok { | |
| 249 | + | return nil, domain.ErrNotFound | |
| 250 | + | } | |
| 251 | + | a, err := decodeReleaseAsset(item) | |
| 252 | + | if err != nil { | |
| 253 | + | return nil, err | |
| 254 | + | } | |
| 255 | + | out = append(out, a) | |
| 256 | + | } | |
| 257 | + | return out, nil | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // AllAssets returns every asset for garbage collection. | |
| 261 | + | func (c *Releases) AllAssets(ctx context.Context) ([]domain.ReleaseAsset, error) { | |
| 262 | + | var out []domain.ReleaseAsset | |
| 263 | + | for _, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte("relasset/")) { | |
| 264 | + | a, err := decodeReleaseAsset(item) | |
| 265 | + | if err != nil { | |
| 266 | + | return nil, err | |
| 267 | + | } | |
| 268 | + | out = append(out, a) | |
| 269 | + | } | |
| 270 | + | return out, nil | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // ApplyReleases atomically changes metadata and its quota gate. | |
| 274 | + | // | |
| 275 | + | //hexcheck:ignore suspicious-business-logic-in-adapter enforces storage version preconditions and unique secondary indexes, not release policy | |
| 276 | + | func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) error { | |
| 277 | + | log := zerowrap.FromCtx(ctx) | |
| 278 | + | var b kv.Batch | |
| 279 | + | if err := guardRepoKey(ctx, c.db, &b, w.Repo); err != nil { | |
| 280 | + | return err | |
| 281 | + | } | |
| 282 | + | if w.UsageVersion == 0 { | |
| 283 | + | b.RequireAbsent(relUseKey(w.Repo)) | |
| 284 | + | } else { | |
| 285 | + | b.RequireVersion(relUseKey(w.Repo), w.UsageVersion) | |
| 286 | + | } | |
| 287 | + | b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used})) | |
| 288 | + | snap := c.db.Snapshot(ctx) | |
| 289 | + | for _, r := range w.DeleteReleases { | |
| 290 | + | if r.Repo != w.Repo || r.Version == 0 { | |
| 291 | + | return domain.ErrInvalid | |
| 292 | + | } | |
| 293 | + | b.RequireVersion(relKey(r.Repo, r.ID), r.Version) | |
| 294 | + | b.Delete(relKey(r.Repo, r.ID)) | |
| 295 | + | b.Delete(relTagKey(r.Repo, r.Tag)) | |
| 296 | + | b.Delete(relIDKey(r.ID)) | |
| 297 | + | } | |
| 298 | + | for _, r := range w.DeleteAssets { | |
| 299 | + | if r.Repo != w.Repo || r.Version == 0 { | |
| 300 | + | return domain.ErrInvalid | |
| 301 | + | } | |
| 302 | + | b.RequireVersion(relAssetKey(r.ID), r.Version) | |
| 303 | + | b.Delete(relAssetKey(r.ID)) | |
| 304 | + | b.Delete(relNameKey(r)) | |
| 305 | + | } | |
| 306 | + | seenReleases := map[string]int64{} | |
| 307 | + | for _, r := range w.PutReleases { | |
| 308 | + | if r.Repo != w.Repo || r.ID < 1 { | |
| 309 | + | return domain.ErrInvalid | |
| 310 | + | } | |
| 311 | + | idxKey := relTagKey(r.Repo, r.Tag) | |
| 312 | + | if id, ok := seenReleases[string(idxKey)]; ok && id != r.ID { | |
| 313 | + | return domain.ErrAlreadyExists | |
| 314 | + | } | |
| 315 | + | seenReleases[string(idxKey)] = r.ID | |
| 316 | + | if item, ok := snap.Get(ctx, idxKey); ok && string(item.Value) != fmt.Sprintf(idFormat, r.ID) { | |
| 317 | + | removed := false | |
| 318 | + | for _, d := range w.DeleteReleases { | |
| 319 | + | if string(relTagKey(d.Repo, d.Tag)) == string(idxKey) { | |
| 320 | + | removed = true | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | if !removed { | |
| 324 | + | return domain.ErrAlreadyExists | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | if old, ok := snap.Get(ctx, relKey(r.Repo, r.ID)); ok { | |
| 328 | + | previous, err := decodeRelease(old) | |
| 329 | + | if err != nil { | |
| 330 | + | return err | |
| 331 | + | } | |
| 332 | + | oldKey := relTagKey(previous.Repo, previous.Tag) | |
| 333 | + | if string(oldKey) != string(idxKey) { | |
| 334 | + | b.Delete(oldKey) | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | stage(&b, relKey(r.Repo, r.ID), encodeRelease(r), r.Version) | |
| 338 | + | b.Put(idxKey, []byte(fmt.Sprintf(idFormat, r.ID))) | |
| 339 | + | if r.Version == 0 { | |
| 340 | + | b.RequireAbsent(relIDKey(r.ID)) | |
| 341 | + | } | |
| 342 | + | b.Put(relIDKey(r.ID), []byte(r.Repo.String())) | |
| 343 | + | } | |
| 344 | + | seenAssets := map[string]int64{} | |
| 345 | + | for _, r := range w.PutAssets { | |
| 346 | + | if r.Repo != w.Repo || r.ID < 1 { | |
| 347 | + | return domain.ErrInvalid | |
| 348 | + | } | |
| 349 | + | idxKey := relNameKey(r) | |
| 350 | + | if id, ok := seenAssets[string(idxKey)]; ok && id != r.ID { | |
| 351 | + | return domain.ErrAlreadyExists | |
| 352 | + | } | |
| 353 | + | seenAssets[string(idxKey)] = r.ID | |
| 354 | + | if item, ok := snap.Get(ctx, idxKey); ok && string(item.Value) != fmt.Sprintf(idFormat, r.ID) { | |
| 355 | + | removed := false | |
| 356 | + | for _, d := range w.DeleteAssets { | |
| 357 | + | if string(relNameKey(d)) == string(idxKey) { | |
| 358 | + | removed = true | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | if !removed { | |
| 362 | + | return domain.ErrAlreadyExists | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | if old, ok := snap.Get(ctx, relAssetKey(r.ID)); ok { | |
| 366 | + | previous, err := decodeReleaseAsset(old) | |
| 367 | + | if err != nil { | |
| 368 | + | return err | |
| 369 | + | } | |
| 370 | + | oldKey := relNameKey(previous) | |
| 371 | + | if string(oldKey) != string(idxKey) { | |
| 372 | + | b.Delete(oldKey) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | stage(&b, relAssetKey(r.ID), encodeReleaseAsset(r), r.Version) | |
| 376 | + | b.Put(idxKey, []byte(fmt.Sprintf(idFormat, r.ID))) | |
| 377 | + | } | |
| 378 | + | _, err := c.db.Apply(ctx, &b) | |
| 379 | + | if errors.Is(err, kv.ErrConflict) { | |
| 380 | + | return domain.ErrConflict | |
| 381 | + | } | |
| 382 | + | if err != nil { | |
| 383 | + | return fmt.Errorf("write releases: %w", err) | |
| 384 | + | } | |
| 385 | + | log.Debug().Str("repo", w.Repo.String()).Msg("release metadata updated") | |
| 386 | + | return nil | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // releaseKeys collects all repository-owned release keys, including global indexes. | |
| 390 | + | func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |
| 391 | + | keys := [][]byte{relUseKey(repo)} | |
| 392 | + | for _, prefix := range []string{"rel/", "reltag/", "relassets/"} { | |
| 393 | + | for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) { | |
| 394 | + | keys = append(keys, slices.Clone(key)) | |
| 395 | + | if prefix == "rel/" { | |
| 396 | + | if r, err := decodeRelease(item); err == nil { | |
| 397 | + | keys = append(keys, relIDKey(r.ID)) | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | for key, item := range snap.Prefix(ctx, []byte("relasset/")) { | |
| 403 | + | if a, err := decodeReleaseAsset(item); err == nil && a.Repo == repo { | |
| 404 | + | keys = append(keys, slices.Clone(key)) | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | return keys | |
| 408 | + | } | |
A internal/adapters/kvstore/releases_test.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package kvstore_test | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | kv "bnema.dev/ermite-kv" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestReleaseStorage(t *testing.T) { | |
| 17 | + | ctx := context.Background() | |
| 18 | + | dir := t.TempDir() | |
| 19 | + | s := openStore(t, dir) | |
| 20 | + | repo := name(t, "release-test") | |
| 21 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()})) | |
| 22 | + | st := s.Releases() | |
| 23 | + | id, err := st.NextReleaseID(ctx) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | r := domain.Release{ID: id, Repo: repo, Tag: "v1", Title: "title", Notes: "notes", MakeLatest: true, TargetCommitish: "abc", Author: "owner", CreatedAt: created, PublishedAt: created, UpdatedAt: created} | |
| 26 | + | assetID, err := st.NextReleaseID(ctx) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | a := domain.ReleaseAsset{ID: assetID, ReleaseID: id, Repo: repo, Name: "binary", Label: "label", ContentType: "application/octet-stream", Size: 3, Digest: "sha256:abc", State: domain.ReleaseAssetUploading, Uploader: "owner", Charged: 3, MultipartID: "multipart", CreatedAt: created, UpdatedAt: created} | |
| 29 | + | w := ports.ReleaseWrite{Repo: repo, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}} | |
| 30 | + | require.NoError(t, st.ApplyReleases(ctx, w)) | |
| 31 | + | got, err := st.GetReleaseByTag(ctx, repo, "v1") | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | r.Version = got.Version | |
| 34 | + | require.Equal(t, r, got) | |
| 35 | + | ar, err := st.GetAssetByName(ctx, repo, id, "binary") | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | a.Version = ar.Version | |
| 38 | + | require.Equal(t, a, ar) | |
| 39 | + | rr, err := st.ReleaseRepo(ctx, id) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | require.Equal(t, repo, rr) | |
| 42 | + | rs, err := st.ListReleases(ctx, repo) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.Equal(t, []domain.Release{r}, rs) | |
| 45 | + | as, err := st.ListAssets(ctx, repo, id) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | require.Equal(t, []domain.ReleaseAsset{a}, as) | |
| 48 | + | all, err := st.AllAssets(ctx) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | require.Equal(t, as, all) | |
| 51 | + | require.ErrorIs(t, st.ApplyReleases(ctx, w), domain.ErrConflict) | |
| 52 | + | used, version, err := st.ReleaseUsage(ctx, repo) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | require.EqualValues(t, 3, used) | |
| 55 | + | duplicate := r | |
| 56 | + | duplicate.ID += 10 | |
| 57 | + | duplicate.Version = 0 | |
| 58 | + | require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{duplicate}}), domain.ErrAlreadyExists) | |
| 59 | + | da := a | |
| 60 | + | da.ID += 10 | |
| 61 | + | da.Version = 0 | |
| 62 | + | require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutAssets: []domain.ReleaseAsset{da}}), domain.ErrAlreadyExists) | |
| 63 | + | stale := r | |
| 64 | + | stale.Version++ | |
| 65 | + | require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{stale}}), domain.ErrConflict) | |
| 66 | + | staleAsset := a | |
| 67 | + | staleAsset.Version++ | |
| 68 | + | require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, DeleteAssets: []domain.ReleaseAsset{staleAsset}}), domain.ErrConflict) | |
| 69 | + | r.Tag = "v2" | |
| 70 | + | a.Name = "renamed" | |
| 71 | + | require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}})) | |
| 72 | + | _, err = st.GetReleaseByTag(ctx, repo, "v1") | |
| 73 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 74 | + | _, err = st.GetAssetByName(ctx, repo, id, "binary") | |
| 75 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 76 | + | renamedRelease, err := st.GetReleaseByTag(ctx, repo, "v2") | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | renamedAsset, err := st.GetAssetByName(ctx, repo, id, "renamed") | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | _, version, err = st.ReleaseUsage(ctx, repo) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, DeleteReleases: []domain.Release{renamedRelease}, DeleteAssets: []domain.ReleaseAsset{renamedAsset}})) | |
| 83 | + | _, err = st.GetRelease(ctx, repo, id) | |
| 84 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 85 | + | _, err = st.GetAsset(ctx, a.ID) | |
| 86 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 87 | + | _, version, err = st.ReleaseUsage(ctx, repo) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | r.Version, a.Version = 0, 0 | |
| 90 | + | require.NoError(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 3, PutReleases: []domain.Release{r}, PutAssets: []domain.ReleaseAsset{a}})) | |
| 91 | + | require.NoError(t, s.Delete(ctx, repo)) | |
| 92 | + | _, err = st.GetRelease(ctx, repo, id) | |
| 93 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 94 | + | _, err = st.ReleaseRepo(ctx, id) | |
| 95 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 96 | + | _, err = st.GetAsset(ctx, a.ID) | |
| 97 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 98 | + | all, err = st.AllAssets(ctx) | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | require.Empty(t, all) | |
| 101 | + | used, version, err = st.ReleaseUsage(ctx, repo) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | require.Zero(t, used) | |
| 104 | + | require.Zero(t, version) | |
| 105 | + | require.ErrorIs(t, st.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo}), domain.ErrNotFound) | |
| 106 | + | require.NoError(t, s.Close(ctx)) | |
| 107 | + | db, err := kv.Open(ctx, dir, kv.Options{}) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | defer db.Close(ctx) | |
| 110 | + | for _, prefix := range []string{"rel/", "reltag/", "relid/", "relasset/", "relassets/", "reluse/"} { | |
| 111 | + | for key := range db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) { | |
| 112 | + | t.Errorf("repository deletion left release key %q", key) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | func TestConcurrentReleaseIDs(t *testing.T) { | |
| 117 | + | st := openStore(t, t.TempDir()).Releases() | |
| 118 | + | var wg sync.WaitGroup | |
| 119 | + | ids := make(chan int64, 32) | |
| 120 | + | for range 32 { | |
| 121 | + | wg.Go(func() { id, err := st.NextReleaseID(context.Background()); require.NoError(t, err); ids <- id }) | |
| 122 | + | } | |
| 123 | + | wg.Wait() | |
| 124 | + | close(ids) | |
| 125 | + | seen := map[int64]bool{} | |
| 126 | + | for id := range ids { | |
| 127 | + | require.False(t, seen[id]) | |
| 128 | + | seen[id] = true | |
| 129 | + | } | |
| 130 | + | require.Len(t, seen, 32) | |
| 131 | + | } | |
M internal/adapters/kvstore/repos.go +1 -0
| @@ -163,6 +163,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | |||
| 163 | 163 | keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...) | |
| 164 | 164 | keys = append(keys, pendingKeys(ctx, snap, name)...) | |
| 165 | 165 | keys = append(keys, ciKeys(ctx, snap, name)...) | |
| 166 | + | keys = append(keys, releaseKeys(ctx, snap, name)...) | |
| 166 | 167 | keys = append(keys, settingsKeys(ctx, snap, name)...) | |
| 167 | 168 | keys = append(keys, mirrorKeys(ctx, snap, name)...) | |
| 168 | 169 | for len(keys) > deleteChunk { | |
M internal/adapters/kvstore/store.go +25 -4
| @@ -40,8 +40,10 @@ import ( | |||
| 40 | 40 | // if missing) to the stored tokens that held all nine original scopes with no | |
| 41 | 41 | // repository list and no branch prefix, so that they keep the owner rights (see | |
| 42 | 42 | // upgradeTokens). Version 5 grants mr:force-merge to unrestricted tokens | |
| 43 | - | // holding all eleven previous scopes, preserving their owner rights. | |
| 44 | - | const SchemaVersion = 5 | |
| 43 | + | // holding all eleven previous scopes, preserving their owner rights. Version 6 | |
| 44 | + | // adds releases and release:write; older builds cannot clean up their metadata | |
| 45 | + | // or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens). | |
| 46 | + | const SchemaVersion = 6 | |
| 45 | 47 | ||
| 46 | 48 | const schemaKey = "meta/schema" | |
| 47 | 49 | ||
| @@ -110,7 +112,7 @@ func (s *Store) Check(ctx context.Context) error { | |||
| 110 | 112 | func (s *Store) checkSchema(ctx context.Context) error { | |
| 111 | 113 | log := zerowrap.FromCtx(ctx) | |
| 112 | 114 | key := []byte(schemaKey) | |
| 113 | - | for range 4 { | |
| 115 | + | for range 5 { | |
| 114 | 116 | item, ok := s.db.Get(ctx, key) | |
| 115 | 117 | if !ok { | |
| 116 | 118 | var b kv.Batch | |
| @@ -142,8 +144,11 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||
| 142 | 144 | // The tokens are upgraded in the same batch, so that a crash | |
| 143 | 145 | // leaves the old version and the upgrade runs again. | |
| 144 | 146 | target := uint32(SchemaVersion) | |
| 145 | - | if sv.GetVersion() < 4 { | |
| 147 | + | switch { | |
| 148 | + | case sv.GetVersion() < 4: | |
| 146 | 149 | target = 4 | |
| 150 | + | case sv.GetVersion() == 4: | |
| 151 | + | target = 5 | |
| 147 | 152 | } | |
| 148 | 153 | var b kv.Batch | |
| 149 | 154 | b.RequireVersion(key, item.Version) | |
| @@ -157,6 +162,11 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||
| 157 | 162 | return err | |
| 158 | 163 | } | |
| 159 | 164 | } | |
| 165 | + | if sv.GetVersion() == 5 { | |
| 166 | + | if err := s.upgradeReleaseTokens(ctx, &b); err != nil { | |
| 167 | + | return err | |
| 168 | + | } | |
| 169 | + | } | |
| 160 | 170 | b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: target})) | |
| 161 | 171 | _, err := s.db.Apply(ctx, &b) | |
| 162 | 172 | if errors.Is(err, kv.ErrConflict) { | |
| @@ -214,6 +224,17 @@ func (s *Store) upgradeForceMergeTokens(ctx context.Context, b *kv.Batch) error | |||
| 214 | 224 | return s.upgradeTokenScopes(ctx, b, preForceMergeScopes, []domain.Scope{domain.ScopeMRForceMerge}) | |
| 215 | 225 | } | |
| 216 | 226 | ||
| 227 | + | // preReleaseScopes are the twelve scopes of schema 5, before release:write. | |
| 228 | + | var preReleaseScopes = append(slices.Clone(preForceMergeScopes), domain.ScopeMRForceMerge) | |
| 229 | + | ||
| 230 | + | // upgradeReleaseTokens adds to b the schema 6 upgrade of the tokens: a token | |
| 231 | + | // that holds all of preReleaseScopes, limited to no repository and no branch | |
| 232 | + | // prefix, had the owner rights, which AllScopes also needs release:write for, | |
| 233 | + | // so it gets it. Restricted or partial tokens are left as they are. | |
| 234 | + | func (s *Store) upgradeReleaseTokens(ctx context.Context, b *kv.Batch) error { | |
| 235 | + | return s.upgradeTokenScopes(ctx, b, preReleaseScopes, []domain.Scope{domain.ScopeReleaseWrite}) | |
| 236 | + | } | |
| 237 | + | ||
| 217 | 238 | func (s *Store) upgradeTokenScopes(ctx context.Context, b *kv.Batch, required, addedScopes []domain.Scope) error { | |
| 218 | 239 | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) { | |
| 219 | 240 | pb := &storagepb.Token{} | |
M internal/adapters/kvstore/store_test.go +57 -0
| @@ -363,3 +363,60 @@ func TestRepoWithoutTopicsReadsNil(t *testing.T) { | |||
| 363 | 363 | require.NoError(t, err) | |
| 364 | 364 | assert.Equal(t, []string{"a", "b"}, got.Topics) | |
| 365 | 365 | } | |
| 366 | + | ||
| 367 | + | func TestSchema6TokenUpgrade(t *testing.T) { | |
| 368 | + | ctx := context.Background() | |
| 369 | + | for _, from := range []uint32{3, 4, 5} { | |
| 370 | + | t.Run(fmt.Sprint(from), func(t *testing.T) { | |
| 371 | + | dir := t.TempDir() | |
| 372 | + | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: from})) | |
| 373 | + | all := domain.AllScopes() | |
| 374 | + | scopes := make([]string, 0, len(all)-1) | |
| 375 | + | for _, sc := range all { | |
| 376 | + | if sc != domain.ScopeReleaseWrite { | |
| 377 | + | scopes = append(scopes, sc.String()) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | for _, tc := range []struct { | |
| 381 | + | id string | |
| 382 | + | scopes []string | |
| 383 | + | repos, prefixes []string | |
| 384 | + | upgraded bool | |
| 385 | + | }{ | |
| 386 | + | {id: "full", scopes: scopes, upgraded: true}, | |
| 387 | + | {id: "partial", scopes: []string{"repo:read"}}, | |
| 388 | + | {id: "missing-review", scopes: func() []string { | |
| 389 | + | var out []string | |
| 390 | + | for _, s := range scopes { | |
| 391 | + | if s != "mr:review" { | |
| 392 | + | out = append(out, s) | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | return out | |
| 396 | + | }(), upgraded: from < 4}, | |
| 397 | + | {id: "repo", scopes: scopes, repos: []string{"a"}}, | |
| 398 | + | {id: "branch", scopes: scopes, prefixes: []string{"bot/"}}, | |
| 399 | + | } { | |
| 400 | + | putRaw(t, dir, "token/"+tc.id, mustProto(t, &storagepb.Token{Id: tc.id, Scopes: tc.scopes, Repos: tc.repos, BranchPrefixes: tc.prefixes})) | |
| 401 | + | } | |
| 402 | + | s, err := kvstore.Open(ctx, dir) | |
| 403 | + | require.NoError(t, err) | |
| 404 | + | require.NoError(t, s.Close(ctx)) | |
| 405 | + | assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir)) | |
| 406 | + | db, err := kv.Open(ctx, dir, kv.Options{}) | |
| 407 | + | require.NoError(t, err) | |
| 408 | + | defer db.Close(ctx) | |
| 409 | + | for _, id := range []string{"full", "partial", "missing-review", "repo", "branch"} { | |
| 410 | + | item, ok := db.Get(ctx, []byte("token/"+id)) | |
| 411 | + | require.True(t, ok) | |
| 412 | + | var pb storagepb.Token | |
| 413 | + | require.NoError(t, proto.Unmarshal(item.Value, &pb)) | |
| 414 | + | if id == "full" || (id == "missing-review" && from < 4) { | |
| 415 | + | assert.Contains(t, pb.Scopes, "release:write") | |
| 416 | + | } else { | |
| 417 | + | assert.NotContains(t, pb.Scopes, "release:write") | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | }) | |
| 421 | + | } | |
| 422 | + | } | |
M internal/adapters/rpc/api.go +9 -0
| @@ -59,6 +59,8 @@ type Config struct { | |||
| 59 | 59 | SigningKey ports.SigningKeyService | |
| 60 | 60 | // Issues serves IssueService. | |
| 61 | 61 | Issues ports.IssueService | |
| 62 | + | // Releases serves ReleaseService when set; leave unset rather than a typed nil. | |
| 63 | + | Releases ports.ReleaseService | |
| 62 | 64 | // IssueModeration serves the moderation calls of IssueService. | |
| 63 | 65 | IssueModeration ports.IssueModerationService | |
| 64 | 66 | Version string | |
| @@ -142,6 +144,9 @@ func New(cfg Config) *API { | |||
| 142 | 144 | if artifacts { | |
| 143 | 145 | features = append(features, FeatureCIArtifacts) | |
| 144 | 146 | } | |
| 147 | + | if cfg.Releases != nil { | |
| 148 | + | features = append(features, FeatureReleases) | |
| 149 | + | } | |
| 145 | 150 | if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() { | |
| 146 | 151 | features = append(features, FeatureCIOIDC) | |
| 147 | 152 | } | |
| @@ -162,6 +167,10 @@ func New(cfg Config) *API { | |||
| 162 | 167 | mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...)) | |
| 163 | 168 | mux.Handle(ermiteconnect.NewEnvironmentServiceHandler(&environmentService{cfg.Environments}, opts...)) | |
| 164 | 169 | } | |
| 170 | + | if cfg.Releases != nil { | |
| 171 | + | releaseOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes)) | |
| 172 | + | mux.Handle(ermiteconnect.NewReleaseServiceHandler(&releaseService{cfg.Releases}, releaseOpts...)) | |
| 173 | + | } | |
| 165 | 174 | if cfg.Runner != nil { | |
| 166 | 175 | runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes)) | |
| 167 | 176 | runners := &runnerService{runners: cfg.Runner, limiter: cfg.Limiter} | |
M internal/adapters/rpc/convert.go +28 -3
| @@ -137,6 +137,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{ | |||
| 137 | 137 | domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER, | |
| 138 | 138 | domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN, | |
| 139 | 139 | domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE, | |
| 140 | + | domain.ScopeReleaseWrite: pb.Scope_SCOPE_RELEASE_WRITE, | |
| 140 | 141 | } | |
| 141 | 142 | ||
| 142 | 143 | func scopeFromPB(s pb.Scope) (domain.Scope, error) { | |
| @@ -537,9 +538,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 537 | 538 | out.Time = timestamp(ev.At) | |
| 538 | 539 | out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}} | |
| 539 | 540 | default: | |
| 540 | - | // Web-only events, such as domain.IssueReferenced and | |
| 541 | - | // domain.IssuePendingRemoved: the API has no messages for them. | |
| 542 | - | return nil | |
| 541 | + | // Release lifecycle conversions live alongside the event service. | |
| 542 | + | // Web-only events have no representation in the API. | |
| 543 | + | return releaseEventToPB(we) | |
| 543 | 544 | } | |
| 544 | 545 | return out | |
| 545 | 546 | } | |
| @@ -566,3 +567,27 @@ func passkeysToPB(list []domain.Passkey) []*pb.Passkey { | |||
| 566 | 567 | } | |
| 567 | 568 | return out | |
| 568 | 569 | } | |
| 570 | + | ||
| 571 | + | func releaseToPB(r domain.Release) *pb.Release { | |
| 572 | + | return &pb.Release{ | |
| 573 | + | Id: r.ID, Repo: r.Repo.String(), Tag: r.Tag, Title: r.Title, Notes: r.Notes, | |
| 574 | + | Draft: r.Draft, Prerelease: r.Prerelease, TargetCommitish: r.TargetCommitish, Author: r.Author, | |
| 575 | + | CreatedAt: timestamp(r.CreatedAt), PublishedAt: timestamp(r.PublishedAt), UpdatedAt: timestamp(r.UpdatedAt), | |
| 576 | + | } | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | func assetToPB(a domain.ReleaseAsset) *pb.ReleaseAsset { | |
| 580 | + | return &pb.ReleaseAsset{ | |
| 581 | + | Id: a.ID, ReleaseId: a.ReleaseID, Name: a.Name, Label: a.Label, ContentType: a.ContentType, | |
| 582 | + | Size: a.Size, Digest: a.Digest, Uploader: a.Uploader, | |
| 583 | + | CreatedAt: timestamp(a.CreatedAt), UpdatedAt: timestamp(a.UpdatedAt), | |
| 584 | + | } | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | func assetsToPB(assets []domain.ReleaseAsset) []*pb.ReleaseAsset { | |
| 588 | + | out := make([]*pb.ReleaseAsset, 0, len(assets)) | |
| 589 | + | for _, a := range assets { | |
| 590 | + | out = append(out, assetToPB(a)) | |
| 591 | + | } | |
| 592 | + | return out | |
| 593 | + | } | |
M internal/adapters/rpc/errors.go +2 -0
| @@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) { | |||
| 55 | 55 | return connect.CodeAborted, true | |
| 56 | 56 | case errors.Is(err, domain.ErrResourceExhausted): | |
| 57 | 57 | return connect.CodeResourceExhausted, true | |
| 58 | + | case errors.Is(err, domain.ErrOutOfRange): | |
| 59 | + | return connect.CodeOutOfRange, true | |
| 58 | 60 | case errors.Is(err, domain.ErrUnavailable): | |
| 59 | 61 | return connect.CodeUnavailable, true | |
| 60 | 62 | } | |
M internal/adapters/rpc/event.go +26 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "connectrpc.com/connect" | |
| 9 | 9 | ||
| 10 | 10 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | 12 | "bnema.dev/ermite/internal/ports" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| @@ -37,3 +38,28 @@ func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchR | |||
| 37 | 38 | }) | |
| 38 | 39 | return connectError(ctx, err) | |
| 39 | 40 | } | |
| 41 | + | ||
| 42 | + | func releaseEventToPB(we ports.WatchEvent) *pb.Event { | |
| 43 | + | out := &pb.Event{Id: we.ID} | |
| 44 | + | switch ev := we.Event.(type) { | |
| 45 | + | case domain.ReleasePublished: | |
| 46 | + | r := ev.Release | |
| 47 | + | out.Time = timestamp(ev.At) | |
| 48 | + | out.Kind = &pb.Event_ReleasePublished{ReleasePublished: &pb.ReleasePublished{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}} | |
| 49 | + | case domain.ReleaseUpdated: | |
| 50 | + | r := ev.Release | |
| 51 | + | out.Time = timestamp(ev.At) | |
| 52 | + | out.Kind = &pb.Event_ReleaseUpdated{ReleaseUpdated: &pb.ReleaseUpdated{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}} | |
| 53 | + | case domain.ReleaseDeleted: | |
| 54 | + | r := ev.Release | |
| 55 | + | out.Time = timestamp(ev.At) | |
| 56 | + | out.Kind = &pb.Event_ReleaseDeleted{ReleaseDeleted: &pb.ReleaseDeleted{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag}} | |
| 57 | + | case domain.ReleaseUnpublished: | |
| 58 | + | r := ev.Release | |
| 59 | + | out.Time = timestamp(ev.At) | |
| 60 | + | out.Kind = &pb.Event_ReleaseUnpublished{ReleaseUnpublished: &pb.ReleaseUnpublished{Repo: r.Repo.String(), Id: r.ID, Tag: r.Tag, Draft: r.Draft, Prerelease: r.Prerelease}} | |
| 61 | + | default: | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | return out | |
| 65 | + | } | |
A internal/adapters/rpc/release.go +358 -0
| @@ -0,0 +1,358 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | ||
| 11 | + | "connectrpc.com/connect" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // FeatureReleases is listed by GetVersion when ReleaseService is served. | |
| 20 | + | const FeatureReleases = "releases" | |
| 21 | + | ||
| 22 | + | // releaseService implements ermiteconnect.ReleaseServiceHandler. | |
| 23 | + | type releaseService struct{ rel ports.ReleaseService } | |
| 24 | + | ||
| 25 | + | var _ ermiteconnect.ReleaseServiceHandler = (*releaseService)(nil) | |
| 26 | + | ||
| 27 | + | // releaseMessage fills the computed latest flag and uploaded assets using the | |
| 28 | + | // same caller's read permissions. No latest release is a normal repository state. | |
| 29 | + | func (s *releaseService) releaseMessage(ctx context.Context, repo domain.RepoName, r domain.Release) (*pb.Release, error) { | |
| 30 | + | latest, err := s.latestID(ctx, repo) | |
| 31 | + | if err != nil { | |
| 32 | + | return nil, err | |
| 33 | + | } | |
| 34 | + | return s.releaseRow(ctx, repo, r, latest) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // latestID is the id of the latest release, or 0 when the repository has none. | |
| 38 | + | func (s *releaseService) latestID(ctx context.Context, repo domain.RepoName) (int64, error) { | |
| 39 | + | latest, err := s.rel.GetRelease(ctx, caller(ctx), repo, ports.ReleaseRef{Latest: true}) | |
| 40 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 41 | + | return 0, err | |
| 42 | + | } | |
| 43 | + | return latest.ID, nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // releaseRow converts one release given the id of the latest one, so a list | |
| 47 | + | // resolves the latest release once. | |
| 48 | + | func (s *releaseService) releaseRow(ctx context.Context, repo domain.RepoName, r domain.Release, latest int64) (*pb.Release, error) { | |
| 49 | + | assets, err := s.rel.ListAssets(ctx, caller(ctx), repo, r.ID) | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, err | |
| 52 | + | } | |
| 53 | + | out := releaseToPB(r) | |
| 54 | + | out.Latest = latest != 0 && latest == r.ID | |
| 55 | + | out.Assets = assetsToPB(assets) | |
| 56 | + | return out, nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (s *releaseService) CreateRelease(ctx context.Context, req *connect.Request[pb.CreateReleaseRequest]) (*connect.Response[pb.CreateReleaseResponse], error) { | |
| 60 | + | m := req.Msg | |
| 61 | + | repo, err := repoOf(m.GetRepo()) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, connectError(ctx, err) | |
| 64 | + | } | |
| 65 | + | r, err := s.rel.CreateRelease(ctx, caller(ctx), repo, ports.CreateReleaseParams{Tag: m.GetTag(), TargetCommitish: m.GetTargetCommitish(), Title: m.GetTitle(), Notes: m.GetNotes(), Draft: m.GetDraft(), Prerelease: m.GetPrerelease(), MakeLatest: m.GetMakeLatest()}) | |
| 66 | + | if err != nil { | |
| 67 | + | return nil, connectError(ctx, err) | |
| 68 | + | } | |
| 69 | + | out, err := s.releaseMessage(ctx, repo, r) | |
| 70 | + | if err != nil { | |
| 71 | + | return nil, connectError(ctx, err) | |
| 72 | + | } | |
| 73 | + | return connect.NewResponse(&pb.CreateReleaseResponse{Release: out}), nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func releaseRef(m *pb.GetReleaseRequest) (ports.ReleaseRef, error) { | |
| 77 | + | switch v := m.GetSelector().(type) { | |
| 78 | + | case *pb.GetReleaseRequest_Id: | |
| 79 | + | if v.Id > 0 { | |
| 80 | + | return ports.ReleaseRef{ID: v.Id}, nil | |
| 81 | + | } | |
| 82 | + | case *pb.GetReleaseRequest_Tag: | |
| 83 | + | if v.Tag != "" { | |
| 84 | + | return ports.ReleaseRef{Tag: v.Tag}, nil | |
| 85 | + | } | |
| 86 | + | case *pb.GetReleaseRequest_Latest: | |
| 87 | + | if v.Latest { | |
| 88 | + | return ports.ReleaseRef{Latest: true}, nil | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | return ports.ReleaseRef{}, fmt.Errorf("%w: select an id, tag or latest=true", domain.ErrInvalid) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (s *releaseService) GetRelease(ctx context.Context, req *connect.Request[pb.GetReleaseRequest]) (*connect.Response[pb.GetReleaseResponse], error) { | |
| 95 | + | repo, err := repoOf(req.Msg.GetRepo()) | |
| 96 | + | if err != nil { | |
| 97 | + | return nil, connectError(ctx, err) | |
| 98 | + | } | |
| 99 | + | ref, err := releaseRef(req.Msg) | |
| 100 | + | if err != nil { | |
| 101 | + | return nil, connectError(ctx, err) | |
| 102 | + | } | |
| 103 | + | r, err := s.rel.GetRelease(ctx, caller(ctx), repo, ref) | |
| 104 | + | if err != nil { | |
| 105 | + | return nil, connectError(ctx, err) | |
| 106 | + | } | |
| 107 | + | out, err := s.releaseMessage(ctx, repo, r) | |
| 108 | + | if err != nil { | |
| 109 | + | return nil, connectError(ctx, err) | |
| 110 | + | } | |
| 111 | + | return connect.NewResponse(&pb.GetReleaseResponse{Release: out}), nil | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (s *releaseService) ListReleases(ctx context.Context, req *connect.Request[pb.ListReleasesRequest]) (*connect.Response[pb.ListReleasesResponse], error) { | |
| 115 | + | repo, err := repoOf(req.Msg.GetRepo()) | |
| 116 | + | if err != nil { | |
| 117 | + | return nil, connectError(ctx, err) | |
| 118 | + | } | |
| 119 | + | page, err := s.rel.ListReleases(ctx, caller(ctx), repo, int(req.Msg.GetPageSize()), req.Msg.GetPageToken()) | |
| 120 | + | if err != nil { | |
| 121 | + | return nil, connectError(ctx, err) | |
| 122 | + | } | |
| 123 | + | out := &pb.ListReleasesResponse{Releases: make([]*pb.Release, 0, len(page.Releases)), NextPageToken: page.NextPageToken} | |
| 124 | + | latest, err := s.latestID(ctx, repo) | |
| 125 | + | if err != nil { | |
| 126 | + | return nil, connectError(ctx, err) | |
| 127 | + | } | |
| 128 | + | for _, r := range page.Releases { | |
| 129 | + | m, err := s.releaseRow(ctx, repo, r, latest) | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, connectError(ctx, err) | |
| 132 | + | } | |
| 133 | + | out.Releases = append(out.Releases, m) | |
| 134 | + | } | |
| 135 | + | return connect.NewResponse(out), nil | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func releaseUpdateParams(m *pb.UpdateReleaseRequest) (ports.UpdateReleaseParams, error) { | |
| 139 | + | var p ports.UpdateReleaseParams | |
| 140 | + | if len(m.GetUpdateMask().GetPaths()) == 0 { | |
| 141 | + | return p, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid) | |
| 142 | + | } | |
| 143 | + | for _, path := range m.GetUpdateMask().GetPaths() { | |
| 144 | + | switch path { | |
| 145 | + | case "tag": | |
| 146 | + | p.Tag = new(m.GetTag()) | |
| 147 | + | case "target_commitish": | |
| 148 | + | p.TargetCommitish = new(m.GetTargetCommitish()) | |
| 149 | + | case "title": | |
| 150 | + | p.Title = new(m.GetTitle()) | |
| 151 | + | case "notes": | |
| 152 | + | p.Notes = new(m.GetNotes()) | |
| 153 | + | case "draft": | |
| 154 | + | p.Draft = new(m.GetDraft()) | |
| 155 | + | case "prerelease": | |
| 156 | + | p.Prerelease = new(m.GetPrerelease()) | |
| 157 | + | case "make_latest": | |
| 158 | + | p.MakeLatest = new(m.GetMakeLatest()) | |
| 159 | + | default: | |
| 160 | + | return p, fmt.Errorf("%w: unknown update_mask path %q", domain.ErrInvalid, path) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | return p, nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (s *releaseService) UpdateRelease(ctx context.Context, req *connect.Request[pb.UpdateReleaseRequest]) (*connect.Response[pb.UpdateReleaseResponse], error) { | |
| 167 | + | repo, err := repoOf(req.Msg.GetRepo()) | |
| 168 | + | if err != nil { | |
| 169 | + | return nil, connectError(ctx, err) | |
| 170 | + | } | |
| 171 | + | p, err := releaseUpdateParams(req.Msg) | |
| 172 | + | if err != nil { | |
| 173 | + | return nil, connectError(ctx, err) | |
| 174 | + | } | |
| 175 | + | r, err := s.rel.UpdateRelease(ctx, caller(ctx), repo, req.Msg.GetId(), p) | |
| 176 | + | if err != nil { | |
| 177 | + | return nil, connectError(ctx, err) | |
| 178 | + | } | |
| 179 | + | out, err := s.releaseMessage(ctx, repo, r) | |
| 180 | + | if err != nil { | |
| 181 | + | return nil, connectError(ctx, err) | |
| 182 | + | } | |
| 183 | + | return connect.NewResponse(&pb.UpdateReleaseResponse{Release: out}), nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (s *releaseService) DeleteRelease(ctx context.Context, req *connect.Request[pb.DeleteReleaseRequest]) (*connect.Response[pb.DeleteReleaseResponse], error) { | |
| 187 | + | repo, err := repoOf(req.Msg.GetRepo()) | |
| 188 | + | if err != nil { | |
| 189 | + | return nil, connectError(ctx, err) | |
| 190 | + | } | |
| 191 | + | if err := s.rel.DeleteRelease(ctx, caller(ctx), repo, req.Msg.GetId()); err != nil { | |
| 192 | + | return nil, connectError(ctx, err) | |
| 193 | + | } | |
| 194 | + | return connect.NewResponse(&pb.DeleteReleaseResponse{}), nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // chunkReader retains at most one message; metadata belongs to the first only. | |
| 198 | + | type chunkReader struct { | |
| 199 | + | stream *connect.ClientStream[pb.UploadReleaseAssetRequest] | |
| 200 | + | buf []byte | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (r *chunkReader) Read(p []byte) (int, error) { | |
| 204 | + | if len(p) == 0 { | |
| 205 | + | return 0, nil | |
| 206 | + | } | |
| 207 | + | for len(r.buf) == 0 { | |
| 208 | + | if !r.stream.Receive() { | |
| 209 | + | if err := r.stream.Err(); err != nil { | |
| 210 | + | return 0, err | |
| 211 | + | } | |
| 212 | + | return 0, io.EOF | |
| 213 | + | } | |
| 214 | + | m := r.stream.Msg() | |
| 215 | + | if m.GetRepo() != "" || m.GetReleaseId() != 0 || m.GetName() != "" || m.GetLabel() != "" || m.GetContentType() != "" || m.GetSize() != 0 || m.GetReplace() { | |
| 216 | + | return 0, fmt.Errorf("%w: upload metadata is in the first message only", domain.ErrInvalid) | |
| 217 | + | } | |
| 218 | + | if err := checkBlockData(m.GetData()); err != nil { | |
| 219 | + | return 0, err | |
| 220 | + | } | |
| 221 | + | r.buf = m.GetData() | |
| 222 | + | } | |
| 223 | + | n := copy(p, r.buf) | |
| 224 | + | r.buf = r.buf[n:] | |
| 225 | + | return n, nil | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func (s *releaseService) UploadReleaseAsset(ctx context.Context, stream *connect.ClientStream[pb.UploadReleaseAssetRequest]) (*connect.Response[pb.UploadReleaseAssetResponse], error) { | |
| 229 | + | if !stream.Receive() { | |
| 230 | + | err := stream.Err() | |
| 231 | + | if err == nil { | |
| 232 | + | err = fmt.Errorf("%w: the stream has no message", domain.ErrInvalid) | |
| 233 | + | } | |
| 234 | + | return nil, connectError(ctx, err) | |
| 235 | + | } | |
| 236 | + | m := stream.Msg() | |
| 237 | + | repo, err := repoOf(m.GetRepo()) | |
| 238 | + | if err != nil { | |
| 239 | + | return nil, connectError(ctx, err) | |
| 240 | + | } | |
| 241 | + | if m.GetSize() < 0 { | |
| 242 | + | return nil, connectError(ctx, fmt.Errorf("%w: size must be nonnegative", domain.ErrInvalid)) | |
| 243 | + | } | |
| 244 | + | if err := checkBlockData(m.GetData()); err != nil { | |
| 245 | + | return nil, connectError(ctx, err) | |
| 246 | + | } | |
| 247 | + | a, err := s.rel.UploadAsset(ctx, caller(ctx), repo, ports.UploadAssetParams{ReleaseID: m.GetReleaseId(), Name: m.GetName(), Label: m.GetLabel(), ContentType: m.GetContentType(), Size: m.GetSize(), Replace: m.GetReplace()}, &chunkReader{stream: stream, buf: m.GetData()}) | |
| 248 | + | if err != nil { | |
| 249 | + | return nil, connectError(ctx, err) | |
| 250 | + | } | |
| 251 | + | return connect.NewResponse(&pb.UploadReleaseAssetResponse{Asset: assetToPB(a)}), nil | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func assetUpdateParams(m *pb.UpdateReleaseAssetRequest) (ports.UpdateAssetParams, error) { | |
| 255 | + | var p ports.UpdateAssetParams | |
| 256 | + | if len(m.GetUpdateMask().GetPaths()) == 0 { | |
| 257 | + | return p, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid) | |
| 258 | + | } | |
| 259 | + | for _, path := range m.GetUpdateMask().GetPaths() { | |
| 260 | + | switch path { | |
| 261 | + | case "name": | |
| 262 | + | p.Name = new(m.GetName()) | |
| 263 | + | case "label": | |
| 264 | + | p.Label = new(m.GetLabel()) | |
| 265 | + | default: | |
| 266 | + | return p, fmt.Errorf("%w: unknown update_mask path %q", domain.ErrInvalid, path) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | return p, nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func (s *releaseService) UpdateReleaseAsset(ctx context.Context, req *connect.Request[pb.UpdateReleaseAssetRequest]) (*connect.Response[pb.UpdateReleaseAssetResponse], error) { | |
| 273 | + | repo, err := repoOf(req.Msg.GetRepo()) | |
| 274 | + | if err != nil { | |
| 275 | + | return nil, connectError(ctx, err) | |
| 276 | + | } | |
| 277 | + | p, err := assetUpdateParams(req.Msg) | |
| 278 | + | if err != nil { | |
| 279 | + | return nil, connectError(ctx, err) | |
| 280 | + | } | |
| 281 | + | a, err := s.rel.UpdateAsset(ctx, caller(ctx), repo, req.Msg.GetAssetId(), p) | |
| 282 | + | if err != nil { | |
| 283 | + | return nil, connectError(ctx, err) | |
| 284 | + | } | |
| 285 | + | return connect.NewResponse(&pb.UpdateReleaseAssetResponse{Asset: assetToPB(a)}), nil | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (s *releaseService) DeleteReleaseAsset(ctx context.Context, req *connect.Request[pb.DeleteReleaseAssetRequest]) (*connect.Response[pb.DeleteReleaseAssetResponse], error) { | |
| 289 | + | repo, err := repoOf(req.Msg.GetRepo()) | |
| 290 | + | if err != nil { | |
| 291 | + | return nil, connectError(ctx, err) | |
| 292 | + | } | |
| 293 | + | if err := s.rel.DeleteAsset(ctx, caller(ctx), repo, req.Msg.GetAssetId()); err != nil { | |
| 294 | + | return nil, connectError(ctx, err) | |
| 295 | + | } | |
| 296 | + | return connect.NewResponse(&pb.DeleteReleaseAssetResponse{}), nil | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func assetRef(m *pb.DownloadReleaseAssetRequest) (ports.AssetRef, error) { | |
| 300 | + | switch v := m.GetSelector().(type) { | |
| 301 | + | case *pb.DownloadReleaseAssetRequest_AssetId: | |
| 302 | + | if v.AssetId > 0 { | |
| 303 | + | return ports.AssetRef{ID: v.AssetId}, nil | |
| 304 | + | } | |
| 305 | + | case *pb.DownloadReleaseAssetRequest_Tag: | |
| 306 | + | if v.Tag.GetTag() != "" && v.Tag.GetName() != "" { | |
| 307 | + | return ports.AssetRef{Tag: v.Tag.GetTag(), Name: v.Tag.GetName()}, nil | |
| 308 | + | } | |
| 309 | + | case *pb.DownloadReleaseAssetRequest_Latest: | |
| 310 | + | if v.Latest.GetLatest() && v.Latest.GetName() != "" { | |
| 311 | + | return ports.AssetRef{Latest: true, Name: v.Latest.GetName()}, nil | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | return ports.AssetRef{}, fmt.Errorf("%w: select an asset id, tag/name or latest=true/name", domain.ErrInvalid) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func (s *releaseService) DownloadReleaseAsset(ctx context.Context, req *connect.Request[pb.DownloadReleaseAssetRequest], stream *connect.ServerStream[pb.DownloadReleaseAssetResponse]) error { | |
| 318 | + | repo, err := repoOf(req.Msg.GetRepo()) | |
| 319 | + | if err != nil { | |
| 320 | + | return connectError(ctx, err) | |
| 321 | + | } | |
| 322 | + | ref, err := assetRef(req.Msg) | |
| 323 | + | if err != nil { | |
| 324 | + | return connectError(ctx, err) | |
| 325 | + | } | |
| 326 | + | err = s.rel.DownloadAsset(ctx, caller(ctx), repo, ref, req.Msg.GetOffset(), -1, func(c ports.AssetChunk) error { | |
| 327 | + | m := &pb.DownloadReleaseAssetResponse{Chunk: c.Data} | |
| 328 | + | if c.Asset != nil { | |
| 329 | + | m.Asset = assetToPB(*c.Asset) | |
| 330 | + | } | |
| 331 | + | return stream.Send(m) | |
| 332 | + | }) | |
| 333 | + | return connectError(ctx, err) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (s *releaseService) ListReleaseAssets(ctx context.Context, req *connect.Request[pb.ListReleaseAssetsRequest]) (*connect.Response[pb.ListReleaseAssetsResponse], error) { | |
| 337 | + | repo, err := repoOf(req.Msg.GetRepo()) | |
| 338 | + | if err != nil { | |
| 339 | + | return nil, connectError(ctx, err) | |
| 340 | + | } | |
| 341 | + | assets, err := s.rel.ListAssets(ctx, caller(ctx), repo, req.Msg.GetReleaseId()) | |
| 342 | + | if err != nil { | |
| 343 | + | return nil, connectError(ctx, err) | |
| 344 | + | } | |
| 345 | + | return connect.NewResponse(&pb.ListReleaseAssetsResponse{Assets: assetsToPB(assets)}), nil | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func (s *releaseService) GetReleaseUsage(ctx context.Context, req *connect.Request[pb.GetReleaseUsageRequest]) (*connect.Response[pb.GetReleaseUsageResponse], error) { | |
| 349 | + | repo, err := repoOf(req.Msg.GetRepo()) | |
| 350 | + | if err != nil { | |
| 351 | + | return nil, connectError(ctx, err) | |
| 352 | + | } | |
| 353 | + | u, err := s.rel.Usage(ctx, caller(ctx), repo) | |
| 354 | + | if err != nil { | |
| 355 | + | return nil, connectError(ctx, err) | |
| 356 | + | } | |
| 357 | + | return connect.NewResponse(&pb.GetReleaseUsageResponse{UsedBytes: u.Used, QuotaBytes: u.Limits.Quota, AssetMaxBytes: u.Limits.AssetMax, AssetsMax: int32(u.Limits.AssetsMax)}), nil | |
| 358 | + | } | |
A internal/adapters/rpc/release_test.go +472 -0
| @@ -0,0 +1,472 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "io" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "connectrpc.com/connect" | |
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | "google.golang.org/protobuf/proto" | |
| 18 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 19 | + | ||
| 20 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 21 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 22 | + | "bnema.dev/ermite/internal/domain" | |
| 23 | + | "bnema.dev/ermite/internal/ports" | |
| 24 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | func releaseClient(t *testing.T, rel ports.ReleaseService) (ermiteconnect.ReleaseServiceClient, ermiteconnect.ServerServiceClient) { | |
| 28 | + | t.Helper() | |
| 29 | + | srv := httptest.NewUnstartedServer(New(Config{Releases: rel, Sessions: mocks.NewSessionService(t), SigningKey: mocks.NewSigningKeyService(t)}).Handler()) | |
| 30 | + | srv.EnableHTTP2 = true | |
| 31 | + | srv.StartTLS() | |
| 32 | + | t.Cleanup(srv.Close) | |
| 33 | + | return ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL), ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestReleaseRPCUnary(t *testing.T) { | |
| 37 | + | ctx := context.Background() | |
| 38 | + | repo := mustRepoName(t, "demo") | |
| 39 | + | now := time.Now().UTC().Truncate(time.Second) | |
| 40 | + | r := domain.Release{ID: 7, Repo: repo, Tag: "v1", Title: "title", Notes: "notes", Prerelease: true, TargetCommitish: "abc", Author: "owner", CreatedAt: now, PublishedAt: now, UpdatedAt: now} | |
| 41 | + | a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Repo: repo, Name: "a.zip", Label: "label", ContentType: "application/zip", Size: 3, Digest: "sha256:123", Uploader: "owner", CreatedAt: now, UpdatedAt: now} | |
| 42 | + | expected := releaseToPB(r) | |
| 43 | + | expected.Latest = true | |
| 44 | + | expected.Assets = []*pb.ReleaseAsset{assetToPB(a)} | |
| 45 | + | for _, method := range []string{"create", "get id", "get tag", "get latest", "list", "update", "delete", "update asset", "delete asset", "list assets", "usage"} { | |
| 46 | + | t.Run(method, func(t *testing.T) { | |
| 47 | + | rel := mocks.NewReleaseService(t) | |
| 48 | + | c, _ := releaseClient(t, rel) | |
| 49 | + | p := domain.Anonymous() | |
| 50 | + | hydrate := func() { | |
| 51 | + | rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(r, nil).Once() | |
| 52 | + | rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return([]domain.ReleaseAsset{a}, nil).Once() | |
| 53 | + | } | |
| 54 | + | switch method { | |
| 55 | + | case "create": | |
| 56 | + | rel.EXPECT().CreateRelease(mock.Anything, p, repo, ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true}).Return(r, nil).Once() | |
| 57 | + | hydrate() | |
| 58 | + | res, err := c.CreateRelease(ctx, connect.NewRequest(&pb.CreateReleaseRequest{Repo: "demo", Tag: "v1", TargetCommitish: "abc", Title: "title", Notes: "notes", Draft: true, Prerelease: true, MakeLatest: true})) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | assert.True(t, proto.Equal(expected, res.Msg.GetRelease())) | |
| 61 | + | case "get id", "get tag", "get latest": | |
| 62 | + | req := &pb.GetReleaseRequest{Repo: "demo"} | |
| 63 | + | ref := ports.ReleaseRef{} | |
| 64 | + | switch method { | |
| 65 | + | case "get id": | |
| 66 | + | req.Selector = &pb.GetReleaseRequest_Id{Id: 7} | |
| 67 | + | ref.ID = 7 | |
| 68 | + | case "get tag": | |
| 69 | + | req.Selector = &pb.GetReleaseRequest_Tag{Tag: "v1"} | |
| 70 | + | ref.Tag = "v1" | |
| 71 | + | default: | |
| 72 | + | req.Selector = &pb.GetReleaseRequest_Latest{Latest: true} | |
| 73 | + | ref.Latest = true | |
| 74 | + | } | |
| 75 | + | rel.EXPECT().GetRelease(mock.Anything, p, repo, ref).Return(r, nil).Once() | |
| 76 | + | hydrate() | |
| 77 | + | res, err := c.GetRelease(ctx, connect.NewRequest(req)) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | assert.True(t, proto.Equal(expected, res.Msg.GetRelease())) | |
| 80 | + | case "list": | |
| 81 | + | rel.EXPECT().ListReleases(mock.Anything, p, repo, 17, "cursor").Return(ports.ReleasePage{Releases: []domain.Release{r}, NextPageToken: "next"}, nil).Once() | |
| 82 | + | hydrate() | |
| 83 | + | res, err := c.ListReleases(ctx, connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo", PageSize: 17, PageToken: "cursor"})) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | require.Len(t, res.Msg.Releases, 1) | |
| 86 | + | assert.True(t, proto.Equal(expected, res.Msg.Releases[0])) | |
| 87 | + | assert.Equal(t, "next", res.Msg.NextPageToken) | |
| 88 | + | case "update": | |
| 89 | + | rel.EXPECT().UpdateRelease(mock.Anything, p, repo, int64(7), ports.UpdateReleaseParams{Title: new(""), Draft: new(false)}).Return(r, nil).Once() | |
| 90 | + | hydrate() | |
| 91 | + | res, err := c.UpdateRelease(ctx, connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", Id: 7, Tag: "ignored", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title", "draft"}}})) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | assert.True(t, proto.Equal(expected, res.Msg.GetRelease())) | |
| 94 | + | case "delete": | |
| 95 | + | rel.EXPECT().DeleteRelease(mock.Anything, p, repo, int64(7)).Return(nil).Once() | |
| 96 | + | _, err := c.DeleteRelease(ctx, connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7})) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | case "update asset": | |
| 99 | + | rel.EXPECT().UpdateAsset(mock.Anything, p, repo, int64(9), ports.UpdateAssetParams{Name: new("new.zip"), Label: new("")}).Return(a, nil).Once() | |
| 100 | + | res, err := c.UpdateReleaseAsset(ctx, connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo", AssetId: 9, Name: "new.zip", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name", "label"}}})) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | assert.True(t, proto.Equal(assetToPB(a), res.Msg.GetAsset())) | |
| 103 | + | case "delete asset": | |
| 104 | + | rel.EXPECT().DeleteAsset(mock.Anything, p, repo, int64(9)).Return(nil).Once() | |
| 105 | + | _, err := c.DeleteReleaseAsset(ctx, connect.NewRequest(&pb.DeleteReleaseAssetRequest{Repo: "demo", AssetId: 9})) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | case "list assets": | |
| 108 | + | rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(7)).Return([]domain.ReleaseAsset{a}, nil).Once() | |
| 109 | + | res, err := c.ListReleaseAssets(ctx, connect.NewRequest(&pb.ListReleaseAssetsRequest{Repo: "demo", ReleaseId: 7})) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | require.Len(t, res.Msg.Assets, 1) | |
| 112 | + | assert.True(t, proto.Equal(assetToPB(a), res.Msg.Assets[0])) | |
| 113 | + | case "usage": | |
| 114 | + | rel.EXPECT().Usage(mock.Anything, p, repo).Return(ports.ReleaseUsage{Used: 1, Limits: ports.ReleaseLimits{Quota: 2, AssetMax: 3, AssetsMax: 4}}, nil).Once() | |
| 115 | + | res, err := c.GetReleaseUsage(ctx, connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"})) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | assert.True(t, proto.Equal(&pb.GetReleaseUsageResponse{UsedBytes: 1, QuotaBytes: 2, AssetMaxBytes: 3, AssetsMax: 4}, res.Msg)) | |
| 118 | + | } | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestReleaseFeatureAndMount(t *testing.T) { | |
| 124 | + | for _, enabled := range []bool{false, true} { | |
| 125 | + | t.Run(map[bool]string{false: "absent", true: "present"}[enabled], func(t *testing.T) { | |
| 126 | + | var rel ports.ReleaseService | |
| 127 | + | if enabled { | |
| 128 | + | rel = mocks.NewReleaseService(t) | |
| 129 | + | } | |
| 130 | + | c, server := releaseClient(t, rel) | |
| 131 | + | res, err := server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | assert.Equal(t, enabled, containsReleaseFeature(res.Msg.Features)) | |
| 134 | + | assert.NotContains(t, Features, FeatureReleases) | |
| 135 | + | if !enabled { | |
| 136 | + | _, err := c.GetReleaseUsage(context.Background(), connect.NewRequest(&pb.GetReleaseUsageRequest{Repo: "demo"})) | |
| 137 | + | requireCode(t, connect.CodeUnimplemented, err) | |
| 138 | + | } | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | func containsReleaseFeature(features []string) bool { | |
| 143 | + | for _, f := range features { | |
| 144 | + | if f == FeatureReleases { | |
| 145 | + | return true | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | return false | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestReleaseRPCUpload(t *testing.T) { | |
| 152 | + | for _, firstData := range []bool{false, true} { | |
| 153 | + | t.Run(map[bool]string{false: "header only", true: "header with data"}[firstData], func(t *testing.T) { | |
| 154 | + | rel := mocks.NewReleaseService(t) | |
| 155 | + | c, _ := releaseClient(t, rel) | |
| 156 | + | full := bytes.Repeat([]byte("x"), domain.CIArtifactChunk) | |
| 157 | + | tail := []byte("tail") | |
| 158 | + | want := append(bytes.Clone(full), tail...) | |
| 159 | + | rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.UploadAssetParams{ReleaseID: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true}, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) { | |
| 160 | + | got, err := io.ReadAll(r) | |
| 161 | + | assert.NoError(t, err) | |
| 162 | + | assert.Equal(t, want, got) | |
| 163 | + | return domain.ReleaseAsset{ID: 9, Size: int64(len(got))}, err | |
| 164 | + | }).Once() | |
| 165 | + | stream := c.UploadReleaseAsset(context.Background()) | |
| 166 | + | first := &pb.UploadReleaseAssetRequest{Repo: "demo", ReleaseId: 7, Name: "a", Label: "l", ContentType: "application/octet-stream", Size: int64(len(want)), Replace: true} | |
| 167 | + | if firstData { | |
| 168 | + | first.Data = full | |
| 169 | + | } | |
| 170 | + | require.NoError(t, stream.Send(first)) | |
| 171 | + | if !firstData { | |
| 172 | + | require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: full})) | |
| 173 | + | } | |
| 174 | + | require.NoError(t, stream.Send(&pb.UploadReleaseAssetRequest{Data: tail})) | |
| 175 | + | res, err := stream.CloseAndReceive() | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | assert.EqualValues(t, 9, res.Msg.Asset.Id) | |
| 178 | + | assert.EqualValues(t, len(want), res.Msg.Asset.Size) | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestReleaseRPCUploadInvalid(t *testing.T) { | |
| 184 | + | tests := []struct { | |
| 185 | + | name string | |
| 186 | + | messages []*pb.UploadReleaseAssetRequest | |
| 187 | + | read bool | |
| 188 | + | code connect.Code | |
| 189 | + | }{ | |
| 190 | + | {name: "empty", code: connect.CodeInvalidArgument}, | |
| 191 | + | {name: "negative size", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Size: -1}}, code: connect.CodeInvalidArgument}, | |
| 192 | + | {name: "bad repo", messages: []*pb.UploadReleaseAssetRequest{{Repo: "../bad"}}, code: connect.CodeInvalidArgument}, | |
| 193 | + | {name: "first chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, domain.CIArtifactChunk+1)}}, code: connect.CodeInvalidArgument}, | |
| 194 | + | {name: "later chunk too big", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {Data: make([]byte, domain.CIArtifactChunk+1)}}, read: true, code: connect.CodeInvalidArgument}, | |
| 195 | + | {name: "message exceeds envelope", messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo", Data: make([]byte, RunnerMaxRequestBytes+1)}}, code: connect.CodeResourceExhausted}, | |
| 196 | + | } | |
| 197 | + | for _, m := range []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, {ReleaseId: 1}, {Name: "a"}, {Label: "l"}, {ContentType: "text/plain"}, {Size: 1}, {Replace: true}} { | |
| 198 | + | tests = append(tests, struct { | |
| 199 | + | name string | |
| 200 | + | messages []*pb.UploadReleaseAssetRequest | |
| 201 | + | read bool | |
| 202 | + | code connect.Code | |
| 203 | + | }{name: "repeated metadata " + m.String(), messages: []*pb.UploadReleaseAssetRequest{{Repo: "demo"}, m}, read: true, code: connect.CodeInvalidArgument}) | |
| 204 | + | } | |
| 205 | + | for _, tt := range tests { | |
| 206 | + | t.Run(tt.name, func(t *testing.T) { | |
| 207 | + | rel := mocks.NewReleaseService(t) | |
| 208 | + | c, _ := releaseClient(t, rel) | |
| 209 | + | if tt.read { | |
| 210 | + | rel.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.UploadAssetParams, r io.Reader) (domain.ReleaseAsset, error) { | |
| 211 | + | _, err := io.ReadAll(r) | |
| 212 | + | return domain.ReleaseAsset{}, err | |
| 213 | + | }).Once() | |
| 214 | + | } | |
| 215 | + | stream := c.UploadReleaseAsset(context.Background()) | |
| 216 | + | for _, m := range tt.messages { | |
| 217 | + | if err := stream.Send(m); err != nil { | |
| 218 | + | break | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | _, err := stream.CloseAndReceive() | |
| 222 | + | requireCode(t, tt.code, err) | |
| 223 | + | }) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestReleaseRPCDownload(t *testing.T) { | |
| 228 | + | for _, selector := range []string{"id", "tag", "latest"} { | |
| 229 | + | t.Run(selector, func(t *testing.T) { | |
| 230 | + | rel := mocks.NewReleaseService(t) | |
| 231 | + | c, _ := releaseClient(t, rel) | |
| 232 | + | req := &pb.DownloadReleaseAssetRequest{Repo: "demo", Offset: 3} | |
| 233 | + | ref := ports.AssetRef{} | |
| 234 | + | switch selector { | |
| 235 | + | case "id": | |
| 236 | + | req.Selector = &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9} | |
| 237 | + | ref.ID = 9 | |
| 238 | + | case "tag": | |
| 239 | + | req.Selector = &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1", Name: "a"}} | |
| 240 | + | ref.Tag = "v1" | |
| 241 | + | ref.Name = "a" | |
| 242 | + | default: | |
| 243 | + | req.Selector = &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Latest: true, Name: "a"}} | |
| 244 | + | ref.Latest = true | |
| 245 | + | ref.Name = "a" | |
| 246 | + | } | |
| 247 | + | a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "a", Size: 8} | |
| 248 | + | rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ref, int64(3), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error { | |
| 249 | + | if err := send(ports.AssetChunk{Asset: &a}); err != nil { | |
| 250 | + | return err | |
| 251 | + | } | |
| 252 | + | if err := send(ports.AssetChunk{Data: []byte("12")}); err != nil { | |
| 253 | + | return err | |
| 254 | + | } | |
| 255 | + | return send(ports.AssetChunk{Data: []byte("345")}) | |
| 256 | + | }).Once() | |
| 257 | + | stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(req)) | |
| 258 | + | require.NoError(t, err) | |
| 259 | + | defer stream.Close() | |
| 260 | + | require.True(t, stream.Receive()) | |
| 261 | + | assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset)) | |
| 262 | + | assert.Empty(t, stream.Msg().Chunk) | |
| 263 | + | var got []byte | |
| 264 | + | for stream.Receive() { | |
| 265 | + | assert.Nil(t, stream.Msg().Asset) | |
| 266 | + | got = append(got, stream.Msg().Chunk...) | |
| 267 | + | } | |
| 268 | + | require.NoError(t, stream.Err()) | |
| 269 | + | assert.Equal(t, "12345", string(got)) | |
| 270 | + | }) | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestReleaseRPCErrors(t *testing.T) { | |
| 275 | + | for _, tt := range []struct { | |
| 276 | + | err error | |
| 277 | + | code connect.Code | |
| 278 | + | }{{domain.ErrNotFound, connect.CodeNotFound}, {domain.ErrAlreadyExists, connect.CodeAlreadyExists}, {domain.ErrResourceExhausted, connect.CodeResourceExhausted}, {domain.ErrForbidden, connect.CodePermissionDenied}, {domain.ErrOutOfRange, connect.CodeOutOfRange}, {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition}, {domain.ErrInvalid, connect.CodeInvalidArgument}} { | |
| 279 | + | t.Run(tt.code.String(), func(t *testing.T) { | |
| 280 | + | rel := mocks.NewReleaseService(t) | |
| 281 | + | c, _ := releaseClient(t, rel) | |
| 282 | + | rel.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.ReleaseRef{ID: 7}).Return(domain.Release{}, tt.err).Once() | |
| 283 | + | _, err := c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}})) | |
| 284 | + | requireCode(t, tt.code, err) | |
| 285 | + | rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).Return(tt.err).Once() | |
| 286 | + | stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}})) | |
| 287 | + | if err == nil { | |
| 288 | + | defer stream.Close() | |
| 289 | + | assert.False(t, stream.Receive()) | |
| 290 | + | err = stream.Err() | |
| 291 | + | } | |
| 292 | + | requireCode(t, tt.code, err) | |
| 293 | + | }) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestReleaseMasksAndSelectors(t *testing.T) { | |
| 298 | + | paths := []string{"tag", "target_commitish", "title", "notes", "draft", "prerelease", "make_latest"} | |
| 299 | + | p, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}}) | |
| 300 | + | require.NoError(t, err) | |
| 301 | + | assert.Equal(t, ports.UpdateReleaseParams{Tag: new(""), TargetCommitish: new(""), Title: new(""), Notes: new(""), Draft: new(false), Prerelease: new(false), MakeLatest: new(false)}, p) | |
| 302 | + | for _, paths := range [][]string{nil, {"unknown"}, {"*"}, {"title", "assets"}} { | |
| 303 | + | _, err := releaseUpdateParams(&pb.UpdateReleaseRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}}) | |
| 304 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 305 | + | _, err = assetUpdateParams(&pb.UpdateReleaseAssetRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}}) | |
| 306 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 307 | + | } | |
| 308 | + | for _, req := range []*pb.GetReleaseRequest{{}, {Selector: &pb.GetReleaseRequest_Id{Id: -1}}, {Selector: &pb.GetReleaseRequest_Tag{}}, {Selector: &pb.GetReleaseRequest_Latest{Latest: false}}} { | |
| 309 | + | _, err := releaseRef(req) | |
| 310 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 311 | + | } | |
| 312 | + | for _, req := range []*pb.DownloadReleaseAssetRequest{{}, {Selector: &pb.DownloadReleaseAssetRequest_AssetId{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{}}, {Selector: &pb.DownloadReleaseAssetRequest_Tag{Tag: &pb.ReleaseAssetTag{Tag: "v1"}}}, {Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}}} { | |
| 313 | + | _, err := assetRef(req) | |
| 314 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestReleaseEvents(t *testing.T) { | |
| 319 | + | r := domain.Release{ID: 7, Repo: mustRepoName(t, "demo"), Tag: "v1", Draft: true, Prerelease: true} | |
| 320 | + | at := time.Now().UTC() | |
| 321 | + | events := []domain.Event{domain.ReleasePublished{Release: r, At: at}, domain.ReleaseUpdated{Release: r, At: at}, domain.ReleaseDeleted{Release: r, At: at}, domain.ReleaseUnpublished{Release: r, At: at}} | |
| 322 | + | expected := []*pb.Event{ | |
| 323 | + | {Kind: &pb.Event_ReleasePublished{ReleasePublished: &pb.ReleasePublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}}, | |
| 324 | + | {Kind: &pb.Event_ReleaseUpdated{ReleaseUpdated: &pb.ReleaseUpdated{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}}, | |
| 325 | + | {Kind: &pb.Event_ReleaseDeleted{ReleaseDeleted: &pb.ReleaseDeleted{Repo: "demo", Id: 7, Tag: "v1"}}}, | |
| 326 | + | {Kind: &pb.Event_ReleaseUnpublished{ReleaseUnpublished: &pb.ReleaseUnpublished{Repo: "demo", Id: 7, Tag: "v1", Draft: true, Prerelease: true}}}, | |
| 327 | + | } | |
| 328 | + | for i, ev := range events { | |
| 329 | + | t.Run(ev.EventKind(), func(t *testing.T) { | |
| 330 | + | expected[i].Id = "event" | |
| 331 | + | expected[i].Time = timestamp(at) | |
| 332 | + | assert.True(t, proto.Equal(expected[i], watchEventToPB(ports.WatchEvent{ID: "event", Event: ev}))) | |
| 333 | + | }) | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestReleaseResponseEnrichment(t *testing.T) { | |
| 338 | + | repo := mustRepoName(t, "demo") | |
| 339 | + | for _, tt := range []struct { | |
| 340 | + | name string | |
| 341 | + | latest domain.Release | |
| 342 | + | latestErr, assetsErr error | |
| 343 | + | code connect.Code | |
| 344 | + | wantLatest bool | |
| 345 | + | }{ | |
| 346 | + | {name: "draft without latest", latestErr: domain.ErrNotFound}, | |
| 347 | + | {name: "different latest", latest: domain.Release{ID: 8}}, | |
| 348 | + | {name: "current latest", latest: domain.Release{ID: 7}, wantLatest: true}, | |
| 349 | + | {name: "latest lookup fails", latestErr: domain.ErrUnavailable, code: connect.CodeUnavailable}, | |
| 350 | + | {name: "assets lookup fails", assetsErr: domain.ErrNotFound, code: connect.CodeNotFound}, | |
| 351 | + | } { | |
| 352 | + | t.Run(tt.name, func(t *testing.T) { | |
| 353 | + | rel := mocks.NewReleaseService(t) | |
| 354 | + | rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{ID: 7}).Return(domain.Release{ID: 7, Repo: repo, Draft: true}, nil).Once() | |
| 355 | + | rel.EXPECT().GetRelease(mock.Anything, domain.Owner(), repo, ports.ReleaseRef{Latest: true}).Return(tt.latest, tt.latestErr).Once() | |
| 356 | + | if tt.code != connect.CodeUnavailable { | |
| 357 | + | rel.EXPECT().ListAssets(mock.Anything, domain.Owner(), repo, int64(7)).Return(nil, tt.assetsErr).Once() | |
| 358 | + | } | |
| 359 | + | res, err := (&releaseService{rel}).GetRelease(ownerCtx(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Id{Id: 7}})) | |
| 360 | + | if tt.code != 0 { | |
| 361 | + | requireCode(t, tt.code, err) | |
| 362 | + | return | |
| 363 | + | } | |
| 364 | + | require.NoError(t, err) | |
| 365 | + | assert.Equal(t, tt.wantLatest, res.Msg.Release.Latest) | |
| 366 | + | assert.Empty(t, res.Msg.Release.Assets) | |
| 367 | + | assert.Nil(t, res.Msg.Release.PublishedAt) | |
| 368 | + | }) | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | func TestReleaseRPCAuthenticatedCaller(t *testing.T) { | |
| 373 | + | rel := mocks.NewReleaseService(t) | |
| 374 | + | auth := mocks.NewAuthService(t) | |
| 375 | + | auth.EXPECT().Authenticate(mock.Anything, "secret").Return(domain.Owner(), nil).Once() | |
| 376 | + | rel.EXPECT().DeleteRelease(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(7)).Return(nil).Once() | |
| 377 | + | srv := httptest.NewServer(New(Config{Releases: rel, Auth: auth, Sessions: mocks.NewSessionService(t)}).Handler()) | |
| 378 | + | defer srv.Close() | |
| 379 | + | c := ermiteconnect.NewReleaseServiceClient(srv.Client(), srv.URL) | |
| 380 | + | req := connect.NewRequest(&pb.DeleteReleaseRequest{Repo: "demo", Id: 7}) | |
| 381 | + | req.Header().Set("Authorization", "Bearer secret") | |
| 382 | + | _, err := c.DeleteRelease(context.Background(), req) | |
| 383 | + | require.NoError(t, err) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func TestReleaseRPCDownloadFullChunk(t *testing.T) { | |
| 387 | + | rel := mocks.NewReleaseService(t) | |
| 388 | + | c, _ := releaseClient(t, rel) | |
| 389 | + | data := bytes.Repeat([]byte("x"), domain.CIArtifactChunk) | |
| 390 | + | rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error { | |
| 391 | + | if err := send(ports.AssetChunk{Asset: &domain.ReleaseAsset{ID: 9, Size: int64(len(data))}}); err != nil { | |
| 392 | + | return err | |
| 393 | + | } | |
| 394 | + | return send(ports.AssetChunk{Data: data}) | |
| 395 | + | }).Once() | |
| 396 | + | stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}})) | |
| 397 | + | require.NoError(t, err) | |
| 398 | + | defer stream.Close() | |
| 399 | + | require.True(t, stream.Receive()) | |
| 400 | + | require.NotNil(t, stream.Msg().Asset) | |
| 401 | + | require.True(t, stream.Receive()) | |
| 402 | + | assert.Equal(t, data, stream.Msg().Chunk) | |
| 403 | + | assert.False(t, stream.Receive()) | |
| 404 | + | require.NoError(t, stream.Err()) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | func TestReleaseRPCRejectsInvalidMasksAndSelectors(t *testing.T) { | |
| 408 | + | c, _ := releaseClient(t, mocks.NewReleaseService(t)) | |
| 409 | + | _, err := c.UpdateRelease(context.Background(), connect.NewRequest(&pb.UpdateReleaseRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"unknown"}}})) | |
| 410 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 411 | + | _, err = c.UpdateReleaseAsset(context.Background(), connect.NewRequest(&pb.UpdateReleaseAssetRequest{Repo: "demo"})) | |
| 412 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 413 | + | _, err = c.GetRelease(context.Background(), connect.NewRequest(&pb.GetReleaseRequest{Repo: "demo", Selector: &pb.GetReleaseRequest_Latest{Latest: false}})) | |
| 414 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 415 | + | stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_Latest{Latest: &pb.ReleaseAssetLatest{Name: "a"}}})) | |
| 416 | + | if err == nil { | |
| 417 | + | defer stream.Close() | |
| 418 | + | assert.False(t, stream.Receive()) | |
| 419 | + | err = stream.Err() | |
| 420 | + | } | |
| 421 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | func TestReleaseRPCListResolvesLatestOnce(t *testing.T) { | |
| 425 | + | repo := mustRepoName(t, "demo") | |
| 426 | + | rel := mocks.NewReleaseService(t) | |
| 427 | + | c, _ := releaseClient(t, rel) | |
| 428 | + | p := domain.Anonymous() | |
| 429 | + | rows := []domain.Release{{ID: 3, Repo: repo, Tag: "c"}, {ID: 2, Repo: repo, Tag: "b"}, {ID: 1, Repo: repo, Tag: "a"}} | |
| 430 | + | rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: rows}, nil).Once() | |
| 431 | + | rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(rows[1], nil).Once() | |
| 432 | + | for _, r := range rows { | |
| 433 | + | rel.EXPECT().ListAssets(mock.Anything, p, repo, r.ID).Return(nil, nil).Once() | |
| 434 | + | } | |
| 435 | + | res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"})) | |
| 436 | + | require.NoError(t, err) | |
| 437 | + | require.Len(t, res.Msg.Releases, 3) | |
| 438 | + | for _, m := range res.Msg.Releases { | |
| 439 | + | assert.Equal(t, m.Id == 2, m.Latest, m.Tag) | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func TestReleaseRPCListWithoutLatest(t *testing.T) { | |
| 444 | + | repo := mustRepoName(t, "demo") | |
| 445 | + | rel := mocks.NewReleaseService(t) | |
| 446 | + | c, _ := releaseClient(t, rel) | |
| 447 | + | p := domain.Anonymous() | |
| 448 | + | rel.EXPECT().ListReleases(mock.Anything, p, repo, 0, "").Return(ports.ReleasePage{Releases: []domain.Release{{ID: 1, Repo: repo}}}, nil).Once() | |
| 449 | + | rel.EXPECT().GetRelease(mock.Anything, p, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound).Once() | |
| 450 | + | rel.EXPECT().ListAssets(mock.Anything, p, repo, int64(1)).Return(nil, nil).Once() | |
| 451 | + | res, err := c.ListReleases(context.Background(), connect.NewRequest(&pb.ListReleasesRequest{Repo: "demo"})) | |
| 452 | + | require.NoError(t, err) | |
| 453 | + | require.Len(t, res.Msg.Releases, 1) | |
| 454 | + | assert.False(t, res.Msg.Releases[0].Latest) | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func TestReleaseRPCDownloadZeroByteAsset(t *testing.T) { | |
| 458 | + | rel := mocks.NewReleaseService(t) | |
| 459 | + | c, _ := releaseClient(t, rel) | |
| 460 | + | a := domain.ReleaseAsset{ID: 9, ReleaseID: 7, Name: "empty", Size: 0} | |
| 461 | + | // The use case sends the metadata and no data for an empty asset. | |
| 462 | + | rel.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), mustRepoName(t, "demo"), ports.AssetRef{ID: 9}, int64(0), int64(-1), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error { | |
| 463 | + | return send(ports.AssetChunk{Asset: &a}) | |
| 464 | + | }).Once() | |
| 465 | + | stream, err := c.DownloadReleaseAsset(context.Background(), connect.NewRequest(&pb.DownloadReleaseAssetRequest{Repo: "demo", Selector: &pb.DownloadReleaseAssetRequest_AssetId{AssetId: 9}})) | |
| 466 | + | require.NoError(t, err) | |
| 467 | + | defer stream.Close() | |
| 468 | + | require.True(t, stream.Receive()) | |
| 469 | + | assert.True(t, proto.Equal(assetToPB(a), stream.Msg().Asset)) | |
| 470 | + | assert.False(t, stream.Receive()) | |
| 471 | + | require.NoError(t, stream.Err()) | |
| 472 | + | } | |
M internal/adapters/web/fragments.go +8 -2
| @@ -62,7 +62,8 @@ func liveEvent(we ports.WatchEvent) bool { | |||
| 62 | 62 | return true | |
| 63 | 63 | } | |
| 64 | 64 | switch we.Event.(type) { | |
| 65 | - | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 65 | + | case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished, | |
| 66 | + | domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 66 | 67 | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved, | |
| 67 | 68 | domain.RunUpdated, domain.JobUpdated, | |
| 68 | 69 | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced: | |
| @@ -164,6 +165,11 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||
| 164 | 165 | // repoPage handles the events of the topic of one repository. | |
| 165 | 166 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 166 | 167 | switch ev := we.Event.(type) { | |
| 168 | + | case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished: | |
| 169 | + | if l.view == "releases" || l.view == "release" { | |
| 170 | + | return gap, nil // notes, assets, latest or visibility changed: reload | |
| 171 | + | } | |
| 172 | + | return nil, nil | |
| 167 | 173 | case domain.RunUpdated: | |
| 168 | 174 | return l.runEvent(ctx, ev) | |
| 169 | 175 | case domain.JobUpdated: | |
| @@ -179,7 +185,7 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 179 | 185 | return gap, nil // no longer visible | |
| 180 | 186 | case err != nil: | |
| 181 | 187 | return nil, err | |
| 182 | - | case l.view == "summary", l.view == "refs": | |
| 188 | + | case l.view == "summary", l.view == "refs", l.view == "releases", l.view == "release": | |
| 183 | 189 | return gap, nil // the description, the default branch | |
| 184 | 190 | } | |
| 185 | 191 | return nil, nil | |
M internal/adapters/web/fragments_test.go +20 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | 10 | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/a-h/templ" | |
| 13 | 14 | "github.com/stretchr/testify/assert" | |
| @@ -99,3 +100,22 @@ func TestMRPushedHeadSignal(t *testing.T) { | |||
| 99 | 100 | } | |
| 100 | 101 | } | |
| 101 | 102 | } | |
| 103 | + | ||
| 104 | + | // Release lifecycle events invalidate the release pages without putting notes | |
| 105 | + | // or asset metadata in SSE payloads. Other views need no release fragments. | |
| 106 | + | func TestReleaseLiveUpdates(t *testing.T) { | |
| 107 | + | for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} { | |
| 108 | + | we := ports.WatchEvent{Event: ev} | |
| 109 | + | require.True(t, liveEvent(we), ev.EventKind()) | |
| 110 | + | for _, view := range []string{"releases", "release", "summary", "refs", "mr"} { | |
| 111 | + | st := &liveStream{repo: "proj", view: view} | |
| 112 | + | got, err := st.render(t.Context(), we) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | if view == "release" || view == "releases" { | |
| 115 | + | assert.Equal(t, gap, got) | |
| 116 | + | } else { | |
| 117 | + | assert.Empty(t, got) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
A internal/adapters/web/releases.go +244 -0
| @@ -0,0 +1,244 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "html/template" | |
| 9 | + | "net/http" | |
| 10 | + | "net/url" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/a-h/templ" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/httprange" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func releasesBase(repo string) string { return repoHref(repo, "releases") } | |
| 24 | + | func releaseHref(repo, tag string) string { return repoHref(repo, "releases", "tag", tag) } | |
| 25 | + | func releaseAssetHref(repo string, id int64) string { | |
| 26 | + | return releasesBase(repo) + "/assets/" + strconv.FormatInt(id, 10) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // ReleaseRow describes a release without mistaking MakeLatest (a pin) for | |
| 30 | + | // the latest release selected by the service. | |
| 31 | + | type ReleaseRow struct { | |
| 32 | + | Tag, Title, Href, Published string | |
| 33 | + | Draft, Prerelease, Latest bool | |
| 34 | + | AssetCount int | |
| 35 | + | } | |
| 36 | + | type ReleaseListPage struct { | |
| 37 | + | Rows []ReleaseRow | |
| 38 | + | Older string | |
| 39 | + | } | |
| 40 | + | type ReleaseAssetRow struct{ Name, Href, Size, ContentType string } | |
| 41 | + | type ReleaseDetailPage struct { | |
| 42 | + | ReleaseRow | |
| 43 | + | Notes template.HTML | |
| 44 | + | SHA, CommitHref, SourceHref string | |
| 45 | + | Assets []ReleaseAssetRow | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (s *Server) latestReleaseID(ctx context.Context, repo domain.RepoName) (int64, error) { | |
| 49 | + | rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true}) | |
| 50 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 51 | + | return 0, nil | |
| 52 | + | } | |
| 53 | + | return rel.ID, err | |
| 54 | + | } | |
| 55 | + | func releaseRow(repo string, rel domain.Release, latest int64) ReleaseRow { | |
| 56 | + | published := "draft" | |
| 57 | + | if !rel.Draft && !rel.PublishedAt.IsZero() { | |
| 58 | + | published = rel.PublishedAt.UTC().Format("2006-01-02") | |
| 59 | + | } | |
| 60 | + | title := rel.Title | |
| 61 | + | if title == "" { | |
| 62 | + | title = rel.Tag | |
| 63 | + | } | |
| 64 | + | return ReleaseRow{Tag: rel.Tag, Title: title, Href: releaseHref(repo, rel.Tag), Published: published, Draft: rel.Draft, Prerelease: rel.Prerelease, Latest: rel.ID == latest} | |
| 65 | + | } | |
| 66 | + | func (s *Server) releaseList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 67 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 68 | + | if err != nil { | |
| 69 | + | return nil, 0, err | |
| 70 | + | } | |
| 71 | + | page, err := s.cfg.Releases.ListReleases(ctx, principal(ctx), d.repo.Name, 30, r.URL.Query().Get("page")) | |
| 72 | + | if err != nil { | |
| 73 | + | return nil, 0, err | |
| 74 | + | } | |
| 75 | + | latest, err := s.latestReleaseID(ctx, d.repo.Name) | |
| 76 | + | if err != nil { | |
| 77 | + | return nil, 0, err | |
| 78 | + | } | |
| 79 | + | p := ReleaseListPage{} | |
| 80 | + | for _, rel := range page.Releases { | |
| 81 | + | row := releaseRow(d.name(), rel, latest) | |
| 82 | + | assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID) | |
| 83 | + | if err != nil { | |
| 84 | + | return nil, 0, err | |
| 85 | + | } | |
| 86 | + | row.AssetCount = len(assets) | |
| 87 | + | p.Rows = append(p.Rows, row) | |
| 88 | + | } | |
| 89 | + | if page.NextPageToken != "" { | |
| 90 | + | p.Older = releasesBase(d.name()) + "?page=" + url.QueryEscape(page.NextPageToken) | |
| 91 | + | } | |
| 92 | + | l := s.repoLayout(ctx, d, "releases") | |
| 93 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=releases") | |
| 94 | + | l.Title = "Releases · " + d.name() | |
| 95 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Current: true}) | |
| 96 | + | return Releases(l, p), http.StatusOK, nil | |
| 97 | + | } | |
| 98 | + | func (s *Server) releaseShow(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 99 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 100 | + | if err != nil { | |
| 101 | + | return nil, 0, err | |
| 102 | + | } | |
| 103 | + | rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), d.repo.Name, ports.ReleaseRef{Tag: r.PathValue("tag")}) | |
| 104 | + | if err != nil { | |
| 105 | + | return nil, 0, err | |
| 106 | + | } | |
| 107 | + | latest, err := s.latestReleaseID(ctx, d.repo.Name) | |
| 108 | + | if err != nil { | |
| 109 | + | return nil, 0, err | |
| 110 | + | } | |
| 111 | + | assets, err := s.cfg.Releases.ListAssets(ctx, principal(ctx), d.repo.Name, rel.ID) | |
| 112 | + | if err != nil { | |
| 113 | + | return nil, 0, err | |
| 114 | + | } | |
| 115 | + | notes, _, err := s.md.render([]byte(rel.Notes), d.name(), rel.Tag, "") | |
| 116 | + | if err != nil { | |
| 117 | + | return nil, 0, err | |
| 118 | + | } | |
| 119 | + | p := ReleaseDetailPage{ReleaseRow: releaseRow(d.name(), rel, latest), Notes: notes, SourceHref: repoHref(d.name(), "tree", rel.Tag)} | |
| 120 | + | if rel.TargetCommitish != "" { | |
| 121 | + | p.SHA = short(rel.TargetCommitish) | |
| 122 | + | p.CommitHref = repoHref(d.name(), "commit", rel.TargetCommitish) | |
| 123 | + | } | |
| 124 | + | for _, a := range assets { | |
| 125 | + | p.Assets = append(p.Assets, ReleaseAssetRow{Name: a.Name, Href: releaseAssetHref(d.name(), a.ID), Size: humanSize(a.Size), ContentType: a.ContentType}) | |
| 126 | + | } | |
| 127 | + | l := s.repoLayout(ctx, d, "releases") | |
| 128 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=release") | |
| 129 | + | l.Title = rel.Tag + " · " + d.name() | |
| 130 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "releases", Href: releasesBase(d.name())}, Crumb{Label: rel.Tag, Current: true}) | |
| 131 | + | return ReleaseDetail(l, p), http.StatusOK, nil | |
| 132 | + | } | |
| 133 | + | func (s *Server) releaseLatest(w http.ResponseWriter, r *http.Request) { | |
| 134 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 135 | + | defer cancel() | |
| 136 | + | repo, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 137 | + | if err != nil { | |
| 138 | + | s.fail(ctx, w, r, err) | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true}) | |
| 142 | + | if err != nil { | |
| 143 | + | s.fail(ctx, w, r, err) | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | cacheHeaders(ctx, w.Header(), "no-cache", sessionCache) | |
| 147 | + | http.Redirect(w, r, releaseHref(repo.String(), rel.Tag), http.StatusSeeOther) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func releaseHeaders(h http.Header, a domain.ReleaseAsset) { | |
| 151 | + | // Reuse the artifact sandbox and attachment policy; never trust an | |
| 152 | + | // uploader's media type to execute content in the forge's origin. | |
| 153 | + | typ := "application/octet-stream" | |
| 154 | + | if a.ContentType == domain.CIArtifactZip { | |
| 155 | + | typ = domain.CIArtifactZip | |
| 156 | + | } | |
| 157 | + | h.Set("Content-Type", typ) | |
| 158 | + | h.Set("Content-Disposition", contentDisposition(attachmentName(a.Name))) | |
| 159 | + | h.Set("X-Content-Type-Options", "nosniff") | |
| 160 | + | h.Set("Content-Security-Policy", rawCSP) | |
| 161 | + | h.Set("Content-Length", strconv.FormatInt(a.Size, 10)) | |
| 162 | + | h.Set("ETag", `"`+strings.Replace(a.Digest, "sha256:", "sha256-", 1)+`"`) | |
| 163 | + | h.Set("Accept-Ranges", "bytes") | |
| 164 | + | } | |
| 165 | + | func (s *Server) releaseDownload(w http.ResponseWriter, r *http.Request) { | |
| 166 | + | ctx, cancel := context.WithTimeout(r.Context(), 60*time.Minute) | |
| 167 | + | defer cancel() | |
| 168 | + | repo, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 169 | + | if err != nil { | |
| 170 | + | s.fail(ctx, w, r, errNotFound) | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | ref := ports.AssetRef{Tag: r.PathValue("tag"), Name: r.PathValue("asset")} | |
| 174 | + | if id := r.PathValue("id"); id != "" { | |
| 175 | + | ref.ID, err = strconv.ParseInt(id, 10, 64) | |
| 176 | + | if err != nil || ref.ID < 1 { | |
| 177 | + | s.fail(ctx, w, r, errNotFound) | |
| 178 | + | return | |
| 179 | + | } | |
| 180 | + | } else if ref.Tag == "" { | |
| 181 | + | ref.Latest = true | |
| 182 | + | } | |
| 183 | + | a, err := s.cfg.Releases.GetAsset(ctx, principal(ctx), repo, ref) | |
| 184 | + | if err != nil { | |
| 185 | + | s.fail(ctx, w, r, errNotFound) | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | // Resolve latest/tag only once: a moving latest cannot select another | |
| 189 | + | // asset between reading the headers and streaming the bytes. | |
| 190 | + | ref = ports.AssetRef{ID: a.ID} | |
| 191 | + | h := w.Header() | |
| 192 | + | releaseHeaders(h, a) | |
| 193 | + | cacheHeaders(ctx, h, "no-cache", sessionCache) | |
| 194 | + | offset, length, partial, err := httprange.Select(r, a.Size, h.Get("ETag")) | |
| 195 | + | if err != nil { | |
| 196 | + | h.Set("Content-Range", httprange.Unsatisfied(a.Size)) | |
| 197 | + | h.Set("Content-Length", "0") | |
| 198 | + | w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | status := http.StatusOK | |
| 202 | + | if partial { | |
| 203 | + | status = http.StatusPartialContent | |
| 204 | + | h.Set("Content-Range", httprange.ContentRange(offset, length, a.Size)) | |
| 205 | + | } | |
| 206 | + | h.Set("Content-Length", strconv.FormatInt(length, 10)) | |
| 207 | + | if r.Method == http.MethodHead { | |
| 208 | + | w.WriteHeader(status) | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | started := false | |
| 212 | + | rc := http.NewResponseController(w) | |
| 213 | + | defer func() { _ = rc.SetWriteDeadline(time.Time{}) }() | |
| 214 | + | err = s.cfg.Releases.DownloadAsset(ctx, principal(ctx), repo, ref, offset, length, func(c ports.AssetChunk) error { | |
| 215 | + | if !started { | |
| 216 | + | if c.Asset == nil || c.Asset.ID != a.ID || c.Asset.Digest != a.Digest || c.Asset.Size != a.Size { | |
| 217 | + | return errors.New("web: release asset changed before download") | |
| 218 | + | } | |
| 219 | + | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |
| 220 | + | w.WriteHeader(status) | |
| 221 | + | started = true | |
| 222 | + | } | |
| 223 | + | if len(c.Data) == 0 { | |
| 224 | + | return nil | |
| 225 | + | } | |
| 226 | + | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |
| 227 | + | _, err := w.Write(c.Data) | |
| 228 | + | return err | |
| 229 | + | }) | |
| 230 | + | if err == nil { | |
| 231 | + | return | |
| 232 | + | } | |
| 233 | + | if !started { | |
| 234 | + | // Do not leave binary headers or range metadata on the HTML error page. | |
| 235 | + | for _, key := range []string{"Content-Length", "Content-Disposition", "Content-Range", "Content-Security-Policy", "ETag", "Accept-Ranges"} { | |
| 236 | + | h.Del(key) | |
| 237 | + | } | |
| 238 | + | s.fail(ctx, w, r, errNotFound) | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | log := zerowrap.FromCtx(ctx) | |
| 242 | + | log.Error().Err(err).Str("repo", repo.String()).Int64("asset", a.ID).Msg("release download failed") | |
| 243 | + | panic(http.ErrAbortHandler) | |
| 244 | + | } | |
A internal/adapters/web/releases.templ +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package web | |
| 2 | + | ||
| 3 | + | // ReleaseBadges are textual as well as visual, including the computed latest. | |
| 4 | + | templ ReleaseBadges(r ReleaseRow) { | |
| 5 | + | if r.Draft { | |
| 6 | + | <span class="pill">Draft</span> | |
| 7 | + | } | |
| 8 | + | if r.Prerelease { | |
| 9 | + | <span class="pill">Pre-release</span> | |
| 10 | + | } | |
| 11 | + | if r.Latest { | |
| 12 | + | <span class="pill">Latest</span> | |
| 13 | + | } | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | templ Releases(l Layout, p ReleaseListPage) { | |
| 17 | + | @Shell(l, nil) { | |
| 18 | + | <h1>Releases</h1> | |
| 19 | + | if len(p.Rows) == 0 { | |
| 20 | + | <p class="dim">no releases yet.</p> | |
| 21 | + | } else { | |
| 22 | + | <table class="list" aria-label="Releases"> | |
| 23 | + | <thead><tr><th>tag</th><th>title</th><th>published</th><th>assets</th></tr></thead> | |
| 24 | + | <tbody> | |
| 25 | + | for _, r := range p.Rows { | |
| 26 | + | <tr> | |
| 27 | + | <td><a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a></td> | |
| 28 | + | <td>{ r.Title } @ReleaseBadges(r)</td> | |
| 29 | + | <td>{ r.Published }</td><td>{ itoa(r.AssetCount) }</td> | |
| 30 | + | </tr> | |
| 31 | + | } | |
| 32 | + | </tbody> | |
| 33 | + | </table> | |
| 34 | + | } | |
| 35 | + | if p.Older != "" { | |
| 36 | + | <p class="more"><a href={ templ.SafeURL(p.Older) } rel="next">older releases</a></p> | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | templ ReleaseDetail(l Layout, p ReleaseDetailPage) { | |
| 42 | + | @Shell(l, nil) { | |
| 43 | + | <h1>{ p.Title }</h1> | |
| 44 | + | <p> | |
| 45 | + | <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow) | |
| 46 | + | if p.SHA != "" { | |
| 47 | + | <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a> | |
| 48 | + | } | |
| 49 | + | <span class="dim">{ p.Published }</span> | |
| 50 | + | </p> | |
| 51 | + | <div class="md">@templ.Raw(string(p.Notes))</div> | |
| 52 | + | <h2>Assets</h2> | |
| 53 | + | if len(p.Assets) == 0 { | |
| 54 | + | <p class="dim">no assets.</p> | |
| 55 | + | } else { | |
| 56 | + | <table class="list" aria-label="Release assets"> | |
| 57 | + | <thead><tr><th>name</th><th>size</th><th>content type</th></tr></thead> | |
| 58 | + | <tbody> | |
| 59 | + | for _, a := range p.Assets { | |
| 60 | + | <tr><td><a href={ templ.SafeURL(a.Href) } download>{ a.Name }</a></td><td>{ a.Size }</td><td>{ a.ContentType }</td></tr> | |
| 61 | + | } | |
| 62 | + | </tbody> | |
| 63 | + | </table> | |
| 64 | + | } | |
| 65 | + | <p><a href={ templ.SafeURL(p.SourceHref) }>Source</a></p> | |
| 66 | + | } | |
| 67 | + | } | |
A internal/adapters/web/releases_templ.go +439 -0 generated file: collapsed
A internal/adapters/web/releases_test.go +298 -0
| @@ -0,0 +1,298 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "mime" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 11 | + | "net/url" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | type releaseFixture struct { | |
| 26 | + | srv *Server | |
| 27 | + | svc *mocks.ReleaseService | |
| 28 | + | published, draft domain.Release | |
| 29 | + | asset domain.ReleaseAsset | |
| 30 | + | data string | |
| 31 | + | downloadErr error | |
| 32 | + | failAfterMetadata bool | |
| 33 | + | downloads int | |
| 34 | + | tokens []string | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func newReleaseFixture(t *testing.T) *releaseFixture { | |
| 38 | + | t.Helper() | |
| 39 | + | repo := repoName(t, "proj") | |
| 40 | + | f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"} | |
| 41 | + | f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true} | |
| 42 | + | f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true} | |
| 43 | + | f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)} | |
| 44 | + | f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) { | |
| 45 | + | if ref.Latest || ref.Tag == f.published.Tag { | |
| 46 | + | return f.published, nil | |
| 47 | + | } | |
| 48 | + | if ref.Tag == f.draft.Tag && p.IsOwner() { | |
| 49 | + | return f.draft, nil | |
| 50 | + | } | |
| 51 | + | return domain.Release{}, domain.ErrNotFound | |
| 52 | + | }).Maybe() | |
| 53 | + | f.svc.EXPECT().ListReleases(mock.Anything, mock.Anything, repo, 30, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, _ int, token string) (ports.ReleasePage, error) { | |
| 54 | + | f.tokens = append(f.tokens, token) | |
| 55 | + | rows := []domain.Release{f.published} | |
| 56 | + | if p.IsOwner() { | |
| 57 | + | rows = append([]domain.Release{f.draft}, rows...) | |
| 58 | + | } | |
| 59 | + | return ports.ReleasePage{Releases: rows, NextPageToken: "next+/="}, nil | |
| 60 | + | }).Maybe() | |
| 61 | + | f.svc.EXPECT().ListAssets(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, id int64) ([]domain.ReleaseAsset, error) { | |
| 62 | + | if id == f.published.ID { | |
| 63 | + | return []domain.ReleaseAsset{f.asset}, nil | |
| 64 | + | } | |
| 65 | + | if id == f.draft.ID && p.IsOwner() { | |
| 66 | + | return nil, nil | |
| 67 | + | } | |
| 68 | + | return nil, domain.ErrNotFound | |
| 69 | + | }).Maybe() | |
| 70 | + | f.svc.EXPECT().GetAsset(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) { | |
| 71 | + | if ref.ID == f.asset.ID || (ref.Name == f.asset.Name && (ref.Latest || ref.Tag == f.published.Tag)) { | |
| 72 | + | return f.asset, nil | |
| 73 | + | } | |
| 74 | + | return domain.ReleaseAsset{}, domain.ErrNotFound | |
| 75 | + | }).Maybe() | |
| 76 | + | f.svc.EXPECT().DownloadAsset(mock.Anything, mock.Anything, repo, ports.AssetRef{ID: 7}, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error { | |
| 77 | + | f.downloads++ | |
| 78 | + | deadline, ok := ctx.Deadline() | |
| 79 | + | require.True(t, ok) | |
| 80 | + | assert.InDelta(t, 3600, time.Until(deadline).Seconds(), 2) | |
| 81 | + | if f.downloadErr != nil && !f.failAfterMetadata { | |
| 82 | + | return f.downloadErr | |
| 83 | + | } | |
| 84 | + | if err := send(ports.AssetChunk{Asset: &f.asset}); err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | if f.downloadErr != nil { | |
| 88 | + | return f.downloadErr | |
| 89 | + | } | |
| 90 | + | return send(ports.AssetChunk{Data: []byte(f.data[offset : offset+length])}) | |
| 91 | + | }).Maybe() | |
| 92 | + | browse := mocks.NewBrowseService(t) | |
| 93 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |
| 94 | + | f.srv = newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Releases: f.svc, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{}, Now: func() time.Time { return fixedNow }}) | |
| 95 | + | return f | |
| 96 | + | } | |
| 97 | + | func (f *releaseFixture) request(method, path, cookie, byteRange string) *httptest.ResponseRecorder { | |
| 98 | + | r := httptest.NewRequest(method, path, nil) | |
| 99 | + | if cookie != "" { | |
| 100 | + | r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) | |
| 101 | + | } | |
| 102 | + | if byteRange != "" { | |
| 103 | + | r.Header.Set("Range", byteRange) | |
| 104 | + | } | |
| 105 | + | w := httptest.NewRecorder() | |
| 106 | + | f.srv.ServeHTTP(w, r) | |
| 107 | + | return w | |
| 108 | + | } | |
| 109 | + | func TestReleasePages(t *testing.T) { | |
| 110 | + | f := newReleaseFixture(t) | |
| 111 | + | for _, path := range []string{"/proj/releases", "/proj/releases/"} { | |
| 112 | + | w := f.request("GET", path, "", "") | |
| 113 | + | require.Equal(t, 200, w.Code) | |
| 114 | + | body := w.Body.String() | |
| 115 | + | assert.Contains(t, body, "First release") | |
| 116 | + | assert.NotContains(t, body, "Secret draft") | |
| 117 | + | assert.Contains(t, body, ">Latest</span>") | |
| 118 | + | assert.Contains(t, body, ">Pre-release</span>") | |
| 119 | + | assert.Contains(t, body, `href="/proj/releases/tag/stable/v1"`) | |
| 120 | + | assert.Contains(t, body, "2026-10-12") | |
| 121 | + | assert.Contains(t, body, `<td>1</td>`) | |
| 122 | + | assert.Contains(t, body, `?page=next%2B%2F%3D`) | |
| 123 | + | assert.Contains(t, body, `aria-keyshortcuts="l"`) | |
| 124 | + | assert.Contains(t, body, "view=releases") | |
| 125 | + | } | |
| 126 | + | owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "") | |
| 127 | + | require.Equal(t, 200, owner.Code) | |
| 128 | + | assert.Contains(t, owner.Body.String(), "Secret draft") | |
| 129 | + | assert.Contains(t, owner.Body.String(), ">Draft</span>") | |
| 130 | + | assert.Contains(t, owner.Header().Get("Cache-Control"), "no-store") | |
| 131 | + | assert.Equal(t, []string{"", "", "cursor+/="}, f.tokens) | |
| 132 | + | // The pin on the draft must not become a computed latest badge. | |
| 133 | + | assert.Equal(t, 1, strings.Count(owner.Body.String(), ">Latest</span>")) | |
| 134 | + | w := f.request("GET", "/proj/releases/tag/stable/v1", "", "") | |
| 135 | + | require.Equal(t, 200, w.Code) | |
| 136 | + | body := w.Body.String() | |
| 137 | + | for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} { | |
| 138 | + | assert.Contains(t, body, text) | |
| 139 | + | } | |
| 140 | + | assert.NotContains(t, body, "<script>alert") | |
| 141 | + | assert.NotContains(t, body, `href="javascript:`) | |
| 142 | + | assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`) | |
| 143 | + | for _, path := range []string{"/proj/releases/tag/unknown", "/proj/releases/tag/unpublished"} { | |
| 144 | + | assert.Equal(t, 404, f.request("GET", path, "", "").Code) | |
| 145 | + | } | |
| 146 | + | assert.Equal(t, 200, f.request("GET", "/proj/releases/tag/unpublished", "tok", "").Code) | |
| 147 | + | latest := f.request("GET", "/proj/releases/latest", "", "") | |
| 148 | + | assert.Equal(t, 303, latest.Code) | |
| 149 | + | assert.Equal(t, "/proj/releases/tag/stable/v1", latest.Header().Get("Location")) | |
| 150 | + | } | |
| 151 | + | func TestReleaseDownloads(t *testing.T) { | |
| 152 | + | f := newReleaseFixture(t) | |
| 153 | + | // A simple tag uses the stable URL; slash tags use the page's asset-id link. | |
| 154 | + | f.published.Tag = "v1" | |
| 155 | + | for _, path := range []string{"/proj/releases/download/v1/build.zip", "/proj/releases/latest/download/build.zip", "/proj/releases/assets/7"} { | |
| 156 | + | for _, tc := range []struct { | |
| 157 | + | rangeValue, body, cr string | |
| 158 | + | status int | |
| 159 | + | }{ | |
| 160 | + | {"", "0123456789", "", 200}, {"bytes=2-5", "2345", "bytes 2-5/10", 206}, {"bytes=7-", "789", "bytes 7-9/10", 206}, {"bytes=-3", "789", "bytes 7-9/10", 206}, {"bytes=8-99", "89", "bytes 8-9/10", 206}, {"bytes=0-0,2-3", "0123456789", "", 200}, {"bytes=10-", "", "bytes */10", 416}, | |
| 161 | + | } { | |
| 162 | + | w := f.request("GET", path, "", tc.rangeValue) | |
| 163 | + | require.Equal(t, tc.status, w.Code, path+" "+tc.rangeValue) | |
| 164 | + | assert.Equal(t, tc.body, w.Body.String()) | |
| 165 | + | assert.Equal(t, tc.cr, w.Header().Get("Content-Range")) | |
| 166 | + | assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges")) | |
| 167 | + | assert.Equal(t, `"sha256-`+strings.Repeat("b", 64)+`"`, w.Header().Get("ETag")) | |
| 168 | + | assert.Equal(t, "application/zip", w.Header().Get("Content-Type")) | |
| 169 | + | assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options")) | |
| 170 | + | assert.Equal(t, rawCSP, w.Header().Get("Content-Security-Policy")) | |
| 171 | + | _, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition")) | |
| 172 | + | require.NoError(t, err) | |
| 173 | + | assert.Equal(t, "build.zip", params["filename"]) | |
| 174 | + | assert.Equal(t, itoa(len(tc.body)), w.Header().Get("Content-Length")) | |
| 175 | + | } | |
| 176 | + | before := f.downloads | |
| 177 | + | w := f.request("HEAD", path, "", "bytes=1-2") | |
| 178 | + | assert.Equal(t, 200, w.Code) | |
| 179 | + | assert.Empty(t, w.Header().Get("Content-Range")) | |
| 180 | + | assert.Empty(t, w.Body.String()) | |
| 181 | + | assert.Equal(t, "10", w.Header().Get("Content-Length")) | |
| 182 | + | assert.Equal(t, before, f.downloads) | |
| 183 | + | } | |
| 184 | + | for _, path := range []string{"/proj/releases/assets/0", "/proj/releases/assets/nope", "/proj/releases/assets/99", "/proj/releases/download/missing/build.zip", "/proj/releases/latest/download/nope"} { | |
| 185 | + | assert.Equal(t, 404, f.request("GET", path, "", "").Code) | |
| 186 | + | } | |
| 187 | + | r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil) | |
| 188 | + | r.Header.Set("Range", "bytes=1-2") | |
| 189 | + | r.Header.Set("If-Range", `"old"`) | |
| 190 | + | w := httptest.NewRecorder() | |
| 191 | + | f.srv.ServeHTTP(w, r) | |
| 192 | + | assert.Equal(t, 200, w.Code) | |
| 193 | + | assert.Equal(t, f.data, w.Body.String()) | |
| 194 | + | } | |
| 195 | + | func TestReleaseDownloadIfRange(t *testing.T) { | |
| 196 | + | f := newReleaseFixture(t) | |
| 197 | + | etag := `"sha256-` + strings.Repeat("b", 64) + `"` | |
| 198 | + | for _, tc := range []struct { | |
| 199 | + | name, ifRange, body, cr string | |
| 200 | + | status int | |
| 201 | + | }{ | |
| 202 | + | {"matching", etag, "2345", "bytes 2-5/10", 206}, | |
| 203 | + | {"stale", `"old"`, "0123456789", "", 200}, | |
| 204 | + | {"date", "Wed, 21 Oct 2026 12:00:00 GMT", "0123456789", "", 200}, | |
| 205 | + | } { | |
| 206 | + | r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil) | |
| 207 | + | r.Header.Set("Range", "bytes=2-5") | |
| 208 | + | r.Header.Set("If-Range", tc.ifRange) | |
| 209 | + | w := httptest.NewRecorder() | |
| 210 | + | f.srv.ServeHTTP(w, r) | |
| 211 | + | assert.Equal(t, tc.status, w.Code, tc.name) | |
| 212 | + | assert.Equal(t, tc.body, w.Body.String(), tc.name) | |
| 213 | + | assert.Equal(t, tc.cr, w.Header().Get("Content-Range"), tc.name) | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // A tag with a slash is one path segment once escaped: the download route must | |
| 218 | + | // match it and resolve the tag with the slash. | |
| 219 | + | func TestReleaseDownloadSlashTag(t *testing.T) { | |
| 220 | + | f := newReleaseFixture(t) // the published tag is "stable/v1" | |
| 221 | + | w := f.request("GET", "/proj/releases/download/"+url.PathEscape(f.published.Tag)+"/build.zip", "", "") | |
| 222 | + | require.Equal(t, 200, w.Code) | |
| 223 | + | assert.Equal(t, f.data, w.Body.String()) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestReleaseDownloadFailures(t *testing.T) { | |
| 227 | + | f := newReleaseFixture(t) | |
| 228 | + | f.downloadErr = errors.New("blob unavailable") | |
| 229 | + | w := f.request("GET", "/proj/releases/assets/7", "", "bytes=1-2") | |
| 230 | + | assert.Equal(t, 404, w.Code) | |
| 231 | + | assert.Contains(t, w.Header().Get("Content-Type"), "text/html") | |
| 232 | + | for _, key := range []string{"Content-Disposition", "Content-Range", "Accept-Ranges"} { | |
| 233 | + | assert.Empty(t, w.Header().Get(key)) | |
| 234 | + | } | |
| 235 | + | f.failAfterMetadata = true | |
| 236 | + | assert.PanicsWithValue(t, http.ErrAbortHandler, func() { f.request("GET", "/proj/releases/assets/7", "", "") }) | |
| 237 | + | } | |
| 238 | + | func TestReleaseAttachmentHeaders(t *testing.T) { | |
| 239 | + | for _, name := range []string{"résumé.zip", "build.zip", "evil\r\nX-Injected: true.html", `<img src=x onerror=alert(1)>.html`} { | |
| 240 | + | h := http.Header{} | |
| 241 | + | releaseHeaders(h, domain.ReleaseAsset{Name: name, ContentType: "text/html", Digest: "sha256:abcd", Size: 12}) | |
| 242 | + | assert.Equal(t, "application/octet-stream", h.Get("Content-Type")) | |
| 243 | + | assert.NotContains(t, h.Get("Content-Disposition"), "\r") | |
| 244 | + | assert.NotContains(t, h.Get("Content-Disposition"), "\n") | |
| 245 | + | media, params, err := mime.ParseMediaType(h.Get("Content-Disposition")) | |
| 246 | + | require.NoError(t, err) | |
| 247 | + | assert.Equal(t, "attachment", media) | |
| 248 | + | assert.Equal(t, attachmentName(name), params["filename"]) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | func TestReleasesDisabled(t *testing.T) { | |
| 252 | + | s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)}) | |
| 253 | + | for _, path := range []string{"/proj/releases", "/proj/releases/latest", "/proj/releases/assets/7"} { | |
| 254 | + | w := httptest.NewRecorder() | |
| 255 | + | s.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) | |
| 256 | + | assert.Equal(t, 404, w.Code) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestReleaseEmptyDownloads(t *testing.T) { | |
| 261 | + | f := newReleaseFixture(t) | |
| 262 | + | f.asset.Size = 0 | |
| 263 | + | f.data = "" | |
| 264 | + | w := f.request("GET", "/proj/releases/assets/7", "", "") | |
| 265 | + | assert.Equal(t, 200, w.Code) | |
| 266 | + | assert.Equal(t, "0", w.Header().Get("Content-Length")) | |
| 267 | + | assert.Empty(t, w.Body.String()) | |
| 268 | + | w = f.request("GET", "/proj/releases/assets/7", "", "bytes=0-") | |
| 269 | + | assert.Equal(t, 416, w.Code) | |
| 270 | + | assert.Equal(t, "bytes */0", w.Header().Get("Content-Range")) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func TestReleaseLatestMissing(t *testing.T) { | |
| 274 | + | svc := mocks.NewReleaseService(t) | |
| 275 | + | repo := repoName(t, "proj") | |
| 276 | + | svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound) | |
| 277 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc}) | |
| 278 | + | w := httptest.NewRecorder() | |
| 279 | + | srv.ServeHTTP(w, httptest.NewRequest("GET", "/proj/releases/latest", nil)) | |
| 280 | + | assert.Equal(t, 404, w.Code) | |
| 281 | + | assert.Empty(t, w.Header().Get("Location")) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestReleaseAssetNameEscapedInPage(t *testing.T) { | |
| 285 | + | f := newReleaseFixture(t) | |
| 286 | + | f.asset.Name = `<img src=x onerror=alert(1)>` | |
| 287 | + | w := f.request("GET", "/proj/releases/tag/stable/v1", "", "") | |
| 288 | + | require.Equal(t, 200, w.Code) | |
| 289 | + | assert.NotContains(t, w.Body.String(), f.asset.Name) | |
| 290 | + | assert.Contains(t, w.Body.String(), "<img src=x onerror=alert(1)>") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func TestReleaseRowFallbacks(t *testing.T) { | |
| 294 | + | row := releaseRow("proj", domain.Release{ID: 1, Tag: "v1", Draft: true, PublishedAt: fixedNow}, 0) | |
| 295 | + | assert.Equal(t, "v1", row.Title) | |
| 296 | + | assert.Equal(t, "draft", row.Published) | |
| 297 | + | assert.False(t, row.Latest) | |
| 298 | + | } | |
M internal/adapters/web/repo.go +3 -0
| @@ -156,6 +156,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 156 | 156 | if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() { | |
| 157 | 157 | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | |
| 158 | 158 | } | |
| 159 | + | if s.cfg.Releases != nil { | |
| 160 | + | l.Sections = append(l.Sections, mk("l", "Releases", releasesBase(d.name()), "releases")) | |
| 161 | + | } | |
| 159 | 162 | l.Sections = s.withActions(l.Sections, d.name(), section) | |
| 160 | 163 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | |
| 161 | 164 | ||
M internal/adapters/web/web.go +11 -0
| @@ -91,6 +91,8 @@ type Config struct { | |||
| 91 | 91 | // their download and their deletion. It needs Runs. Without it there is | |
| 92 | 92 | // neither. Leave it unset rather than assigning a typed nil pointer. | |
| 93 | 93 | Artifacts ports.ArtifactService | |
| 94 | + | // Releases serves the release pages and streamed assets. Nil disables them. | |
| 95 | + | Releases ports.ReleaseService | |
| 94 | 96 | // Events is the source of the event stream behind /-/events. Without | |
| 95 | 97 | // it the pages are not live and the route answers 404. Leave it unset | |
| 96 | 98 | // rather than assigning a typed nil pointer. | |
| @@ -252,6 +254,15 @@ func (s *Server) routes() *http.ServeMux { | |||
| 252 | 254 | mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage) | |
| 253 | 255 | mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost) | |
| 254 | 256 | } | |
| 257 | + | if s.cfg.Releases != nil { | |
| 258 | + | mux.HandleFunc("GET /{repo}/releases", s.page(s.releaseList)) | |
| 259 | + | mux.HandleFunc("GET /{repo}/releases/{$}", s.page(s.releaseList)) | |
| 260 | + | mux.HandleFunc("GET /{repo}/releases/tag/{tag...}", s.page(s.releaseShow)) | |
| 261 | + | mux.HandleFunc("GET /{repo}/releases/latest", s.releaseLatest) | |
| 262 | + | mux.HandleFunc("GET /{repo}/releases/download/{tag}/{asset}", s.releaseDownload) | |
| 263 | + | mux.HandleFunc("GET /{repo}/releases/latest/download/{asset}", s.releaseDownload) | |
| 264 | + | mux.HandleFunc("GET /{repo}/releases/assets/{id}", s.releaseDownload) | |
| 265 | + | } | |
| 255 | 266 | if s.cfg.Runs != nil { | |
| 256 | 267 | mux.HandleFunc("GET /{repo}/actions", s.page(s.actions)) | |
| 257 | 268 | mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions)) | |
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
M internal/app/ci_artifacts_test.go +110 -12
| @@ -25,6 +25,7 @@ import ( | |||
| 25 | 25 | ||
| 26 | 26 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 27 | 27 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 28 | + | "bnema.dev/ermite/internal/adapters/blobdisk" | |
| 28 | 29 | "bnema.dev/ermite/internal/adapters/ciartifacts" | |
| 29 | 30 | "bnema.dev/ermite/internal/domain" | |
| 30 | 31 | "bnema.dev/ermite/internal/ports" | |
| @@ -48,6 +49,14 @@ jobs: | |||
| 48 | 49 | - run: echo down | |
| 49 | 50 | ` | |
| 50 | 51 | ||
| 52 | + | // newArtifactHarness uses the production blob wiring. | |
| 53 | + | func newArtifactHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness { | |
| 54 | + | t.Helper() | |
| 55 | + | c := newCIHarness(t, tweak) | |
| 56 | + | c.artifactRoot = blobdisk.Dir(c.data) | |
| 57 | + | return c | |
| 58 | + | } | |
| 59 | + | ||
| 51 | 60 | // artifactAPI are the artifact clients of one caller. | |
| 52 | 61 | type artifactAPI struct { | |
| 53 | 62 | job ermiteconnect.JobArtifactServiceClient | |
| @@ -202,11 +211,11 @@ func (c *ciHarness) usage() int64 { | |||
| 202 | 211 | return used | |
| 203 | 212 | } | |
| 204 | 213 | ||
| 205 | - | // artifactFiles lists every file under ci/artifacts. | |
| 214 | + | // artifactFiles lists the new CI objects under the test blob root. | |
| 206 | 215 | func (c *ciHarness) artifactFiles() []string { | |
| 207 | 216 | c.t.Helper() | |
| 208 | 217 | var out []string | |
| 209 | - | root := ciartifacts.Dir(c.data) | |
| 218 | + | root := filepath.Join(c.artifactRoot, "objects", "ci") | |
| 210 | 219 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |
| 211 | 220 | if err == nil && !d.IsDir() { | |
| 212 | 221 | rel, _ := filepath.Rel(root, path) | |
| @@ -249,7 +258,7 @@ func (c *ciHarness) newArtifactRun(r *fakeRunner, files map[string]string) (runI | |||
| 249 | 258 | } | |
| 250 | 259 | ||
| 251 | 260 | func TestCIArtifactsPassBetweenJobs(t *testing.T) { | |
| 252 | - | c := newCIHarness(t, nil) | |
| 261 | + | c := newArtifactHarness(t, nil) | |
| 253 | 262 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 254 | 263 | _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow}) | |
| 255 | 264 | up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob) | |
| @@ -417,7 +426,7 @@ func TestCIArtifactsPassBetweenJobs(t *testing.T) { | |||
| 417 | 426 | // AC3: a job token of another run reaches nothing of this run, and the calls | |
| 418 | 427 | // are refused once the job ended. | |
| 419 | 428 | func TestCIArtifactsAreIsolatedPerRunAndJob(t *testing.T) { | |
| 420 | - | c := newCIHarness(t, nil) | |
| 429 | + | c := newArtifactHarness(t, nil) | |
| 421 | 430 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 422 | 431 | runA, upA, downA := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow}) | |
| 423 | 432 | _, upB, _ := c.newArtifactRun(runner, map[string]string{"README": "two"}) | |
| @@ -505,7 +514,7 @@ func (s *shiftClock) since(t time.Time) time.Duration { return s.Now().Sub(t) } | |||
| 505 | 514 | ||
| 506 | 515 | func TestCIArtifactLimits(t *testing.T) { | |
| 507 | 516 | const mib = 1 << 20 | |
| 508 | - | c := newCIHarness(t, func(cfg *usecase.CIConfig) { | |
| 517 | + | c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { | |
| 509 | 518 | cfg.ArtifactLimits = ports.ArtifactLimits{Retention: 10 * 24 * time.Hour, Quota: 3 * mib, MaxSize: 2 * mib} | |
| 510 | 519 | }) | |
| 511 | 520 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| @@ -567,7 +576,9 @@ func TestCIArtifactLimits(t *testing.T) { | |||
| 567 | 576 | ||
| 568 | 577 | func TestCIArtifactsSweep(t *testing.T) { | |
| 569 | 578 | clk := &shiftClock{} | |
| 570 | - | c := newCIHarness(t, func(cfg *usecase.CIConfig) { | |
| 579 | + | legacyRoot := ciartifacts.Dir(t.TempDir()) | |
| 580 | + | c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { | |
| 581 | + | cfg.ArtifactFiles = ciartifacts.New(legacyRoot) | |
| 571 | 582 | cfg.Clock = clk | |
| 572 | 583 | cfg.Tick = 20 * time.Millisecond | |
| 573 | 584 | cfg.ArtifactLimits.Retention = 2 * 24 * time.Hour | |
| @@ -583,9 +594,10 @@ func TestCIArtifactsSweep(t *testing.T) { | |||
| 583 | 594 | crashed := up.mustCreate("crashed") | |
| 584 | 595 | _, err := up.block(crashed, "x", []byte("stale")) | |
| 585 | 596 | require.NoError(t, err) | |
| 586 | - | stray := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", runID), fmt.Sprintf("%020d", int64(999999))) | |
| 597 | + | stray := filepath.Join(legacyRoot, fmt.Sprintf("%020d", runID), fmt.Sprintf("%020d", int64(999999))) | |
| 598 | + | require.NoError(t, os.MkdirAll(filepath.Dir(stray), 0o700)) | |
| 587 | 599 | require.NoError(t, os.WriteFile(stray, []byte("orphan"), 0o600)) | |
| 588 | - | strayRun := filepath.Join(ciartifacts.Dir(c.data), fmt.Sprintf("%020d", int64(888888))) | |
| 600 | + | strayRun := filepath.Join(legacyRoot, fmt.Sprintf("%020d", int64(888888))) | |
| 589 | 601 | require.NoError(t, os.MkdirAll(strayRun, 0o700)) | |
| 590 | 602 | start := clk.Now() | |
| 591 | 603 | ||
| @@ -619,7 +631,7 @@ func TestCIArtifactsSweep(t *testing.T) { | |||
| 619 | 631 | ||
| 620 | 632 | // Deleting the repository takes the records and the files of its artifacts. | |
| 621 | 633 | func TestCIArtifactsGoWithTheRepository(t *testing.T) { | |
| 622 | - | c := newCIHarness(t, nil) | |
| 634 | + | c := newArtifactHarness(t, nil) | |
| 623 | 635 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 624 | 636 | _, upJob, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow}) | |
| 625 | 637 | a := c.jobArtifacts(upJob).upload("dist", []byte("x")) | |
| @@ -648,7 +660,7 @@ func TestCIArtifactsOff(t *testing.T) { | |||
| 648 | 660 | // before they are written, against the charges and the other writes. | |
| 649 | 661 | func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) { | |
| 650 | 662 | const kib, mib = 1 << 10, 1 << 20 | |
| 651 | - | c := newCIHarness(t, func(cfg *usecase.CIConfig) { | |
| 663 | + | c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { | |
| 652 | 664 | cfg.ArtifactLimits = ports.ArtifactLimits{Quota: 3 * mib, MaxSize: 2 * mib} | |
| 653 | 665 | }) | |
| 654 | 666 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| @@ -657,7 +669,7 @@ func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) { | |||
| 657 | 669 | ||
| 658 | 670 | blockFiles := func() (total int64) { | |
| 659 | 671 | for _, f := range c.artifactFiles() { | |
| 660 | - | if info, err := os.Stat(filepath.Join(ciartifacts.Dir(c.data), f)); err == nil { | |
| 672 | + | if info, err := os.Stat(filepath.Join(c.artifactRoot, "objects", "ci", f)); err == nil { | |
| 661 | 673 | total += info.Size() | |
| 662 | 674 | } | |
| 663 | 675 | } | |
| @@ -733,7 +745,7 @@ func TestCIArtifactLimitsHoldUnderConcurrency(t *testing.T) { | |||
| 733 | 745 | } | |
| 734 | 746 | ||
| 735 | 747 | func TestCIArtifactCaps(t *testing.T) { | |
| 736 | - | c := newCIHarness(t, nil) | |
| 748 | + | c := newArtifactHarness(t, nil) | |
| 737 | 749 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 738 | 750 | _, upJob, downJob := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow}) | |
| 739 | 751 | up, down := c.jobArtifacts(upJob), c.jobArtifacts(downJob) | |
| @@ -787,3 +799,89 @@ func TestCIArtifactCaps(t *testing.T) { | |||
| 787 | 799 | assert.True(t, bytes.Equal(content, body)) | |
| 788 | 800 | }) | |
| 789 | 801 | } | |
| 802 | + | ||
| 803 | + | // Both storage generations remain downloadable through the real RPC handlers. | |
| 804 | + | func TestCIArtifactBlobAndLegacyDownload(t *testing.T) { | |
| 805 | + | // This test inserts legacy data after startup; supply its reader explicitly. | |
| 806 | + | legacyRoot := ciartifacts.Dir(t.TempDir()) | |
| 807 | + | c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { cfg.ArtifactFiles = ciartifacts.New(legacyRoot) }) | |
| 808 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 809 | + | _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow}) | |
| 810 | + | up := c.jobArtifacts(job) | |
| 811 | + | current := up.upload("new", []byte("new blob")) | |
| 812 | + | store := c.forge.store.Artifacts() | |
| 813 | + | a, err := store.GetArtifact(c.ctx, current.GetId()) | |
| 814 | + | require.NoError(t, err) | |
| 815 | + | assert.Equal(t, domain.CIArtifactStoreBlob, a.Store) | |
| 816 | + | path := filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey())) | |
| 817 | + | data, err := os.ReadFile(path) | |
| 818 | + | require.NoError(t, err) | |
| 819 | + | assert.Equal(t, "new blob", string(data)) | |
| 820 | + | _, got, err := up.download(a.ID, 0) | |
| 821 | + | require.NoError(t, err) | |
| 822 | + | assert.Equal(t, data, got) | |
| 823 | + | ||
| 824 | + | // Simulate an on-disk record written by the pre-upgrade server: no store | |
| 825 | + | // field and contents at the old id-only path, absent from the blob store. | |
| 826 | + | legacy := up.upload("old", []byte("old disk")) | |
| 827 | + | a, err = store.GetArtifact(c.ctx, legacy.GetId()) | |
| 828 | + | require.NoError(t, err) | |
| 829 | + | oldPath := filepath.Join(legacyRoot, fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID)) | |
| 830 | + | require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o700)) | |
| 831 | + | require.NoError(t, os.WriteFile(oldPath, []byte("old disk"), 0o600)) | |
| 832 | + | require.NoError(t, os.Remove(filepath.Join(c.artifactRoot, "objects", filepath.FromSlash(a.BlobKey())))) | |
| 833 | + | used, version, err := store.ArtifactUsage(c.ctx, a.Repo) | |
| 834 | + | require.NoError(t, err) | |
| 835 | + | a.Store = domain.CIArtifactStoreLegacy | |
| 836 | + | require.NoError(t, store.ApplyArtifacts(c.ctx, ports.CIArtifactWrite{Repo: a.Repo, Used: used, UsageVersion: version, Put: []domain.CIArtifact{a}})) | |
| 837 | + | _, got, err = up.download(a.ID, 0) | |
| 838 | + | require.NoError(t, err) | |
| 839 | + | assert.Equal(t, "old disk", string(got)) | |
| 840 | + | require.NoError(t, up.remove("old")) | |
| 841 | + | _, err = os.Stat(oldPath) | |
| 842 | + | assert.True(t, os.IsNotExist(err)) | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | func TestCIArtifactWithoutLegacyReader(t *testing.T) { | |
| 846 | + | c := newArtifactHarness(t, func(cfg *usecase.CIConfig) { cfg.ArtifactFiles = nil }) | |
| 847 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 848 | + | _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow}) | |
| 849 | + | up := c.jobArtifacts(job) | |
| 850 | + | a := up.upload("blob only", []byte("contents")) | |
| 851 | + | _, got, err := up.download(a.GetId(), 0) | |
| 852 | + | require.NoError(t, err) | |
| 853 | + | assert.Equal(t, "contents", string(got)) | |
| 854 | + | require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.name(c.repo))) | |
| 855 | + | assert.Empty(t, c.artifactFiles()) | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | func TestCIArtifactMultipartAndOversizedBlock(t *testing.T) { | |
| 859 | + | c := newArtifactHarness(t, nil) | |
| 860 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 861 | + | _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow}) | |
| 862 | + | up := c.jobArtifacts(job) | |
| 863 | + | id := up.mustCreate("multipart") | |
| 864 | + | first, second := pattern(6<<20, 1), pattern(3<<20, 2) | |
| 865 | + | _, err := up.block(id, "a", first) | |
| 866 | + | require.NoError(t, err) | |
| 867 | + | _, err = up.block(id, "b", second) | |
| 868 | + | require.NoError(t, err) | |
| 869 | + | committed, err := up.commit(id, "a", "b") | |
| 870 | + | require.NoError(t, err) | |
| 871 | + | _, err = up.finalize("multipart", committed.GetDigest()) | |
| 872 | + | require.NoError(t, err) | |
| 873 | + | _, got, err := up.download(id, 0) | |
| 874 | + | require.NoError(t, err) | |
| 875 | + | assert.Equal(t, append(first, second...), got) | |
| 876 | + | before := c.usage() | |
| 877 | + | refused := up.mustCreate("too big") | |
| 878 | + | _, err = up.block(refused, "a", make([]byte, domain.CIArtifactMaxBlockSize+1)) | |
| 879 | + | require.Error(t, err) | |
| 880 | + | assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) | |
| 881 | + | assert.Contains(t, err.Error(), "a block has at most 8 MiB") | |
| 882 | + | assert.Equal(t, before, c.usage()) | |
| 883 | + | a, err := c.forge.store.Artifacts().GetArtifact(c.ctx, refused) | |
| 884 | + | require.NoError(t, err) | |
| 885 | + | assert.Zero(t, a.Charged) | |
| 886 | + | require.NoError(t, blobdisk.New(c.artifactRoot).List(c.ctx, a.BlobKey()+".blocks/", func(ports.BlobInfo) error { t.Error("refused block survived"); return nil })) | |
| 887 | + | } | |
M internal/app/ci_e2e_test.go +2 -0
| @@ -28,6 +28,8 @@ type ciHarness struct { | |||
| 28 | 28 | root *clients | |
| 29 | 29 | wt string | |
| 30 | 30 | repo string | |
| 31 | + | // artifactRoot is supplied by the artifact test rig until P8 wires blobs. | |
| 32 | + | artifactRoot string | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 33 | 35 | const ciWorkflow = `name: ci | |
M internal/app/cli_e2e_test.go +4 -3
| @@ -334,7 +334,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 334 | 334 | assert.JSONEq(t, `[]`, res.stdout) | |
| 335 | 335 | ||
| 336 | 336 | // version: no token needed. This server runs on http://127.0.0.1, where | |
| 337 | - | // passkeys are not available: they are the only feature missing. | |
| 337 | + | // passkeys are not available. | |
| 338 | 338 | res = c.cli(t, "", "version", "--json").ok(t) | |
| 339 | 339 | assert.Equal(t, 1, strings.Count(res.stderr, "warning:"), res.stderr) | |
| 340 | 340 | assert.Contains(t, res.stderr, "the server does not support passkeys") | |
| @@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 350 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 351 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 352 | 352 | assert.NotEmpty(t, ver.Server.Version) | |
| 353 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, ver.Server.Features) | |
| 353 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready", "releases"}, ver.Server.Features) | |
| 354 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 355 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 356 | 356 | require.Equal(t, 0, code, stderr) | |
| @@ -389,7 +389,8 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 389 | 389 | assert.Empty(t, tk.Secret, "list never shows secrets") | |
| 390 | 390 | byLabel[tk.Label] = tk | |
| 391 | 391 | } | |
| 392 | - | assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes()), "the cli preset grants every scope") | |
| 392 | + | assert.Len(t, byLabel["daily"].Scopes, len(client.AllScopes())) | |
| 393 | + | assert.Contains(t, byLabel["daily"].Scopes, "release:write") | |
| 393 | 394 | assert.Equal(t, tmp.ID, byLabel["tmp"].ID) | |
| 394 | 395 | assert.NotContains(t, res.stdout, `"secret"`) | |
| 395 | 396 | res = c.cli(t, secret, "token", "list").ok(t) | |
A internal/app/cli_release.go +603 -0
| @@ -0,0 +1,603 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "context" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/hex" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "io" | |
| 13 | + | "mime" | |
| 14 | + | "net/http" | |
| 15 | + | "net/url" | |
| 16 | + | "os" | |
| 17 | + | "path/filepath" | |
| 18 | + | "slices" | |
| 19 | + | "strconv" | |
| 20 | + | "strings" | |
| 21 | + | ||
| 22 | + | "connectrpc.com/connect" | |
| 23 | + | "github.com/spf13/cobra" | |
| 24 | + | "golang.org/x/term" | |
| 25 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 26 | + | ||
| 27 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 28 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | var errNoReleases = errors.New("the server has no releases (it lacks the releases feature)") | |
| 32 | + | ||
| 33 | + | func requireReleases(ctx context.Context, c *client.Client) error { | |
| 34 | + | res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{})) | |
| 35 | + | if err != nil { | |
| 36 | + | return c.Describe(err) | |
| 37 | + | } | |
| 38 | + | if !slices.Contains(res.Msg.GetFeatures(), client.FeatureReleases) { | |
| 39 | + | return errNoReleases | |
| 40 | + | } | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func releaseClient(env *cliEnv, cmd *cobra.Command) (*client.Client, error) { | |
| 45 | + | c, err := env.client(cmd) | |
| 46 | + | if err != nil { | |
| 47 | + | return nil, err | |
| 48 | + | } | |
| 49 | + | if err := requireReleases(env.ctx(cmd), c); err != nil { | |
| 50 | + | return nil, err | |
| 51 | + | } | |
| 52 | + | return c, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // jsonRelease and jsonReleaseAsset keep the CLI shape independent of protobuf JSON. | |
| 56 | + | type jsonReleaseAsset struct { | |
| 57 | + | ID int64 `json:"id"` | |
| 58 | + | ReleaseID int64 `json:"release_id"` | |
| 59 | + | Name string `json:"name"` | |
| 60 | + | Label string `json:"label"` | |
| 61 | + | ContentType string `json:"content_type"` | |
| 62 | + | Size int64 `json:"size"` | |
| 63 | + | Digest string `json:"digest"` | |
| 64 | + | Uploader string `json:"uploader"` | |
| 65 | + | CreatedAt *string `json:"created_at"` | |
| 66 | + | UpdatedAt *string `json:"updated_at"` | |
| 67 | + | } | |
| 68 | + | type jsonRelease struct { | |
| 69 | + | ID int64 `json:"id"` | |
| 70 | + | Repo string `json:"repo"` | |
| 71 | + | Tag string `json:"tag"` | |
| 72 | + | Title string `json:"title"` | |
| 73 | + | Notes string `json:"notes"` | |
| 74 | + | Draft bool `json:"draft"` | |
| 75 | + | Prerelease bool `json:"prerelease"` | |
| 76 | + | Latest bool `json:"latest"` | |
| 77 | + | TargetCommitish string `json:"target_commitish"` | |
| 78 | + | Author string `json:"author"` | |
| 79 | + | CreatedAt *string `json:"created_at"` | |
| 80 | + | PublishedAt *string `json:"published_at"` | |
| 81 | + | UpdatedAt *string `json:"updated_at"` | |
| 82 | + | Assets []jsonReleaseAsset `json:"assets"` | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func toJSONRelease(r *ermite.Release) jsonRelease { | |
| 86 | + | out := jsonRelease{ID: r.GetId(), Repo: r.GetRepo(), Tag: r.GetTag(), Title: r.GetTitle(), Notes: r.GetNotes(), Draft: r.GetDraft(), Prerelease: r.GetPrerelease(), Latest: r.GetLatest(), TargetCommitish: r.GetTargetCommitish(), Author: r.GetAuthor(), CreatedAt: jsonTime(r.GetCreatedAt()), PublishedAt: jsonTime(r.GetPublishedAt()), UpdatedAt: jsonTime(r.GetUpdatedAt()), Assets: []jsonReleaseAsset{}} | |
| 87 | + | for _, a := range r.GetAssets() { | |
| 88 | + | out.Assets = append(out.Assets, jsonReleaseAsset{a.GetId(), a.GetReleaseId(), a.GetName(), a.GetLabel(), a.GetContentType(), a.GetSize(), a.GetDigest(), a.GetUploader(), jsonTime(a.GetCreatedAt()), jsonTime(a.GetUpdatedAt())}) | |
| 89 | + | } | |
| 90 | + | return out | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | const releaseJSONHelp = "\nJSON fields: id, repo, tag, title, notes, draft, prerelease, latest, target_commitish, author,\ncreated_at, published_at, updated_at (RFC 3339 UTC or null), assets (id, release_id, name, label,\ncontent_type, size, digest, uploader, created_at, updated_at). list prints an array." | |
| 94 | + | ||
| 95 | + | func newReleaseCmd(env *cliEnv) *cobra.Command { | |
| 96 | + | cmd := &cobra.Command{Use: "release", Short: "Manage releases and their assets", Long: "Create and manage tagged releases. Reads follow repository visibility; writes need release:write.\nNeeds a server with the releases feature. Select releases by tag, numeric id, or latest (view/download)."} | |
| 97 | + | cmd.AddCommand(newReleaseCreateCmd(env), newReleaseListCmd(env), newReleaseViewCmd(env), newReleaseEditCmd(env), newReleaseDeleteCmd(env), newReleaseUploadCmd(env), newReleaseDownloadCmd(env)) | |
| 98 | + | return cmd | |
| 99 | + | } | |
| 100 | + | func releaseCommand(env *cliEnv, use, short string, n int) *cobra.Command { | |
| 101 | + | return &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(n), ValidArgsFunction: completeRepoNames(env)} | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func getRelease(ctx context.Context, c *client.Client, repo, selector string, latest bool) (*ermite.Release, error) { | |
| 105 | + | req := &ermite.GetReleaseRequest{Repo: repo} | |
| 106 | + | if selector == "latest" && latest { | |
| 107 | + | req.Selector = &ermite.GetReleaseRequest_Latest{Latest: true} | |
| 108 | + | } else if selector != "" && strings.Trim(selector, "0123456789") == "" { | |
| 109 | + | id, err := strconv.ParseInt(selector, 10, 64) | |
| 110 | + | if err != nil || id <= 0 { | |
| 111 | + | return nil, fmt.Errorf("invalid release id %q", selector) | |
| 112 | + | } | |
| 113 | + | req.Selector = &ermite.GetReleaseRequest_Id{Id: id} | |
| 114 | + | } else { | |
| 115 | + | req.Selector = &ermite.GetReleaseRequest_Tag{Tag: selector} | |
| 116 | + | } | |
| 117 | + | res, err := c.Releases().GetRelease(ctx, connect.NewRequest(req)) | |
| 118 | + | if err != nil { | |
| 119 | + | return nil, c.Describe(err) | |
| 120 | + | } | |
| 121 | + | return res.Msg.GetRelease(), nil | |
| 122 | + | } | |
| 123 | + | func releaseState(r *ermite.Release) string { | |
| 124 | + | if r.GetDraft() { | |
| 125 | + | return "draft" | |
| 126 | + | } | |
| 127 | + | if r.GetPrerelease() { | |
| 128 | + | return "prerelease" | |
| 129 | + | } | |
| 130 | + | return "published" | |
| 131 | + | } | |
| 132 | + | func releaseURL(base, repo, tag string) string { | |
| 133 | + | return strings.TrimRight(base, "/") + "/" + url.PathEscape(repo) + "/releases/tag/" + url.PathEscape(tag) | |
| 134 | + | } | |
| 135 | + | func outputRelease(cmd *cobra.Command, c *client.Client, r *ermite.Release, asJSON bool) error { | |
| 136 | + | if asJSON { | |
| 137 | + | return writeJSON(cmd.OutOrStdout(), toJSONRelease(r)) | |
| 138 | + | } | |
| 139 | + | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\nid: %d\n", releaseURL(c.URL(), r.GetRepo(), r.GetTag()), r.GetId()) | |
| 140 | + | return err | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | type releaseFields struct { | |
| 144 | + | title, notes, notesFile, target, tag string | |
| 145 | + | draft, publish, prerelease, noPrerelease, latest bool | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (f *releaseFields) flags(cmd *cobra.Command, edit bool) { | |
| 149 | + | cmd.Flags().StringVar(&f.title, "title", "", "release title") | |
| 150 | + | cmd.Flags().StringVar(&f.notes, "notes", "", "Markdown release notes") | |
| 151 | + | cmd.Flags().StringVar(&f.notesFile, "notes-file", "", "read Markdown release notes from a file (- for stdin)") | |
| 152 | + | cmd.MarkFlagsMutuallyExclusive("notes", "notes-file") | |
| 153 | + | cmd.Flags().StringVar(&f.target, "target", "", "target commit or branch for a missing tag") | |
| 154 | + | cmd.Flags().BoolVar(&f.draft, "draft", false, "keep as a draft") | |
| 155 | + | cmd.Flags().BoolVar(&f.prerelease, "prerelease", false, "mark as a prerelease") | |
| 156 | + | cmd.Flags().BoolVar(&f.latest, "latest", false, "pin as latest") | |
| 157 | + | if edit { | |
| 158 | + | cmd.Flags().StringVar(&f.tag, "tag", "", "new tag") | |
| 159 | + | cmd.Flags().BoolVar(&f.publish, "publish", false, "publish a draft") | |
| 160 | + | cmd.Flags().BoolVar(&f.noPrerelease, "no-prerelease", false, "clear the prerelease flag") | |
| 161 | + | cmd.MarkFlagsMutuallyExclusive("draft", "publish") | |
| 162 | + | cmd.MarkFlagsMutuallyExclusive("prerelease", "no-prerelease") | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | func (f *releaseFields) readNotes(cmd *cobra.Command) error { | |
| 166 | + | if !cmd.Flags().Changed("notes-file") { | |
| 167 | + | return nil | |
| 168 | + | } | |
| 169 | + | var data []byte | |
| 170 | + | var err error | |
| 171 | + | if f.notesFile == "-" { | |
| 172 | + | data, err = io.ReadAll(cmd.InOrStdin()) | |
| 173 | + | } else { | |
| 174 | + | data, err = os.ReadFile(f.notesFile) | |
| 175 | + | } | |
| 176 | + | f.notes = string(data) | |
| 177 | + | return err | |
| 178 | + | } | |
| 179 | + | func newReleaseCreateCmd(env *cliEnv) *cobra.Command { | |
| 180 | + | var f releaseFields | |
| 181 | + | var assets []string | |
| 182 | + | cmd := releaseCommand(env, "create <repo> <tag>", "Create a release", 2) | |
| 183 | + | cmd.Long = "Create a release, then upload each --asset FILE[#LABEL] (repeatable; shell expands globs)." + releaseJSONHelp | |
| 184 | + | f.flags(cmd, false) | |
| 185 | + | cmd.Flags().StringArrayVar(&assets, "asset", nil, "upload FILE[#LABEL] (repeatable)") | |
| 186 | + | asJSON := addJSONFlag(cmd) | |
| 187 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 188 | + | if err := f.readNotes(cmd); err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | c, err := releaseClient(env, cmd) | |
| 192 | + | if err != nil { | |
| 193 | + | return err | |
| 194 | + | } | |
| 195 | + | ctx := env.ctx(cmd) | |
| 196 | + | res, err := c.Releases().CreateRelease(ctx, connect.NewRequest(&ermite.CreateReleaseRequest{Repo: args[0], Tag: args[1], Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft, Prerelease: f.prerelease, MakeLatest: f.latest})) | |
| 197 | + | if err != nil { | |
| 198 | + | return c.Describe(err) | |
| 199 | + | } | |
| 200 | + | r := res.Msg.GetRelease() | |
| 201 | + | for _, spec := range assets { | |
| 202 | + | a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, "", false) | |
| 203 | + | if err != nil { | |
| 204 | + | return fmt.Errorf("release %d was created, but asset upload failed: %w", r.GetId(), err) | |
| 205 | + | } | |
| 206 | + | r.Assets = append(r.Assets, a) | |
| 207 | + | } | |
| 208 | + | return outputRelease(cmd, c, r, *asJSON) | |
| 209 | + | } | |
| 210 | + | return cmd | |
| 211 | + | } | |
| 212 | + | func newReleaseListCmd(env *cliEnv) *cobra.Command { | |
| 213 | + | var limit int32 | |
| 214 | + | cmd := releaseCommand(env, "list <repo>", "List releases, newest first", 1) | |
| 215 | + | cmd.Long = "List releases, including drafts visible to writers." + releaseJSONHelp | |
| 216 | + | cmd.Flags().Int32Var(&limit, "limit", 30, "maximum number of releases") | |
| 217 | + | asJSON := addJSONFlag(cmd) | |
| 218 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 219 | + | if limit <= 0 { | |
| 220 | + | return errors.New("--limit must be positive") | |
| 221 | + | } | |
| 222 | + | c, err := releaseClient(env, cmd) | |
| 223 | + | if err != nil { | |
| 224 | + | return err | |
| 225 | + | } | |
| 226 | + | all := []*ermite.Release{} | |
| 227 | + | page := "" | |
| 228 | + | for int32(len(all)) < limit { | |
| 229 | + | res, err := c.Releases().ListReleases(env.ctx(cmd), connect.NewRequest(&ermite.ListReleasesRequest{Repo: args[0], PageSize: limit - int32(len(all)), PageToken: page})) | |
| 230 | + | if err != nil { | |
| 231 | + | return c.Describe(err) | |
| 232 | + | } | |
| 233 | + | all = append(all, res.Msg.GetReleases()...) | |
| 234 | + | next := res.Msg.GetNextPageToken() | |
| 235 | + | if next == "" { | |
| 236 | + | break | |
| 237 | + | } | |
| 238 | + | if next == page { | |
| 239 | + | return errors.New("the server repeated a release page token") | |
| 240 | + | } | |
| 241 | + | page = next | |
| 242 | + | } | |
| 243 | + | if int32(len(all)) > limit { | |
| 244 | + | all = all[:limit] | |
| 245 | + | } | |
| 246 | + | if *asJSON { | |
| 247 | + | out := make([]jsonRelease, 0, len(all)) | |
| 248 | + | for _, r := range all { | |
| 249 | + | out = append(out, toJSONRelease(r)) | |
| 250 | + | } | |
| 251 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 252 | + | } | |
| 253 | + | rows := [][]string{} | |
| 254 | + | for _, r := range all { | |
| 255 | + | rows = append(rows, []string{strconv.FormatInt(r.GetId(), 10), r.GetTag(), r.GetTitle(), releaseState(r), shortTime(r.GetPublishedAt(), "-"), strconv.Itoa(len(r.GetAssets()))}) | |
| 256 | + | } | |
| 257 | + | return table(cmd.OutOrStdout(), []string{"ID", "TAG", "TITLE", "STATE", "PUBLISHED", "ASSETS"}, rows) | |
| 258 | + | } | |
| 259 | + | return cmd | |
| 260 | + | } | |
| 261 | + | func newReleaseViewCmd(env *cliEnv) *cobra.Command { | |
| 262 | + | var notes bool | |
| 263 | + | cmd := releaseCommand(env, "view <repo> <tag|id|latest>", "View a release and its assets", 2) | |
| 264 | + | cmd.Long = "View release fields, Markdown notes and assets. --notes prints only Markdown." + releaseJSONHelp | |
| 265 | + | asJSON := addJSONFlag(cmd) | |
| 266 | + | cmd.Flags().BoolVar(¬es, "notes", false, "print only Markdown notes") | |
| 267 | + | cmd.MarkFlagsMutuallyExclusive("json", "notes") | |
| 268 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 269 | + | c, err := releaseClient(env, cmd) | |
| 270 | + | if err != nil { | |
| 271 | + | return err | |
| 272 | + | } | |
| 273 | + | r, err := getRelease(env.ctx(cmd), c, args[0], args[1], true) | |
| 274 | + | if err != nil { | |
| 275 | + | return err | |
| 276 | + | } | |
| 277 | + | if *asJSON { | |
| 278 | + | return writeJSON(cmd.OutOrStdout(), toJSONRelease(r)) | |
| 279 | + | } | |
| 280 | + | if notes { | |
| 281 | + | _, err := fmt.Fprint(cmd.OutOrStdout(), r.GetNotes()) | |
| 282 | + | return err | |
| 283 | + | } | |
| 284 | + | if err := fields(cmd.OutOrStdout(), [][2]string{{"id", strconv.FormatInt(r.GetId(), 10)}, {"tag", r.GetTag()}, {"title", r.GetTitle()}, {"state", releaseState(r)}, {"latest", strconv.FormatBool(r.GetLatest())}, {"target", r.GetTargetCommitish()}, {"author", r.GetAuthor()}, {"created", shortTime(r.GetCreatedAt(), "-")}, {"published", shortTime(r.GetPublishedAt(), "-")}, {"updated", shortTime(r.GetUpdatedAt(), "-")}}); err != nil { | |
| 285 | + | return err | |
| 286 | + | } | |
| 287 | + | if _, err := fmt.Fprintln(cmd.OutOrStdout(), clean(r.GetNotes())); err != nil { | |
| 288 | + | return err | |
| 289 | + | } | |
| 290 | + | rows := [][]string{} | |
| 291 | + | for _, a := range r.GetAssets() { | |
| 292 | + | rows = append(rows, []string{a.GetName(), humanBytes(a.GetSize()), a.GetContentType()}) | |
| 293 | + | } | |
| 294 | + | return table(cmd.OutOrStdout(), []string{"NAME", "SIZE", "TYPE"}, rows) | |
| 295 | + | } | |
| 296 | + | return cmd | |
| 297 | + | } | |
| 298 | + | func newReleaseEditCmd(env *cliEnv) *cobra.Command { | |
| 299 | + | var f releaseFields | |
| 300 | + | cmd := releaseCommand(env, "edit <repo> <tag|id>", "Edit a release", 2) | |
| 301 | + | f.flags(cmd, true) | |
| 302 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 303 | + | if err := f.readNotes(cmd); err != nil { | |
| 304 | + | return err | |
| 305 | + | } | |
| 306 | + | paths := []string{} | |
| 307 | + | for _, p := range [][2]string{{"title", "title"}, {"notes", "notes"}, {"notes-file", "notes"}, {"tag", "tag"}, {"target", "target_commitish"}, {"draft", "draft"}, {"publish", "draft"}, {"prerelease", "prerelease"}, {"no-prerelease", "prerelease"}, {"latest", "make_latest"}} { | |
| 308 | + | if cmd.Flags().Changed(p[0]) { | |
| 309 | + | paths = append(paths, p[1]) | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | if len(paths) == 0 { | |
| 313 | + | return errors.New("give at least one field to edit") | |
| 314 | + | } | |
| 315 | + | c, err := releaseClient(env, cmd) | |
| 316 | + | if err != nil { | |
| 317 | + | return err | |
| 318 | + | } | |
| 319 | + | ctx := env.ctx(cmd) | |
| 320 | + | r, err := getRelease(ctx, c, args[0], args[1], false) | |
| 321 | + | if err != nil { | |
| 322 | + | return err | |
| 323 | + | } | |
| 324 | + | res, err := c.Releases().UpdateRelease(ctx, connect.NewRequest(&ermite.UpdateReleaseRequest{Repo: args[0], Id: r.GetId(), Tag: f.tag, Title: f.title, Notes: f.notes, TargetCommitish: f.target, Draft: f.draft && !f.publish, Prerelease: f.prerelease && !f.noPrerelease, MakeLatest: f.latest, UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}})) | |
| 325 | + | if err != nil { | |
| 326 | + | return c.Describe(err) | |
| 327 | + | } | |
| 328 | + | return outputRelease(cmd, c, res.Msg.GetRelease(), false) | |
| 329 | + | } | |
| 330 | + | return cmd | |
| 331 | + | } | |
| 332 | + | func newReleaseDeleteCmd(env *cliEnv) *cobra.Command { | |
| 333 | + | var yes bool | |
| 334 | + | cmd := releaseCommand(env, "delete <repo> <tag|id>", "Delete a release and its assets (keeps the tag)", 2) | |
| 335 | + | cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation") | |
| 336 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 337 | + | c, err := releaseClient(env, cmd) | |
| 338 | + | if err != nil { | |
| 339 | + | return err | |
| 340 | + | } | |
| 341 | + | ctx := env.ctx(cmd) | |
| 342 | + | r, err := getRelease(ctx, c, args[0], args[1], false) | |
| 343 | + | if err != nil { | |
| 344 | + | return err | |
| 345 | + | } | |
| 346 | + | if in, ok := cmd.InOrStdin().(*os.File); !yes && ok && term.IsTerminal(int(in.Fd())) { | |
| 347 | + | _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Delete release %q and all its assets? [y/N] ", clean(r.GetTag())) | |
| 348 | + | answer, err := bufio.NewReader(in).ReadString('\n') | |
| 349 | + | if err != nil || !strings.EqualFold(strings.TrimSpace(answer), "y") { | |
| 350 | + | return errors.New("aborted") | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | _, err = c.Releases().DeleteRelease(ctx, connect.NewRequest(&ermite.DeleteReleaseRequest{Repo: args[0], Id: r.GetId()})) | |
| 354 | + | if err != nil { | |
| 355 | + | return c.Describe(err) | |
| 356 | + | } | |
| 357 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted release %d\n", r.GetId()) | |
| 358 | + | return err | |
| 359 | + | } | |
| 360 | + | return cmd | |
| 361 | + | } | |
| 362 | + | func newReleaseUploadCmd(env *cliEnv) *cobra.Command { | |
| 363 | + | var clobber bool | |
| 364 | + | var contentType string | |
| 365 | + | cmd := releaseCommand(env, "upload <repo> <tag|id> FILE[#LABEL]...", "Upload release assets", 3) | |
| 366 | + | cmd.Args = cobra.MinimumNArgs(3) | |
| 367 | + | cmd.Flags().BoolVar(&clobber, "clobber", false, "replace assets with the same name") | |
| 368 | + | cmd.Flags().StringVar(&contentType, "content-type", "", "override detected media type") | |
| 369 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 370 | + | c, err := releaseClient(env, cmd) | |
| 371 | + | if err != nil { | |
| 372 | + | return err | |
| 373 | + | } | |
| 374 | + | ctx := env.ctx(cmd) | |
| 375 | + | r, err := getRelease(ctx, c, args[0], args[1], false) | |
| 376 | + | if err != nil { | |
| 377 | + | return err | |
| 378 | + | } | |
| 379 | + | for _, spec := range args[2:] { | |
| 380 | + | a, err := uploadReleaseFile(ctx, c, args[0], r.GetId(), spec, contentType, clobber) | |
| 381 | + | if err != nil { | |
| 382 | + | return err | |
| 383 | + | } | |
| 384 | + | noticef(cmd, "uploaded %s (%s)", clean(a.GetName()), humanBytes(a.GetSize())) | |
| 385 | + | } | |
| 386 | + | return nil | |
| 387 | + | } | |
| 388 | + | return cmd | |
| 389 | + | } | |
| 390 | + | func uploadReleaseFile(ctx context.Context, c *client.Client, repo string, id int64, spec, contentType string, replace bool) (*ermite.ReleaseAsset, error) { | |
| 391 | + | filename, label, _ := strings.Cut(spec, "#") | |
| 392 | + | f, err := os.Open(filename) | |
| 393 | + | if err != nil { | |
| 394 | + | return nil, err | |
| 395 | + | } | |
| 396 | + | defer func() { _ = f.Close() }() | |
| 397 | + | info, err := f.Stat() | |
| 398 | + | if err != nil { | |
| 399 | + | return nil, err | |
| 400 | + | } | |
| 401 | + | if !info.Mode().IsRegular() { | |
| 402 | + | return nil, fmt.Errorf("%q is not a regular file", filename) | |
| 403 | + | } | |
| 404 | + | if contentType == "" { | |
| 405 | + | contentType = mime.TypeByExtension(filepath.Ext(filename)) | |
| 406 | + | if contentType == "" { | |
| 407 | + | buf := make([]byte, 512) | |
| 408 | + | n, err := f.Read(buf) | |
| 409 | + | if err != nil && !errors.Is(err, io.EOF) { | |
| 410 | + | return nil, err | |
| 411 | + | } | |
| 412 | + | contentType = http.DetectContentType(buf[:n]) | |
| 413 | + | if _, err := f.Seek(0, io.SeekStart); err != nil { | |
| 414 | + | return nil, err | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | if _, _, err := mime.ParseMediaType(contentType); err != nil { | |
| 419 | + | return nil, fmt.Errorf("invalid --content-type: %w", err) | |
| 420 | + | } | |
| 421 | + | name := filepath.Base(filename) | |
| 422 | + | stream := c.Releases().UploadReleaseAsset(ctx) | |
| 423 | + | // Always close the stream, including a local read or Send failure. | |
| 424 | + | defer func() { | |
| 425 | + | if conn, err := stream.Conn(); err == nil { | |
| 426 | + | _ = conn.CloseRequest() | |
| 427 | + | _ = conn.CloseResponse() | |
| 428 | + | } | |
| 429 | + | }() | |
| 430 | + | err = stream.Send(&ermite.UploadReleaseAssetRequest{Repo: repo, ReleaseId: id, Name: name, Label: label, ContentType: contentType, Size: info.Size(), Replace: replace}) | |
| 431 | + | buf := make([]byte, 1<<20) | |
| 432 | + | for err == nil { | |
| 433 | + | n, rerr := f.Read(buf) | |
| 434 | + | if n > 0 { | |
| 435 | + | err = stream.Send(&ermite.UploadReleaseAssetRequest{Data: buf[:n]}) | |
| 436 | + | } | |
| 437 | + | if rerr != nil { | |
| 438 | + | if !errors.Is(rerr, io.EOF) { | |
| 439 | + | return nil, rerr | |
| 440 | + | } | |
| 441 | + | break | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | // CloseAndReceive exposes a server error even when Send returned EOF. | |
| 445 | + | res, closeErr := stream.CloseAndReceive() | |
| 446 | + | if closeErr != nil { | |
| 447 | + | err = closeErr | |
| 448 | + | } | |
| 449 | + | if err != nil { | |
| 450 | + | if connect.CodeOf(err) == connect.CodeAlreadyExists && !replace { | |
| 451 | + | return nil, fmt.Errorf("asset %q already exists; use --clobber to replace it", clean(name)) | |
| 452 | + | } | |
| 453 | + | return nil, c.Describe(err) | |
| 454 | + | } | |
| 455 | + | return res.Msg.GetAsset(), nil | |
| 456 | + | } | |
| 457 | + | func newReleaseDownloadCmd(env *cliEnv) *cobra.Command { | |
| 458 | + | var names, patterns []string | |
| 459 | + | var dir string | |
| 460 | + | cmd := releaseCommand(env, "download <repo> <tag|id|latest>", "Download assets without overwriting existing files", 2) | |
| 461 | + | cmd.Flags().StringArrayVar(&names, "name", nil, "asset name (repeatable)") | |
| 462 | + | cmd.Flags().StringArrayVar(&patterns, "pattern", nil, "asset name glob (repeatable)") | |
| 463 | + | cmd.Flags().StringVarP(&dir, "dir", "D", ".", "download directory") | |
| 464 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 465 | + | for _, p := range patterns { | |
| 466 | + | if _, err := filepath.Match(p, ""); err != nil { | |
| 467 | + | return fmt.Errorf("--pattern: %w", err) | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | c, err := releaseClient(env, cmd) | |
| 471 | + | if err != nil { | |
| 472 | + | return err | |
| 473 | + | } | |
| 474 | + | ctx := env.ctx(cmd) | |
| 475 | + | r, err := getRelease(ctx, c, args[0], args[1], true) | |
| 476 | + | if err != nil { | |
| 477 | + | return err | |
| 478 | + | } | |
| 479 | + | picked, err := pickReleaseAssets(r.GetAssets(), names, patterns) | |
| 480 | + | if err != nil { | |
| 481 | + | return err | |
| 482 | + | } | |
| 483 | + | if len(picked) == 0 { | |
| 484 | + | return errors.New("no matching release assets") | |
| 485 | + | } | |
| 486 | + | for _, a := range picked { | |
| 487 | + | if err := checkArtifactName(a.GetName()); err != nil { | |
| 488 | + | return err | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 492 | + | return err | |
| 493 | + | } | |
| 494 | + | root, err := os.OpenRoot(dir) | |
| 495 | + | if err != nil { | |
| 496 | + | return err | |
| 497 | + | } | |
| 498 | + | defer func() { _ = root.Close() }() | |
| 499 | + | for _, a := range picked { | |
| 500 | + | if err := downloadReleaseAsset(ctx, c, args[0], a, root); err != nil { | |
| 501 | + | return fmt.Errorf("asset %q: %w", clean(a.GetName()), err) | |
| 502 | + | } | |
| 503 | + | noticef(cmd, "downloaded %s to %s", clean(a.GetName()), clean(filepath.Join(dir, a.GetName()))) | |
| 504 | + | } | |
| 505 | + | return nil | |
| 506 | + | } | |
| 507 | + | return cmd | |
| 508 | + | } | |
| 509 | + | func pickReleaseAssets(all []*ermite.ReleaseAsset, names, patterns []string) ([]*ermite.ReleaseAsset, error) { | |
| 510 | + | for _, name := range names { | |
| 511 | + | if !slices.ContainsFunc(all, func(a *ermite.ReleaseAsset) bool { return a.GetName() == name }) { | |
| 512 | + | return nil, fmt.Errorf("the release has no asset %q", clean(name)) | |
| 513 | + | } | |
| 514 | + | } | |
| 515 | + | out := []*ermite.ReleaseAsset{} | |
| 516 | + | for _, a := range all { | |
| 517 | + | match := len(names) == 0 && len(patterns) == 0 || slices.Contains(names, a.GetName()) | |
| 518 | + | for _, p := range patterns { | |
| 519 | + | ok, err := filepath.Match(p, a.GetName()) | |
| 520 | + | if err != nil { | |
| 521 | + | return nil, err | |
| 522 | + | } | |
| 523 | + | match = match || ok | |
| 524 | + | } | |
| 525 | + | if match { | |
| 526 | + | out = append(out, a) | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | return out, nil | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | // downloadReleaseAsset owns the destination exclusively; errors remove it. Only | |
| 533 | + | // transport failures resume, once, using the immutable asset id and kept offset. | |
| 534 | + | func downloadReleaseAsset(ctx context.Context, c *client.Client, repo string, a *ermite.ReleaseAsset, root *os.Root) (err error) { | |
| 535 | + | f, err := root.OpenFile(a.GetName(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | |
| 536 | + | if err != nil { | |
| 537 | + | return err | |
| 538 | + | } | |
| 539 | + | defer func() { | |
| 540 | + | cerr := f.Close() | |
| 541 | + | if err == nil { | |
| 542 | + | err = cerr | |
| 543 | + | } | |
| 544 | + | if err != nil { | |
| 545 | + | _ = root.Remove(a.GetName()) | |
| 546 | + | } | |
| 547 | + | }() | |
| 548 | + | hash := sha256.New() | |
| 549 | + | w := io.MultiWriter(f, hash) | |
| 550 | + | var kept int64 | |
| 551 | + | for attempt := 0; attempt < 2; attempt++ { | |
| 552 | + | stream, openErr := c.Releases().DownloadReleaseAsset(ctx, connect.NewRequest(&ermite.DownloadReleaseAssetRequest{Repo: repo, Selector: &ermite.DownloadReleaseAssetRequest_AssetId{AssetId: a.GetId()}, Offset: kept})) | |
| 553 | + | if openErr != nil { | |
| 554 | + | err = openErr | |
| 555 | + | } else { | |
| 556 | + | described := false | |
| 557 | + | for stream.Receive() { | |
| 558 | + | msg := stream.Msg() | |
| 559 | + | if !described { | |
| 560 | + | meta := msg.GetAsset() | |
| 561 | + | if meta == nil || meta.GetId() != a.GetId() || meta.GetSize() != a.GetSize() || meta.GetDigest() != a.GetDigest() { | |
| 562 | + | _ = stream.Close() | |
| 563 | + | return errors.New("the server did not describe the expected asset") | |
| 564 | + | } | |
| 565 | + | described = true | |
| 566 | + | } | |
| 567 | + | chunk := msg.GetChunk() | |
| 568 | + | if kept+int64(len(chunk)) > a.GetSize() { | |
| 569 | + | _ = stream.Close() | |
| 570 | + | return errors.New("the server sent more than the asset size") | |
| 571 | + | } | |
| 572 | + | n, werr := w.Write(chunk) | |
| 573 | + | kept += int64(n) | |
| 574 | + | if werr != nil { | |
| 575 | + | _ = stream.Close() | |
| 576 | + | return werr | |
| 577 | + | } | |
| 578 | + | } | |
| 579 | + | err = stream.Err() | |
| 580 | + | _ = stream.Close() | |
| 581 | + | if err == nil && (!described || kept != a.GetSize()) { | |
| 582 | + | err = io.ErrUnexpectedEOF | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | if err == nil { | |
| 586 | + | break | |
| 587 | + | } | |
| 588 | + | if ctx.Err() != nil { | |
| 589 | + | return ctx.Err() | |
| 590 | + | } | |
| 591 | + | code := connect.CodeOf(err) | |
| 592 | + | if attempt == 1 || !(code == connect.CodeUnavailable || code == connect.CodeInternal || errors.Is(err, io.ErrUnexpectedEOF)) { | |
| 593 | + | return c.Describe(err) | |
| 594 | + | } | |
| 595 | + | } | |
| 596 | + | if err != nil { | |
| 597 | + | return c.Describe(err) | |
| 598 | + | } | |
| 599 | + | if a.GetDigest() != "sha256:"+hex.EncodeToString(hash.Sum(nil)) { | |
| 600 | + | return errors.New("asset digest mismatch") | |
| 601 | + | } | |
| 602 | + | return f.Sync() | |
| 603 | + | } | |
A internal/app/cli_release_e2e_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strconv" | |
| 11 | + | "testing" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/adapters/rpc" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestReleaseCLIEndToEnd(t *testing.T) { | |
| 20 | + | c := newCLIHarness(t) | |
| 21 | + | c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t) | |
| 22 | + | wt := c.workTree("main", "README.md") | |
| 23 | + | c.mustGit(wt, "tag", "v1") | |
| 24 | + | c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main", "refs/tags/v1") | |
| 25 | + | ||
| 26 | + | bin := func(args ...string) string { | |
| 27 | + | t.Helper() | |
| 28 | + | out, stderr, code := c.bin(t, adminToken, args...) | |
| 29 | + | require.Equal(t, 0, code, stderr) | |
| 30 | + | return out | |
| 31 | + | } | |
| 32 | + | asset := filepath.Join(t.TempDir(), "binary.dat") | |
| 33 | + | data := []byte("release payload\x00\xff") | |
| 34 | + | require.NoError(t, os.WriteFile(asset, data, 0o600)) | |
| 35 | + | created := decode[jsonRelease](t, bin("release", "create", "proj", "v1", "--title", "Version one", "--notes", "# shipped", "--asset", asset+"#Binary", "--json")) | |
| 36 | + | require.Positive(t, created.ID) | |
| 37 | + | require.Len(t, created.Assets, 1) | |
| 38 | + | assert.Equal(t, "Binary", created.Assets[0].Label) | |
| 39 | + | viewed := decode[jsonRelease](t, bin("release", "view", "proj", "v1", "--json")) | |
| 40 | + | assert.Equal(t, created.ID, viewed.ID) | |
| 41 | + | assert.Equal(t, "# shipped", viewed.Notes) | |
| 42 | + | assert.Contains(t, bin("release", "list", "proj"), "Version one") | |
| 43 | + | bin("release", "edit", "proj", strconv.FormatInt(created.ID, 10), "--title", "Edited", "--no-prerelease") | |
| 44 | + | assert.Equal(t, "Edited", decode[jsonRelease](t, bin("release", "view", "proj", "latest", "--json")).Title) | |
| 45 | + | dir := t.TempDir() | |
| 46 | + | bin("release", "download", "proj", "latest", "--dir", dir, "--pattern", "*.dat") | |
| 47 | + | downloaded, err := os.ReadFile(filepath.Join(dir, "binary.dat")) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | assert.Equal(t, data, downloaded) | |
| 50 | + | _, stderr, code := c.bin(t, adminToken, "release", "upload", "proj", "v1", asset) | |
| 51 | + | requireOneLineError(t, stderr, code, "binary.dat", "--clobber") | |
| 52 | + | bin("release", "upload", "proj", "v1", asset, "--clobber") | |
| 53 | + | bin("release", "delete", "proj", "v1", "--yes") | |
| 54 | + | _, stderr, code = c.bin(t, adminToken, "release", "view", "proj", "v1") | |
| 55 | + | requireOneLineError(t, stderr, code, "not found") | |
| 56 | + | // An older server without the service still gets a clear refusal. | |
| 57 | + | api := rpc.New(rpc.Config{Auth: c.forge.Auth, SigningKey: c.forge.SigningKey, Sessions: c.forge.Sessions}) | |
| 58 | + | mux := http.NewServeMux() | |
| 59 | + | mux.Handle("/-/api/", http.StripPrefix("/-/api", api.Handler())) | |
| 60 | + | srv := httptest.NewServer(mux) | |
| 61 | + | t.Cleanup(srv.Close) | |
| 62 | + | c.url = srv.URL | |
| 63 | + | _, stderr, code = c.bin(t, adminToken, "release", "list", "proj") | |
| 64 | + | requireOneLineError(t, stderr, code, "the server has no releases (it lacks the releases feature)") | |
| 65 | + | } | |
A internal/app/cli_release_test.go +388 -0
| @@ -0,0 +1,388 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/sha256" | |
| 8 | + | "fmt" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "sync" | |
| 14 | + | "testing" | |
| 15 | + | ||
| 16 | + | "connectrpc.com/connect" | |
| 17 | + | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | "google.golang.org/protobuf/proto" | |
| 20 | + | ||
| 21 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 22 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // A focused version of cli_fake_test's Connect rig, without changing the shared fake. | |
| 26 | + | type fakeReleaseAPI struct { | |
| 27 | + | ermiteconnect.UnimplementedReleaseServiceHandler | |
| 28 | + | mu sync.Mutex | |
| 29 | + | create *ermite.CreateReleaseRequest | |
| 30 | + | gets []*ermite.GetReleaseRequest | |
| 31 | + | lists []*ermite.ListReleasesRequest | |
| 32 | + | update *ermite.UpdateReleaseRequest | |
| 33 | + | deleted *ermite.DeleteReleaseRequest | |
| 34 | + | uploads []*ermite.UploadReleaseAssetRequest | |
| 35 | + | offsets []int64 | |
| 36 | + | release *ermite.Release | |
| 37 | + | data []byte | |
| 38 | + | uploadErr error | |
| 39 | + | download func(*ermite.DownloadReleaseAssetRequest, *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (f *fakeReleaseAPI) serve(t *testing.T, features ...string) string { | |
| 43 | + | t.Helper() | |
| 44 | + | mux := http.NewServeMux() | |
| 45 | + | p, h := ermiteconnect.NewReleaseServiceHandler(f) | |
| 46 | + | mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h)) | |
| 47 | + | v := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 48 | + | return &ermite.GetVersionResponse{Features: features}, nil | |
| 49 | + | }} | |
| 50 | + | p, h = ermiteconnect.NewServerServiceHandler(v) | |
| 51 | + | mux.Handle("/-/api"+p, http.StripPrefix("/-/api", h)) | |
| 52 | + | srv := httptest.NewServer(mux) | |
| 53 | + | t.Cleanup(srv.Close) | |
| 54 | + | return srv.URL | |
| 55 | + | } | |
| 56 | + | func (f *fakeReleaseAPI) CreateRelease(_ context.Context, r *connect.Request[ermite.CreateReleaseRequest]) (*connect.Response[ermite.CreateReleaseResponse], error) { | |
| 57 | + | f.mu.Lock() | |
| 58 | + | defer f.mu.Unlock() | |
| 59 | + | f.create = proto.Clone(r.Msg).(*ermite.CreateReleaseRequest) | |
| 60 | + | return connect.NewResponse(&ermite.CreateReleaseResponse{Release: f.release}), nil | |
| 61 | + | } | |
| 62 | + | func (f *fakeReleaseAPI) GetRelease(_ context.Context, r *connect.Request[ermite.GetReleaseRequest]) (*connect.Response[ermite.GetReleaseResponse], error) { | |
| 63 | + | f.mu.Lock() | |
| 64 | + | defer f.mu.Unlock() | |
| 65 | + | f.gets = append(f.gets, proto.Clone(r.Msg).(*ermite.GetReleaseRequest)) | |
| 66 | + | return connect.NewResponse(&ermite.GetReleaseResponse{Release: f.release}), nil | |
| 67 | + | } | |
| 68 | + | func (f *fakeReleaseAPI) ListReleases(_ context.Context, r *connect.Request[ermite.ListReleasesRequest]) (*connect.Response[ermite.ListReleasesResponse], error) { | |
| 69 | + | f.mu.Lock() | |
| 70 | + | defer f.mu.Unlock() | |
| 71 | + | f.lists = append(f.lists, proto.Clone(r.Msg).(*ermite.ListReleasesRequest)) | |
| 72 | + | next := "" | |
| 73 | + | if r.Msg.GetPageToken() == "" { | |
| 74 | + | next = "next" | |
| 75 | + | } | |
| 76 | + | return connect.NewResponse(&ermite.ListReleasesResponse{Releases: []*ermite.Release{f.release}, NextPageToken: next}), nil | |
| 77 | + | } | |
| 78 | + | func (f *fakeReleaseAPI) UpdateRelease(_ context.Context, r *connect.Request[ermite.UpdateReleaseRequest]) (*connect.Response[ermite.UpdateReleaseResponse], error) { | |
| 79 | + | f.mu.Lock() | |
| 80 | + | defer f.mu.Unlock() | |
| 81 | + | f.update = proto.Clone(r.Msg).(*ermite.UpdateReleaseRequest) | |
| 82 | + | return connect.NewResponse(&ermite.UpdateReleaseResponse{Release: f.release}), nil | |
| 83 | + | } | |
| 84 | + | func (f *fakeReleaseAPI) DeleteRelease(_ context.Context, r *connect.Request[ermite.DeleteReleaseRequest]) (*connect.Response[ermite.DeleteReleaseResponse], error) { | |
| 85 | + | f.mu.Lock() | |
| 86 | + | defer f.mu.Unlock() | |
| 87 | + | f.deleted = proto.Clone(r.Msg).(*ermite.DeleteReleaseRequest) | |
| 88 | + | return connect.NewResponse(&ermite.DeleteReleaseResponse{}), nil | |
| 89 | + | } | |
| 90 | + | func (f *fakeReleaseAPI) UploadReleaseAsset(_ context.Context, st *connect.ClientStream[ermite.UploadReleaseAssetRequest]) (*connect.Response[ermite.UploadReleaseAssetResponse], error) { | |
| 91 | + | var got []*ermite.UploadReleaseAssetRequest | |
| 92 | + | for st.Receive() { | |
| 93 | + | got = append(got, proto.Clone(st.Msg()).(*ermite.UploadReleaseAssetRequest)) | |
| 94 | + | } | |
| 95 | + | if err := st.Err(); err != nil { | |
| 96 | + | return nil, err | |
| 97 | + | } | |
| 98 | + | f.mu.Lock() | |
| 99 | + | defer f.mu.Unlock() | |
| 100 | + | f.uploads = append(f.uploads, got...) | |
| 101 | + | if f.uploadErr != nil { | |
| 102 | + | return nil, f.uploadErr | |
| 103 | + | } | |
| 104 | + | return connect.NewResponse(&ermite.UploadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: got[0].GetName(), Size: got[0].GetSize()}}), nil | |
| 105 | + | } | |
| 106 | + | func (f *fakeReleaseAPI) DownloadReleaseAsset(_ context.Context, r *connect.Request[ermite.DownloadReleaseAssetRequest], st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error { | |
| 107 | + | f.mu.Lock() | |
| 108 | + | f.offsets = append(f.offsets, r.Msg.GetOffset()) | |
| 109 | + | fn := f.download | |
| 110 | + | f.mu.Unlock() | |
| 111 | + | if fn != nil { | |
| 112 | + | return fn(r.Msg, st) | |
| 113 | + | } | |
| 114 | + | if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: f.release.Assets[0]}); err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: f.data[r.Msg.GetOffset():]}) | |
| 118 | + | } | |
| 119 | + | func releaseFixture() *ermite.Release { | |
| 120 | + | return &ermite.Release{Id: 7, Repo: "proj", Tag: "v1", Title: "First", Notes: "# Notes\n", PublishedAt: ts("2026-10-10T12:00:00Z"), Assets: []*ermite.ReleaseAsset{}} | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestCLIReleaseCreateAndUpload(t *testing.T) { | |
| 124 | + | data := make([]byte, 5<<19) | |
| 125 | + | file := filepath.Join(t.TempDir(), "asset.bin") | |
| 126 | + | require.NoError(t, os.WriteFile(file, data, 0o600)) | |
| 127 | + | notes := filepath.Join(t.TempDir(), "notes.md") | |
| 128 | + | require.NoError(t, os.WriteFile(notes, []byte("## hello"), 0o600)) | |
| 129 | + | f := &fakeReleaseAPI{release: releaseFixture()} | |
| 130 | + | vars := envFor(f.serve(t, "releases")) | |
| 131 | + | res := runCLI(t, vars, "", "release", "create", "proj", "v1", "--title", "Hello", "--notes-file", notes, "--target", "main", "--draft", "--prerelease", "--latest", "--asset", file+"#Binary", "--json").ok(t) | |
| 132 | + | out := decode[jsonRelease](t, res.stdout) | |
| 133 | + | assert.Equal(t, int64(7), out.ID) | |
| 134 | + | require.Len(t, out.Assets, 1) | |
| 135 | + | f.mu.Lock() | |
| 136 | + | defer f.mu.Unlock() | |
| 137 | + | assert.True(t, proto.Equal(&ermite.CreateReleaseRequest{Repo: "proj", Tag: "v1", Title: "Hello", Notes: "## hello", TargetCommitish: "main", Draft: true, Prerelease: true, MakeLatest: true}, f.create)) | |
| 138 | + | require.Len(t, f.uploads, 4) | |
| 139 | + | assert.Equal(t, "Binary", f.uploads[0].GetLabel()) | |
| 140 | + | assert.Equal(t, int64(len(data)), f.uploads[0].GetSize()) | |
| 141 | + | assert.Empty(t, f.uploads[0].GetData()) | |
| 142 | + | assert.False(t, f.uploads[0].GetReplace()) | |
| 143 | + | assert.NotEmpty(t, f.uploads[0].GetContentType()) | |
| 144 | + | for i, n := range []int{1 << 20, 1 << 20, 1 << 19} { | |
| 145 | + | assert.Len(t, f.uploads[i+1].GetData(), n) | |
| 146 | + | assert.Empty(t, f.uploads[i+1].GetRepo()) | |
| 147 | + | assert.Empty(t, f.uploads[i+1].GetName()) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | func TestCLIReleaseUpload(t *testing.T) { | |
| 151 | + | file := filepath.Join(t.TempDir(), "x") | |
| 152 | + | require.NoError(t, os.WriteFile(file, []byte("hello"), 0o600)) | |
| 153 | + | f := &fakeReleaseAPI{release: releaseFixture()} | |
| 154 | + | vars := envFor(f.serve(t, "releases")) | |
| 155 | + | runCLI(t, vars, "", "release", "upload", "proj", "7", file+"#A", "--clobber", "--content-type", "application/test").ok(t) | |
| 156 | + | f.mu.Lock() | |
| 157 | + | assert.True(t, f.uploads[0].GetReplace()) | |
| 158 | + | assert.Equal(t, "application/test", f.uploads[0].GetContentType()) | |
| 159 | + | assert.Equal(t, int64(7), f.gets[0].GetId()) | |
| 160 | + | f.uploadErr = connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("taken")) | |
| 161 | + | f.mu.Unlock() | |
| 162 | + | res := runCLI(t, vars, "", "release", "upload", "proj", "v1", file) | |
| 163 | + | require.ErrorContains(t, res.err, "--clobber") | |
| 164 | + | assert.Contains(t, res.err.Error(), `"x"`) | |
| 165 | + | } | |
| 166 | + | func TestCLIReleaseList(t *testing.T) { | |
| 167 | + | f := &fakeReleaseAPI{release: releaseFixture()} | |
| 168 | + | vars := envFor(f.serve(t, "releases")) | |
| 169 | + | res := runCLI(t, vars, "", "release", "list", "proj", "--limit", "1").ok(t) | |
| 170 | + | assert.Equal(t, "ID TAG TITLE STATE PUBLISHED ASSETS\n7 v1 First published 2026-10-10 12:00 0\n", res.stdout) | |
| 171 | + | res = runCLI(t, vars, "", "release", "list", "proj", "--limit", "2", "--json").ok(t) | |
| 172 | + | assert.Len(t, decode[[]jsonRelease](t, res.stdout), 2) | |
| 173 | + | f.mu.Lock() | |
| 174 | + | defer f.mu.Unlock() | |
| 175 | + | require.Len(t, f.lists, 3) | |
| 176 | + | assert.Equal(t, int32(2), f.lists[1].GetPageSize()) | |
| 177 | + | assert.Equal(t, "next", f.lists[2].GetPageToken()) | |
| 178 | + | assert.Equal(t, int32(1), f.lists[2].GetPageSize()) | |
| 179 | + | } | |
| 180 | + | func TestCLIReleaseViewSelectors(t *testing.T) { | |
| 181 | + | f := &fakeReleaseAPI{release: releaseFixture()} | |
| 182 | + | vars := envFor(f.serve(t, "releases")) | |
| 183 | + | for _, sel := range []string{"v1", "7", "latest"} { | |
| 184 | + | runCLI(t, vars, "", "release", "view", "proj", sel, "--json").ok(t) | |
| 185 | + | } | |
| 186 | + | assert.Equal(t, "# Notes\n", runCLI(t, vars, "", "release", "view", "proj", "latest", "--notes").ok(t).stdout) | |
| 187 | + | res := runCLI(t, vars, "", "release", "view", "proj", "v1").ok(t) | |
| 188 | + | assert.Contains(t, res.stdout, "NAME SIZE TYPE") | |
| 189 | + | assert.Contains(t, res.stdout, "First") | |
| 190 | + | f.mu.Lock() | |
| 191 | + | defer f.mu.Unlock() | |
| 192 | + | assert.Equal(t, "v1", f.gets[0].GetTag()) | |
| 193 | + | assert.Equal(t, int64(7), f.gets[1].GetId()) | |
| 194 | + | assert.True(t, f.gets[2].GetLatest()) | |
| 195 | + | } | |
| 196 | + | func TestCLIReleaseEdit(t *testing.T) { | |
| 197 | + | f := &fakeReleaseAPI{release: releaseFixture()} | |
| 198 | + | vars := envFor(f.serve(t, "releases")) | |
| 199 | + | runCLI(t, vars, "", "release", "edit", "proj", "v1", "--title=", "--notes-file", "-", "--tag", "v2", "--target", "main", "--publish", "--no-prerelease", "--latest=false").ok(t) | |
| 200 | + | f.mu.Lock() | |
| 201 | + | assert.ElementsMatch(t, []string{"title", "notes", "tag", "target_commitish", "draft", "prerelease", "make_latest"}, f.update.GetUpdateMask().GetPaths()) | |
| 202 | + | assert.False(t, f.update.GetDraft()) | |
| 203 | + | assert.False(t, f.update.GetPrerelease()) | |
| 204 | + | assert.False(t, f.update.GetMakeLatest()) | |
| 205 | + | assert.Empty(t, f.update.GetTitle()) | |
| 206 | + | assert.Equal(t, "v2", f.update.GetTag()) | |
| 207 | + | f.mu.Unlock() | |
| 208 | + | runCLI(t, vars, "", "release", "edit", "proj", "7", "--draft", "--prerelease", "--notes", "text").ok(t) | |
| 209 | + | f.mu.Lock() | |
| 210 | + | defer f.mu.Unlock() | |
| 211 | + | assert.True(t, f.update.GetDraft()) | |
| 212 | + | assert.True(t, f.update.GetPrerelease()) | |
| 213 | + | assert.Equal(t, "text", f.update.GetNotes()) | |
| 214 | + | } | |
| 215 | + | func TestCLIReleaseDelete(t *testing.T) { | |
| 216 | + | for _, flags := range [][]string{nil, {"--yes"}} { | |
| 217 | + | f := &fakeReleaseAPI{release: releaseFixture()} | |
| 218 | + | vars := envFor(f.serve(t, "releases")) | |
| 219 | + | res := runCLI(t, vars, "", append([]string{"release", "delete", "proj", "v1"}, flags...)...).ok(t) | |
| 220 | + | assert.Equal(t, "deleted release 7\n", res.stdout) | |
| 221 | + | f.mu.Lock() | |
| 222 | + | assert.Equal(t, int64(7), f.deleted.GetId()) | |
| 223 | + | assert.Equal(t, "proj", f.deleted.GetRepo()) | |
| 224 | + | f.mu.Unlock() | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | func TestCLIReleaseNoFeature(t *testing.T) { | |
| 228 | + | f := &fakeReleaseAPI{release: releaseFixture()} | |
| 229 | + | vars := envFor(f.serve(t)) | |
| 230 | + | for _, args := range [][]string{{"create", "proj", "v1"}, {"list", "proj"}, {"view", "proj", "latest"}, {"edit", "proj", "v1", "--title", "x"}, {"delete", "proj", "v1"}, {"upload", "proj", "v1", "x"}, {"download", "proj", "latest"}} { | |
| 231 | + | res := runCLI(t, vars, "", append([]string{"release"}, args...)...) | |
| 232 | + | require.ErrorIs(t, res.err, errNoReleases) | |
| 233 | + | } | |
| 234 | + | f.mu.Lock() | |
| 235 | + | defer f.mu.Unlock() | |
| 236 | + | assert.Nil(t, f.create) | |
| 237 | + | assert.Empty(t, f.gets) | |
| 238 | + | assert.Nil(t, f.update) | |
| 239 | + | assert.Nil(t, f.deleted) | |
| 240 | + | assert.Empty(t, f.uploads) | |
| 241 | + | } | |
| 242 | + | func TestCLIReleaseFlagValidation(t *testing.T) { | |
| 243 | + | for _, args := range [][]string{{"create", "proj", "v1", "--notes", "x", "--notes-file", "f"}, {"edit", "proj", "v1", "--draft", "--publish"}, {"edit", "proj", "v1", "--prerelease", "--no-prerelease"}, {"edit", "proj", "v1"}, {"list", "proj", "--limit", "0"}, {"download", "proj", "v1", "--pattern", "["}, {"view", "proj", "v1", "--notes", "--json"}} { | |
| 244 | + | require.Error(t, runCLI(t, nil, "", append([]string{"release"}, args...)...).err) | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | func TestCLIReleaseDownload(t *testing.T) { | |
| 248 | + | for _, mode := range []string{"normal", "resume", "twice broken", "digest mismatch", "unsafe", "overwrite", "empty"} { | |
| 249 | + | t.Run(mode, func(t *testing.T) { | |
| 250 | + | data := []byte("hello world") | |
| 251 | + | if mode == "empty" { | |
| 252 | + | data = nil | |
| 253 | + | } | |
| 254 | + | a := &ermite.ReleaseAsset{Id: 9, ReleaseId: 7, Name: "asset.bin", Size: int64(len(data)), Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))} | |
| 255 | + | if mode == "unsafe" { | |
| 256 | + | a.Name = "../escape" | |
| 257 | + | } | |
| 258 | + | if mode == "digest mismatch" { | |
| 259 | + | a.Digest = "sha256:bad" | |
| 260 | + | } | |
| 261 | + | r := releaseFixture() | |
| 262 | + | r.Assets = []*ermite.ReleaseAsset{a} | |
| 263 | + | f := &fakeReleaseAPI{release: r, data: data} | |
| 264 | + | if mode == "resume" || mode == "twice broken" { | |
| 265 | + | f.download = func(req *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error { | |
| 266 | + | if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil { | |
| 267 | + | return err | |
| 268 | + | } | |
| 269 | + | if req.GetOffset() == 0 { | |
| 270 | + | if err := st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[:4]}); err != nil { | |
| 271 | + | return err | |
| 272 | + | } | |
| 273 | + | return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken")) | |
| 274 | + | } | |
| 275 | + | if mode == "twice broken" { | |
| 276 | + | return connect.NewError(connect.CodeUnavailable, fmt.Errorf("broken")) | |
| 277 | + | } | |
| 278 | + | return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data[req.GetOffset():]}) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | dir := t.TempDir() | |
| 282 | + | path := filepath.Join(dir, a.GetName()) | |
| 283 | + | if mode == "overwrite" { | |
| 284 | + | require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600)) | |
| 285 | + | } | |
| 286 | + | res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "latest", "--dir", dir) | |
| 287 | + | switch mode { | |
| 288 | + | case "unsafe", "twice broken", "digest mismatch": | |
| 289 | + | require.Error(t, res.err) | |
| 290 | + | if mode != "unsafe" { | |
| 291 | + | _, err := os.Stat(path) | |
| 292 | + | assert.True(t, os.IsNotExist(err)) | |
| 293 | + | } | |
| 294 | + | case "overwrite": | |
| 295 | + | require.Error(t, res.err) | |
| 296 | + | got, err := os.ReadFile(path) | |
| 297 | + | require.NoError(t, err) | |
| 298 | + | assert.Equal(t, "keep", string(got)) | |
| 299 | + | default: | |
| 300 | + | res.ok(t) | |
| 301 | + | got, err := os.ReadFile(path) | |
| 302 | + | require.NoError(t, err) | |
| 303 | + | assert.Equal(t, string(data), string(got)) | |
| 304 | + | } | |
| 305 | + | f.mu.Lock() | |
| 306 | + | defer f.mu.Unlock() | |
| 307 | + | if mode == "resume" || mode == "twice broken" { | |
| 308 | + | assert.Equal(t, []int64{0, 4}, f.offsets) | |
| 309 | + | } | |
| 310 | + | if mode == "normal" { | |
| 311 | + | assert.True(t, f.gets[0].GetLatest()) | |
| 312 | + | } | |
| 313 | + | }) | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | func TestReleaseAssetSelection(t *testing.T) { | |
| 317 | + | all := []*ermite.ReleaseAsset{{Name: "a.zip"}, {Name: "b.txt"}, {Name: "c.zip"}} | |
| 318 | + | picked, err := pickReleaseAssets(all, []string{"b.txt"}, []string{"*.zip"}) | |
| 319 | + | require.NoError(t, err) | |
| 320 | + | assert.Len(t, picked, 3) | |
| 321 | + | picked, err = pickReleaseAssets(all, nil, []string{"*.zip"}) | |
| 322 | + | require.NoError(t, err) | |
| 323 | + | assert.Len(t, picked, 2) | |
| 324 | + | _, err = pickReleaseAssets(all, []string{"missing"}, nil) | |
| 325 | + | require.Error(t, err) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func TestCLIReleaseDownloadProtocolFailures(t *testing.T) { | |
| 329 | + | for _, mode := range []string{"missing metadata", "wrong metadata", "too much data", "short data", "permission denied"} { | |
| 330 | + | t.Run(mode, func(t *testing.T) { | |
| 331 | + | data := []byte("abc") | |
| 332 | + | a := &ermite.ReleaseAsset{Id: 9, Name: "a", Size: 3, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))} | |
| 333 | + | r := releaseFixture() | |
| 334 | + | r.Assets = []*ermite.ReleaseAsset{a} | |
| 335 | + | f := &fakeReleaseAPI{release: r, download: func(_ *ermite.DownloadReleaseAssetRequest, st *connect.ServerStream[ermite.DownloadReleaseAssetResponse]) error { | |
| 336 | + | switch mode { | |
| 337 | + | case "missing metadata": | |
| 338 | + | return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: data}) | |
| 339 | + | case "wrong metadata": | |
| 340 | + | return st.Send(&ermite.DownloadReleaseAssetResponse{Asset: &ermite.ReleaseAsset{Id: 10}}) | |
| 341 | + | case "permission denied": | |
| 342 | + | return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("denied")) | |
| 343 | + | } | |
| 344 | + | if err := st.Send(&ermite.DownloadReleaseAssetResponse{Asset: a}); err != nil { | |
| 345 | + | return err | |
| 346 | + | } | |
| 347 | + | if mode == "too much data" { | |
| 348 | + | return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("abcd")}) | |
| 349 | + | } | |
| 350 | + | return st.Send(&ermite.DownloadReleaseAssetResponse{Chunk: []byte("a")}) | |
| 351 | + | }} | |
| 352 | + | dir := t.TempDir() | |
| 353 | + | res := runCLI(t, envFor(f.serve(t, "releases")), "", "release", "download", "proj", "v1", "--dir", dir) | |
| 354 | + | require.Error(t, res.err) | |
| 355 | + | _, err := os.Stat(filepath.Join(dir, "a")) | |
| 356 | + | assert.True(t, os.IsNotExist(err)) | |
| 357 | + | f.mu.Lock() | |
| 358 | + | defer f.mu.Unlock() | |
| 359 | + | if mode == "short data" { | |
| 360 | + | assert.Len(t, f.offsets, 2) | |
| 361 | + | } else { | |
| 362 | + | assert.Len(t, f.offsets, 1) | |
| 363 | + | } | |
| 364 | + | }) | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func TestCLIReleaseUploadContentTypeAndEmpty(t *testing.T) { | |
| 369 | + | for _, data := range []string{"hello world", ""} { | |
| 370 | + | t.Run(fmt.Sprintf("size %d", len(data)), func(t *testing.T) { | |
| 371 | + | file := filepath.Join(t.TempDir(), "unknown-extension") | |
| 372 | + | require.NoError(t, os.WriteFile(file, []byte(data), 0o600)) | |
| 373 | + | f := &fakeReleaseAPI{release: releaseFixture()} | |
| 374 | + | vars := envFor(f.serve(t, "releases")) | |
| 375 | + | runCLI(t, vars, "", "release", "upload", "proj", "v1", file).ok(t) | |
| 376 | + | f.mu.Lock() | |
| 377 | + | defer f.mu.Unlock() | |
| 378 | + | assert.Equal(t, int64(len(data)), f.uploads[0].GetSize()) | |
| 379 | + | assert.Equal(t, "text/plain; charset=utf-8", f.uploads[0].GetContentType()) | |
| 380 | + | if data == "" { | |
| 381 | + | assert.Len(t, f.uploads, 1) | |
| 382 | + | } else { | |
| 383 | + | require.Len(t, f.uploads, 2) | |
| 384 | + | assert.Equal(t, data, string(f.uploads[1].GetData())) | |
| 385 | + | } | |
| 386 | + | }) | |
| 387 | + | } | |
| 388 | + | } | |
M internal/app/cli_token.go +9 -0
| @@ -145,6 +145,15 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 145 | 145 | _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope") | |
| 146 | 146 | } | |
| 147 | 147 | } | |
| 148 | + | if slices.Contains(req.Scopes, ermite.Scope_SCOPE_RELEASE_WRITE) { | |
| 149 | + | if err := requireReleases(env.ctx(cmd), c); err != nil { | |
| 150 | + | if !errors.Is(err, errNoReleases) || explicitScope(scopes, ermite.Scope_SCOPE_RELEASE_WRITE) { | |
| 151 | + | return fmt.Errorf("%w (needed for the release:write scope; nothing was created)", err) | |
| 152 | + | } | |
| 153 | + | req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_RELEASE_WRITE }) | |
| 154 | + | _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no releases feature, so the token has no release:write scope") | |
| 155 | + | } | |
| 156 | + | } | |
| 148 | 157 | res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req)) | |
| 149 | 158 | if err != nil { | |
| 150 | 159 | return c.Describe(err) | |
M internal/app/cli_token_test.go +40 -1
| @@ -4,6 +4,7 @@ package app | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| @@ -174,7 +175,9 @@ func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) { | |||
| 174 | 175 | "cli": client.AllScopes(), | |
| 175 | 176 | } { | |
| 176 | 177 | t.Run(preset, func(t *testing.T) { | |
| 177 | - | f := &fakeAPI{getVersion: withIssues, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 178 | + | f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 179 | + | return &ermite.GetVersionResponse{Features: []string{"issues", "releases"}}, nil | |
| 180 | + | }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 178 | 181 | return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil | |
| 179 | 182 | }} | |
| 180 | 183 | runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t) | |
| @@ -273,3 +276,39 @@ func TestTokenCompletion(t *testing.T) { | |||
| 273 | 276 | res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--repo", "").ok(t) | |
| 274 | 277 | assert.Contains(t, res.stdout, "alpha") | |
| 275 | 278 | } | |
| 279 | + | ||
| 280 | + | func TestTokenReleaseWriteFeatureGuard(t *testing.T) { | |
| 281 | + | for _, supported := range []bool{false, true} { | |
| 282 | + | t.Run(fmt.Sprint(supported), func(t *testing.T) { | |
| 283 | + | f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 284 | + | features := []string{"issues"} | |
| 285 | + | if supported { | |
| 286 | + | features = append(features, "releases") | |
| 287 | + | } | |
| 288 | + | return &ermite.GetVersionResponse{Features: features}, nil | |
| 289 | + | }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) { | |
| 290 | + | return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil | |
| 291 | + | }} | |
| 292 | + | vars := envFor(f.serve(t)) | |
| 293 | + | res := runCLI(t, vars, "", "token", "create", "daily", "--preset", "cli").ok(t) | |
| 294 | + | got := lastCreateToken(t, f).GetScopes() | |
| 295 | + | if supported { | |
| 296 | + | assert.Contains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE) | |
| 297 | + | assert.NotContains(t, res.stderr, "no release:write") | |
| 298 | + | } else { | |
| 299 | + | assert.NotContains(t, got, ermite.Scope_SCOPE_RELEASE_WRITE) | |
| 300 | + | assert.Contains(t, res.stderr, "the server has no releases feature, so the token has no release:write scope") | |
| 301 | + | } | |
| 302 | + | before, _ := f.snapshot() | |
| 303 | + | res = runCLI(t, vars, "", "token", "create", "explicit", "--scope", "release:write") | |
| 304 | + | if supported { | |
| 305 | + | res.ok(t) | |
| 306 | + | } else { | |
| 307 | + | require.ErrorIs(t, res.err, errNoReleases) | |
| 308 | + | after, _ := f.snapshot() | |
| 309 | + | assert.Equal(t, len(before)+1, len(after)) | |
| 310 | + | assert.Equal(t, "GetVersion", after[len(after)-1]) | |
| 311 | + | } | |
| 312 | + | }) | |
| 313 | + | } | |
| 314 | + | } | |
M internal/app/cli_version.go +1 -0
| @@ -109,6 +109,7 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 109 | 109 | ||
| 110 | 110 | // featureUse says what breaks without a feature, for the version warning. | |
| 111 | 111 | var featureUse = map[string]string{ | |
| 112 | + | client.FeatureReleases: "`ermite release` needs: the commands will fail on this server, and explicit release:write scopes are refused", | |
| 112 | 113 | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | |
| 113 | 114 | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 114 | 115 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
M internal/app/cli_version_test.go +5 -5
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // allFeatures are the features this client uses. | |
| 20 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys"} | |
| 20 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "releases", "passkeys"} | |
| 21 | 21 | ||
| 22 | 22 | // Every feature the client uses needs a sentence for the version warning. | |
| 23 | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | |
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 45 | 45 | f := versionFake(allFeatures...) | |
| 46 | 46 | url := f.serve(t) | |
| 47 | 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 48 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, passkeys\n", res.stdout) | |
| 48 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, releases, passkeys\n", res.stdout) | |
| 49 | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 50 | 50 | ||
| 51 | 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 52 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 52 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","releases","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 56 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys") | |
| 56 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "releases", "passkeys") | |
| 57 | 57 | url := f.serve(t) | |
| 58 | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 59 | 59 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 61 | 61 | assert.NotContains(t, res.stderr, "repo.update") | |
| 62 | 62 | ||
| 63 | 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 64 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 64 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","releases","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 65 | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 66 | 66 | ||
| 67 | 67 | f = versionFake() | |
M internal/app/forge.go +49 -4
| @@ -17,6 +17,8 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | "github.com/bnema/zerowrap" | |
| 19 | 19 | ||
| 20 | + | "bnema.dev/ermite/internal/adapters/blobdisk" | |
| 21 | + | "bnema.dev/ermite/internal/adapters/blobs3" | |
| 20 | 22 | "bnema.dev/ermite/internal/adapters/ciartifacts" | |
| 21 | 23 | "bnema.dev/ermite/internal/adapters/cilogs" | |
| 22 | 24 | "bnema.dev/ermite/internal/adapters/cisummaries" | |
| @@ -153,7 +155,9 @@ type forge struct { | |||
| 153 | 155 | EventLog *usecase.Events | |
| 154 | 156 | // CI is the CI engine: workflow trigger, planner, runner protocol and the | |
| 155 | 157 | // owner side. It starts with the server and stops with it. | |
| 156 | - | CI *usecase.CI | |
| 158 | + | CI *usecase.CI | |
| 159 | + | Releases *usecase.Releases | |
| 160 | + | Blobs ports.BlobStore | |
| 157 | 161 | // Settings are the secrets, variables and environments of CI. | |
| 158 | 162 | Settings *usecase.CISettings | |
| 159 | 163 | // Mirrors exposes Warning and Badge for the hook server and web composition. | |
| @@ -268,6 +272,13 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 268 | 272 | } | |
| 269 | 273 | log.Info().Str("reader", cfg.GitReader).Msg("git reader selected") | |
| 270 | 274 | f.Browse = usecase.NewBrowse(store, reader) | |
| 275 | + | f.Blobs, err = blobStore(ctx, cfg) | |
| 276 | + | if err != nil { | |
| 277 | + | return nil, err | |
| 278 | + | } | |
| 279 | + | f.Releases = usecase.NewReleases(store.Releases(), f.Blobs, store, reader, git, f.Pushes, f.Bus, clock, ports.ReleaseLimits{AssetMax: cfg.ReleaseAssetMax, AssetsMax: int64(cfg.ReleaseAssetsMax), Quota: cfg.ReleaseQuota}) | |
| 280 | + | f.Pushes.WithReleases(f.Releases) | |
| 281 | + | f.Repos.WithReleases(f.Releases) | |
| 271 | 282 | ||
| 272 | 283 | // Secrets, variables and environments (ADR 0019, CI-3). With a key the | |
| 273 | 284 | // secrets are sealed under it, and the server refuses to start when the | |
| @@ -335,6 +346,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 335 | 346 | OIDC: f.OIDC, | |
| 336 | 347 | } | |
| 337 | 348 | artifacts(&ciCfg, cfg, store) | |
| 349 | + | ciCfg.Blobs = f.Blobs | |
| 350 | + | ciCfg.ReleaseSweeper = f.Releases | |
| 338 | 351 | f.pullRequests(&ciCfg, store, git, committer) | |
| 339 | 352 | if opts.ci != nil { | |
| 340 | 353 | opts.ci(&ciCfg) | |
| @@ -459,6 +472,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 459 | 472 | OIDCKeys: f.OIDC, | |
| 460 | 473 | JobArtifacts: jobArtifacts, | |
| 461 | 474 | Artifacts: artifacts, | |
| 475 | + | Releases: f.Releases, | |
| 462 | 476 | Version: serverVersion(), | |
| 463 | 477 | TrustedProxies: cfg.TrustedProxies, | |
| 464 | 478 | Limiter: limiter, | |
| @@ -487,7 +501,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 487 | 501 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 488 | 502 | } | |
| 489 | 503 | // The REST subset the actions call on github.api_url. | |
| 490 | - | api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient}) | |
| 504 | + | releaseAPI := usecase.NewReleasesGitHub(f.Releases, cfg.Owner, cfg.PublicURL) | |
| 505 | + | api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, Releases: releaseAPI, ReleaseDownload: f.Releases, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient}) | |
| 491 | 506 | for _, p := range githubapi.Patterns { | |
| 492 | 507 | routes = append(routes, httpserver.Route{Pattern: p, Handler: api}) | |
| 493 | 508 | } | |
| @@ -593,16 +608,46 @@ func (f *forge) Close(ctx context.Context) { | |||
| 593 | 608 | } | |
| 594 | 609 | ||
| 595 | 610 | // artifacts gives the CI the stores and limits of the artifacts (CI-4 D3): | |
| 596 | - | // records in the kv store, contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0 | |
| 611 | + | // records in the kv store, legacy contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0 | |
| 597 | 612 | // gives none, and the API then serves no artifact service. | |
| 598 | 613 | func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) { | |
| 599 | 614 | if !cfg.CIArtifacts { | |
| 600 | 615 | return | |
| 601 | 616 | } | |
| 602 | - | ci.Artifacts, ci.ArtifactFiles = store.Artifacts(), ciartifacts.New(ciartifacts.Dir(cfg.DataDir)) | |
| 617 | + | ci.Artifacts = store.Artifacts() | |
| 618 | + | if info, err := os.Stat(ciartifacts.Dir(cfg.DataDir)); err == nil && info.IsDir() { | |
| 619 | + | ci.ArtifactFiles = ciartifacts.New(ciartifacts.Dir(cfg.DataDir)) | |
| 620 | + | } | |
| 603 | 621 | ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax} | |
| 604 | 622 | } | |
| 605 | 623 | ||
| 624 | + | // blobStore selects and probes the content backend before any engine starts. | |
| 625 | + | func blobStore(ctx context.Context, cfg config.Config) (ports.BlobStore, error) { | |
| 626 | + | var blobs ports.BlobStore | |
| 627 | + | backend, location := "disk", blobdisk.Dir(cfg.DataDir) | |
| 628 | + | if cfg.S3Bucket == "" { | |
| 629 | + | blobs = blobdisk.New(location) | |
| 630 | + | } else { | |
| 631 | + | backend, location = "s3", cfg.S3Bucket | |
| 632 | + | store, err := blobs3.New(ctx, blobs3.Config{Bucket: cfg.S3Bucket, Prefix: cfg.S3Prefix, Endpoint: cfg.S3Endpoint, Region: cfg.S3Region, AccessKeyID: cfg.S3AccessKeyID, SecretAccessKey: cfg.S3SecretAccessKey, ForcePathStyle: cfg.S3ForcePathStyle}) | |
| 633 | + | if err != nil { | |
| 634 | + | return nil, fmt.Errorf("%s: %w", config.EnvS3Bucket, err) | |
| 635 | + | } | |
| 636 | + | blobs = store | |
| 637 | + | } | |
| 638 | + | probe, cancel := context.WithTimeout(ctx, 30*time.Second) | |
| 639 | + | defer cancel() | |
| 640 | + | if err := blobs.Check(probe); err != nil { | |
| 641 | + | if backend == "s3" { | |
| 642 | + | return nil, fmt.Errorf("%s: %w", config.EnvS3Bucket, err) | |
| 643 | + | } | |
| 644 | + | return nil, fmt.Errorf("blob store: %w", err) | |
| 645 | + | } | |
| 646 | + | log := zerowrap.FromCtx(ctx) | |
| 647 | + | log.Info().Str("backend", backend).Str("location", location).Msg("blob store ready") | |
| 648 | + | return blobs, nil | |
| 649 | + | } | |
| 650 | + | ||
| 606 | 651 | // pullRequests gives the CI the merge requests, the refs/pull refs and the | |
| 607 | 652 | // identity of the test merge commits (CI-3, D5). | |
| 608 | 653 | func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *gitcli.Git, committer domain.Identity) { | |
A internal/app/release_e2e_test.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package app | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strings" | |
| 14 | + | "sync/atomic" | |
| 15 | + | "testing" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/adapters/blobdisk" | |
| 18 | + | "bnema.dev/ermite/internal/adapters/ciartifacts" | |
| 19 | + | "bnema.dev/ermite/internal/config" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | + | "bnema.dev/ermite/internal/usecase" | |
| 23 | + | "github.com/johannesboyne/gofakes3" | |
| 24 | + | "github.com/johannesboyne/gofakes3/backend/s3mem" | |
| 25 | + | "github.com/stretchr/testify/assert" | |
| 26 | + | "github.com/stretchr/testify/require" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | func TestReleaseBlobForge(t *testing.T) { | |
| 30 | + | for _, backend := range []string{"disk", "s3"} { | |
| 31 | + | t.Run(backend, func(t *testing.T) { | |
| 32 | + | env := map[string]string{} | |
| 33 | + | var admin atomic.Int64 | |
| 34 | + | if backend == "s3" { | |
| 35 | + | mem := s3mem.New() | |
| 36 | + | require.NoError(t, mem.CreateBucket("assets")) | |
| 37 | + | fake := gofakes3.New(mem).Server() | |
| 38 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 39 | + | // Allow only the startup probe/list and object/multipart subset. No bucket administration. | |
| 40 | + | bucket := r.URL.Path == "/assets" || r.URL.Path == "/assets/" | |
| 41 | + | allowed := true | |
| 42 | + | if bucket { | |
| 43 | + | allowed = r.Method == "HEAD" || (r.Method == "GET" && (r.URL.Query().Get("list-type") == "2" || r.URL.Query().Has("uploads"))) | |
| 44 | + | } | |
| 45 | + | for _, key := range []string{"acl", "policy", "tagging", "lifecycle", "versioning", "cors", "notification", "encryption", "website", "logging"} { | |
| 46 | + | if r.URL.Query().Has(key) { | |
| 47 | + | allowed = false | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | if !allowed { | |
| 51 | + | admin.Add(1) | |
| 52 | + | http.Error(w, "unexpected bucket operation", 500) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | fake.ServeHTTP(w, r) | |
| 56 | + | })) | |
| 57 | + | t.Cleanup(srv.Close) | |
| 58 | + | env = map[string]string{config.EnvS3Bucket: "assets", config.EnvS3Endpoint: srv.URL, config.EnvS3Region: "us-east-1", config.EnvS3AccessKeyID: "test-private-id", config.EnvS3SecretAccessKey: "test-private-secret", config.EnvS3ForcePathStyle: "1", config.EnvS3Prefix: "forge/"} | |
| 59 | + | } | |
| 60 | + | h := newHarness(t, harnessOpts{env: env}) | |
| 61 | + | h.publicRepo("demo", "README.md") | |
| 62 | + | rel, err := h.forge.Releases.CreateRelease(h.ctx, domain.Root(), h.name("demo"), ports.CreateReleaseParams{Tag: "v1", TargetCommitish: "main", Title: "Version one"}) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | asset, err := h.forge.Releases.UploadAsset(h.ctx, domain.Root(), h.name("demo"), ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", Size: 7}, strings.NewReader("payload")) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | if backend == "disk" { | |
| 67 | + | data, err := os.ReadFile(filepath.Join(blobdisk.Dir(h.data), "objects", filepath.FromSlash(asset.BlobKey()))) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | assert.Equal(t, "payload", string(data)) | |
| 70 | + | } | |
| 71 | + | req, err := http.NewRequestWithContext(h.ctx, "GET", h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/assets/%d", asset.ID)), nil) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | req.Header.Set("Accept", "application/octet-stream") | |
| 74 | + | req.Header.Set("Range", "bytes=1-3") | |
| 75 | + | res, err := h.srv.Client().Do(req) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | body, err := io.ReadAll(res.Body) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | res.Body.Close() | |
| 80 | + | assert.Equal(t, 206, res.StatusCode) | |
| 81 | + | assert.Equal(t, "ayl", string(body)) | |
| 82 | + | assert.Empty(t, res.Header.Get("Location")) | |
| 83 | + | res, err = h.srv.Client().Get(h.url(fmt.Sprintf("/-/github/api/repos/owner/demo/releases/%d", rel.ID))) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | var obj map[string]any | |
| 86 | + | require.NoError(t, json.NewDecoder(res.Body).Decode(&obj)) | |
| 87 | + | res.Body.Close() | |
| 88 | + | assert.Equal(t, 200, res.StatusCode) | |
| 89 | + | assert.Contains(t, obj["upload_url"], "http://example.test/-/github/api/uploads/") | |
| 90 | + | // New artifacts use the same production backend, not a test-local blob store. | |
| 91 | + | wt := h.workTree("main") | |
| 92 | + | h.mustGit(wt, "fetch", "-q", h.urlAuth(adminToken, "/demo.git"), "main") | |
| 93 | + | h.mustGit(wt, "reset", "--hard", "FETCH_HEAD") | |
| 94 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "demo", wt: wt} | |
| 95 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 96 | + | _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow}) | |
| 97 | + | up := c.jobArtifacts(job) | |
| 98 | + | a := up.upload("dist", []byte("artifact bytes")) | |
| 99 | + | _, data, err := up.download(a.GetId(), 0) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | assert.Equal(t, "artifact bytes", string(data)) | |
| 102 | + | record, err := h.forge.store.Artifacts().GetArtifact(h.ctx, a.GetId()) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | assert.Equal(t, domain.CIArtifactStoreBlob, record.Store) | |
| 105 | + | _, err = h.forge.Blobs.Stat(h.ctx, record.BlobKey()) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | assert.Contains(t, h.logs.String(), "blob store ready") | |
| 108 | + | assert.NotContains(t, h.logs.String(), "test-private-") | |
| 109 | + | assert.Zero(t, admin.Load(), "no bucket-admin operations") | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestBlobStartupProbe(t *testing.T) { | |
| 115 | + | _, err := blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Region: "us-east-1"}) | |
| 116 | + | require.Error(t, err) | |
| 117 | + | assert.Contains(t, err.Error(), config.EnvS3Bucket) | |
| 118 | + | mem := s3mem.New() | |
| 119 | + | srv := httptest.NewServer(gofakes3.New(mem).Server()) | |
| 120 | + | defer srv.Close() | |
| 121 | + | _, err = blobStore(context.Background(), config.Config{S3Bucket: "missing", S3Endpoint: srv.URL, S3Region: "us-east-1", S3AccessKeyID: "private-id", S3SecretAccessKey: "private-secret", S3ForcePathStyle: true}) | |
| 122 | + | require.Error(t, err) | |
| 123 | + | assert.Contains(t, err.Error(), "ERMITE_S3_BUCKET") | |
| 124 | + | assert.Contains(t, err.Error(), "not reachable") | |
| 125 | + | assert.NotContains(t, err.Error(), "private-") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestArtifactLegacyProductionWiring(t *testing.T) { | |
| 129 | + | // Seed the old tree before forge startup, exactly as existing installations do. | |
| 130 | + | data, err := os.MkdirTemp("", "ermite-legacy") | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | t.Cleanup(func() { _ = os.RemoveAll(data) }) | |
| 133 | + | require.NoError(t, prepareDataDir(data)) | |
| 134 | + | require.NoError(t, os.MkdirAll(ciartifacts.Dir(data), 0700)) | |
| 135 | + | h := newHarness(t, harnessOpts{env: map[string]string{config.EnvDataDir: data}}) | |
| 136 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj", wt: h.workTree("main")} | |
| 137 | + | _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 140 | + | _, job, _ := c.newArtifactRun(runner, map[string]string{".github/workflows/ci.yml": artifactWorkflow}) | |
| 141 | + | up := c.jobArtifacts(job) | |
| 142 | + | created := up.upload("old", []byte("legacy bytes")) | |
| 143 | + | store := h.forge.store.Artifacts() | |
| 144 | + | a, err := store.GetArtifact(h.ctx, created.GetId()) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | path := filepath.Join(ciartifacts.Dir(data), fmt.Sprintf("%020d", a.RunID), fmt.Sprintf("%020d", a.ID)) | |
| 147 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) | |
| 148 | + | require.NoError(t, os.WriteFile(path, []byte("legacy bytes"), 0600)) | |
| 149 | + | require.NoError(t, h.forge.Blobs.Delete(h.ctx, a.BlobKey())) | |
| 150 | + | a.Store = domain.CIArtifactStoreLegacy | |
| 151 | + | used, version, err := store.ArtifactUsage(h.ctx, a.Repo) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | require.NoError(t, store.ApplyArtifacts(h.ctx, ports.CIArtifactWrite{Repo: a.Repo, Used: used, UsageVersion: version, Put: []domain.CIArtifact{a}})) | |
| 154 | + | _, body, err := up.download(a.ID, 0) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | assert.Equal(t, "legacy bytes", string(body)) | |
| 157 | + | // A fresh install does not need or create the legacy tree. | |
| 158 | + | fresh := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci = func(cfg *usecase.CIConfig) { assert.Nil(t, cfg.ArtifactFiles) } }) | |
| 159 | + | assert.NoDirExists(t, ciartifacts.Dir(fresh.data)) | |
| 160 | + | } | |
M internal/app/root.go +1 -1
| @@ -31,7 +31,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 31 | 31 | root.AddCommand( | |
| 32 | 32 | newServeCmd(), newHookCmd(), | |
| 33 | 33 | newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 34 | - | newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | |
| 34 | + | newMRCmd(env), newIssueCmd(env), newReleaseCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | |
| 35 | 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | |
| 36 | 36 | ) | |
| 37 | 37 | return root | |
M internal/app/testdata/web/blob-binary.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -358,31 +362,37 @@ | |||
| 358 | 362 | <span class="k">section</span> | |
| 359 | 363 | </a> | |
| 360 | 364 | </li> | |
| 361 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 365 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 366 | + | <a href="/files/releases" tabindex="-1"> | |
| 367 | + | <span class="lbl">releases</span> | |
| 368 | + | <span class="k">section</span> | |
| 369 | + | </a> | |
| 370 | + | </li> | |
| 371 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 362 | 372 | <a href="/files/actions" tabindex="-1"> | |
| 363 | 373 | <span class="lbl">actions</span> | |
| 364 | 374 | <span class="k">section</span> | |
| 365 | 375 | </a> | |
| 366 | 376 | </li> | |
| 367 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 377 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 368 | 378 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 369 | 379 | <span class="lbl">feature/x</span> | |
| 370 | 380 | <span class="k">branch</span> | |
| 371 | 381 | </a> | |
| 372 | 382 | </li> | |
| 373 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 383 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 374 | 384 | <a href="/files/tree/main" tabindex="-1"> | |
| 375 | 385 | <span class="lbl">main</span> | |
| 376 | 386 | <span class="k">branch</span> | |
| 377 | 387 | </a> | |
| 378 | 388 | </li> | |
| 379 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 389 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 380 | 390 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 381 | 391 | <span class="lbl">v1.0</span> | |
| 382 | 392 | <span class="k">tag</span> | |
| 383 | 393 | </a> | |
| 384 | 394 | </li> | |
| 385 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 395 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 386 | 396 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 387 | 397 | <span class="lbl">turn single-key shortcuts off</span> | |
| 388 | 398 | <span class="k">action</span> | |
M internal/app/testdata/web/blob-code.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -428,31 +432,37 @@ | |||
| 428 | 432 | <span class="k">section</span> | |
| 429 | 433 | </a> | |
| 430 | 434 | </li> | |
| 431 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 435 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 436 | + | <a href="/files/releases" tabindex="-1"> | |
| 437 | + | <span class="lbl">releases</span> | |
| 438 | + | <span class="k">section</span> | |
| 439 | + | </a> | |
| 440 | + | </li> | |
| 441 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 432 | 442 | <a href="/files/actions" tabindex="-1"> | |
| 433 | 443 | <span class="lbl">actions</span> | |
| 434 | 444 | <span class="k">section</span> | |
| 435 | 445 | </a> | |
| 436 | 446 | </li> | |
| 437 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 447 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 438 | 448 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 439 | 449 | <span class="lbl">feature/x</span> | |
| 440 | 450 | <span class="k">branch</span> | |
| 441 | 451 | </a> | |
| 442 | 452 | </li> | |
| 443 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 453 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 444 | 454 | <a href="/files/tree/main" tabindex="-1"> | |
| 445 | 455 | <span class="lbl">main</span> | |
| 446 | 456 | <span class="k">branch</span> | |
| 447 | 457 | </a> | |
| 448 | 458 | </li> | |
| 449 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 459 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 450 | 460 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 451 | 461 | <span class="lbl">v1.0</span> | |
| 452 | 462 | <span class="k">tag</span> | |
| 453 | 463 | </a> | |
| 454 | 464 | </li> | |
| 455 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 465 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 456 | 466 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 457 | 467 | <span class="lbl">turn single-key shortcuts off</span> | |
| 458 | 468 | <span class="k">action</span> | |
M internal/app/testdata/web/blob-empty.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -358,31 +362,37 @@ | |||
| 358 | 362 | <span class="k">section</span> | |
| 359 | 363 | </a> | |
| 360 | 364 | </li> | |
| 361 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 365 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 366 | + | <a href="/files/releases" tabindex="-1"> | |
| 367 | + | <span class="lbl">releases</span> | |
| 368 | + | <span class="k">section</span> | |
| 369 | + | </a> | |
| 370 | + | </li> | |
| 371 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 362 | 372 | <a href="/files/actions" tabindex="-1"> | |
| 363 | 373 | <span class="lbl">actions</span> | |
| 364 | 374 | <span class="k">section</span> | |
| 365 | 375 | </a> | |
| 366 | 376 | </li> | |
| 367 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 377 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 368 | 378 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 369 | 379 | <span class="lbl">feature/x</span> | |
| 370 | 380 | <span class="k">branch</span> | |
| 371 | 381 | </a> | |
| 372 | 382 | </li> | |
| 373 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 383 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 374 | 384 | <a href="/files/tree/main" tabindex="-1"> | |
| 375 | 385 | <span class="lbl">main</span> | |
| 376 | 386 | <span class="k">branch</span> | |
| 377 | 387 | </a> | |
| 378 | 388 | </li> | |
| 379 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 389 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 380 | 390 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 381 | 391 | <span class="lbl">v1.0</span> | |
| 382 | 392 | <span class="k">tag</span> | |
| 383 | 393 | </a> | |
| 384 | 394 | </li> | |
| 385 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 395 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 386 | 396 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 387 | 397 | <span class="lbl">turn single-key shortcuts off</span> | |
| 388 | 398 | <span class="k">action</span> | |
M internal/app/testdata/web/blob-image.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -360,31 +364,37 @@ | |||
| 360 | 364 | <span class="k">section</span> | |
| 361 | 365 | </a> | |
| 362 | 366 | </li> | |
| 363 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 367 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 368 | + | <a href="/files/releases" tabindex="-1"> | |
| 369 | + | <span class="lbl">releases</span> | |
| 370 | + | <span class="k">section</span> | |
| 371 | + | </a> | |
| 372 | + | </li> | |
| 373 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 364 | 374 | <a href="/files/actions" tabindex="-1"> | |
| 365 | 375 | <span class="lbl">actions</span> | |
| 366 | 376 | <span class="k">section</span> | |
| 367 | 377 | </a> | |
| 368 | 378 | </li> | |
| 369 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 379 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 370 | 380 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 371 | 381 | <span class="lbl">feature/x</span> | |
| 372 | 382 | <span class="k">branch</span> | |
| 373 | 383 | </a> | |
| 374 | 384 | </li> | |
| 375 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 385 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 376 | 386 | <a href="/files/tree/main" tabindex="-1"> | |
| 377 | 387 | <span class="lbl">main</span> | |
| 378 | 388 | <span class="k">branch</span> | |
| 379 | 389 | </a> | |
| 380 | 390 | </li> | |
| 381 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 391 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 382 | 392 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 383 | 393 | <span class="lbl">v1.0</span> | |
| 384 | 394 | <span class="k">tag</span> | |
| 385 | 395 | </a> | |
| 386 | 396 | </li> | |
| 387 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 397 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 388 | 398 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 389 | 399 | <span class="lbl">turn single-key shortcuts off</span> | |
| 390 | 400 | <span class="k">action</span> | |
M internal/app/testdata/web/blob-markdown.html +15 -5
| @@ -96,6 +96,10 @@ | |||
| 96 | 96 | </a> | |
| 97 | 97 | </li> | |
| 98 | 98 | <li> | |
| 99 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 100 | + | </a> | |
| 101 | + | </li> | |
| 102 | + | <li> | |
| 99 | 103 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 100 | 104 | </a> | |
| 101 | 105 | </li> | |
| @@ -425,31 +429,37 @@ | |||
| 425 | 429 | <span class="k">section</span> | |
| 426 | 430 | </a> | |
| 427 | 431 | </li> | |
| 428 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 432 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 433 | + | <a href="/files/releases" tabindex="-1"> | |
| 434 | + | <span class="lbl">releases</span> | |
| 435 | + | <span class="k">section</span> | |
| 436 | + | </a> | |
| 437 | + | </li> | |
| 438 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 429 | 439 | <a href="/files/actions" tabindex="-1"> | |
| 430 | 440 | <span class="lbl">actions</span> | |
| 431 | 441 | <span class="k">section</span> | |
| 432 | 442 | </a> | |
| 433 | 443 | </li> | |
| 434 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 444 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 435 | 445 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 436 | 446 | <span class="lbl">feature/x</span> | |
| 437 | 447 | <span class="k">branch</span> | |
| 438 | 448 | </a> | |
| 439 | 449 | </li> | |
| 440 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 450 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 441 | 451 | <a href="/files/tree/main" tabindex="-1"> | |
| 442 | 452 | <span class="lbl">main</span> | |
| 443 | 453 | <span class="k">branch</span> | |
| 444 | 454 | </a> | |
| 445 | 455 | </li> | |
| 446 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 456 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 447 | 457 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 448 | 458 | <span class="lbl">v1.0</span> | |
| 449 | 459 | <span class="k">tag</span> | |
| 450 | 460 | </a> | |
| 451 | 461 | </li> | |
| 452 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 462 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 453 | 463 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 454 | 464 | <span class="lbl">turn single-key shortcuts off</span> | |
| 455 | 465 | <span class="k">action</span> | |
M internal/app/testdata/web/blob-svg.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -383,31 +387,37 @@ | |||
| 383 | 387 | <span class="k">section</span> | |
| 384 | 388 | </a> | |
| 385 | 389 | </li> | |
| 386 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 390 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 391 | + | <a href="/files/releases" tabindex="-1"> | |
| 392 | + | <span class="lbl">releases</span> | |
| 393 | + | <span class="k">section</span> | |
| 394 | + | </a> | |
| 395 | + | </li> | |
| 396 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 387 | 397 | <a href="/files/actions" tabindex="-1"> | |
| 388 | 398 | <span class="lbl">actions</span> | |
| 389 | 399 | <span class="k">section</span> | |
| 390 | 400 | </a> | |
| 391 | 401 | </li> | |
| 392 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 402 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 393 | 403 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 394 | 404 | <span class="lbl">feature/x</span> | |
| 395 | 405 | <span class="k">branch</span> | |
| 396 | 406 | </a> | |
| 397 | 407 | </li> | |
| 398 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 408 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 399 | 409 | <a href="/files/tree/main" tabindex="-1"> | |
| 400 | 410 | <span class="lbl">main</span> | |
| 401 | 411 | <span class="k">branch</span> | |
| 402 | 412 | </a> | |
| 403 | 413 | </li> | |
| 404 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 414 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 405 | 415 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 406 | 416 | <span class="lbl">v1.0</span> | |
| 407 | 417 | <span class="k">tag</span> | |
| 408 | 418 | </a> | |
| 409 | 419 | </li> | |
| 410 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 420 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 411 | 421 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 412 | 422 | <span class="lbl">turn single-key shortcuts off</span> | |
| 413 | 423 | <span class="k">action</span> | |
M internal/app/testdata/web/blob-text.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -378,31 +382,37 @@ | |||
| 378 | 382 | <span class="k">section</span> | |
| 379 | 383 | </a> | |
| 380 | 384 | </li> | |
| 381 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 385 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 386 | + | <a href="/files/releases" tabindex="-1"> | |
| 387 | + | <span class="lbl">releases</span> | |
| 388 | + | <span class="k">section</span> | |
| 389 | + | </a> | |
| 390 | + | </li> | |
| 391 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 382 | 392 | <a href="/files/actions" tabindex="-1"> | |
| 383 | 393 | <span class="lbl">actions</span> | |
| 384 | 394 | <span class="k">section</span> | |
| 385 | 395 | </a> | |
| 386 | 396 | </li> | |
| 387 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 397 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 388 | 398 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 389 | 399 | <span class="lbl">feature/x</span> | |
| 390 | 400 | <span class="k">branch</span> | |
| 391 | 401 | </a> | |
| 392 | 402 | </li> | |
| 393 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 403 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 394 | 404 | <a href="/files/tree/main" tabindex="-1"> | |
| 395 | 405 | <span class="lbl">main</span> | |
| 396 | 406 | <span class="k">branch</span> | |
| 397 | 407 | </a> | |
| 398 | 408 | </li> | |
| 399 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 409 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 400 | 410 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 401 | 411 | <span class="lbl">v1.0</span> | |
| 402 | 412 | <span class="k">tag</span> | |
| 403 | 413 | </a> | |
| 404 | 414 | </li> | |
| 405 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 415 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 406 | 416 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 407 | 417 | <span class="lbl">turn single-key shortcuts off</span> | |
| 408 | 418 | <span class="k">action</span> | |
M internal/app/testdata/web/blob-too-large.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -358,31 +362,37 @@ | |||
| 358 | 362 | <span class="k">section</span> | |
| 359 | 363 | </a> | |
| 360 | 364 | </li> | |
| 361 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 365 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 366 | + | <a href="/files/releases" tabindex="-1"> | |
| 367 | + | <span class="lbl">releases</span> | |
| 368 | + | <span class="k">section</span> | |
| 369 | + | </a> | |
| 370 | + | </li> | |
| 371 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 362 | 372 | <a href="/files/actions" tabindex="-1"> | |
| 363 | 373 | <span class="lbl">actions</span> | |
| 364 | 374 | <span class="k">section</span> | |
| 365 | 375 | </a> | |
| 366 | 376 | </li> | |
| 367 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 377 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 368 | 378 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 369 | 379 | <span class="lbl">feature/x</span> | |
| 370 | 380 | <span class="k">branch</span> | |
| 371 | 381 | </a> | |
| 372 | 382 | </li> | |
| 373 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 383 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 374 | 384 | <a href="/files/tree/main" tabindex="-1"> | |
| 375 | 385 | <span class="lbl">main</span> | |
| 376 | 386 | <span class="k">branch</span> | |
| 377 | 387 | </a> | |
| 378 | 388 | </li> | |
| 379 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 389 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 380 | 390 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 381 | 391 | <span class="lbl">v1.0</span> | |
| 382 | 392 | <span class="k">tag</span> | |
| 383 | 393 | </a> | |
| 384 | 394 | </li> | |
| 385 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 395 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 386 | 396 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 387 | 397 | <span class="lbl">turn single-key shortcuts off</span> | |
| 388 | 398 | <span class="k">action</span> | |
M internal/app/testdata/web/blob-trojan.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -425,31 +429,37 @@ | |||
| 425 | 429 | <span class="k">section</span> | |
| 426 | 430 | </a> | |
| 427 | 431 | </li> | |
| 428 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 432 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 433 | + | <a href="/files/releases" tabindex="-1"> | |
| 434 | + | <span class="lbl">releases</span> | |
| 435 | + | <span class="k">section</span> | |
| 436 | + | </a> | |
| 437 | + | </li> | |
| 438 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 429 | 439 | <a href="/files/actions" tabindex="-1"> | |
| 430 | 440 | <span class="lbl">actions</span> | |
| 431 | 441 | <span class="k">section</span> | |
| 432 | 442 | </a> | |
| 433 | 443 | </li> | |
| 434 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 444 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 435 | 445 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 436 | 446 | <span class="lbl">feature/x</span> | |
| 437 | 447 | <span class="k">branch</span> | |
| 438 | 448 | </a> | |
| 439 | 449 | </li> | |
| 440 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 450 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 441 | 451 | <a href="/files/tree/main" tabindex="-1"> | |
| 442 | 452 | <span class="lbl">main</span> | |
| 443 | 453 | <span class="k">branch</span> | |
| 444 | 454 | </a> | |
| 445 | 455 | </li> | |
| 446 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 456 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 447 | 457 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 448 | 458 | <span class="lbl">v1.0</span> | |
| 449 | 459 | <span class="k">tag</span> | |
| 450 | 460 | </a> | |
| 451 | 461 | </li> | |
| 452 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 462 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 453 | 463 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 454 | 464 | <span class="lbl">turn single-key shortcuts off</span> | |
| 455 | 465 | <span class="k">action</span> | |
M internal/app/testdata/web/commit-big.html +15 -5
| @@ -92,6 +92,10 @@ | |||
| 92 | 92 | </a> | |
| 93 | 93 | </li> | |
| 94 | 94 | <li> | |
| 95 | + | <a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 96 | + | </a> | |
| 97 | + | </li> | |
| 98 | + | <li> | |
| 95 | 99 | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 96 | 100 | </a> | |
| 97 | 101 | </li> | |
| @@ -410,31 +414,37 @@ | |||
| 410 | 414 | <span class="k">section</span> | |
| 411 | 415 | </a> | |
| 412 | 416 | </li> | |
| 413 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 417 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 418 | + | <a href="/diffs/releases" tabindex="-1"> | |
| 419 | + | <span class="lbl">releases</span> | |
| 420 | + | <span class="k">section</span> | |
| 421 | + | </a> | |
| 422 | + | </li> | |
| 423 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 414 | 424 | <a href="/diffs/actions" tabindex="-1"> | |
| 415 | 425 | <span class="lbl">actions</span> | |
| 416 | 426 | <span class="k">section</span> | |
| 417 | 427 | </a> | |
| 418 | 428 | </li> | |
| 419 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 429 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 420 | 430 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 421 | 431 | <span class="lbl">main</span> | |
| 422 | 432 | <span class="k">branch</span> | |
| 423 | 433 | </a> | |
| 424 | 434 | </li> | |
| 425 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch side"> | |
| 435 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch side"> | |
| 426 | 436 | <a href="/diffs/tree/side" tabindex="-1"> | |
| 427 | 437 | <span class="lbl">side</span> | |
| 428 | 438 | <span class="k">branch</span> | |
| 429 | 439 | </a> | |
| 430 | 440 | </li> | |
| 431 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 441 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 432 | 442 | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 433 | 443 | <span class="lbl">v1.0.0</span> | |
| 434 | 444 | <span class="k">tag</span> | |
| 435 | 445 | </a> | |
| 436 | 446 | </li> | |
| 437 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 447 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 438 | 448 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 439 | 449 | <span class="lbl">turn single-key shortcuts off</span> | |
| 440 | 450 | <span class="k">action</span> | |
M internal/app/testdata/web/commit-binary.html +15 -5
| @@ -92,6 +92,10 @@ | |||
| 92 | 92 | </a> | |
| 93 | 93 | </li> | |
| 94 | 94 | <li> | |
| 95 | + | <a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 96 | + | </a> | |
| 97 | + | </li> | |
| 98 | + | <li> | |
| 95 | 99 | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 96 | 100 | </a> | |
| 97 | 101 | </li> | |
| @@ -401,31 +405,37 @@ | |||
| 401 | 405 | <span class="k">section</span> | |
| 402 | 406 | </a> | |
| 403 | 407 | </li> | |
| 404 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 408 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 409 | + | <a href="/diffs/releases" tabindex="-1"> | |
| 410 | + | <span class="lbl">releases</span> | |
| 411 | + | <span class="k">section</span> | |
| 412 | + | </a> | |
| 413 | + | </li> | |
| 414 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 405 | 415 | <a href="/diffs/actions" tabindex="-1"> | |
| 406 | 416 | <span class="lbl">actions</span> | |
| 407 | 417 | <span class="k">section</span> | |
| 408 | 418 | </a> | |
| 409 | 419 | </li> | |
| 410 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 420 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 411 | 421 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 412 | 422 | <span class="lbl">main</span> | |
| 413 | 423 | <span class="k">branch</span> | |
| 414 | 424 | </a> | |
| 415 | 425 | </li> | |
| 416 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch side"> | |
| 426 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch side"> | |
| 417 | 427 | <a href="/diffs/tree/side" tabindex="-1"> | |
| 418 | 428 | <span class="lbl">side</span> | |
| 419 | 429 | <span class="k">branch</span> | |
| 420 | 430 | </a> | |
| 421 | 431 | </li> | |
| 422 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 432 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 423 | 433 | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 424 | 434 | <span class="lbl">v1.0.0</span> | |
| 425 | 435 | <span class="k">tag</span> | |
| 426 | 436 | </a> | |
| 427 | 437 | </li> | |
| 428 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 438 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 429 | 439 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 430 | 440 | <span class="lbl">turn single-key shortcuts off</span> | |
| 431 | 441 | <span class="k">action</span> | |
M internal/app/testdata/web/commit-merge.html +15 -5
| @@ -92,6 +92,10 @@ | |||
| 92 | 92 | </a> | |
| 93 | 93 | </li> | |
| 94 | 94 | <li> | |
| 95 | + | <a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 96 | + | </a> | |
| 97 | + | </li> | |
| 98 | + | <li> | |
| 95 | 99 | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 96 | 100 | </a> | |
| 97 | 101 | </li> | |
| @@ -443,31 +447,37 @@ | |||
| 443 | 447 | <span class="k">section</span> | |
| 444 | 448 | </a> | |
| 445 | 449 | </li> | |
| 446 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 450 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 451 | + | <a href="/diffs/releases" tabindex="-1"> | |
| 452 | + | <span class="lbl">releases</span> | |
| 453 | + | <span class="k">section</span> | |
| 454 | + | </a> | |
| 455 | + | </li> | |
| 456 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 447 | 457 | <a href="/diffs/actions" tabindex="-1"> | |
| 448 | 458 | <span class="lbl">actions</span> | |
| 449 | 459 | <span class="k">section</span> | |
| 450 | 460 | </a> | |
| 451 | 461 | </li> | |
| 452 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 462 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 453 | 463 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 454 | 464 | <span class="lbl">main</span> | |
| 455 | 465 | <span class="k">branch</span> | |
| 456 | 466 | </a> | |
| 457 | 467 | </li> | |
| 458 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch side"> | |
| 468 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch side"> | |
| 459 | 469 | <a href="/diffs/tree/side" tabindex="-1"> | |
| 460 | 470 | <span class="lbl">side</span> | |
| 461 | 471 | <span class="k">branch</span> | |
| 462 | 472 | </a> | |
| 463 | 473 | </li> | |
| 464 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 474 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 465 | 475 | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 466 | 476 | <span class="lbl">v1.0.0</span> | |
| 467 | 477 | <span class="k">tag</span> | |
| 468 | 478 | </a> | |
| 469 | 479 | </li> | |
| 470 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 480 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 471 | 481 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 472 | 482 | <span class="lbl">turn single-key shortcuts off</span> | |
| 473 | 483 | <span class="k">action</span> | |
M internal/app/testdata/web/commit-modify-split.html +15 -5
| @@ -92,6 +92,10 @@ | |||
| 92 | 92 | </a> | |
| 93 | 93 | </li> | |
| 94 | 94 | <li> | |
| 95 | + | <a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 96 | + | </a> | |
| 97 | + | </li> | |
| 98 | + | <li> | |
| 95 | 99 | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 96 | 100 | </a> | |
| 97 | 101 | </li> | |
| @@ -566,31 +570,37 @@ And <b>html</b> & "quotes".</pre> | |||
| 566 | 570 | <span class="k">section</span> | |
| 567 | 571 | </a> | |
| 568 | 572 | </li> | |
| 569 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 573 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 574 | + | <a href="/diffs/releases" tabindex="-1"> | |
| 575 | + | <span class="lbl">releases</span> | |
| 576 | + | <span class="k">section</span> | |
| 577 | + | </a> | |
| 578 | + | </li> | |
| 579 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 570 | 580 | <a href="/diffs/actions" tabindex="-1"> | |
| 571 | 581 | <span class="lbl">actions</span> | |
| 572 | 582 | <span class="k">section</span> | |
| 573 | 583 | </a> | |
| 574 | 584 | </li> | |
| 575 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 585 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 576 | 586 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 577 | 587 | <span class="lbl">main</span> | |
| 578 | 588 | <span class="k">branch</span> | |
| 579 | 589 | </a> | |
| 580 | 590 | </li> | |
| 581 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch side"> | |
| 591 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch side"> | |
| 582 | 592 | <a href="/diffs/tree/side" tabindex="-1"> | |
| 583 | 593 | <span class="lbl">side</span> | |
| 584 | 594 | <span class="k">branch</span> | |
| 585 | 595 | </a> | |
| 586 | 596 | </li> | |
| 587 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 597 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 588 | 598 | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 589 | 599 | <span class="lbl">v1.0.0</span> | |
| 590 | 600 | <span class="k">tag</span> | |
| 591 | 601 | </a> | |
| 592 | 602 | </li> | |
| 593 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 603 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 594 | 604 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 595 | 605 | <span class="lbl">turn single-key shortcuts off</span> | |
| 596 | 606 | <span class="k">action</span> | |
M internal/app/testdata/web/commit-modify.html +15 -5
| @@ -92,6 +92,10 @@ | |||
| 92 | 92 | </a> | |
| 93 | 93 | </li> | |
| 94 | 94 | <li> | |
| 95 | + | <a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 96 | + | </a> | |
| 97 | + | </li> | |
| 98 | + | <li> | |
| 95 | 99 | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 96 | 100 | </a> | |
| 97 | 101 | </li> | |
| @@ -566,31 +570,37 @@ And <b>html</b> & "quotes".</pre> | |||
| 566 | 570 | <span class="k">section</span> | |
| 567 | 571 | </a> | |
| 568 | 572 | </li> | |
| 569 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 573 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 574 | + | <a href="/diffs/releases" tabindex="-1"> | |
| 575 | + | <span class="lbl">releases</span> | |
| 576 | + | <span class="k">section</span> | |
| 577 | + | </a> | |
| 578 | + | </li> | |
| 579 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 570 | 580 | <a href="/diffs/actions" tabindex="-1"> | |
| 571 | 581 | <span class="lbl">actions</span> | |
| 572 | 582 | <span class="k">section</span> | |
| 573 | 583 | </a> | |
| 574 | 584 | </li> | |
| 575 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 585 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 576 | 586 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 577 | 587 | <span class="lbl">main</span> | |
| 578 | 588 | <span class="k">branch</span> | |
| 579 | 589 | </a> | |
| 580 | 590 | </li> | |
| 581 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch side"> | |
| 591 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch side"> | |
| 582 | 592 | <a href="/diffs/tree/side" tabindex="-1"> | |
| 583 | 593 | <span class="lbl">side</span> | |
| 584 | 594 | <span class="k">branch</span> | |
| 585 | 595 | </a> | |
| 586 | 596 | </li> | |
| 587 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 597 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 588 | 598 | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 589 | 599 | <span class="lbl">v1.0.0</span> | |
| 590 | 600 | <span class="k">tag</span> | |
| 591 | 601 | </a> | |
| 592 | 602 | </li> | |
| 593 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 603 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 594 | 604 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 595 | 605 | <span class="lbl">turn single-key shortcuts off</span> | |
| 596 | 606 | <span class="k">action</span> | |
M internal/app/testdata/web/commit-rename.html +15 -5
| @@ -92,6 +92,10 @@ | |||
| 92 | 92 | </a> | |
| 93 | 93 | </li> | |
| 94 | 94 | <li> | |
| 95 | + | <a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 96 | + | </a> | |
| 97 | + | </li> | |
| 98 | + | <li> | |
| 95 | 99 | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 96 | 100 | </a> | |
| 97 | 101 | </li> | |
| @@ -470,31 +474,37 @@ | |||
| 470 | 474 | <span class="k">section</span> | |
| 471 | 475 | </a> | |
| 472 | 476 | </li> | |
| 473 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 477 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 478 | + | <a href="/diffs/releases" tabindex="-1"> | |
| 479 | + | <span class="lbl">releases</span> | |
| 480 | + | <span class="k">section</span> | |
| 481 | + | </a> | |
| 482 | + | </li> | |
| 483 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 474 | 484 | <a href="/diffs/actions" tabindex="-1"> | |
| 475 | 485 | <span class="lbl">actions</span> | |
| 476 | 486 | <span class="k">section</span> | |
| 477 | 487 | </a> | |
| 478 | 488 | </li> | |
| 479 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 489 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 480 | 490 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 481 | 491 | <span class="lbl">main</span> | |
| 482 | 492 | <span class="k">branch</span> | |
| 483 | 493 | </a> | |
| 484 | 494 | </li> | |
| 485 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch side"> | |
| 495 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch side"> | |
| 486 | 496 | <a href="/diffs/tree/side" tabindex="-1"> | |
| 487 | 497 | <span class="lbl">side</span> | |
| 488 | 498 | <span class="k">branch</span> | |
| 489 | 499 | </a> | |
| 490 | 500 | </li> | |
| 491 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 501 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 492 | 502 | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 493 | 503 | <span class="lbl">v1.0.0</span> | |
| 494 | 504 | <span class="k">tag</span> | |
| 495 | 505 | </a> | |
| 496 | 506 | </li> | |
| 497 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 507 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 498 | 508 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 499 | 509 | <span class="lbl">turn single-key shortcuts off</span> | |
| 500 | 510 | <span class="k">action</span> | |
M internal/app/testdata/web/commit-root.html +15 -5
| @@ -92,6 +92,10 @@ | |||
| 92 | 92 | </a> | |
| 93 | 93 | </li> | |
| 94 | 94 | <li> | |
| 95 | + | <a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 96 | + | </a> | |
| 97 | + | </li> | |
| 98 | + | <li> | |
| 95 | 99 | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 96 | 100 | </a> | |
| 97 | 101 | </li> | |
| @@ -789,31 +793,37 @@ | |||
| 789 | 793 | <span class="k">section</span> | |
| 790 | 794 | </a> | |
| 791 | 795 | </li> | |
| 792 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 796 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 797 | + | <a href="/diffs/releases" tabindex="-1"> | |
| 798 | + | <span class="lbl">releases</span> | |
| 799 | + | <span class="k">section</span> | |
| 800 | + | </a> | |
| 801 | + | </li> | |
| 802 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 793 | 803 | <a href="/diffs/actions" tabindex="-1"> | |
| 794 | 804 | <span class="lbl">actions</span> | |
| 795 | 805 | <span class="k">section</span> | |
| 796 | 806 | </a> | |
| 797 | 807 | </li> | |
| 798 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 808 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 799 | 809 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 800 | 810 | <span class="lbl">main</span> | |
| 801 | 811 | <span class="k">branch</span> | |
| 802 | 812 | </a> | |
| 803 | 813 | </li> | |
| 804 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch side"> | |
| 814 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch side"> | |
| 805 | 815 | <a href="/diffs/tree/side" tabindex="-1"> | |
| 806 | 816 | <span class="lbl">side</span> | |
| 807 | 817 | <span class="k">branch</span> | |
| 808 | 818 | </a> | |
| 809 | 819 | </li> | |
| 810 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 820 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 811 | 821 | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 812 | 822 | <span class="lbl">v1.0.0</span> | |
| 813 | 823 | <span class="k">tag</span> | |
| 814 | 824 | </a> | |
| 815 | 825 | </li> | |
| 816 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 826 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 817 | 827 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 818 | 828 | <span class="lbl">turn single-key shortcuts off</span> | |
| 819 | 829 | <span class="k">action</span> | |
M internal/app/testdata/web/commit-trojan.html +15 -5
| @@ -92,6 +92,10 @@ | |||
| 92 | 92 | </a> | |
| 93 | 93 | </li> | |
| 94 | 94 | <li> | |
| 95 | + | <a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 96 | + | </a> | |
| 97 | + | </li> | |
| 98 | + | <li> | |
| 95 | 99 | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 96 | 100 | </a> | |
| 97 | 101 | </li> | |
| @@ -618,31 +622,37 @@ | |||
| 618 | 622 | <span class="k">section</span> | |
| 619 | 623 | </a> | |
| 620 | 624 | </li> | |
| 621 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 625 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 626 | + | <a href="/diffs/releases" tabindex="-1"> | |
| 627 | + | <span class="lbl">releases</span> | |
| 628 | + | <span class="k">section</span> | |
| 629 | + | </a> | |
| 630 | + | </li> | |
| 631 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 622 | 632 | <a href="/diffs/actions" tabindex="-1"> | |
| 623 | 633 | <span class="lbl">actions</span> | |
| 624 | 634 | <span class="k">section</span> | |
| 625 | 635 | </a> | |
| 626 | 636 | </li> | |
| 627 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 637 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 628 | 638 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 629 | 639 | <span class="lbl">main</span> | |
| 630 | 640 | <span class="k">branch</span> | |
| 631 | 641 | </a> | |
| 632 | 642 | </li> | |
| 633 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch side"> | |
| 643 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch side"> | |
| 634 | 644 | <a href="/diffs/tree/side" tabindex="-1"> | |
| 635 | 645 | <span class="lbl">side</span> | |
| 636 | 646 | <span class="k">branch</span> | |
| 637 | 647 | </a> | |
| 638 | 648 | </li> | |
| 639 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 649 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 640 | 650 | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 641 | 651 | <span class="lbl">v1.0.0</span> | |
| 642 | 652 | <span class="k">tag</span> | |
| 643 | 653 | </a> | |
| 644 | 654 | </li> | |
| 645 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 655 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 646 | 656 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 647 | 657 | <span class="lbl">turn single-key shortcuts off</span> | |
| 648 | 658 | <span class="k">action</span> | |
M internal/app/testdata/web/commits-page2.html +14 -4
| @@ -90,6 +90,10 @@ | |||
| 90 | 90 | </a> | |
| 91 | 91 | </li> | |
| 92 | 92 | <li> | |
| 93 | + | <a href="/long/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 94 | + | </a> | |
| 95 | + | </li> | |
| 96 | + | <li> | |
| 93 | 97 | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 94 | 98 | </a> | |
| 95 | 99 | </li> | |
| @@ -377,25 +381,31 @@ | |||
| 377 | 381 | <span class="k">section</span> | |
| 378 | 382 | </a> | |
| 379 | 383 | </li> | |
| 380 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 384 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 385 | + | <a href="/long/releases" tabindex="-1"> | |
| 386 | + | <span class="lbl">releases</span> | |
| 387 | + | <span class="k">section</span> | |
| 388 | + | </a> | |
| 389 | + | </li> | |
| 390 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 381 | 391 | <a href="/long/actions" tabindex="-1"> | |
| 382 | 392 | <span class="lbl">actions</span> | |
| 383 | 393 | <span class="k">section</span> | |
| 384 | 394 | </a> | |
| 385 | 395 | </li> | |
| 386 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 396 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 387 | 397 | <a href="/long/tree/main" tabindex="-1"> | |
| 388 | 398 | <span class="lbl">main</span> | |
| 389 | 399 | <span class="k">branch</span> | |
| 390 | 400 | </a> | |
| 391 | 401 | </li> | |
| 392 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="repo diffs"> | |
| 402 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="repo diffs"> | |
| 393 | 403 | <a href="/diffs" tabindex="-1"> | |
| 394 | 404 | <span class="lbl">diffs</span> | |
| 395 | 405 | <span class="k">repo</span> | |
| 396 | 406 | </a> | |
| 397 | 407 | </li> | |
| 398 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 408 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 399 | 409 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 400 | 410 | <span class="lbl">turn single-key shortcuts off</span> | |
| 401 | 411 | <span class="k">action</span> | |
M internal/app/testdata/web/commits-path.html +14 -4
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/long/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -392,25 +396,31 @@ | |||
| 392 | 396 | <span class="k">section</span> | |
| 393 | 397 | </a> | |
| 394 | 398 | </li> | |
| 395 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 399 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 400 | + | <a href="/long/releases" tabindex="-1"> | |
| 401 | + | <span class="lbl">releases</span> | |
| 402 | + | <span class="k">section</span> | |
| 403 | + | </a> | |
| 404 | + | </li> | |
| 405 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 396 | 406 | <a href="/long/actions" tabindex="-1"> | |
| 397 | 407 | <span class="lbl">actions</span> | |
| 398 | 408 | <span class="k">section</span> | |
| 399 | 409 | </a> | |
| 400 | 410 | </li> | |
| 401 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 411 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 402 | 412 | <a href="/long/tree/main" tabindex="-1"> | |
| 403 | 413 | <span class="lbl">main</span> | |
| 404 | 414 | <span class="k">branch</span> | |
| 405 | 415 | </a> | |
| 406 | 416 | </li> | |
| 407 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="repo diffs"> | |
| 417 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="repo diffs"> | |
| 408 | 418 | <a href="/diffs" tabindex="-1"> | |
| 409 | 419 | <span class="lbl">diffs</span> | |
| 410 | 420 | <span class="k">repo</span> | |
| 411 | 421 | </a> | |
| 412 | 422 | </li> | |
| 413 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 423 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 414 | 424 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 415 | 425 | <span class="lbl">turn single-key shortcuts off</span> | |
| 416 | 426 | <span class="k">action</span> | |
M internal/app/testdata/web/commits-root.html +14 -4
| @@ -90,6 +90,10 @@ | |||
| 90 | 90 | </a> | |
| 91 | 91 | </li> | |
| 92 | 92 | <li> | |
| 93 | + | <a href="/long/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 94 | + | </a> | |
| 95 | + | </li> | |
| 96 | + | <li> | |
| 93 | 97 | <a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 94 | 98 | </a> | |
| 95 | 99 | </li> | |
| @@ -1182,25 +1186,31 @@ | |||
| 1182 | 1186 | <span class="k">section</span> | |
| 1183 | 1187 | </a> | |
| 1184 | 1188 | </li> | |
| 1185 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 1189 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 1190 | + | <a href="/long/releases" tabindex="-1"> | |
| 1191 | + | <span class="lbl">releases</span> | |
| 1192 | + | <span class="k">section</span> | |
| 1193 | + | </a> | |
| 1194 | + | </li> | |
| 1195 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 1186 | 1196 | <a href="/long/actions" tabindex="-1"> | |
| 1187 | 1197 | <span class="lbl">actions</span> | |
| 1188 | 1198 | <span class="k">section</span> | |
| 1189 | 1199 | </a> | |
| 1190 | 1200 | </li> | |
| 1191 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 1201 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 1192 | 1202 | <a href="/long/tree/main" tabindex="-1"> | |
| 1193 | 1203 | <span class="lbl">main</span> | |
| 1194 | 1204 | <span class="k">branch</span> | |
| 1195 | 1205 | </a> | |
| 1196 | 1206 | </li> | |
| 1197 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="repo diffs"> | |
| 1207 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="repo diffs"> | |
| 1198 | 1208 | <a href="/diffs" tabindex="-1"> | |
| 1199 | 1209 | <span class="lbl">diffs</span> | |
| 1200 | 1210 | <span class="k">repo</span> | |
| 1201 | 1211 | </a> | |
| 1202 | 1212 | </li> | |
| 1203 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 1213 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 1204 | 1214 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 1205 | 1215 | <span class="lbl">turn single-key shortcuts off</span> | |
| 1206 | 1216 | <span class="k">action</span> | |
M internal/app/testdata/web/commits-small.html +16 -6
| @@ -90,6 +90,10 @@ | |||
| 90 | 90 | </a> | |
| 91 | 91 | </li> | |
| 92 | 92 | <li> | |
| 93 | + | <a href="/diffs/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 94 | + | </a> | |
| 95 | + | </li> | |
| 96 | + | <li> | |
| 93 | 97 | <a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 94 | 98 | </a> | |
| 95 | 99 | </li> | |
| @@ -535,37 +539,43 @@ | |||
| 535 | 539 | <span class="k">section</span> | |
| 536 | 540 | </a> | |
| 537 | 541 | </li> | |
| 538 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 542 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 543 | + | <a href="/diffs/releases" tabindex="-1"> | |
| 544 | + | <span class="lbl">releases</span> | |
| 545 | + | <span class="k">section</span> | |
| 546 | + | </a> | |
| 547 | + | </li> | |
| 548 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 539 | 549 | <a href="/diffs/actions" tabindex="-1"> | |
| 540 | 550 | <span class="lbl">actions</span> | |
| 541 | 551 | <span class="k">section</span> | |
| 542 | 552 | </a> | |
| 543 | 553 | </li> | |
| 544 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch main"> | |
| 554 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 545 | 555 | <a href="/diffs/tree/main" tabindex="-1"> | |
| 546 | 556 | <span class="lbl">main</span> | |
| 547 | 557 | <span class="k">branch</span> | |
| 548 | 558 | </a> | |
| 549 | 559 | </li> | |
| 550 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch side"> | |
| 560 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch side"> | |
| 551 | 561 | <a href="/diffs/tree/side" tabindex="-1"> | |
| 552 | 562 | <span class="lbl">side</span> | |
| 553 | 563 | <span class="k">branch</span> | |
| 554 | 564 | </a> | |
| 555 | 565 | </li> | |
| 556 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 566 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0.0"> | |
| 557 | 567 | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 558 | 568 | <span class="lbl">v1.0.0</span> | |
| 559 | 569 | <span class="k">tag</span> | |
| 560 | 570 | </a> | |
| 561 | 571 | </li> | |
| 562 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="repo long"> | |
| 572 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="repo long"> | |
| 563 | 573 | <a href="/long" tabindex="-1"> | |
| 564 | 574 | <span class="lbl">long</span> | |
| 565 | 575 | <span class="k">repo</span> | |
| 566 | 576 | </a> | |
| 567 | 577 | </li> | |
| 568 | - | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 578 | + | <li id="cmd-o12" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 569 | 579 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 570 | 580 | <span class="lbl">turn single-key shortcuts off</span> | |
| 571 | 581 | <span class="k">action</span> | |
M internal/app/testdata/web/refs.html +17 -7
| @@ -87,6 +87,10 @@ | |||
| 87 | 87 | </a> | |
| 88 | 88 | </li> | |
| 89 | 89 | <li> | |
| 90 | + | <a href="/demo/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 91 | + | </a> | |
| 92 | + | </li> | |
| 93 | + | <li> | |
| 90 | 94 | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 91 | 95 | </a> | |
| 92 | 96 | </li> | |
| @@ -421,43 +425,49 @@ | |||
| 421 | 425 | <span class="k">section</span> | |
| 422 | 426 | </a> | |
| 423 | 427 | </li> | |
| 424 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 428 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 429 | + | <a href="/demo/releases" tabindex="-1"> | |
| 430 | + | <span class="lbl">releases</span> | |
| 431 | + | <span class="k">section</span> | |
| 432 | + | </a> | |
| 433 | + | </li> | |
| 434 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 425 | 435 | <a href="/demo/actions" tabindex="-1"> | |
| 426 | 436 | <span class="lbl">actions</span> | |
| 427 | 437 | <span class="k">section</span> | |
| 428 | 438 | </a> | |
| 429 | 439 | </li> | |
| 430 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 440 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 431 | 441 | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 432 | 442 | <span class="lbl">feature/x</span> | |
| 433 | 443 | <span class="k">branch</span> | |
| 434 | 444 | </a> | |
| 435 | 445 | </li> | |
| 436 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 446 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 437 | 447 | <a href="/demo/tree/main" tabindex="-1"> | |
| 438 | 448 | <span class="lbl">main</span> | |
| 439 | 449 | <span class="k">branch</span> | |
| 440 | 450 | </a> | |
| 441 | 451 | </li> | |
| 442 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v0.1.0"> | |
| 452 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.1.0"> | |
| 443 | 453 | <a href="/demo/tree/v0.1.0" tabindex="-1"> | |
| 444 | 454 | <span class="lbl">v0.1.0</span> | |
| 445 | 455 | <span class="k">tag</span> | |
| 446 | 456 | </a> | |
| 447 | 457 | </li> | |
| 448 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.2.0"> | |
| 458 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="tag v0.2.0"> | |
| 449 | 459 | <a href="/demo/tree/v0.2.0" tabindex="-1"> | |
| 450 | 460 | <span class="lbl">v0.2.0</span> | |
| 451 | 461 | <span class="k">tag</span> | |
| 452 | 462 | </a> | |
| 453 | 463 | </li> | |
| 454 | - | <li id="cmd-o11" role="option" aria-selected="false" data-k="repo quiet"> | |
| 464 | + | <li id="cmd-o12" role="option" aria-selected="false" data-k="repo quiet"> | |
| 455 | 465 | <a href="/quiet" tabindex="-1"> | |
| 456 | 466 | <span class="lbl">quiet</span> | |
| 457 | 467 | <span class="k">repo</span> | |
| 458 | 468 | </a> | |
| 459 | 469 | </li> | |
| 460 | - | <li id="cmd-o12" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 470 | + | <li id="cmd-o13" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 461 | 471 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 462 | 472 | <span class="lbl">turn single-key shortcuts off</span> | |
| 463 | 473 | <span class="k">action</span> | |
M internal/app/testdata/web/summary.html +17 -7
| @@ -84,6 +84,10 @@ | |||
| 84 | 84 | </a> | |
| 85 | 85 | </li> | |
| 86 | 86 | <li> | |
| 87 | + | <a href="/demo/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 88 | + | </a> | |
| 89 | + | </li> | |
| 90 | + | <li> | |
| 87 | 91 | <a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 88 | 92 | </a> | |
| 89 | 93 | </li> | |
| @@ -447,43 +451,49 @@ | |||
| 447 | 451 | <span class="k">section</span> | |
| 448 | 452 | </a> | |
| 449 | 453 | </li> | |
| 450 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 454 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 455 | + | <a href="/demo/releases" tabindex="-1"> | |
| 456 | + | <span class="lbl">releases</span> | |
| 457 | + | <span class="k">section</span> | |
| 458 | + | </a> | |
| 459 | + | </li> | |
| 460 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 451 | 461 | <a href="/demo/actions" tabindex="-1"> | |
| 452 | 462 | <span class="lbl">actions</span> | |
| 453 | 463 | <span class="k">section</span> | |
| 454 | 464 | </a> | |
| 455 | 465 | </li> | |
| 456 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 466 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 457 | 467 | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 458 | 468 | <span class="lbl">feature/x</span> | |
| 459 | 469 | <span class="k">branch</span> | |
| 460 | 470 | </a> | |
| 461 | 471 | </li> | |
| 462 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 472 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 463 | 473 | <a href="/demo/tree/main" tabindex="-1"> | |
| 464 | 474 | <span class="lbl">main</span> | |
| 465 | 475 | <span class="k">branch</span> | |
| 466 | 476 | </a> | |
| 467 | 477 | </li> | |
| 468 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v0.1.0"> | |
| 478 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.1.0"> | |
| 469 | 479 | <a href="/demo/tree/v0.1.0" tabindex="-1"> | |
| 470 | 480 | <span class="lbl">v0.1.0</span> | |
| 471 | 481 | <span class="k">tag</span> | |
| 472 | 482 | </a> | |
| 473 | 483 | </li> | |
| 474 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v0.2.0"> | |
| 484 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="tag v0.2.0"> | |
| 475 | 485 | <a href="/demo/tree/v0.2.0" tabindex="-1"> | |
| 476 | 486 | <span class="lbl">v0.2.0</span> | |
| 477 | 487 | <span class="k">tag</span> | |
| 478 | 488 | </a> | |
| 479 | 489 | </li> | |
| 480 | - | <li id="cmd-o11" role="option" aria-selected="false" data-k="repo quiet"> | |
| 490 | + | <li id="cmd-o12" role="option" aria-selected="false" data-k="repo quiet"> | |
| 481 | 491 | <a href="/quiet" tabindex="-1"> | |
| 482 | 492 | <span class="lbl">quiet</span> | |
| 483 | 493 | <span class="k">repo</span> | |
| 484 | 494 | </a> | |
| 485 | 495 | </li> | |
| 486 | - | <li id="cmd-o12" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 496 | + | <li id="cmd-o13" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 487 | 497 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 488 | 498 | <span class="lbl">turn single-key shortcuts off</span> | |
| 489 | 499 | <span class="k">action</span> | |
M internal/app/testdata/web/tree-nested.html +15 -5
| @@ -96,6 +96,10 @@ | |||
| 96 | 96 | </a> | |
| 97 | 97 | </li> | |
| 98 | 98 | <li> | |
| 99 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 100 | + | </a> | |
| 101 | + | </li> | |
| 102 | + | <li> | |
| 99 | 103 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 100 | 104 | </a> | |
| 101 | 105 | </li> | |
| @@ -373,31 +377,37 @@ | |||
| 373 | 377 | <span class="k">section</span> | |
| 374 | 378 | </a> | |
| 375 | 379 | </li> | |
| 376 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 380 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 381 | + | <a href="/files/releases" tabindex="-1"> | |
| 382 | + | <span class="lbl">releases</span> | |
| 383 | + | <span class="k">section</span> | |
| 384 | + | </a> | |
| 385 | + | </li> | |
| 386 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 377 | 387 | <a href="/files/actions" tabindex="-1"> | |
| 378 | 388 | <span class="lbl">actions</span> | |
| 379 | 389 | <span class="k">section</span> | |
| 380 | 390 | </a> | |
| 381 | 391 | </li> | |
| 382 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 392 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 383 | 393 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 384 | 394 | <span class="lbl">feature/x</span> | |
| 385 | 395 | <span class="k">branch</span> | |
| 386 | 396 | </a> | |
| 387 | 397 | </li> | |
| 388 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 398 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 389 | 399 | <a href="/files/tree/main" tabindex="-1"> | |
| 390 | 400 | <span class="lbl">main</span> | |
| 391 | 401 | <span class="k">branch</span> | |
| 392 | 402 | </a> | |
| 393 | 403 | </li> | |
| 394 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 404 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 395 | 405 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 396 | 406 | <span class="lbl">v1.0</span> | |
| 397 | 407 | <span class="k">tag</span> | |
| 398 | 408 | </a> | |
| 399 | 409 | </li> | |
| 400 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 410 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 401 | 411 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 402 | 412 | <span class="lbl">turn single-key shortcuts off</span> | |
| 403 | 413 | <span class="k">action</span> | |
M internal/app/testdata/web/tree-root.html +15 -5
| @@ -90,6 +90,10 @@ | |||
| 90 | 90 | </a> | |
| 91 | 91 | </li> | |
| 92 | 92 | <li> | |
| 93 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 94 | + | </a> | |
| 95 | + | </li> | |
| 96 | + | <li> | |
| 93 | 97 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 94 | 98 | </a> | |
| 95 | 99 | </li> | |
| @@ -594,31 +598,37 @@ | |||
| 594 | 598 | <span class="k">section</span> | |
| 595 | 599 | </a> | |
| 596 | 600 | </li> | |
| 597 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 601 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 602 | + | <a href="/files/releases" tabindex="-1"> | |
| 603 | + | <span class="lbl">releases</span> | |
| 604 | + | <span class="k">section</span> | |
| 605 | + | </a> | |
| 606 | + | </li> | |
| 607 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 598 | 608 | <a href="/files/actions" tabindex="-1"> | |
| 599 | 609 | <span class="lbl">actions</span> | |
| 600 | 610 | <span class="k">section</span> | |
| 601 | 611 | </a> | |
| 602 | 612 | </li> | |
| 603 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 613 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 604 | 614 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 605 | 615 | <span class="lbl">feature/x</span> | |
| 606 | 616 | <span class="k">branch</span> | |
| 607 | 617 | </a> | |
| 608 | 618 | </li> | |
| 609 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 619 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 610 | 620 | <a href="/files/tree/main" tabindex="-1"> | |
| 611 | 621 | <span class="lbl">main</span> | |
| 612 | 622 | <span class="k">branch</span> | |
| 613 | 623 | </a> | |
| 614 | 624 | </li> | |
| 615 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 625 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 616 | 626 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 617 | 627 | <span class="lbl">v1.0</span> | |
| 618 | 628 | <span class="k">tag</span> | |
| 619 | 629 | </a> | |
| 620 | 630 | </li> | |
| 621 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 631 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 622 | 632 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 623 | 633 | <span class="lbl">turn single-key shortcuts off</span> | |
| 624 | 634 | <span class="k">action</span> | |
M internal/app/testdata/web/tree-slash-ref.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -370,31 +374,37 @@ | |||
| 370 | 374 | <span class="k">section</span> | |
| 371 | 375 | </a> | |
| 372 | 376 | </li> | |
| 373 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 377 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 378 | + | <a href="/files/releases" tabindex="-1"> | |
| 379 | + | <span class="lbl">releases</span> | |
| 380 | + | <span class="k">section</span> | |
| 381 | + | </a> | |
| 382 | + | </li> | |
| 383 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 374 | 384 | <a href="/files/actions" tabindex="-1"> | |
| 375 | 385 | <span class="lbl">actions</span> | |
| 376 | 386 | <span class="k">section</span> | |
| 377 | 387 | </a> | |
| 378 | 388 | </li> | |
| 379 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 389 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 380 | 390 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 381 | 391 | <span class="lbl">feature/x</span> | |
| 382 | 392 | <span class="k">branch</span> | |
| 383 | 393 | </a> | |
| 384 | 394 | </li> | |
| 385 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 395 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 386 | 396 | <a href="/files/tree/main" tabindex="-1"> | |
| 387 | 397 | <span class="lbl">main</span> | |
| 388 | 398 | <span class="k">branch</span> | |
| 389 | 399 | </a> | |
| 390 | 400 | </li> | |
| 391 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 401 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 392 | 402 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 393 | 403 | <span class="lbl">v1.0</span> | |
| 394 | 404 | <span class="k">tag</span> | |
| 395 | 405 | </a> | |
| 396 | 406 | </li> | |
| 397 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 407 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 398 | 408 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 399 | 409 | <span class="lbl">turn single-key shortcuts off</span> | |
| 400 | 410 | <span class="k">action</span> | |
M internal/app/testdata/web/tree-subdir.html +15 -5
| @@ -93,6 +93,10 @@ | |||
| 93 | 93 | </a> | |
| 94 | 94 | </li> | |
| 95 | 95 | <li> | |
| 96 | + | <a href="/files/releases" data-key="l" aria-keyshortcuts="l">Releases <kbd aria-hidden="true">l</kbd> | |
| 97 | + | </a> | |
| 98 | + | </li> | |
| 99 | + | <li> | |
| 96 | 100 | <a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd> | |
| 97 | 101 | </a> | |
| 98 | 102 | </li> | |
| @@ -386,31 +390,37 @@ | |||
| 386 | 390 | <span class="k">section</span> | |
| 387 | 391 | </a> | |
| 388 | 392 | </li> | |
| 389 | - | <li id="cmd-o6" role="option" aria-selected="false" data-k="section actions"> | |
| 393 | + | <li id="cmd-o6" role="option" aria-selected="false" data-k="section releases"> | |
| 394 | + | <a href="/files/releases" tabindex="-1"> | |
| 395 | + | <span class="lbl">releases</span> | |
| 396 | + | <span class="k">section</span> | |
| 397 | + | </a> | |
| 398 | + | </li> | |
| 399 | + | <li id="cmd-o7" role="option" aria-selected="false" data-k="section actions"> | |
| 390 | 400 | <a href="/files/actions" tabindex="-1"> | |
| 391 | 401 | <span class="lbl">actions</span> | |
| 392 | 402 | <span class="k">section</span> | |
| 393 | 403 | </a> | |
| 394 | 404 | </li> | |
| 395 | - | <li id="cmd-o7" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 405 | + | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch feature/x"> | |
| 396 | 406 | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 397 | 407 | <span class="lbl">feature/x</span> | |
| 398 | 408 | <span class="k">branch</span> | |
| 399 | 409 | </a> | |
| 400 | 410 | </li> | |
| 401 | - | <li id="cmd-o8" role="option" aria-selected="false" data-k="branch main"> | |
| 411 | + | <li id="cmd-o9" role="option" aria-selected="false" data-k="branch main"> | |
| 402 | 412 | <a href="/files/tree/main" tabindex="-1"> | |
| 403 | 413 | <span class="lbl">main</span> | |
| 404 | 414 | <span class="k">branch</span> | |
| 405 | 415 | </a> | |
| 406 | 416 | </li> | |
| 407 | - | <li id="cmd-o9" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 417 | + | <li id="cmd-o10" role="option" aria-selected="false" data-k="tag v1.0"> | |
| 408 | 418 | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 409 | 419 | <span class="lbl">v1.0</span> | |
| 410 | 420 | <span class="k">tag</span> | |
| 411 | 421 | </a> | |
| 412 | 422 | </li> | |
| 413 | - | <li id="cmd-o10" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 423 | + | <li id="cmd-o11" role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"> | |
| 414 | 424 | <a href="#" data-fire="keys" tabindex="-1"> | |
| 415 | 425 | <span class="lbl">turn single-key shortcuts off</span> | |
| 416 | 426 | <span class="k">action</span> | |
M internal/app/web.go +1 -0
| @@ -57,6 +57,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||
| 57 | 57 | SigningKey: signing, | |
| 58 | 58 | Runs: f.CI, | |
| 59 | 59 | Artifacts: artifacts, | |
| 60 | + | Releases: f.Releases, | |
| 60 | 61 | Events: f.EventLog, | |
| 61 | 62 | Mirrors: f.Mirrors, | |
| 62 | 63 | Owner: cfg.Owner, | |
A internal/app/web_release_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "io" | |
| 9 | + | "net/http" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/usecase" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // Releases and web use the production forge wiring. | |
| 23 | + | func TestWebRelease(t *testing.T) { | |
| 24 | + | h := newHarness(t, harnessOpts{}) | |
| 25 | + | h.webRepo("demo") | |
| 26 | + | blobs, releases := h.forge.Blobs, h.forge.Releases | |
| 27 | + | repo := h.name("demo") | |
| 28 | + | rel, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v0.2.0", Title: "Demo release", Notes: "## Install\n\n**Download** the binary. <script>alert(1)</script>"}) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | payload := "binary release data\x00\xff\n" | |
| 31 | + | asset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", ContentType: "application/octet-stream", Size: int64(len(payload))}, strings.NewReader(payload)) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | draft, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "draft", Title: "Hidden draft", Draft: true}) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | draftAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: draft.ID, Name: "hidden.bin", Size: 1}, strings.NewReader("x")) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | ts := h.srv | |
| 38 | + | get := func(method, path, byteRange string) (int, http.Header, string) { | |
| 39 | + | t.Helper() | |
| 40 | + | req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, nil) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | if byteRange != "" { | |
| 43 | + | req.Header.Set("Range", byteRange) | |
| 44 | + | } | |
| 45 | + | res, err := ts.Client().Do(req) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | defer res.Body.Close() | |
| 48 | + | body, err := io.ReadAll(res.Body) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | return res.StatusCode, res.Header, string(body) | |
| 51 | + | } | |
| 52 | + | status, _, body := get("GET", "/demo/releases", "") | |
| 53 | + | require.Equal(t, 200, status) | |
| 54 | + | assert.Contains(t, body, "Demo release") | |
| 55 | + | assert.NotContains(t, body, "Hidden draft") | |
| 56 | + | status, _, body = get("GET", "/demo/releases/tag/v0.2.0", "") | |
| 57 | + | require.Equal(t, 200, status) | |
| 58 | + | assert.Contains(t, body, "<strong>Download</strong>") | |
| 59 | + | assert.NotContains(t, body, "<script>alert") | |
| 60 | + | assert.Contains(t, body, "demo.bin") | |
| 61 | + | assert.Contains(t, body, "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10)) | |
| 62 | + | for _, path := range []string{"/demo/releases/download/v0.2.0/demo.bin", "/demo/releases/latest/download/demo.bin", "/demo/releases/assets/" + strconv.FormatInt(asset.ID, 10)} { | |
| 63 | + | status, headers, body := get("GET", path, "") | |
| 64 | + | require.Equal(t, 200, status) | |
| 65 | + | assert.Equal(t, payload, body) | |
| 66 | + | assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length")) | |
| 67 | + | assert.Equal(t, "bytes", headers.Get("Accept-Ranges")) | |
| 68 | + | assert.Contains(t, headers.Get("Content-Disposition"), "attachment") | |
| 69 | + | status, headers, body = get("GET", path, "bytes=2-6") | |
| 70 | + | require.Equal(t, 206, status) | |
| 71 | + | assert.Equal(t, payload[2:7], body) | |
| 72 | + | assert.Equal(t, "bytes 2-6/"+strconv.Itoa(len(payload)), headers.Get("Content-Range")) | |
| 73 | + | assert.Equal(t, "5", headers.Get("Content-Length")) | |
| 74 | + | status, headers, body = get("HEAD", path, "") | |
| 75 | + | require.Equal(t, 200, status) | |
| 76 | + | assert.Empty(t, body) | |
| 77 | + | assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length")) | |
| 78 | + | } | |
| 79 | + | for _, path := range []string{"/demo/releases/tag/draft", "/demo/releases/download/draft/hidden.bin", "/demo/releases/assets/" + strconv.FormatInt(draftAsset.ID, 10)} { | |
| 80 | + | status, _, _ := get("GET", path, "") | |
| 81 | + | assert.Equal(t, 404, status) | |
| 82 | + | } | |
| 83 | + | // Slash-tag page and asset-id download remain unambiguous. | |
| 84 | + | slash, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "series/v1", TargetCommitish: "main", Title: "Slash tag"}) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | status, _, body = get("GET", "/demo/releases/tag/series/v1", "") | |
| 87 | + | assert.Equal(t, 200, status) | |
| 88 | + | assert.Contains(t, body, slash.Title) | |
| 89 | + | // The browser_download_url of the REST API for a slash tag is served by | |
| 90 | + | // the web route. | |
| 91 | + | slashAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: slash.ID, Name: "s.bin", Size: 5}, strings.NewReader("slash")) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | gh := usecase.NewReleasesGitHub(releases, "owner", ts.URL) | |
| 94 | + | raw, err := gh.GetAsset(h.ctx, domain.Root(), "owner", "demo", slashAsset.ID) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | var obj struct { | |
| 97 | + | URL string `json:"browser_download_url"` | |
| 98 | + | } | |
| 99 | + | require.NoError(t, json.Unmarshal(raw, &obj)) | |
| 100 | + | require.Equal(t, ts.URL+"/demo/releases/download/series%2Fv1/s.bin", obj.URL) | |
| 101 | + | status, _, body = get("GET", strings.TrimPrefix(obj.URL, ts.URL), "") | |
| 102 | + | assert.Equal(t, 200, status) | |
| 103 | + | assert.Equal(t, "slash", body) | |
| 104 | + | // A missing blob is a 404 page before any binary headers are written. | |
| 105 | + | require.NoError(t, blobs.Delete(context.Background(), asset.BlobKey())) | |
| 106 | + | status, headers, body := get("GET", "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10), "") | |
| 107 | + | assert.Equal(t, 404, status) | |
| 108 | + | assert.Contains(t, headers.Get("Content-Type"), "text/html") | |
| 109 | + | assert.Contains(t, body, "no such page") | |
| 110 | + | assert.Empty(t, headers.Get("Content-Disposition")) | |
| 111 | + | } | |
M internal/boundary/boundary_test.go +14 -2
| @@ -35,6 +35,7 @@ const ( | |||
| 35 | 35 | layerUsecase packageLayer = "usecase" | |
| 36 | 36 | layerAdapter packageLayer = "adapter" | |
| 37 | 37 | layerClientLimit packageLayer = "clientlimit" | |
| 38 | + | layerHTTPRange packageLayer = "httprange" | |
| 38 | 39 | layerConfig packageLayer = "config" | |
| 39 | 40 | layerPorts packageLayer = "ports" | |
| 40 | 41 | layerDomain packageLayer = "domain" | |
| @@ -55,13 +56,16 @@ const ( | |||
| 55 | 56 | // each other, and keeps the web adapter off the generated API code: like | |
| 56 | 57 | // every other adapter it reads and writes through the ports (ADR 0020). | |
| 57 | 58 | // - internal/clientlimit is infrastructure shared by the HTTP adapters. | |
| 59 | + | // - internal/httprange is infrastructure shared by the HTTP adapters that | |
| 60 | + | // serve byte ranges; it imports nothing of the module. | |
| 58 | 61 | // - internal/config parses the environment into plain values. | |
| 59 | 62 | var productionDependencies = map[packageLayer]map[packageLayer]bool{ | |
| 60 | 63 | layerCommand: {layerApp: true}, | |
| 61 | - | layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |
| 64 | + | layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |
| 62 | 65 | layerUsecase: {layerUsecase: true, layerPorts: true, layerDomain: true}, | |
| 63 | - | layerAdapter: {layerAdapter: true, layerClientLimit: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |
| 66 | + | layerAdapter: {layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |
| 64 | 67 | layerClientLimit: {layerPorts: true}, | |
| 68 | + | layerHTTPRange: {}, | |
| 65 | 69 | layerConfig: {}, | |
| 66 | 70 | layerPorts: {layerDomain: true}, | |
| 67 | 71 | layerDomain: {layerDomain: true}, | |
| @@ -149,6 +153,12 @@ func TestImportBoundaryFixtures(t *testing.T) { | |||
| 149 | 153 | {"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false}, | |
| 150 | 154 | {"adapter rejects sibling adapter", p("internal/adapters/web"), p("internal/adapters/gitcli"), false, false}, | |
| 151 | 155 | {"adapter test may import sibling adapter", p("internal/adapters/ermitegit"), p("internal/adapters/gitcli"), true, true}, | |
| 156 | + | {"app accepts httprange", p("internal/app"), p("internal/httprange"), false, true}, | |
| 157 | + | {"usecase rejects httprange", p("internal/usecase"), p("internal/httprange"), false, false}, | |
| 158 | + | {"domain rejects httprange", p("internal/domain"), p("internal/httprange"), false, false}, | |
| 159 | + | {"ports reject httprange", p("internal/ports"), p("internal/httprange"), false, false}, | |
| 160 | + | {"adapter accepts httprange", p("internal/adapters/web"), p("internal/httprange"), false, true}, | |
| 161 | + | {"httprange rejects domain", p("internal/httprange"), p("internal/domain"), false, false}, | |
| 152 | 162 | {"clientlimit accepts ports", p("internal/clientlimit"), p("internal/ports"), false, true}, | |
| 153 | 163 | {"clientlimit rejects usecase", p("internal/clientlimit"), p("internal/usecase"), false, false}, | |
| 154 | 164 | {"config rejects domain", p("internal/config"), p("internal/domain"), false, false}, | |
| @@ -319,6 +329,8 @@ func classifyPackage(path string) (packageLayer, error) { | |||
| 319 | 329 | return layerAdapter, nil | |
| 320 | 330 | case under(path, p("internal/clientlimit")): | |
| 321 | 331 | return layerClientLimit, nil | |
| 332 | + | case under(path, p("internal/httprange")): | |
| 333 | + | return layerHTTPRange, nil | |
| 322 | 334 | case under(path, p("internal/config")): | |
| 323 | 335 | return layerConfig, nil | |
| 324 | 336 | case path == p("internal/ports"): | |
M internal/config/config.go +117 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "net" | |
| 13 | 13 | "net/netip" | |
| 14 | 14 | "net/url" | |
| 15 | + | "os" | |
| 15 | 16 | "path/filepath" | |
| 16 | 17 | "strconv" | |
| 17 | 18 | "strings" | |
| @@ -56,6 +57,16 @@ const ( | |||
| 56 | 57 | EnvCIArtifactRetention = "ERMITE_CI_ARTIFACT_RETENTION" | |
| 57 | 58 | EnvCIArtifactQuota = "ERMITE_CI_ARTIFACT_QUOTA" | |
| 58 | 59 | EnvCIArtifactMax = "ERMITE_CI_ARTIFACT_MAX" | |
| 60 | + | EnvS3Bucket = "ERMITE_S3_BUCKET" | |
| 61 | + | EnvS3Endpoint = "ERMITE_S3_ENDPOINT" | |
| 62 | + | EnvS3Region = "ERMITE_S3_REGION" | |
| 63 | + | EnvS3AccessKeyID = "ERMITE_S3_ACCESS_KEY_ID" | |
| 64 | + | EnvS3SecretAccessKey = "ERMITE_S3_SECRET_ACCESS_KEY" | |
| 65 | + | EnvS3ForcePathStyle = "ERMITE_S3_FORCE_PATH_STYLE" | |
| 66 | + | EnvS3Prefix = "ERMITE_S3_PREFIX" | |
| 67 | + | EnvReleaseAssetMax = "ERMITE_RELEASE_ASSET_MAX" | |
| 68 | + | EnvReleaseAssetsMax = "ERMITE_RELEASE_ASSETS_MAX" | |
| 69 | + | EnvReleaseQuota = "ERMITE_RELEASE_QUOTA" | |
| 59 | 70 | ) | |
| 60 | 71 | ||
| 61 | 72 | // SecretsKeyLen is the length of the secrets key in bytes (AES-256). | |
| @@ -140,6 +151,12 @@ type Config struct { | |||
| 140 | 151 | CIArtifactRetention time.Duration | |
| 141 | 152 | CIArtifactQuota int64 | |
| 142 | 153 | CIArtifactMax int64 | |
| 154 | + | // S3Bucket selects S3; empty selects local disk. Credentials are secrets. | |
| 155 | + | S3Bucket, S3Endpoint, S3Region, S3AccessKeyID, S3SecretAccessKey, S3Prefix string | |
| 156 | + | S3ForcePathStyle bool | |
| 157 | + | // Zero release limits leave defaults to the release use case. | |
| 158 | + | ReleaseAssetMax, ReleaseQuota int64 | |
| 159 | + | ReleaseAssetsMax int | |
| 143 | 160 | } | |
| 144 | 161 | ||
| 145 | 162 | // Error lists every configuration problem found, in variable order. | |
| @@ -167,6 +184,7 @@ func Load(get func(string) string) (Config, error) { | |||
| 167 | 184 | bad := func(key, format string, args ...any) { | |
| 168 | 185 | problems = append(problems, key+": "+fmt.Sprintf(format, args...)) | |
| 169 | 186 | } | |
| 187 | + | get = resolveFiles(get, &problems) | |
| 170 | 188 | var c Config | |
| 171 | 189 | ||
| 172 | 190 | c.Addr = get(EnvAddr) | |
| @@ -342,12 +360,111 @@ func Load(get func(string) string) (Config, error) { | |||
| 342 | 360 | } | |
| 343 | 361 | } | |
| 344 | 362 | ||
| 363 | + | loadStorage(get, &c, &problems) | |
| 345 | 364 | if len(problems) > 0 { | |
| 346 | 365 | return Config{}, &Error{Problems: problems} | |
| 347 | 366 | } | |
| 348 | 367 | return c, nil | |
| 349 | 368 | } | |
| 350 | 369 | ||
| 370 | + | // resolveFiles resolves only the four supported secrets, once at startup. | |
| 371 | + | // Neither file paths, file errors nor secret contents enter diagnostics. | |
| 372 | + | func resolveFiles(get func(string) string, problems *[]string) func(string) string { | |
| 373 | + | values := make(map[string]string) | |
| 374 | + | for _, key := range []string{EnvAdminToken, EnvSecretsKey, EnvS3AccessKeyID, EnvS3SecretAccessKey} { | |
| 375 | + | value, file := get(key), get(key+"_FILE") | |
| 376 | + | if value != "" && file != "" { | |
| 377 | + | *problems = append(*problems, key+": cannot set both variable and _FILE") | |
| 378 | + | } else if file != "" { | |
| 379 | + | data, err := os.ReadFile(file) | |
| 380 | + | if err != nil { | |
| 381 | + | *problems = append(*problems, key+"_FILE: cannot read secret file") | |
| 382 | + | } else { | |
| 383 | + | value = strings.TrimRight(string(data), "\r\n") | |
| 384 | + | if value == "" { | |
| 385 | + | *problems = append(*problems, key+"_FILE: secret file is empty") | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | values[key] = value | |
| 390 | + | } | |
| 391 | + | return func(key string) string { | |
| 392 | + | if value, ok := values[key]; ok { | |
| 393 | + | return value | |
| 394 | + | } | |
| 395 | + | return get(key) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func loadStorage(get func(string) string, c *Config, problems *[]string) { | |
| 400 | + | bad := func(key, message string) { *problems = append(*problems, key+": "+message) } | |
| 401 | + | c.S3Bucket = get(EnvS3Bucket) | |
| 402 | + | c.S3Endpoint = get(EnvS3Endpoint) | |
| 403 | + | c.S3Region = get(EnvS3Region) | |
| 404 | + | c.S3AccessKeyID = get(EnvS3AccessKeyID) | |
| 405 | + | c.S3SecretAccessKey = get(EnvS3SecretAccessKey) | |
| 406 | + | c.S3Prefix = strings.Trim(get(EnvS3Prefix), "/") | |
| 407 | + | if c.S3Prefix != "" { | |
| 408 | + | valid := len(c.S3Prefix) <= 512 | |
| 409 | + | for _, part := range strings.Split(c.S3Prefix, "/") { | |
| 410 | + | if part == "" || part == "." || part == ".." { | |
| 411 | + | valid = false | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | for _, r := range c.S3Prefix { | |
| 415 | + | if r < 0x21 || r > 0x7e || r == '\\' { | |
| 416 | + | valid = false | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | if !valid { | |
| 420 | + | bad(EnvS3Prefix, "must be an ASCII key prefix without empty, dot or dot-dot segments") | |
| 421 | + | } | |
| 422 | + | c.S3Prefix += "/" | |
| 423 | + | } | |
| 424 | + | if c.S3Endpoint != "" { | |
| 425 | + | if endpoint, err := parseURL(c.S3Endpoint, false); err != nil { | |
| 426 | + | bad(EnvS3Endpoint, "must be an http(s) URL without credentials, path, query or fragment") | |
| 427 | + | } else { | |
| 428 | + | c.S3Endpoint = endpoint | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | switch get(EnvS3ForcePathStyle) { | |
| 432 | + | case "", "0", "false": | |
| 433 | + | case "1", "true": | |
| 434 | + | c.S3ForcePathStyle = true | |
| 435 | + | default: | |
| 436 | + | bad(EnvS3ForcePathStyle, "must be 0, 1, false or true") | |
| 437 | + | } | |
| 438 | + | if c.S3Bucket != "" { | |
| 439 | + | for _, v := range []struct{ key, value string }{{EnvS3Region, c.S3Region}, {EnvS3AccessKeyID, c.S3AccessKeyID}, {EnvS3SecretAccessKey, c.S3SecretAccessKey}} { | |
| 440 | + | if strings.TrimSpace(v.value) == "" { | |
| 441 | + | bad(v.key, "required when ERMITE_S3_BUCKET is set") | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | for _, v := range []struct { | |
| 446 | + | key string | |
| 447 | + | dst *int64 | |
| 448 | + | }{{EnvReleaseAssetMax, &c.ReleaseAssetMax}, {EnvReleaseQuota, &c.ReleaseQuota}} { | |
| 449 | + | if raw := strings.TrimSpace(get(v.key)); raw != "" { | |
| 450 | + | n, err := parseSize(raw) | |
| 451 | + | if err != nil { | |
| 452 | + | bad(v.key, err.Error()) | |
| 453 | + | } else { | |
| 454 | + | *v.dst = n | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | if raw := get(EnvReleaseAssetsMax); raw != "" { | |
| 459 | + | n, err := strconv.Atoi(raw) | |
| 460 | + | if err != nil || n < 1 { | |
| 461 | + | bad(EnvReleaseAssetsMax, "must be a positive integer") | |
| 462 | + | } else { | |
| 463 | + | c.ReleaseAssetsMax = n | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | ||
| 351 | 468 | // sizeUnits are the suffixes of parseSize, longest first. | |
| 352 | 469 | var sizeUnits = []struct { | |
| 353 | 470 | suffix string | |
A internal/config/storage_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package config_test | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/config" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func s3Env() map[string]string { | |
| 18 | + | return map[string]string{ | |
| 19 | + | config.EnvS3Bucket: "bucket", config.EnvS3Region: "us-east-1", | |
| 20 | + | config.EnvS3AccessKeyID: "private-id", config.EnvS3SecretAccessKey: "private-secret", | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | func TestStorageConfig(t *testing.T) { | |
| 24 | + | c, err := config.Load(lookup(valid(nil))) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | assert.Empty(t, c.S3Bucket) | |
| 27 | + | env := s3Env() | |
| 28 | + | env[config.EnvS3Endpoint] = "http://localhost:9000/" | |
| 29 | + | env[config.EnvS3Prefix] = "/forge/blobs/" | |
| 30 | + | env[config.EnvS3ForcePathStyle] = "1" | |
| 31 | + | env[config.EnvReleaseAssetMax] = "512MiB" | |
| 32 | + | env[config.EnvReleaseQuota] = "10GiB" | |
| 33 | + | env[config.EnvReleaseAssetsMax] = "7" | |
| 34 | + | c, err = config.Load(lookup(valid(env))) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | assert.Equal(t, "forge/blobs/", c.S3Prefix) | |
| 37 | + | assert.Equal(t, "http://localhost:9000", c.S3Endpoint) | |
| 38 | + | assert.True(t, c.S3ForcePathStyle) | |
| 39 | + | assert.EqualValues(t, 512<<20, c.ReleaseAssetMax) | |
| 40 | + | assert.EqualValues(t, 10<<30, c.ReleaseQuota) | |
| 41 | + | assert.Equal(t, 7, c.ReleaseAssetsMax) | |
| 42 | + | for _, key := range []string{config.EnvS3Region, config.EnvS3AccessKeyID, config.EnvS3SecretAccessKey} { | |
| 43 | + | t.Run(key, func(t *testing.T) { | |
| 44 | + | env := s3Env() | |
| 45 | + | env[key] = "" | |
| 46 | + | _, err := config.Load(lookup(valid(env))) | |
| 47 | + | require.Error(t, err) | |
| 48 | + | assert.Contains(t, err.Error(), key) | |
| 49 | + | assert.NotContains(t, err.Error(), "private-") | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | for key, value := range map[string]string{config.EnvS3Endpoint: "https://private-id:private-secret@example.org", config.EnvS3ForcePathStyle: "yes", config.EnvS3Prefix: "x/../y", config.EnvReleaseAssetMax: "0", config.EnvReleaseQuota: "-1", config.EnvReleaseAssetsMax: "0"} { | |
| 53 | + | t.Run(key+" invalid", func(t *testing.T) { | |
| 54 | + | env := s3Env() | |
| 55 | + | env[key] = value | |
| 56 | + | _, err := config.Load(lookup(valid(env))) | |
| 57 | + | require.Error(t, err) | |
| 58 | + | assert.Contains(t, err.Error(), key) | |
| 59 | + | assert.NotContains(t, err.Error(), "private-") | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | for _, value := range []string{"0", "1", "false", "true"} { | |
| 63 | + | env := s3Env() | |
| 64 | + | env[config.EnvS3ForcePathStyle] = value | |
| 65 | + | _, err := config.Load(lookup(valid(env))) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | func TestSecretFiles(t *testing.T) { | |
| 70 | + | for _, key := range []string{config.EnvAdminToken, config.EnvSecretsKey, config.EnvS3AccessKeyID, config.EnvS3SecretAccessKey} { | |
| 71 | + | t.Run(key, func(t *testing.T) { | |
| 72 | + | secret := strings.Repeat("s", 32) | |
| 73 | + | if key == config.EnvSecretsKey { | |
| 74 | + | secret = base64.StdEncoding.EncodeToString([]byte(secret)) | |
| 75 | + | } | |
| 76 | + | path := filepath.Join(t.TempDir(), "secret") | |
| 77 | + | require.NoError(t, os.WriteFile(path, []byte(secret+"\r\n"), 0600)) | |
| 78 | + | env := valid(s3Env()) | |
| 79 | + | env[key] = "" | |
| 80 | + | env[key+"_FILE"] = path | |
| 81 | + | c, err := config.Load(lookup(env)) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | switch key { | |
| 84 | + | case config.EnvAdminToken: | |
| 85 | + | assert.Equal(t, sha256.Sum256([]byte(secret)), c.AdminTokenHash) | |
| 86 | + | case config.EnvSecretsKey: | |
| 87 | + | assert.Len(t, c.SecretsKey, 32) | |
| 88 | + | case config.EnvS3AccessKeyID: | |
| 89 | + | assert.Equal(t, secret, c.S3AccessKeyID) | |
| 90 | + | case config.EnvS3SecretAccessKey: | |
| 91 | + | assert.Equal(t, secret, c.S3SecretAccessKey) | |
| 92 | + | } | |
| 93 | + | env[key] = secret | |
| 94 | + | _, err = config.Load(lookup(env)) | |
| 95 | + | require.Error(t, err) | |
| 96 | + | assert.Contains(t, err.Error(), "both") | |
| 97 | + | assert.NotContains(t, err.Error(), secret) | |
| 98 | + | env[key] = "" | |
| 99 | + | env[key+"_FILE"] = path + "missing" | |
| 100 | + | _, err = config.Load(lookup(env)) | |
| 101 | + | require.Error(t, err) | |
| 102 | + | assert.Contains(t, err.Error(), key+"_FILE") | |
| 103 | + | assert.NotContains(t, err.Error(), path) | |
| 104 | + | require.NoError(t, os.WriteFile(path, []byte("\n"), 0600)) | |
| 105 | + | env[key+"_FILE"] = path | |
| 106 | + | _, err = config.Load(lookup(env)) | |
| 107 | + | require.Error(t, err) | |
| 108 | + | assert.Contains(t, err.Error(), "empty") | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
M internal/domain/ci_artifact.go +21 -0
| @@ -28,6 +28,8 @@ const ( | |||
| 28 | 28 | CIArtifactMaxPerRun = 1000 | |
| 29 | 29 | // CIArtifactChunk is the largest piece of data of one message, both ways. | |
| 30 | 30 | CIArtifactChunk = 1 << 20 | |
| 31 | + | // CIArtifactMaxBlockSize bounds the in-memory staging of a toolkit block. | |
| 32 | + | CIArtifactMaxBlockSize = 8 << 20 | |
| 31 | 33 | // CIArtifactMinRetention is the shortest retention of an artifact. | |
| 32 | 34 | CIArtifactMinRetention = 24 * time.Hour | |
| 33 | 35 | // CIArtifactPendingMax is how long an upload may stay unfinished before the | |
| @@ -42,6 +44,16 @@ const ( | |||
| 42 | 44 | CIArtifactZip = "application/zip" | |
| 43 | 45 | ) | |
| 44 | 46 | ||
| 47 | + | // CIArtifactStore identifies the storage of an artifact's contents. | |
| 48 | + | type CIArtifactStore string | |
| 49 | + | ||
| 50 | + | const ( | |
| 51 | + | // CIArtifactStoreLegacy is the pre-blob-store layout under ci/artifacts. | |
| 52 | + | CIArtifactStoreLegacy CIArtifactStore = "" | |
| 53 | + | // CIArtifactStoreBlob stores the contents through ports.BlobStore. | |
| 54 | + | CIArtifactStoreBlob CIArtifactStore = "blob" | |
| 55 | + | ) | |
| 56 | + | ||
| 45 | 57 | // CIArtifactState is where an artifact is in its upload. | |
| 46 | 58 | type CIArtifactState string | |
| 47 | 59 | ||
| @@ -74,6 +86,7 @@ type CIArtifact struct { | |||
| 74 | 86 | // Digest is "sha256:<hex>" of the content, known from the commit. | |
| 75 | 87 | Digest string | |
| 76 | 88 | State CIArtifactState | |
| 89 | + | Store CIArtifactStore | |
| 77 | 90 | CreatedAt time.Time | |
| 78 | 91 | ExpiresAt time.Time | |
| 79 | 92 | // Charged is what the artifact counts against the quota of its | |
| @@ -85,6 +98,14 @@ type CIArtifact struct { | |||
| 85 | 98 | Version uint64 | |
| 86 | 99 | } | |
| 87 | 100 | ||
| 101 | + | // BlobKey names the contents by immutable run and artifact ids only. | |
| 102 | + | func (a CIArtifact) BlobKey() string { | |
| 103 | + | return fmt.Sprintf("ci/%020d/%020d", a.RunID, a.ID) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // BlockKey names a staged block; file is generated by the use case, not a user. | |
| 107 | + | func (a CIArtifact) BlockKey(file string) string { return a.BlobKey() + ".blocks/" + file } | |
| 108 | + | ||
| 88 | 109 | // Final reports whether the artifact is finalized. | |
| 89 | 110 | func (a CIArtifact) Final() bool { return a.State == CIArtifactFinal } | |
| 90 | 111 | ||
M internal/domain/ci_artifact_test.go +7 -0
| @@ -13,6 +13,13 @@ import ( | |||
| 13 | 13 | "bnema.dev/ermite/internal/domain" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | func TestCIArtifactBlobKeys(t *testing.T) { | |
| 17 | + | a := domain.CIArtifact{RunID: 7, ID: 42, Name: "not-used-in-keys"} | |
| 18 | + | assert.Equal(t, "ci/00000000000000000007/00000000000000000042", a.BlobKey()) | |
| 19 | + | assert.Equal(t, a.BlobKey()+".blocks/abcd.0123", a.BlockKey("abcd.0123")) | |
| 20 | + | assert.Equal(t, domain.CIArtifactStoreLegacy, a.Store) | |
| 21 | + | } | |
| 22 | + | ||
| 16 | 23 | func TestParseArtifactName(t *testing.T) { | |
| 17 | 24 | for _, ok := range []string{"dist", "my artifact (linux) v1.2", "é", strings.Repeat("é", 256), "..", "a.b-c_d"} { | |
| 18 | 25 | assert.NoError(t, domain.ParseArtifactName(ok), "%q", ok) | |
M internal/domain/event.go +44 -0
| @@ -366,3 +366,47 @@ type MirrorRecovered struct { | |||
| 366 | 366 | } | |
| 367 | 367 | ||
| 368 | 368 | func (MirrorRecovered) EventKind() string { return "mirror.recovered" } | |
| 369 | + | ||
| 370 | + | // ReleasePublished records a release lifecycle change. | |
| 371 | + | type ReleasePublished struct { | |
| 372 | + | Release Release | |
| 373 | + | At time.Time | |
| 374 | + | Visibility Visibility | |
| 375 | + | CreatedAt time.Time | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // EventKind implements Event. | |
| 379 | + | func (ReleasePublished) EventKind() string { return "release.published" } | |
| 380 | + | ||
| 381 | + | // ReleaseUpdated records a release lifecycle change. | |
| 382 | + | type ReleaseUpdated struct { | |
| 383 | + | Release Release | |
| 384 | + | At time.Time | |
| 385 | + | Visibility Visibility | |
| 386 | + | CreatedAt time.Time | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // EventKind implements Event. | |
| 390 | + | func (ReleaseUpdated) EventKind() string { return "release.updated" } | |
| 391 | + | ||
| 392 | + | // ReleaseDeleted records a release lifecycle change. | |
| 393 | + | type ReleaseDeleted struct { | |
| 394 | + | Release Release | |
| 395 | + | At time.Time | |
| 396 | + | Visibility Visibility | |
| 397 | + | CreatedAt time.Time | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // EventKind implements Event. | |
| 401 | + | func (ReleaseDeleted) EventKind() string { return "release.deleted" } | |
| 402 | + | ||
| 403 | + | // ReleaseUnpublished records a release lifecycle change. | |
| 404 | + | type ReleaseUnpublished struct { | |
| 405 | + | Release Release | |
| 406 | + | At time.Time | |
| 407 | + | Visibility Visibility | |
| 408 | + | CreatedAt time.Time | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // EventKind implements Event. | |
| 412 | + | func (ReleaseUnpublished) EventKind() string { return "release.unpublished" } | |
A internal/domain/release.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "mime" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | "unicode" | |
| 11 | + | "unicode/utf8" | |
| 12 | + | ||
| 13 | + | "golang.org/x/text/unicode/norm" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Release binds release notes and assets to a repository tag. | |
| 17 | + | type Release struct { | |
| 18 | + | ID int64 | |
| 19 | + | Repo RepoName | |
| 20 | + | Tag, Title, Notes string | |
| 21 | + | Draft, Prerelease, MakeLatest bool | |
| 22 | + | // TargetCommitish is the commit at publication, empty for an unresolved draft. | |
| 23 | + | TargetCommitish string | |
| 24 | + | Author string | |
| 25 | + | CreatedAt, PublishedAt, UpdatedAt time.Time | |
| 26 | + | Version uint64 | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // ReleaseAssetState distinguishes reserved uploads from visible assets. | |
| 30 | + | type ReleaseAssetState string | |
| 31 | + | ||
| 32 | + | const ( | |
| 33 | + | ReleaseAssetUploading ReleaseAssetState = "uploading" | |
| 34 | + | ReleaseAssetUploaded ReleaseAssetState = "uploaded" | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | // ReleaseAsset is metadata only; its contents live in the blob store. | |
| 38 | + | type ReleaseAsset struct { | |
| 39 | + | ID, ReleaseID int64 | |
| 40 | + | Repo RepoName | |
| 41 | + | Name, Label, ContentType string | |
| 42 | + | Size int64 | |
| 43 | + | Digest string | |
| 44 | + | State ReleaseAssetState | |
| 45 | + | Uploader string | |
| 46 | + | // Charged counts bytes reserved against repository quota. | |
| 47 | + | Charged int64 | |
| 48 | + | // MultipartID identifies an unfinished upload for cleanup. | |
| 49 | + | MultipartID string | |
| 50 | + | CreatedAt, UpdatedAt time.Time | |
| 51 | + | Version uint64 | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // BlobKey contains only a globally allocated identifier. | |
| 55 | + | func (a ReleaseAsset) BlobKey() string { return fmt.Sprintf("release/%020d", a.ID) } | |
| 56 | + | ||
| 57 | + | const ( | |
| 58 | + | ReleaseMaxTitleLen = 256 | |
| 59 | + | ReleaseMaxNotesLen = 125_000 | |
| 60 | + | ReleaseAssetMaxNameLen = 255 | |
| 61 | + | ReleaseAssetMaxLabelLen = 255 | |
| 62 | + | ReleaseAssetPendingMax = 24 * time.Hour | |
| 63 | + | DefaultReleaseAssetMax = 2 << 30 | |
| 64 | + | DefaultReleaseAssetsMax = 1000 | |
| 65 | + | DefaultReleaseQuota = 20 << 30 | |
| 66 | + | ) | |
| 67 | + | ||
| 68 | + | // ParseReleaseTag validates a short tag using Git reference-name rules. | |
| 69 | + | func ParseReleaseTag(s string) (string, error) { | |
| 70 | + | if s == "" { | |
| 71 | + | return "", fmt.Errorf("%w: empty tag name", ErrInvalid) | |
| 72 | + | } | |
| 73 | + | if strings.HasPrefix(s, "refs/") { | |
| 74 | + | return "", fmt.Errorf("%w: %q: give the short tag name", ErrInvalid, s) | |
| 75 | + | } | |
| 76 | + | if reason := refNameProblem(s); reason != "" { | |
| 77 | + | return "", fmt.Errorf("%w: %q: %s", ErrInvalid, s, reason) | |
| 78 | + | } | |
| 79 | + | return s, nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func cleanReleaseText(s string, limit int, multiline bool) (string, error) { | |
| 83 | + | if !utf8.ValidString(s) { | |
| 84 | + | return "", fmt.Errorf("%w: invalid UTF-8", ErrInvalid) | |
| 85 | + | } | |
| 86 | + | s = norm.NFC.String(s) | |
| 87 | + | s = strings.Map(func(r rune) rune { | |
| 88 | + | if multiline && (r == '\n' || r == '\t' || r == '\r') { | |
| 89 | + | return r | |
| 90 | + | } | |
| 91 | + | if unicode.IsControl(r) || (!multiline && isBidiControl(r)) { | |
| 92 | + | return -1 | |
| 93 | + | } | |
| 94 | + | return r | |
| 95 | + | }, s) | |
| 96 | + | if utf8.RuneCountInString(s) > limit { | |
| 97 | + | return "", fmt.Errorf("%w: text exceeds %d characters", ErrInvalid, limit) | |
| 98 | + | } | |
| 99 | + | return s, nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // ParseReleaseTitle normalizes and cleans an optional title. | |
| 103 | + | func ParseReleaseTitle(s string) (string, error) { | |
| 104 | + | return cleanReleaseText(s, ReleaseMaxTitleLen, false) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // ParseReleaseNotes validates and cleans multiline notes. | |
| 108 | + | func ParseReleaseNotes(s string) (string, error) { | |
| 109 | + | return cleanReleaseText(s, ReleaseMaxNotesLen, true) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // ParseReleaseAssetLabel normalizes and cleans an optional label. | |
| 113 | + | func ParseReleaseAssetLabel(s string) (string, error) { | |
| 114 | + | return cleanReleaseText(s, ReleaseAssetMaxLabelLen, false) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // ParseReleaseAssetName validates a single filename without rewriting it. | |
| 118 | + | func ParseReleaseAssetName(s string) (string, error) { | |
| 119 | + | if !utf8.ValidString(s) || s == "" || s == "." || s == ".." || utf8.RuneCountInString(s) > ReleaseAssetMaxNameLen || strings.ContainsAny(s, "/\\") { | |
| 120 | + | return "", fmt.Errorf("%w: invalid asset name", ErrInvalid) | |
| 121 | + | } | |
| 122 | + | for _, r := range s { | |
| 123 | + | if unicode.IsControl(r) { | |
| 124 | + | return "", fmt.Errorf("%w: control character in asset name", ErrInvalid) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | return s, nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // ParseReleaseContentType validates a printable ASCII media type. | |
| 131 | + | func ParseReleaseContentType(s string) (string, error) { | |
| 132 | + | if s == "" { | |
| 133 | + | return "application/octet-stream", nil | |
| 134 | + | } | |
| 135 | + | if len(s) > 255 { | |
| 136 | + | return "", fmt.Errorf("%w: media type too long", ErrInvalid) | |
| 137 | + | } | |
| 138 | + | for _, r := range s { | |
| 139 | + | if r < 32 || r > 126 { | |
| 140 | + | return "", fmt.Errorf("%w: invalid media type", ErrInvalid) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | if _, _, err := mime.ParseMediaType(s); err != nil { | |
| 144 | + | return "", fmt.Errorf("%w: media type: %v", ErrInvalid, err) | |
| 145 | + | } | |
| 146 | + | return s, nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Published reports whether the release is public to repository readers. | |
| 150 | + | func (r Release) Published() bool { return !r.Draft } | |
| 151 | + | ||
| 152 | + | // LatestRelease selects a pin first, then the newest stable published release. | |
| 153 | + | func LatestRelease(rs []Release) (Release, bool) { | |
| 154 | + | var best Release | |
| 155 | + | ok := false | |
| 156 | + | for _, r := range rs { | |
| 157 | + | if r.Draft || r.Prerelease { | |
| 158 | + | continue | |
| 159 | + | } | |
| 160 | + | if !ok || (r.MakeLatest && !best.MakeLatest) || (r.MakeLatest == best.MakeLatest && (r.CreatedAt.After(best.CreatedAt) || (r.CreatedAt.Equal(best.CreatedAt) && r.ID > best.ID))) { | |
| 161 | + | best, ok = r, true | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | return best, ok | |
| 165 | + | } | |
A internal/domain/release_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package domain_test | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "bnema.dev/ermite/internal/domain" | |
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestReleaseParsers(t *testing.T) { | |
| 13 | + | for _, tc := range []struct { | |
| 14 | + | parse func(string) (string, error) | |
| 15 | + | good, bad []string | |
| 16 | + | }{ | |
| 17 | + | {domain.ParseReleaseTag, []string{"v1.0", "nightly/x"}, []string{"", "refs/tags/v1", "..", "a..b", "a b"}}, | |
| 18 | + | {domain.ParseReleaseTitle, []string{"", strings.Repeat("é", 256)}, []string{strings.Repeat("x", 257), "\xff"}}, | |
| 19 | + | {domain.ParseReleaseNotes, []string{"", "a\n\t\rb", strings.Repeat("x", 125000)}, []string{"\xff", strings.Repeat("x", 125001)}}, | |
| 20 | + | {domain.ParseReleaseAssetName, []string{"a b", "é", strings.Repeat("x", 255)}, []string{"", ".", "..", "a/b", "a\\b", "a\x00", "\xff", strings.Repeat("x", 256)}}, | |
| 21 | + | {domain.ParseReleaseAssetLabel, []string{"", strings.Repeat("x", 255)}, []string{"\xff", strings.Repeat("x", 256)}}, | |
| 22 | + | {domain.ParseReleaseContentType, []string{"", "text/plain; charset=utf-8"}, []string{"bad/type/type", "text/plain\n", strings.Repeat("x", 256), "é"}}, | |
| 23 | + | } { | |
| 24 | + | for _, s := range tc.good { | |
| 25 | + | _, err := tc.parse(s) | |
| 26 | + | require.NoError(t, err, s) | |
| 27 | + | } | |
| 28 | + | for _, s := range tc.bad { | |
| 29 | + | _, err := tc.parse(s) | |
| 30 | + | require.ErrorIs(t, err, domain.ErrInvalid, s) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | title, err := domain.ParseReleaseTitle("e\u0301\x00\u202e") | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | require.Equal(t, "é", title) | |
| 36 | + | notes, err := domain.ParseReleaseNotes("a\x00\n\t\r") | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.Equal(t, "a\n\t\r", notes) | |
| 39 | + | require.Equal(t, "release/00000000000000000001", (domain.ReleaseAsset{ID: 1}).BlobKey()) | |
| 40 | + | scope, err := domain.ParseScope("release:write") | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | require.Equal(t, domain.ScopeReleaseWrite, scope) | |
| 43 | + | } | |
| 44 | + | func TestLatestRelease(t *testing.T) { | |
| 45 | + | now := time.Now() | |
| 46 | + | rs := []domain.Release{{ID: 1, CreatedAt: now, MakeLatest: true}, {ID: 2, CreatedAt: now.Add(time.Hour)}, {ID: 3, Draft: true, MakeLatest: true}, {ID: 4, Prerelease: true, MakeLatest: true}} | |
| 47 | + | r, ok := domain.LatestRelease(rs) | |
| 48 | + | require.True(t, ok) | |
| 49 | + | require.EqualValues(t, 1, r.ID) | |
| 50 | + | rs[0].MakeLatest = false | |
| 51 | + | r, ok = domain.LatestRelease(rs) | |
| 52 | + | require.True(t, ok) | |
| 53 | + | require.EqualValues(t, 2, r.ID) | |
| 54 | + | rs[0].CreatedAt = rs[1].CreatedAt | |
| 55 | + | r, _ = domain.LatestRelease(rs) | |
| 56 | + | require.EqualValues(t, 2, r.ID) | |
| 57 | + | _, ok = domain.LatestRelease(rs[2:]) | |
| 58 | + | require.False(t, ok) | |
| 59 | + | } | |
M internal/domain/token.go +4 -2
| @@ -45,16 +45,18 @@ const ( | |||
| 45 | 45 | // ScopeIssueWrite creates, comments on, closes and reopens issues. Moderation | |
| 46 | 46 | // is not part of it: it stays with the owner. | |
| 47 | 47 | ScopeIssueWrite Scope = "issue:write" | |
| 48 | + | // ScopeReleaseWrite creates, edits and deletes releases and their assets. | |
| 49 | + | ScopeReleaseWrite Scope = "release:write" | |
| 48 | 50 | ) | |
| 49 | 51 | ||
| 50 | - | // AllScopes returns the twelve scopes in a stable order. | |
| 52 | + | // AllScopes returns the thirteen scopes in a stable order. | |
| 51 | 53 | func AllScopes() []Scope { | |
| 52 | 54 | return []Scope{ | |
| 53 | 55 | ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin, | |
| 54 | 56 | ScopeMRWrite, ScopeMRMerge, ScopeMRForceMerge, ScopeMRReview, | |
| 55 | 57 | ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister, | |
| 56 | 58 | ScopeTokenAdmin, | |
| 57 | - | ScopeIssueWrite, | |
| 59 | + | ScopeIssueWrite, ScopeReleaseWrite, | |
| 58 | 60 | } | |
| 59 | 61 | } | |
| 60 | 62 | ||
M internal/domain/token_test.go +5 -1
| @@ -16,7 +16,11 @@ import ( | |||
| 16 | 16 | func TestScopes(t *testing.T) { | |
| 17 | 17 | t.Parallel() | |
| 18 | 18 | all := domain.AllScopes() | |
| 19 | - | assert.Len(t, all, 12) | |
| 19 | + | assert.Len(t, all, 13) | |
| 20 | + | assert.Equal(t, domain.ScopeMRForceMerge, all[5]) | |
| 21 | + | assert.Equal(t, domain.ScopeReleaseWrite, all[len(all)-1]) | |
| 22 | + | assert.Contains(t, all, domain.ScopeReleaseWrite) | |
| 23 | + | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[:len(all)-1]}).HasOwnerRights(), "missing only release:write") | |
| 20 | 24 | assert.Contains(t, all, domain.ScopeIssueWrite) | |
| 21 | 25 | got, err := domain.ParseScope("issue:write") | |
| 22 | 26 | require.NoError(t, err) | |
A internal/httprange/httprange.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package httprange selects the byte range of a download from the Range and | |
| 4 | + | // If-Range headers of a request. The HTTP adapters that serve release assets | |
| 5 | + | // share it, so they answer the same way. | |
| 6 | + | package httprange | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "errors" | |
| 10 | + | "net/http" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // ErrUnsatisfiable means the request asked for a range the content cannot give. | |
| 16 | + | // The caller answers 416 with "Content-Range: bytes */<size>". | |
| 17 | + | var ErrUnsatisfiable = errors.New("httprange: unsatisfiable byte range") | |
| 18 | + | ||
| 19 | + | // Select returns the part of a size-byte body to send. | |
| 20 | + | // | |
| 21 | + | // Without a usable Range it returns the whole body (0, size, false, nil): | |
| 22 | + | // - the method is not GET (HEAD answers with the headers of the full body); | |
| 23 | + | // - there is no Range header, its unit is not "bytes", or it lists several | |
| 24 | + | // ranges (ADR 0034: multipart ranges are not served); | |
| 25 | + | // - If-Range is present and is not exactly etag. The comparison is strong: | |
| 26 | + | // a weak validator or an HTTP date never matches. | |
| 27 | + | // | |
| 28 | + | // A single "bytes=a-b", "bytes=a-" or "bytes=-n" gives (offset, length, true, | |
| 29 | + | // nil); an end past the content is cut to it. A malformed range, a start at or | |
| 30 | + | // past size, a suffix of zero, or any range of an empty body returns | |
| 31 | + | // ErrUnsatisfiable. | |
| 32 | + | func Select(r *http.Request, size int64, etag string) (offset, length int64, partial bool, err error) { | |
| 33 | + | whole := func() (int64, int64, bool, error) { return 0, size, false, nil } | |
| 34 | + | if r.Method != http.MethodGet { | |
| 35 | + | return whole() | |
| 36 | + | } | |
| 37 | + | value := strings.Join(r.Header.Values("Range"), ",") | |
| 38 | + | if value == "" || strings.Contains(value, ",") || !strings.HasPrefix(value, "bytes=") { | |
| 39 | + | return whole() | |
| 40 | + | } | |
| 41 | + | if ifRange := r.Header.Get("If-Range"); ifRange != "" && (etag == "" || ifRange != etag || strings.HasPrefix(ifRange, "W/")) { | |
| 42 | + | return whole() | |
| 43 | + | } | |
| 44 | + | a, b, ok := strings.Cut(strings.TrimSpace(strings.TrimPrefix(value, "bytes=")), "-") | |
| 45 | + | if !ok || size <= 0 { | |
| 46 | + | return 0, 0, false, ErrUnsatisfiable | |
| 47 | + | } | |
| 48 | + | if a == "" { | |
| 49 | + | n, e := strconv.ParseInt(b, 10, 64) | |
| 50 | + | if e != nil || n <= 0 { | |
| 51 | + | return 0, 0, false, ErrUnsatisfiable | |
| 52 | + | } | |
| 53 | + | n = min(n, size) | |
| 54 | + | return size - n, n, true, nil | |
| 55 | + | } | |
| 56 | + | start, e := strconv.ParseInt(a, 10, 64) | |
| 57 | + | if e != nil || start < 0 || start >= size { | |
| 58 | + | return 0, 0, false, ErrUnsatisfiable | |
| 59 | + | } | |
| 60 | + | end := size - 1 | |
| 61 | + | if b != "" { | |
| 62 | + | end, e = strconv.ParseInt(b, 10, 64) | |
| 63 | + | if e != nil || end < start { | |
| 64 | + | return 0, 0, false, ErrUnsatisfiable | |
| 65 | + | } | |
| 66 | + | end = min(end, size-1) | |
| 67 | + | } | |
| 68 | + | return start, end - start + 1, true, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // ContentRange is the Content-Range value of a partial answer. | |
| 72 | + | func ContentRange(offset, length, size int64) string { | |
| 73 | + | return "bytes " + strconv.FormatInt(offset, 10) + "-" + strconv.FormatInt(offset+length-1, 10) + "/" + strconv.FormatInt(size, 10) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Unsatisfied is the Content-Range value of a 416 answer. | |
| 77 | + | func Unsatisfied(size int64) string { return "bytes */" + strconv.FormatInt(size, 10) } | |
A internal/httprange/httprange_test.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package httprange_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/httprange" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestSelect(t *testing.T) { | |
| 16 | + | const etag = `"sha256-abc"` | |
| 17 | + | for _, tc := range []struct { | |
| 18 | + | name string | |
| 19 | + | method, rng, ifRange string | |
| 20 | + | size int64 | |
| 21 | + | offset, length int64 | |
| 22 | + | partial bool | |
| 23 | + | err error | |
| 24 | + | }{ | |
| 25 | + | {name: "no range", size: 10, length: 10}, | |
| 26 | + | {name: "closed", rng: "bytes=2-4", size: 10, offset: 2, length: 3, partial: true}, | |
| 27 | + | {name: "open", rng: "bytes=7-", size: 10, offset: 7, length: 3, partial: true}, | |
| 28 | + | {name: "suffix", rng: "bytes=-4", size: 10, offset: 6, length: 4, partial: true}, | |
| 29 | + | {name: "suffix longer than body", rng: "bytes=-40", size: 10, offset: 0, length: 10, partial: true}, | |
| 30 | + | {name: "end cut to size", rng: "bytes=8-99", size: 10, offset: 8, length: 2, partial: true}, | |
| 31 | + | {name: "spaces", rng: "bytes= 1-2 ", size: 10, offset: 1, length: 2, partial: true}, | |
| 32 | + | {name: "start past end", rng: "bytes=10-", size: 10, err: httprange.ErrUnsatisfiable}, | |
| 33 | + | {name: "end before start", rng: "bytes=5-4", size: 10, err: httprange.ErrUnsatisfiable}, | |
| 34 | + | {name: "zero suffix", rng: "bytes=-0", size: 10, err: httprange.ErrUnsatisfiable}, | |
| 35 | + | {name: "garbage", rng: "bytes=a-b", size: 10, err: httprange.ErrUnsatisfiable}, | |
| 36 | + | {name: "no dash", rng: "bytes=5", size: 10, err: httprange.ErrUnsatisfiable}, | |
| 37 | + | {name: "empty body", rng: "bytes=0-", size: 0, err: httprange.ErrUnsatisfiable}, | |
| 38 | + | {name: "multiple ignored", rng: "bytes=0-1,3-4", size: 10, length: 10}, | |
| 39 | + | {name: "other unit ignored", rng: "items=0-1", size: 10, length: 10}, | |
| 40 | + | {name: "head ignores range", method: http.MethodHead, rng: "bytes=2-4", size: 10, length: 10}, | |
| 41 | + | {name: "head ignores bad range", method: http.MethodHead, rng: "bytes=99-", size: 10, length: 10}, | |
| 42 | + | {name: "matching if-range", rng: "bytes=2-4", ifRange: etag, size: 10, offset: 2, length: 3, partial: true}, | |
| 43 | + | {name: "stale if-range", rng: "bytes=2-4", ifRange: `"other"`, size: 10, length: 10}, | |
| 44 | + | {name: "date if-range", rng: "bytes=2-4", ifRange: "Wed, 21 Oct 2015 07:28:00 GMT", size: 10, length: 10}, | |
| 45 | + | {name: "weak if-range", rng: "bytes=2-4", ifRange: "W/" + etag, size: 10, length: 10}, | |
| 46 | + | {name: "stale if-range with bad range", rng: "bytes=99-", ifRange: `"other"`, size: 10, length: 10}, | |
| 47 | + | } { | |
| 48 | + | t.Run(tc.name, func(t *testing.T) { | |
| 49 | + | method := tc.method | |
| 50 | + | if method == "" { | |
| 51 | + | method = http.MethodGet | |
| 52 | + | } | |
| 53 | + | r := httptest.NewRequest(method, "/x", nil) | |
| 54 | + | if tc.rng != "" { | |
| 55 | + | r.Header.Set("Range", tc.rng) | |
| 56 | + | } | |
| 57 | + | if tc.ifRange != "" { | |
| 58 | + | r.Header.Set("If-Range", tc.ifRange) | |
| 59 | + | } | |
| 60 | + | offset, length, partial, err := httprange.Select(r, tc.size, etag) | |
| 61 | + | require.ErrorIs(t, err, tc.err) | |
| 62 | + | require.Equal(t, tc.offset, offset) | |
| 63 | + | require.Equal(t, tc.length, length) | |
| 64 | + | require.Equal(t, tc.partial, partial) | |
| 65 | + | }) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestContentRange(t *testing.T) { | |
| 70 | + | require.Equal(t, "bytes 2-4/10", httprange.ContentRange(2, 3, 10)) | |
| 71 | + | require.Equal(t, "bytes */10", httprange.Unsatisfied(10)) | |
| 72 | + | } | |
A internal/ports/blob.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package ports | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // BlobStore keeps the contents of release assets and CI artifacts, never | |
| 13 | + | // their metadata. Keys are built by the use cases from ids only ("a/b/c", | |
| 14 | + | // ASCII, no empty segment, no ".", no ".." segment, no leading "/", ≤ 512 | |
| 15 | + | // bytes); an adapter rejects any other key with domain.ErrInvalid. | |
| 16 | + | // *blobdisk.Store and *blobs3.Store implement it. | |
| 17 | + | type BlobStore interface { | |
| 18 | + | Put(ctx context.Context, key string, r io.Reader, size int64) (BlobInfo, error) // size ≥ 0 required; replaces; a failure leaves no object | |
| 19 | + | Open(ctx context.Context, key string, offset, length int64) (io.ReadCloser, BlobInfo, error) // length < 0: to the end; offset ≥ size → domain.ErrOutOfRange | |
| 20 | + | Stat(ctx context.Context, key string) (BlobInfo, error) | |
| 21 | + | Delete(ctx context.Context, key string) error // absent: nil | |
| 22 | + | List(ctx context.Context, prefix string, fn func(BlobInfo) error) error // lexicographic; fn error stops and is returned | |
| 23 | + | NewMultipart(ctx context.Context, key string) (MultipartUpload, error) | |
| 24 | + | ListMultipart(ctx context.Context, prefix string, fn func(MultipartInfo) error) error | |
| 25 | + | AbortMultipart(ctx context.Context, key, id string) error // for GC of uploads listed by ListMultipart | |
| 26 | + | Check(ctx context.Context) error // startup probe; never creates anything | |
| 27 | + | } | |
| 28 | + | type BlobInfo struct { | |
| 29 | + | Key string | |
| 30 | + | Size int64 | |
| 31 | + | Digest string | |
| 32 | + | ModTime time.Time | |
| 33 | + | } // Digest "sha256:<hex>" from Put, "" from Stat/List/Open on S3 | |
| 34 | + | type MultipartInfo struct { | |
| 35 | + | Key, ID string | |
| 36 | + | Started time.Time | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // MultipartUpload uploads one object in parts of BlobMinPartSize or more | |
| 40 | + | // (the last may be smaller), at most BlobMaxParts, numbered from 1 in the | |
| 41 | + | // order given to Complete. Abort discards everything; Complete or Abort | |
| 42 | + | // must be called exactly once. | |
| 43 | + | type MultipartUpload interface { | |
| 44 | + | ID() string | |
| 45 | + | UploadPart(ctx context.Context, n int, r io.Reader, size int64) error | |
| 46 | + | Complete(ctx context.Context) (BlobInfo, error) // parts in number order; Digest "" (the caller hashes) | |
| 47 | + | Abort(ctx context.Context) error | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | const ( | |
| 51 | + | BlobMinPartSize = 5 << 20 | |
| 52 | + | BlobPartSize = 8 << 20 | |
| 53 | + | BlobMaxParts = 10_000 | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | // ValidBlobKey reports whether key follows the rule above. | |
| 57 | + | func ValidBlobKey(key string) bool { | |
| 58 | + | if len(key) == 0 || len(key) > 512 { | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | for _, c := range []byte(key) { | |
| 62 | + | if c > 127 { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | for _, segment := range strings.Split(key, "/") { | |
| 67 | + | if segment == "" || segment == "." || segment == ".." { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | return true | |
| 72 | + | } | |
A internal/ports/blob_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package ports_test | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite/internal/ports" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestValidBlobKey(t *testing.T) { | |
| 13 | + | for _, v := range []struct { | |
| 14 | + | key string | |
| 15 | + | ok bool | |
| 16 | + | }{{"ci/00000000000000000001/x", true}, {"a/.tmp-object", true}, {"a.parts/b", true}, {"", false}, {"/a", false}, {"a//b", false}, {"a/../b", false}, {"a/./b", false}, {"é", false}, {strings.Repeat("a", 513), false}} { | |
| 17 | + | t.Run(v.key, func(t *testing.T) { require.Equal(t, v.ok, ports.ValidBlobKey(v.key)) }) | |
| 18 | + | } | |
| 19 | + | } | |
M internal/ports/ci_artifact.go +3 -13
| @@ -75,24 +75,14 @@ type CIArtifactRef struct { | |||
| 75 | 75 | ID int64 | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | - | // CIArtifactFiles keeps the contents of the CI artifacts and their staged | |
| 79 | - | // blocks, never in the metadata store. Files are named by ids only. | |
| 78 | + | // CIArtifactFiles reads and removes the artifacts recorded before the blob | |
| 79 | + | // store (ADR 0034); removed once they have all expired. *ciartifacts.Store | |
| 80 | + | // implements it. Files are named by ids only. | |
| 80 | 81 | type CIArtifactFiles interface { | |
| 81 | - | // PutBlock writes src into a new block file of the artifact, synced, and | |
| 82 | - | // returns its size. A failure of src or of the write leaves no file. | |
| 83 | - | PutBlock(ctx context.Context, ref CIArtifactRef, file string, src io.Reader) (int64, error) | |
| 84 | - | // RemoveBlock deletes one block file; a missing one is not an error. | |
| 85 | - | RemoveBlock(ctx context.Context, ref CIArtifactRef, file string) error | |
| 86 | - | // Assemble concatenates the block files in order into the content of the | |
| 87 | - | // artifact, which replaces any earlier one whole, and returns its size and | |
| 88 | - | // digest ("sha256:<hex>"). A missing block file is domain.ErrNotFound. | |
| 89 | - | Assemble(ctx context.Context, ref CIArtifactRef, files []string) (size int64, digest string, err error) | |
| 90 | 82 | // Open returns the content from offset, or domain.ErrNotFound. | |
| 91 | 83 | Open(ctx context.Context, ref CIArtifactRef, offset int64) (io.ReadCloser, error) | |
| 92 | 84 | // Remove deletes the content and the blocks of the artifact. | |
| 93 | 85 | Remove(ctx context.Context, ref CIArtifactRef) error | |
| 94 | - | // RemoveBlocks deletes the blocks of the artifact. | |
| 95 | - | RemoveBlocks(ctx context.Context, ref CIArtifactRef) error | |
| 96 | 86 | // RemoveRuns deletes the files of the runs. | |
| 97 | 87 | RemoveRuns(ctx context.Context, runIDs []int64) error | |
| 98 | 88 | // RunIDs lists the runs that have a directory, ArtifactIDs the artifacts | |
A internal/ports/mocks/BlobStore.go +644 -0
| @@ -0,0 +1,644 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewBlobStore creates a new instance of BlobStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewBlobStore(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *BlobStore { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &BlobStore{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // BlobStore is an autogenerated mock type for the BlobStore type | |
| 39 | + | type BlobStore struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type BlobStore_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *BlobStore) EXPECT() *BlobStore_Expecter { | |
| 48 | + | return &BlobStore_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // AbortMultipart provides a mock function for the type BlobStore | |
| 52 | + | func (_mock *BlobStore) AbortMultipart(ctx context.Context, key string, id string) error { | |
| 53 | + | ret := _mock.Called(ctx, key, id) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for AbortMultipart") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 61 | + | r0 = returnFunc(ctx, key, id) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Error(0) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // BlobStore_AbortMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AbortMultipart' | |
| 69 | + | type BlobStore_AbortMultipart_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // AbortMultipart is a helper method to define mock.On call | |
| 74 | + | // - ctx context.Context | |
| 75 | + | // - key string | |
| 76 | + | // - id string | |
| 77 | + | func (_e *BlobStore_Expecter) AbortMultipart(ctx any, key any, id any) *BlobStore_AbortMultipart_Call { | |
| 78 | + | return &BlobStore_AbortMultipart_Call{Call: _e.mock.On("AbortMultipart", ctx, key, id)} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (_c *BlobStore_AbortMultipart_Call) Run(run func(ctx context.Context, key string, id string)) *BlobStore_AbortMultipart_Call { | |
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | + | var arg0 context.Context | |
| 84 | + | if args[0] != nil { | |
| 85 | + | arg0 = args[0].(context.Context) | |
| 86 | + | } | |
| 87 | + | var arg1 string | |
| 88 | + | if args[1] != nil { | |
| 89 | + | arg1 = args[1].(string) | |
| 90 | + | } | |
| 91 | + | var arg2 string | |
| 92 | + | if args[2] != nil { | |
| 93 | + | arg2 = args[2].(string) | |
| 94 | + | } | |
| 95 | + | run( | |
| 96 | + | arg0, | |
| 97 | + | arg1, | |
| 98 | + | arg2, | |
| 99 | + | ) | |
| 100 | + | }) | |
| 101 | + | return _c | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (_c *BlobStore_AbortMultipart_Call) Return(err error) *BlobStore_AbortMultipart_Call { | |
| 105 | + | _c.Call.Return(err) | |
| 106 | + | return _c | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (_c *BlobStore_AbortMultipart_Call) RunAndReturn(run func(ctx context.Context, key string, id string) error) *BlobStore_AbortMultipart_Call { | |
| 110 | + | _c.Call.Return(run) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Check provides a mock function for the type BlobStore | |
| 115 | + | func (_mock *BlobStore) Check(ctx context.Context) error { | |
| 116 | + | ret := _mock.Called(ctx) | |
| 117 | + | ||
| 118 | + | if len(ret) == 0 { | |
| 119 | + | panic("no return value specified for Check") | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | var r0 error | |
| 123 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 124 | + | r0 = returnFunc(ctx) | |
| 125 | + | } else { | |
| 126 | + | r0 = ret.Error(0) | |
| 127 | + | } | |
| 128 | + | return r0 | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // BlobStore_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check' | |
| 132 | + | type BlobStore_Check_Call struct { | |
| 133 | + | *mock.Call | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Check is a helper method to define mock.On call | |
| 137 | + | // - ctx context.Context | |
| 138 | + | func (_e *BlobStore_Expecter) Check(ctx any) *BlobStore_Check_Call { | |
| 139 | + | return &BlobStore_Check_Call{Call: _e.mock.On("Check", ctx)} | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (_c *BlobStore_Check_Call) Run(run func(ctx context.Context)) *BlobStore_Check_Call { | |
| 143 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 144 | + | var arg0 context.Context | |
| 145 | + | if args[0] != nil { | |
| 146 | + | arg0 = args[0].(context.Context) | |
| 147 | + | } | |
| 148 | + | run( | |
| 149 | + | arg0, | |
| 150 | + | ) | |
| 151 | + | }) | |
| 152 | + | return _c | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (_c *BlobStore_Check_Call) Return(err error) *BlobStore_Check_Call { | |
| 156 | + | _c.Call.Return(err) | |
| 157 | + | return _c | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (_c *BlobStore_Check_Call) RunAndReturn(run func(ctx context.Context) error) *BlobStore_Check_Call { | |
| 161 | + | _c.Call.Return(run) | |
| 162 | + | return _c | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Delete provides a mock function for the type BlobStore | |
| 166 | + | func (_mock *BlobStore) Delete(ctx context.Context, key string) error { | |
| 167 | + | ret := _mock.Called(ctx, key) | |
| 168 | + | ||
| 169 | + | if len(ret) == 0 { | |
| 170 | + | panic("no return value specified for Delete") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | var r0 error | |
| 174 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 175 | + | r0 = returnFunc(ctx, key) | |
| 176 | + | } else { | |
| 177 | + | r0 = ret.Error(0) | |
| 178 | + | } | |
| 179 | + | return r0 | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // BlobStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 183 | + | type BlobStore_Delete_Call struct { | |
| 184 | + | *mock.Call | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // Delete is a helper method to define mock.On call | |
| 188 | + | // - ctx context.Context | |
| 189 | + | // - key string | |
| 190 | + | func (_e *BlobStore_Expecter) Delete(ctx any, key any) *BlobStore_Delete_Call { | |
| 191 | + | return &BlobStore_Delete_Call{Call: _e.mock.On("Delete", ctx, key)} | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (_c *BlobStore_Delete_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Delete_Call { | |
| 195 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 196 | + | var arg0 context.Context | |
| 197 | + | if args[0] != nil { | |
| 198 | + | arg0 = args[0].(context.Context) | |
| 199 | + | } | |
| 200 | + | var arg1 string | |
| 201 | + | if args[1] != nil { | |
| 202 | + | arg1 = args[1].(string) | |
| 203 | + | } | |
| 204 | + | run( | |
| 205 | + | arg0, | |
| 206 | + | arg1, | |
| 207 | + | ) | |
| 208 | + | }) | |
| 209 | + | return _c | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (_c *BlobStore_Delete_Call) Return(err error) *BlobStore_Delete_Call { | |
| 213 | + | _c.Call.Return(err) | |
| 214 | + | return _c | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (_c *BlobStore_Delete_Call) RunAndReturn(run func(ctx context.Context, key string) error) *BlobStore_Delete_Call { | |
| 218 | + | _c.Call.Return(run) | |
| 219 | + | return _c | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // List provides a mock function for the type BlobStore | |
| 223 | + | func (_mock *BlobStore) List(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error { | |
| 224 | + | ret := _mock.Called(ctx, prefix, fn) | |
| 225 | + | ||
| 226 | + | if len(ret) == 0 { | |
| 227 | + | panic("no return value specified for List") | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | var r0 error | |
| 231 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.BlobInfo) error) error); ok { | |
| 232 | + | r0 = returnFunc(ctx, prefix, fn) | |
| 233 | + | } else { | |
| 234 | + | r0 = ret.Error(0) | |
| 235 | + | } | |
| 236 | + | return r0 | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // BlobStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 240 | + | type BlobStore_List_Call struct { | |
| 241 | + | *mock.Call | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // List is a helper method to define mock.On call | |
| 245 | + | // - ctx context.Context | |
| 246 | + | // - prefix string | |
| 247 | + | // - fn func(ports.BlobInfo) error | |
| 248 | + | func (_e *BlobStore_Expecter) List(ctx any, prefix any, fn any) *BlobStore_List_Call { | |
| 249 | + | return &BlobStore_List_Call{Call: _e.mock.On("List", ctx, prefix, fn)} | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func (_c *BlobStore_List_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error)) *BlobStore_List_Call { | |
| 253 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 254 | + | var arg0 context.Context | |
| 255 | + | if args[0] != nil { | |
| 256 | + | arg0 = args[0].(context.Context) | |
| 257 | + | } | |
| 258 | + | var arg1 string | |
| 259 | + | if args[1] != nil { | |
| 260 | + | arg1 = args[1].(string) | |
| 261 | + | } | |
| 262 | + | var arg2 func(ports.BlobInfo) error | |
| 263 | + | if args[2] != nil { | |
| 264 | + | arg2 = args[2].(func(ports.BlobInfo) error) | |
| 265 | + | } | |
| 266 | + | run( | |
| 267 | + | arg0, | |
| 268 | + | arg1, | |
| 269 | + | arg2, | |
| 270 | + | ) | |
| 271 | + | }) | |
| 272 | + | return _c | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func (_c *BlobStore_List_Call) Return(err error) *BlobStore_List_Call { | |
| 276 | + | _c.Call.Return(err) | |
| 277 | + | return _c | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func (_c *BlobStore_List_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.BlobInfo) error) error) *BlobStore_List_Call { | |
| 281 | + | _c.Call.Return(run) | |
| 282 | + | return _c | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // ListMultipart provides a mock function for the type BlobStore | |
| 286 | + | func (_mock *BlobStore) ListMultipart(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error { | |
| 287 | + | ret := _mock.Called(ctx, prefix, fn) | |
| 288 | + | ||
| 289 | + | if len(ret) == 0 { | |
| 290 | + | panic("no return value specified for ListMultipart") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | var r0 error | |
| 294 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, func(ports.MultipartInfo) error) error); ok { | |
| 295 | + | r0 = returnFunc(ctx, prefix, fn) | |
| 296 | + | } else { | |
| 297 | + | r0 = ret.Error(0) | |
| 298 | + | } | |
| 299 | + | return r0 | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // BlobStore_ListMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListMultipart' | |
| 303 | + | type BlobStore_ListMultipart_Call struct { | |
| 304 | + | *mock.Call | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // ListMultipart is a helper method to define mock.On call | |
| 308 | + | // - ctx context.Context | |
| 309 | + | // - prefix string | |
| 310 | + | // - fn func(ports.MultipartInfo) error | |
| 311 | + | func (_e *BlobStore_Expecter) ListMultipart(ctx any, prefix any, fn any) *BlobStore_ListMultipart_Call { | |
| 312 | + | return &BlobStore_ListMultipart_Call{Call: _e.mock.On("ListMultipart", ctx, prefix, fn)} | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func (_c *BlobStore_ListMultipart_Call) Run(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error)) *BlobStore_ListMultipart_Call { | |
| 316 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 317 | + | var arg0 context.Context | |
| 318 | + | if args[0] != nil { | |
| 319 | + | arg0 = args[0].(context.Context) | |
| 320 | + | } | |
| 321 | + | var arg1 string | |
| 322 | + | if args[1] != nil { | |
| 323 | + | arg1 = args[1].(string) | |
| 324 | + | } | |
| 325 | + | var arg2 func(ports.MultipartInfo) error | |
| 326 | + | if args[2] != nil { | |
| 327 | + | arg2 = args[2].(func(ports.MultipartInfo) error) | |
| 328 | + | } | |
| 329 | + | run( | |
| 330 | + | arg0, | |
| 331 | + | arg1, | |
| 332 | + | arg2, | |
| 333 | + | ) | |
| 334 | + | }) | |
| 335 | + | return _c | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func (_c *BlobStore_ListMultipart_Call) Return(err error) *BlobStore_ListMultipart_Call { | |
| 339 | + | _c.Call.Return(err) | |
| 340 | + | return _c | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func (_c *BlobStore_ListMultipart_Call) RunAndReturn(run func(ctx context.Context, prefix string, fn func(ports.MultipartInfo) error) error) *BlobStore_ListMultipart_Call { | |
| 344 | + | _c.Call.Return(run) | |
| 345 | + | return _c | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // NewMultipart provides a mock function for the type BlobStore | |
| 349 | + | func (_mock *BlobStore) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) { | |
| 350 | + | ret := _mock.Called(ctx, key) | |
| 351 | + | ||
| 352 | + | if len(ret) == 0 { | |
| 353 | + | panic("no return value specified for NewMultipart") | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | var r0 ports.MultipartUpload | |
| 357 | + | var r1 error | |
| 358 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.MultipartUpload, error)); ok { | |
| 359 | + | return returnFunc(ctx, key) | |
| 360 | + | } | |
| 361 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.MultipartUpload); ok { | |
| 362 | + | r0 = returnFunc(ctx, key) | |
| 363 | + | } else { | |
| 364 | + | if ret.Get(0) != nil { | |
| 365 | + | r0 = ret.Get(0).(ports.MultipartUpload) | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 369 | + | r1 = returnFunc(ctx, key) | |
| 370 | + | } else { | |
| 371 | + | r1 = ret.Error(1) | |
| 372 | + | } | |
| 373 | + | return r0, r1 | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // BlobStore_NewMultipart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewMultipart' | |
| 377 | + | type BlobStore_NewMultipart_Call struct { | |
| 378 | + | *mock.Call | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // NewMultipart is a helper method to define mock.On call | |
| 382 | + | // - ctx context.Context | |
| 383 | + | // - key string | |
| 384 | + | func (_e *BlobStore_Expecter) NewMultipart(ctx any, key any) *BlobStore_NewMultipart_Call { | |
| 385 | + | return &BlobStore_NewMultipart_Call{Call: _e.mock.On("NewMultipart", ctx, key)} | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func (_c *BlobStore_NewMultipart_Call) Run(run func(ctx context.Context, key string)) *BlobStore_NewMultipart_Call { | |
| 389 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 390 | + | var arg0 context.Context | |
| 391 | + | if args[0] != nil { | |
| 392 | + | arg0 = args[0].(context.Context) | |
| 393 | + | } | |
| 394 | + | var arg1 string | |
| 395 | + | if args[1] != nil { | |
| 396 | + | arg1 = args[1].(string) | |
| 397 | + | } | |
| 398 | + | run( | |
| 399 | + | arg0, | |
| 400 | + | arg1, | |
| 401 | + | ) | |
| 402 | + | }) | |
| 403 | + | return _c | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func (_c *BlobStore_NewMultipart_Call) Return(multipartUpload ports.MultipartUpload, err error) *BlobStore_NewMultipart_Call { | |
| 407 | + | _c.Call.Return(multipartUpload, err) | |
| 408 | + | return _c | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func (_c *BlobStore_NewMultipart_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.MultipartUpload, error)) *BlobStore_NewMultipart_Call { | |
| 412 | + | _c.Call.Return(run) | |
| 413 | + | return _c | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // Open provides a mock function for the type BlobStore | |
| 417 | + | func (_mock *BlobStore) Open(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error) { | |
| 418 | + | ret := _mock.Called(ctx, key, offset, length) | |
| 419 | + | ||
| 420 | + | if len(ret) == 0 { | |
| 421 | + | panic("no return value specified for Open") | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | var r0 io.ReadCloser | |
| 425 | + | var r1 ports.BlobInfo | |
| 426 | + | var r2 error | |
| 427 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) (io.ReadCloser, ports.BlobInfo, error)); ok { | |
| 428 | + | return returnFunc(ctx, key, offset, length) | |
| 429 | + | } | |
| 430 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int64, int64) io.ReadCloser); ok { | |
| 431 | + | r0 = returnFunc(ctx, key, offset, length) | |
| 432 | + | } else { | |
| 433 | + | if ret.Get(0) != nil { | |
| 434 | + | r0 = ret.Get(0).(io.ReadCloser) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int64, int64) ports.BlobInfo); ok { | |
| 438 | + | r1 = returnFunc(ctx, key, offset, length) | |
| 439 | + | } else { | |
| 440 | + | r1 = ret.Get(1).(ports.BlobInfo) | |
| 441 | + | } | |
| 442 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string, int64, int64) error); ok { | |
| 443 | + | r2 = returnFunc(ctx, key, offset, length) | |
| 444 | + | } else { | |
| 445 | + | r2 = ret.Error(2) | |
| 446 | + | } | |
| 447 | + | return r0, r1, r2 | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // BlobStore_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open' | |
| 451 | + | type BlobStore_Open_Call struct { | |
| 452 | + | *mock.Call | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | // Open is a helper method to define mock.On call | |
| 456 | + | // - ctx context.Context | |
| 457 | + | // - key string | |
| 458 | + | // - offset int64 | |
| 459 | + | // - length int64 | |
| 460 | + | func (_e *BlobStore_Expecter) Open(ctx any, key any, offset any, length any) *BlobStore_Open_Call { | |
| 461 | + | return &BlobStore_Open_Call{Call: _e.mock.On("Open", ctx, key, offset, length)} | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func (_c *BlobStore_Open_Call) Run(run func(ctx context.Context, key string, offset int64, length int64)) *BlobStore_Open_Call { | |
| 465 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 466 | + | var arg0 context.Context | |
| 467 | + | if args[0] != nil { | |
| 468 | + | arg0 = args[0].(context.Context) | |
| 469 | + | } | |
| 470 | + | var arg1 string | |
| 471 | + | if args[1] != nil { | |
| 472 | + | arg1 = args[1].(string) | |
| 473 | + | } | |
| 474 | + | var arg2 int64 | |
| 475 | + | if args[2] != nil { | |
| 476 | + | arg2 = args[2].(int64) | |
| 477 | + | } | |
| 478 | + | var arg3 int64 | |
| 479 | + | if args[3] != nil { | |
| 480 | + | arg3 = args[3].(int64) | |
| 481 | + | } | |
| 482 | + | run( | |
| 483 | + | arg0, | |
| 484 | + | arg1, | |
| 485 | + | arg2, | |
| 486 | + | arg3, | |
| 487 | + | ) | |
| 488 | + | }) | |
| 489 | + | return _c | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func (_c *BlobStore_Open_Call) Return(readCloser io.ReadCloser, blobInfo ports.BlobInfo, err error) *BlobStore_Open_Call { | |
| 493 | + | _c.Call.Return(readCloser, blobInfo, err) | |
| 494 | + | return _c | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | func (_c *BlobStore_Open_Call) RunAndReturn(run func(ctx context.Context, key string, offset int64, length int64) (io.ReadCloser, ports.BlobInfo, error)) *BlobStore_Open_Call { | |
| 498 | + | _c.Call.Return(run) | |
| 499 | + | return _c | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | // Put provides a mock function for the type BlobStore | |
| 503 | + | func (_mock *BlobStore) Put(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) { | |
| 504 | + | ret := _mock.Called(ctx, key, r, size) | |
| 505 | + | ||
| 506 | + | if len(ret) == 0 { | |
| 507 | + | panic("no return value specified for Put") | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | var r0 ports.BlobInfo | |
| 511 | + | var r1 error | |
| 512 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) (ports.BlobInfo, error)); ok { | |
| 513 | + | return returnFunc(ctx, key, r, size) | |
| 514 | + | } | |
| 515 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, io.Reader, int64) ports.BlobInfo); ok { | |
| 516 | + | r0 = returnFunc(ctx, key, r, size) | |
| 517 | + | } else { | |
| 518 | + | r0 = ret.Get(0).(ports.BlobInfo) | |
| 519 | + | } | |
| 520 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, io.Reader, int64) error); ok { | |
| 521 | + | r1 = returnFunc(ctx, key, r, size) | |
| 522 | + | } else { | |
| 523 | + | r1 = ret.Error(1) | |
| 524 | + | } | |
| 525 | + | return r0, r1 | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // BlobStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put' | |
| 529 | + | type BlobStore_Put_Call struct { | |
| 530 | + | *mock.Call | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | // Put is a helper method to define mock.On call | |
| 534 | + | // - ctx context.Context | |
| 535 | + | // - key string | |
| 536 | + | // - r io.Reader | |
| 537 | + | // - size int64 | |
| 538 | + | func (_e *BlobStore_Expecter) Put(ctx any, key any, r any, size any) *BlobStore_Put_Call { | |
| 539 | + | return &BlobStore_Put_Call{Call: _e.mock.On("Put", ctx, key, r, size)} | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | func (_c *BlobStore_Put_Call) Run(run func(ctx context.Context, key string, r io.Reader, size int64)) *BlobStore_Put_Call { | |
| 543 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 544 | + | var arg0 context.Context | |
| 545 | + | if args[0] != nil { | |
| 546 | + | arg0 = args[0].(context.Context) | |
| 547 | + | } | |
| 548 | + | var arg1 string | |
| 549 | + | if args[1] != nil { | |
| 550 | + | arg1 = args[1].(string) | |
| 551 | + | } | |
| 552 | + | var arg2 io.Reader | |
| 553 | + | if args[2] != nil { | |
| 554 | + | arg2 = args[2].(io.Reader) | |
| 555 | + | } | |
| 556 | + | var arg3 int64 | |
| 557 | + | if args[3] != nil { | |
| 558 | + | arg3 = args[3].(int64) | |
| 559 | + | } | |
| 560 | + | run( | |
| 561 | + | arg0, | |
| 562 | + | arg1, | |
| 563 | + | arg2, | |
| 564 | + | arg3, | |
| 565 | + | ) | |
| 566 | + | }) | |
| 567 | + | return _c | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | func (_c *BlobStore_Put_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Put_Call { | |
| 571 | + | _c.Call.Return(blobInfo, err) | |
| 572 | + | return _c | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | func (_c *BlobStore_Put_Call) RunAndReturn(run func(ctx context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error)) *BlobStore_Put_Call { | |
| 576 | + | _c.Call.Return(run) | |
| 577 | + | return _c | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | // Stat provides a mock function for the type BlobStore | |
| 581 | + | func (_mock *BlobStore) Stat(ctx context.Context, key string) (ports.BlobInfo, error) { | |
| 582 | + | ret := _mock.Called(ctx, key) | |
| 583 | + | ||
| 584 | + | if len(ret) == 0 { | |
| 585 | + | panic("no return value specified for Stat") | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | var r0 ports.BlobInfo | |
| 589 | + | var r1 error | |
| 590 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.BlobInfo, error)); ok { | |
| 591 | + | return returnFunc(ctx, key) | |
| 592 | + | } | |
| 593 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.BlobInfo); ok { | |
| 594 | + | r0 = returnFunc(ctx, key) | |
| 595 | + | } else { | |
| 596 | + | r0 = ret.Get(0).(ports.BlobInfo) | |
| 597 | + | } | |
| 598 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 599 | + | r1 = returnFunc(ctx, key) | |
| 600 | + | } else { | |
| 601 | + | r1 = ret.Error(1) | |
| 602 | + | } | |
| 603 | + | return r0, r1 | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // BlobStore_Stat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stat' | |
| 607 | + | type BlobStore_Stat_Call struct { | |
| 608 | + | *mock.Call | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | // Stat is a helper method to define mock.On call | |
| 612 | + | // - ctx context.Context | |
| 613 | + | // - key string | |
| 614 | + | func (_e *BlobStore_Expecter) Stat(ctx any, key any) *BlobStore_Stat_Call { | |
| 615 | + | return &BlobStore_Stat_Call{Call: _e.mock.On("Stat", ctx, key)} | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | func (_c *BlobStore_Stat_Call) Run(run func(ctx context.Context, key string)) *BlobStore_Stat_Call { | |
| 619 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 620 | + | var arg0 context.Context | |
| 621 | + | if args[0] != nil { | |
| 622 | + | arg0 = args[0].(context.Context) | |
| 623 | + | } | |
| 624 | + | var arg1 string | |
| 625 | + | if args[1] != nil { | |
| 626 | + | arg1 = args[1].(string) | |
| 627 | + | } | |
| 628 | + | run( | |
| 629 | + | arg0, | |
| 630 | + | arg1, | |
| 631 | + | ) | |
| 632 | + | }) | |
| 633 | + | return _c | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | func (_c *BlobStore_Stat_Call) Return(blobInfo ports.BlobInfo, err error) *BlobStore_Stat_Call { | |
| 637 | + | _c.Call.Return(blobInfo, err) | |
| 638 | + | return _c | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | func (_c *BlobStore_Stat_Call) RunAndReturn(run func(ctx context.Context, key string) (ports.BlobInfo, error)) *BlobStore_Stat_Call { | |
| 642 | + | _c.Call.Return(run) | |
| 643 | + | return _c | |
| 644 | + | } | |
M internal/ports/mocks/CIArtifactFiles.go +0 -276
| @@ -116,84 +116,6 @@ func (_c *CIArtifactFiles_ArtifactIDs_Call) RunAndReturn(run func(ctx context.Co | |||
| 116 | 116 | return _c | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | - | // Assemble provides a mock function for the type CIArtifactFiles | |
| 120 | - | func (_mock *CIArtifactFiles) Assemble(ctx context.Context, ref ports.CIArtifactRef, files []string) (int64, string, error) { | |
| 121 | - | ret := _mock.Called(ctx, ref, files) | |
| 122 | - | ||
| 123 | - | if len(ret) == 0 { | |
| 124 | - | panic("no return value specified for Assemble") | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | var r0 int64 | |
| 128 | - | var r1 string | |
| 129 | - | var r2 error | |
| 130 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, []string) (int64, string, error)); ok { | |
| 131 | - | return returnFunc(ctx, ref, files) | |
| 132 | - | } | |
| 133 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, []string) int64); ok { | |
| 134 | - | r0 = returnFunc(ctx, ref, files) | |
| 135 | - | } else { | |
| 136 | - | r0 = ret.Get(0).(int64) | |
| 137 | - | } | |
| 138 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CIArtifactRef, []string) string); ok { | |
| 139 | - | r1 = returnFunc(ctx, ref, files) | |
| 140 | - | } else { | |
| 141 | - | r1 = ret.Get(1).(string) | |
| 142 | - | } | |
| 143 | - | if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CIArtifactRef, []string) error); ok { | |
| 144 | - | r2 = returnFunc(ctx, ref, files) | |
| 145 | - | } else { | |
| 146 | - | r2 = ret.Error(2) | |
| 147 | - | } | |
| 148 | - | return r0, r1, r2 | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | // CIArtifactFiles_Assemble_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Assemble' | |
| 152 | - | type CIArtifactFiles_Assemble_Call struct { | |
| 153 | - | *mock.Call | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // Assemble is a helper method to define mock.On call | |
| 157 | - | // - ctx context.Context | |
| 158 | - | // - ref ports.CIArtifactRef | |
| 159 | - | // - files []string | |
| 160 | - | func (_e *CIArtifactFiles_Expecter) Assemble(ctx any, ref any, files any) *CIArtifactFiles_Assemble_Call { | |
| 161 | - | return &CIArtifactFiles_Assemble_Call{Call: _e.mock.On("Assemble", ctx, ref, files)} | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | func (_c *CIArtifactFiles_Assemble_Call) Run(run func(ctx context.Context, ref ports.CIArtifactRef, files []string)) *CIArtifactFiles_Assemble_Call { | |
| 165 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 166 | - | var arg0 context.Context | |
| 167 | - | if args[0] != nil { | |
| 168 | - | arg0 = args[0].(context.Context) | |
| 169 | - | } | |
| 170 | - | var arg1 ports.CIArtifactRef | |
| 171 | - | if args[1] != nil { | |
| 172 | - | arg1 = args[1].(ports.CIArtifactRef) | |
| 173 | - | } | |
| 174 | - | var arg2 []string | |
| 175 | - | if args[2] != nil { | |
| 176 | - | arg2 = args[2].([]string) | |
| 177 | - | } | |
| 178 | - | run( | |
| 179 | - | arg0, | |
| 180 | - | arg1, | |
| 181 | - | arg2, | |
| 182 | - | ) | |
| 183 | - | }) | |
| 184 | - | return _c | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | func (_c *CIArtifactFiles_Assemble_Call) Return(size int64, digest string, err error) *CIArtifactFiles_Assemble_Call { | |
| 188 | - | _c.Call.Return(size, digest, err) | |
| 189 | - | return _c | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | func (_c *CIArtifactFiles_Assemble_Call) RunAndReturn(run func(ctx context.Context, ref ports.CIArtifactRef, files []string) (int64, string, error)) *CIArtifactFiles_Assemble_Call { | |
| 193 | - | _c.Call.Return(run) | |
| 194 | - | return _c | |
| 195 | - | } | |
| 196 | - | ||
| 197 | 119 | // Open provides a mock function for the type CIArtifactFiles | |
| 198 | 120 | func (_mock *CIArtifactFiles) Open(ctx context.Context, ref ports.CIArtifactRef, offset int64) (io.ReadCloser, error) { | |
| 199 | 121 | ret := _mock.Called(ctx, ref, offset) | |
| @@ -268,84 +190,6 @@ func (_c *CIArtifactFiles_Open_Call) RunAndReturn(run func(ctx context.Context, | |||
| 268 | 190 | return _c | |
| 269 | 191 | } | |
| 270 | 192 | ||
| 271 | - | // PutBlock provides a mock function for the type CIArtifactFiles | |
| 272 | - | func (_mock *CIArtifactFiles) PutBlock(ctx context.Context, ref ports.CIArtifactRef, file string, src io.Reader) (int64, error) { | |
| 273 | - | ret := _mock.Called(ctx, ref, file, src) | |
| 274 | - | ||
| 275 | - | if len(ret) == 0 { | |
| 276 | - | panic("no return value specified for PutBlock") | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | var r0 int64 | |
| 280 | - | var r1 error | |
| 281 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, string, io.Reader) (int64, error)); ok { | |
| 282 | - | return returnFunc(ctx, ref, file, src) | |
| 283 | - | } | |
| 284 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, string, io.Reader) int64); ok { | |
| 285 | - | r0 = returnFunc(ctx, ref, file, src) | |
| 286 | - | } else { | |
| 287 | - | r0 = ret.Get(0).(int64) | |
| 288 | - | } | |
| 289 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CIArtifactRef, string, io.Reader) error); ok { | |
| 290 | - | r1 = returnFunc(ctx, ref, file, src) | |
| 291 | - | } else { | |
| 292 | - | r1 = ret.Error(1) | |
| 293 | - | } | |
| 294 | - | return r0, r1 | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | // CIArtifactFiles_PutBlock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutBlock' | |
| 298 | - | type CIArtifactFiles_PutBlock_Call struct { | |
| 299 | - | *mock.Call | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | // PutBlock is a helper method to define mock.On call | |
| 303 | - | // - ctx context.Context | |
| 304 | - | // - ref ports.CIArtifactRef | |
| 305 | - | // - file string | |
| 306 | - | // - src io.Reader | |
| 307 | - | func (_e *CIArtifactFiles_Expecter) PutBlock(ctx any, ref any, file any, src any) *CIArtifactFiles_PutBlock_Call { | |
| 308 | - | return &CIArtifactFiles_PutBlock_Call{Call: _e.mock.On("PutBlock", ctx, ref, file, src)} | |
| 309 | - | } | |
| 310 | - | ||
| 311 | - | func (_c *CIArtifactFiles_PutBlock_Call) Run(run func(ctx context.Context, ref ports.CIArtifactRef, file string, src io.Reader)) *CIArtifactFiles_PutBlock_Call { | |
| 312 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 313 | - | var arg0 context.Context | |
| 314 | - | if args[0] != nil { | |
| 315 | - | arg0 = args[0].(context.Context) | |
| 316 | - | } | |
| 317 | - | var arg1 ports.CIArtifactRef | |
| 318 | - | if args[1] != nil { | |
| 319 | - | arg1 = args[1].(ports.CIArtifactRef) | |
| 320 | - | } | |
| 321 | - | var arg2 string | |
| 322 | - | if args[2] != nil { | |
| 323 | - | arg2 = args[2].(string) | |
| 324 | - | } | |
| 325 | - | var arg3 io.Reader | |
| 326 | - | if args[3] != nil { | |
| 327 | - | arg3 = args[3].(io.Reader) | |
| 328 | - | } | |
| 329 | - | run( | |
| 330 | - | arg0, | |
| 331 | - | arg1, | |
| 332 | - | arg2, | |
| 333 | - | arg3, | |
| 334 | - | ) | |
| 335 | - | }) | |
| 336 | - | return _c | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | func (_c *CIArtifactFiles_PutBlock_Call) Return(n int64, err error) *CIArtifactFiles_PutBlock_Call { | |
| 340 | - | _c.Call.Return(n, err) | |
| 341 | - | return _c | |
| 342 | - | } | |
| 343 | - | ||
| 344 | - | func (_c *CIArtifactFiles_PutBlock_Call) RunAndReturn(run func(ctx context.Context, ref ports.CIArtifactRef, file string, src io.Reader) (int64, error)) *CIArtifactFiles_PutBlock_Call { | |
| 345 | - | _c.Call.Return(run) | |
| 346 | - | return _c | |
| 347 | - | } | |
| 348 | - | ||
| 349 | 193 | // Remove provides a mock function for the type CIArtifactFiles | |
| 350 | 194 | func (_mock *CIArtifactFiles) Remove(ctx context.Context, ref ports.CIArtifactRef) error { | |
| 351 | 195 | ret := _mock.Called(ctx, ref) | |
| @@ -403,126 +247,6 @@ func (_c *CIArtifactFiles_Remove_Call) RunAndReturn(run func(ctx context.Context | |||
| 403 | 247 | return _c | |
| 404 | 248 | } | |
| 405 | 249 | ||
| 406 | - | // RemoveBlock provides a mock function for the type CIArtifactFiles | |
| 407 | - | func (_mock *CIArtifactFiles) RemoveBlock(ctx context.Context, ref ports.CIArtifactRef, file string) error { | |
| 408 | - | ret := _mock.Called(ctx, ref, file) | |
| 409 | - | ||
| 410 | - | if len(ret) == 0 { | |
| 411 | - | panic("no return value specified for RemoveBlock") | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | var r0 error | |
| 415 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef, string) error); ok { | |
| 416 | - | r0 = returnFunc(ctx, ref, file) | |
| 417 | - | } else { | |
| 418 | - | r0 = ret.Error(0) | |
| 419 | - | } | |
| 420 | - | return r0 | |
| 421 | - | } | |
| 422 | - | ||
| 423 | - | // CIArtifactFiles_RemoveBlock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveBlock' | |
| 424 | - | type CIArtifactFiles_RemoveBlock_Call struct { | |
| 425 | - | *mock.Call | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | // RemoveBlock is a helper method to define mock.On call | |
| 429 | - | // - ctx context.Context | |
| 430 | - | // - ref ports.CIArtifactRef | |
| 431 | - | // - file string | |
| 432 | - | func (_e *CIArtifactFiles_Expecter) RemoveBlock(ctx any, ref any, file any) *CIArtifactFiles_RemoveBlock_Call { | |
| 433 | - | return &CIArtifactFiles_RemoveBlock_Call{Call: _e.mock.On("RemoveBlock", ctx, ref, file)} | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | func (_c *CIArtifactFiles_RemoveBlock_Call) Run(run func(ctx context.Context, ref ports.CIArtifactRef, file string)) *CIArtifactFiles_RemoveBlock_Call { | |
| 437 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 438 | - | var arg0 context.Context | |
| 439 | - | if args[0] != nil { | |
| 440 | - | arg0 = args[0].(context.Context) | |
| 441 | - | } | |
| 442 | - | var arg1 ports.CIArtifactRef | |
| 443 | - | if args[1] != nil { | |
| 444 | - | arg1 = args[1].(ports.CIArtifactRef) | |
| 445 | - | } | |
| 446 | - | var arg2 string | |
| 447 | - | if args[2] != nil { | |
| 448 | - | arg2 = args[2].(string) | |
| 449 | - | } | |
| 450 | - | run( | |
| 451 | - | arg0, | |
| 452 | - | arg1, | |
| 453 | - | arg2, | |
| 454 | - | ) | |
| 455 | - | }) | |
| 456 | - | return _c | |
| 457 | - | } | |
| 458 | - | ||
| 459 | - | func (_c *CIArtifactFiles_RemoveBlock_Call) Return(err error) *CIArtifactFiles_RemoveBlock_Call { | |
| 460 | - | _c.Call.Return(err) | |
| 461 | - | return _c | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | func (_c *CIArtifactFiles_RemoveBlock_Call) RunAndReturn(run func(ctx context.Context, ref ports.CIArtifactRef, file string) error) *CIArtifactFiles_RemoveBlock_Call { | |
| 465 | - | _c.Call.Return(run) | |
| 466 | - | return _c | |
| 467 | - | } | |
| 468 | - | ||
| 469 | - | // RemoveBlocks provides a mock function for the type CIArtifactFiles | |
| 470 | - | func (_mock *CIArtifactFiles) RemoveBlocks(ctx context.Context, ref ports.CIArtifactRef) error { | |
| 471 | - | ret := _mock.Called(ctx, ref) | |
| 472 | - | ||
| 473 | - | if len(ret) == 0 { | |
| 474 | - | panic("no return value specified for RemoveBlocks") | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | var r0 error | |
| 478 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIArtifactRef) error); ok { | |
| 479 | - | r0 = returnFunc(ctx, ref) | |
| 480 | - | } else { | |
| 481 | - | r0 = ret.Error(0) | |
| 482 | - | } | |
| 483 | - | return r0 | |
| 484 | - | } | |
| 485 | - | ||
| 486 | - | // CIArtifactFiles_RemoveBlocks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveBlocks' | |
| 487 | - | type CIArtifactFiles_RemoveBlocks_Call struct { | |
| 488 | - | *mock.Call | |
| 489 | - | } | |
| 490 | - | ||
| 491 | - | // RemoveBlocks is a helper method to define mock.On call | |
| 492 | - | // - ctx context.Context | |
| 493 | - | // - ref ports.CIArtifactRef | |
| 494 | - | func (_e *CIArtifactFiles_Expecter) RemoveBlocks(ctx any, ref any) *CIArtifactFiles_RemoveBlocks_Call { | |
| 495 | - | return &CIArtifactFiles_RemoveBlocks_Call{Call: _e.mock.On("RemoveBlocks", ctx, ref)} | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | func (_c *CIArtifactFiles_RemoveBlocks_Call) Run(run func(ctx context.Context, ref ports.CIArtifactRef)) *CIArtifactFiles_RemoveBlocks_Call { | |
| 499 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 500 | - | var arg0 context.Context | |
| 501 | - | if args[0] != nil { | |
| 502 | - | arg0 = args[0].(context.Context) | |
| 503 | - | } | |
| 504 | - | var arg1 ports.CIArtifactRef | |
| 505 | - | if args[1] != nil { | |
| 506 | - | arg1 = args[1].(ports.CIArtifactRef) | |
| 507 | - | } | |
| 508 | - | run( | |
| 509 | - | arg0, | |
| 510 | - | arg1, | |
| 511 | - | ) | |
| 512 | - | }) | |
| 513 | - | return _c | |
| 514 | - | } | |
| 515 | - | ||
| 516 | - | func (_c *CIArtifactFiles_RemoveBlocks_Call) Return(err error) *CIArtifactFiles_RemoveBlocks_Call { | |
| 517 | - | _c.Call.Return(err) | |
| 518 | - | return _c | |
| 519 | - | } | |
| 520 | - | ||
| 521 | - | func (_c *CIArtifactFiles_RemoveBlocks_Call) RunAndReturn(run func(ctx context.Context, ref ports.CIArtifactRef) error) *CIArtifactFiles_RemoveBlocks_Call { | |
| 522 | - | _c.Call.Return(run) | |
| 523 | - | return _c | |
| 524 | - | } | |
| 525 | - | ||
| 526 | 250 | // RemoveRuns provides a mock function for the type CIArtifactFiles | |
| 527 | 251 | func (_mock *CIArtifactFiles) RemoveRuns(ctx context.Context, runIDs []int64) error { | |
| 528 | 252 | ret := _mock.Called(ctx, runIDs) | |
A internal/ports/mocks/GitHubReleaseAPI.go +1044 -0
| @@ -0,0 +1,1044 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | mock "github.com/stretchr/testify/mock" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // NewGitHubReleaseAPI creates a new instance of GitHubReleaseAPI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 17 | + | // The first argument is typically a *testing.T value. | |
| 18 | + | func NewGitHubReleaseAPI(t interface { | |
| 19 | + | mock.TestingT | |
| 20 | + | Cleanup(func()) | |
| 21 | + | }) *GitHubReleaseAPI { | |
| 22 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 23 | + | helper.Helper() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | mock := &GitHubReleaseAPI{} | |
| 27 | + | mock.Mock.Test(t) | |
| 28 | + | ||
| 29 | + | t.Cleanup(func() { | |
| 30 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 31 | + | helper.Helper() | |
| 32 | + | } | |
| 33 | + | mock.AssertExpectations(t) | |
| 34 | + | }) | |
| 35 | + | ||
| 36 | + | return mock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // GitHubReleaseAPI is an autogenerated mock type for the GitHubReleaseAPI type | |
| 40 | + | type GitHubReleaseAPI struct { | |
| 41 | + | mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type GitHubReleaseAPI_Expecter struct { | |
| 45 | + | mock *mock.Mock | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (_m *GitHubReleaseAPI) EXPECT() *GitHubReleaseAPI_Expecter { | |
| 49 | + | return &GitHubReleaseAPI_Expecter{mock: &_m.Mock} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // AssetByID provides a mock function for the type GitHubReleaseAPI | |
| 53 | + | func (_mock *GitHubReleaseAPI) AssetByID(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error) { | |
| 54 | + | ret := _mock.Called(ctx, p, owner, repo, assetID) | |
| 55 | + | ||
| 56 | + | if len(ret) == 0 { | |
| 57 | + | panic("no return value specified for AssetByID") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | var r0 domain.ReleaseAsset | |
| 61 | + | var r1 error | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) (domain.ReleaseAsset, error)); ok { | |
| 63 | + | return returnFunc(ctx, p, owner, repo, assetID) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) domain.ReleaseAsset); ok { | |
| 66 | + | r0 = returnFunc(ctx, p, owner, repo, assetID) | |
| 67 | + | } else { | |
| 68 | + | r0 = ret.Get(0).(domain.ReleaseAsset) | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, p, owner, repo, assetID) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // GitHubReleaseAPI_AssetByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssetByID' | |
| 79 | + | type GitHubReleaseAPI_AssetByID_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // AssetByID is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - p domain.Principal | |
| 86 | + | // - owner string | |
| 87 | + | // - repo string | |
| 88 | + | // - assetID int64 | |
| 89 | + | func (_e *GitHubReleaseAPI_Expecter) AssetByID(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_AssetByID_Call { | |
| 90 | + | return &GitHubReleaseAPI_AssetByID_Call{Call: _e.mock.On("AssetByID", ctx, p, owner, repo, assetID)} | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (_c *GitHubReleaseAPI_AssetByID_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_AssetByID_Call { | |
| 94 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 95 | + | var arg0 context.Context | |
| 96 | + | if args[0] != nil { | |
| 97 | + | arg0 = args[0].(context.Context) | |
| 98 | + | } | |
| 99 | + | var arg1 domain.Principal | |
| 100 | + | if args[1] != nil { | |
| 101 | + | arg1 = args[1].(domain.Principal) | |
| 102 | + | } | |
| 103 | + | var arg2 string | |
| 104 | + | if args[2] != nil { | |
| 105 | + | arg2 = args[2].(string) | |
| 106 | + | } | |
| 107 | + | var arg3 string | |
| 108 | + | if args[3] != nil { | |
| 109 | + | arg3 = args[3].(string) | |
| 110 | + | } | |
| 111 | + | var arg4 int64 | |
| 112 | + | if args[4] != nil { | |
| 113 | + | arg4 = args[4].(int64) | |
| 114 | + | } | |
| 115 | + | run( | |
| 116 | + | arg0, | |
| 117 | + | arg1, | |
| 118 | + | arg2, | |
| 119 | + | arg3, | |
| 120 | + | arg4, | |
| 121 | + | ) | |
| 122 | + | }) | |
| 123 | + | return _c | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (_c *GitHubReleaseAPI_AssetByID_Call) Return(releaseAsset domain.ReleaseAsset, err error) *GitHubReleaseAPI_AssetByID_Call { | |
| 127 | + | _c.Call.Return(releaseAsset, err) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (_c *GitHubReleaseAPI_AssetByID_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error)) *GitHubReleaseAPI_AssetByID_Call { | |
| 132 | + | _c.Call.Return(run) | |
| 133 | + | return _c | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // CreateRelease provides a mock function for the type GitHubReleaseAPI | |
| 137 | + | func (_mock *GitHubReleaseAPI) CreateRelease(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error) { | |
| 138 | + | ret := _mock.Called(ctx, p, owner, repo, body) | |
| 139 | + | ||
| 140 | + | if len(ret) == 0 { | |
| 141 | + | panic("no return value specified for CreateRelease") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | var r0 []byte | |
| 145 | + | var r1 error | |
| 146 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) ([]byte, error)); ok { | |
| 147 | + | return returnFunc(ctx, p, owner, repo, body) | |
| 148 | + | } | |
| 149 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) []byte); ok { | |
| 150 | + | r0 = returnFunc(ctx, p, owner, repo, body) | |
| 151 | + | } else { | |
| 152 | + | if ret.Get(0) != nil { | |
| 153 | + | r0 = ret.Get(0).([]byte) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, []byte) error); ok { | |
| 157 | + | r1 = returnFunc(ctx, p, owner, repo, body) | |
| 158 | + | } else { | |
| 159 | + | r1 = ret.Error(1) | |
| 160 | + | } | |
| 161 | + | return r0, r1 | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // GitHubReleaseAPI_CreateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRelease' | |
| 165 | + | type GitHubReleaseAPI_CreateRelease_Call struct { | |
| 166 | + | *mock.Call | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // CreateRelease is a helper method to define mock.On call | |
| 170 | + | // - ctx context.Context | |
| 171 | + | // - p domain.Principal | |
| 172 | + | // - owner string | |
| 173 | + | // - repo string | |
| 174 | + | // - body []byte | |
| 175 | + | func (_e *GitHubReleaseAPI_Expecter) CreateRelease(ctx any, p any, owner any, repo any, body any) *GitHubReleaseAPI_CreateRelease_Call { | |
| 176 | + | return &GitHubReleaseAPI_CreateRelease_Call{Call: _e.mock.On("CreateRelease", ctx, p, owner, repo, body)} | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (_c *GitHubReleaseAPI_CreateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte)) *GitHubReleaseAPI_CreateRelease_Call { | |
| 180 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 181 | + | var arg0 context.Context | |
| 182 | + | if args[0] != nil { | |
| 183 | + | arg0 = args[0].(context.Context) | |
| 184 | + | } | |
| 185 | + | var arg1 domain.Principal | |
| 186 | + | if args[1] != nil { | |
| 187 | + | arg1 = args[1].(domain.Principal) | |
| 188 | + | } | |
| 189 | + | var arg2 string | |
| 190 | + | if args[2] != nil { | |
| 191 | + | arg2 = args[2].(string) | |
| 192 | + | } | |
| 193 | + | var arg3 string | |
| 194 | + | if args[3] != nil { | |
| 195 | + | arg3 = args[3].(string) | |
| 196 | + | } | |
| 197 | + | var arg4 []byte | |
| 198 | + | if args[4] != nil { | |
| 199 | + | arg4 = args[4].([]byte) | |
| 200 | + | } | |
| 201 | + | run( | |
| 202 | + | arg0, | |
| 203 | + | arg1, | |
| 204 | + | arg2, | |
| 205 | + | arg3, | |
| 206 | + | arg4, | |
| 207 | + | ) | |
| 208 | + | }) | |
| 209 | + | return _c | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (_c *GitHubReleaseAPI_CreateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_CreateRelease_Call { | |
| 213 | + | _c.Call.Return(bytes, err) | |
| 214 | + | return _c | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (_c *GitHubReleaseAPI_CreateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error)) *GitHubReleaseAPI_CreateRelease_Call { | |
| 218 | + | _c.Call.Return(run) | |
| 219 | + | return _c | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // DeleteAsset provides a mock function for the type GitHubReleaseAPI | |
| 223 | + | func (_mock *GitHubReleaseAPI) DeleteAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error { | |
| 224 | + | ret := _mock.Called(ctx, p, owner, repo, assetID) | |
| 225 | + | ||
| 226 | + | if len(ret) == 0 { | |
| 227 | + | panic("no return value specified for DeleteAsset") | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | var r0 error | |
| 231 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |
| 232 | + | r0 = returnFunc(ctx, p, owner, repo, assetID) | |
| 233 | + | } else { | |
| 234 | + | r0 = ret.Error(0) | |
| 235 | + | } | |
| 236 | + | return r0 | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // GitHubReleaseAPI_DeleteAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAsset' | |
| 240 | + | type GitHubReleaseAPI_DeleteAsset_Call struct { | |
| 241 | + | *mock.Call | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // DeleteAsset is a helper method to define mock.On call | |
| 245 | + | // - ctx context.Context | |
| 246 | + | // - p domain.Principal | |
| 247 | + | // - owner string | |
| 248 | + | // - repo string | |
| 249 | + | // - assetID int64 | |
| 250 | + | func (_e *GitHubReleaseAPI_Expecter) DeleteAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_DeleteAsset_Call { | |
| 251 | + | return &GitHubReleaseAPI_DeleteAsset_Call{Call: _e.mock.On("DeleteAsset", ctx, p, owner, repo, assetID)} | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (_c *GitHubReleaseAPI_DeleteAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_DeleteAsset_Call { | |
| 255 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 256 | + | var arg0 context.Context | |
| 257 | + | if args[0] != nil { | |
| 258 | + | arg0 = args[0].(context.Context) | |
| 259 | + | } | |
| 260 | + | var arg1 domain.Principal | |
| 261 | + | if args[1] != nil { | |
| 262 | + | arg1 = args[1].(domain.Principal) | |
| 263 | + | } | |
| 264 | + | var arg2 string | |
| 265 | + | if args[2] != nil { | |
| 266 | + | arg2 = args[2].(string) | |
| 267 | + | } | |
| 268 | + | var arg3 string | |
| 269 | + | if args[3] != nil { | |
| 270 | + | arg3 = args[3].(string) | |
| 271 | + | } | |
| 272 | + | var arg4 int64 | |
| 273 | + | if args[4] != nil { | |
| 274 | + | arg4 = args[4].(int64) | |
| 275 | + | } | |
| 276 | + | run( | |
| 277 | + | arg0, | |
| 278 | + | arg1, | |
| 279 | + | arg2, | |
| 280 | + | arg3, | |
| 281 | + | arg4, | |
| 282 | + | ) | |
| 283 | + | }) | |
| 284 | + | return _c | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (_c *GitHubReleaseAPI_DeleteAsset_Call) Return(err error) *GitHubReleaseAPI_DeleteAsset_Call { | |
| 288 | + | _c.Call.Return(err) | |
| 289 | + | return _c | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (_c *GitHubReleaseAPI_DeleteAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error) *GitHubReleaseAPI_DeleteAsset_Call { | |
| 293 | + | _c.Call.Return(run) | |
| 294 | + | return _c | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // DeleteRelease provides a mock function for the type GitHubReleaseAPI | |
| 298 | + | func (_mock *GitHubReleaseAPI) DeleteRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error { | |
| 299 | + | ret := _mock.Called(ctx, p, owner, repo, id) | |
| 300 | + | ||
| 301 | + | if len(ret) == 0 { | |
| 302 | + | panic("no return value specified for DeleteRelease") | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | var r0 error | |
| 306 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |
| 307 | + | r0 = returnFunc(ctx, p, owner, repo, id) | |
| 308 | + | } else { | |
| 309 | + | r0 = ret.Error(0) | |
| 310 | + | } | |
| 311 | + | return r0 | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // GitHubReleaseAPI_DeleteRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRelease' | |
| 315 | + | type GitHubReleaseAPI_DeleteRelease_Call struct { | |
| 316 | + | *mock.Call | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // DeleteRelease is a helper method to define mock.On call | |
| 320 | + | // - ctx context.Context | |
| 321 | + | // - p domain.Principal | |
| 322 | + | // - owner string | |
| 323 | + | // - repo string | |
| 324 | + | // - id int64 | |
| 325 | + | func (_e *GitHubReleaseAPI_Expecter) DeleteRelease(ctx any, p any, owner any, repo any, id any) *GitHubReleaseAPI_DeleteRelease_Call { | |
| 326 | + | return &GitHubReleaseAPI_DeleteRelease_Call{Call: _e.mock.On("DeleteRelease", ctx, p, owner, repo, id)} | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func (_c *GitHubReleaseAPI_DeleteRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64)) *GitHubReleaseAPI_DeleteRelease_Call { | |
| 330 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 331 | + | var arg0 context.Context | |
| 332 | + | if args[0] != nil { | |
| 333 | + | arg0 = args[0].(context.Context) | |
| 334 | + | } | |
| 335 | + | var arg1 domain.Principal | |
| 336 | + | if args[1] != nil { | |
| 337 | + | arg1 = args[1].(domain.Principal) | |
| 338 | + | } | |
| 339 | + | var arg2 string | |
| 340 | + | if args[2] != nil { | |
| 341 | + | arg2 = args[2].(string) | |
| 342 | + | } | |
| 343 | + | var arg3 string | |
| 344 | + | if args[3] != nil { | |
| 345 | + | arg3 = args[3].(string) | |
| 346 | + | } | |
| 347 | + | var arg4 int64 | |
| 348 | + | if args[4] != nil { | |
| 349 | + | arg4 = args[4].(int64) | |
| 350 | + | } | |
| 351 | + | run( | |
| 352 | + | arg0, | |
| 353 | + | arg1, | |
| 354 | + | arg2, | |
| 355 | + | arg3, | |
| 356 | + | arg4, | |
| 357 | + | ) | |
| 358 | + | }) | |
| 359 | + | return _c | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (_c *GitHubReleaseAPI_DeleteRelease_Call) Return(err error) *GitHubReleaseAPI_DeleteRelease_Call { | |
| 363 | + | _c.Call.Return(err) | |
| 364 | + | return _c | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func (_c *GitHubReleaseAPI_DeleteRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error) *GitHubReleaseAPI_DeleteRelease_Call { | |
| 368 | + | _c.Call.Return(run) | |
| 369 | + | return _c | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // GetAsset provides a mock function for the type GitHubReleaseAPI | |
| 373 | + | func (_mock *GitHubReleaseAPI) GetAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error) { | |
| 374 | + | ret := _mock.Called(ctx, p, owner, repo, assetID) | |
| 375 | + | ||
| 376 | + | if len(ret) == 0 { | |
| 377 | + | panic("no return value specified for GetAsset") | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | var r0 []byte | |
| 381 | + | var r1 error | |
| 382 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) ([]byte, error)); ok { | |
| 383 | + | return returnFunc(ctx, p, owner, repo, assetID) | |
| 384 | + | } | |
| 385 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) []byte); ok { | |
| 386 | + | r0 = returnFunc(ctx, p, owner, repo, assetID) | |
| 387 | + | } else { | |
| 388 | + | if ret.Get(0) != nil { | |
| 389 | + | r0 = ret.Get(0).([]byte) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok { | |
| 393 | + | r1 = returnFunc(ctx, p, owner, repo, assetID) | |
| 394 | + | } else { | |
| 395 | + | r1 = ret.Error(1) | |
| 396 | + | } | |
| 397 | + | return r0, r1 | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // GitHubReleaseAPI_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset' | |
| 401 | + | type GitHubReleaseAPI_GetAsset_Call struct { | |
| 402 | + | *mock.Call | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // GetAsset is a helper method to define mock.On call | |
| 406 | + | // - ctx context.Context | |
| 407 | + | // - p domain.Principal | |
| 408 | + | // - owner string | |
| 409 | + | // - repo string | |
| 410 | + | // - assetID int64 | |
| 411 | + | func (_e *GitHubReleaseAPI_Expecter) GetAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_GetAsset_Call { | |
| 412 | + | return &GitHubReleaseAPI_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, p, owner, repo, assetID)} | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func (_c *GitHubReleaseAPI_GetAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_GetAsset_Call { | |
| 416 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 417 | + | var arg0 context.Context | |
| 418 | + | if args[0] != nil { | |
| 419 | + | arg0 = args[0].(context.Context) | |
| 420 | + | } | |
| 421 | + | var arg1 domain.Principal | |
| 422 | + | if args[1] != nil { | |
| 423 | + | arg1 = args[1].(domain.Principal) | |
| 424 | + | } | |
| 425 | + | var arg2 string | |
| 426 | + | if args[2] != nil { | |
| 427 | + | arg2 = args[2].(string) | |
| 428 | + | } | |
| 429 | + | var arg3 string | |
| 430 | + | if args[3] != nil { | |
| 431 | + | arg3 = args[3].(string) | |
| 432 | + | } | |
| 433 | + | var arg4 int64 | |
| 434 | + | if args[4] != nil { | |
| 435 | + | arg4 = args[4].(int64) | |
| 436 | + | } | |
| 437 | + | run( | |
| 438 | + | arg0, | |
| 439 | + | arg1, | |
| 440 | + | arg2, | |
| 441 | + | arg3, | |
| 442 | + | arg4, | |
| 443 | + | ) | |
| 444 | + | }) | |
| 445 | + | return _c | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func (_c *GitHubReleaseAPI_GetAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetAsset_Call { | |
| 449 | + | _c.Call.Return(bytes, err) | |
| 450 | + | return _c | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func (_c *GitHubReleaseAPI_GetAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error)) *GitHubReleaseAPI_GetAsset_Call { | |
| 454 | + | _c.Call.Return(run) | |
| 455 | + | return _c | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // GetRelease provides a mock function for the type GitHubReleaseAPI | |
| 459 | + | func (_mock *GitHubReleaseAPI) GetRelease(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error) { | |
| 460 | + | ret := _mock.Called(ctx, p, owner, repo, ref) | |
| 461 | + | ||
| 462 | + | if len(ret) == 0 { | |
| 463 | + | panic("no return value specified for GetRelease") | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | var r0 []byte | |
| 467 | + | var r1 error | |
| 468 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) ([]byte, error)); ok { | |
| 469 | + | return returnFunc(ctx, p, owner, repo, ref) | |
| 470 | + | } | |
| 471 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) []byte); ok { | |
| 472 | + | r0 = returnFunc(ctx, p, owner, repo, ref) | |
| 473 | + | } else { | |
| 474 | + | if ret.Get(0) != nil { | |
| 475 | + | r0 = ret.Get(0).([]byte) | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) error); ok { | |
| 479 | + | r1 = returnFunc(ctx, p, owner, repo, ref) | |
| 480 | + | } else { | |
| 481 | + | r1 = ret.Error(1) | |
| 482 | + | } | |
| 483 | + | return r0, r1 | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | // GitHubReleaseAPI_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease' | |
| 487 | + | type GitHubReleaseAPI_GetRelease_Call struct { | |
| 488 | + | *mock.Call | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // GetRelease is a helper method to define mock.On call | |
| 492 | + | // - ctx context.Context | |
| 493 | + | // - p domain.Principal | |
| 494 | + | // - owner string | |
| 495 | + | // - repo string | |
| 496 | + | // - ref ports.ReleaseRef | |
| 497 | + | func (_e *GitHubReleaseAPI_Expecter) GetRelease(ctx any, p any, owner any, repo any, ref any) *GitHubReleaseAPI_GetRelease_Call { | |
| 498 | + | return &GitHubReleaseAPI_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, p, owner, repo, ref)} | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | func (_c *GitHubReleaseAPI_GetRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef)) *GitHubReleaseAPI_GetRelease_Call { | |
| 502 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 503 | + | var arg0 context.Context | |
| 504 | + | if args[0] != nil { | |
| 505 | + | arg0 = args[0].(context.Context) | |
| 506 | + | } | |
| 507 | + | var arg1 domain.Principal | |
| 508 | + | if args[1] != nil { | |
| 509 | + | arg1 = args[1].(domain.Principal) | |
| 510 | + | } | |
| 511 | + | var arg2 string | |
| 512 | + | if args[2] != nil { | |
| 513 | + | arg2 = args[2].(string) | |
| 514 | + | } | |
| 515 | + | var arg3 string | |
| 516 | + | if args[3] != nil { | |
| 517 | + | arg3 = args[3].(string) | |
| 518 | + | } | |
| 519 | + | var arg4 ports.ReleaseRef | |
| 520 | + | if args[4] != nil { | |
| 521 | + | arg4 = args[4].(ports.ReleaseRef) | |
| 522 | + | } | |
| 523 | + | run( | |
| 524 | + | arg0, | |
| 525 | + | arg1, | |
| 526 | + | arg2, | |
| 527 | + | arg3, | |
| 528 | + | arg4, | |
| 529 | + | ) | |
| 530 | + | }) | |
| 531 | + | return _c | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | func (_c *GitHubReleaseAPI_GetRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetRelease_Call { | |
| 535 | + | _c.Call.Return(bytes, err) | |
| 536 | + | return _c | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | func (_c *GitHubReleaseAPI_GetRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error)) *GitHubReleaseAPI_GetRelease_Call { | |
| 540 | + | _c.Call.Return(run) | |
| 541 | + | return _c | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | // ListAssets provides a mock function for the type GitHubReleaseAPI | |
| 545 | + | func (_mock *GitHubReleaseAPI) ListAssets(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error) { | |
| 546 | + | ret := _mock.Called(ctx, p, owner, repo, id, page, perPage) | |
| 547 | + | ||
| 548 | + | if len(ret) == 0 { | |
| 549 | + | panic("no return value specified for ListAssets") | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | var r0 []byte | |
| 553 | + | var r1 string | |
| 554 | + | var r2 error | |
| 555 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) ([]byte, string, error)); ok { | |
| 556 | + | return returnFunc(ctx, p, owner, repo, id, page, perPage) | |
| 557 | + | } | |
| 558 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) []byte); ok { | |
| 559 | + | r0 = returnFunc(ctx, p, owner, repo, id, page, perPage) | |
| 560 | + | } else { | |
| 561 | + | if ret.Get(0) != nil { | |
| 562 | + | r0 = ret.Get(0).([]byte) | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, int, int) string); ok { | |
| 566 | + | r1 = returnFunc(ctx, p, owner, repo, id, page, perPage) | |
| 567 | + | } else { | |
| 568 | + | r1 = ret.Get(1).(string) | |
| 569 | + | } | |
| 570 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int64, int, int) error); ok { | |
| 571 | + | r2 = returnFunc(ctx, p, owner, repo, id, page, perPage) | |
| 572 | + | } else { | |
| 573 | + | r2 = ret.Error(2) | |
| 574 | + | } | |
| 575 | + | return r0, r1, r2 | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // GitHubReleaseAPI_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets' | |
| 579 | + | type GitHubReleaseAPI_ListAssets_Call struct { | |
| 580 | + | *mock.Call | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | // ListAssets is a helper method to define mock.On call | |
| 584 | + | // - ctx context.Context | |
| 585 | + | // - p domain.Principal | |
| 586 | + | // - owner string | |
| 587 | + | // - repo string | |
| 588 | + | // - id int64 | |
| 589 | + | // - page int | |
| 590 | + | // - perPage int | |
| 591 | + | func (_e *GitHubReleaseAPI_Expecter) ListAssets(ctx any, p any, owner any, repo any, id any, page any, perPage any) *GitHubReleaseAPI_ListAssets_Call { | |
| 592 | + | return &GitHubReleaseAPI_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, p, owner, repo, id, page, perPage)} | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | func (_c *GitHubReleaseAPI_ListAssets_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int)) *GitHubReleaseAPI_ListAssets_Call { | |
| 596 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 597 | + | var arg0 context.Context | |
| 598 | + | if args[0] != nil { | |
| 599 | + | arg0 = args[0].(context.Context) | |
| 600 | + | } | |
| 601 | + | var arg1 domain.Principal | |
| 602 | + | if args[1] != nil { | |
| 603 | + | arg1 = args[1].(domain.Principal) | |
| 604 | + | } | |
| 605 | + | var arg2 string | |
| 606 | + | if args[2] != nil { | |
| 607 | + | arg2 = args[2].(string) | |
| 608 | + | } | |
| 609 | + | var arg3 string | |
| 610 | + | if args[3] != nil { | |
| 611 | + | arg3 = args[3].(string) | |
| 612 | + | } | |
| 613 | + | var arg4 int64 | |
| 614 | + | if args[4] != nil { | |
| 615 | + | arg4 = args[4].(int64) | |
| 616 | + | } | |
| 617 | + | var arg5 int | |
| 618 | + | if args[5] != nil { | |
| 619 | + | arg5 = args[5].(int) | |
| 620 | + | } | |
| 621 | + | var arg6 int | |
| 622 | + | if args[6] != nil { | |
| 623 | + | arg6 = args[6].(int) | |
| 624 | + | } | |
| 625 | + | run( | |
| 626 | + | arg0, | |
| 627 | + | arg1, | |
| 628 | + | arg2, | |
| 629 | + | arg3, | |
| 630 | + | arg4, | |
| 631 | + | arg5, | |
| 632 | + | arg6, | |
| 633 | + | ) | |
| 634 | + | }) | |
| 635 | + | return _c | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | func (_c *GitHubReleaseAPI_ListAssets_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListAssets_Call { | |
| 639 | + | _c.Call.Return(body, link, err) | |
| 640 | + | return _c | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | func (_c *GitHubReleaseAPI_ListAssets_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListAssets_Call { | |
| 644 | + | _c.Call.Return(run) | |
| 645 | + | return _c | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | // ListReleases provides a mock function for the type GitHubReleaseAPI | |
| 649 | + | func (_mock *GitHubReleaseAPI) ListReleases(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error) { | |
| 650 | + | ret := _mock.Called(ctx, p, owner, repo, page, perPage) | |
| 651 | + | ||
| 652 | + | if len(ret) == 0 { | |
| 653 | + | panic("no return value specified for ListReleases") | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | var r0 []byte | |
| 657 | + | var r1 string | |
| 658 | + | var r2 error | |
| 659 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) ([]byte, string, error)); ok { | |
| 660 | + | return returnFunc(ctx, p, owner, repo, page, perPage) | |
| 661 | + | } | |
| 662 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) []byte); ok { | |
| 663 | + | r0 = returnFunc(ctx, p, owner, repo, page, perPage) | |
| 664 | + | } else { | |
| 665 | + | if ret.Get(0) != nil { | |
| 666 | + | r0 = ret.Get(0).([]byte) | |
| 667 | + | } | |
| 668 | + | } | |
| 669 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int, int) string); ok { | |
| 670 | + | r1 = returnFunc(ctx, p, owner, repo, page, perPage) | |
| 671 | + | } else { | |
| 672 | + | r1 = ret.Get(1).(string) | |
| 673 | + | } | |
| 674 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int, int) error); ok { | |
| 675 | + | r2 = returnFunc(ctx, p, owner, repo, page, perPage) | |
| 676 | + | } else { | |
| 677 | + | r2 = ret.Error(2) | |
| 678 | + | } | |
| 679 | + | return r0, r1, r2 | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | // GitHubReleaseAPI_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases' | |
| 683 | + | type GitHubReleaseAPI_ListReleases_Call struct { | |
| 684 | + | *mock.Call | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | // ListReleases is a helper method to define mock.On call | |
| 688 | + | // - ctx context.Context | |
| 689 | + | // - p domain.Principal | |
| 690 | + | // - owner string | |
| 691 | + | // - repo string | |
| 692 | + | // - page int | |
| 693 | + | // - perPage int | |
| 694 | + | func (_e *GitHubReleaseAPI_Expecter) ListReleases(ctx any, p any, owner any, repo any, page any, perPage any) *GitHubReleaseAPI_ListReleases_Call { | |
| 695 | + | return &GitHubReleaseAPI_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, p, owner, repo, page, perPage)} | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | func (_c *GitHubReleaseAPI_ListReleases_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int)) *GitHubReleaseAPI_ListReleases_Call { | |
| 699 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 700 | + | var arg0 context.Context | |
| 701 | + | if args[0] != nil { | |
| 702 | + | arg0 = args[0].(context.Context) | |
| 703 | + | } | |
| 704 | + | var arg1 domain.Principal | |
| 705 | + | if args[1] != nil { | |
| 706 | + | arg1 = args[1].(domain.Principal) | |
| 707 | + | } | |
| 708 | + | var arg2 string | |
| 709 | + | if args[2] != nil { | |
| 710 | + | arg2 = args[2].(string) | |
| 711 | + | } | |
| 712 | + | var arg3 string | |
| 713 | + | if args[3] != nil { | |
| 714 | + | arg3 = args[3].(string) | |
| 715 | + | } | |
| 716 | + | var arg4 int | |
| 717 | + | if args[4] != nil { | |
| 718 | + | arg4 = args[4].(int) | |
| 719 | + | } | |
| 720 | + | var arg5 int | |
| 721 | + | if args[5] != nil { | |
| 722 | + | arg5 = args[5].(int) | |
| 723 | + | } | |
| 724 | + | run( | |
| 725 | + | arg0, | |
| 726 | + | arg1, | |
| 727 | + | arg2, | |
| 728 | + | arg3, | |
| 729 | + | arg4, | |
| 730 | + | arg5, | |
| 731 | + | ) | |
| 732 | + | }) | |
| 733 | + | return _c | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | func (_c *GitHubReleaseAPI_ListReleases_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListReleases_Call { | |
| 737 | + | _c.Call.Return(body, link, err) | |
| 738 | + | return _c | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | func (_c *GitHubReleaseAPI_ListReleases_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListReleases_Call { | |
| 742 | + | _c.Call.Return(run) | |
| 743 | + | return _c | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | // UpdateAsset provides a mock function for the type GitHubReleaseAPI | |
| 747 | + | func (_mock *GitHubReleaseAPI) UpdateAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error) { | |
| 748 | + | ret := _mock.Called(ctx, p, owner, repo, assetID, body) | |
| 749 | + | ||
| 750 | + | if len(ret) == 0 { | |
| 751 | + | panic("no return value specified for UpdateAsset") | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | var r0 []byte | |
| 755 | + | var r1 error | |
| 756 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok { | |
| 757 | + | return returnFunc(ctx, p, owner, repo, assetID, body) | |
| 758 | + | } | |
| 759 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok { | |
| 760 | + | r0 = returnFunc(ctx, p, owner, repo, assetID, body) | |
| 761 | + | } else { | |
| 762 | + | if ret.Get(0) != nil { | |
| 763 | + | r0 = ret.Get(0).([]byte) | |
| 764 | + | } | |
| 765 | + | } | |
| 766 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok { | |
| 767 | + | r1 = returnFunc(ctx, p, owner, repo, assetID, body) | |
| 768 | + | } else { | |
| 769 | + | r1 = ret.Error(1) | |
| 770 | + | } | |
| 771 | + | return r0, r1 | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | // GitHubReleaseAPI_UpdateAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAsset' | |
| 775 | + | type GitHubReleaseAPI_UpdateAsset_Call struct { | |
| 776 | + | *mock.Call | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | // UpdateAsset is a helper method to define mock.On call | |
| 780 | + | // - ctx context.Context | |
| 781 | + | // - p domain.Principal | |
| 782 | + | // - owner string | |
| 783 | + | // - repo string | |
| 784 | + | // - assetID int64 | |
| 785 | + | // - body []byte | |
| 786 | + | func (_e *GitHubReleaseAPI_Expecter) UpdateAsset(ctx any, p any, owner any, repo any, assetID any, body any) *GitHubReleaseAPI_UpdateAsset_Call { | |
| 787 | + | return &GitHubReleaseAPI_UpdateAsset_Call{Call: _e.mock.On("UpdateAsset", ctx, p, owner, repo, assetID, body)} | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | func (_c *GitHubReleaseAPI_UpdateAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte)) *GitHubReleaseAPI_UpdateAsset_Call { | |
| 791 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 792 | + | var arg0 context.Context | |
| 793 | + | if args[0] != nil { | |
| 794 | + | arg0 = args[0].(context.Context) | |
| 795 | + | } | |
| 796 | + | var arg1 domain.Principal | |
| 797 | + | if args[1] != nil { | |
| 798 | + | arg1 = args[1].(domain.Principal) | |
| 799 | + | } | |
| 800 | + | var arg2 string | |
| 801 | + | if args[2] != nil { | |
| 802 | + | arg2 = args[2].(string) | |
| 803 | + | } | |
| 804 | + | var arg3 string | |
| 805 | + | if args[3] != nil { | |
| 806 | + | arg3 = args[3].(string) | |
| 807 | + | } | |
| 808 | + | var arg4 int64 | |
| 809 | + | if args[4] != nil { | |
| 810 | + | arg4 = args[4].(int64) | |
| 811 | + | } | |
| 812 | + | var arg5 []byte | |
| 813 | + | if args[5] != nil { | |
| 814 | + | arg5 = args[5].([]byte) | |
| 815 | + | } | |
| 816 | + | run( | |
| 817 | + | arg0, | |
| 818 | + | arg1, | |
| 819 | + | arg2, | |
| 820 | + | arg3, | |
| 821 | + | arg4, | |
| 822 | + | arg5, | |
| 823 | + | ) | |
| 824 | + | }) | |
| 825 | + | return _c | |
| 826 | + | } | |
| 827 | + | ||
| 828 | + | func (_c *GitHubReleaseAPI_UpdateAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateAsset_Call { | |
| 829 | + | _c.Call.Return(bytes, err) | |
| 830 | + | return _c | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | func (_c *GitHubReleaseAPI_UpdateAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateAsset_Call { | |
| 834 | + | _c.Call.Return(run) | |
| 835 | + | return _c | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | // UpdateRelease provides a mock function for the type GitHubReleaseAPI | |
| 839 | + | func (_mock *GitHubReleaseAPI) UpdateRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error) { | |
| 840 | + | ret := _mock.Called(ctx, p, owner, repo, id, body) | |
| 841 | + | ||
| 842 | + | if len(ret) == 0 { | |
| 843 | + | panic("no return value specified for UpdateRelease") | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | var r0 []byte | |
| 847 | + | var r1 error | |
| 848 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok { | |
| 849 | + | return returnFunc(ctx, p, owner, repo, id, body) | |
| 850 | + | } | |
| 851 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok { | |
| 852 | + | r0 = returnFunc(ctx, p, owner, repo, id, body) | |
| 853 | + | } else { | |
| 854 | + | if ret.Get(0) != nil { | |
| 855 | + | r0 = ret.Get(0).([]byte) | |
| 856 | + | } | |
| 857 | + | } | |
| 858 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok { | |
| 859 | + | r1 = returnFunc(ctx, p, owner, repo, id, body) | |
| 860 | + | } else { | |
| 861 | + | r1 = ret.Error(1) | |
| 862 | + | } | |
| 863 | + | return r0, r1 | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | // GitHubReleaseAPI_UpdateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRelease' | |
| 867 | + | type GitHubReleaseAPI_UpdateRelease_Call struct { | |
| 868 | + | *mock.Call | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | // UpdateRelease is a helper method to define mock.On call | |
| 872 | + | // - ctx context.Context | |
| 873 | + | // - p domain.Principal | |
| 874 | + | // - owner string | |
| 875 | + | // - repo string | |
| 876 | + | // - id int64 | |
| 877 | + | // - body []byte | |
| 878 | + | func (_e *GitHubReleaseAPI_Expecter) UpdateRelease(ctx any, p any, owner any, repo any, id any, body any) *GitHubReleaseAPI_UpdateRelease_Call { | |
| 879 | + | return &GitHubReleaseAPI_UpdateRelease_Call{Call: _e.mock.On("UpdateRelease", ctx, p, owner, repo, id, body)} | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | func (_c *GitHubReleaseAPI_UpdateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte)) *GitHubReleaseAPI_UpdateRelease_Call { | |
| 883 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 884 | + | var arg0 context.Context | |
| 885 | + | if args[0] != nil { | |
| 886 | + | arg0 = args[0].(context.Context) | |
| 887 | + | } | |
| 888 | + | var arg1 domain.Principal | |
| 889 | + | if args[1] != nil { | |
| 890 | + | arg1 = args[1].(domain.Principal) | |
| 891 | + | } | |
| 892 | + | var arg2 string | |
| 893 | + | if args[2] != nil { | |
| 894 | + | arg2 = args[2].(string) | |
| 895 | + | } | |
| 896 | + | var arg3 string | |
| 897 | + | if args[3] != nil { | |
| 898 | + | arg3 = args[3].(string) | |
| 899 | + | } | |
| 900 | + | var arg4 int64 | |
| 901 | + | if args[4] != nil { | |
| 902 | + | arg4 = args[4].(int64) | |
| 903 | + | } | |
| 904 | + | var arg5 []byte | |
| 905 | + | if args[5] != nil { | |
| 906 | + | arg5 = args[5].([]byte) | |
| 907 | + | } | |
| 908 | + | run( | |
| 909 | + | arg0, | |
| 910 | + | arg1, | |
| 911 | + | arg2, | |
| 912 | + | arg3, | |
| 913 | + | arg4, | |
| 914 | + | arg5, | |
| 915 | + | ) | |
| 916 | + | }) | |
| 917 | + | return _c | |
| 918 | + | } | |
| 919 | + | ||
| 920 | + | func (_c *GitHubReleaseAPI_UpdateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateRelease_Call { | |
| 921 | + | _c.Call.Return(bytes, err) | |
| 922 | + | return _c | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | func (_c *GitHubReleaseAPI_UpdateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateRelease_Call { | |
| 926 | + | _c.Call.Return(run) | |
| 927 | + | return _c | |
| 928 | + | } | |
| 929 | + | ||
| 930 | + | // UploadAsset provides a mock function for the type GitHubReleaseAPI | |
| 931 | + | func (_mock *GitHubReleaseAPI) UploadAsset(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error) { | |
| 932 | + | ret := _mock.Called(ctx, p, owner, repo, id, name, label, contentType, size, r) | |
| 933 | + | ||
| 934 | + | if len(ret) == 0 { | |
| 935 | + | panic("no return value specified for UploadAsset") | |
| 936 | + | } | |
| 937 | + | ||
| 938 | + | var r0 []byte | |
| 939 | + | var r1 error | |
| 940 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) ([]byte, error)); ok { | |
| 941 | + | return returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r) | |
| 942 | + | } | |
| 943 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) []byte); ok { | |
| 944 | + | r0 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r) | |
| 945 | + | } else { | |
| 946 | + | if ret.Get(0) != nil { | |
| 947 | + | r0 = ret.Get(0).([]byte) | |
| 948 | + | } | |
| 949 | + | } | |
| 950 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) error); ok { | |
| 951 | + | r1 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r) | |
| 952 | + | } else { | |
| 953 | + | r1 = ret.Error(1) | |
| 954 | + | } | |
| 955 | + | return r0, r1 | |
| 956 | + | } | |
| 957 | + | ||
| 958 | + | // GitHubReleaseAPI_UploadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadAsset' | |
| 959 | + | type GitHubReleaseAPI_UploadAsset_Call struct { | |
| 960 | + | *mock.Call | |
| 961 | + | } | |
| 962 | + | ||
| 963 | + | // UploadAsset is a helper method to define mock.On call | |
| 964 | + | // - ctx context.Context | |
| 965 | + | // - p domain.Principal | |
| 966 | + | // - owner string | |
| 967 | + | // - repo string | |
| 968 | + | // - id int64 | |
| 969 | + | // - name string | |
| 970 | + | // - label string | |
| 971 | + | // - contentType string | |
| 972 | + | // - size int64 | |
| 973 | + | // - r io.Reader | |
| 974 | + | func (_e *GitHubReleaseAPI_Expecter) UploadAsset(ctx any, p any, owner any, repo any, id any, name any, label any, contentType any, size any, r any) *GitHubReleaseAPI_UploadAsset_Call { | |
| 975 | + | return &GitHubReleaseAPI_UploadAsset_Call{Call: _e.mock.On("UploadAsset", ctx, p, owner, repo, id, name, label, contentType, size, r)} | |
| 976 | + | } | |
| 977 | + | ||
| 978 | + | func (_c *GitHubReleaseAPI_UploadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader)) *GitHubReleaseAPI_UploadAsset_Call { | |
| 979 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 980 | + | var arg0 context.Context | |
| 981 | + | if args[0] != nil { | |
| 982 | + | arg0 = args[0].(context.Context) | |
| 983 | + | } | |
| 984 | + | var arg1 domain.Principal | |
| 985 | + | if args[1] != nil { | |
| 986 | + | arg1 = args[1].(domain.Principal) | |
| 987 | + | } | |
| 988 | + | var arg2 string | |
| 989 | + | if args[2] != nil { | |
| 990 | + | arg2 = args[2].(string) | |
| 991 | + | } | |
| 992 | + | var arg3 string | |
| 993 | + | if args[3] != nil { | |
| 994 | + | arg3 = args[3].(string) | |
| 995 | + | } | |
| 996 | + | var arg4 int64 | |
| 997 | + | if args[4] != nil { | |
| 998 | + | arg4 = args[4].(int64) | |
| 999 | + | } | |
| 1000 | + | var arg5 string | |
| 1001 | + | if args[5] != nil { | |
| 1002 | + | arg5 = args[5].(string) | |
| 1003 | + | } | |
| 1004 | + | var arg6 string | |
| 1005 | + | if args[6] != nil { | |
| 1006 | + | arg6 = args[6].(string) | |
| 1007 | + | } | |
| 1008 | + | var arg7 string | |
| 1009 | + | if args[7] != nil { | |
| 1010 | + | arg7 = args[7].(string) | |
| 1011 | + | } | |
| 1012 | + | var arg8 int64 | |
| 1013 | + | if args[8] != nil { | |
| 1014 | + | arg8 = args[8].(int64) | |
| 1015 | + | } | |
| 1016 | + | var arg9 io.Reader | |
| 1017 | + | if args[9] != nil { | |
| 1018 | + | arg9 = args[9].(io.Reader) | |
| 1019 | + | } | |
| 1020 | + | run( | |
| 1021 | + | arg0, | |
| 1022 | + | arg1, | |
| 1023 | + | arg2, | |
| 1024 | + | arg3, | |
| 1025 | + | arg4, | |
| 1026 | + | arg5, | |
| 1027 | + | arg6, | |
| 1028 | + | arg7, | |
| 1029 | + | arg8, | |
| 1030 | + | arg9, | |
| 1031 | + | ) | |
| 1032 | + | }) | |
| 1033 | + | return _c | |
| 1034 | + | } | |
| 1035 | + | ||
| 1036 | + | func (_c *GitHubReleaseAPI_UploadAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UploadAsset_Call { | |
| 1037 | + | _c.Call.Return(bytes, err) | |
| 1038 | + | return _c | |
| 1039 | + | } | |
| 1040 | + | ||
| 1041 | + | func (_c *GitHubReleaseAPI_UploadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error)) *GitHubReleaseAPI_UploadAsset_Call { | |
| 1042 | + | _c.Call.Return(run) | |
| 1043 | + | return _c | |
| 1044 | + | } | |
A internal/ports/mocks/MultipartUpload.go +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewMultipartUpload creates a new instance of MultipartUpload. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewMultipartUpload(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *MultipartUpload { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &MultipartUpload{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // MultipartUpload is an autogenerated mock type for the MultipartUpload type | |
| 39 | + | type MultipartUpload struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type MultipartUpload_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *MultipartUpload) EXPECT() *MultipartUpload_Expecter { | |
| 48 | + | return &MultipartUpload_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Abort provides a mock function for the type MultipartUpload | |
| 52 | + | func (_mock *MultipartUpload) Abort(ctx context.Context) error { | |
| 53 | + | ret := _mock.Called(ctx) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Abort") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 61 | + | r0 = returnFunc(ctx) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Error(0) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // MultipartUpload_Abort_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Abort' | |
| 69 | + | type MultipartUpload_Abort_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Abort is a helper method to define mock.On call | |
| 74 | + | // - ctx context.Context | |
| 75 | + | func (_e *MultipartUpload_Expecter) Abort(ctx any) *MultipartUpload_Abort_Call { | |
| 76 | + | return &MultipartUpload_Abort_Call{Call: _e.mock.On("Abort", ctx)} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *MultipartUpload_Abort_Call) Run(run func(ctx context.Context)) *MultipartUpload_Abort_Call { | |
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | + | var arg0 context.Context | |
| 82 | + | if args[0] != nil { | |
| 83 | + | arg0 = args[0].(context.Context) | |
| 84 | + | } | |
| 85 | + | run( | |
| 86 | + | arg0, | |
| 87 | + | ) | |
| 88 | + | }) | |
| 89 | + | return _c | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *MultipartUpload_Abort_Call) Return(err error) *MultipartUpload_Abort_Call { | |
| 93 | + | _c.Call.Return(err) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *MultipartUpload_Abort_Call) RunAndReturn(run func(ctx context.Context) error) *MultipartUpload_Abort_Call { | |
| 98 | + | _c.Call.Return(run) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Complete provides a mock function for the type MultipartUpload | |
| 103 | + | func (_mock *MultipartUpload) Complete(ctx context.Context) (ports.BlobInfo, error) { | |
| 104 | + | ret := _mock.Called(ctx) | |
| 105 | + | ||
| 106 | + | if len(ret) == 0 { | |
| 107 | + | panic("no return value specified for Complete") | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | var r0 ports.BlobInfo | |
| 111 | + | var r1 error | |
| 112 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.BlobInfo, error)); ok { | |
| 113 | + | return returnFunc(ctx) | |
| 114 | + | } | |
| 115 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ports.BlobInfo); ok { | |
| 116 | + | r0 = returnFunc(ctx) | |
| 117 | + | } else { | |
| 118 | + | r0 = ret.Get(0).(ports.BlobInfo) | |
| 119 | + | } | |
| 120 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 121 | + | r1 = returnFunc(ctx) | |
| 122 | + | } else { | |
| 123 | + | r1 = ret.Error(1) | |
| 124 | + | } | |
| 125 | + | return r0, r1 | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // MultipartUpload_Complete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Complete' | |
| 129 | + | type MultipartUpload_Complete_Call struct { | |
| 130 | + | *mock.Call | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Complete is a helper method to define mock.On call | |
| 134 | + | // - ctx context.Context | |
| 135 | + | func (_e *MultipartUpload_Expecter) Complete(ctx any) *MultipartUpload_Complete_Call { | |
| 136 | + | return &MultipartUpload_Complete_Call{Call: _e.mock.On("Complete", ctx)} | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (_c *MultipartUpload_Complete_Call) Run(run func(ctx context.Context)) *MultipartUpload_Complete_Call { | |
| 140 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 141 | + | var arg0 context.Context | |
| 142 | + | if args[0] != nil { | |
| 143 | + | arg0 = args[0].(context.Context) | |
| 144 | + | } | |
| 145 | + | run( | |
| 146 | + | arg0, | |
| 147 | + | ) | |
| 148 | + | }) | |
| 149 | + | return _c | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (_c *MultipartUpload_Complete_Call) Return(blobInfo ports.BlobInfo, err error) *MultipartUpload_Complete_Call { | |
| 153 | + | _c.Call.Return(blobInfo, err) | |
| 154 | + | return _c | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (_c *MultipartUpload_Complete_Call) RunAndReturn(run func(ctx context.Context) (ports.BlobInfo, error)) *MultipartUpload_Complete_Call { | |
| 158 | + | _c.Call.Return(run) | |
| 159 | + | return _c | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // ID provides a mock function for the type MultipartUpload | |
| 163 | + | func (_mock *MultipartUpload) ID() string { | |
| 164 | + | ret := _mock.Called() | |
| 165 | + | ||
| 166 | + | if len(ret) == 0 { | |
| 167 | + | panic("no return value specified for ID") | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | var r0 string | |
| 171 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 172 | + | r0 = returnFunc() | |
| 173 | + | } else { | |
| 174 | + | r0 = ret.Get(0).(string) | |
| 175 | + | } | |
| 176 | + | return r0 | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // MultipartUpload_ID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ID' | |
| 180 | + | type MultipartUpload_ID_Call struct { | |
| 181 | + | *mock.Call | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // ID is a helper method to define mock.On call | |
| 185 | + | func (_e *MultipartUpload_Expecter) ID() *MultipartUpload_ID_Call { | |
| 186 | + | return &MultipartUpload_ID_Call{Call: _e.mock.On("ID")} | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (_c *MultipartUpload_ID_Call) Run(run func()) *MultipartUpload_ID_Call { | |
| 190 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 191 | + | run() | |
| 192 | + | }) | |
| 193 | + | return _c | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (_c *MultipartUpload_ID_Call) Return(s string) *MultipartUpload_ID_Call { | |
| 197 | + | _c.Call.Return(s) | |
| 198 | + | return _c | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (_c *MultipartUpload_ID_Call) RunAndReturn(run func() string) *MultipartUpload_ID_Call { | |
| 202 | + | _c.Call.Return(run) | |
| 203 | + | return _c | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // UploadPart provides a mock function for the type MultipartUpload | |
| 207 | + | func (_mock *MultipartUpload) UploadPart(ctx context.Context, n int, r io.Reader, size int64) error { | |
| 208 | + | ret := _mock.Called(ctx, n, r, size) | |
| 209 | + | ||
| 210 | + | if len(ret) == 0 { | |
| 211 | + | panic("no return value specified for UploadPart") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | var r0 error | |
| 215 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int, io.Reader, int64) error); ok { | |
| 216 | + | r0 = returnFunc(ctx, n, r, size) | |
| 217 | + | } else { | |
| 218 | + | r0 = ret.Error(0) | |
| 219 | + | } | |
| 220 | + | return r0 | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // MultipartUpload_UploadPart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadPart' | |
| 224 | + | type MultipartUpload_UploadPart_Call struct { | |
| 225 | + | *mock.Call | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // UploadPart is a helper method to define mock.On call | |
| 229 | + | // - ctx context.Context | |
| 230 | + | // - n int | |
| 231 | + | // - r io.Reader | |
| 232 | + | // - size int64 | |
| 233 | + | func (_e *MultipartUpload_Expecter) UploadPart(ctx any, n any, r any, size any) *MultipartUpload_UploadPart_Call { | |
| 234 | + | return &MultipartUpload_UploadPart_Call{Call: _e.mock.On("UploadPart", ctx, n, r, size)} | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func (_c *MultipartUpload_UploadPart_Call) Run(run func(ctx context.Context, n int, r io.Reader, size int64)) *MultipartUpload_UploadPart_Call { | |
| 238 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 239 | + | var arg0 context.Context | |
| 240 | + | if args[0] != nil { | |
| 241 | + | arg0 = args[0].(context.Context) | |
| 242 | + | } | |
| 243 | + | var arg1 int | |
| 244 | + | if args[1] != nil { | |
| 245 | + | arg1 = args[1].(int) | |
| 246 | + | } | |
| 247 | + | var arg2 io.Reader | |
| 248 | + | if args[2] != nil { | |
| 249 | + | arg2 = args[2].(io.Reader) | |
| 250 | + | } | |
| 251 | + | var arg3 int64 | |
| 252 | + | if args[3] != nil { | |
| 253 | + | arg3 = args[3].(int64) | |
| 254 | + | } | |
| 255 | + | run( | |
| 256 | + | arg0, | |
| 257 | + | arg1, | |
| 258 | + | arg2, | |
| 259 | + | arg3, | |
| 260 | + | ) | |
| 261 | + | }) | |
| 262 | + | return _c | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func (_c *MultipartUpload_UploadPart_Call) Return(err error) *MultipartUpload_UploadPart_Call { | |
| 266 | + | _c.Call.Return(err) | |
| 267 | + | return _c | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func (_c *MultipartUpload_UploadPart_Call) RunAndReturn(run func(ctx context.Context, n int, r io.Reader, size int64) error) *MultipartUpload_UploadPart_Call { | |
| 271 | + | _c.Call.Return(run) | |
| 272 | + | return _c | |
| 273 | + | } | |
A internal/ports/mocks/ReleaseRepoCleaner.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewReleaseRepoCleaner creates a new instance of ReleaseRepoCleaner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewReleaseRepoCleaner(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *ReleaseRepoCleaner { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &ReleaseRepoCleaner{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // ReleaseRepoCleaner is an autogenerated mock type for the ReleaseRepoCleaner type | |
| 38 | + | type ReleaseRepoCleaner struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type ReleaseRepoCleaner_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *ReleaseRepoCleaner) EXPECT() *ReleaseRepoCleaner_Expecter { | |
| 47 | + | return &ReleaseRepoCleaner_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // AssetsOf provides a mock function for the type ReleaseRepoCleaner | |
| 51 | + | func (_mock *ReleaseRepoCleaner) AssetsOf(ctx context.Context, repo domain.RepoName) ([]domain.ReleaseAsset, error) { | |
| 52 | + | ret := _mock.Called(ctx, repo) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for AssetsOf") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 []domain.ReleaseAsset | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.ReleaseAsset, error)); ok { | |
| 61 | + | return returnFunc(ctx, repo) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.ReleaseAsset); ok { | |
| 64 | + | r0 = returnFunc(ctx, repo) | |
| 65 | + | } else { | |
| 66 | + | if ret.Get(0) != nil { | |
| 67 | + | r0 = ret.Get(0).([]domain.ReleaseAsset) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, repo) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ReleaseRepoCleaner_AssetsOf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssetsOf' | |
| 79 | + | type ReleaseRepoCleaner_AssetsOf_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // AssetsOf is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - repo domain.RepoName | |
| 86 | + | func (_e *ReleaseRepoCleaner_Expecter) AssetsOf(ctx any, repo any) *ReleaseRepoCleaner_AssetsOf_Call { | |
| 87 | + | return &ReleaseRepoCleaner_AssetsOf_Call{Call: _e.mock.On("AssetsOf", ctx, repo)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *ReleaseRepoCleaner_AssetsOf_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseRepoCleaner_AssetsOf_Call { | |
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | + | var arg0 context.Context | |
| 93 | + | if args[0] != nil { | |
| 94 | + | arg0 = args[0].(context.Context) | |
| 95 | + | } | |
| 96 | + | var arg1 domain.RepoName | |
| 97 | + | if args[1] != nil { | |
| 98 | + | arg1 = args[1].(domain.RepoName) | |
| 99 | + | } | |
| 100 | + | run( | |
| 101 | + | arg0, | |
| 102 | + | arg1, | |
| 103 | + | ) | |
| 104 | + | }) | |
| 105 | + | return _c | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (_c *ReleaseRepoCleaner_AssetsOf_Call) Return(releaseAssets []domain.ReleaseAsset, err error) *ReleaseRepoCleaner_AssetsOf_Call { | |
| 109 | + | _c.Call.Return(releaseAssets, err) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *ReleaseRepoCleaner_AssetsOf_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.ReleaseAsset, error)) *ReleaseRepoCleaner_AssetsOf_Call { | |
| 114 | + | _c.Call.Return(run) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // OnRepoDeleted provides a mock function for the type ReleaseRepoCleaner | |
| 119 | + | func (_mock *ReleaseRepoCleaner) OnRepoDeleted(ctx context.Context, repo domain.RepoName, assets []domain.ReleaseAsset) { | |
| 120 | + | _mock.Called(ctx, repo, assets) | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // ReleaseRepoCleaner_OnRepoDeleted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnRepoDeleted' | |
| 125 | + | type ReleaseRepoCleaner_OnRepoDeleted_Call struct { | |
| 126 | + | *mock.Call | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // OnRepoDeleted is a helper method to define mock.On call | |
| 130 | + | // - ctx context.Context | |
| 131 | + | // - repo domain.RepoName | |
| 132 | + | // - assets []domain.ReleaseAsset | |
| 133 | + | func (_e *ReleaseRepoCleaner_Expecter) OnRepoDeleted(ctx any, repo any, assets any) *ReleaseRepoCleaner_OnRepoDeleted_Call { | |
| 134 | + | return &ReleaseRepoCleaner_OnRepoDeleted_Call{Call: _e.mock.On("OnRepoDeleted", ctx, repo, assets)} | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (_c *ReleaseRepoCleaner_OnRepoDeleted_Call) Run(run func(ctx context.Context, repo domain.RepoName, assets []domain.ReleaseAsset)) *ReleaseRepoCleaner_OnRepoDeleted_Call { | |
| 138 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 139 | + | var arg0 context.Context | |
| 140 | + | if args[0] != nil { | |
| 141 | + | arg0 = args[0].(context.Context) | |
| 142 | + | } | |
| 143 | + | var arg1 domain.RepoName | |
| 144 | + | if args[1] != nil { | |
| 145 | + | arg1 = args[1].(domain.RepoName) | |
| 146 | + | } | |
| 147 | + | var arg2 []domain.ReleaseAsset | |
| 148 | + | if args[2] != nil { | |
| 149 | + | arg2 = args[2].([]domain.ReleaseAsset) | |
| 150 | + | } | |
| 151 | + | run( | |
| 152 | + | arg0, | |
| 153 | + | arg1, | |
| 154 | + | arg2, | |
| 155 | + | ) | |
| 156 | + | }) | |
| 157 | + | return _c | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (_c *ReleaseRepoCleaner_OnRepoDeleted_Call) Return() *ReleaseRepoCleaner_OnRepoDeleted_Call { | |
| 161 | + | _c.Call.Return() | |
| 162 | + | return _c | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (_c *ReleaseRepoCleaner_OnRepoDeleted_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, assets []domain.ReleaseAsset)) *ReleaseRepoCleaner_OnRepoDeleted_Call { | |
| 166 | + | _c.Run(run) | |
| 167 | + | return _c | |
| 168 | + | } | |
A internal/ports/mocks/ReleaseService.go +997 -0
| @@ -0,0 +1,997 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | mock "github.com/stretchr/testify/mock" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // NewReleaseService creates a new instance of ReleaseService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 17 | + | // The first argument is typically a *testing.T value. | |
| 18 | + | func NewReleaseService(t interface { | |
| 19 | + | mock.TestingT | |
| 20 | + | Cleanup(func()) | |
| 21 | + | }) *ReleaseService { | |
| 22 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 23 | + | helper.Helper() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | mock := &ReleaseService{} | |
| 27 | + | mock.Mock.Test(t) | |
| 28 | + | ||
| 29 | + | t.Cleanup(func() { | |
| 30 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 31 | + | helper.Helper() | |
| 32 | + | } | |
| 33 | + | mock.AssertExpectations(t) | |
| 34 | + | }) | |
| 35 | + | ||
| 36 | + | return mock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // ReleaseService is an autogenerated mock type for the ReleaseService type | |
| 40 | + | type ReleaseService struct { | |
| 41 | + | mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type ReleaseService_Expecter struct { | |
| 45 | + | mock *mock.Mock | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (_m *ReleaseService) EXPECT() *ReleaseService_Expecter { | |
| 49 | + | return &ReleaseService_Expecter{mock: &_m.Mock} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // CreateRelease provides a mock function for the type ReleaseService | |
| 53 | + | func (_mock *ReleaseService) CreateRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.CreateReleaseParams) (domain.Release, error) { | |
| 54 | + | ret := _mock.Called(ctx, p, repo, params) | |
| 55 | + | ||
| 56 | + | if len(ret) == 0 { | |
| 57 | + | panic("no return value specified for CreateRelease") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | var r0 domain.Release | |
| 61 | + | var r1 error | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateReleaseParams) (domain.Release, error)); ok { | |
| 63 | + | return returnFunc(ctx, p, repo, params) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateReleaseParams) domain.Release); ok { | |
| 66 | + | r0 = returnFunc(ctx, p, repo, params) | |
| 67 | + | } else { | |
| 68 | + | r0 = ret.Get(0).(domain.Release) | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateReleaseParams) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, p, repo, params) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ReleaseService_CreateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRelease' | |
| 79 | + | type ReleaseService_CreateRelease_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // CreateRelease is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - p domain.Principal | |
| 86 | + | // - repo domain.RepoName | |
| 87 | + | // - params ports.CreateReleaseParams | |
| 88 | + | func (_e *ReleaseService_Expecter) CreateRelease(ctx any, p any, repo any, params any) *ReleaseService_CreateRelease_Call { | |
| 89 | + | return &ReleaseService_CreateRelease_Call{Call: _e.mock.On("CreateRelease", ctx, p, repo, params)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *ReleaseService_CreateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.CreateReleaseParams)) *ReleaseService_CreateRelease_Call { | |
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 94 | + | var arg0 context.Context | |
| 95 | + | if args[0] != nil { | |
| 96 | + | arg0 = args[0].(context.Context) | |
| 97 | + | } | |
| 98 | + | var arg1 domain.Principal | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(domain.Principal) | |
| 101 | + | } | |
| 102 | + | var arg2 domain.RepoName | |
| 103 | + | if args[2] != nil { | |
| 104 | + | arg2 = args[2].(domain.RepoName) | |
| 105 | + | } | |
| 106 | + | var arg3 ports.CreateReleaseParams | |
| 107 | + | if args[3] != nil { | |
| 108 | + | arg3 = args[3].(ports.CreateReleaseParams) | |
| 109 | + | } | |
| 110 | + | run( | |
| 111 | + | arg0, | |
| 112 | + | arg1, | |
| 113 | + | arg2, | |
| 114 | + | arg3, | |
| 115 | + | ) | |
| 116 | + | }) | |
| 117 | + | return _c | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (_c *ReleaseService_CreateRelease_Call) Return(release domain.Release, err error) *ReleaseService_CreateRelease_Call { | |
| 121 | + | _c.Call.Return(release, err) | |
| 122 | + | return _c | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (_c *ReleaseService_CreateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.CreateReleaseParams) (domain.Release, error)) *ReleaseService_CreateRelease_Call { | |
| 126 | + | _c.Call.Return(run) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // DeleteAsset provides a mock function for the type ReleaseService | |
| 131 | + | func (_mock *ReleaseService) DeleteAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64) error { | |
| 132 | + | ret := _mock.Called(ctx, p, repo, assetID) | |
| 133 | + | ||
| 134 | + | if len(ret) == 0 { | |
| 135 | + | panic("no return value specified for DeleteAsset") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | var r0 error | |
| 139 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 140 | + | r0 = returnFunc(ctx, p, repo, assetID) | |
| 141 | + | } else { | |
| 142 | + | r0 = ret.Error(0) | |
| 143 | + | } | |
| 144 | + | return r0 | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // ReleaseService_DeleteAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAsset' | |
| 148 | + | type ReleaseService_DeleteAsset_Call struct { | |
| 149 | + | *mock.Call | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // DeleteAsset is a helper method to define mock.On call | |
| 153 | + | // - ctx context.Context | |
| 154 | + | // - p domain.Principal | |
| 155 | + | // - repo domain.RepoName | |
| 156 | + | // - assetID int64 | |
| 157 | + | func (_e *ReleaseService_Expecter) DeleteAsset(ctx any, p any, repo any, assetID any) *ReleaseService_DeleteAsset_Call { | |
| 158 | + | return &ReleaseService_DeleteAsset_Call{Call: _e.mock.On("DeleteAsset", ctx, p, repo, assetID)} | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (_c *ReleaseService_DeleteAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64)) *ReleaseService_DeleteAsset_Call { | |
| 162 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 163 | + | var arg0 context.Context | |
| 164 | + | if args[0] != nil { | |
| 165 | + | arg0 = args[0].(context.Context) | |
| 166 | + | } | |
| 167 | + | var arg1 domain.Principal | |
| 168 | + | if args[1] != nil { | |
| 169 | + | arg1 = args[1].(domain.Principal) | |
| 170 | + | } | |
| 171 | + | var arg2 domain.RepoName | |
| 172 | + | if args[2] != nil { | |
| 173 | + | arg2 = args[2].(domain.RepoName) | |
| 174 | + | } | |
| 175 | + | var arg3 int64 | |
| 176 | + | if args[3] != nil { | |
| 177 | + | arg3 = args[3].(int64) | |
| 178 | + | } | |
| 179 | + | run( | |
| 180 | + | arg0, | |
| 181 | + | arg1, | |
| 182 | + | arg2, | |
| 183 | + | arg3, | |
| 184 | + | ) | |
| 185 | + | }) | |
| 186 | + | return _c | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (_c *ReleaseService_DeleteAsset_Call) Return(err error) *ReleaseService_DeleteAsset_Call { | |
| 190 | + | _c.Call.Return(err) | |
| 191 | + | return _c | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (_c *ReleaseService_DeleteAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64) error) *ReleaseService_DeleteAsset_Call { | |
| 195 | + | _c.Call.Return(run) | |
| 196 | + | return _c | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // DeleteRelease provides a mock function for the type ReleaseService | |
| 200 | + | func (_mock *ReleaseService) DeleteRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error { | |
| 201 | + | ret := _mock.Called(ctx, p, repo, id) | |
| 202 | + | ||
| 203 | + | if len(ret) == 0 { | |
| 204 | + | panic("no return value specified for DeleteRelease") | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | var r0 error | |
| 208 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 209 | + | r0 = returnFunc(ctx, p, repo, id) | |
| 210 | + | } else { | |
| 211 | + | r0 = ret.Error(0) | |
| 212 | + | } | |
| 213 | + | return r0 | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // ReleaseService_DeleteRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRelease' | |
| 217 | + | type ReleaseService_DeleteRelease_Call struct { | |
| 218 | + | *mock.Call | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // DeleteRelease is a helper method to define mock.On call | |
| 222 | + | // - ctx context.Context | |
| 223 | + | // - p domain.Principal | |
| 224 | + | // - repo domain.RepoName | |
| 225 | + | // - id int64 | |
| 226 | + | func (_e *ReleaseService_Expecter) DeleteRelease(ctx any, p any, repo any, id any) *ReleaseService_DeleteRelease_Call { | |
| 227 | + | return &ReleaseService_DeleteRelease_Call{Call: _e.mock.On("DeleteRelease", ctx, p, repo, id)} | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (_c *ReleaseService_DeleteRelease_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64)) *ReleaseService_DeleteRelease_Call { | |
| 231 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 232 | + | var arg0 context.Context | |
| 233 | + | if args[0] != nil { | |
| 234 | + | arg0 = args[0].(context.Context) | |
| 235 | + | } | |
| 236 | + | var arg1 domain.Principal | |
| 237 | + | if args[1] != nil { | |
| 238 | + | arg1 = args[1].(domain.Principal) | |
| 239 | + | } | |
| 240 | + | var arg2 domain.RepoName | |
| 241 | + | if args[2] != nil { | |
| 242 | + | arg2 = args[2].(domain.RepoName) | |
| 243 | + | } | |
| 244 | + | var arg3 int64 | |
| 245 | + | if args[3] != nil { | |
| 246 | + | arg3 = args[3].(int64) | |
| 247 | + | } | |
| 248 | + | run( | |
| 249 | + | arg0, | |
| 250 | + | arg1, | |
| 251 | + | arg2, | |
| 252 | + | arg3, | |
| 253 | + | ) | |
| 254 | + | }) | |
| 255 | + | return _c | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (_c *ReleaseService_DeleteRelease_Call) Return(err error) *ReleaseService_DeleteRelease_Call { | |
| 259 | + | _c.Call.Return(err) | |
| 260 | + | return _c | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func (_c *ReleaseService_DeleteRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error) *ReleaseService_DeleteRelease_Call { | |
| 264 | + | _c.Call.Return(run) | |
| 265 | + | return _c | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // DownloadAsset provides a mock function for the type ReleaseService | |
| 269 | + | func (_mock *ReleaseService) DownloadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset int64, length int64, send func(ports.AssetChunk) error) error { | |
| 270 | + | ret := _mock.Called(ctx, p, repo, ref, offset, length, send) | |
| 271 | + | ||
| 272 | + | if len(ret) == 0 { | |
| 273 | + | panic("no return value specified for DownloadAsset") | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | var r0 error | |
| 277 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.AssetRef, int64, int64, func(ports.AssetChunk) error) error); ok { | |
| 278 | + | r0 = returnFunc(ctx, p, repo, ref, offset, length, send) | |
| 279 | + | } else { | |
| 280 | + | r0 = ret.Error(0) | |
| 281 | + | } | |
| 282 | + | return r0 | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // ReleaseService_DownloadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DownloadAsset' | |
| 286 | + | type ReleaseService_DownloadAsset_Call struct { | |
| 287 | + | *mock.Call | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // DownloadAsset is a helper method to define mock.On call | |
| 291 | + | // - ctx context.Context | |
| 292 | + | // - p domain.Principal | |
| 293 | + | // - repo domain.RepoName | |
| 294 | + | // - ref ports.AssetRef | |
| 295 | + | // - offset int64 | |
| 296 | + | // - length int64 | |
| 297 | + | // - send func(ports.AssetChunk) error | |
| 298 | + | func (_e *ReleaseService_Expecter) DownloadAsset(ctx any, p any, repo any, ref any, offset any, length any, send any) *ReleaseService_DownloadAsset_Call { | |
| 299 | + | return &ReleaseService_DownloadAsset_Call{Call: _e.mock.On("DownloadAsset", ctx, p, repo, ref, offset, length, send)} | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func (_c *ReleaseService_DownloadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset int64, length int64, send func(ports.AssetChunk) error)) *ReleaseService_DownloadAsset_Call { | |
| 303 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 304 | + | var arg0 context.Context | |
| 305 | + | if args[0] != nil { | |
| 306 | + | arg0 = args[0].(context.Context) | |
| 307 | + | } | |
| 308 | + | var arg1 domain.Principal | |
| 309 | + | if args[1] != nil { | |
| 310 | + | arg1 = args[1].(domain.Principal) | |
| 311 | + | } | |
| 312 | + | var arg2 domain.RepoName | |
| 313 | + | if args[2] != nil { | |
| 314 | + | arg2 = args[2].(domain.RepoName) | |
| 315 | + | } | |
| 316 | + | var arg3 ports.AssetRef | |
| 317 | + | if args[3] != nil { | |
| 318 | + | arg3 = args[3].(ports.AssetRef) | |
| 319 | + | } | |
| 320 | + | var arg4 int64 | |
| 321 | + | if args[4] != nil { | |
| 322 | + | arg4 = args[4].(int64) | |
| 323 | + | } | |
| 324 | + | var arg5 int64 | |
| 325 | + | if args[5] != nil { | |
| 326 | + | arg5 = args[5].(int64) | |
| 327 | + | } | |
| 328 | + | var arg6 func(ports.AssetChunk) error | |
| 329 | + | if args[6] != nil { | |
| 330 | + | arg6 = args[6].(func(ports.AssetChunk) error) | |
| 331 | + | } | |
| 332 | + | run( | |
| 333 | + | arg0, | |
| 334 | + | arg1, | |
| 335 | + | arg2, | |
| 336 | + | arg3, | |
| 337 | + | arg4, | |
| 338 | + | arg5, | |
| 339 | + | arg6, | |
| 340 | + | ) | |
| 341 | + | }) | |
| 342 | + | return _c | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func (_c *ReleaseService_DownloadAsset_Call) Return(err error) *ReleaseService_DownloadAsset_Call { | |
| 346 | + | _c.Call.Return(err) | |
| 347 | + | return _c | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | func (_c *ReleaseService_DownloadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset int64, length int64, send func(ports.AssetChunk) error) error) *ReleaseService_DownloadAsset_Call { | |
| 351 | + | _c.Call.Return(run) | |
| 352 | + | return _c | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // GetAsset provides a mock function for the type ReleaseService | |
| 356 | + | func (_mock *ReleaseService) GetAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) { | |
| 357 | + | ret := _mock.Called(ctx, p, repo, ref) | |
| 358 | + | ||
| 359 | + | if len(ret) == 0 { | |
| 360 | + | panic("no return value specified for GetAsset") | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | var r0 domain.ReleaseAsset | |
| 364 | + | var r1 error | |
| 365 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.AssetRef) (domain.ReleaseAsset, error)); ok { | |
| 366 | + | return returnFunc(ctx, p, repo, ref) | |
| 367 | + | } | |
| 368 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.AssetRef) domain.ReleaseAsset); ok { | |
| 369 | + | r0 = returnFunc(ctx, p, repo, ref) | |
| 370 | + | } else { | |
| 371 | + | r0 = ret.Get(0).(domain.ReleaseAsset) | |
| 372 | + | } | |
| 373 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.AssetRef) error); ok { | |
| 374 | + | r1 = returnFunc(ctx, p, repo, ref) | |
| 375 | + | } else { | |
| 376 | + | r1 = ret.Error(1) | |
| 377 | + | } | |
| 378 | + | return r0, r1 | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // ReleaseService_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset' | |
| 382 | + | type ReleaseService_GetAsset_Call struct { | |
| 383 | + | *mock.Call | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // GetAsset is a helper method to define mock.On call | |
| 387 | + | // - ctx context.Context | |
| 388 | + | // - p domain.Principal | |
| 389 | + | // - repo domain.RepoName | |
| 390 | + | // - ref ports.AssetRef | |
| 391 | + | func (_e *ReleaseService_Expecter) GetAsset(ctx any, p any, repo any, ref any) *ReleaseService_GetAsset_Call { | |
| 392 | + | return &ReleaseService_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, p, repo, ref)} | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func (_c *ReleaseService_GetAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef)) *ReleaseService_GetAsset_Call { | |
| 396 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 397 | + | var arg0 context.Context | |
| 398 | + | if args[0] != nil { | |
| 399 | + | arg0 = args[0].(context.Context) | |
| 400 | + | } | |
| 401 | + | var arg1 domain.Principal | |
| 402 | + | if args[1] != nil { | |
| 403 | + | arg1 = args[1].(domain.Principal) | |
| 404 | + | } | |
| 405 | + | var arg2 domain.RepoName | |
| 406 | + | if args[2] != nil { | |
| 407 | + | arg2 = args[2].(domain.RepoName) | |
| 408 | + | } | |
| 409 | + | var arg3 ports.AssetRef | |
| 410 | + | if args[3] != nil { | |
| 411 | + | arg3 = args[3].(ports.AssetRef) | |
| 412 | + | } | |
| 413 | + | run( | |
| 414 | + | arg0, | |
| 415 | + | arg1, | |
| 416 | + | arg2, | |
| 417 | + | arg3, | |
| 418 | + | ) | |
| 419 | + | }) | |
| 420 | + | return _c | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func (_c *ReleaseService_GetAsset_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseService_GetAsset_Call { | |
| 424 | + | _c.Call.Return(releaseAsset, err) | |
| 425 | + | return _c | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | func (_c *ReleaseService_GetAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error)) *ReleaseService_GetAsset_Call { | |
| 429 | + | _c.Call.Return(run) | |
| 430 | + | return _c | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // GetRelease provides a mock function for the type ReleaseService | |
| 434 | + | func (_mock *ReleaseService) GetRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) { | |
| 435 | + | ret := _mock.Called(ctx, p, repo, ref) | |
| 436 | + | ||
| 437 | + | if len(ret) == 0 { | |
| 438 | + | panic("no return value specified for GetRelease") | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | var r0 domain.Release | |
| 442 | + | var r1 error | |
| 443 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ReleaseRef) (domain.Release, error)); ok { | |
| 444 | + | return returnFunc(ctx, p, repo, ref) | |
| 445 | + | } | |
| 446 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ReleaseRef) domain.Release); ok { | |
| 447 | + | r0 = returnFunc(ctx, p, repo, ref) | |
| 448 | + | } else { | |
| 449 | + | r0 = ret.Get(0).(domain.Release) | |
| 450 | + | } | |
| 451 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ReleaseRef) error); ok { | |
| 452 | + | r1 = returnFunc(ctx, p, repo, ref) | |
| 453 | + | } else { | |
| 454 | + | r1 = ret.Error(1) | |
| 455 | + | } | |
| 456 | + | return r0, r1 | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // ReleaseService_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease' | |
| 460 | + | type ReleaseService_GetRelease_Call struct { | |
| 461 | + | *mock.Call | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | // GetRelease is a helper method to define mock.On call | |
| 465 | + | // - ctx context.Context | |
| 466 | + | // - p domain.Principal | |
| 467 | + | // - repo domain.RepoName | |
| 468 | + | // - ref ports.ReleaseRef | |
| 469 | + | func (_e *ReleaseService_Expecter) GetRelease(ctx any, p any, repo any, ref any) *ReleaseService_GetRelease_Call { | |
| 470 | + | return &ReleaseService_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, p, repo, ref)} | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func (_c *ReleaseService_GetRelease_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.ReleaseRef)) *ReleaseService_GetRelease_Call { | |
| 474 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 475 | + | var arg0 context.Context | |
| 476 | + | if args[0] != nil { | |
| 477 | + | arg0 = args[0].(context.Context) | |
| 478 | + | } | |
| 479 | + | var arg1 domain.Principal | |
| 480 | + | if args[1] != nil { | |
| 481 | + | arg1 = args[1].(domain.Principal) | |
| 482 | + | } | |
| 483 | + | var arg2 domain.RepoName | |
| 484 | + | if args[2] != nil { | |
| 485 | + | arg2 = args[2].(domain.RepoName) | |
| 486 | + | } | |
| 487 | + | var arg3 ports.ReleaseRef | |
| 488 | + | if args[3] != nil { | |
| 489 | + | arg3 = args[3].(ports.ReleaseRef) | |
| 490 | + | } | |
| 491 | + | run( | |
| 492 | + | arg0, | |
| 493 | + | arg1, | |
| 494 | + | arg2, | |
| 495 | + | arg3, | |
| 496 | + | ) | |
| 497 | + | }) | |
| 498 | + | return _c | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | func (_c *ReleaseService_GetRelease_Call) Return(release domain.Release, err error) *ReleaseService_GetRelease_Call { | |
| 502 | + | _c.Call.Return(release, err) | |
| 503 | + | return _c | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | func (_c *ReleaseService_GetRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.ReleaseRef) (domain.Release, error)) *ReleaseService_GetRelease_Call { | |
| 507 | + | _c.Call.Return(run) | |
| 508 | + | return _c | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | // ListAssets provides a mock function for the type ReleaseService | |
| 512 | + | func (_mock *ReleaseService) ListAssets(ctx context.Context, p domain.Principal, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) { | |
| 513 | + | ret := _mock.Called(ctx, p, repo, releaseID) | |
| 514 | + | ||
| 515 | + | if len(ret) == 0 { | |
| 516 | + | panic("no return value specified for ListAssets") | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | var r0 []domain.ReleaseAsset | |
| 520 | + | var r1 error | |
| 521 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) ([]domain.ReleaseAsset, error)); ok { | |
| 522 | + | return returnFunc(ctx, p, repo, releaseID) | |
| 523 | + | } | |
| 524 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) []domain.ReleaseAsset); ok { | |
| 525 | + | r0 = returnFunc(ctx, p, repo, releaseID) | |
| 526 | + | } else { | |
| 527 | + | if ret.Get(0) != nil { | |
| 528 | + | r0 = ret.Get(0).([]domain.ReleaseAsset) | |
| 529 | + | } | |
| 530 | + | } | |
| 531 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 532 | + | r1 = returnFunc(ctx, p, repo, releaseID) | |
| 533 | + | } else { | |
| 534 | + | r1 = ret.Error(1) | |
| 535 | + | } | |
| 536 | + | return r0, r1 | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | // ReleaseService_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets' | |
| 540 | + | type ReleaseService_ListAssets_Call struct { | |
| 541 | + | *mock.Call | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | // ListAssets is a helper method to define mock.On call | |
| 545 | + | // - ctx context.Context | |
| 546 | + | // - p domain.Principal | |
| 547 | + | // - repo domain.RepoName | |
| 548 | + | // - releaseID int64 | |
| 549 | + | func (_e *ReleaseService_Expecter) ListAssets(ctx any, p any, repo any, releaseID any) *ReleaseService_ListAssets_Call { | |
| 550 | + | return &ReleaseService_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, p, repo, releaseID)} | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | func (_c *ReleaseService_ListAssets_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, releaseID int64)) *ReleaseService_ListAssets_Call { | |
| 554 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 555 | + | var arg0 context.Context | |
| 556 | + | if args[0] != nil { | |
| 557 | + | arg0 = args[0].(context.Context) | |
| 558 | + | } | |
| 559 | + | var arg1 domain.Principal | |
| 560 | + | if args[1] != nil { | |
| 561 | + | arg1 = args[1].(domain.Principal) | |
| 562 | + | } | |
| 563 | + | var arg2 domain.RepoName | |
| 564 | + | if args[2] != nil { | |
| 565 | + | arg2 = args[2].(domain.RepoName) | |
| 566 | + | } | |
| 567 | + | var arg3 int64 | |
| 568 | + | if args[3] != nil { | |
| 569 | + | arg3 = args[3].(int64) | |
| 570 | + | } | |
| 571 | + | run( | |
| 572 | + | arg0, | |
| 573 | + | arg1, | |
| 574 | + | arg2, | |
| 575 | + | arg3, | |
| 576 | + | ) | |
| 577 | + | }) | |
| 578 | + | return _c | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | func (_c *ReleaseService_ListAssets_Call) Return(releaseAssets []domain.ReleaseAsset, err error) *ReleaseService_ListAssets_Call { | |
| 582 | + | _c.Call.Return(releaseAssets, err) | |
| 583 | + | return _c | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | func (_c *ReleaseService_ListAssets_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error)) *ReleaseService_ListAssets_Call { | |
| 587 | + | _c.Call.Return(run) | |
| 588 | + | return _c | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | // ListReleases provides a mock function for the type ReleaseService | |
| 592 | + | func (_mock *ReleaseService) ListReleases(ctx context.Context, p domain.Principal, repo domain.RepoName, pageSize int, pageToken string) (ports.ReleasePage, error) { | |
| 593 | + | ret := _mock.Called(ctx, p, repo, pageSize, pageToken) | |
| 594 | + | ||
| 595 | + | if len(ret) == 0 { | |
| 596 | + | panic("no return value specified for ListReleases") | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | var r0 ports.ReleasePage | |
| 600 | + | var r1 error | |
| 601 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int, string) (ports.ReleasePage, error)); ok { | |
| 602 | + | return returnFunc(ctx, p, repo, pageSize, pageToken) | |
| 603 | + | } | |
| 604 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int, string) ports.ReleasePage); ok { | |
| 605 | + | r0 = returnFunc(ctx, p, repo, pageSize, pageToken) | |
| 606 | + | } else { | |
| 607 | + | r0 = ret.Get(0).(ports.ReleasePage) | |
| 608 | + | } | |
| 609 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int, string) error); ok { | |
| 610 | + | r1 = returnFunc(ctx, p, repo, pageSize, pageToken) | |
| 611 | + | } else { | |
| 612 | + | r1 = ret.Error(1) | |
| 613 | + | } | |
| 614 | + | return r0, r1 | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | // ReleaseService_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases' | |
| 618 | + | type ReleaseService_ListReleases_Call struct { | |
| 619 | + | *mock.Call | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | // ListReleases is a helper method to define mock.On call | |
| 623 | + | // - ctx context.Context | |
| 624 | + | // - p domain.Principal | |
| 625 | + | // - repo domain.RepoName | |
| 626 | + | // - pageSize int | |
| 627 | + | // - pageToken string | |
| 628 | + | func (_e *ReleaseService_Expecter) ListReleases(ctx any, p any, repo any, pageSize any, pageToken any) *ReleaseService_ListReleases_Call { | |
| 629 | + | return &ReleaseService_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, p, repo, pageSize, pageToken)} | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | func (_c *ReleaseService_ListReleases_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, pageSize int, pageToken string)) *ReleaseService_ListReleases_Call { | |
| 633 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 634 | + | var arg0 context.Context | |
| 635 | + | if args[0] != nil { | |
| 636 | + | arg0 = args[0].(context.Context) | |
| 637 | + | } | |
| 638 | + | var arg1 domain.Principal | |
| 639 | + | if args[1] != nil { | |
| 640 | + | arg1 = args[1].(domain.Principal) | |
| 641 | + | } | |
| 642 | + | var arg2 domain.RepoName | |
| 643 | + | if args[2] != nil { | |
| 644 | + | arg2 = args[2].(domain.RepoName) | |
| 645 | + | } | |
| 646 | + | var arg3 int | |
| 647 | + | if args[3] != nil { | |
| 648 | + | arg3 = args[3].(int) | |
| 649 | + | } | |
| 650 | + | var arg4 string | |
| 651 | + | if args[4] != nil { | |
| 652 | + | arg4 = args[4].(string) | |
| 653 | + | } | |
| 654 | + | run( | |
| 655 | + | arg0, | |
| 656 | + | arg1, | |
| 657 | + | arg2, | |
| 658 | + | arg3, | |
| 659 | + | arg4, | |
| 660 | + | ) | |
| 661 | + | }) | |
| 662 | + | return _c | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | func (_c *ReleaseService_ListReleases_Call) Return(releasePage ports.ReleasePage, err error) *ReleaseService_ListReleases_Call { | |
| 666 | + | _c.Call.Return(releasePage, err) | |
| 667 | + | return _c | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | func (_c *ReleaseService_ListReleases_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, pageSize int, pageToken string) (ports.ReleasePage, error)) *ReleaseService_ListReleases_Call { | |
| 671 | + | _c.Call.Return(run) | |
| 672 | + | return _c | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | // UpdateAsset provides a mock function for the type ReleaseService | |
| 676 | + | func (_mock *ReleaseService) UpdateAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64, params ports.UpdateAssetParams) (domain.ReleaseAsset, error) { | |
| 677 | + | ret := _mock.Called(ctx, p, repo, assetID, params) | |
| 678 | + | ||
| 679 | + | if len(ret) == 0 { | |
| 680 | + | panic("no return value specified for UpdateAsset") | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | var r0 domain.ReleaseAsset | |
| 684 | + | var r1 error | |
| 685 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateAssetParams) (domain.ReleaseAsset, error)); ok { | |
| 686 | + | return returnFunc(ctx, p, repo, assetID, params) | |
| 687 | + | } | |
| 688 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateAssetParams) domain.ReleaseAsset); ok { | |
| 689 | + | r0 = returnFunc(ctx, p, repo, assetID, params) | |
| 690 | + | } else { | |
| 691 | + | r0 = ret.Get(0).(domain.ReleaseAsset) | |
| 692 | + | } | |
| 693 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateAssetParams) error); ok { | |
| 694 | + | r1 = returnFunc(ctx, p, repo, assetID, params) | |
| 695 | + | } else { | |
| 696 | + | r1 = ret.Error(1) | |
| 697 | + | } | |
| 698 | + | return r0, r1 | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | // ReleaseService_UpdateAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAsset' | |
| 702 | + | type ReleaseService_UpdateAsset_Call struct { | |
| 703 | + | *mock.Call | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | // UpdateAsset is a helper method to define mock.On call | |
| 707 | + | // - ctx context.Context | |
| 708 | + | // - p domain.Principal | |
| 709 | + | // - repo domain.RepoName | |
| 710 | + | // - assetID int64 | |
| 711 | + | // - params ports.UpdateAssetParams | |
| 712 | + | func (_e *ReleaseService_Expecter) UpdateAsset(ctx any, p any, repo any, assetID any, params any) *ReleaseService_UpdateAsset_Call { | |
| 713 | + | return &ReleaseService_UpdateAsset_Call{Call: _e.mock.On("UpdateAsset", ctx, p, repo, assetID, params)} | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | func (_c *ReleaseService_UpdateAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64, params ports.UpdateAssetParams)) *ReleaseService_UpdateAsset_Call { | |
| 717 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 718 | + | var arg0 context.Context | |
| 719 | + | if args[0] != nil { | |
| 720 | + | arg0 = args[0].(context.Context) | |
| 721 | + | } | |
| 722 | + | var arg1 domain.Principal | |
| 723 | + | if args[1] != nil { | |
| 724 | + | arg1 = args[1].(domain.Principal) | |
| 725 | + | } | |
| 726 | + | var arg2 domain.RepoName | |
| 727 | + | if args[2] != nil { | |
| 728 | + | arg2 = args[2].(domain.RepoName) | |
| 729 | + | } | |
| 730 | + | var arg3 int64 | |
| 731 | + | if args[3] != nil { | |
| 732 | + | arg3 = args[3].(int64) | |
| 733 | + | } | |
| 734 | + | var arg4 ports.UpdateAssetParams | |
| 735 | + | if args[4] != nil { | |
| 736 | + | arg4 = args[4].(ports.UpdateAssetParams) | |
| 737 | + | } | |
| 738 | + | run( | |
| 739 | + | arg0, | |
| 740 | + | arg1, | |
| 741 | + | arg2, | |
| 742 | + | arg3, | |
| 743 | + | arg4, | |
| 744 | + | ) | |
| 745 | + | }) | |
| 746 | + | return _c | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | func (_c *ReleaseService_UpdateAsset_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseService_UpdateAsset_Call { | |
| 750 | + | _c.Call.Return(releaseAsset, err) | |
| 751 | + | return _c | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | func (_c *ReleaseService_UpdateAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64, params ports.UpdateAssetParams) (domain.ReleaseAsset, error)) *ReleaseService_UpdateAsset_Call { | |
| 755 | + | _c.Call.Return(run) | |
| 756 | + | return _c | |
| 757 | + | } | |
| 758 | + | ||
| 759 | + | // UpdateRelease provides a mock function for the type ReleaseService | |
| 760 | + | func (_mock *ReleaseService) UpdateRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateReleaseParams) (domain.Release, error) { | |
| 761 | + | ret := _mock.Called(ctx, p, repo, id, params) | |
| 762 | + | ||
| 763 | + | if len(ret) == 0 { | |
| 764 | + | panic("no return value specified for UpdateRelease") | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | var r0 domain.Release | |
| 768 | + | var r1 error | |
| 769 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateReleaseParams) (domain.Release, error)); ok { | |
| 770 | + | return returnFunc(ctx, p, repo, id, params) | |
| 771 | + | } | |
| 772 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateReleaseParams) domain.Release); ok { | |
| 773 | + | r0 = returnFunc(ctx, p, repo, id, params) | |
| 774 | + | } else { | |
| 775 | + | r0 = ret.Get(0).(domain.Release) | |
| 776 | + | } | |
| 777 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.UpdateReleaseParams) error); ok { | |
| 778 | + | r1 = returnFunc(ctx, p, repo, id, params) | |
| 779 | + | } else { | |
| 780 | + | r1 = ret.Error(1) | |
| 781 | + | } | |
| 782 | + | return r0, r1 | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | // ReleaseService_UpdateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRelease' | |
| 786 | + | type ReleaseService_UpdateRelease_Call struct { | |
| 787 | + | *mock.Call | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | // UpdateRelease is a helper method to define mock.On call | |
| 791 | + | // - ctx context.Context | |
| 792 | + | // - p domain.Principal | |
| 793 | + | // - repo domain.RepoName | |
| 794 | + | // - id int64 | |
| 795 | + | // - params ports.UpdateReleaseParams | |
| 796 | + | func (_e *ReleaseService_Expecter) UpdateRelease(ctx any, p any, repo any, id any, params any) *ReleaseService_UpdateRelease_Call { | |
| 797 | + | return &ReleaseService_UpdateRelease_Call{Call: _e.mock.On("UpdateRelease", ctx, p, repo, id, params)} | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | func (_c *ReleaseService_UpdateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateReleaseParams)) *ReleaseService_UpdateRelease_Call { | |
| 801 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 802 | + | var arg0 context.Context | |
| 803 | + | if args[0] != nil { | |
| 804 | + | arg0 = args[0].(context.Context) | |
| 805 | + | } | |
| 806 | + | var arg1 domain.Principal | |
| 807 | + | if args[1] != nil { | |
| 808 | + | arg1 = args[1].(domain.Principal) | |
| 809 | + | } | |
| 810 | + | var arg2 domain.RepoName | |
| 811 | + | if args[2] != nil { | |
| 812 | + | arg2 = args[2].(domain.RepoName) | |
| 813 | + | } | |
| 814 | + | var arg3 int64 | |
| 815 | + | if args[3] != nil { | |
| 816 | + | arg3 = args[3].(int64) | |
| 817 | + | } | |
| 818 | + | var arg4 ports.UpdateReleaseParams | |
| 819 | + | if args[4] != nil { | |
| 820 | + | arg4 = args[4].(ports.UpdateReleaseParams) | |
| 821 | + | } | |
| 822 | + | run( | |
| 823 | + | arg0, | |
| 824 | + | arg1, | |
| 825 | + | arg2, | |
| 826 | + | arg3, | |
| 827 | + | arg4, | |
| 828 | + | ) | |
| 829 | + | }) | |
| 830 | + | return _c | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | func (_c *ReleaseService_UpdateRelease_Call) Return(release domain.Release, err error) *ReleaseService_UpdateRelease_Call { | |
| 834 | + | _c.Call.Return(release, err) | |
| 835 | + | return _c | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | func (_c *ReleaseService_UpdateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateReleaseParams) (domain.Release, error)) *ReleaseService_UpdateRelease_Call { | |
| 839 | + | _c.Call.Return(run) | |
| 840 | + | return _c | |
| 841 | + | } | |
| 842 | + | ||
| 843 | + | // UploadAsset provides a mock function for the type ReleaseService | |
| 844 | + | func (_mock *ReleaseService) UploadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.UploadAssetParams, body io.Reader) (domain.ReleaseAsset, error) { | |
| 845 | + | ret := _mock.Called(ctx, p, repo, params, body) | |
| 846 | + | ||
| 847 | + | if len(ret) == 0 { | |
| 848 | + | panic("no return value specified for UploadAsset") | |
| 849 | + | } | |
| 850 | + | ||
| 851 | + | var r0 domain.ReleaseAsset | |
| 852 | + | var r1 error | |
| 853 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.UploadAssetParams, io.Reader) (domain.ReleaseAsset, error)); ok { | |
| 854 | + | return returnFunc(ctx, p, repo, params, body) | |
| 855 | + | } | |
| 856 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.UploadAssetParams, io.Reader) domain.ReleaseAsset); ok { | |
| 857 | + | r0 = returnFunc(ctx, p, repo, params, body) | |
| 858 | + | } else { | |
| 859 | + | r0 = ret.Get(0).(domain.ReleaseAsset) | |
| 860 | + | } | |
| 861 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.UploadAssetParams, io.Reader) error); ok { | |
| 862 | + | r1 = returnFunc(ctx, p, repo, params, body) | |
| 863 | + | } else { | |
| 864 | + | r1 = ret.Error(1) | |
| 865 | + | } | |
| 866 | + | return r0, r1 | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | // ReleaseService_UploadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadAsset' | |
| 870 | + | type ReleaseService_UploadAsset_Call struct { | |
| 871 | + | *mock.Call | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | // UploadAsset is a helper method to define mock.On call | |
| 875 | + | // - ctx context.Context | |
| 876 | + | // - p domain.Principal | |
| 877 | + | // - repo domain.RepoName | |
| 878 | + | // - params ports.UploadAssetParams | |
| 879 | + | // - body io.Reader | |
| 880 | + | func (_e *ReleaseService_Expecter) UploadAsset(ctx any, p any, repo any, params any, body any) *ReleaseService_UploadAsset_Call { | |
| 881 | + | return &ReleaseService_UploadAsset_Call{Call: _e.mock.On("UploadAsset", ctx, p, repo, params, body)} | |
| 882 | + | } | |
| 883 | + | ||
| 884 | + | func (_c *ReleaseService_UploadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.UploadAssetParams, body io.Reader)) *ReleaseService_UploadAsset_Call { | |
| 885 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 886 | + | var arg0 context.Context | |
| 887 | + | if args[0] != nil { | |
| 888 | + | arg0 = args[0].(context.Context) | |
| 889 | + | } | |
| 890 | + | var arg1 domain.Principal | |
| 891 | + | if args[1] != nil { | |
| 892 | + | arg1 = args[1].(domain.Principal) | |
| 893 | + | } | |
| 894 | + | var arg2 domain.RepoName | |
| 895 | + | if args[2] != nil { | |
| 896 | + | arg2 = args[2].(domain.RepoName) | |
| 897 | + | } | |
| 898 | + | var arg3 ports.UploadAssetParams | |
| 899 | + | if args[3] != nil { | |
| 900 | + | arg3 = args[3].(ports.UploadAssetParams) | |
| 901 | + | } | |
| 902 | + | var arg4 io.Reader | |
| 903 | + | if args[4] != nil { | |
| 904 | + | arg4 = args[4].(io.Reader) | |
| 905 | + | } | |
| 906 | + | run( | |
| 907 | + | arg0, | |
| 908 | + | arg1, | |
| 909 | + | arg2, | |
| 910 | + | arg3, | |
| 911 | + | arg4, | |
| 912 | + | ) | |
| 913 | + | }) | |
| 914 | + | return _c | |
| 915 | + | } | |
| 916 | + | ||
| 917 | + | func (_c *ReleaseService_UploadAsset_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseService_UploadAsset_Call { | |
| 918 | + | _c.Call.Return(releaseAsset, err) | |
| 919 | + | return _c | |
| 920 | + | } | |
| 921 | + | ||
| 922 | + | func (_c *ReleaseService_UploadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.UploadAssetParams, body io.Reader) (domain.ReleaseAsset, error)) *ReleaseService_UploadAsset_Call { | |
| 923 | + | _c.Call.Return(run) | |
| 924 | + | return _c | |
| 925 | + | } | |
| 926 | + | ||
| 927 | + | // Usage provides a mock function for the type ReleaseService | |
| 928 | + | func (_mock *ReleaseService) Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.ReleaseUsage, error) { | |
| 929 | + | ret := _mock.Called(ctx, p, repo) | |
| 930 | + | ||
| 931 | + | if len(ret) == 0 { | |
| 932 | + | panic("no return value specified for Usage") | |
| 933 | + | } | |
| 934 | + | ||
| 935 | + | var r0 ports.ReleaseUsage | |
| 936 | + | var r1 error | |
| 937 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (ports.ReleaseUsage, error)); ok { | |
| 938 | + | return returnFunc(ctx, p, repo) | |
| 939 | + | } | |
| 940 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ports.ReleaseUsage); ok { | |
| 941 | + | r0 = returnFunc(ctx, p, repo) | |
| 942 | + | } else { | |
| 943 | + | r0 = ret.Get(0).(ports.ReleaseUsage) | |
| 944 | + | } | |
| 945 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |
| 946 | + | r1 = returnFunc(ctx, p, repo) | |
| 947 | + | } else { | |
| 948 | + | r1 = ret.Error(1) | |
| 949 | + | } | |
| 950 | + | return r0, r1 | |
| 951 | + | } | |
| 952 | + | ||
| 953 | + | // ReleaseService_Usage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Usage' | |
| 954 | + | type ReleaseService_Usage_Call struct { | |
| 955 | + | *mock.Call | |
| 956 | + | } | |
| 957 | + | ||
| 958 | + | // Usage is a helper method to define mock.On call | |
| 959 | + | // - ctx context.Context | |
| 960 | + | // - p domain.Principal | |
| 961 | + | // - repo domain.RepoName | |
| 962 | + | func (_e *ReleaseService_Expecter) Usage(ctx any, p any, repo any) *ReleaseService_Usage_Call { | |
| 963 | + | return &ReleaseService_Usage_Call{Call: _e.mock.On("Usage", ctx, p, repo)} | |
| 964 | + | } | |
| 965 | + | ||
| 966 | + | func (_c *ReleaseService_Usage_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *ReleaseService_Usage_Call { | |
| 967 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 968 | + | var arg0 context.Context | |
| 969 | + | if args[0] != nil { | |
| 970 | + | arg0 = args[0].(context.Context) | |
| 971 | + | } | |
| 972 | + | var arg1 domain.Principal | |
| 973 | + | if args[1] != nil { | |
| 974 | + | arg1 = args[1].(domain.Principal) | |
| 975 | + | } | |
| 976 | + | var arg2 domain.RepoName | |
| 977 | + | if args[2] != nil { | |
| 978 | + | arg2 = args[2].(domain.RepoName) | |
| 979 | + | } | |
| 980 | + | run( | |
| 981 | + | arg0, | |
| 982 | + | arg1, | |
| 983 | + | arg2, | |
| 984 | + | ) | |
| 985 | + | }) | |
| 986 | + | return _c | |
| 987 | + | } | |
| 988 | + | ||
| 989 | + | func (_c *ReleaseService_Usage_Call) Return(releaseUsage ports.ReleaseUsage, err error) *ReleaseService_Usage_Call { | |
| 990 | + | _c.Call.Return(releaseUsage, err) | |
| 991 | + | return _c | |
| 992 | + | } | |
| 993 | + | ||
| 994 | + | func (_c *ReleaseService_Usage_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.ReleaseUsage, error)) *ReleaseService_Usage_Call { | |
| 995 | + | _c.Call.Return(run) | |
| 996 | + | return _c | |
| 997 | + | } | |
A internal/ports/mocks/ReleaseStore.go +796 -0
| @@ -0,0 +1,796 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewReleaseStore creates a new instance of ReleaseStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewReleaseStore(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *ReleaseStore { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &ReleaseStore{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // ReleaseStore is an autogenerated mock type for the ReleaseStore type | |
| 39 | + | type ReleaseStore struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type ReleaseStore_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *ReleaseStore) EXPECT() *ReleaseStore_Expecter { | |
| 48 | + | return &ReleaseStore_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // AllAssets provides a mock function for the type ReleaseStore | |
| 52 | + | func (_mock *ReleaseStore) AllAssets(ctx context.Context) ([]domain.ReleaseAsset, error) { | |
| 53 | + | ret := _mock.Called(ctx) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for AllAssets") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 []domain.ReleaseAsset | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.ReleaseAsset, error)); ok { | |
| 62 | + | return returnFunc(ctx) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.ReleaseAsset); ok { | |
| 65 | + | r0 = returnFunc(ctx) | |
| 66 | + | } else { | |
| 67 | + | if ret.Get(0) != nil { | |
| 68 | + | r0 = ret.Get(0).([]domain.ReleaseAsset) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 72 | + | r1 = returnFunc(ctx) | |
| 73 | + | } else { | |
| 74 | + | r1 = ret.Error(1) | |
| 75 | + | } | |
| 76 | + | return r0, r1 | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // ReleaseStore_AllAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllAssets' | |
| 80 | + | type ReleaseStore_AllAssets_Call struct { | |
| 81 | + | *mock.Call | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // AllAssets is a helper method to define mock.On call | |
| 85 | + | // - ctx context.Context | |
| 86 | + | func (_e *ReleaseStore_Expecter) AllAssets(ctx any) *ReleaseStore_AllAssets_Call { | |
| 87 | + | return &ReleaseStore_AllAssets_Call{Call: _e.mock.On("AllAssets", ctx)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *ReleaseStore_AllAssets_Call) Run(run func(ctx context.Context)) *ReleaseStore_AllAssets_Call { | |
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | + | var arg0 context.Context | |
| 93 | + | if args[0] != nil { | |
| 94 | + | arg0 = args[0].(context.Context) | |
| 95 | + | } | |
| 96 | + | run( | |
| 97 | + | arg0, | |
| 98 | + | ) | |
| 99 | + | }) | |
| 100 | + | return _c | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (_c *ReleaseStore_AllAssets_Call) Return(releaseAssets []domain.ReleaseAsset, err error) *ReleaseStore_AllAssets_Call { | |
| 104 | + | _c.Call.Return(releaseAssets, err) | |
| 105 | + | return _c | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (_c *ReleaseStore_AllAssets_Call) RunAndReturn(run func(ctx context.Context) ([]domain.ReleaseAsset, error)) *ReleaseStore_AllAssets_Call { | |
| 109 | + | _c.Call.Return(run) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // ApplyReleases provides a mock function for the type ReleaseStore | |
| 114 | + | func (_mock *ReleaseStore) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) error { | |
| 115 | + | ret := _mock.Called(ctx, w) | |
| 116 | + | ||
| 117 | + | if len(ret) == 0 { | |
| 118 | + | panic("no return value specified for ApplyReleases") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | var r0 error | |
| 122 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.ReleaseWrite) error); ok { | |
| 123 | + | r0 = returnFunc(ctx, w) | |
| 124 | + | } else { | |
| 125 | + | r0 = ret.Error(0) | |
| 126 | + | } | |
| 127 | + | return r0 | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // ReleaseStore_ApplyReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyReleases' | |
| 131 | + | type ReleaseStore_ApplyReleases_Call struct { | |
| 132 | + | *mock.Call | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // ApplyReleases is a helper method to define mock.On call | |
| 136 | + | // - ctx context.Context | |
| 137 | + | // - w ports.ReleaseWrite | |
| 138 | + | func (_e *ReleaseStore_Expecter) ApplyReleases(ctx any, w any) *ReleaseStore_ApplyReleases_Call { | |
| 139 | + | return &ReleaseStore_ApplyReleases_Call{Call: _e.mock.On("ApplyReleases", ctx, w)} | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (_c *ReleaseStore_ApplyReleases_Call) Run(run func(ctx context.Context, w ports.ReleaseWrite)) *ReleaseStore_ApplyReleases_Call { | |
| 143 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 144 | + | var arg0 context.Context | |
| 145 | + | if args[0] != nil { | |
| 146 | + | arg0 = args[0].(context.Context) | |
| 147 | + | } | |
| 148 | + | var arg1 ports.ReleaseWrite | |
| 149 | + | if args[1] != nil { | |
| 150 | + | arg1 = args[1].(ports.ReleaseWrite) | |
| 151 | + | } | |
| 152 | + | run( | |
| 153 | + | arg0, | |
| 154 | + | arg1, | |
| 155 | + | ) | |
| 156 | + | }) | |
| 157 | + | return _c | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (_c *ReleaseStore_ApplyReleases_Call) Return(err error) *ReleaseStore_ApplyReleases_Call { | |
| 161 | + | _c.Call.Return(err) | |
| 162 | + | return _c | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (_c *ReleaseStore_ApplyReleases_Call) RunAndReturn(run func(ctx context.Context, w ports.ReleaseWrite) error) *ReleaseStore_ApplyReleases_Call { | |
| 166 | + | _c.Call.Return(run) | |
| 167 | + | return _c | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // GetAsset provides a mock function for the type ReleaseStore | |
| 171 | + | func (_mock *ReleaseStore) GetAsset(ctx context.Context, id int64) (domain.ReleaseAsset, error) { | |
| 172 | + | ret := _mock.Called(ctx, id) | |
| 173 | + | ||
| 174 | + | if len(ret) == 0 { | |
| 175 | + | panic("no return value specified for GetAsset") | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | var r0 domain.ReleaseAsset | |
| 179 | + | var r1 error | |
| 180 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64) (domain.ReleaseAsset, error)); ok { | |
| 181 | + | return returnFunc(ctx, id) | |
| 182 | + | } | |
| 183 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64) domain.ReleaseAsset); ok { | |
| 184 | + | r0 = returnFunc(ctx, id) | |
| 185 | + | } else { | |
| 186 | + | r0 = ret.Get(0).(domain.ReleaseAsset) | |
| 187 | + | } | |
| 188 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int64) error); ok { | |
| 189 | + | r1 = returnFunc(ctx, id) | |
| 190 | + | } else { | |
| 191 | + | r1 = ret.Error(1) | |
| 192 | + | } | |
| 193 | + | return r0, r1 | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // ReleaseStore_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset' | |
| 197 | + | type ReleaseStore_GetAsset_Call struct { | |
| 198 | + | *mock.Call | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // GetAsset is a helper method to define mock.On call | |
| 202 | + | // - ctx context.Context | |
| 203 | + | // - id int64 | |
| 204 | + | func (_e *ReleaseStore_Expecter) GetAsset(ctx any, id any) *ReleaseStore_GetAsset_Call { | |
| 205 | + | return &ReleaseStore_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, id)} | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (_c *ReleaseStore_GetAsset_Call) Run(run func(ctx context.Context, id int64)) *ReleaseStore_GetAsset_Call { | |
| 209 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 210 | + | var arg0 context.Context | |
| 211 | + | if args[0] != nil { | |
| 212 | + | arg0 = args[0].(context.Context) | |
| 213 | + | } | |
| 214 | + | var arg1 int64 | |
| 215 | + | if args[1] != nil { | |
| 216 | + | arg1 = args[1].(int64) | |
| 217 | + | } | |
| 218 | + | run( | |
| 219 | + | arg0, | |
| 220 | + | arg1, | |
| 221 | + | ) | |
| 222 | + | }) | |
| 223 | + | return _c | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (_c *ReleaseStore_GetAsset_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseStore_GetAsset_Call { | |
| 227 | + | _c.Call.Return(releaseAsset, err) | |
| 228 | + | return _c | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (_c *ReleaseStore_GetAsset_Call) RunAndReturn(run func(ctx context.Context, id int64) (domain.ReleaseAsset, error)) *ReleaseStore_GetAsset_Call { | |
| 232 | + | _c.Call.Return(run) | |
| 233 | + | return _c | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // GetAssetByName provides a mock function for the type ReleaseStore | |
| 237 | + | func (_mock *ReleaseStore) GetAssetByName(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error) { | |
| 238 | + | ret := _mock.Called(ctx, repo, releaseID, name) | |
| 239 | + | ||
| 240 | + | if len(ret) == 0 { | |
| 241 | + | panic("no return value specified for GetAssetByName") | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | var r0 domain.ReleaseAsset | |
| 245 | + | var r1 error | |
| 246 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, string) (domain.ReleaseAsset, error)); ok { | |
| 247 | + | return returnFunc(ctx, repo, releaseID, name) | |
| 248 | + | } | |
| 249 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, string) domain.ReleaseAsset); ok { | |
| 250 | + | r0 = returnFunc(ctx, repo, releaseID, name) | |
| 251 | + | } else { | |
| 252 | + | r0 = ret.Get(0).(domain.ReleaseAsset) | |
| 253 | + | } | |
| 254 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, string) error); ok { | |
| 255 | + | r1 = returnFunc(ctx, repo, releaseID, name) | |
| 256 | + | } else { | |
| 257 | + | r1 = ret.Error(1) | |
| 258 | + | } | |
| 259 | + | return r0, r1 | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // ReleaseStore_GetAssetByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAssetByName' | |
| 263 | + | type ReleaseStore_GetAssetByName_Call struct { | |
| 264 | + | *mock.Call | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // GetAssetByName is a helper method to define mock.On call | |
| 268 | + | // - ctx context.Context | |
| 269 | + | // - repo domain.RepoName | |
| 270 | + | // - releaseID int64 | |
| 271 | + | // - name string | |
| 272 | + | func (_e *ReleaseStore_Expecter) GetAssetByName(ctx any, repo any, releaseID any, name any) *ReleaseStore_GetAssetByName_Call { | |
| 273 | + | return &ReleaseStore_GetAssetByName_Call{Call: _e.mock.On("GetAssetByName", ctx, repo, releaseID, name)} | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (_c *ReleaseStore_GetAssetByName_Call) Run(run func(ctx context.Context, repo domain.RepoName, releaseID int64, name string)) *ReleaseStore_GetAssetByName_Call { | |
| 277 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 278 | + | var arg0 context.Context | |
| 279 | + | if args[0] != nil { | |
| 280 | + | arg0 = args[0].(context.Context) | |
| 281 | + | } | |
| 282 | + | var arg1 domain.RepoName | |
| 283 | + | if args[1] != nil { | |
| 284 | + | arg1 = args[1].(domain.RepoName) | |
| 285 | + | } | |
| 286 | + | var arg2 int64 | |
| 287 | + | if args[2] != nil { | |
| 288 | + | arg2 = args[2].(int64) | |
| 289 | + | } | |
| 290 | + | var arg3 string | |
| 291 | + | if args[3] != nil { | |
| 292 | + | arg3 = args[3].(string) | |
| 293 | + | } | |
| 294 | + | run( | |
| 295 | + | arg0, | |
| 296 | + | arg1, | |
| 297 | + | arg2, | |
| 298 | + | arg3, | |
| 299 | + | ) | |
| 300 | + | }) | |
| 301 | + | return _c | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func (_c *ReleaseStore_GetAssetByName_Call) Return(releaseAsset domain.ReleaseAsset, err error) *ReleaseStore_GetAssetByName_Call { | |
| 305 | + | _c.Call.Return(releaseAsset, err) | |
| 306 | + | return _c | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (_c *ReleaseStore_GetAssetByName_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error)) *ReleaseStore_GetAssetByName_Call { | |
| 310 | + | _c.Call.Return(run) | |
| 311 | + | return _c | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // GetRelease provides a mock function for the type ReleaseStore | |
| 315 | + | func (_mock *ReleaseStore) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) { | |
| 316 | + | ret := _mock.Called(ctx, repo, id) | |
| 317 | + | ||
| 318 | + | if len(ret) == 0 { | |
| 319 | + | panic("no return value specified for GetRelease") | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | var r0 domain.Release | |
| 323 | + | var r1 error | |
| 324 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Release, error)); ok { | |
| 325 | + | return returnFunc(ctx, repo, id) | |
| 326 | + | } | |
| 327 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Release); ok { | |
| 328 | + | r0 = returnFunc(ctx, repo, id) | |
| 329 | + | } else { | |
| 330 | + | r0 = ret.Get(0).(domain.Release) | |
| 331 | + | } | |
| 332 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |
| 333 | + | r1 = returnFunc(ctx, repo, id) | |
| 334 | + | } else { | |
| 335 | + | r1 = ret.Error(1) | |
| 336 | + | } | |
| 337 | + | return r0, r1 | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // ReleaseStore_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease' | |
| 341 | + | type ReleaseStore_GetRelease_Call struct { | |
| 342 | + | *mock.Call | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // GetRelease is a helper method to define mock.On call | |
| 346 | + | // - ctx context.Context | |
| 347 | + | // - repo domain.RepoName | |
| 348 | + | // - id int64 | |
| 349 | + | func (_e *ReleaseStore_Expecter) GetRelease(ctx any, repo any, id any) *ReleaseStore_GetRelease_Call { | |
| 350 | + | return &ReleaseStore_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, repo, id)} | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func (_c *ReleaseStore_GetRelease_Call) Run(run func(ctx context.Context, repo domain.RepoName, id int64)) *ReleaseStore_GetRelease_Call { | |
| 354 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 355 | + | var arg0 context.Context | |
| 356 | + | if args[0] != nil { | |
| 357 | + | arg0 = args[0].(context.Context) | |
| 358 | + | } | |
| 359 | + | var arg1 domain.RepoName | |
| 360 | + | if args[1] != nil { | |
| 361 | + | arg1 = args[1].(domain.RepoName) | |
| 362 | + | } | |
| 363 | + | var arg2 int64 | |
| 364 | + | if args[2] != nil { | |
| 365 | + | arg2 = args[2].(int64) | |
| 366 | + | } | |
| 367 | + | run( | |
| 368 | + | arg0, | |
| 369 | + | arg1, | |
| 370 | + | arg2, | |
| 371 | + | ) | |
| 372 | + | }) | |
| 373 | + | return _c | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func (_c *ReleaseStore_GetRelease_Call) Return(release domain.Release, err error) *ReleaseStore_GetRelease_Call { | |
| 377 | + | _c.Call.Return(release, err) | |
| 378 | + | return _c | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func (_c *ReleaseStore_GetRelease_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error)) *ReleaseStore_GetRelease_Call { | |
| 382 | + | _c.Call.Return(run) | |
| 383 | + | return _c | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // GetReleaseByTag provides a mock function for the type ReleaseStore | |
| 387 | + | func (_mock *ReleaseStore) GetReleaseByTag(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error) { | |
| 388 | + | ret := _mock.Called(ctx, repo, tag) | |
| 389 | + | ||
| 390 | + | if len(ret) == 0 { | |
| 391 | + | panic("no return value specified for GetReleaseByTag") | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | var r0 domain.Release | |
| 395 | + | var r1 error | |
| 396 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) (domain.Release, error)); ok { | |
| 397 | + | return returnFunc(ctx, repo, tag) | |
| 398 | + | } | |
| 399 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) domain.Release); ok { | |
| 400 | + | r0 = returnFunc(ctx, repo, tag) | |
| 401 | + | } else { | |
| 402 | + | r0 = ret.Get(0).(domain.Release) | |
| 403 | + | } | |
| 404 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok { | |
| 405 | + | r1 = returnFunc(ctx, repo, tag) | |
| 406 | + | } else { | |
| 407 | + | r1 = ret.Error(1) | |
| 408 | + | } | |
| 409 | + | return r0, r1 | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | // ReleaseStore_GetReleaseByTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetReleaseByTag' | |
| 413 | + | type ReleaseStore_GetReleaseByTag_Call struct { | |
| 414 | + | *mock.Call | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | // GetReleaseByTag is a helper method to define mock.On call | |
| 418 | + | // - ctx context.Context | |
| 419 | + | // - repo domain.RepoName | |
| 420 | + | // - tag string | |
| 421 | + | func (_e *ReleaseStore_Expecter) GetReleaseByTag(ctx any, repo any, tag any) *ReleaseStore_GetReleaseByTag_Call { | |
| 422 | + | return &ReleaseStore_GetReleaseByTag_Call{Call: _e.mock.On("GetReleaseByTag", ctx, repo, tag)} | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func (_c *ReleaseStore_GetReleaseByTag_Call) Run(run func(ctx context.Context, repo domain.RepoName, tag string)) *ReleaseStore_GetReleaseByTag_Call { | |
| 426 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 427 | + | var arg0 context.Context | |
| 428 | + | if args[0] != nil { | |
| 429 | + | arg0 = args[0].(context.Context) | |
| 430 | + | } | |
| 431 | + | var arg1 domain.RepoName | |
| 432 | + | if args[1] != nil { | |
| 433 | + | arg1 = args[1].(domain.RepoName) | |
| 434 | + | } | |
| 435 | + | var arg2 string | |
| 436 | + | if args[2] != nil { | |
| 437 | + | arg2 = args[2].(string) | |
| 438 | + | } | |
| 439 | + | run( | |
| 440 | + | arg0, | |
| 441 | + | arg1, | |
| 442 | + | arg2, | |
| 443 | + | ) | |
| 444 | + | }) | |
| 445 | + | return _c | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func (_c *ReleaseStore_GetReleaseByTag_Call) Return(release domain.Release, err error) *ReleaseStore_GetReleaseByTag_Call { | |
| 449 | + | _c.Call.Return(release, err) | |
| 450 | + | return _c | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func (_c *ReleaseStore_GetReleaseByTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error)) *ReleaseStore_GetReleaseByTag_Call { | |
| 454 | + | _c.Call.Return(run) | |
| 455 | + | return _c | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // ListAssets provides a mock function for the type ReleaseStore | |
| 459 | + | func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) { | |
| 460 | + | ret := _mock.Called(ctx, repo, releaseID) | |
| 461 | + | ||
| 462 | + | if len(ret) == 0 { | |
| 463 | + | panic("no return value specified for ListAssets") | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | var r0 []domain.ReleaseAsset | |
| 467 | + | var r1 error | |
| 468 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.ReleaseAsset, error)); ok { | |
| 469 | + | return returnFunc(ctx, repo, releaseID) | |
| 470 | + | } | |
| 471 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.ReleaseAsset); ok { | |
| 472 | + | r0 = returnFunc(ctx, repo, releaseID) | |
| 473 | + | } else { | |
| 474 | + | if ret.Get(0) != nil { | |
| 475 | + | r0 = ret.Get(0).([]domain.ReleaseAsset) | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |
| 479 | + | r1 = returnFunc(ctx, repo, releaseID) | |
| 480 | + | } else { | |
| 481 | + | r1 = ret.Error(1) | |
| 482 | + | } | |
| 483 | + | return r0, r1 | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | // ReleaseStore_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets' | |
| 487 | + | type ReleaseStore_ListAssets_Call struct { | |
| 488 | + | *mock.Call | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // ListAssets is a helper method to define mock.On call | |
| 492 | + | // - ctx context.Context | |
| 493 | + | // - repo domain.RepoName | |
| 494 | + | // - releaseID int64 | |
| 495 | + | func (_e *ReleaseStore_Expecter) ListAssets(ctx any, repo any, releaseID any) *ReleaseStore_ListAssets_Call { | |
| 496 | + | return &ReleaseStore_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, repo, releaseID)} | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | func (_c *ReleaseStore_ListAssets_Call) Run(run func(ctx context.Context, repo domain.RepoName, releaseID int64)) *ReleaseStore_ListAssets_Call { | |
| 500 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 501 | + | var arg0 context.Context | |
| 502 | + | if args[0] != nil { | |
| 503 | + | arg0 = args[0].(context.Context) | |
| 504 | + | } | |
| 505 | + | var arg1 domain.RepoName | |
| 506 | + | if args[1] != nil { | |
| 507 | + | arg1 = args[1].(domain.RepoName) | |
| 508 | + | } | |
| 509 | + | var arg2 int64 | |
| 510 | + | if args[2] != nil { | |
| 511 | + | arg2 = args[2].(int64) | |
| 512 | + | } | |
| 513 | + | run( | |
| 514 | + | arg0, | |
| 515 | + | arg1, | |
| 516 | + | arg2, | |
| 517 | + | ) | |
| 518 | + | }) | |
| 519 | + | return _c | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func (_c *ReleaseStore_ListAssets_Call) Return(releaseAssets []domain.ReleaseAsset, err error) *ReleaseStore_ListAssets_Call { | |
| 523 | + | _c.Call.Return(releaseAssets, err) | |
| 524 | + | return _c | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | func (_c *ReleaseStore_ListAssets_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error)) *ReleaseStore_ListAssets_Call { | |
| 528 | + | _c.Call.Return(run) | |
| 529 | + | return _c | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | // ListReleases provides a mock function for the type ReleaseStore | |
| 533 | + | func (_mock *ReleaseStore) ListReleases(ctx context.Context, repo domain.RepoName) ([]domain.Release, error) { | |
| 534 | + | ret := _mock.Called(ctx, repo) | |
| 535 | + | ||
| 536 | + | if len(ret) == 0 { | |
| 537 | + | panic("no return value specified for ListReleases") | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | var r0 []domain.Release | |
| 541 | + | var r1 error | |
| 542 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.Release, error)); ok { | |
| 543 | + | return returnFunc(ctx, repo) | |
| 544 | + | } | |
| 545 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.Release); ok { | |
| 546 | + | r0 = returnFunc(ctx, repo) | |
| 547 | + | } else { | |
| 548 | + | if ret.Get(0) != nil { | |
| 549 | + | r0 = ret.Get(0).([]domain.Release) | |
| 550 | + | } | |
| 551 | + | } | |
| 552 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok { | |
| 553 | + | r1 = returnFunc(ctx, repo) | |
| 554 | + | } else { | |
| 555 | + | r1 = ret.Error(1) | |
| 556 | + | } | |
| 557 | + | return r0, r1 | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | // ReleaseStore_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases' | |
| 561 | + | type ReleaseStore_ListReleases_Call struct { | |
| 562 | + | *mock.Call | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | // ListReleases is a helper method to define mock.On call | |
| 566 | + | // - ctx context.Context | |
| 567 | + | // - repo domain.RepoName | |
| 568 | + | func (_e *ReleaseStore_Expecter) ListReleases(ctx any, repo any) *ReleaseStore_ListReleases_Call { | |
| 569 | + | return &ReleaseStore_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, repo)} | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | func (_c *ReleaseStore_ListReleases_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_ListReleases_Call { | |
| 573 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 574 | + | var arg0 context.Context | |
| 575 | + | if args[0] != nil { | |
| 576 | + | arg0 = args[0].(context.Context) | |
| 577 | + | } | |
| 578 | + | var arg1 domain.RepoName | |
| 579 | + | if args[1] != nil { | |
| 580 | + | arg1 = args[1].(domain.RepoName) | |
| 581 | + | } | |
| 582 | + | run( | |
| 583 | + | arg0, | |
| 584 | + | arg1, | |
| 585 | + | ) | |
| 586 | + | }) | |
| 587 | + | return _c | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | func (_c *ReleaseStore_ListReleases_Call) Return(releases []domain.Release, err error) *ReleaseStore_ListReleases_Call { | |
| 591 | + | _c.Call.Return(releases, err) | |
| 592 | + | return _c | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | func (_c *ReleaseStore_ListReleases_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.Release, error)) *ReleaseStore_ListReleases_Call { | |
| 596 | + | _c.Call.Return(run) | |
| 597 | + | return _c | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | // NextReleaseID provides a mock function for the type ReleaseStore | |
| 601 | + | func (_mock *ReleaseStore) NextReleaseID(ctx context.Context) (int64, error) { | |
| 602 | + | ret := _mock.Called(ctx) | |
| 603 | + | ||
| 604 | + | if len(ret) == 0 { | |
| 605 | + | panic("no return value specified for NextReleaseID") | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | var r0 int64 | |
| 609 | + | var r1 error | |
| 610 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (int64, error)); ok { | |
| 611 | + | return returnFunc(ctx) | |
| 612 | + | } | |
| 613 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) int64); ok { | |
| 614 | + | r0 = returnFunc(ctx) | |
| 615 | + | } else { | |
| 616 | + | r0 = ret.Get(0).(int64) | |
| 617 | + | } | |
| 618 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 619 | + | r1 = returnFunc(ctx) | |
| 620 | + | } else { | |
| 621 | + | r1 = ret.Error(1) | |
| 622 | + | } | |
| 623 | + | return r0, r1 | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | // ReleaseStore_NextReleaseID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NextReleaseID' | |
| 627 | + | type ReleaseStore_NextReleaseID_Call struct { | |
| 628 | + | *mock.Call | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | // NextReleaseID is a helper method to define mock.On call | |
| 632 | + | // - ctx context.Context | |
| 633 | + | func (_e *ReleaseStore_Expecter) NextReleaseID(ctx any) *ReleaseStore_NextReleaseID_Call { | |
| 634 | + | return &ReleaseStore_NextReleaseID_Call{Call: _e.mock.On("NextReleaseID", ctx)} | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | func (_c *ReleaseStore_NextReleaseID_Call) Run(run func(ctx context.Context)) *ReleaseStore_NextReleaseID_Call { | |
| 638 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 639 | + | var arg0 context.Context | |
| 640 | + | if args[0] != nil { | |
| 641 | + | arg0 = args[0].(context.Context) | |
| 642 | + | } | |
| 643 | + | run( | |
| 644 | + | arg0, | |
| 645 | + | ) | |
| 646 | + | }) | |
| 647 | + | return _c | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | func (_c *ReleaseStore_NextReleaseID_Call) Return(n int64, err error) *ReleaseStore_NextReleaseID_Call { | |
| 651 | + | _c.Call.Return(n, err) | |
| 652 | + | return _c | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | func (_c *ReleaseStore_NextReleaseID_Call) RunAndReturn(run func(ctx context.Context) (int64, error)) *ReleaseStore_NextReleaseID_Call { | |
| 656 | + | _c.Call.Return(run) | |
| 657 | + | return _c | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | // ReleaseRepo provides a mock function for the type ReleaseStore | |
| 661 | + | func (_mock *ReleaseStore) ReleaseRepo(ctx context.Context, id int64) (domain.RepoName, error) { | |
| 662 | + | ret := _mock.Called(ctx, id) | |
| 663 | + | ||
| 664 | + | if len(ret) == 0 { | |
| 665 | + | panic("no return value specified for ReleaseRepo") | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | var r0 domain.RepoName | |
| 669 | + | var r1 error | |
| 670 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64) (domain.RepoName, error)); ok { | |
| 671 | + | return returnFunc(ctx, id) | |
| 672 | + | } | |
| 673 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int64) domain.RepoName); ok { | |
| 674 | + | r0 = returnFunc(ctx, id) | |
| 675 | + | } else { | |
| 676 | + | r0 = ret.Get(0).(domain.RepoName) | |
| 677 | + | } | |
| 678 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int64) error); ok { | |
| 679 | + | r1 = returnFunc(ctx, id) | |
| 680 | + | } else { | |
| 681 | + | r1 = ret.Error(1) | |
| 682 | + | } | |
| 683 | + | return r0, r1 | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | // ReleaseStore_ReleaseRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReleaseRepo' | |
| 687 | + | type ReleaseStore_ReleaseRepo_Call struct { | |
| 688 | + | *mock.Call | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | // ReleaseRepo is a helper method to define mock.On call | |
| 692 | + | // - ctx context.Context | |
| 693 | + | // - id int64 | |
| 694 | + | func (_e *ReleaseStore_Expecter) ReleaseRepo(ctx any, id any) *ReleaseStore_ReleaseRepo_Call { | |
| 695 | + | return &ReleaseStore_ReleaseRepo_Call{Call: _e.mock.On("ReleaseRepo", ctx, id)} | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | func (_c *ReleaseStore_ReleaseRepo_Call) Run(run func(ctx context.Context, id int64)) *ReleaseStore_ReleaseRepo_Call { | |
| 699 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 700 | + | var arg0 context.Context | |
| 701 | + | if args[0] != nil { | |
| 702 | + | arg0 = args[0].(context.Context) | |
| 703 | + | } | |
| 704 | + | var arg1 int64 | |
| 705 | + | if args[1] != nil { | |
| 706 | + | arg1 = args[1].(int64) | |
| 707 | + | } | |
| 708 | + | run( | |
| 709 | + | arg0, | |
| 710 | + | arg1, | |
| 711 | + | ) | |
| 712 | + | }) | |
| 713 | + | return _c | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | func (_c *ReleaseStore_ReleaseRepo_Call) Return(repoName domain.RepoName, err error) *ReleaseStore_ReleaseRepo_Call { | |
| 717 | + | _c.Call.Return(repoName, err) | |
| 718 | + | return _c | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | func (_c *ReleaseStore_ReleaseRepo_Call) RunAndReturn(run func(ctx context.Context, id int64) (domain.RepoName, error)) *ReleaseStore_ReleaseRepo_Call { | |
| 722 | + | _c.Call.Return(run) | |
| 723 | + | return _c | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | // ReleaseUsage provides a mock function for the type ReleaseStore | |
| 727 | + | func (_mock *ReleaseStore) ReleaseUsage(ctx context.Context, repo domain.RepoName) (int64, uint64, error) { | |
| 728 | + | ret := _mock.Called(ctx, repo) | |
| 729 | + | ||
| 730 | + | if len(ret) == 0 { | |
| 731 | + | panic("no return value specified for ReleaseUsage") | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | var r0 int64 | |
| 735 | + | var r1 uint64 | |
| 736 | + | var r2 error | |
| 737 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int64, uint64, error)); ok { | |
| 738 | + | return returnFunc(ctx, repo) | |
| 739 | + | } | |
| 740 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int64); ok { | |
| 741 | + | r0 = returnFunc(ctx, repo) | |
| 742 | + | } else { | |
| 743 | + | r0 = ret.Get(0).(int64) | |
| 744 | + | } | |
| 745 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) uint64); ok { | |
| 746 | + | r1 = returnFunc(ctx, repo) | |
| 747 | + | } else { | |
| 748 | + | r1 = ret.Get(1).(uint64) | |
| 749 | + | } | |
| 750 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok { | |
| 751 | + | r2 = returnFunc(ctx, repo) | |
| 752 | + | } else { | |
| 753 | + | r2 = ret.Error(2) | |
| 754 | + | } | |
| 755 | + | return r0, r1, r2 | |
| 756 | + | } | |
| 757 | + | ||
| 758 | + | // ReleaseStore_ReleaseUsage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReleaseUsage' | |
| 759 | + | type ReleaseStore_ReleaseUsage_Call struct { | |
| 760 | + | *mock.Call | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | // ReleaseUsage is a helper method to define mock.On call | |
| 764 | + | // - ctx context.Context | |
| 765 | + | // - repo domain.RepoName | |
| 766 | + | func (_e *ReleaseStore_Expecter) ReleaseUsage(ctx any, repo any) *ReleaseStore_ReleaseUsage_Call { | |
| 767 | + | return &ReleaseStore_ReleaseUsage_Call{Call: _e.mock.On("ReleaseUsage", ctx, repo)} | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | func (_c *ReleaseStore_ReleaseUsage_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_ReleaseUsage_Call { | |
| 771 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 772 | + | var arg0 context.Context | |
| 773 | + | if args[0] != nil { | |
| 774 | + | arg0 = args[0].(context.Context) | |
| 775 | + | } | |
| 776 | + | var arg1 domain.RepoName | |
| 777 | + | if args[1] != nil { | |
| 778 | + | arg1 = args[1].(domain.RepoName) | |
| 779 | + | } | |
| 780 | + | run( | |
| 781 | + | arg0, | |
| 782 | + | arg1, | |
| 783 | + | ) | |
| 784 | + | }) | |
| 785 | + | return _c | |
| 786 | + | } | |
| 787 | + | ||
| 788 | + | func (_c *ReleaseStore_ReleaseUsage_Call) Return(used int64, version uint64, err error) *ReleaseStore_ReleaseUsage_Call { | |
| 789 | + | _c.Call.Return(used, version, err) | |
| 790 | + | return _c | |
| 791 | + | } | |
| 792 | + | ||
| 793 | + | func (_c *ReleaseStore_ReleaseUsage_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int64, uint64, error)) *ReleaseStore_ReleaseUsage_Call { | |
| 794 | + | _c.Call.Return(run) | |
| 795 | + | return _c | |
| 796 | + | } | |
A internal/ports/mocks/ReleaseSweeper.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewReleaseSweeper creates a new instance of ReleaseSweeper. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewReleaseSweeper(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *ReleaseSweeper { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | mock := &ReleaseSweeper{} | |
| 24 | + | mock.Mock.Test(t) | |
| 25 | + | ||
| 26 | + | t.Cleanup(func() { | |
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | + | helper.Helper() | |
| 29 | + | } | |
| 30 | + | mock.AssertExpectations(t) | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | return mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // ReleaseSweeper is an autogenerated mock type for the ReleaseSweeper type | |
| 37 | + | type ReleaseSweeper struct { | |
| 38 | + | mock.Mock | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type ReleaseSweeper_Expecter struct { | |
| 42 | + | mock *mock.Mock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (_m *ReleaseSweeper) EXPECT() *ReleaseSweeper_Expecter { | |
| 46 | + | return &ReleaseSweeper_Expecter{mock: &_m.Mock} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Sweep provides a mock function for the type ReleaseSweeper | |
| 50 | + | func (_mock *ReleaseSweeper) Sweep(ctx context.Context) { | |
| 51 | + | _mock.Called(ctx) | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // ReleaseSweeper_Sweep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sweep' | |
| 56 | + | type ReleaseSweeper_Sweep_Call struct { | |
| 57 | + | *mock.Call | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Sweep is a helper method to define mock.On call | |
| 61 | + | // - ctx context.Context | |
| 62 | + | func (_e *ReleaseSweeper_Expecter) Sweep(ctx any) *ReleaseSweeper_Sweep_Call { | |
| 63 | + | return &ReleaseSweeper_Sweep_Call{Call: _e.mock.On("Sweep", ctx)} | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (_c *ReleaseSweeper_Sweep_Call) Run(run func(ctx context.Context)) *ReleaseSweeper_Sweep_Call { | |
| 67 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 68 | + | var arg0 context.Context | |
| 69 | + | if args[0] != nil { | |
| 70 | + | arg0 = args[0].(context.Context) | |
| 71 | + | } | |
| 72 | + | run( | |
| 73 | + | arg0, | |
| 74 | + | ) | |
| 75 | + | }) | |
| 76 | + | return _c | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *ReleaseSweeper_Sweep_Call) Return() *ReleaseSweeper_Sweep_Call { | |
| 80 | + | _c.Call.Return() | |
| 81 | + | return _c | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (_c *ReleaseSweeper_Sweep_Call) RunAndReturn(run func(ctx context.Context)) *ReleaseSweeper_Sweep_Call { | |
| 85 | + | _c.Run(run) | |
| 86 | + | return _c | |
| 87 | + | } | |
A internal/ports/mocks/ReleaseTagTrigger.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewReleaseTagTrigger creates a new instance of ReleaseTagTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewReleaseTagTrigger(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *ReleaseTagTrigger { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &ReleaseTagTrigger{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // ReleaseTagTrigger is an autogenerated mock type for the ReleaseTagTrigger type | |
| 38 | + | type ReleaseTagTrigger struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type ReleaseTagTrigger_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *ReleaseTagTrigger) EXPECT() *ReleaseTagTrigger_Expecter { | |
| 47 | + | return &ReleaseTagTrigger_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // OnRefsMoved provides a mock function for the type ReleaseTagTrigger | |
| 51 | + | func (_mock *ReleaseTagTrigger) OnRefsMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) { | |
| 52 | + | _mock.Called(ctx, repo, updates) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // ReleaseTagTrigger_OnRefsMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnRefsMoved' | |
| 57 | + | type ReleaseTagTrigger_OnRefsMoved_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // OnRefsMoved is a helper method to define mock.On call | |
| 62 | + | // - ctx context.Context | |
| 63 | + | // - repo domain.RepoName | |
| 64 | + | // - updates []domain.RefUpdate | |
| 65 | + | func (_e *ReleaseTagTrigger_Expecter) OnRefsMoved(ctx any, repo any, updates any) *ReleaseTagTrigger_OnRefsMoved_Call { | |
| 66 | + | return &ReleaseTagTrigger_OnRefsMoved_Call{Call: _e.mock.On("OnRefsMoved", ctx, repo, updates)} | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (_c *ReleaseTagTrigger_OnRefsMoved_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *ReleaseTagTrigger_OnRefsMoved_Call { | |
| 70 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 71 | + | var arg0 context.Context | |
| 72 | + | if args[0] != nil { | |
| 73 | + | arg0 = args[0].(context.Context) | |
| 74 | + | } | |
| 75 | + | var arg1 domain.RepoName | |
| 76 | + | if args[1] != nil { | |
| 77 | + | arg1 = args[1].(domain.RepoName) | |
| 78 | + | } | |
| 79 | + | var arg2 []domain.RefUpdate | |
| 80 | + | if args[2] != nil { | |
| 81 | + | arg2 = args[2].([]domain.RefUpdate) | |
| 82 | + | } | |
| 83 | + | run( | |
| 84 | + | arg0, | |
| 85 | + | arg1, | |
| 86 | + | arg2, | |
| 87 | + | ) | |
| 88 | + | }) | |
| 89 | + | return _c | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *ReleaseTagTrigger_OnRefsMoved_Call) Return() *ReleaseTagTrigger_OnRefsMoved_Call { | |
| 93 | + | _c.Call.Return() | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *ReleaseTagTrigger_OnRefsMoved_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *ReleaseTagTrigger_OnRefsMoved_Call { | |
| 98 | + | _c.Run(run) | |
| 99 | + | return _c | |
| 100 | + | } | |
A internal/ports/mocks/ReleaseTagWriter.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewReleaseTagWriter creates a new instance of ReleaseTagWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewReleaseTagWriter(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *ReleaseTagWriter { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &ReleaseTagWriter{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // ReleaseTagWriter is an autogenerated mock type for the ReleaseTagWriter type | |
| 38 | + | type ReleaseTagWriter struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type ReleaseTagWriter_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *ReleaseTagWriter) EXPECT() *ReleaseTagWriter_Expecter { | |
| 47 | + | return &ReleaseTagWriter_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // CreateTag provides a mock function for the type ReleaseTagWriter | |
| 51 | + | func (_mock *ReleaseTagWriter) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error { | |
| 52 | + | ret := _mock.Called(ctx, repo, tag, target) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for CreateTag") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, domain.ObjectID) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, repo, tag, target) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // ReleaseTagWriter_CreateTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTag' | |
| 68 | + | type ReleaseTagWriter_CreateTag_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // CreateTag is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - repo domain.RepoName | |
| 75 | + | // - tag string | |
| 76 | + | // - target domain.ObjectID | |
| 77 | + | func (_e *ReleaseTagWriter_Expecter) CreateTag(ctx any, repo any, tag any, target any) *ReleaseTagWriter_CreateTag_Call { | |
| 78 | + | return &ReleaseTagWriter_CreateTag_Call{Call: _e.mock.On("CreateTag", ctx, repo, tag, target)} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (_c *ReleaseTagWriter_CreateTag_Call) Run(run func(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID)) *ReleaseTagWriter_CreateTag_Call { | |
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | + | var arg0 context.Context | |
| 84 | + | if args[0] != nil { | |
| 85 | + | arg0 = args[0].(context.Context) | |
| 86 | + | } | |
| 87 | + | var arg1 domain.RepoName | |
| 88 | + | if args[1] != nil { | |
| 89 | + | arg1 = args[1].(domain.RepoName) | |
| 90 | + | } | |
| 91 | + | var arg2 string | |
| 92 | + | if args[2] != nil { | |
| 93 | + | arg2 = args[2].(string) | |
| 94 | + | } | |
| 95 | + | var arg3 domain.ObjectID | |
| 96 | + | if args[3] != nil { | |
| 97 | + | arg3 = args[3].(domain.ObjectID) | |
| 98 | + | } | |
| 99 | + | run( | |
| 100 | + | arg0, | |
| 101 | + | arg1, | |
| 102 | + | arg2, | |
| 103 | + | arg3, | |
| 104 | + | ) | |
| 105 | + | }) | |
| 106 | + | return _c | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (_c *ReleaseTagWriter_CreateTag_Call) Return(err error) *ReleaseTagWriter_CreateTag_Call { | |
| 110 | + | _c.Call.Return(err) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (_c *ReleaseTagWriter_CreateTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error) *ReleaseTagWriter_CreateTag_Call { | |
| 115 | + | _c.Call.Return(run) | |
| 116 | + | return _c | |
| 117 | + | } | |
A internal/ports/release.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package ports | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bnema.dev/ermite/internal/domain" | |
| 7 | + | "context" | |
| 8 | + | "io" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ReleaseWrite atomically changes one repository's metadata. Every write | |
| 12 | + | // requires UsageVersion (zero requires absence), and each record's Version. | |
| 13 | + | // Used includes both uploading and uploaded assets. | |
| 14 | + | type ReleaseWrite struct { | |
| 15 | + | Repo domain.RepoName | |
| 16 | + | Used int64 | |
| 17 | + | UsageVersion uint64 | |
| 18 | + | PutReleases, DeleteReleases []domain.Release | |
| 19 | + | PutAssets, DeleteAssets []domain.ReleaseAsset | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it. | |
| 23 | + | type ReleaseStore interface { | |
| 24 | + | NextReleaseID(ctx context.Context) (int64, error) | |
| 25 | + | ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error) | |
| 26 | + | GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) | |
| 27 | + | GetReleaseByTag(ctx context.Context, repo domain.RepoName, tag string) (domain.Release, error) | |
| 28 | + | ReleaseRepo(ctx context.Context, id int64) (domain.RepoName, error) | |
| 29 | + | ListReleases(ctx context.Context, repo domain.RepoName) ([]domain.Release, error) | |
| 30 | + | GetAsset(ctx context.Context, id int64) (domain.ReleaseAsset, error) | |
| 31 | + | GetAssetByName(ctx context.Context, repo domain.RepoName, releaseID int64, name string) (domain.ReleaseAsset, error) | |
| 32 | + | ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) | |
| 33 | + | AllAssets(ctx context.Context) ([]domain.ReleaseAsset, error) | |
| 34 | + | ApplyReleases(ctx context.Context, w ReleaseWrite) error | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // ReleaseLimits defaults to domain.DefaultReleaseAssetMax (ERMITE_RELEASE_ASSET_MAX), | |
| 38 | + | // domain.DefaultReleaseAssetsMax (ERMITE_RELEASE_ASSETS_MAX), and | |
| 39 | + | // domain.DefaultReleaseQuota (ERMITE_RELEASE_QUOTA). | |
| 40 | + | type ReleaseLimits struct{ AssetMax, AssetsMax, Quota int64 } | |
| 41 | + | type CreateReleaseParams struct { | |
| 42 | + | Tag, TargetCommitish, Title, Notes string | |
| 43 | + | Draft, Prerelease, MakeLatest bool | |
| 44 | + | } | |
| 45 | + | type UpdateReleaseParams struct { | |
| 46 | + | Tag, TargetCommitish, Title, Notes *string | |
| 47 | + | Draft, Prerelease, MakeLatest *bool | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ReleaseRef selects exactly one of ID, Tag, Latest. | |
| 51 | + | type ReleaseRef struct { | |
| 52 | + | ID int64 | |
| 53 | + | Tag string | |
| 54 | + | Latest bool | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // AssetRef selects ID, (Tag, Name), or (Latest, Name). | |
| 58 | + | type AssetRef struct { | |
| 59 | + | ID int64 | |
| 60 | + | Tag, Name string | |
| 61 | + | Latest bool | |
| 62 | + | } | |
| 63 | + | type UploadAssetParams struct { | |
| 64 | + | ReleaseID int64 | |
| 65 | + | Name, Label, ContentType string | |
| 66 | + | Size int64 | |
| 67 | + | Replace bool | |
| 68 | + | } | |
| 69 | + | type UpdateAssetParams struct{ Name, Label *string } | |
| 70 | + | type ReleaseUsage struct { | |
| 71 | + | Used int64 | |
| 72 | + | Limits ReleaseLimits | |
| 73 | + | } | |
| 74 | + | type AssetChunk struct { | |
| 75 | + | Asset *domain.ReleaseAsset | |
| 76 | + | Data []byte | |
| 77 | + | } | |
| 78 | + | type ReleasePage struct { | |
| 79 | + | Releases []domain.Release | |
| 80 | + | NextPageToken string | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // ReleaseService manages releases and streams assets; *usecase.Releases implements it. | |
| 84 | + | type ReleaseService interface { | |
| 85 | + | CreateRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, params CreateReleaseParams) (domain.Release, error) | |
| 86 | + | GetRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ReleaseRef) (domain.Release, error) | |
| 87 | + | ListReleases(ctx context.Context, p domain.Principal, repo domain.RepoName, pageSize int, pageToken string) (ReleasePage, error) | |
| 88 | + | UpdateRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params UpdateReleaseParams) (domain.Release, error) | |
| 89 | + | DeleteRelease(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error | |
| 90 | + | ListAssets(ctx context.Context, p domain.Principal, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) | |
| 91 | + | UploadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, params UploadAssetParams, body io.Reader) (domain.ReleaseAsset, error) | |
| 92 | + | UpdateAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64, params UpdateAssetParams) (domain.ReleaseAsset, error) | |
| 93 | + | DeleteAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, assetID int64) error | |
| 94 | + | GetAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref AssetRef) (domain.ReleaseAsset, error) | |
| 95 | + | // DownloadAsset sends metadata first, then chunks at most domain.CIArtifactChunk; | |
| 96 | + | // length less than zero reads to the end. | |
| 97 | + | DownloadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref AssetRef, offset, length int64, send func(AssetChunk) error) error | |
| 98 | + | Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ReleaseUsage, error) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // ReleaseTagWriter creates lightweight tags without moving existing refs. | |
| 102 | + | // *gitcli.Git implements it. An existing ref returns domain.ErrAlreadyExists. | |
| 103 | + | type ReleaseTagWriter interface { | |
| 104 | + | CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // ReleaseTagTrigger handles tag deletion; *usecase.Releases implements it. | |
| 108 | + | type ReleaseTagTrigger interface { | |
| 109 | + | OnRefsMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // ReleaseSweeper removes stale assets and blobs; *usecase.Releases implements it. | |
| 113 | + | type ReleaseSweeper interface{ Sweep(ctx context.Context) } | |
| 114 | + | ||
| 115 | + | // ReleaseRepoCleaner removes contents after metadata deletion; *usecase.Releases implements it. | |
| 116 | + | type ReleaseRepoCleaner interface { | |
| 117 | + | AssetsOf(ctx context.Context, repo domain.RepoName) ([]domain.ReleaseAsset, error) | |
| 118 | + | OnRepoDeleted(ctx context.Context, repo domain.RepoName, assets []domain.ReleaseAsset) | |
| 119 | + | } | |
M internal/ports/services.go +17 -0
| @@ -390,3 +390,20 @@ type EnvironmentService interface { | |||
| 390 | 390 | ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error) | |
| 391 | 391 | DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error | |
| 392 | 392 | } | |
| 393 | + | ||
| 394 | + | // GitHubReleaseAPI is the Releases part of the REST subset. owner/repo follow | |
| 395 | + | // GitHubAPIService; bodies are GitHub JSON. *usecase.ReleasesGitHub implements it. | |
| 396 | + | type GitHubReleaseAPI interface { | |
| 397 | + | CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error) | |
| 398 | + | GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ReleaseRef) ([]byte, error) | |
| 399 | + | ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) (body []byte, link string, err error) | |
| 400 | + | UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) | |
| 401 | + | DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error | |
| 402 | + | ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) (body []byte, link string, err error) | |
| 403 | + | UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, r io.Reader) ([]byte, error) | |
| 404 | + | GetAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) ([]byte, error) | |
| 405 | + | UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64, body []byte) ([]byte, error) | |
| 406 | + | DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) error | |
| 407 | + | // AssetByID resolves metadata for a download through ReleaseService. | |
| 408 | + | AssetByID(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) (domain.ReleaseAsset, error) | |
| 409 | + | } | |
M internal/usecase/ci.go +15 -7
| @@ -125,14 +125,17 @@ type CIConfig struct { | |||
| 125 | 125 | PublicURL string | |
| 126 | 126 | // Retention is how long runs, jobs, checks and logs are kept. | |
| 127 | 127 | Retention time.Duration | |
| 128 | - | // Artifacts and ArtifactFiles keep the artifacts of the runs (CI-4 D3): | |
| 129 | - | // the records and the contents. Without both the artifact services refuse | |
| 128 | + | // Artifacts and Blobs keep the records and contents of the artifacts. | |
| 129 | + | // Without both the artifact services refuse | |
| 130 | 130 | // every call. ArtifactLimits are their settings; a zero field takes its | |
| 131 | 131 | // default (the run retention of domain.CIRetentionDays, | |
| 132 | 132 | // domain.DefaultCIArtifactQuota, domain.DefaultCIArtifactMaxSize). Leave | |
| 133 | 133 | // them unset rather than assigning typed nil pointers. | |
| 134 | - | Artifacts ports.CIArtifactStore | |
| 134 | + | Artifacts ports.CIArtifactStore | |
| 135 | + | // ArtifactFiles is the legacy reader; nil when $ERMITE_DATA_DIR/ci/artifacts | |
| 136 | + | // does not exist at startup; P8 wires it. | |
| 135 | 137 | ArtifactFiles ports.CIArtifactFiles | |
| 138 | + | Blobs ports.BlobStore | |
| 136 | 139 | ArtifactLimits ports.ArtifactLimits | |
| 137 | 140 | ||
| 138 | 141 | // The durations below default to the contract's; tests shorten them. | |
| @@ -186,6 +189,8 @@ type CIConfig struct { | |||
| 186 | 189 | // ScheduleVisit, when set, is called with each repository the scheduler | |
| 187 | 190 | // looks at. Tests use it. | |
| 188 | 191 | ScheduleVisit func(repo domain.RepoName) | |
| 192 | + | // ReleaseSweeper collects abandoned release uploads during the daily sweep. | |
| 193 | + | ReleaseSweeper ports.ReleaseSweeper | |
| 189 | 194 | } | |
| 190 | 195 | ||
| 191 | 196 | func (c CIConfig) withDefaults() CIConfig { | |
| @@ -244,9 +249,12 @@ func (c CIConfig) withDefaults() CIConfig { | |||
| 244 | 249 | // calls (a runner's update and an owner's cancel) never lose one another's | |
| 245 | 250 | // write. Assignment takes the assignment lock before the lock of the run. | |
| 246 | 251 | type CI struct { | |
| 247 | - | cfg CIConfig | |
| 248 | - | boot time.Time | |
| 249 | - | queue chan ciTrigger | |
| 252 | + | cfg CIConfig | |
| 253 | + | // uploadSlots bounds the artifact uploads and assemblies buffering a | |
| 254 | + | // block in memory at once; nil (hand-built CI in tests) means unbounded. | |
| 255 | + | uploadSlots chan struct{} | |
| 256 | + | boot time.Time | |
| 257 | + | queue chan ciTrigger | |
| 250 | 258 | // runEvents are the requested and completed events of runs, for the | |
| 251 | 259 | // workflow_run workflows (ci_workflowrun.go). | |
| 252 | 260 | runEvents chan runEvent | |
| @@ -315,7 +323,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||
| 315 | 323 | cfg = cfg.withDefaults() | |
| 316 | 324 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | |
| 317 | 325 | return &CI{ | |
| 318 | - | pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge, | |
| 326 | + | pushes: map[domain.RepoName]int{}, uploadSlots: make(chan struct{}, ciArtifactUploadSlots), pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge, | |
| 319 | 327 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts), | |
| 320 | 328 | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(), | |
| 321 | 329 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | |
M internal/usecase/ci_artifacts.go +325 -12
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "fmt" | |
| 14 | 14 | "io" | |
| 15 | 15 | "strconv" | |
| 16 | + | "strings" | |
| 16 | 17 | "sync" | |
| 17 | 18 | "time" | |
| 18 | 19 | ||
| @@ -25,7 +26,7 @@ import ( | |||
| 25 | 26 | // The artifacts of the runs (artifacts v4, CI-4 D3). The runner translates the | |
| 26 | 27 | // toolkit's protocol and calls JobArtifactService with the job token; owners | |
| 27 | 28 | // use ArtifactService. The records are in CIConfig.Artifacts, the contents in | |
| 28 | - | // CIConfig.ArtifactFiles, named by ids only. | |
| 29 | + | // CIConfig.Blobs (ArtifactFiles for legacy records), named by ids only. | |
| 29 | 30 | // | |
| 30 | 31 | // Every write of the artifacts of a repository goes through its usage record | |
| 31 | 32 | // (ports.CIArtifactWrite): a decision is made on the records read after it and | |
| @@ -50,6 +51,44 @@ const ( | |||
| 50 | 51 | commitTries = 5 | |
| 51 | 52 | ) | |
| 52 | 53 | ||
| 54 | + | // ciArtifactUploadSlots bounds the artifact block uploads and assemblies that | |
| 55 | + | // hold an 8 MiB buffer at the same time, server-wide. | |
| 56 | + | const ciArtifactUploadSlots = 16 | |
| 57 | + | ||
| 58 | + | // blockBufs pools the 8 MiB buffers of uploads and assemblies. CIArtifactMaxBlockSize | |
| 59 | + | // and ports.BlobPartSize are both 8 MiB. | |
| 60 | + | var blockBufs = sync.Pool{New: func() any { | |
| 61 | + | b := make([]byte, max(domain.CIArtifactMaxBlockSize, ports.BlobPartSize)) | |
| 62 | + | return &b | |
| 63 | + | }} | |
| 64 | + | ||
| 65 | + | // acquireUploadSlot takes an upload slot without waiting; release frees it. | |
| 66 | + | func (c *CI) acquireUploadSlot() (release func(), err error) { | |
| 67 | + | if c.uploadSlots == nil { | |
| 68 | + | return func() {}, nil | |
| 69 | + | } | |
| 70 | + | select { | |
| 71 | + | case c.uploadSlots <- struct{}{}: | |
| 72 | + | return func() { <-c.uploadSlots }, nil | |
| 73 | + | default: | |
| 74 | + | return nil, fmt.Errorf("%w: too many artifact uploads in progress, retry", domain.ErrResourceExhausted) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // waitUploadSlot takes an upload slot, waiting for one until ctx ends: a | |
| 79 | + | // commit must not fail because block uploads are in flight. | |
| 80 | + | func (c *CI) waitUploadSlot(ctx context.Context) (release func(), err error) { | |
| 81 | + | if c.uploadSlots == nil { | |
| 82 | + | return func() {}, nil | |
| 83 | + | } | |
| 84 | + | select { | |
| 85 | + | case c.uploadSlots <- struct{}{}: | |
| 86 | + | return func() { <-c.uploadSlots }, nil | |
| 87 | + | case <-ctx.Done(): | |
| 88 | + | return nil, ctx.Err() | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 53 | 92 | var ( | |
| 54 | 93 | // errNoArtifactWrite ends updateArtifacts without a write. | |
| 55 | 94 | errNoArtifactWrite = errors.New("no write") | |
| @@ -57,7 +96,7 @@ var ( | |||
| 57 | 96 | errArtifactChanged = errors.New("artifact changed") | |
| 58 | 97 | ) | |
| 59 | 98 | ||
| 60 | - | func (c *CI) artifactsOn() bool { return c.cfg.Artifacts != nil && c.cfg.ArtifactFiles != nil } | |
| 99 | + | func (c *CI) artifactsOn() bool { return c.cfg.Artifacts != nil && c.cfg.Blobs != nil } | |
| 61 | 100 | ||
| 62 | 101 | func artifactRef(a domain.CIArtifact) ports.CIArtifactRef { | |
| 63 | 102 | return ports.CIArtifactRef{RunID: a.RunID, ID: a.ID} | |
| @@ -309,7 +348,13 @@ func (c *CI) removeArtifacts(ctx context.Context, repo domain.RepoName, runID in | |||
| 309 | 348 | // logged: the sweep removes the files that no record names. | |
| 310 | 349 | func (c *CI) removeArtifactFiles(ctx context.Context, arts []domain.CIArtifact) { | |
| 311 | 350 | for _, a := range arts { | |
| 312 | - | if err := c.cfg.ArtifactFiles.Remove(ctx, artifactRef(a)); err != nil { | |
| 351 | + | var err error | |
| 352 | + | if a.Store == domain.CIArtifactStoreBlob { | |
| 353 | + | err = errors.Join(c.cfg.Blobs.Delete(ctx, a.BlobKey()), c.removeBlobBlocks(ctx, a)) | |
| 354 | + | } else if c.cfg.ArtifactFiles != nil { | |
| 355 | + | err = c.cfg.ArtifactFiles.Remove(ctx, artifactRef(a)) | |
| 356 | + | } | |
| 357 | + | if err != nil { | |
| 313 | 358 | ctxLog(ctx).Warn().Err(err).Int64("artifact_id", a.ID).Msg("ci: remove the files of an artifact; the sweep will") | |
| 314 | 359 | } | |
| 315 | 360 | } | |
| @@ -373,7 +418,7 @@ func (c *CI) CreateJobArtifact(ctx context.Context, secret string, params ports. | |||
| 373 | 418 | } | |
| 374 | 419 | a := domain.CIArtifact{ | |
| 375 | 420 | ID: id, Repo: j.Repo, RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID, Assignment: j.TokenHash, | |
| 376 | - | Name: params.Name, MediaType: mediaType, State: domain.CIArtifactPending, | |
| 421 | + | Name: params.Name, MediaType: mediaType, State: domain.CIArtifactPending, Store: domain.CIArtifactStoreBlob, | |
| 377 | 422 | CreatedAt: now, ExpiresAt: domain.ArtifactExpiry(params.ExpiresAt, now, c.cfg.ArtifactLimits.Retention), | |
| 378 | 423 | } | |
| 379 | 424 | var discarded []domain.CIArtifact | |
| @@ -460,15 +505,24 @@ func (c *CI) UploadJobArtifactBlock(ctx context.Context, secret, jobID string, a | |||
| 460 | 505 | if a.State != domain.CIArtifactPending { | |
| 461 | 506 | return 0, errCommitted(a.ID) | |
| 462 | 507 | } | |
| 508 | + | // The slot is taken before anything is reserved, read or allocated. | |
| 509 | + | freeSlot, err := c.acquireUploadSlot() | |
| 510 | + | if err != nil { | |
| 511 | + | return 0, err | |
| 512 | + | } | |
| 513 | + | defer freeSlot() | |
| 463 | 514 | // Every byte is reserved before it is written, against the charges and | |
| 464 | 515 | // the other writes in flight (a block it replaces counts until the record | |
| 465 | 516 | // drops it): nothing beyond the limits ever reaches the disk. | |
| 466 | 517 | src := &reservingReader{ctx: ctx, c: c, a: a, r: data} | |
| 467 | 518 | defer func() { c.release(a, src.reserved) }() | |
| 468 | 519 | limits := c.cfg.ArtifactLimits | |
| 469 | - | ref, file := artifactRef(a), newBlockFile(blockID) | |
| 470 | - | size, err := c.cfg.ArtifactFiles.PutBlock(ctx, ref, file, src) | |
| 520 | + | file := newBlockFile(blockID) | |
| 521 | + | size, err := c.putUnsized(ctx, a.BlockKey(file), src) | |
| 471 | 522 | if err != nil { | |
| 523 | + | if derr := c.cfg.Blobs.Delete(context.WithoutCancel(ctx), a.BlockKey(file)); derr != nil { | |
| 524 | + | ctxLog(ctx).Warn().Err(derr).Msg("ci: remove a refused block; the sweep will") | |
| 525 | + | } | |
| 472 | 526 | return 0, err | |
| 473 | 527 | } | |
| 474 | 528 | var old *ports.CIArtifactBlock | |
| @@ -509,13 +563,13 @@ func (c *CI) UploadJobArtifactBlock(ctx context.Context, secret, jobID string, a | |||
| 509 | 563 | return nil | |
| 510 | 564 | }) | |
| 511 | 565 | if err != nil { | |
| 512 | - | if rerr := c.cfg.ArtifactFiles.RemoveBlock(ctx, ref, file); rerr != nil { | |
| 566 | + | if rerr := c.cfg.Blobs.Delete(context.WithoutCancel(ctx), a.BlockKey(file)); rerr != nil { | |
| 513 | 567 | ctxLog(ctx).Warn().Err(rerr).Int64("artifact_id", a.ID).Msg("ci: remove a refused block; the sweep will") | |
| 514 | 568 | } | |
| 515 | 569 | return 0, err | |
| 516 | 570 | } | |
| 517 | 571 | if old != nil { | |
| 518 | - | if err := c.cfg.ArtifactFiles.RemoveBlock(ctx, ref, old.File); err != nil { | |
| 572 | + | if err := c.cfg.Blobs.Delete(ctx, a.BlockKey(old.File)); err != nil { | |
| 519 | 573 | ctxLog(ctx).Warn().Err(err).Int64("artifact_id", a.ID).Msg("ci: remove a replaced block") | |
| 520 | 574 | } | |
| 521 | 575 | } | |
| @@ -602,7 +656,7 @@ func (c *CI) CommitJobArtifact(ctx context.Context, secret, jobID string, artifa | |||
| 602 | 656 | return 0, "", err | |
| 603 | 657 | } | |
| 604 | 658 | } | |
| 605 | - | size, digest, err := c.cfg.ArtifactFiles.Assemble(ctx, artifactRef(a), files) | |
| 659 | + | size, digest, err := c.assembleBlobs(ctx, a, files, total) | |
| 606 | 660 | if err != nil { | |
| 607 | 661 | c.release(a, excess) | |
| 608 | 662 | } | |
| @@ -638,7 +692,7 @@ func (c *CI) CommitJobArtifact(ctx context.Context, secret, jobID string, artifa | |||
| 638 | 692 | if err != nil { | |
| 639 | 693 | return 0, "", err | |
| 640 | 694 | } | |
| 641 | - | if err := c.cfg.ArtifactFiles.RemoveBlocks(ctx, artifactRef(a)); err != nil { | |
| 695 | + | if err := c.removeBlobBlocks(ctx, a); err != nil { | |
| 642 | 696 | ctxLog(ctx).Warn().Err(err).Int64("artifact_id", a.ID).Msg("ci: remove the blocks of a committed artifact") | |
| 643 | 697 | } | |
| 644 | 698 | c.dropIfEnded(ctx, j) | |
| @@ -782,7 +836,23 @@ func (c *CI) streamArtifact(ctx context.Context, a domain.CIArtifact, offset int | |||
| 782 | 836 | if offset < 0 || offset > a.Size { | |
| 783 | 837 | return fmt.Errorf("%w: offset %d is beyond the size %d", domain.ErrOutOfRange, offset, a.Size) | |
| 784 | 838 | } | |
| 785 | - | f, err := c.cfg.ArtifactFiles.Open(ctx, artifactRef(a), offset) | |
| 839 | + | var f io.ReadCloser | |
| 840 | + | var err error | |
| 841 | + | if a.Store == domain.CIArtifactStoreBlob { | |
| 842 | + | // BlobStore rejects offsets at EOF, while artifact downloads permit | |
| 843 | + | // an empty resumed stream (including zero-byte artifacts). | |
| 844 | + | if offset == a.Size { | |
| 845 | + | _, err = c.cfg.Blobs.Stat(ctx, a.BlobKey()) | |
| 846 | + | f = io.NopCloser(bytes.NewReader(nil)) | |
| 847 | + | } else { | |
| 848 | + | f, _, err = c.cfg.Blobs.Open(ctx, a.BlobKey(), offset, -1) | |
| 849 | + | } | |
| 850 | + | } else if c.cfg.ArtifactFiles != nil { | |
| 851 | + | f, err = c.cfg.ArtifactFiles.Open(ctx, artifactRef(a), offset) | |
| 852 | + | } else { | |
| 853 | + | ctxLog(ctx).Error().Int64("artifact_id", a.ID).Msg("ci: legacy artifact reader is unavailable") | |
| 854 | + | return domain.ErrNotFound | |
| 855 | + | } | |
| 786 | 856 | if err != nil { | |
| 787 | 857 | return err | |
| 788 | 858 | } | |
| @@ -1008,6 +1078,10 @@ func (c *CI) sweepArtifactOrphans(ctx context.Context) { | |||
| 1008 | 1078 | } | |
| 1009 | 1079 | } | |
| 1010 | 1080 | } | |
| 1081 | + | c.sweepArtifactBlobOrphans(ctx) | |
| 1082 | + | if c.cfg.ArtifactFiles == nil { | |
| 1083 | + | return | |
| 1084 | + | } | |
| 1011 | 1085 | c.removeOrphans(ctx, "artifacts", "artifact directories", c.cfg.ArtifactFiles.RunIDs, c.cfg.ArtifactFiles.RemoveRuns) | |
| 1012 | 1086 | runIDs, err := c.cfg.ArtifactFiles.RunIDs(ctx) | |
| 1013 | 1087 | if err != nil { | |
| @@ -1022,7 +1096,7 @@ func (c *CI) sweepArtifactOrphans(ctx context.Context) { | |||
| 1022 | 1096 | } | |
| 1023 | 1097 | for _, id := range ids { | |
| 1024 | 1098 | a, err := c.cfg.Artifacts.GetArtifact(ctx, id) | |
| 1025 | - | if err == nil && a.RunID == run { | |
| 1099 | + | if err == nil && a.RunID == run && a.Store != domain.CIArtifactStoreBlob { | |
| 1026 | 1100 | continue | |
| 1027 | 1101 | } | |
| 1028 | 1102 | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| @@ -1035,3 +1109,242 @@ func (c *CI) sweepArtifactOrphans(ctx context.Context) { | |||
| 1035 | 1109 | } | |
| 1036 | 1110 | } | |
| 1037 | 1111 | } | |
| 1112 | + | ||
| 1113 | + | // putUnsized buffers at most one toolkit block; Put requires an exact size. | |
| 1114 | + | func (c *CI) putUnsized(ctx context.Context, key string, r io.Reader) (int64, error) { | |
| 1115 | + | // A fixed backing array prevents bytes.Buffer's geometric growth from | |
| 1116 | + | // allocating beyond the block bound. Probe the next byte separately. | |
| 1117 | + | bp := blockBufs.Get().(*[]byte) | |
| 1118 | + | defer blockBufs.Put(bp) | |
| 1119 | + | buf := (*bp)[:domain.CIArtifactMaxBlockSize] | |
| 1120 | + | n, err := io.ReadFull(r, buf) | |
| 1121 | + | if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { | |
| 1122 | + | return 0, err | |
| 1123 | + | } | |
| 1124 | + | if n == len(buf) { | |
| 1125 | + | var extra [1]byte | |
| 1126 | + | m, err := io.ReadFull(r, extra[:]) | |
| 1127 | + | if m != 0 { | |
| 1128 | + | return 0, fmt.Errorf("%w: a block has at most 8 MiB", domain.ErrInvalid) | |
| 1129 | + | } | |
| 1130 | + | if err != io.EOF { | |
| 1131 | + | return 0, err | |
| 1132 | + | } | |
| 1133 | + | } | |
| 1134 | + | if err := ctx.Err(); err != nil { | |
| 1135 | + | return 0, err | |
| 1136 | + | } | |
| 1137 | + | _, err = c.cfg.Blobs.Put(ctx, key, bytes.NewReader(buf[:n]), int64(n)) | |
| 1138 | + | return int64(n), err | |
| 1139 | + | } | |
| 1140 | + | ||
| 1141 | + | func (c *CI) removeBlobBlocks(ctx context.Context, a domain.CIArtifact) error { | |
| 1142 | + | return c.cfg.Blobs.List(ctx, a.BlobKey()+".blocks/", func(b ports.BlobInfo) error { | |
| 1143 | + | return c.cfg.Blobs.Delete(ctx, b.Key) | |
| 1144 | + | }) | |
| 1145 | + | } | |
| 1146 | + | ||
| 1147 | + | // assembleBlobs keeps one 8 MiB part in memory, even when a block crosses | |
| 1148 | + | // part boundaries or is repeated in the commit list. | |
| 1149 | + | func (c *CI) assembleBlobs(ctx context.Context, a domain.CIArtifact, files []string, total int64) (int64, string, error) { | |
| 1150 | + | freeSlot, err := c.waitUploadSlot(ctx) | |
| 1151 | + | if err != nil { | |
| 1152 | + | return 0, "", err | |
| 1153 | + | } | |
| 1154 | + | defer freeSlot() | |
| 1155 | + | var upload ports.MultipartUpload | |
| 1156 | + | if total > ports.BlobPartSize { | |
| 1157 | + | var err error | |
| 1158 | + | upload, err = c.cfg.Blobs.NewMultipart(ctx, a.BlobKey()) | |
| 1159 | + | if err != nil { | |
| 1160 | + | return 0, "", err | |
| 1161 | + | } | |
| 1162 | + | } | |
| 1163 | + | completed := false | |
| 1164 | + | defer func() { | |
| 1165 | + | if upload != nil && !completed { | |
| 1166 | + | if err := upload.Abort(context.WithoutCancel(ctx)); err != nil { | |
| 1167 | + | ctxLog(ctx).Warn().Err(err).Msg("ci: abort artifact assembly; the sweep will") | |
| 1168 | + | } | |
| 1169 | + | } | |
| 1170 | + | }() | |
| 1171 | + | bp := blockBufs.Get().(*[]byte) | |
| 1172 | + | defer blockBufs.Put(bp) | |
| 1173 | + | buf := (*bp)[:ports.BlobPartSize] | |
| 1174 | + | used, part := 0, 1 | |
| 1175 | + | var size int64 | |
| 1176 | + | h := sha256.New() | |
| 1177 | + | flush := func() error { | |
| 1178 | + | if err := upload.UploadPart(ctx, part, bytes.NewReader(buf[:used]), int64(used)); err != nil { | |
| 1179 | + | return err | |
| 1180 | + | } | |
| 1181 | + | used = 0 | |
| 1182 | + | part++ | |
| 1183 | + | return nil | |
| 1184 | + | } | |
| 1185 | + | for _, file := range files { | |
| 1186 | + | r, info, err := c.cfg.Blobs.Open(ctx, a.BlockKey(file), 0, -1) | |
| 1187 | + | // Empty blocks cannot be opened under BlobStore's EOF contract. | |
| 1188 | + | if errors.Is(err, domain.ErrOutOfRange) { | |
| 1189 | + | info, err = c.cfg.Blobs.Stat(ctx, a.BlockKey(file)) | |
| 1190 | + | if err == nil && info.Size == 0 { | |
| 1191 | + | continue | |
| 1192 | + | } | |
| 1193 | + | if err == nil { | |
| 1194 | + | return 0, "", domain.ErrOutOfRange | |
| 1195 | + | } | |
| 1196 | + | } | |
| 1197 | + | if err != nil { | |
| 1198 | + | return 0, "", err | |
| 1199 | + | } | |
| 1200 | + | if info.Size > total-size { | |
| 1201 | + | _ = r.Close() | |
| 1202 | + | return 0, "", fmt.Errorf("%w: artifact block size changed", domain.ErrInvalid) | |
| 1203 | + | } | |
| 1204 | + | left := info.Size | |
| 1205 | + | for left > 0 { | |
| 1206 | + | if err := ctx.Err(); err != nil { | |
| 1207 | + | _ = r.Close() | |
| 1208 | + | return 0, "", err | |
| 1209 | + | } | |
| 1210 | + | want := min(left, int64(len(buf)-used)) | |
| 1211 | + | if want == 0 { | |
| 1212 | + | _ = r.Close() | |
| 1213 | + | return 0, "", fmt.Errorf("%w: artifact block size changed", domain.ErrInvalid) | |
| 1214 | + | } | |
| 1215 | + | n, err := io.ReadFull(r, buf[used:used+int(want)]) | |
| 1216 | + | if err != nil { | |
| 1217 | + | _ = r.Close() | |
| 1218 | + | return 0, "", err | |
| 1219 | + | } | |
| 1220 | + | h.Write(buf[used : used+n]) | |
| 1221 | + | used += n | |
| 1222 | + | size += int64(n) | |
| 1223 | + | left -= int64(n) | |
| 1224 | + | if used == len(buf) && upload != nil { | |
| 1225 | + | if err := flush(); err != nil { | |
| 1226 | + | _ = r.Close() | |
| 1227 | + | return 0, "", err | |
| 1228 | + | } | |
| 1229 | + | } | |
| 1230 | + | } | |
| 1231 | + | var extra [1]byte | |
| 1232 | + | n, err := io.ReadFull(r, extra[:]) | |
| 1233 | + | cerr := r.Close() | |
| 1234 | + | if n != 0 || err != io.EOF { | |
| 1235 | + | return 0, "", fmt.Errorf("%w: artifact block size changed", domain.ErrInvalid) | |
| 1236 | + | } | |
| 1237 | + | if cerr != nil { | |
| 1238 | + | return 0, "", cerr | |
| 1239 | + | } | |
| 1240 | + | } | |
| 1241 | + | if size != total { | |
| 1242 | + | return 0, "", fmt.Errorf("%w: artifact block size changed", domain.ErrInvalid) | |
| 1243 | + | } | |
| 1244 | + | if upload == nil { | |
| 1245 | + | if _, err := c.cfg.Blobs.Put(ctx, a.BlobKey(), bytes.NewReader(buf[:used]), size); err != nil { | |
| 1246 | + | return 0, "", err | |
| 1247 | + | } | |
| 1248 | + | } else { | |
| 1249 | + | if used > 0 { | |
| 1250 | + | if err := flush(); err != nil { | |
| 1251 | + | return 0, "", err | |
| 1252 | + | } | |
| 1253 | + | } | |
| 1254 | + | if _, err := upload.Complete(ctx); err != nil { | |
| 1255 | + | return 0, "", err | |
| 1256 | + | } | |
| 1257 | + | completed = true | |
| 1258 | + | } | |
| 1259 | + | return size, "sha256:" + hex.EncodeToString(h.Sum(nil)), nil | |
| 1260 | + | } | |
| 1261 | + | ||
| 1262 | + | func (c *CI) sweepArtifactBlobOrphans(ctx context.Context) { | |
| 1263 | + | log := ctxLog(ctx) | |
| 1264 | + | cutoff := c.now().Add(-domain.CIArtifactPendingMax) | |
| 1265 | + | err := c.cfg.Blobs.List(ctx, "ci/", func(b ports.BlobInfo) error { | |
| 1266 | + | if !b.ModTime.Before(cutoff) { | |
| 1267 | + | return nil | |
| 1268 | + | } | |
| 1269 | + | run, id, block, ok := artifactBlobIDs(b.Key) | |
| 1270 | + | if !ok { | |
| 1271 | + | return nil | |
| 1272 | + | } | |
| 1273 | + | a, err := c.cfg.Artifacts.GetArtifact(ctx, id) | |
| 1274 | + | if err != nil && !errors.Is(err, domain.ErrNotFound) { | |
| 1275 | + | return nil | |
| 1276 | + | } | |
| 1277 | + | if err == nil && a.RunID == run && a.Store == domain.CIArtifactStoreBlob && (!block || a.State == domain.CIArtifactPending) { | |
| 1278 | + | return nil | |
| 1279 | + | } | |
| 1280 | + | if err := c.cfg.Blobs.Delete(ctx, b.Key); err != nil { | |
| 1281 | + | log.Error().Err(err).Msg("ci: remove orphan artifact blob") | |
| 1282 | + | } | |
| 1283 | + | return nil | |
| 1284 | + | }) | |
| 1285 | + | if err != nil { | |
| 1286 | + | log.Error().Err(err).Msg("ci: list artifact blobs") | |
| 1287 | + | } | |
| 1288 | + | err = c.cfg.Blobs.ListMultipart(ctx, "ci/", func(m ports.MultipartInfo) error { | |
| 1289 | + | if m.Started.Before(cutoff) { | |
| 1290 | + | if err := c.cfg.Blobs.AbortMultipart(ctx, m.Key, m.ID); err != nil { | |
| 1291 | + | log.Error().Err(err).Msg("ci: abort abandoned artifact upload") | |
| 1292 | + | } | |
| 1293 | + | } | |
| 1294 | + | return nil | |
| 1295 | + | }) | |
| 1296 | + | if err != nil { | |
| 1297 | + | log.Error().Err(err).Msg("ci: list artifact multipart uploads") | |
| 1298 | + | } | |
| 1299 | + | } | |
| 1300 | + | ||
| 1301 | + | func artifactBlobIDs(key string) (run, id int64, block, ok bool) { | |
| 1302 | + | rest, found := strings.CutPrefix(key, "ci/") | |
| 1303 | + | if !found { | |
| 1304 | + | return | |
| 1305 | + | } | |
| 1306 | + | r, tail, found := strings.Cut(rest, "/") | |
| 1307 | + | if !found || len(r) != 20 { | |
| 1308 | + | return | |
| 1309 | + | } | |
| 1310 | + | name, file, block := strings.Cut(tail, ".blocks/") | |
| 1311 | + | if len(name) != 20 || (block && file == "") { | |
| 1312 | + | return 0, 0, false, false | |
| 1313 | + | } | |
| 1314 | + | run, err := strconv.ParseInt(r, 10, 64) | |
| 1315 | + | if err != nil || run <= 0 { | |
| 1316 | + | return 0, 0, false, false | |
| 1317 | + | } | |
| 1318 | + | id, err = strconv.ParseInt(name, 10, 64) | |
| 1319 | + | return run, id, block, err == nil && id > 0 | |
| 1320 | + | } | |
| 1321 | + | ||
| 1322 | + | // removeArtifactRuns follows repository deletion, when records are already | |
| 1323 | + | // gone. Run ids are global, so their blob prefixes cannot name another repo. | |
| 1324 | + | func (c *CI) removeArtifactRuns(ctx context.Context, runIDs []int64) error { | |
| 1325 | + | var errs []error | |
| 1326 | + | if c.cfg.ArtifactFiles != nil { | |
| 1327 | + | errs = append(errs, c.cfg.ArtifactFiles.RemoveRuns(ctx, runIDs)) | |
| 1328 | + | } | |
| 1329 | + | if c.cfg.Blobs != nil { | |
| 1330 | + | for _, run := range runIDs { | |
| 1331 | + | if run <= 0 { | |
| 1332 | + | continue | |
| 1333 | + | } | |
| 1334 | + | prefix := fmt.Sprintf("ci/%020d/", run) | |
| 1335 | + | errs = append(errs, c.cfg.Blobs.List(ctx, prefix, func(b ports.BlobInfo) error { | |
| 1336 | + | if err := c.cfg.Blobs.Delete(ctx, b.Key); err != nil { | |
| 1337 | + | errs = append(errs, err) | |
| 1338 | + | } | |
| 1339 | + | return nil | |
| 1340 | + | })) | |
| 1341 | + | errs = append(errs, c.cfg.Blobs.ListMultipart(ctx, prefix, func(m ports.MultipartInfo) error { | |
| 1342 | + | if err := c.cfg.Blobs.AbortMultipart(ctx, m.Key, m.ID); err != nil { | |
| 1343 | + | errs = append(errs, err) | |
| 1344 | + | } | |
| 1345 | + | return nil | |
| 1346 | + | })) | |
| 1347 | + | } | |
| 1348 | + | } | |
| 1349 | + | return errors.Join(errs...) | |
| 1350 | + | } | |
A internal/usecase/ci_artifacts_blob_test.go +301 -0
| @@ -0,0 +1,301 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/hex" | |
| 10 | + | "errors" | |
| 11 | + | "io" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "testing" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/mock" | |
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ||
| 21 | + | "bnema.dev/ermite/internal/domain" | |
| 22 | + | "bnema.dev/ermite/internal/ports" | |
| 23 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // artifactBlobMock uses generated port mocks with byte storage in callbacks. | |
| 27 | + | // Real-disk end-to-end behavior is exercised by internal/app's artifact rig. | |
| 28 | + | func artifactBlobMock(t *testing.T, failPart bool) (*mocks.BlobStore, *int, *int) { | |
| 29 | + | t.Helper() | |
| 30 | + | b := mocks.NewBlobStore(t) | |
| 31 | + | objects := map[string][]byte{} | |
| 32 | + | active := map[string]ports.MultipartInfo{} | |
| 33 | + | puts, multiparts := new(int), new(int) | |
| 34 | + | b.EXPECT().Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string, r io.Reader, size int64) (ports.BlobInfo, error) { | |
| 35 | + | *puts++ | |
| 36 | + | data, err := io.ReadAll(r) | |
| 37 | + | if err != nil { | |
| 38 | + | return ports.BlobInfo{}, err | |
| 39 | + | } | |
| 40 | + | if int64(len(data)) != size { | |
| 41 | + | return ports.BlobInfo{}, domain.ErrInvalid | |
| 42 | + | } | |
| 43 | + | objects[key] = data | |
| 44 | + | return ports.BlobInfo{Key: key, Size: size}, nil | |
| 45 | + | }).Maybe() | |
| 46 | + | b.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string, offset, length int64) (io.ReadCloser, ports.BlobInfo, error) { | |
| 47 | + | data, ok := objects[key] | |
| 48 | + | if !ok { | |
| 49 | + | return nil, ports.BlobInfo{}, domain.ErrNotFound | |
| 50 | + | } | |
| 51 | + | info := ports.BlobInfo{Key: key, Size: int64(len(data))} | |
| 52 | + | if offset >= info.Size { | |
| 53 | + | return nil, info, domain.ErrOutOfRange | |
| 54 | + | } | |
| 55 | + | end := info.Size | |
| 56 | + | if length >= 0 { | |
| 57 | + | end = min(end, offset+length) | |
| 58 | + | } | |
| 59 | + | return io.NopCloser(bytes.NewReader(data[offset:end])), info, nil | |
| 60 | + | }).Maybe() | |
| 61 | + | b.EXPECT().Stat(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string) (ports.BlobInfo, error) { | |
| 62 | + | data, ok := objects[key] | |
| 63 | + | if !ok { | |
| 64 | + | return ports.BlobInfo{}, domain.ErrNotFound | |
| 65 | + | } | |
| 66 | + | return ports.BlobInfo{Key: key, Size: int64(len(data))}, nil | |
| 67 | + | }).Maybe() | |
| 68 | + | b.EXPECT().Delete(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string) error { delete(objects, key); return nil }).Maybe() | |
| 69 | + | b.EXPECT().List(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, prefix string, fn func(ports.BlobInfo) error) error { | |
| 70 | + | for key, data := range objects { | |
| 71 | + | if strings.HasPrefix(key, prefix) { | |
| 72 | + | if err := fn(ports.BlobInfo{Key: key, Size: int64(len(data))}); err != nil { | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | return nil | |
| 78 | + | }).Maybe() | |
| 79 | + | b.EXPECT().ListMultipart(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, prefix string, fn func(ports.MultipartInfo) error) error { | |
| 80 | + | for key, m := range active { | |
| 81 | + | if strings.HasPrefix(key, prefix) { | |
| 82 | + | if err := fn(m); err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return nil | |
| 88 | + | }).Maybe() | |
| 89 | + | b.EXPECT().AbortMultipart(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key, id string) error { delete(active, key); return nil }).Maybe() | |
| 90 | + | b.EXPECT().NewMultipart(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, key string) (ports.MultipartUpload, error) { | |
| 91 | + | *multiparts++ | |
| 92 | + | active[key] = ports.MultipartInfo{Key: key, ID: "upload"} | |
| 93 | + | u := mocks.NewMultipartUpload(t) | |
| 94 | + | var data []byte | |
| 95 | + | u.EXPECT().UploadPart(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ int, r io.Reader, size int64) error { | |
| 96 | + | if failPart { | |
| 97 | + | return errors.New("part failed") | |
| 98 | + | } | |
| 99 | + | part, err := io.ReadAll(r) | |
| 100 | + | data = append(data, part...) | |
| 101 | + | return err | |
| 102 | + | }).Maybe() | |
| 103 | + | u.EXPECT().Complete(mock.Anything).RunAndReturn(func(context.Context) (ports.BlobInfo, error) { | |
| 104 | + | objects[key] = data | |
| 105 | + | delete(active, key) | |
| 106 | + | return ports.BlobInfo{Key: key, Size: int64(len(data))}, nil | |
| 107 | + | }).Maybe() | |
| 108 | + | u.EXPECT().Abort(mock.Anything).RunAndReturn(func(context.Context) error { delete(active, key); return nil }).Maybe() | |
| 109 | + | return u, nil | |
| 110 | + | }).Maybe() | |
| 111 | + | return b, puts, multiparts | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestArtifactBlobAssembly(t *testing.T) { | |
| 115 | + | for _, size := range []int{0, 1, ports.BlobPartSize, ports.BlobPartSize + 1, 2*ports.BlobPartSize + 17} { | |
| 116 | + | t.Run(strconv.Itoa(size), func(t *testing.T) { | |
| 117 | + | ctx := t.Context() | |
| 118 | + | b, puts, multiparts := artifactBlobMock(t, false) | |
| 119 | + | c := &CI{cfg: CIConfig{Blobs: b}} | |
| 120 | + | a := domain.CIArtifact{RunID: 1, ID: 2, Store: domain.CIArtifactStoreBlob} | |
| 121 | + | data := bytes.Repeat([]byte("x"), size) | |
| 122 | + | // Parts straddle block boundaries, with an empty block in the list. | |
| 123 | + | cut := min(size, 3<<20) | |
| 124 | + | _, err := b.Put(ctx, a.BlockKey("a"), bytes.NewReader(data[:cut]), int64(cut)) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | _, err = b.Put(ctx, a.BlockKey("b"), bytes.NewReader(data[cut:]), int64(size-cut)) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | _, err = b.Put(ctx, a.BlockKey("empty"), bytes.NewReader(nil), 0) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | *puts = 0 | |
| 131 | + | n, digest, err := c.assembleBlobs(ctx, a, []string{"a", "empty", "b"}, int64(size)) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | assert.EqualValues(t, size, n) | |
| 134 | + | sum := sha256.Sum256(data) | |
| 135 | + | assert.Equal(t, "sha256:"+hex.EncodeToString(sum[:]), digest) | |
| 136 | + | if size <= ports.BlobPartSize { | |
| 137 | + | assert.Equal(t, 1, *puts) | |
| 138 | + | assert.Zero(t, *multiparts) | |
| 139 | + | } else { | |
| 140 | + | assert.Zero(t, *puts) | |
| 141 | + | assert.Equal(t, 1, *multiparts) | |
| 142 | + | } | |
| 143 | + | if size > 0 { | |
| 144 | + | r, _, err := b.Open(ctx, a.BlobKey(), 0, -1) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | got, err := io.ReadAll(r) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | require.NoError(t, r.Close()) | |
| 149 | + | assert.Equal(t, data, got) | |
| 150 | + | } else { | |
| 151 | + | info, err := b.Stat(ctx, a.BlobKey()) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | assert.Zero(t, info.Size) | |
| 154 | + | } | |
| 155 | + | // No legacy reader is needed for a blob download, including EOF resumes. | |
| 156 | + | a.Size = int64(size) | |
| 157 | + | var got []byte | |
| 158 | + | require.NoError(t, c.streamArtifact(ctx, a, 0, func(ch ports.ArtifactChunk) error { got = append(got, ch.Data...); return nil })) | |
| 159 | + | assert.Equal(t, data, append([]byte{}, got...)) | |
| 160 | + | require.NoError(t, c.streamArtifact(ctx, a, a.Size, func(ports.ArtifactChunk) error { return nil })) | |
| 161 | + | c.removeArtifactFiles(ctx, []domain.CIArtifact{a}) | |
| 162 | + | _, err = b.Stat(ctx, a.BlobKey()) | |
| 163 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 164 | + | require.NoError(t, b.List(ctx, a.BlobKey()+".blocks/", func(ports.BlobInfo) error { t.Error("block survived deletion"); return nil })) | |
| 165 | + | }) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestArtifactBlobAssemblyFailureAborts(t *testing.T) { | |
| 170 | + | for _, failPart := range []bool{false, true} { | |
| 171 | + | t.Run(map[bool]string{false: "missing block", true: "failed part"}[failPart], func(t *testing.T) { | |
| 172 | + | b, _, multiparts := artifactBlobMock(t, failPart) | |
| 173 | + | c := &CI{cfg: CIConfig{Blobs: b}} | |
| 174 | + | a := domain.CIArtifact{RunID: 1, ID: 2} | |
| 175 | + | data := bytes.Repeat([]byte("a"), ports.BlobPartSize) | |
| 176 | + | _, err := b.Put(t.Context(), a.BlockKey("a"), bytes.NewReader(data), int64(len(data))) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | _, _, err = c.assembleBlobs(t.Context(), a, []string{"a", "missing"}, ports.BlobPartSize+1) | |
| 179 | + | require.Error(t, err) | |
| 180 | + | assert.Equal(t, 1, *multiparts) | |
| 181 | + | _, err = b.Stat(t.Context(), a.BlobKey()) | |
| 182 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 183 | + | require.NoError(t, b.ListMultipart(t.Context(), "ci/", func(ports.MultipartInfo) error { t.Error("multipart survived failure"); return nil })) | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestArtifactPutUnsizedBound(t *testing.T) { | |
| 189 | + | b, _, _ := artifactBlobMock(t, false) | |
| 190 | + | c := &CI{cfg: CIConfig{Blobs: b}} | |
| 191 | + | for _, size := range []int{0, 1, domain.CIArtifactMaxBlockSize, domain.CIArtifactMaxBlockSize + 1} { | |
| 192 | + | n, err := c.putUnsized(t.Context(), "ci/1/2", bytes.NewReader(make([]byte, size))) | |
| 193 | + | if size > domain.CIArtifactMaxBlockSize { | |
| 194 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 195 | + | assert.Contains(t, err.Error(), "a block has at most 8 MiB") | |
| 196 | + | } else { | |
| 197 | + | require.NoError(t, err) | |
| 198 | + | assert.EqualValues(t, size, n) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 202 | + | cancel() | |
| 203 | + | _, err := c.putUnsized(ctx, "ci/1/cancelled", bytes.NewReader([]byte("x"))) | |
| 204 | + | assert.ErrorIs(t, err, context.Canceled) | |
| 205 | + | _, err = b.Stat(t.Context(), "ci/1/cancelled") | |
| 206 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestArtifactLegacyStream(t *testing.T) { | |
| 210 | + | a := domain.CIArtifact{RunID: 1, ID: 2, Size: 6} | |
| 211 | + | legacy := mocks.NewCIArtifactFiles(t) | |
| 212 | + | legacy.EXPECT().Open(mock.Anything, artifactRef(a), int64(2)).RunAndReturn(func(context.Context, ports.CIArtifactRef, int64) (io.ReadCloser, error) { | |
| 213 | + | return io.NopCloser(bytes.NewReader([]byte("gacy"))), nil | |
| 214 | + | }).Twice() | |
| 215 | + | c := &CI{cfg: CIConfig{ArtifactFiles: legacy}} | |
| 216 | + | for _, store := range []domain.CIArtifactStore{domain.CIArtifactStoreLegacy, "disk"} { | |
| 217 | + | a.Store = store | |
| 218 | + | var got []byte | |
| 219 | + | require.NoError(t, c.streamArtifact(t.Context(), a, 2, func(ch ports.ArtifactChunk) error { got = append(got, ch.Data...); return nil })) | |
| 220 | + | assert.Equal(t, "gacy", string(got)) | |
| 221 | + | } | |
| 222 | + | c.cfg.ArtifactFiles = nil | |
| 223 | + | assert.ErrorIs(t, c.streamArtifact(t.Context(), a, 0, func(ports.ArtifactChunk) error { return nil }), domain.ErrNotFound) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestArtifactBlobOrphanSweep(t *testing.T) { | |
| 227 | + | ctx := t.Context() | |
| 228 | + | b := mocks.NewBlobStore(t) | |
| 229 | + | s := mocks.NewCIArtifactStore(t) | |
| 230 | + | c := &CI{cfg: CIConfig{Blobs: b, Artifacts: s, Clock: stillClock{}}} | |
| 231 | + | now := c.now() | |
| 232 | + | old := now.Add(-25 * time.Hour) | |
| 233 | + | pending := domain.CIArtifact{RunID: 1, ID: 2, Store: domain.CIArtifactStoreBlob, State: domain.CIArtifactPending} | |
| 234 | + | committed := pending | |
| 235 | + | committed.ID = 3 | |
| 236 | + | committed.State = domain.CIArtifactCommitted | |
| 237 | + | missing := pending | |
| 238 | + | missing.ID = 4 | |
| 239 | + | b.EXPECT().List(mock.Anything, "ci/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.BlobInfo) error) error { | |
| 240 | + | for _, info := range []ports.BlobInfo{ | |
| 241 | + | {Key: missing.BlobKey(), ModTime: now}, // young: no lookup, no deletion | |
| 242 | + | {Key: missing.BlobKey(), ModTime: old}, | |
| 243 | + | {Key: pending.BlockKey("a"), ModTime: old}, | |
| 244 | + | {Key: committed.BlockKey("a"), ModTime: old}, | |
| 245 | + | {Key: committed.BlobKey(), ModTime: old}, | |
| 246 | + | } { | |
| 247 | + | require.NoError(t, fn(info)) | |
| 248 | + | } | |
| 249 | + | return nil | |
| 250 | + | }) | |
| 251 | + | s.EXPECT().GetArtifact(mock.Anything, missing.ID).Return(domain.CIArtifact{}, domain.ErrNotFound).Once() | |
| 252 | + | s.EXPECT().GetArtifact(mock.Anything, pending.ID).Return(pending, nil).Once() | |
| 253 | + | s.EXPECT().GetArtifact(mock.Anything, committed.ID).Return(committed, nil).Twice() | |
| 254 | + | b.EXPECT().Delete(mock.Anything, missing.BlobKey()).Return(nil).Once() | |
| 255 | + | b.EXPECT().Delete(mock.Anything, committed.BlockKey("a")).Return(nil).Once() | |
| 256 | + | b.EXPECT().ListMultipart(mock.Anything, "ci/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.MultipartInfo) error) error { | |
| 257 | + | require.NoError(t, fn(ports.MultipartInfo{Key: missing.BlobKey(), ID: "old", Started: old})) | |
| 258 | + | require.NoError(t, fn(ports.MultipartInfo{Key: missing.BlobKey(), ID: "young", Started: now})) | |
| 259 | + | return nil | |
| 260 | + | }) | |
| 261 | + | b.EXPECT().AbortMultipart(mock.Anything, missing.BlobKey(), "old").Return(nil).Once() | |
| 262 | + | c.sweepArtifactBlobOrphans(ctx) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestArtifactRemoveRunsWithoutLegacyReader(t *testing.T) { | |
| 266 | + | ctx := t.Context() | |
| 267 | + | b, _, _ := artifactBlobMock(t, false) | |
| 268 | + | c := &CI{cfg: CIConfig{Blobs: b}} | |
| 269 | + | a := domain.CIArtifact{RunID: 1, ID: 2} | |
| 270 | + | other := a | |
| 271 | + | other.RunID = 2 | |
| 272 | + | for _, key := range []string{a.BlobKey(), a.BlockKey("a"), other.BlobKey()} { | |
| 273 | + | _, err := b.Put(ctx, key, bytes.NewReader([]byte("x")), 1) | |
| 274 | + | require.NoError(t, err) | |
| 275 | + | } | |
| 276 | + | _, err := b.NewMultipart(ctx, a.BlobKey()) | |
| 277 | + | require.NoError(t, err) | |
| 278 | + | require.NoError(t, c.removeArtifactRuns(ctx, []int64{1})) | |
| 279 | + | _, err = b.Stat(ctx, a.BlobKey()) | |
| 280 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 281 | + | _, err = b.Stat(ctx, a.BlockKey("a")) | |
| 282 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 283 | + | _, err = b.Stat(ctx, other.BlobKey()) | |
| 284 | + | require.NoError(t, err) | |
| 285 | + | require.NoError(t, b.ListMultipart(ctx, "ci/", func(ports.MultipartInfo) error { t.Error("upload survived run deletion"); return nil })) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestArtifactBlobIDs(t *testing.T) { | |
| 289 | + | a := domain.CIArtifact{RunID: 7, ID: 42} | |
| 290 | + | for _, key := range []string{a.BlobKey(), a.BlockKey("abcdef.0123")} { | |
| 291 | + | run, id, block, ok := artifactBlobIDs(key) | |
| 292 | + | assert.True(t, ok) | |
| 293 | + | assert.EqualValues(t, 7, run) | |
| 294 | + | assert.EqualValues(t, 42, id) | |
| 295 | + | assert.Equal(t, strings.Contains(key, ".blocks/"), block) | |
| 296 | + | } | |
| 297 | + | for _, key := range []string{"release/1", "ci/1/2", a.BlobKey() + ".blocks/", a.BlobKey() + "/wrong"} { | |
| 298 | + | _, _, _, ok := artifactBlobIDs(key) | |
| 299 | + | assert.False(t, ok, key) | |
| 300 | + | } | |
| 301 | + | } | |
A internal/usecase/ci_artifacts_slots_test.go +179 -0
| @@ -0,0 +1,179 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "io" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // gateReader signals its first Read on started, then blocks until release is | |
| 24 | + | // closed or ctx ends; it then returns data (or err when set). | |
| 25 | + | type gateReader struct { | |
| 26 | + | ctx context.Context | |
| 27 | + | started chan struct{} | |
| 28 | + | release chan struct{} | |
| 29 | + | data []byte | |
| 30 | + | err error | |
| 31 | + | once sync.Once | |
| 32 | + | reads int | |
| 33 | + | r *bytes.Reader | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (g *gateReader) Read(p []byte) (int, error) { | |
| 37 | + | g.reads++ | |
| 38 | + | g.once.Do(func() { | |
| 39 | + | if g.started != nil { | |
| 40 | + | close(g.started) | |
| 41 | + | } | |
| 42 | + | }) | |
| 43 | + | if g.release != nil { | |
| 44 | + | select { | |
| 45 | + | case <-g.release: | |
| 46 | + | case <-g.ctx.Done(): | |
| 47 | + | return 0, g.ctx.Err() | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | if g.err != nil { | |
| 51 | + | return 0, g.err | |
| 52 | + | } | |
| 53 | + | if g.r == nil { | |
| 54 | + | g.r = bytes.NewReader(g.data) | |
| 55 | + | } | |
| 56 | + | return g.r.Read(p) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type slotFixture struct { | |
| 60 | + | c *CI | |
| 61 | + | secret string | |
| 62 | + | job domain.CIJob | |
| 63 | + | puts *int | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func newSlotFixture(t *testing.T, slots int) *slotFixture { | |
| 67 | + | t.Helper() | |
| 68 | + | repo, err := domain.ParseRepoName("proj") | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | secret := domain.JobTokenPrefix + strings.Repeat("a", domain.CITokenRandomLen) | |
| 71 | + | hash := domain.HashTokenSecret(secret) | |
| 72 | + | j := domain.CIJob{ID: "1-0-0", RunID: 1, RunAttempt: 1, Repo: repo, TokenHash: hash, Phase: domain.CIPhaseRunning} | |
| 73 | + | a := domain.CIArtifact{ID: 7, Repo: repo, RunID: 1, Attempt: 1, JobID: j.ID, Assignment: hash, State: domain.CIArtifactPending} | |
| 74 | + | ||
| 75 | + | store := mocks.NewCIStore(t) | |
| 76 | + | store.EXPECT().FindJobByToken(mock.Anything, hash, mock.Anything).Return(j, nil).Maybe() | |
| 77 | + | store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil).Maybe() | |
| 78 | + | arts := mocks.NewCIArtifactStore(t) | |
| 79 | + | arts.EXPECT().ArtifactUsage(mock.Anything, repo).Return(0, 1, nil).Maybe() | |
| 80 | + | arts.EXPECT().GetArtifact(mock.Anything, a.ID).Return(a, nil).Maybe() | |
| 81 | + | arts.EXPECT().ArtifactBlocks(mock.Anything, a.ID).Return(nil, nil).Maybe() | |
| 82 | + | arts.EXPECT().ApplyArtifacts(mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 83 | + | clock := mocks.NewClock(t) | |
| 84 | + | clock.EXPECT().Now().Return(time.Unix(1000, 0)).Maybe() | |
| 85 | + | blobs, puts, _ := artifactBlobMock(t, false) | |
| 86 | + | c := &CI{ | |
| 87 | + | cfg: CIConfig{ | |
| 88 | + | Store: store, Artifacts: arts, Blobs: blobs, Clock: clock, | |
| 89 | + | ArtifactLimits: ports.ArtifactLimits{Quota: 1 << 30, MaxSize: 1 << 30}, | |
| 90 | + | }, | |
| 91 | + | uploadSlots: make(chan struct{}, slots), | |
| 92 | + | } | |
| 93 | + | return &slotFixture{c: c, secret: secret, job: j, puts: puts} | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (f *slotFixture) upload(ctx context.Context, r io.Reader) (int64, error) { | |
| 97 | + | return f.c.UploadJobArtifactBlock(ctx, f.secret, f.job.ID, 7, []byte("blk"), r) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestArtifactUploadSlotsBound(t *testing.T) { | |
| 101 | + | for _, mode := range []string{"success", "body error", "ctx cancel"} { | |
| 102 | + | t.Run(mode, func(t *testing.T) { | |
| 103 | + | f := newSlotFixture(t, 1) | |
| 104 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 105 | + | defer cancel() | |
| 106 | + | hold := &gateReader{ctx: ctx, started: make(chan struct{}), release: make(chan struct{}), data: []byte("held")} | |
| 107 | + | if mode == "body error" { | |
| 108 | + | hold.err = errors.New("connection reset") | |
| 109 | + | } | |
| 110 | + | type result struct { | |
| 111 | + | n int64 | |
| 112 | + | err error | |
| 113 | + | } | |
| 114 | + | done := make(chan result, 1) | |
| 115 | + | go func() { n, err := f.upload(ctx, hold); done <- result{n, err} }() | |
| 116 | + | <-hold.started | |
| 117 | + | ||
| 118 | + | // The slot is taken: the next upload is refused at once, before | |
| 119 | + | // reading its body, and nothing is reserved or stored. | |
| 120 | + | refused := &gateReader{ctx: t.Context(), data: []byte("x")} | |
| 121 | + | _, err := f.upload(t.Context(), refused) | |
| 122 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 123 | + | require.Zero(t, refused.reads) | |
| 124 | + | require.Zero(t, *f.puts) | |
| 125 | + | require.Zero(t, f.c.reservations.of(f.job.Repo).total) | |
| 126 | + | ||
| 127 | + | switch mode { | |
| 128 | + | case "ctx cancel": | |
| 129 | + | cancel() | |
| 130 | + | default: | |
| 131 | + | close(hold.release) | |
| 132 | + | } | |
| 133 | + | res := <-done | |
| 134 | + | if mode == "success" { | |
| 135 | + | require.NoError(t, res.err) | |
| 136 | + | require.EqualValues(t, 4, res.n) | |
| 137 | + | } else { | |
| 138 | + | require.Error(t, res.err) | |
| 139 | + | } | |
| 140 | + | require.Empty(t, f.c.uploadSlots, "the slot is released") | |
| 141 | + | ||
| 142 | + | n, err := f.upload(t.Context(), &gateReader{ctx: t.Context(), data: []byte("again")}) | |
| 143 | + | require.NoError(t, err) | |
| 144 | + | require.EqualValues(t, 5, n) | |
| 145 | + | require.Empty(t, f.c.uploadSlots) | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestArtifactAssemblyTakesASlot(t *testing.T) { | |
| 151 | + | b, _, _ := artifactBlobMock(t, false) | |
| 152 | + | c := &CI{cfg: CIConfig{Blobs: b}, uploadSlots: make(chan struct{}, 1)} | |
| 153 | + | a := domain.CIArtifact{RunID: 1, ID: 2} | |
| 154 | + | _, err := b.Put(t.Context(), a.BlockKey("a"), strings.NewReader("hi"), 2) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | ||
| 157 | + | // A full set of slots makes the assembly wait, not fail; it gives up | |
| 158 | + | // only when its context ends. | |
| 159 | + | c.uploadSlots <- struct{}{} | |
| 160 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 161 | + | cancel() | |
| 162 | + | _, _, err = c.assembleBlobs(ctx, a, []string{"a"}, 2) | |
| 163 | + | require.ErrorIs(t, err, context.Canceled) | |
| 164 | + | ||
| 165 | + | type result struct { | |
| 166 | + | n int64 | |
| 167 | + | err error | |
| 168 | + | } | |
| 169 | + | done := make(chan result, 1) | |
| 170 | + | go func() { | |
| 171 | + | n, _, err := c.assembleBlobs(t.Context(), a, []string{"a"}, 2) | |
| 172 | + | done <- result{n, err} | |
| 173 | + | }() | |
| 174 | + | <-c.uploadSlots // free the slot: the waiting assembly proceeds | |
| 175 | + | res := <-done | |
| 176 | + | require.NoError(t, res.err) | |
| 177 | + | require.EqualValues(t, 2, res.n) | |
| 178 | + | require.Empty(t, c.uploadSlots) | |
| 179 | + | } | |
M internal/usecase/ci_watch.go +4 -3
| @@ -276,6 +276,9 @@ func (c *CI) sweep(ctx context.Context) { | |||
| 276 | 276 | c.removeOrphans(ctx, "logs", "log directories", c.cfg.Logs.RunIDs, c.cfg.Logs.RemoveRuns) | |
| 277 | 277 | c.removeOrphans(ctx, "summaries", "summary directories", c.cfg.Summaries.RunIDs, c.cfg.Summaries.RemoveRuns) | |
| 278 | 278 | c.sweepArtifacts(ctx) | |
| 279 | + | if c.cfg.ReleaseSweeper != nil { | |
| 280 | + | c.cfg.ReleaseSweeper.Sweep(ctx) | |
| 281 | + | } | |
| 279 | 282 | } | |
| 280 | 283 | ||
| 281 | 284 | // removeOrphans deletes what a file store (logs, summaries) holds for runs that | |
| @@ -323,8 +326,6 @@ func (c *CI) RemoveLogs(ctx context.Context, runIDs []int64) error { | |||
| 323 | 326 | c.locks.Delete(id) | |
| 324 | 327 | } | |
| 325 | 328 | err := errors.Join(c.cfg.Logs.RemoveRuns(ctx, runIDs), c.cfg.Summaries.RemoveRuns(ctx, runIDs)) | |
| 326 | - | if c.artifactsOn() { | |
| 327 | - | err = errors.Join(err, c.cfg.ArtifactFiles.RemoveRuns(ctx, runIDs)) | |
| 328 | - | } | |
| 329 | + | err = errors.Join(err, c.removeArtifactRuns(ctx, runIDs)) | |
| 329 | 330 | return err | |
| 330 | 331 | } | |
M internal/usecase/events.go +25 -0
| @@ -171,6 +171,14 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||
| 171 | 171 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 172 | 172 | case domain.MirrorRecovered: | |
| 173 | 173 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 174 | + | case domain.ReleasePublished: | |
| 175 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 176 | + | case domain.ReleaseUpdated: | |
| 177 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 178 | + | case domain.ReleaseDeleted: | |
| 179 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 180 | + | case domain.ReleaseUnpublished: | |
| 181 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 174 | 182 | case domain.PushEvent: | |
| 175 | 183 | if ev.CreatedAt.IsZero() { | |
| 176 | 184 | return bufferedEvent{} // repository unknown at publish time: nobody is told | |
| @@ -531,6 +539,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 531 | 539 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 532 | 540 | case domain.MirrorRecovered: | |
| 533 | 541 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 542 | + | case domain.ReleasePublished: | |
| 543 | + | return e.deliverRelease(ctx, w, ev.Release, be, out, send) | |
| 544 | + | case domain.ReleaseUpdated: | |
| 545 | + | return e.deliverRelease(ctx, w, ev.Release, be, out, send) | |
| 546 | + | case domain.ReleaseDeleted: | |
| 547 | + | return e.deliverRelease(ctx, w, ev.Release, be, out, send) | |
| 548 | + | case domain.ReleaseUnpublished: | |
| 549 | + | return e.deliverRelease(ctx, w, ev.Release, be, out, send) | |
| 534 | 550 | case domain.PushEvent: | |
| 535 | 551 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 536 | 552 | case domain.MergeRequestOpened: | |
| @@ -646,6 +662,15 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName | |||
| 646 | 662 | return send(out) | |
| 647 | 663 | } | |
| 648 | 664 | ||
| 665 | + | // deliverRelease sends a release event. The event of a draft goes only to | |
| 666 | + | // release writers: a draft is invisible to every other reader. | |
| 667 | + | func (e *Events) deliverRelease(ctx context.Context, w *watch, rel domain.Release, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error { | |
| 668 | + | if rel.Draft && !releaseWriter(w.p, rel.Repo) { | |
| 669 | + | return nil | |
| 670 | + | } | |
| 671 | + | return e.deliverRepo(ctx, w, rel.Repo, false, be, out, send) | |
| 672 | + | } | |
| 673 | + | ||
| 649 | 674 | // isIssueEvent reports whether ev is about an issue. | |
| 650 | 675 | func isIssueEvent(ev domain.Event) bool { | |
| 651 | 676 | switch ev.(type) { | |
M internal/usecase/events_test.go +95 -0
| @@ -1034,3 +1034,98 @@ func TestIssueReferencedRepoOnly(t *testing.T) { | |||
| 1034 | 1034 | } | |
| 1035 | 1035 | }) | |
| 1036 | 1036 | } | |
| 1037 | + | ||
| 1038 | + | func TestDraftReleaseEventsReachOnlyReleaseWriters(t *testing.T) { | |
| 1039 | + | t.Parallel() | |
| 1040 | + | synctest.Test(t, func(t *testing.T) { | |
| 1041 | + | r := newEventRig(t, 64) | |
| 1042 | + | pub := r.repo("pub", domain.VisibilityPublic) | |
| 1043 | + | other := r.repo("other", domain.VisibilityPublic) | |
| 1044 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1045 | + | defer cancel() | |
| 1046 | + | ||
| 1047 | + | job := func(repo domain.RepoName, level string) domain.Principal { | |
| 1048 | + | return domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: repo, Permissions: map[string]string{"contents": level}}) | |
| 1049 | + | } | |
| 1050 | + | writerBranch := scopedToken(domain.ScopeReleaseWrite, []string{"agent/"}, "pub") | |
| 1051 | + | cases := []struct { | |
| 1052 | + | name string | |
| 1053 | + | p domain.Principal | |
| 1054 | + | want bool | |
| 1055 | + | }{ | |
| 1056 | + | {"anonymous", domain.Anonymous(), false}, | |
| 1057 | + | {"reader", tokenPrincipal(domain.ScopeRepoRead), false}, | |
| 1058 | + | {"branch-limited writer", writerBranch, false}, | |
| 1059 | + | {"job token of another repo", job(other, "write"), false}, | |
| 1060 | + | {"job token contents:read", job(pub, "read"), false}, | |
| 1061 | + | {"release:write token", tokenPrincipal(domain.ScopeReleaseWrite, "pub"), true}, | |
| 1062 | + | {"job token contents:write", job(pub, "write"), true}, | |
| 1063 | + | } | |
| 1064 | + | ||
| 1065 | + | anchor := r.watch(ctx, domain.Root(), ports.WatchParams{}) | |
| 1066 | + | synctest.Wait() | |
| 1067 | + | r.push(pub, 1) | |
| 1068 | + | anchorID := anchor.next(t).ID | |
| 1069 | + | ||
| 1070 | + | draft := domain.Release{ID: 1, Repo: pub, Tag: "d", Draft: true} | |
| 1071 | + | published := domain.Release{ID: 2, Repo: pub, Tag: "v1"} | |
| 1072 | + | events := func(rel domain.Release) []domain.Event { | |
| 1073 | + | return []domain.Event{ | |
| 1074 | + | domain.ReleasePublished{Release: rel, At: now, CreatedAt: now, Visibility: domain.VisibilityPublic}, | |
| 1075 | + | domain.ReleaseUpdated{Release: rel, At: now, CreatedAt: now, Visibility: domain.VisibilityPublic}, | |
| 1076 | + | domain.ReleaseDeleted{Release: rel, At: now, CreatedAt: now, Visibility: domain.VisibilityPublic}, | |
| 1077 | + | domain.ReleaseUnpublished{Release: rel, At: now, CreatedAt: now, Visibility: domain.VisibilityPublic}, | |
| 1078 | + | } | |
| 1079 | + | } | |
| 1080 | + | check := func(t *testing.T, w *watcher, want bool) { | |
| 1081 | + | t.Helper() | |
| 1082 | + | if want { | |
| 1083 | + | for _, ev := range events(draft) { | |
| 1084 | + | require.Equal(t, ev.EventKind(), w.next(t).Event.EventKind()) | |
| 1085 | + | } | |
| 1086 | + | } | |
| 1087 | + | for _, ev := range events(published) { | |
| 1088 | + | e := w.next(t).Event | |
| 1089 | + | require.Equal(t, ev.EventKind(), e.EventKind()) | |
| 1090 | + | require.EqualValues(t, 2, releaseOf(e)) | |
| 1091 | + | } | |
| 1092 | + | w.quiet(t, r, pub) | |
| 1093 | + | } | |
| 1094 | + | ||
| 1095 | + | var live []*watcher | |
| 1096 | + | for _, tc := range cases { | |
| 1097 | + | live = append(live, r.watch(ctx, tc.p, ports.WatchParams{})) | |
| 1098 | + | } | |
| 1099 | + | synctest.Wait() | |
| 1100 | + | for _, ev := range events(draft) { | |
| 1101 | + | r.feed <- ev | |
| 1102 | + | } | |
| 1103 | + | for _, ev := range events(published) { | |
| 1104 | + | r.feed <- ev | |
| 1105 | + | } | |
| 1106 | + | for i, tc := range cases { | |
| 1107 | + | t.Log("live", tc.name) | |
| 1108 | + | check(t, live[i], tc.want) | |
| 1109 | + | } | |
| 1110 | + | for _, tc := range cases { | |
| 1111 | + | t.Log("replay", tc.name) | |
| 1112 | + | w := r.watch(ctx, tc.p, ports.WatchParams{ResumeAfter: anchorID}) | |
| 1113 | + | check(t, w, tc.want) | |
| 1114 | + | } | |
| 1115 | + | }) | |
| 1116 | + | } | |
| 1117 | + | ||
| 1118 | + | // releaseOf returns the id of the release an event is about. | |
| 1119 | + | func releaseOf(ev domain.Event) int64 { | |
| 1120 | + | switch ev := ev.(type) { | |
| 1121 | + | case domain.ReleasePublished: | |
| 1122 | + | return ev.Release.ID | |
| 1123 | + | case domain.ReleaseUpdated: | |
| 1124 | + | return ev.Release.ID | |
| 1125 | + | case domain.ReleaseDeleted: | |
| 1126 | + | return ev.Release.ID | |
| 1127 | + | case domain.ReleaseUnpublished: | |
| 1128 | + | return ev.Release.ID | |
| 1129 | + | } | |
| 1130 | + | return 0 | |
| 1131 | + | } | |
M internal/usecase/pushes.go +21 -14
| @@ -17,20 +17,21 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // Pushes are the use cases around a Git push. | |
| 19 | 19 | type Pushes struct { | |
| 20 | - | links ports.IssueLinkTrigger | |
| 21 | - | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 22 | - | meta ports.RepoMetaStore | |
| 23 | - | repos *Repos | |
| 24 | - | git ports.GitRepos | |
| 25 | - | bus ports.EventBus | |
| 26 | - | clock ports.Clock | |
| 27 | - | maint ports.MaintenanceScheduler | |
| 28 | - | auth ports.Revalidator | |
| 29 | - | fresh ports.RepoFreshener // nil: the reader needs no telling | |
| 30 | - | ci ports.WorkflowTrigger // nil: no CI | |
| 31 | - | trees ports.PushTreeReader // nil: pushes by job tokens are refused | |
| 32 | - | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | |
| 33 | - | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | |
| 20 | + | releases ports.ReleaseTagTrigger | |
| 21 | + | links ports.IssueLinkTrigger | |
| 22 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 23 | + | meta ports.RepoMetaStore | |
| 24 | + | repos *Repos | |
| 25 | + | git ports.GitRepos | |
| 26 | + | bus ports.EventBus | |
| 27 | + | clock ports.Clock | |
| 28 | + | maint ports.MaintenanceScheduler | |
| 29 | + | auth ports.Revalidator | |
| 30 | + | fresh ports.RepoFreshener // nil: the reader needs no telling | |
| 31 | + | ci ports.WorkflowTrigger // nil: no CI | |
| 32 | + | trees ports.PushTreeReader // nil: pushes by job tokens are refused | |
| 33 | + | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | |
| 34 | + | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | var ( | |
| @@ -45,6 +46,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p | |||
| 45 | 46 | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth} | |
| 46 | 47 | } | |
| 47 | 48 | ||
| 49 | + | // WithReleases enables synchronous release tag-deletion handling before the first push. | |
| 50 | + | func (u *Pushes) WithReleases(t ports.ReleaseTagTrigger) *Pushes { u.releases = t; return u } | |
| 51 | + | ||
| 48 | 52 | // WithIssueLinks enables synchronous best-effort issue indexing before the first push. | |
| 49 | 53 | func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u } | |
| 50 | 54 | ||
| @@ -272,6 +276,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 272 | 276 | if u.links != nil { | |
| 273 | 277 | u.links.OnBranchesMoved(ctx, name, updates) | |
| 274 | 278 | } | |
| 279 | + | if u.releases != nil { | |
| 280 | + | u.releases.OnRefsMoved(ctx, name, updates) | |
| 281 | + | } | |
| 275 | 282 | u.bus.Publish(ctx, domain.PushEvent{ | |
| 276 | 283 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | |
| 277 | 284 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
A internal/usecase/releases.go +494 -0
| @@ -0,0 +1,494 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "slices" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Releases manages release metadata and blob contents. Construct once per server. | |
| 20 | + | type Releases struct { | |
| 21 | + | store ports.ReleaseStore | |
| 22 | + | blobs ports.BlobStore | |
| 23 | + | repos ports.RepoMetaStore | |
| 24 | + | reader ports.RepoReader | |
| 25 | + | tags ports.ReleaseTagWriter | |
| 26 | + | pushes ports.PushHookService | |
| 27 | + | bus ports.EventBus | |
| 28 | + | clock ports.Clock | |
| 29 | + | limits ports.ReleaseLimits | |
| 30 | + | uploads chan struct{} | |
| 31 | + | sweepMu sync.Mutex | |
| 32 | + | misses map[int64]int | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | var _ ports.ReleaseService = (*Releases)(nil) | |
| 36 | + | var _ ports.ReleaseTagTrigger = (*Releases)(nil) | |
| 37 | + | var _ ports.ReleaseRepoCleaner = (*Releases)(nil) | |
| 38 | + | var _ ports.ReleaseSweeper = (*Releases)(nil) | |
| 39 | + | ||
| 40 | + | // NewReleases constructs the release service. Zero limits select the defaults. | |
| 41 | + | // pushes must be the shared Pushes instance, so created tags notify every hook. | |
| 42 | + | func NewReleases(store ports.ReleaseStore, blobs ports.BlobStore, repos ports.RepoMetaStore, reader ports.RepoReader, tags ports.ReleaseTagWriter, pushes ports.PushHookService, bus ports.EventBus, clock ports.Clock, limits ports.ReleaseLimits) *Releases { | |
| 43 | + | if limits.AssetMax == 0 { | |
| 44 | + | limits.AssetMax = domain.DefaultReleaseAssetMax | |
| 45 | + | } | |
| 46 | + | if limits.AssetsMax == 0 { | |
| 47 | + | limits.AssetsMax = domain.DefaultReleaseAssetsMax | |
| 48 | + | } | |
| 49 | + | if limits.Quota == 0 { | |
| 50 | + | limits.Quota = domain.DefaultReleaseQuota | |
| 51 | + | } | |
| 52 | + | return &Releases{store: store, blobs: blobs, repos: repos, reader: reader, tags: tags, pushes: pushes, bus: bus, clock: clock, limits: limits, uploads: make(chan struct{}, 16), misses: make(map[int64]int)} | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (r *Releases) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 56 | + | repo, err := lookupRepo(ctx, r.repos, name) | |
| 57 | + | if err == nil && !canRead(p, repo) { | |
| 58 | + | err = domain.ErrNotFound | |
| 59 | + | } | |
| 60 | + | return repo, err | |
| 61 | + | } | |
| 62 | + | func releaseWriter(p domain.Principal, name domain.RepoName) bool { | |
| 63 | + | return !p.IsBranchLimited() && (p.Allows(domain.ScopeReleaseWrite, name) || p.JobPermits(domain.PermContents, domain.PermissionWrite, name)) | |
| 64 | + | } | |
| 65 | + | func (r *Releases) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 66 | + | repo, err := r.readable(ctx, p, name) | |
| 67 | + | if err != nil { | |
| 68 | + | return repo, err | |
| 69 | + | } | |
| 70 | + | if p.IsBranchLimited() { | |
| 71 | + | return repo, fmt.Errorf("%w: a branch-limited token cannot manage releases", domain.ErrForbidden) | |
| 72 | + | } | |
| 73 | + | if releaseWriter(p, name) { | |
| 74 | + | return repo, nil | |
| 75 | + | } | |
| 76 | + | if p.IsAnonymous() { | |
| 77 | + | return repo, domain.ErrUnauthenticated | |
| 78 | + | } | |
| 79 | + | return repo, domain.ErrForbidden | |
| 80 | + | } | |
| 81 | + | func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn func(*ports.ReleaseWrite) error) error { | |
| 82 | + | const releaseTries = 256 | |
| 83 | + | for range releaseTries { | |
| 84 | + | if err := ctx.Err(); err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | used, v, err := r.store.ReleaseUsage(ctx, repo) | |
| 88 | + | if err != nil { | |
| 89 | + | return err | |
| 90 | + | } | |
| 91 | + | w := ports.ReleaseWrite{Repo: repo, Used: used, UsageVersion: v} | |
| 92 | + | if err = fn(&w); err != nil { | |
| 93 | + | return err | |
| 94 | + | } | |
| 95 | + | err = r.store.ApplyReleases(ctx, w) | |
| 96 | + | if errors.Is(err, domain.ErrConflict) { | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | return fmt.Errorf("%w: releases of %s keep changing, try again", domain.ErrUnavailable, repo) | |
| 102 | + | } | |
| 103 | + | func (r *Releases) resolve(ctx context.Context, repo domain.RepoName, rev string) (domain.ObjectID, error) { | |
| 104 | + | v, err := domain.ParseRevision(rev) | |
| 105 | + | if err != nil { | |
| 106 | + | return domain.ObjectID{}, err | |
| 107 | + | } | |
| 108 | + | got, err := r.reader.ResolveRevision(ctx, repo, v) | |
| 109 | + | return got.CommitID, err | |
| 110 | + | } | |
| 111 | + | func (r *Releases) bind(ctx context.Context, p domain.Principal, rel *domain.Release, target string, create bool) error { | |
| 112 | + | oid, err := r.resolve(ctx, rel.Repo, "refs/tags/"+rel.Tag) | |
| 113 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 114 | + | if rel.Draft { | |
| 115 | + | rel.TargetCommitish = "" | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | if !create || target == "" { | |
| 119 | + | return fmt.Errorf("%w: tag %q does not exist; give target_commitish to create it", domain.ErrPreconditionFailed, rel.Tag) | |
| 120 | + | } | |
| 121 | + | if r.pushes == nil { | |
| 122 | + | return fmt.Errorf("%w: release tag notifications are not configured", domain.ErrUnavailable) | |
| 123 | + | } | |
| 124 | + | oid, err = r.resolve(ctx, rel.Repo, target) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | err = r.tags.CreateTag(ctx, rel.Repo, rel.Tag, oid) | |
| 129 | + | if errors.Is(err, domain.ErrAlreadyExists) { | |
| 130 | + | oid, err = r.resolve(ctx, rel.Repo, "refs/tags/"+rel.Tag) | |
| 131 | + | } else if err == nil { | |
| 132 | + | err = r.pushes.Record(ctx, p, rel.Repo, []domain.RefUpdate{{Ref: "refs/tags/" + rel.Tag, Old: domain.ZeroOID, New: oid.String()}}) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | if err != nil { | |
| 136 | + | return err | |
| 137 | + | } | |
| 138 | + | rel.TargetCommitish = oid.String() | |
| 139 | + | return nil | |
| 140 | + | } | |
| 141 | + | func (r *Releases) pin(ctx context.Context, w *ports.ReleaseWrite, rel domain.Release) error { | |
| 142 | + | if !rel.MakeLatest { | |
| 143 | + | return nil | |
| 144 | + | } | |
| 145 | + | all, err := r.store.ListReleases(ctx, rel.Repo) | |
| 146 | + | if err != nil { | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | for _, other := range all { | |
| 150 | + | if other.ID != rel.ID && other.MakeLatest { | |
| 151 | + | other.MakeLatest = false | |
| 152 | + | w.PutReleases = append(w.PutReleases, other) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | return nil | |
| 156 | + | } | |
| 157 | + | func (r *Releases) emit(ctx context.Context, repo domain.Repo, rel domain.Release, kind string) { | |
| 158 | + | at := r.clock.Now() | |
| 159 | + | var ev domain.Event | |
| 160 | + | switch kind { | |
| 161 | + | case "published": | |
| 162 | + | ev = domain.ReleasePublished{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt} | |
| 163 | + | case "updated": | |
| 164 | + | ev = domain.ReleaseUpdated{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt} | |
| 165 | + | case "deleted": | |
| 166 | + | ev = domain.ReleaseDeleted{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt} | |
| 167 | + | case "unpublished": | |
| 168 | + | ev = domain.ReleaseUnpublished{Release: rel, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt} | |
| 169 | + | } | |
| 170 | + | r.bus.Publish(ctx, ev) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // CreateRelease creates a release, optionally creating its tag when publishing. | |
| 174 | + | func (r *Releases) CreateRelease(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateReleaseParams) (domain.Release, error) { | |
| 175 | + | repo, err := r.writable(ctx, p, name) | |
| 176 | + | if err != nil { | |
| 177 | + | return domain.Release{}, err | |
| 178 | + | } | |
| 179 | + | rel := domain.Release{Repo: name, Draft: params.Draft, Prerelease: params.Prerelease, MakeLatest: params.MakeLatest, Author: domain.AuthorName(p), CreatedAt: r.clock.Now(), UpdatedAt: r.clock.Now()} | |
| 180 | + | if rel.Tag, err = domain.ParseReleaseTag(params.Tag); err != nil { | |
| 181 | + | return rel, err | |
| 182 | + | } | |
| 183 | + | if rel.Title, err = domain.ParseReleaseTitle(params.Title); err != nil { | |
| 184 | + | return rel, err | |
| 185 | + | } | |
| 186 | + | if rel.Notes, err = domain.ParseReleaseNotes(params.Notes); err != nil { | |
| 187 | + | return rel, err | |
| 188 | + | } | |
| 189 | + | if _, err = r.store.GetReleaseByTag(ctx, name, rel.Tag); err == nil { | |
| 190 | + | return rel, domain.ErrAlreadyExists | |
| 191 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 192 | + | return rel, err | |
| 193 | + | } | |
| 194 | + | if err = r.bind(ctx, p, &rel, params.TargetCommitish, true); err != nil { | |
| 195 | + | return rel, err | |
| 196 | + | } | |
| 197 | + | if rel.Draft { | |
| 198 | + | rel.MakeLatest = false | |
| 199 | + | } else { | |
| 200 | + | rel.PublishedAt = r.clock.Now() | |
| 201 | + | } | |
| 202 | + | rel.ID, err = r.store.NextReleaseID(ctx) | |
| 203 | + | if err != nil { | |
| 204 | + | return rel, err | |
| 205 | + | } | |
| 206 | + | err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error { | |
| 207 | + | if err := r.pin(ctx, w, rel); err != nil { | |
| 208 | + | return err | |
| 209 | + | } | |
| 210 | + | w.PutReleases = append(w.PutReleases, rel) | |
| 211 | + | return nil | |
| 212 | + | }) | |
| 213 | + | if err != nil { | |
| 214 | + | return domain.Release{}, err | |
| 215 | + | } | |
| 216 | + | if !rel.Draft { | |
| 217 | + | r.emit(ctx, repo, rel, "published") | |
| 218 | + | } | |
| 219 | + | return r.store.GetRelease(ctx, name, rel.ID) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // GetRelease resolves a visible release by exactly one selector. | |
| 223 | + | func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) { | |
| 224 | + | if _, err := r.readable(ctx, p, name); err != nil { | |
| 225 | + | return domain.Release{}, err | |
| 226 | + | } | |
| 227 | + | n := 0 | |
| 228 | + | if ref.ID != 0 { | |
| 229 | + | n++ | |
| 230 | + | } | |
| 231 | + | if ref.Tag != "" { | |
| 232 | + | n++ | |
| 233 | + | } | |
| 234 | + | if ref.Latest { | |
| 235 | + | n++ | |
| 236 | + | } | |
| 237 | + | if n != 1 { | |
| 238 | + | return domain.Release{}, domain.ErrInvalid | |
| 239 | + | } | |
| 240 | + | var rel domain.Release | |
| 241 | + | var err error | |
| 242 | + | switch { | |
| 243 | + | case ref.ID != 0: | |
| 244 | + | rel, err = r.store.GetRelease(ctx, name, ref.ID) | |
| 245 | + | case ref.Tag != "": | |
| 246 | + | rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag) | |
| 247 | + | default: | |
| 248 | + | var all []domain.Release | |
| 249 | + | all, err = r.store.ListReleases(ctx, name) | |
| 250 | + | if err == nil { | |
| 251 | + | var ok bool | |
| 252 | + | rel, ok = domain.LatestRelease(all) | |
| 253 | + | if !ok { | |
| 254 | + | err = domain.ErrNotFound | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | if err == nil && rel.Draft && !releaseWriter(p, name) { | |
| 259 | + | err = domain.ErrNotFound | |
| 260 | + | } | |
| 261 | + | return rel, err | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // ListReleases lists visible releases newest first, with an ID cursor. | |
| 265 | + | func (r *Releases) ListReleases(ctx context.Context, p domain.Principal, name domain.RepoName, size int, token string) (ports.ReleasePage, error) { | |
| 266 | + | var out ports.ReleasePage | |
| 267 | + | if _, err := r.readable(ctx, p, name); err != nil { | |
| 268 | + | return out, err | |
| 269 | + | } | |
| 270 | + | if size == 0 { | |
| 271 | + | size = 30 | |
| 272 | + | } | |
| 273 | + | n, err := pageSize(size) | |
| 274 | + | if err != nil { | |
| 275 | + | return out, err | |
| 276 | + | } | |
| 277 | + | var cursor int64 | |
| 278 | + | if token != "" { | |
| 279 | + | cursor, err = strconv.ParseInt(token, 10, 64) | |
| 280 | + | if err != nil || cursor <= 0 { | |
| 281 | + | return out, domain.ErrInvalidPageToken | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | all, err := r.store.ListReleases(ctx, name) | |
| 285 | + | if err != nil { | |
| 286 | + | return out, err | |
| 287 | + | } | |
| 288 | + | slices.SortFunc(all, func(a, b domain.Release) int { | |
| 289 | + | if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 { | |
| 290 | + | return c | |
| 291 | + | } | |
| 292 | + | if a.ID > b.ID { | |
| 293 | + | return -1 | |
| 294 | + | } | |
| 295 | + | if a.ID < b.ID { | |
| 296 | + | return 1 | |
| 297 | + | } | |
| 298 | + | return 0 | |
| 299 | + | }) | |
| 300 | + | start := token == "" | |
| 301 | + | writer := releaseWriter(p, name) | |
| 302 | + | for _, rel := range all { | |
| 303 | + | if rel.Draft && !writer { | |
| 304 | + | continue // a hidden draft is not even a valid cursor | |
| 305 | + | } | |
| 306 | + | if !start { | |
| 307 | + | if rel.ID == cursor { | |
| 308 | + | start = true | |
| 309 | + | } | |
| 310 | + | continue | |
| 311 | + | } | |
| 312 | + | if len(out.Releases) == n { | |
| 313 | + | out.NextPageToken = strconv.FormatInt(out.Releases[n-1].ID, 10) | |
| 314 | + | break | |
| 315 | + | } | |
| 316 | + | out.Releases = append(out.Releases, rel) | |
| 317 | + | } | |
| 318 | + | if !start { | |
| 319 | + | return out, domain.ErrInvalidPageToken | |
| 320 | + | } | |
| 321 | + | return out, nil | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // UpdateRelease changes only fields whose pointers are non-nil. | |
| 325 | + | func (r *Releases) UpdateRelease(ctx context.Context, p domain.Principal, name domain.RepoName, id int64, params ports.UpdateReleaseParams) (domain.Release, error) { | |
| 326 | + | repo, err := r.writable(ctx, p, name) | |
| 327 | + | if err != nil { | |
| 328 | + | return domain.Release{}, err | |
| 329 | + | } | |
| 330 | + | var rel domain.Release | |
| 331 | + | kind := "updated" | |
| 332 | + | err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error { | |
| 333 | + | var err error | |
| 334 | + | rel, err = r.store.GetRelease(ctx, name, id) | |
| 335 | + | if err != nil { | |
| 336 | + | return err | |
| 337 | + | } | |
| 338 | + | wasDraft := rel.Draft | |
| 339 | + | if params.Tag != nil { | |
| 340 | + | rel.Tag, err = domain.ParseReleaseTag(*params.Tag) | |
| 341 | + | if err != nil { | |
| 342 | + | return err | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | if params.Title != nil { | |
| 346 | + | rel.Title, err = domain.ParseReleaseTitle(*params.Title) | |
| 347 | + | if err != nil { | |
| 348 | + | return err | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | if params.Notes != nil { | |
| 352 | + | rel.Notes, err = domain.ParseReleaseNotes(*params.Notes) | |
| 353 | + | if err != nil { | |
| 354 | + | return err | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | if params.Draft != nil { | |
| 358 | + | rel.Draft = *params.Draft | |
| 359 | + | } | |
| 360 | + | if params.Prerelease != nil { | |
| 361 | + | rel.Prerelease = *params.Prerelease | |
| 362 | + | } | |
| 363 | + | if params.MakeLatest != nil { | |
| 364 | + | rel.MakeLatest = *params.MakeLatest | |
| 365 | + | } | |
| 366 | + | target := "" | |
| 367 | + | if params.TargetCommitish != nil { | |
| 368 | + | target = *params.TargetCommitish | |
| 369 | + | } | |
| 370 | + | if err = r.bind(ctx, p, &rel, target, target != ""); err != nil { | |
| 371 | + | return err | |
| 372 | + | } | |
| 373 | + | kind = "updated" | |
| 374 | + | if rel.Draft { | |
| 375 | + | rel.PublishedAt = time.Time{} | |
| 376 | + | rel.MakeLatest = false | |
| 377 | + | } else if wasDraft { | |
| 378 | + | rel.PublishedAt = r.clock.Now() | |
| 379 | + | kind = "published" | |
| 380 | + | } | |
| 381 | + | rel.UpdatedAt = r.clock.Now() | |
| 382 | + | if err = r.pin(ctx, w, rel); err != nil { | |
| 383 | + | return err | |
| 384 | + | } | |
| 385 | + | w.PutReleases = append(w.PutReleases, rel) | |
| 386 | + | return nil | |
| 387 | + | }) | |
| 388 | + | if err != nil { | |
| 389 | + | return domain.Release{}, err | |
| 390 | + | } | |
| 391 | + | r.emit(ctx, repo, rel, kind) | |
| 392 | + | return r.store.GetRelease(ctx, name, id) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | // DeleteRelease removes metadata first, then its contents. | |
| 396 | + | func (r *Releases) DeleteRelease(ctx context.Context, p domain.Principal, name domain.RepoName, id int64) error { | |
| 397 | + | repo, err := r.writable(ctx, p, name) | |
| 398 | + | if err != nil { | |
| 399 | + | return err | |
| 400 | + | } | |
| 401 | + | var rel domain.Release | |
| 402 | + | var assets []domain.ReleaseAsset | |
| 403 | + | err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error { | |
| 404 | + | var err error | |
| 405 | + | rel, err = r.store.GetRelease(ctx, name, id) | |
| 406 | + | if err != nil { | |
| 407 | + | return err | |
| 408 | + | } | |
| 409 | + | assets, err = r.store.ListAssets(ctx, name, id) | |
| 410 | + | if err != nil { | |
| 411 | + | return err | |
| 412 | + | } | |
| 413 | + | w.DeleteReleases = []domain.Release{rel} | |
| 414 | + | w.DeleteAssets = assets | |
| 415 | + | for _, a := range assets { | |
| 416 | + | w.Used -= a.Charged | |
| 417 | + | } | |
| 418 | + | return nil | |
| 419 | + | }) | |
| 420 | + | if err != nil { | |
| 421 | + | return err | |
| 422 | + | } | |
| 423 | + | r.removeAssetBlobs(context.WithoutCancel(ctx), assets) | |
| 424 | + | r.emit(ctx, repo, rel, "deleted") | |
| 425 | + | return nil | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // OnRefsMoved unpublishes releases of deleted tags; recreated tags stay drafts. | |
| 429 | + | func (r *Releases) OnRefsMoved(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) { | |
| 430 | + | for _, u := range updates { | |
| 431 | + | if !strings.HasPrefix(u.Ref, "refs/tags/") || u.New != domain.ZeroOID { | |
| 432 | + | continue | |
| 433 | + | } | |
| 434 | + | var changed bool | |
| 435 | + | var rel domain.Release | |
| 436 | + | err := r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error { | |
| 437 | + | changed = false | |
| 438 | + | var err error | |
| 439 | + | rel, err = r.store.GetReleaseByTag(ctx, name, strings.TrimPrefix(u.Ref, "refs/tags/")) | |
| 440 | + | if err != nil { | |
| 441 | + | return err | |
| 442 | + | } | |
| 443 | + | if rel.Draft { | |
| 444 | + | return nil | |
| 445 | + | } | |
| 446 | + | rel.Draft = true | |
| 447 | + | rel.MakeLatest = false | |
| 448 | + | rel.PublishedAt = time.Time{} | |
| 449 | + | rel.UpdatedAt = r.clock.Now() | |
| 450 | + | w.PutReleases = []domain.Release{rel} | |
| 451 | + | changed = true | |
| 452 | + | return nil | |
| 453 | + | }) | |
| 454 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 455 | + | continue | |
| 456 | + | } | |
| 457 | + | if err != nil { | |
| 458 | + | ctxLog(ctx).Error().Err(err).Msg("unpublish release after tag deletion") | |
| 459 | + | continue | |
| 460 | + | } | |
| 461 | + | if changed { | |
| 462 | + | repo, err := lookupRepo(ctx, r.repos, name) | |
| 463 | + | if err == nil { | |
| 464 | + | r.emit(ctx, repo, rel, "unpublished") | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // AssetsOf snapshots assets before the repository's metadata is removed. | |
| 471 | + | func (r *Releases) AssetsOf(ctx context.Context, name domain.RepoName) ([]domain.ReleaseAsset, error) { | |
| 472 | + | all, err := r.store.AllAssets(ctx) | |
| 473 | + | if err != nil { | |
| 474 | + | return nil, err | |
| 475 | + | } | |
| 476 | + | return slices.DeleteFunc(all, func(a domain.ReleaseAsset) bool { return a.Repo != name }), nil | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // OnRepoDeleted removes contents after repository metadata has been deleted. | |
| 480 | + | func (r *Releases) OnRepoDeleted(ctx context.Context, _ domain.RepoName, assets []domain.ReleaseAsset) { | |
| 481 | + | r.removeAssetBlobs(ctx, assets) | |
| 482 | + | } | |
| 483 | + | func (r *Releases) removeAssetBlobs(ctx context.Context, assets []domain.ReleaseAsset) { | |
| 484 | + | for _, a := range assets { | |
| 485 | + | if a.MultipartID != "" { | |
| 486 | + | if err := r.blobs.AbortMultipart(ctx, a.BlobKey(), a.MultipartID); err != nil { | |
| 487 | + | ctxLog(ctx).Error().Err(err).Int64("asset", a.ID).Msg("abort release asset multipart") | |
| 488 | + | } | |
| 489 | + | } | |
| 490 | + | if err := r.blobs.Delete(ctx, a.BlobKey()); err != nil { | |
| 491 | + | ctxLog(ctx).Error().Err(err).Int64("asset", a.ID).Msg("remove release asset blob") | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | } | |
A internal/usecase/releases_assets.go +441 -0
| @@ -0,0 +1,441 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "slices" | |
| 13 | + | "strings" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // UploadAsset reserves quota before reading, stores bytes, then publishes metadata. | |
| 21 | + | func (r *Releases) UploadAsset(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UploadAssetParams, body io.Reader) (result domain.ReleaseAsset, err error) { | |
| 22 | + | repo, err := r.writable(ctx, p, name) | |
| 23 | + | if err != nil { | |
| 24 | + | return result, err | |
| 25 | + | } | |
| 26 | + | if params.Size < 0 || params.Size > r.limits.AssetMax { | |
| 27 | + | return result, fmt.Errorf("%w: asset size must be between zero and %s", domain.ErrResourceExhausted, sizeText(r.limits.AssetMax)) | |
| 28 | + | } | |
| 29 | + | a := domain.ReleaseAsset{Repo: name, ReleaseID: params.ReleaseID, Size: params.Size, Charged: params.Size, State: domain.ReleaseAssetUploading, Uploader: domain.AuthorName(p), CreatedAt: r.clock.Now(), UpdatedAt: r.clock.Now()} | |
| 30 | + | if a.Name, err = domain.ParseReleaseAssetName(params.Name); err != nil { | |
| 31 | + | return result, err | |
| 32 | + | } | |
| 33 | + | if a.Label, err = domain.ParseReleaseAssetLabel(params.Label); err != nil { | |
| 34 | + | return result, err | |
| 35 | + | } | |
| 36 | + | if a.ContentType, err = domain.ParseReleaseContentType(params.ContentType); err != nil { | |
| 37 | + | return result, err | |
| 38 | + | } | |
| 39 | + | select { | |
| 40 | + | case r.uploads <- struct{}{}: | |
| 41 | + | defer func() { <-r.uploads }() | |
| 42 | + | default: | |
| 43 | + | return result, fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted) | |
| 44 | + | } | |
| 45 | + | a.ID, err = r.store.NextReleaseID(ctx) | |
| 46 | + | if err != nil { | |
| 47 | + | return result, err | |
| 48 | + | } | |
| 49 | + | var replaced []domain.ReleaseAsset | |
| 50 | + | err = r.updateReleases(ctx, name, func(w *ports.ReleaseWrite) error { | |
| 51 | + | replaced = nil | |
| 52 | + | if _, err := r.store.GetRelease(ctx, name, a.ReleaseID); err != nil { | |
| 53 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 54 | + | owner, lookupErr := r.store.ReleaseRepo(ctx, a.ReleaseID) | |
| 55 | + | if lookupErr == nil && owner != name { | |
| 56 | + | return fmt.Errorf("%w: release belongs to another repository", domain.ErrPreconditionFailed) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | all, err := r.store.ListAssets(ctx, name, a.ReleaseID) | |
| 62 | + | if err != nil { | |
| 63 | + | return err | |
| 64 | + | } | |
| 65 | + | for _, old := range all { | |
| 66 | + | if old.Name != a.Name { | |
| 67 | + | continue | |
| 68 | + | } | |
| 69 | + | if !(old.State == domain.ReleaseAssetUploading && r.clock.Now().Sub(old.UpdatedAt) > 60*time.Second) && !(old.State == domain.ReleaseAssetUploaded && params.Replace) { | |
| 70 | + | return fmt.Errorf("%w: an asset named %q already exists", domain.ErrAlreadyExists, a.Name) | |
| 71 | + | } | |
| 72 | + | replaced = append(replaced, old) | |
| 73 | + | w.DeleteAssets = append(w.DeleteAssets, old) | |
| 74 | + | w.Used -= old.Charged | |
| 75 | + | } | |
| 76 | + | if int64(len(all)-len(replaced)) >= r.limits.AssetsMax { | |
| 77 | + | return fmt.Errorf("%w: release asset count limit is %d", domain.ErrResourceExhausted, r.limits.AssetsMax) | |
| 78 | + | } | |
| 79 | + | if a.Size > r.limits.Quota-w.Used { | |
| 80 | + | return fmt.Errorf("%w: release quota is %s", domain.ErrResourceExhausted, sizeText(r.limits.Quota)) | |
| 81 | + | } | |
| 82 | + | w.Used += a.Charged | |
| 83 | + | w.PutAssets = []domain.ReleaseAsset{a} | |
| 84 | + | return nil | |
| 85 | + | }) | |
| 86 | + | if err != nil { | |
| 87 | + | return result, err | |
| 88 | + | } | |
| 89 | + | r.removeAssetBlobs(context.WithoutCancel(ctx), replaced) | |
| 90 | + | body = releaseContextReader{ctx: ctx, reader: body} | |
| 91 | + | // From here every failure removes the reservation and any completed blob. | |
| 92 | + | defer func() { | |
| 93 | + | if err != nil { | |
| 94 | + | clean := context.WithoutCancel(ctx) | |
| 95 | + | if e := r.removeAssetRecord(clean, a, nil); e != nil && !errors.Is(e, domain.ErrNotFound) { | |
| 96 | + | ctxLog(ctx).Error().Err(e).Int64("asset", a.ID).Msg("refund failed release upload") | |
| 97 | + | } | |
| 98 | + | r.removeAssetBlobs(clean, []domain.ReleaseAsset{a}) | |
| 99 | + | } | |
| 100 | + | }() | |
| 101 | + | if a.Size <= ports.BlobPartSize { | |
| 102 | + | var info ports.BlobInfo | |
| 103 | + | limited := &io.LimitedReader{R: body, N: a.Size} | |
| 104 | + | info, err = r.blobs.Put(ctx, a.BlobKey(), limited, a.Size) | |
| 105 | + | if err != nil { | |
| 106 | + | if limited.N > 0 && errors.Is(err, domain.ErrInvalid) { | |
| 107 | + | err = fmt.Errorf("%w: body shorter than size", domain.ErrInvalid) | |
| 108 | + | } | |
| 109 | + | return result, err | |
| 110 | + | } | |
| 111 | + | a.Digest = info.Digest | |
| 112 | + | if !validReleaseDigest(a.Digest) { | |
| 113 | + | return result, fmt.Errorf("%w: blob store returned no SHA-256 digest", domain.ErrInvalid) | |
| 114 | + | } | |
| 115 | + | } else { | |
| 116 | + | var upload ports.MultipartUpload | |
| 117 | + | upload, err = r.blobs.NewMultipart(ctx, a.BlobKey()) | |
| 118 | + | if err != nil { | |
| 119 | + | return result, err | |
| 120 | + | } | |
| 121 | + | completed := false | |
| 122 | + | defer func() { | |
| 123 | + | if !completed { | |
| 124 | + | if e := upload.Abort(context.WithoutCancel(ctx)); e != nil { | |
| 125 | + | ctxLog(ctx).Error().Err(e).Msg("abort failed release upload") | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | }() | |
| 129 | + | a.MultipartID = upload.ID() | |
| 130 | + | err = r.changeAsset(ctx, a.ID, name, func(current *domain.ReleaseAsset) error { current.MultipartID = a.MultipartID; return nil }) | |
| 131 | + | if err != nil { | |
| 132 | + | return result, err | |
| 133 | + | } | |
| 134 | + | hash := sha256.New() | |
| 135 | + | buf := make([]byte, ports.BlobPartSize) | |
| 136 | + | remaining := a.Size | |
| 137 | + | for part := 1; remaining > 0; part++ { | |
| 138 | + | if part > ports.BlobMaxParts { | |
| 139 | + | return result, domain.ErrResourceExhausted | |
| 140 | + | } | |
| 141 | + | want := min(remaining, int64(len(buf))) | |
| 142 | + | var n int | |
| 143 | + | n, err = io.ReadFull(body, buf[:want]) | |
| 144 | + | if err != nil { | |
| 145 | + | if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { | |
| 146 | + | err = fmt.Errorf("%w: body shorter than size", domain.ErrInvalid) | |
| 147 | + | } | |
| 148 | + | return result, err | |
| 149 | + | } | |
| 150 | + | hash.Write(buf[:n]) | |
| 151 | + | err = upload.UploadPart(ctx, part, bytes.NewReader(buf[:n]), int64(n)) | |
| 152 | + | if err != nil { | |
| 153 | + | return result, err | |
| 154 | + | } | |
| 155 | + | remaining -= int64(n) | |
| 156 | + | } | |
| 157 | + | if err = releaseBodyEnd(body); err != nil { | |
| 158 | + | return result, err | |
| 159 | + | } | |
| 160 | + | _, err = upload.Complete(ctx) | |
| 161 | + | if err != nil { | |
| 162 | + | return result, err | |
| 163 | + | } | |
| 164 | + | completed = true | |
| 165 | + | a.Digest = fmt.Sprintf("sha256:%x", hash.Sum(nil)) | |
| 166 | + | a.MultipartID = "" | |
| 167 | + | } | |
| 168 | + | if a.Size <= ports.BlobPartSize { | |
| 169 | + | if err = releaseBodyEnd(body); err != nil { | |
| 170 | + | return result, err | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | err = r.changeAsset(ctx, a.ID, name, func(current *domain.ReleaseAsset) error { | |
| 174 | + | if current.State != domain.ReleaseAssetUploading { | |
| 175 | + | return domain.ErrConflict | |
| 176 | + | } | |
| 177 | + | current.State = domain.ReleaseAssetUploaded | |
| 178 | + | current.Digest = a.Digest | |
| 179 | + | current.MultipartID = "" | |
| 180 | + | current.UpdatedAt = r.clock.Now() | |
| 181 | + | return nil | |
| 182 | + | }) | |
| 183 | + | if err != nil { | |
| 184 | + | return result, err | |
| 185 | + | } | |
| 186 | + | result, err = r.store.GetAsset(ctx, a.ID) | |
| 187 | + | if err != nil { | |
| 188 | + | return result, err | |
| 189 | + | } | |
| 190 | + | rel, e := r.store.GetRelease(ctx, name, a.ReleaseID) | |
| 191 | + | if e == nil { | |
| 192 | + | r.emit(ctx, repo, rel, "updated") | |
| 193 | + | } | |
| 194 | + | ctxLog(ctx).Info().Int64("release", a.ReleaseID).Int64("asset", a.ID).Int64("size", a.Size).Msg("release asset uploaded") | |
| 195 | + | return result, nil | |
| 196 | + | } | |
| 197 | + | func validReleaseDigest(s string) bool { | |
| 198 | + | if len(s) != 71 || s[:7] != "sha256:" { | |
| 199 | + | return false | |
| 200 | + | } | |
| 201 | + | for _, c := range s[7:] { | |
| 202 | + | if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { | |
| 203 | + | return false | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | return true | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // releaseContextReader checks cancellation between reads, including multipart | |
| 210 | + | // buffering and the final extra-byte check. The transport owns interrupting a | |
| 211 | + | // body.Read already in progress. | |
| 212 | + | type releaseContextReader struct { | |
| 213 | + | ctx context.Context | |
| 214 | + | reader io.Reader | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (r releaseContextReader) Read(p []byte) (int, error) { | |
| 218 | + | if err := r.ctx.Err(); err != nil { | |
| 219 | + | return 0, err | |
| 220 | + | } | |
| 221 | + | return r.reader.Read(p) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func releaseBodyEnd(body io.Reader) error { | |
| 225 | + | var extra [1]byte | |
| 226 | + | n, err := io.ReadFull(body, extra[:]) | |
| 227 | + | if n > 0 { | |
| 228 | + | return fmt.Errorf("%w: body longer than size", domain.ErrInvalid) | |
| 229 | + | } | |
| 230 | + | if errors.Is(err, io.EOF) { | |
| 231 | + | return nil | |
| 232 | + | } | |
| 233 | + | return err | |
| 234 | + | } | |
| 235 | + | func (r *Releases) changeAsset(ctx context.Context, id int64, repo domain.RepoName, fn func(*domain.ReleaseAsset) error) error { | |
| 236 | + | return r.updateReleases(ctx, repo, func(w *ports.ReleaseWrite) error { | |
| 237 | + | a, err := r.store.GetAsset(ctx, id) | |
| 238 | + | if err != nil { | |
| 239 | + | return err | |
| 240 | + | } | |
| 241 | + | if a.Repo != repo { | |
| 242 | + | return domain.ErrNotFound | |
| 243 | + | } | |
| 244 | + | if err = fn(&a); err != nil { | |
| 245 | + | return err | |
| 246 | + | } | |
| 247 | + | w.PutAssets = []domain.ReleaseAsset{a} | |
| 248 | + | return nil | |
| 249 | + | }) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // removeAssetRecord conditionally removes the current record; sweep predicates | |
| 253 | + | // are evaluated again after a conflict so a just-completed upload is protected. | |
| 254 | + | func (r *Releases) removeAssetRecord(ctx context.Context, a domain.ReleaseAsset, predicate func(domain.ReleaseAsset) bool) error { | |
| 255 | + | return r.updateReleases(ctx, a.Repo, func(w *ports.ReleaseWrite) error { | |
| 256 | + | current, err := r.store.GetAsset(ctx, a.ID) | |
| 257 | + | if err != nil { | |
| 258 | + | return err | |
| 259 | + | } | |
| 260 | + | if current.Repo != a.Repo { | |
| 261 | + | return domain.ErrNotFound | |
| 262 | + | } | |
| 263 | + | if predicate != nil && !predicate(current) { | |
| 264 | + | return domain.ErrConflict | |
| 265 | + | } | |
| 266 | + | w.DeleteAssets = []domain.ReleaseAsset{current} | |
| 267 | + | w.Used -= current.Charged | |
| 268 | + | return nil | |
| 269 | + | }) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // ListAssets returns completed assets only, ordered by name. | |
| 273 | + | func (r *Releases) ListAssets(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) ([]domain.ReleaseAsset, error) { | |
| 274 | + | if _, err := r.GetRelease(ctx, p, repo, ports.ReleaseRef{ID: id}); err != nil { | |
| 275 | + | return nil, err | |
| 276 | + | } | |
| 277 | + | all, err := r.store.ListAssets(ctx, repo, id) | |
| 278 | + | if err != nil { | |
| 279 | + | return nil, err | |
| 280 | + | } | |
| 281 | + | return slices.DeleteFunc(all, func(a domain.ReleaseAsset) bool { return a.State != domain.ReleaseAssetUploaded }), nil | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // GetAsset resolves an uploaded asset, enforcing its release's draft rule. | |
| 285 | + | func (r *Releases) GetAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) { | |
| 286 | + | var a domain.ReleaseAsset | |
| 287 | + | var err error | |
| 288 | + | if ref.ID != 0 { | |
| 289 | + | if ref.Tag != "" || ref.Latest || ref.Name != "" { | |
| 290 | + | return a, domain.ErrInvalid | |
| 291 | + | } | |
| 292 | + | if _, err = r.readable(ctx, p, repo); err != nil { | |
| 293 | + | return a, err | |
| 294 | + | } | |
| 295 | + | a, err = r.store.GetAsset(ctx, ref.ID) | |
| 296 | + | if err != nil { | |
| 297 | + | return a, err | |
| 298 | + | } | |
| 299 | + | if a.Repo != repo { | |
| 300 | + | return domain.ReleaseAsset{}, domain.ErrNotFound | |
| 301 | + | } | |
| 302 | + | _, err = r.GetRelease(ctx, p, repo, ports.ReleaseRef{ID: a.ReleaseID}) | |
| 303 | + | } else { | |
| 304 | + | if ref.Name == "" { | |
| 305 | + | return a, domain.ErrInvalid | |
| 306 | + | } | |
| 307 | + | var rel domain.Release | |
| 308 | + | rel, err = r.GetRelease(ctx, p, repo, ports.ReleaseRef{Tag: ref.Tag, Latest: ref.Latest}) | |
| 309 | + | if err != nil { | |
| 310 | + | return a, err | |
| 311 | + | } | |
| 312 | + | a, err = r.store.GetAssetByName(ctx, repo, rel.ID, ref.Name) | |
| 313 | + | } | |
| 314 | + | if err == nil && a.State != domain.ReleaseAssetUploaded { | |
| 315 | + | err = domain.ErrNotFound | |
| 316 | + | } | |
| 317 | + | return a, err | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // UpdateAsset changes an uploaded asset's name and label without moving bytes. | |
| 321 | + | func (r *Releases) UpdateAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64, params ports.UpdateAssetParams) (domain.ReleaseAsset, error) { | |
| 322 | + | meta, err := r.writable(ctx, p, repo) | |
| 323 | + | if err != nil { | |
| 324 | + | return domain.ReleaseAsset{}, err | |
| 325 | + | } | |
| 326 | + | a, err := r.GetAsset(ctx, p, repo, ports.AssetRef{ID: id}) | |
| 327 | + | if err != nil { | |
| 328 | + | return a, err | |
| 329 | + | } | |
| 330 | + | var newName, newLabel string | |
| 331 | + | if params.Name != nil { | |
| 332 | + | newName, err = domain.ParseReleaseAssetName(*params.Name) | |
| 333 | + | if err != nil { | |
| 334 | + | return a, err | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | if params.Label != nil { | |
| 338 | + | newLabel, err = domain.ParseReleaseAssetLabel(*params.Label) | |
| 339 | + | if err != nil { | |
| 340 | + | return a, err | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | err = r.changeAsset(ctx, id, repo, func(current *domain.ReleaseAsset) error { | |
| 344 | + | if current.State != domain.ReleaseAssetUploaded { | |
| 345 | + | return domain.ErrNotFound | |
| 346 | + | } | |
| 347 | + | if params.Name != nil { | |
| 348 | + | current.Name = newName | |
| 349 | + | } | |
| 350 | + | if params.Label != nil { | |
| 351 | + | current.Label = newLabel | |
| 352 | + | } | |
| 353 | + | current.UpdatedAt = r.clock.Now() | |
| 354 | + | return nil | |
| 355 | + | }) | |
| 356 | + | if err != nil { | |
| 357 | + | return domain.ReleaseAsset{}, err | |
| 358 | + | } | |
| 359 | + | a, err = r.store.GetAsset(ctx, id) | |
| 360 | + | if err != nil { | |
| 361 | + | return a, err | |
| 362 | + | } | |
| 363 | + | if rel, e := r.store.GetRelease(ctx, repo, a.ReleaseID); e == nil { | |
| 364 | + | r.emit(ctx, meta, rel, "updated") | |
| 365 | + | } | |
| 366 | + | return a, nil | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // DeleteAsset refunds quota before deleting the blob. | |
| 370 | + | func (r *Releases) DeleteAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, id int64) error { | |
| 371 | + | meta, err := r.writable(ctx, p, repo) | |
| 372 | + | if err != nil { | |
| 373 | + | return err | |
| 374 | + | } | |
| 375 | + | a, err := r.GetAsset(ctx, p, repo, ports.AssetRef{ID: id}) | |
| 376 | + | if err != nil { | |
| 377 | + | return err | |
| 378 | + | } | |
| 379 | + | if err = r.removeAssetRecord(ctx, a, nil); err != nil { | |
| 380 | + | return err | |
| 381 | + | } | |
| 382 | + | r.removeAssetBlobs(context.WithoutCancel(ctx), []domain.ReleaseAsset{a}) | |
| 383 | + | if rel, e := r.store.GetRelease(ctx, repo, a.ReleaseID); e == nil { | |
| 384 | + | r.emit(ctx, meta, rel, "updated") | |
| 385 | + | } | |
| 386 | + | return nil | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // DownloadAsset sends metadata followed by bounded chunks of the requested range. | |
| 390 | + | func (r *Releases) DownloadAsset(ctx context.Context, p domain.Principal, repo domain.RepoName, ref ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error { | |
| 391 | + | a, err := r.GetAsset(ctx, p, repo, ref) | |
| 392 | + | if err != nil { | |
| 393 | + | return err | |
| 394 | + | } | |
| 395 | + | var body io.ReadCloser | |
| 396 | + | if a.Size == 0 && offset == 0 { | |
| 397 | + | // Blob stores reject Open on an empty object: stat it instead. | |
| 398 | + | if _, err = r.blobs.Stat(ctx, a.BlobKey()); err == nil { | |
| 399 | + | body = io.NopCloser(strings.NewReader("")) | |
| 400 | + | } | |
| 401 | + | } else { | |
| 402 | + | body, _, err = r.blobs.Open(ctx, a.BlobKey(), offset, length) | |
| 403 | + | } | |
| 404 | + | if err != nil { | |
| 405 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 406 | + | ctxLog(ctx).Error().Int64("asset", a.ID).Msg("asset blob missing") | |
| 407 | + | } | |
| 408 | + | return err | |
| 409 | + | } | |
| 410 | + | defer body.Close() | |
| 411 | + | if err = send(ports.AssetChunk{Asset: &a}); err != nil { | |
| 412 | + | return err | |
| 413 | + | } | |
| 414 | + | buf := make([]byte, domain.CIArtifactChunk) | |
| 415 | + | for { | |
| 416 | + | if err = ctx.Err(); err != nil { | |
| 417 | + | return err | |
| 418 | + | } | |
| 419 | + | n, e := body.Read(buf) | |
| 420 | + | if n > 0 { | |
| 421 | + | if err = send(ports.AssetChunk{Data: buf[:n]}); err != nil { | |
| 422 | + | return err | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | if errors.Is(e, io.EOF) { | |
| 426 | + | return nil | |
| 427 | + | } | |
| 428 | + | if e != nil { | |
| 429 | + | return e | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // Usage reports quota and configured limits to a repository reader. | |
| 435 | + | func (r *Releases) Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.ReleaseUsage, error) { | |
| 436 | + | if _, err := r.readable(ctx, p, repo); err != nil { | |
| 437 | + | return ports.ReleaseUsage{}, err | |
| 438 | + | } | |
| 439 | + | used, _, err := r.store.ReleaseUsage(ctx, repo) | |
| 440 | + | return ports.ReleaseUsage{Used: used, Limits: r.limits}, err | |
| 441 | + | } | |
A internal/usecase/releases_assets_test.go +308 -0
| @@ -0,0 +1,308 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "sync" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 19 | + | "bnema.dev/ermite/internal/usecase" | |
| 20 | + | "github.com/stretchr/testify/mock" | |
| 21 | + | "github.com/stretchr/testify/require" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | func (r *releaseRig) upload(t *testing.T, rel domain.Release, n string, data []byte) domain.ReleaseAsset { | |
| 25 | + | t.Helper() | |
| 26 | + | a, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: n, Size: int64(len(data))}, bytes.NewReader(data)) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | return a | |
| 29 | + | } | |
| 30 | + | func TestReleaseAssetRoundTrip(t *testing.T) { | |
| 31 | + | r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10, Quota: 10}) | |
| 32 | + | rel := r.create(t, "v1", false) | |
| 33 | + | a := r.upload(t, rel, "binary", []byte("0123456789")) | |
| 34 | + | require.Equal(t, fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("0123456789"))), a.Digest) | |
| 35 | + | usage, err := r.svc.Usage(r.ctx, domain.Anonymous(), r.repo) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | require.EqualValues(t, 10, usage.Used) | |
| 38 | + | for _, tc := range []struct { | |
| 39 | + | offset, length int64 | |
| 40 | + | want string | |
| 41 | + | err error | |
| 42 | + | }{{0, -1, "0123456789", nil}, {3, 4, "3456", nil}, {9, 1, "9", nil}, {10, -1, "", domain.ErrOutOfRange}, {11, -1, "", domain.ErrOutOfRange}} { | |
| 43 | + | var got bytes.Buffer | |
| 44 | + | metadata := false | |
| 45 | + | err := r.svc.DownloadAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{Latest: true, Name: a.Name}, tc.offset, tc.length, func(c ports.AssetChunk) error { | |
| 46 | + | if c.Asset != nil { | |
| 47 | + | require.False(t, metadata) | |
| 48 | + | metadata = true | |
| 49 | + | require.Equal(t, a.ID, c.Asset.ID) | |
| 50 | + | } else { | |
| 51 | + | require.True(t, metadata) | |
| 52 | + | got.Write(c.Data) | |
| 53 | + | } | |
| 54 | + | return nil | |
| 55 | + | }) | |
| 56 | + | require.ErrorIs(t, err, tc.err) | |
| 57 | + | require.Equal(t, tc.want, got.String()) | |
| 58 | + | } | |
| 59 | + | _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "large", Size: 11}, bytes.NewReader(nil)) | |
| 60 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 61 | + | _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "quota", Size: 1}, bytes.NewReader([]byte("x"))) | |
| 62 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 63 | + | _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: a.Name, Size: 0}, bytes.NewReader(nil)) | |
| 64 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 65 | + | replacement, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: a.Name, Size: 2, Replace: true}, bytes.NewReader([]byte("hi"))) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | require.NotEqual(t, a.ID, replacement.ID) | |
| 68 | + | _, err = r.blobs.Stat(r.ctx, a.BlobKey()) | |
| 69 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 70 | + | renamed := "new" | |
| 71 | + | label := "label" | |
| 72 | + | replacement, err = r.svc.UpdateAsset(r.ctx, domain.Root(), r.repo, replacement.ID, ports.UpdateAssetParams{Name: &renamed, Label: &label}) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | require.Equal(t, label, replacement.Label) | |
| 75 | + | list, err := r.svc.ListAssets(r.ctx, domain.Anonymous(), r.repo, rel.ID) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.Len(t, list, 1) | |
| 78 | + | require.Equal(t, renamed, list[0].Name) | |
| 79 | + | require.NoError(t, r.svc.DeleteAsset(r.ctx, domain.Root(), r.repo, replacement.ID)) | |
| 80 | + | usage, err = r.svc.Usage(r.ctx, domain.Root(), r.repo) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | require.Zero(t, usage.Used) | |
| 83 | + | } | |
| 84 | + | func TestReleaseAssetCountBoundary(t *testing.T) { | |
| 85 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 86 | + | rel := r.create(t, "v1", false) | |
| 87 | + | // Seed 999 records in one batch, then exercise the exact public boundary. | |
| 88 | + | used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | w := ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v} | |
| 91 | + | for range 999 { | |
| 92 | + | id, err := r.store.Releases().NextReleaseID(r.ctx) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | w.PutAssets = append(w.PutAssets, domain.ReleaseAsset{ID: id, ReleaseID: rel.ID, Repo: r.repo, Name: fmt.Sprint(id), State: domain.ReleaseAssetUploaded}) | |
| 95 | + | } | |
| 96 | + | require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, w)) | |
| 97 | + | r.upload(t, rel, "thousandth", nil) | |
| 98 | + | _, err = r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "1001"}, bytes.NewReader(nil)) | |
| 99 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 100 | + | } | |
| 101 | + | func TestReleaseAssetBadBodies(t *testing.T) { | |
| 102 | + | bodyErr := errors.New("reader failed") | |
| 103 | + | for _, size := range []int64{4, 9 << 20} { | |
| 104 | + | for _, kind := range []string{"short", "long", "error", "cancel"} { | |
| 105 | + | t.Run(fmt.Sprint(size, "/", kind), func(t *testing.T) { | |
| 106 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 107 | + | rel := r.create(t, "v1", false) | |
| 108 | + | var body io.Reader | |
| 109 | + | switch kind { | |
| 110 | + | case "short": | |
| 111 | + | body = bytes.NewReader(make([]byte, size-1)) | |
| 112 | + | case "long": | |
| 113 | + | body = bytes.NewReader(make([]byte, size+1)) | |
| 114 | + | case "error": | |
| 115 | + | body = errReader{bodyErr} | |
| 116 | + | case "cancel": | |
| 117 | + | body = errReader{context.Canceled} | |
| 118 | + | } | |
| 119 | + | _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "bad", Size: size}, body) | |
| 120 | + | switch kind { | |
| 121 | + | case "error": | |
| 122 | + | require.ErrorIs(t, err, bodyErr) | |
| 123 | + | case "cancel": | |
| 124 | + | require.ErrorIs(t, err, context.Canceled) | |
| 125 | + | default: | |
| 126 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 127 | + | } | |
| 128 | + | all, err := r.store.Releases().AllAssets(r.ctx) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | require.Empty(t, all) | |
| 131 | + | u, err := r.svc.Usage(r.ctx, domain.Root(), r.repo) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | require.Zero(t, u.Used) | |
| 134 | + | require.NoError(t, r.blobs.List(r.ctx, "release/", func(ports.BlobInfo) error { t.Error("blob left behind"); return nil })) | |
| 135 | + | require.NoError(t, r.blobs.ListMultipart(r.ctx, "release/", func(ports.MultipartInfo) error { t.Error("multipart left behind"); return nil })) | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | type releaseBlobSpy struct { | |
| 142 | + | ports.BlobStore | |
| 143 | + | put, multipart int | |
| 144 | + | parts []int64 | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (s *releaseBlobSpy) Put(ctx context.Context, key string, b io.Reader, size int64) (ports.BlobInfo, error) { | |
| 148 | + | s.put++ | |
| 149 | + | return s.BlobStore.Put(ctx, key, b, size) | |
| 150 | + | } | |
| 151 | + | func (s *releaseBlobSpy) NewMultipart(ctx context.Context, key string) (ports.MultipartUpload, error) { | |
| 152 | + | s.multipart++ | |
| 153 | + | u, e := s.BlobStore.NewMultipart(ctx, key) | |
| 154 | + | return &releasePartSpy{MultipartUpload: u, spy: s}, e | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | type releasePartSpy struct { | |
| 158 | + | ports.MultipartUpload | |
| 159 | + | spy *releaseBlobSpy | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (s *releasePartSpy) UploadPart(ctx context.Context, n int, b io.Reader, size int64) error { | |
| 163 | + | s.spy.parts = append(s.spy.parts, size) | |
| 164 | + | return s.MultipartUpload.UploadPart(ctx, n, b, size) | |
| 165 | + | } | |
| 166 | + | func TestReleaseAssetUploadPaths(t *testing.T) { | |
| 167 | + | for _, size := range []int{1 << 20, 9 << 20} { | |
| 168 | + | t.Run(fmt.Sprint(size), func(t *testing.T) { | |
| 169 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 170 | + | rel := r.create(t, "v1", false) | |
| 171 | + | spy := &releaseBlobSpy{BlobStore: r.blobs} | |
| 172 | + | bus := mocks.NewEventBus(t) | |
| 173 | + | bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe() | |
| 174 | + | r.svc = usecase.NewReleases(r.store.Releases(), spy, r.store, r.reader, r.tags, r.pushes, bus, r.clock, ports.ReleaseLimits{}) | |
| 175 | + | data := bytes.Repeat([]byte("z"), size) | |
| 176 | + | a := r.upload(t, rel, "blob", data) | |
| 177 | + | require.Equal(t, fmt.Sprintf("sha256:%x", sha256.Sum256(data)), a.Digest) | |
| 178 | + | if size == 1<<20 { | |
| 179 | + | require.Equal(t, 1, spy.put) | |
| 180 | + | require.Zero(t, spy.multipart) | |
| 181 | + | } else { | |
| 182 | + | require.Zero(t, spy.put) | |
| 183 | + | require.Equal(t, 1, spy.multipart) | |
| 184 | + | require.Equal(t, []int64{8 << 20, 1 << 20}, spy.parts) | |
| 185 | + | } | |
| 186 | + | }) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | func TestReleaseAssetStaleReplacementAndReleaseDelete(t *testing.T) { | |
| 190 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 191 | + | rel := r.create(t, "v1", false) | |
| 192 | + | a := r.upload(t, rel, "pending", []byte("old")) | |
| 193 | + | current, err := r.store.Releases().GetAsset(r.ctx, a.ID) | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | current.State = domain.ReleaseAssetUploading | |
| 196 | + | current.UpdatedAt = now.Add(-61 * time.Second) | |
| 197 | + | used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo) | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{current}})) | |
| 200 | + | b := r.upload(t, rel, "pending", []byte("new")) | |
| 201 | + | require.NotEqual(t, a.ID, b.ID) | |
| 202 | + | require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, rel.ID)) | |
| 203 | + | _, err = r.blobs.Stat(r.ctx, b.BlobKey()) | |
| 204 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 205 | + | u, err := r.svc.Usage(r.ctx, domain.Root(), r.repo) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | require.Zero(t, u.Used) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | type releaseBlockingReader struct { | |
| 211 | + | entered chan<- struct{} | |
| 212 | + | release <-chan struct{} | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (b releaseBlockingReader) Read([]byte) (int, error) { | |
| 216 | + | b.entered <- struct{}{} | |
| 217 | + | <-b.release | |
| 218 | + | return 0, io.EOF | |
| 219 | + | } | |
| 220 | + | func TestReleaseAssetConcurrencyCap(t *testing.T) { | |
| 221 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 222 | + | rel := r.create(t, "v1", false) | |
| 223 | + | entered := make(chan struct{}, 16) | |
| 224 | + | release := make(chan struct{}) | |
| 225 | + | var wg sync.WaitGroup | |
| 226 | + | errs := make(chan error, 16) | |
| 227 | + | for i := range 16 { | |
| 228 | + | wg.Go(func() { | |
| 229 | + | _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: fmt.Sprint(i), Size: 1}, releaseBlockingReader{entered, release}) | |
| 230 | + | errs <- err | |
| 231 | + | }) | |
| 232 | + | } | |
| 233 | + | // Every reader has a reservation and holds its slot before the 17th call. | |
| 234 | + | for range 16 { | |
| 235 | + | <-entered | |
| 236 | + | } | |
| 237 | + | _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "excess", Size: 0}, bytes.NewReader(nil)) | |
| 238 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 239 | + | close(release) | |
| 240 | + | wg.Wait() | |
| 241 | + | close(errs) | |
| 242 | + | for err := range errs { | |
| 243 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | type errReader struct{ err error } | |
| 248 | + | ||
| 249 | + | func (r errReader) Read([]byte) (int, error) { return 0, r.err } | |
| 250 | + | ||
| 251 | + | func TestReleaseAssetDraftAccessAndRenameCollision(t *testing.T) { | |
| 252 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 253 | + | rel := r.create(t, "draft", true) | |
| 254 | + | a := r.upload(t, rel, "a", []byte("abc")) | |
| 255 | + | b := r.upload(t, rel, "b", nil) | |
| 256 | + | _, err := r.svc.GetAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{ID: a.ID}) | |
| 257 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 258 | + | _, err = r.svc.ListAssets(r.ctx, domain.Anonymous(), r.repo, rel.ID) | |
| 259 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 260 | + | _, err = r.svc.UpdateAsset(r.ctx, domain.Root(), r.repo, b.ID, ports.UpdateAssetParams{Name: &a.Name}) | |
| 261 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 262 | + | got, err := r.svc.GetAsset(r.ctx, domain.Root(), r.repo, ports.AssetRef{Tag: rel.Tag, Name: a.Name}) | |
| 263 | + | require.NoError(t, err) | |
| 264 | + | require.Equal(t, a.ID, got.ID) | |
| 265 | + | assets, err := r.svc.AssetsOf(r.ctx, r.repo) | |
| 266 | + | require.NoError(t, err) | |
| 267 | + | require.Len(t, assets, 2) | |
| 268 | + | require.NoError(t, r.store.Delete(r.ctx, r.repo)) | |
| 269 | + | r.svc.OnRepoDeleted(r.ctx, r.repo, assets) | |
| 270 | + | _, err = r.blobs.Stat(r.ctx, a.BlobKey()) | |
| 271 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 272 | + | all, err := r.store.Releases().AllAssets(r.ctx) | |
| 273 | + | require.NoError(t, err) | |
| 274 | + | require.Empty(t, all) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func TestReleaseAssetQuotaAcrossReleasesAndWrongRepository(t *testing.T) { | |
| 278 | + | r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 3, Quota: 5}) | |
| 279 | + | a := r.create(t, "a", false) | |
| 280 | + | b := r.create(t, "b", false) | |
| 281 | + | r.upload(t, a, "a", []byte("abc")) | |
| 282 | + | r.upload(t, b, "b", []byte("de")) | |
| 283 | + | _, err := r.svc.UploadAsset(r.ctx, domain.Root(), r.repo, ports.UploadAssetParams{ReleaseID: b.ID, Name: "extra", Size: 1}, bytes.NewReader([]byte("x"))) | |
| 284 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 285 | + | other := name(t, "other") | |
| 286 | + | require.NoError(t, r.store.Create(r.ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: now})) | |
| 287 | + | _, err = r.svc.UploadAsset(r.ctx, domain.Root(), other, ports.UploadAssetParams{ReleaseID: b.ID, Name: "wrong"}, bytes.NewReader(nil)) | |
| 288 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func TestReleaseAssetDownloadZeroBytes(t *testing.T) { | |
| 292 | + | r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10, Quota: 10}) | |
| 293 | + | rel := r.create(t, "v1", false) | |
| 294 | + | a := r.upload(t, rel, "empty", nil) | |
| 295 | + | var chunks []ports.AssetChunk | |
| 296 | + | err := r.svc.DownloadAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{ID: a.ID}, 0, -1, func(c ports.AssetChunk) error { | |
| 297 | + | chunks = append(chunks, c) | |
| 298 | + | return nil | |
| 299 | + | }) | |
| 300 | + | require.NoError(t, err) | |
| 301 | + | require.Len(t, chunks, 1) | |
| 302 | + | require.Equal(t, a.ID, chunks[0].Asset.ID) | |
| 303 | + | ||
| 304 | + | // A missing blob stays an error. | |
| 305 | + | require.NoError(t, r.blobs.Delete(r.ctx, a.BlobKey())) | |
| 306 | + | err = r.svc.DownloadAsset(r.ctx, domain.Anonymous(), r.repo, ports.AssetRef{ID: a.ID}, 0, -1, func(ports.AssetChunk) error { return nil }) | |
| 307 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 308 | + | } | |
A internal/usecase/releases_github.go +387 -0
| @@ -0,0 +1,387 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/url" | |
| 11 | + | "slices" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // ReleasesGitHub builds the GitHub REST representations of releases. Authorization | |
| 20 | + | // and all mutations remain in Releases; it owns no metadata or contents. | |
| 21 | + | type ReleasesGitHub struct { | |
| 22 | + | rel *Releases | |
| 23 | + | owner, publicURL string | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | var _ ports.GitHubReleaseAPI = (*ReleasesGitHub)(nil) | |
| 27 | + | ||
| 28 | + | // NewReleasesGitHub wraps the shared release service. publicURL is the external | |
| 29 | + | // server URL (without the API prefix), used in upload and browser download URLs. | |
| 30 | + | func NewReleasesGitHub(rel *Releases, owner, publicURL string) *ReleasesGitHub { | |
| 31 | + | return &ReleasesGitHub{rel: rel, owner: owner, publicURL: strings.TrimRight(publicURL, "/")} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (g *ReleasesGitHub) repo(owner, name string) (domain.RepoName, error) { | |
| 35 | + | if !domain.OwnerMatches(g.owner, owner) { | |
| 36 | + | return domain.RepoName{}, domain.ErrNotFound | |
| 37 | + | } | |
| 38 | + | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |
| 39 | + | if err != nil { | |
| 40 | + | return domain.RepoName{}, domain.ErrNotFound | |
| 41 | + | } | |
| 42 | + | return rn, nil | |
| 43 | + | } | |
| 44 | + | func (g *ReleasesGitHub) base(repo domain.RepoName) string { | |
| 45 | + | return g.publicURL + apiPath + "/repos/" + url.PathEscape(g.owner) + "/" + url.PathEscape(repo.String()) | |
| 46 | + | } | |
| 47 | + | func (g *ReleasesGitHub) user(label string) map[string]any { | |
| 48 | + | if label == "" || label == "root" || label == "owner" { | |
| 49 | + | label = g.owner | |
| 50 | + | } | |
| 51 | + | u := ghURLs{server: g.publicURL, api: g.publicURL + apiPath} | |
| 52 | + | return u.userObj(ghIdentity{login: label, id: stableID("user", label)}, false) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // escapeReleaseTag preserves tag separators: the web routes join the tag path. | |
| 56 | + | func escapeReleaseTag(tag string) string { | |
| 57 | + | parts := strings.Split(tag, "/") | |
| 58 | + | for i := range parts { | |
| 59 | + | parts[i] = url.PathEscape(parts[i]) | |
| 60 | + | } | |
| 61 | + | return strings.Join(parts, "/") | |
| 62 | + | } | |
| 63 | + | func (g *ReleasesGitHub) assetObj(a domain.ReleaseAsset, tag string) ordered { | |
| 64 | + | return ordered{ | |
| 65 | + | {"url", g.base(a.Repo) + "/releases/assets/" + strconv.FormatInt(a.ID, 10)}, | |
| 66 | + | {"id", a.ID}, {"node_id", ""}, {"name", a.Name}, {"label", a.Label}, | |
| 67 | + | {"uploader", g.user(a.Uploader)}, {"content_type", a.ContentType}, {"state", "uploaded"}, | |
| 68 | + | {"size", a.Size}, {"digest", a.Digest}, {"download_count", 0}, | |
| 69 | + | {"created_at", a.CreatedAt.UTC().Format(timeLayout)}, {"updated_at", a.UpdatedAt.UTC().Format(timeLayout)}, | |
| 70 | + | {"browser_download_url", g.publicURL + "/" + a.Repo.String() + "/releases/download/" + url.PathEscape(tag) + "/" + url.PathEscape(a.Name)}, | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | func (g *ReleasesGitHub) releaseObj(ctx context.Context, p domain.Principal, r domain.Release) (ordered, error) { | |
| 74 | + | assets, err := g.rel.ListAssets(ctx, p, r.Repo, r.ID) | |
| 75 | + | if err != nil { | |
| 76 | + | return nil, err | |
| 77 | + | } | |
| 78 | + | objects := make([]ordered, 0, len(assets)) | |
| 79 | + | for _, a := range assets { | |
| 80 | + | objects = append(objects, g.assetObj(a, r.Tag)) | |
| 81 | + | } | |
| 82 | + | base := g.base(r.Repo) + "/releases/" + strconv.FormatInt(r.ID, 10) | |
| 83 | + | var published any | |
| 84 | + | if !r.Draft && !r.PublishedAt.IsZero() { | |
| 85 | + | published = r.PublishedAt.UTC().Format(timeLayout) | |
| 86 | + | } | |
| 87 | + | return ordered{ | |
| 88 | + | {"url", base}, {"assets_url", base + "/assets"}, | |
| 89 | + | {"upload_url", g.publicURL + apiPath + "/uploads/repos/" + url.PathEscape(g.owner) + "/" + r.Repo.String() + "/releases/" + strconv.FormatInt(r.ID, 10) + "/assets{?name,label}"}, | |
| 90 | + | {"html_url", g.publicURL + "/" + r.Repo.String() + "/releases/tag/" + escapeReleaseTag(r.Tag)}, | |
| 91 | + | {"id", r.ID}, {"author", g.user(r.Author)}, {"node_id", ""}, {"tag_name", r.Tag}, {"target_commitish", r.TargetCommitish}, | |
| 92 | + | {"name", r.Title}, {"draft", r.Draft}, {"prerelease", r.Prerelease}, {"created_at", r.CreatedAt.UTC().Format(timeLayout)}, | |
| 93 | + | {"published_at", published}, {"assets", objects}, {"tarball_url", g.base(r.Repo) + "/tarball/" + escapeReleaseTag(r.Tag)}, | |
| 94 | + | {"zipball_url", nil}, {"body", r.Notes}, | |
| 95 | + | }, nil | |
| 96 | + | } | |
| 97 | + | func (g *ReleasesGitHub) marshalRelease(ctx context.Context, p domain.Principal, r domain.Release) ([]byte, error) { | |
| 98 | + | o, err := g.releaseObj(ctx, p, r) | |
| 99 | + | if err != nil { | |
| 100 | + | return nil, err | |
| 101 | + | } | |
| 102 | + | return json.Marshal(o) | |
| 103 | + | } | |
| 104 | + | func releaseFields(body []byte) (map[string]json.RawMessage, error) { | |
| 105 | + | var fields map[string]json.RawMessage | |
| 106 | + | if err := json.Unmarshal(body, &fields); err != nil || fields == nil { | |
| 107 | + | return nil, fmt.Errorf("%w: JSON object required", domain.ErrInvalid) | |
| 108 | + | } | |
| 109 | + | return fields, nil | |
| 110 | + | } | |
| 111 | + | func releaseField[T any](fields map[string]json.RawMessage, key string) (*T, error) { | |
| 112 | + | b, ok := fields[key] | |
| 113 | + | if !ok { | |
| 114 | + | return nil, nil | |
| 115 | + | } | |
| 116 | + | var v T | |
| 117 | + | if string(b) == "null" || json.Unmarshal(b, &v) != nil { | |
| 118 | + | return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, key) | |
| 119 | + | } | |
| 120 | + | return &v, nil | |
| 121 | + | } | |
| 122 | + | func releaseParams(body []byte) (ports.UpdateReleaseParams, error) { | |
| 123 | + | var p ports.UpdateReleaseParams | |
| 124 | + | f, err := releaseFields(body) | |
| 125 | + | if err != nil { | |
| 126 | + | return p, err | |
| 127 | + | } | |
| 128 | + | for key, dst := range map[string]**string{"tag_name": &p.Tag, "target_commitish": &p.TargetCommitish, "name": &p.Title, "body": &p.Notes} { | |
| 129 | + | *dst, err = releaseField[string](f, key) | |
| 130 | + | if err != nil { | |
| 131 | + | return p, err | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | for key, dst := range map[string]**bool{"draft": &p.Draft, "prerelease": &p.Prerelease} { | |
| 135 | + | *dst, err = releaseField[bool](f, key) | |
| 136 | + | if err != nil { | |
| 137 | + | return p, err | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | latest, err := releaseField[string](f, "make_latest") | |
| 141 | + | if err != nil { | |
| 142 | + | return p, err | |
| 143 | + | } | |
| 144 | + | if latest != nil { | |
| 145 | + | switch *latest { | |
| 146 | + | case "true": | |
| 147 | + | v := true | |
| 148 | + | p.MakeLatest = &v | |
| 149 | + | case "false": | |
| 150 | + | v := false | |
| 151 | + | p.MakeLatest = &v | |
| 152 | + | case "legacy": | |
| 153 | + | default: | |
| 154 | + | return p, fmt.Errorf("%w: make_latest", domain.ErrInvalid) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | return p, nil | |
| 158 | + | } | |
| 159 | + | func value[T any](p *T) (v T) { | |
| 160 | + | if p != nil { | |
| 161 | + | return *p | |
| 162 | + | } | |
| 163 | + | return v | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // CreateRelease accepts GitHub's create body and returns its representation. | |
| 167 | + | func (g *ReleasesGitHub) CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error) { | |
| 168 | + | rn, err := g.repo(owner, repo) | |
| 169 | + | if err != nil { | |
| 170 | + | return nil, err | |
| 171 | + | } | |
| 172 | + | params, err := releaseParams(body) | |
| 173 | + | if err != nil { | |
| 174 | + | return nil, err | |
| 175 | + | } | |
| 176 | + | if params.Tag == nil || *params.Tag == "" { | |
| 177 | + | return nil, fmt.Errorf("%w: tag_name", domain.ErrInvalid) | |
| 178 | + | } | |
| 179 | + | r, err := g.rel.CreateRelease(ctx, p, rn, ports.CreateReleaseParams{Tag: *params.Tag, TargetCommitish: value(params.TargetCommitish), Title: value(params.Title), Notes: value(params.Notes), Draft: value(params.Draft), Prerelease: value(params.Prerelease), MakeLatest: value(params.MakeLatest)}) | |
| 180 | + | if err != nil { | |
| 181 | + | return nil, err | |
| 182 | + | } | |
| 183 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", r.ID).Msg("github release created") | |
| 184 | + | return g.marshalRelease(ctx, p, r) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // GetRelease resolves an id, tag or latest selector. | |
| 188 | + | func (g *ReleasesGitHub) GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ports.ReleaseRef) ([]byte, error) { | |
| 189 | + | rn, err := g.repo(owner, repo) | |
| 190 | + | if err != nil { | |
| 191 | + | return nil, err | |
| 192 | + | } | |
| 193 | + | r, err := g.rel.GetRelease(ctx, p, rn, ref) | |
| 194 | + | if err != nil { | |
| 195 | + | return nil, err | |
| 196 | + | } | |
| 197 | + | return g.marshalRelease(ctx, p, r) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // UpdateRelease changes only present fields, including explicit false and empty strings. | |
| 201 | + | func (g *ReleasesGitHub) UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) { | |
| 202 | + | rn, err := g.repo(owner, repo) | |
| 203 | + | if err != nil { | |
| 204 | + | return nil, err | |
| 205 | + | } | |
| 206 | + | params, err := releaseParams(body) | |
| 207 | + | if err != nil { | |
| 208 | + | return nil, err | |
| 209 | + | } | |
| 210 | + | r, err := g.rel.UpdateRelease(ctx, p, rn, id, params) | |
| 211 | + | if err != nil { | |
| 212 | + | return nil, err | |
| 213 | + | } | |
| 214 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release updated") | |
| 215 | + | return g.marshalRelease(ctx, p, r) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // DeleteRelease removes a release and all its assets. | |
| 219 | + | func (g *ReleasesGitHub) DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error { | |
| 220 | + | rn, err := g.repo(owner, repo) | |
| 221 | + | if err != nil { | |
| 222 | + | return err | |
| 223 | + | } | |
| 224 | + | if err := g.rel.DeleteRelease(ctx, p, rn, id); err != nil { | |
| 225 | + | return err | |
| 226 | + | } | |
| 227 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release deleted") | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | func releasePagination(page, perPage int) (int, int) { | |
| 231 | + | if page < 1 { | |
| 232 | + | page = 1 | |
| 233 | + | } | |
| 234 | + | if perPage < 1 { | |
| 235 | + | perPage = 30 | |
| 236 | + | } | |
| 237 | + | return page, min(perPage, 100) | |
| 238 | + | } | |
| 239 | + | func releasePageBounds(page, perPage, total int) (int, int) { | |
| 240 | + | // Avoid overflow on arbitrary query parameters. | |
| 241 | + | if page-1 > total/perPage { | |
| 242 | + | return total, total | |
| 243 | + | } | |
| 244 | + | start := min((page-1)*perPage, total) | |
| 245 | + | return start, min(start+perPage, total) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // ListReleases returns visible releases newest first and GitHub Link pagination. | |
| 249 | + | func (g *ReleasesGitHub) ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) ([]byte, string, error) { | |
| 250 | + | rn, err := g.repo(owner, repo) | |
| 251 | + | if err != nil { | |
| 252 | + | return nil, "", err | |
| 253 | + | } | |
| 254 | + | if _, err = g.rel.readable(ctx, p, rn); err != nil { | |
| 255 | + | return nil, "", err | |
| 256 | + | } | |
| 257 | + | all, err := g.rel.store.ListReleases(ctx, rn) | |
| 258 | + | if err != nil { | |
| 259 | + | return nil, "", err | |
| 260 | + | } | |
| 261 | + | all = slices.DeleteFunc(all, func(r domain.Release) bool { return r.Draft && !releaseWriter(p, rn) }) | |
| 262 | + | slices.SortFunc(all, func(a, b domain.Release) int { | |
| 263 | + | if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 { | |
| 264 | + | return c | |
| 265 | + | } | |
| 266 | + | if a.ID > b.ID { | |
| 267 | + | return -1 | |
| 268 | + | } | |
| 269 | + | if a.ID < b.ID { | |
| 270 | + | return 1 | |
| 271 | + | } | |
| 272 | + | return 0 | |
| 273 | + | }) | |
| 274 | + | page, perPage = releasePagination(page, perPage) | |
| 275 | + | start, end := releasePageBounds(page, perPage, len(all)) | |
| 276 | + | objects := make([]ordered, 0, end-start) | |
| 277 | + | for _, r := range all[start:end] { | |
| 278 | + | o, e := g.releaseObj(ctx, p, r) | |
| 279 | + | if e != nil { | |
| 280 | + | return nil, "", e | |
| 281 | + | } | |
| 282 | + | objects = append(objects, o) | |
| 283 | + | } | |
| 284 | + | b, err := json.Marshal(objects) | |
| 285 | + | return b, pullFilesLink(g.base(rn)+"/releases", page, perPage, len(all)), err | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // ListAssets lists uploaded assets with the same pagination conventions. | |
| 289 | + | func (g *ReleasesGitHub) ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) ([]byte, string, error) { | |
| 290 | + | rn, err := g.repo(owner, repo) | |
| 291 | + | if err != nil { | |
| 292 | + | return nil, "", err | |
| 293 | + | } | |
| 294 | + | r, err := g.rel.GetRelease(ctx, p, rn, ports.ReleaseRef{ID: id}) | |
| 295 | + | if err != nil { | |
| 296 | + | return nil, "", err | |
| 297 | + | } | |
| 298 | + | all, err := g.rel.ListAssets(ctx, p, rn, id) | |
| 299 | + | if err != nil { | |
| 300 | + | return nil, "", err | |
| 301 | + | } | |
| 302 | + | page, perPage = releasePagination(page, perPage) | |
| 303 | + | start, end := releasePageBounds(page, perPage, len(all)) | |
| 304 | + | objects := make([]ordered, 0, end-start) | |
| 305 | + | for _, a := range all[start:end] { | |
| 306 | + | objects = append(objects, g.assetObj(a, r.Tag)) | |
| 307 | + | } | |
| 308 | + | b, err := json.Marshal(objects) | |
| 309 | + | return b, pullFilesLink(g.base(rn)+"/releases/"+strconv.FormatInt(id, 10)+"/assets", page, perPage, len(all)), err | |
| 310 | + | } | |
| 311 | + | func (g *ReleasesGitHub) marshalAsset(ctx context.Context, p domain.Principal, a domain.ReleaseAsset) ([]byte, error) { | |
| 312 | + | r, err := g.rel.GetRelease(ctx, p, a.Repo, ports.ReleaseRef{ID: a.ReleaseID}) | |
| 313 | + | if err != nil { | |
| 314 | + | return nil, err | |
| 315 | + | } | |
| 316 | + | return json.Marshal(g.assetObj(a, r.Tag)) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // UploadAsset streams an exact-sized raw body; duplicate names are not replaced. | |
| 320 | + | func (g *ReleasesGitHub) UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, body io.Reader) ([]byte, error) { | |
| 321 | + | rn, err := g.repo(owner, repo) | |
| 322 | + | if err != nil { | |
| 323 | + | return nil, err | |
| 324 | + | } | |
| 325 | + | a, err := g.rel.UploadAsset(ctx, p, rn, ports.UploadAssetParams{ReleaseID: id, Name: name, Label: label, ContentType: contentType, Size: size}, body) | |
| 326 | + | if err != nil { | |
| 327 | + | return nil, err | |
| 328 | + | } | |
| 329 | + | return g.marshalAsset(ctx, p, a) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // AssetByID resolves a visible asset, including its repository ownership. | |
| 333 | + | func (g *ReleasesGitHub) AssetByID(ctx context.Context, p domain.Principal, owner, repo string, id int64) (domain.ReleaseAsset, error) { | |
| 334 | + | rn, err := g.repo(owner, repo) | |
| 335 | + | if err != nil { | |
| 336 | + | return domain.ReleaseAsset{}, err | |
| 337 | + | } | |
| 338 | + | return g.rel.GetAsset(ctx, p, rn, ports.AssetRef{ID: id}) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // GetAsset returns the JSON representation, not the contents. | |
| 342 | + | func (g *ReleasesGitHub) GetAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) ([]byte, error) { | |
| 343 | + | a, err := g.AssetByID(ctx, p, owner, repo, id) | |
| 344 | + | if err != nil { | |
| 345 | + | return nil, err | |
| 346 | + | } | |
| 347 | + | return g.marshalAsset(ctx, p, a) | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // UpdateAsset changes the name and label fields that are present. | |
| 351 | + | func (g *ReleasesGitHub) UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) { | |
| 352 | + | rn, err := g.repo(owner, repo) | |
| 353 | + | if err != nil { | |
| 354 | + | return nil, err | |
| 355 | + | } | |
| 356 | + | f, err := releaseFields(body) | |
| 357 | + | if err != nil { | |
| 358 | + | return nil, err | |
| 359 | + | } | |
| 360 | + | name, err := releaseField[string](f, "name") | |
| 361 | + | if err != nil { | |
| 362 | + | return nil, err | |
| 363 | + | } | |
| 364 | + | label, err := releaseField[string](f, "label") | |
| 365 | + | if err != nil { | |
| 366 | + | return nil, err | |
| 367 | + | } | |
| 368 | + | a, err := g.rel.UpdateAsset(ctx, p, rn, id, ports.UpdateAssetParams{Name: name, Label: label}) | |
| 369 | + | if err != nil { | |
| 370 | + | return nil, err | |
| 371 | + | } | |
| 372 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset updated") | |
| 373 | + | return g.marshalAsset(ctx, p, a) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // DeleteAsset removes the asset record and its blob. | |
| 377 | + | func (g *ReleasesGitHub) DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) error { | |
| 378 | + | rn, err := g.repo(owner, repo) | |
| 379 | + | if err != nil { | |
| 380 | + | return err | |
| 381 | + | } | |
| 382 | + | if err := g.rel.DeleteAsset(ctx, p, rn, id); err != nil { | |
| 383 | + | return err | |
| 384 | + | } | |
| 385 | + | ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset deleted") | |
| 386 | + | return nil | |
| 387 | + | } | |
A internal/usecase/releases_github_test.go +360 -0
| @@ -0,0 +1,360 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "math" | |
| 11 | + | "net/http" | |
| 12 | + | "net/http/httptest" | |
| 13 | + | "os" | |
| 14 | + | "path/filepath" | |
| 15 | + | "strconv" | |
| 16 | + | "strings" | |
| 17 | + | "testing" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/adapters/githubapi" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 23 | + | "bnema.dev/ermite/internal/usecase" | |
| 24 | + | "github.com/stretchr/testify/mock" | |
| 25 | + | "github.com/stretchr/testify/require" | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | func releaseGitHub(t *testing.T, r *releaseRig) *usecase.ReleasesGitHub { | |
| 29 | + | t.Helper() | |
| 30 | + | return usecase.NewReleasesGitHub(r.svc, "acme", "http://localhost:7341/") | |
| 31 | + | } | |
| 32 | + | func releaseGolden(t *testing.T, file string, body []byte) { | |
| 33 | + | t.Helper() | |
| 34 | + | var pretty bytes.Buffer | |
| 35 | + | require.NoError(t, json.Indent(&pretty, body, "", " ")) | |
| 36 | + | pretty.WriteByte('\n') | |
| 37 | + | path := filepath.Join("..", "adapters", "githubapi", "testdata", "releases", file+".json") | |
| 38 | + | if os.Getenv("UPDATE_RELEASE_GOLDENS") == "1" { | |
| 39 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) | |
| 40 | + | require.NoError(t, os.WriteFile(path, pretty.Bytes(), 0644)) | |
| 41 | + | } | |
| 42 | + | want, err := os.ReadFile(path) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.Equal(t, string(want), pretty.String()) | |
| 45 | + | } | |
| 46 | + | func TestGitHubReleaseGoldens(t *testing.T) { | |
| 47 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 48 | + | g := releaseGitHub(t, r) | |
| 49 | + | rel := r.create(t, "v1", false) | |
| 50 | + | for _, name := range []string{"ermite.tar.gz", "checksums.txt"} { | |
| 51 | + | _, err := g.UploadAsset(r.ctx, domain.Root(), "acme", r.repo.String(), rel.ID, name, "", "application/octet-stream", 3, strings.NewReader("abc")) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | } | |
| 54 | + | body, err := g.GetRelease(r.ctx, domain.Anonymous(), "ACME", "RELEASES", ports.ReleaseRef{Tag: "v1"}) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | releaseGolden(t, "release", body) | |
| 57 | + | draft := r.create(t, "next", true) | |
| 58 | + | body, err = g.GetRelease(r.ctx, domain.Root(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID}) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | releaseGolden(t, "draft", body) | |
| 61 | + | _, err = g.GetRelease(r.ctx, domain.Anonymous(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID}) | |
| 62 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 63 | + | } | |
| 64 | + | func TestGitHubReleaseRequests(t *testing.T) { | |
| 65 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 66 | + | g := releaseGitHub(t, r) | |
| 67 | + | repo := r.repo.String() | |
| 68 | + | p := domain.Root() | |
| 69 | + | r.resolve(t, "v1", nil) | |
| 70 | + | body, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1","name":"Title","body":"Notes","draft":true,"prerelease":true,"discussion_category_name":"ignored","make_latest":"legacy"}`)) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | var rel struct { | |
| 73 | + | ID int64 `json:"id"` | |
| 74 | + | } | |
| 75 | + | require.NoError(t, json.Unmarshal(body, &rel)) | |
| 76 | + | body, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"name":"","draft":false,"prerelease":false,"make_latest":"true","unknown":1}`)) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | var got map[string]any | |
| 79 | + | require.NoError(t, json.Unmarshal(body, &got)) | |
| 80 | + | require.Equal(t, "", got["name"]) | |
| 81 | + | require.Equal(t, "Notes", got["body"]) | |
| 82 | + | require.Equal(t, false, got["draft"]) | |
| 83 | + | require.Equal(t, false, got["prerelease"]) | |
| 84 | + | require.NotContains(t, got, "make_latest") | |
| 85 | + | stored, err := r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID}) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | require.True(t, stored.MakeLatest) | |
| 88 | + | _, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"legacy"}`)) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID}) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | require.True(t, stored.MakeLatest) | |
| 93 | + | _, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"false"}`)) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID}) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | require.False(t, stored.MakeLatest) | |
| 98 | + | for _, bad := range []string{`{}`, `null`, `[]`, `{`, `{"tag_name":2}`, `{"tag_name":"v1","draft":"true"}`, `{"tag_name":"v1","name":null}`, `{"tag_name":"v1","make_latest":true}`, `{"tag_name":"v1","make_latest":"yes"}`} { | |
| 99 | + | _, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(bad)) | |
| 100 | + | require.ErrorIs(t, err, domain.ErrInvalid, bad) | |
| 101 | + | } | |
| 102 | + | _, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1"}`)) | |
| 103 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 104 | + | _, err = g.GetRelease(r.ctx, p, "other", repo, ports.ReleaseRef{ID: rel.ID}) | |
| 105 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 106 | + | _, err = g.GetRelease(r.ctx, p, "acme", "bad/name", ports.ReleaseRef{ID: rel.ID}) | |
| 107 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 108 | + | } | |
| 109 | + | func TestGitHubReleasePagination(t *testing.T) { | |
| 110 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 111 | + | g := releaseGitHub(t, r) | |
| 112 | + | for i := 0; i < 5; i++ { | |
| 113 | + | r.create(t, "v"+strconv.Itoa(i), i == 4) | |
| 114 | + | } | |
| 115 | + | for page := 1; page <= 3; page++ { | |
| 116 | + | b, link, err := g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | var rows []struct { | |
| 119 | + | ID int64 `json:"id"` | |
| 120 | + | } | |
| 121 | + | require.NoError(t, json.Unmarshal(b, &rows)) | |
| 122 | + | require.Len(t, rows, min(2, 5-(page-1)*2)) | |
| 123 | + | require.Equal(t, int64(5-(page-1)*2), rows[0].ID) | |
| 124 | + | base := "http://localhost:7341/-/github/api/repos/acme/releases/releases?per_page=2&page=" | |
| 125 | + | switch page { | |
| 126 | + | case 1: | |
| 127 | + | require.Equal(t, `<`+base+`2>; rel="next", <`+base+`3>; rel="last"`, link) | |
| 128 | + | case 2: | |
| 129 | + | require.Equal(t, `<`+base+`3>; rel="next", <`+base+`3>; rel="last", <`+base+`1>; rel="first", <`+base+`1>; rel="prev"`, link) | |
| 130 | + | case 3: | |
| 131 | + | require.Equal(t, `<`+base+`1>; rel="first", <`+base+`2>; rel="prev"`, link) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | b, _, err := g.ListReleases(r.ctx, domain.Anonymous(), "acme", r.repo.String(), 0, 0) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | var rows []any | |
| 137 | + | require.NoError(t, json.Unmarshal(b, &rows)) | |
| 138 | + | require.Len(t, rows, 4) | |
| 139 | + | for _, page := range []int{4, math.MaxInt} { | |
| 140 | + | b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | require.Equal(t, "[]", string(b)) | |
| 143 | + | } | |
| 144 | + | b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), 1, 1000) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | require.NoError(t, json.Unmarshal(b, &rows)) | |
| 147 | + | require.Len(t, rows, 5) | |
| 148 | + | } | |
| 149 | + | func TestGitHubReleaseAssetsAndRetry(t *testing.T) { | |
| 150 | + | r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10}) | |
| 151 | + | g := releaseGitHub(t, r) | |
| 152 | + | p := domain.Root() | |
| 153 | + | repo := r.repo.String() | |
| 154 | + | rel := r.create(t, "v1/slash", false) | |
| 155 | + | upload := func(name string) ([]byte, error) { | |
| 156 | + | return g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, name, "label", "text/plain", 3, strings.NewReader("abc")) | |
| 157 | + | } | |
| 158 | + | b, err := upload("é #?.txt") | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | var asset struct { | |
| 161 | + | ID int64 `json:"id"` | |
| 162 | + | URL string `json:"browser_download_url"` | |
| 163 | + | } | |
| 164 | + | require.NoError(t, json.Unmarshal(b, &asset)) | |
| 165 | + | require.Equal(t, "http://localhost:7341/releases/releases/download/v1%2Fslash/%C3%A9%20%23%3F.txt", asset.URL) | |
| 166 | + | _, err = upload("é #?.txt") | |
| 167 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 168 | + | b, err = g.UpdateAsset(r.ctx, p, "acme", repo, asset.ID, []byte(`{"label":""}`)) | |
| 169 | + | require.NoError(t, err) | |
| 170 | + | require.Contains(t, string(b), `"label":""`) | |
| 171 | + | require.Contains(t, string(b), `"name":"é #?.txt"`) | |
| 172 | + | b, err = g.GetAsset(r.ctx, domain.Anonymous(), "acme", repo, asset.ID) | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | require.Contains(t, string(b), `"state":"uploaded"`) | |
| 175 | + | for i := 0; i < 2; i++ { | |
| 176 | + | _, err = upload(fmt.Sprint(i)) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | } | |
| 179 | + | b, link, err := g.ListAssets(r.ctx, p, "acme", repo, rel.ID, 1, 2) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | require.Contains(t, link, `rel="next"`) | |
| 182 | + | var assets []any | |
| 183 | + | require.NoError(t, json.Unmarshal(b, &assets)) | |
| 184 | + | require.Len(t, assets, 2) | |
| 185 | + | require.NoError(t, g.DeleteAsset(r.ctx, p, "acme", repo, asset.ID)) | |
| 186 | + | _, err = g.AssetByID(r.ctx, p, "acme", repo, asset.ID) | |
| 187 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 188 | + | _, err = upload("é #?.txt") | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | _, err = g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, "huge", "", "", 11, strings.NewReader("ignored")) | |
| 191 | + | require.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 192 | + | require.NoError(t, g.DeleteRelease(r.ctx, p, "acme", repo, rel.ID)) | |
| 193 | + | _, err = g.GetRelease(r.ctx, p, "acme", repo, ports.ReleaseRef{ID: rel.ID}) | |
| 194 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestGitHubReleaseAuthorizationAndLatest(t *testing.T) { | |
| 198 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 199 | + | g := releaseGitHub(t, r) | |
| 200 | + | repo := r.repo.String() | |
| 201 | + | stable := r.create(t, "v1", false) | |
| 202 | + | r.create(t, "draft", true) | |
| 203 | + | b, err := g.GetRelease(r.ctx, domain.Anonymous(), "acme", repo, ports.ReleaseRef{Latest: true}) | |
| 204 | + | require.NoError(t, err) | |
| 205 | + | var got struct { | |
| 206 | + | ID int64 `json:"id"` | |
| 207 | + | } | |
| 208 | + | require.NoError(t, json.Unmarshal(b, &got)) | |
| 209 | + | require.Equal(t, stable.ID, got.ID) | |
| 210 | + | writer := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionWrite}}) | |
| 211 | + | reader := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}}) | |
| 212 | + | branched := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, BranchPrefixes: []string{"feature/"}}) | |
| 213 | + | for _, tc := range []struct { | |
| 214 | + | p domain.Principal | |
| 215 | + | tag string | |
| 216 | + | want error | |
| 217 | + | }{ | |
| 218 | + | {domain.Anonymous(), "anonymous", domain.ErrUnauthenticated}, | |
| 219 | + | {reader, "reader", domain.ErrForbidden}, | |
| 220 | + | {branched, "branch", domain.ErrForbidden}, | |
| 221 | + | {writer, "job", nil}, | |
| 222 | + | } { | |
| 223 | + | if tc.want == nil { | |
| 224 | + | r.resolve(t, tc.tag, nil) | |
| 225 | + | } | |
| 226 | + | b, err = g.CreateRelease(r.ctx, tc.p, "acme", repo, []byte(`{"tag_name":"`+tc.tag+`"}`)) | |
| 227 | + | if tc.want != nil { | |
| 228 | + | require.ErrorIs(t, err, tc.want) | |
| 229 | + | continue | |
| 230 | + | } | |
| 231 | + | require.NoError(t, err) | |
| 232 | + | require.NoError(t, json.Unmarshal(b, &got)) | |
| 233 | + | _, err = g.UploadAsset(r.ctx, tc.p, "acme", repo, got.ID, "ci.tar.gz", "", "", 3, strings.NewReader("abc")) | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | _, err = g.UploadAsset(r.ctx, reader, "acme", repo, got.ID, "read.tar.gz", "", "", 3, strings.NewReader("abc")) | |
| 236 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 237 | + | } | |
| 238 | + | other, err := domain.ParseRepoName("other") | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | require.NoError(t, r.store.Create(r.ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: now})) | |
| 241 | + | b, _, err = g.ListAssets(r.ctx, domain.Root(), "acme", other.String(), stable.ID, 1, 30) | |
| 242 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 243 | + | require.Nil(t, b) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestGitHubReleaseTargetAndMissingTag(t *testing.T) { | |
| 247 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 248 | + | g := releaseGitHub(t, r) | |
| 249 | + | p := domain.Root() | |
| 250 | + | repo := r.repo.String() | |
| 251 | + | r.resolve(t, "missing", domain.ErrNotFound) | |
| 252 | + | _, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing"}`)) | |
| 253 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 254 | + | b, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing","draft":true}`)) | |
| 255 | + | require.NoError(t, err) | |
| 256 | + | var got struct { | |
| 257 | + | ID int64 `json:"id"` | |
| 258 | + | Target string `json:"target_commitish"` | |
| 259 | + | } | |
| 260 | + | require.NoError(t, json.Unmarshal(b, &got)) | |
| 261 | + | require.Empty(t, got.Target) | |
| 262 | + | _, err = g.UpdateRelease(r.ctx, p, "acme", repo, got.ID, []byte(`{"draft":false}`)) | |
| 263 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 264 | + | r.resolve(t, "new", domain.ErrNotFound) | |
| 265 | + | v, err := domain.ParseRevision("main") | |
| 266 | + | require.NoError(t, err) | |
| 267 | + | oid, err := domain.ParseObjectID(oidA) | |
| 268 | + | require.NoError(t, err) | |
| 269 | + | r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once() | |
| 270 | + | r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once() | |
| 271 | + | r.pushes.EXPECT().Record(r.ctx, p, r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once() | |
| 272 | + | b, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"new","target_commitish":"main"}`)) | |
| 273 | + | require.NoError(t, err) | |
| 274 | + | require.NoError(t, json.Unmarshal(b, &got)) | |
| 275 | + | require.Equal(t, oidA, got.Target) | |
| 276 | + | ||
| 277 | + | // PATCH with target_commitish publishes a draft whose tag is missing. | |
| 278 | + | r.resolve(t, "patched", domain.ErrNotFound) | |
| 279 | + | b, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"patched","draft":true}`)) | |
| 280 | + | require.NoError(t, err) | |
| 281 | + | require.NoError(t, json.Unmarshal(b, &got)) | |
| 282 | + | r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once() | |
| 283 | + | r.tags.EXPECT().CreateTag(r.ctx, r.repo, "patched", oid).Return(nil).Once() | |
| 284 | + | r.pushes.EXPECT().Record(r.ctx, p, r.repo, []domain.RefUpdate{{Ref: "refs/tags/patched", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once() | |
| 285 | + | b, err = g.UpdateRelease(r.ctx, p, "acme", repo, got.ID, []byte(`{"draft":false,"target_commitish":"main"}`)) | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | require.Contains(t, string(b), `"draft":false`) | |
| 288 | + | require.Contains(t, string(b), `"target_commitish":"`+oidA+`"`) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // A real HTTP round trip through the facade, metadata store and disk blob | |
| 292 | + | // adapter protects the goreleaser/softprops create, retry, overwrite, publish | |
| 293 | + | // sequence, not just each side's mocked contract. | |
| 294 | + | func TestGitHubReleaseHTTPWorkflow(t *testing.T) { | |
| 295 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 296 | + | g := releaseGitHub(t, r) | |
| 297 | + | auth := mocks.NewAuthService(t) | |
| 298 | + | auth.EXPECT().Authenticate(mock.Anything, "dev-token").Return(domain.Root(), nil).Maybe() | |
| 299 | + | srv := httptest.NewServer(githubapi.New(githubapi.Config{Auth: auth, Releases: g, ReleaseDownload: r.svc})) | |
| 300 | + | defer srv.Close() | |
| 301 | + | do := func(method, path, body string, headers map[string]string) (*http.Response, string) { | |
| 302 | + | req, err := http.NewRequestWithContext(r.ctx, method, srv.URL+githubapi.Prefix+path, strings.NewReader(body)) | |
| 303 | + | require.NoError(t, err) | |
| 304 | + | req.Header.Set("Authorization", "Bearer dev-token") | |
| 305 | + | for k, v := range headers { | |
| 306 | + | req.Header.Set(k, v) | |
| 307 | + | } | |
| 308 | + | resp, err := srv.Client().Do(req) | |
| 309 | + | require.NoError(t, err) | |
| 310 | + | defer resp.Body.Close() | |
| 311 | + | b, err := io.ReadAll(resp.Body) | |
| 312 | + | require.NoError(t, err) | |
| 313 | + | return resp, string(b) | |
| 314 | + | } | |
| 315 | + | base := "/repos/acme/" + r.repo.String() + "/releases" | |
| 316 | + | resp, _ := do("GET", base+"/tags/v1", "", nil) | |
| 317 | + | require.Equal(t, 404, resp.StatusCode) | |
| 318 | + | r.resolve(t, "v1", nil) | |
| 319 | + | resp, b := do("POST", base, `{"tag_name":"v1","draft":true}`, nil) | |
| 320 | + | require.Equal(t, 201, resp.StatusCode) | |
| 321 | + | var rel struct { | |
| 322 | + | ID int64 `json:"id"` | |
| 323 | + | UploadURL string `json:"upload_url"` | |
| 324 | + | } | |
| 325 | + | require.NoError(t, json.Unmarshal([]byte(b), &rel)) | |
| 326 | + | upload := strings.TrimPrefix(strings.Split(rel.UploadURL, "{")[0], "http://localhost:7341"+githubapi.Prefix) + "?name=archive.tar.gz" | |
| 327 | + | resp, b = do("POST", upload, "0123456789", map[string]string{"Content-Type": "application/gzip"}) | |
| 328 | + | require.Equal(t, 201, resp.StatusCode) | |
| 329 | + | var a struct { | |
| 330 | + | ID int64 `json:"id"` | |
| 331 | + | } | |
| 332 | + | require.NoError(t, json.Unmarshal([]byte(b), &a)) | |
| 333 | + | resp, b = do("POST", upload, "replacement", nil) | |
| 334 | + | require.Equal(t, 422, resp.StatusCode) | |
| 335 | + | require.Contains(t, b, `"code":"already_exists"`) | |
| 336 | + | resp, b = do("GET", base+"/"+strconv.FormatInt(rel.ID, 10)+"/assets", "", nil) | |
| 337 | + | require.Equal(t, 200, resp.StatusCode) | |
| 338 | + | require.Contains(t, b, `"name":"archive.tar.gz"`) | |
| 339 | + | assetURL := base + "/assets/" + strconv.FormatInt(a.ID, 10) | |
| 340 | + | resp, _ = do("DELETE", assetURL, "", nil) | |
| 341 | + | require.Equal(t, 204, resp.StatusCode) | |
| 342 | + | resp, b = do("POST", upload, "abcdefghij", nil) | |
| 343 | + | require.Equal(t, 201, resp.StatusCode) | |
| 344 | + | require.NoError(t, json.Unmarshal([]byte(b), &a)) | |
| 345 | + | resp, _ = do("PATCH", base+"/"+strconv.FormatInt(rel.ID, 10), `{"draft":false}`, nil) | |
| 346 | + | require.Equal(t, 200, resp.StatusCode) | |
| 347 | + | assetURL = base + "/assets/" + strconv.FormatInt(a.ID, 10) | |
| 348 | + | resp, b = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5"}) | |
| 349 | + | require.Equal(t, 206, resp.StatusCode) | |
| 350 | + | require.Equal(t, "cdef", b) | |
| 351 | + | require.Equal(t, "bytes 2-5/10", resp.Header.Get("Content-Range")) | |
| 352 | + | require.Equal(t, "4", resp.Header.Get("Content-Length")) | |
| 353 | + | require.Empty(t, resp.Header.Get("Location")) | |
| 354 | + | resp, b = do("HEAD", assetURL, "", map[string]string{"Accept": "application/octet-stream"}) | |
| 355 | + | require.Equal(t, 200, resp.StatusCode) | |
| 356 | + | require.Empty(t, b) | |
| 357 | + | require.Equal(t, "10", resp.Header.Get("Content-Length")) | |
| 358 | + | resp, _ = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "If-None-Match": resp.Header.Get("ETag")}) | |
| 359 | + | require.Equal(t, 304, resp.StatusCode) | |
| 360 | + | } | |
A internal/usecase/releases_integration_test.go +336 -0
| @@ -0,0 +1,336 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "testing" | |
| 9 | + | "testing/synctest" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/adapters/blobdisk" | |
| 12 | + | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 13 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | "bnema.dev/ermite/internal/usecase" | |
| 18 | + | "github.com/stretchr/testify/mock" | |
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | type releaseRig struct { | |
| 23 | + | ctx context.Context | |
| 24 | + | repo domain.RepoName | |
| 25 | + | store *kvstore.Store | |
| 26 | + | blobs ports.BlobStore | |
| 27 | + | reader *mocks.RepoReader | |
| 28 | + | tags *mocks.ReleaseTagWriter | |
| 29 | + | pushes *mocks.PushHookService | |
| 30 | + | clock *mocks.Clock | |
| 31 | + | events <-chan domain.Event | |
| 32 | + | svc *usecase.Releases | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func newReleaseRig(t *testing.T, limits ports.ReleaseLimits) *releaseRig { | |
| 36 | + | t.Helper() | |
| 37 | + | ctx := t.Context() | |
| 38 | + | store, err := kvstore.Open(ctx, t.TempDir()) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) }) | |
| 41 | + | n := name(t, "releases") | |
| 42 | + | require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now})) | |
| 43 | + | clock := mocks.NewClock(t) | |
| 44 | + | clock.EXPECT().Now().Return(now).Maybe() | |
| 45 | + | reader := mocks.NewRepoReader(t) | |
| 46 | + | tags := mocks.NewReleaseTagWriter(t) | |
| 47 | + | pushes := mocks.NewPushHookService(t) | |
| 48 | + | bus := eventbus.NewWithBuffer(1024) | |
| 49 | + | b := blobdisk.New(t.TempDir()) | |
| 50 | + | r := &releaseRig{ctx: ctx, repo: n, store: store, blobs: b, reader: reader, tags: tags, pushes: pushes, clock: clock, events: bus.Subscribe(ctx)} | |
| 51 | + | r.svc = usecase.NewReleases(store.Releases(), b, store, reader, tags, pushes, bus, clock, limits) | |
| 52 | + | return r | |
| 53 | + | } | |
| 54 | + | func (r *releaseRig) resolve(t *testing.T, tag string, err error) { | |
| 55 | + | t.Helper() | |
| 56 | + | v, e := domain.ParseRevision("refs/tags/" + tag) | |
| 57 | + | require.NoError(t, e) | |
| 58 | + | oid, e := domain.ParseObjectID(oidA) | |
| 59 | + | require.NoError(t, e) | |
| 60 | + | r.reader.EXPECT().ResolveRevision(mock.Anything, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, err).Maybe() | |
| 61 | + | } | |
| 62 | + | func (r *releaseRig) create(t *testing.T, tag string, draft bool) domain.Release { | |
| 63 | + | t.Helper() | |
| 64 | + | r.resolve(t, tag, nil) | |
| 65 | + | rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: tag, Draft: draft}) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | return rel | |
| 68 | + | } | |
| 69 | + | func TestReleaseAccessMatrix(t *testing.T) { | |
| 70 | + | for _, private := range []bool{false, true} { | |
| 71 | + | t.Run(fmt.Sprint("private=", private), func(t *testing.T) { | |
| 72 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 73 | + | published := r.create(t, "v1", false) | |
| 74 | + | draft := r.create(t, "draft", true) | |
| 75 | + | if private { | |
| 76 | + | _, err := r.store.Mutate(r.ctx, r.repo, func(repo *domain.Repo) (bool, error) { repo.Visibility = domain.VisibilityPrivate; return true, nil }) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | } | |
| 79 | + | token := func(scopes []domain.Scope, branches ...string) domain.Principal { | |
| 80 | + | return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, BranchPrefixes: branches}) | |
| 81 | + | } | |
| 82 | + | for _, tc := range []struct { | |
| 83 | + | name string | |
| 84 | + | p domain.Principal | |
| 85 | + | read, write bool | |
| 86 | + | }{ | |
| 87 | + | {"anonymous", domain.Anonymous(), !private, false}, | |
| 88 | + | {"reader", token([]domain.Scope{domain.ScopeRepoRead}), true, false}, | |
| 89 | + | {"writer", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}), true, true}, | |
| 90 | + | {"write-only", token([]domain.Scope{domain.ScopeReleaseWrite}), !private, !private}, | |
| 91 | + | {"branch", token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, "x/"), true, false}, | |
| 92 | + | {"job-read", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "read"}}), true, false}, | |
| 93 | + | {"job-write", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: r.repo, Permissions: map[string]string{"contents": "write"}}), true, true}, | |
| 94 | + | {"owner", domain.Owner(), true, true}, | |
| 95 | + | } { | |
| 96 | + | t.Run(tc.name, func(t *testing.T) { | |
| 97 | + | _, err := r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: published.ID}) | |
| 98 | + | if tc.read { | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | } else { | |
| 101 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 102 | + | } | |
| 103 | + | _, err = r.svc.GetRelease(r.ctx, tc.p, r.repo, ports.ReleaseRef{ID: draft.ID}) | |
| 104 | + | if tc.write { | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | } else { | |
| 107 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 108 | + | } | |
| 109 | + | title := "changed" | |
| 110 | + | _, err = r.svc.UpdateRelease(r.ctx, tc.p, r.repo, published.ID, ports.UpdateReleaseParams{Title: &title}) | |
| 111 | + | switch { | |
| 112 | + | case tc.write: | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | case !tc.read: | |
| 115 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 116 | + | case tc.p.IsAnonymous(): | |
| 117 | + | require.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 118 | + | default: | |
| 119 | + | require.ErrorIs(t, err, domain.ErrForbidden) | |
| 120 | + | } | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | }) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | func TestReleaseLifecycle(t *testing.T) { | |
| 127 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 128 | + | first := r.create(t, "v1", false) | |
| 129 | + | second := r.create(t, "v2", false) | |
| 130 | + | r.create(t, "draft", true) | |
| 131 | + | got, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true}) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | require.Equal(t, second.ID, got.ID) | |
| 134 | + | yes := true | |
| 135 | + | _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, first.ID, ports.UpdateReleaseParams{MakeLatest: &yes}) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true}) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | require.Equal(t, first.ID, got.ID) | |
| 140 | + | _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "v1"}) | |
| 141 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 142 | + | page, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, "") | |
| 143 | + | require.NoError(t, err) | |
| 144 | + | require.Len(t, page.Releases, 1) | |
| 145 | + | require.NotEmpty(t, page.NextPageToken) | |
| 146 | + | next, err := r.svc.ListReleases(r.ctx, domain.Anonymous(), r.repo, 1, page.NextPageToken) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | require.Equal(t, first.ID, next.Releases[0].ID) | |
| 149 | + | require.Empty(t, next.NextPageToken) | |
| 150 | + | r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: domain.ZeroOID}}) | |
| 151 | + | got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID}) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | require.True(t, got.Draft) | |
| 154 | + | require.True(t, got.PublishedAt.IsZero()) | |
| 155 | + | require.False(t, got.MakeLatest) | |
| 156 | + | r.svc.OnRefsMoved(r.ctx, r.repo, []domain.RefUpdate{{Ref: "refs/tags/v1", New: oidA}}) | |
| 157 | + | got, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID}) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | require.True(t, got.Draft) | |
| 160 | + | var kinds []string | |
| 161 | + | for len(r.events) > 0 { | |
| 162 | + | kinds = append(kinds, (<-r.events).EventKind()) | |
| 163 | + | } | |
| 164 | + | require.Contains(t, kinds, "release.unpublished") | |
| 165 | + | require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, first.ID)) | |
| 166 | + | _, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: first.ID}) | |
| 167 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 168 | + | } | |
| 169 | + | func TestReleaseMissingTag(t *testing.T) { | |
| 170 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 171 | + | r.resolve(t, "missing", domain.ErrNotFound) | |
| 172 | + | draft, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "missing", Draft: true}) | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | require.Empty(t, draft.TargetCommitish) | |
| 175 | + | no := false | |
| 176 | + | _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no}) | |
| 177 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 178 | + | r.resolve(t, "new", domain.ErrNotFound) | |
| 179 | + | _, err = r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new"}) | |
| 180 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 181 | + | v, err := domain.ParseRevision("main") | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | oid, err := domain.ParseObjectID(oidA) | |
| 184 | + | require.NoError(t, err) | |
| 185 | + | r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once() | |
| 186 | + | r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once() | |
| 187 | + | r.pushes.EXPECT().Record(r.ctx, domain.Root(), r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once() | |
| 188 | + | rel, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "new", TargetCommitish: "main"}) | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | require.Equal(t, oidA, rel.TargetCommitish) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func TestReleaseLatestPinsAndPrereleases(t *testing.T) { | |
| 194 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 195 | + | a := r.create(t, "a", false) | |
| 196 | + | b := r.create(t, "b", false) | |
| 197 | + | yes := true | |
| 198 | + | _, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, b.ID, ports.UpdateReleaseParams{Prerelease: &yes}) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | latest, err := r.svc.GetRelease(r.ctx, domain.Anonymous(), r.repo, ports.ReleaseRef{Latest: true}) | |
| 201 | + | require.NoError(t, err) | |
| 202 | + | require.Equal(t, a.ID, latest.ID) | |
| 203 | + | _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, a.ID, ports.UpdateReleaseParams{MakeLatest: &yes}) | |
| 204 | + | require.NoError(t, err) | |
| 205 | + | r.resolve(t, "c", nil) | |
| 206 | + | c, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "c", MakeLatest: true}) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | a, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{ID: a.ID}) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | require.False(t, a.MakeLatest) | |
| 211 | + | target := "b" | |
| 212 | + | _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, c.ID, ports.UpdateReleaseParams{Tag: &target}) | |
| 213 | + | require.ErrorIs(t, err, domain.ErrAlreadyExists) | |
| 214 | + | require.NoError(t, r.svc.DeleteRelease(r.ctx, domain.Root(), r.repo, c.ID)) | |
| 215 | + | latest, err = r.svc.GetRelease(r.ctx, domain.Root(), r.repo, ports.ReleaseRef{Latest: true}) | |
| 216 | + | require.NoError(t, err) | |
| 217 | + | require.Equal(t, a.ID, latest.ID) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestReleaseEventsReachHomeAndRepositoryTopics(t *testing.T) { | |
| 221 | + | synctest.Test(t, func(t *testing.T) { | |
| 222 | + | r := newEventRig(t, 0) | |
| 223 | + | pub := r.repo("pub", domain.VisibilityPublic) | |
| 224 | + | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 225 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 226 | + | defer cancel() | |
| 227 | + | anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"home"}}) | |
| 228 | + | owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}}) | |
| 229 | + | synctest.Wait() | |
| 230 | + | for _, n := range []domain.RepoName{priv, pub} { | |
| 231 | + | vis := domain.VisibilityPublic | |
| 232 | + | if n == priv { | |
| 233 | + | vis = domain.VisibilityPrivate | |
| 234 | + | } | |
| 235 | + | rel := domain.Release{ID: 1, Repo: n, Tag: "v1"} | |
| 236 | + | r.feed <- domain.ReleasePublished{Release: rel, At: now, CreatedAt: now, Visibility: vis} | |
| 237 | + | r.feed <- domain.ReleaseUpdated{Release: rel, At: now, CreatedAt: now, Visibility: vis} | |
| 238 | + | r.feed <- domain.ReleaseDeleted{Release: rel, At: now, CreatedAt: now, Visibility: vis} | |
| 239 | + | r.feed <- domain.ReleaseUnpublished{Release: rel, At: now, CreatedAt: now, Visibility: vis} | |
| 240 | + | } | |
| 241 | + | for _, w := range []*watcher{anon, owner} { | |
| 242 | + | for _, kind := range []string{"release.published", "release.updated", "release.deleted", "release.unpublished"} { | |
| 243 | + | require.Equal(t, kind, w.next(t).Event.EventKind()) | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | anon.quiet(t, r, pub) | |
| 247 | + | owner.quiet(t, r, priv) | |
| 248 | + | }) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func TestReleaseWriteRetriesUsageConflict(t *testing.T) { | |
| 252 | + | ctx := t.Context() | |
| 253 | + | repo := name(t, "retry") | |
| 254 | + | store := mocks.NewReleaseStore(t) | |
| 255 | + | meta := mocks.NewRepoMetaStore(t) | |
| 256 | + | reader := mocks.NewRepoReader(t) | |
| 257 | + | clock := mocks.NewClock(t) | |
| 258 | + | bus := mocks.NewEventBus(t) | |
| 259 | + | meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic, CreatedAt: now}, nil) | |
| 260 | + | clock.EXPECT().Now().Return(now).Maybe() | |
| 261 | + | rev, err := domain.ParseRevision("refs/tags/v1") | |
| 262 | + | require.NoError(t, err) | |
| 263 | + | reader.EXPECT().ResolveRevision(ctx, repo, rev).Return(domain.ResolvedRevision{}, domain.ErrNotFound) | |
| 264 | + | store.EXPECT().GetReleaseByTag(ctx, repo, "v1").Return(domain.Release{}, domain.ErrNotFound) | |
| 265 | + | store.EXPECT().NextReleaseID(ctx).Return(int64(1), nil) | |
| 266 | + | store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(1), nil).Once() | |
| 267 | + | store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 1 && len(w.PutReleases) == 1 })).Return(domain.ErrConflict).Once() | |
| 268 | + | store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once() | |
| 269 | + | store.EXPECT().ApplyReleases(ctx, mock.MatchedBy(func(w ports.ReleaseWrite) bool { return w.UsageVersion == 2 && len(w.PutReleases) == 1 })).Return(nil).Once() | |
| 270 | + | store.EXPECT().GetRelease(ctx, repo, int64(1)).Return(domain.Release{ID: 1, Repo: repo, Tag: "v1", Draft: true}, nil) | |
| 271 | + | svc := usecase.NewReleases(store, nil, meta, reader, nil, nil, bus, clock, ports.ReleaseLimits{}) | |
| 272 | + | rel, err := svc.CreateRelease(ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v1", Draft: true}) | |
| 273 | + | require.NoError(t, err) | |
| 274 | + | require.EqualValues(t, 1, rel.ID) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func TestReleaseListHidesDraftCursors(t *testing.T) { | |
| 278 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 279 | + | pub := r.create(t, "v1", false) | |
| 280 | + | d1 := r.create(t, "d1", true) | |
| 281 | + | d2 := r.create(t, "d2", true) | |
| 282 | + | anon := domain.Anonymous() | |
| 283 | + | ||
| 284 | + | _, errDraft := r.svc.ListReleases(r.ctx, anon, r.repo, 1, fmt.Sprint(d1.ID)) | |
| 285 | + | _, errUnknown := r.svc.ListReleases(r.ctx, anon, r.repo, 1, "999999") | |
| 286 | + | require.ErrorIs(t, errUnknown, domain.ErrInvalidPageToken) | |
| 287 | + | require.Equal(t, errUnknown, errDraft) | |
| 288 | + | ||
| 289 | + | // A writer still pages through drafts, newest first. | |
| 290 | + | var got []int64 | |
| 291 | + | token := "" | |
| 292 | + | for { | |
| 293 | + | page, err := r.svc.ListReleases(r.ctx, domain.Root(), r.repo, 1, token) | |
| 294 | + | require.NoError(t, err) | |
| 295 | + | for _, rel := range page.Releases { | |
| 296 | + | got = append(got, rel.ID) | |
| 297 | + | } | |
| 298 | + | if page.NextPageToken == "" { | |
| 299 | + | break | |
| 300 | + | } | |
| 301 | + | token = page.NextPageToken | |
| 302 | + | } | |
| 303 | + | require.ElementsMatch(t, []int64{pub.ID, d1.ID, d2.ID}, got) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func TestReleaseUpdateTargetCreatesMissingTag(t *testing.T) { | |
| 307 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 308 | + | r.resolve(t, "missing", domain.ErrNotFound) | |
| 309 | + | draft, err := r.svc.CreateRelease(r.ctx, domain.Root(), r.repo, ports.CreateReleaseParams{Tag: "missing", Draft: true}) | |
| 310 | + | require.NoError(t, err) | |
| 311 | + | no := false | |
| 312 | + | _, err = r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no}) | |
| 313 | + | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 314 | + | ||
| 315 | + | v, err := domain.ParseRevision("main") | |
| 316 | + | require.NoError(t, err) | |
| 317 | + | oid, err := domain.ParseObjectID(oidA) | |
| 318 | + | require.NoError(t, err) | |
| 319 | + | r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once() | |
| 320 | + | r.tags.EXPECT().CreateTag(r.ctx, r.repo, "missing", oid).Return(nil).Once() | |
| 321 | + | r.pushes.EXPECT().Record(r.ctx, domain.Root(), r.repo, []domain.RefUpdate{{Ref: "refs/tags/missing", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once() | |
| 322 | + | main := "main" | |
| 323 | + | rel, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, draft.ID, ports.UpdateReleaseParams{Draft: &no, TargetCommitish: &main}) | |
| 324 | + | require.NoError(t, err) | |
| 325 | + | require.False(t, rel.Draft) | |
| 326 | + | require.Equal(t, oidA, rel.TargetCommitish) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func TestReleaseUpdateTargetNeverMovesExistingTag(t *testing.T) { | |
| 330 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 331 | + | rel := r.create(t, "v1", false) | |
| 332 | + | other := "other" // never resolved: the mock would fail the test | |
| 333 | + | got, err := r.svc.UpdateRelease(r.ctx, domain.Root(), r.repo, rel.ID, ports.UpdateReleaseParams{TargetCommitish: &other}) | |
| 334 | + | require.NoError(t, err) | |
| 335 | + | require.Equal(t, oidA, got.TargetCommitish) | |
| 336 | + | } | |
A internal/usecase/releases_sweep.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Sweep collects abandoned uploads, missing contents, and old orphan blobs. | |
| 16 | + | // A failed metadata scan never authorizes blob deletion. Sweeps are serialized | |
| 17 | + | // so the two observations of a missing blob are always distinct sweeps. | |
| 18 | + | func (r *Releases) Sweep(ctx context.Context) { | |
| 19 | + | r.sweepMu.Lock() | |
| 20 | + | defer r.sweepMu.Unlock() | |
| 21 | + | log := ctxLog(ctx) | |
| 22 | + | now := r.clock.Now() | |
| 23 | + | all, err := r.store.AllAssets(ctx) | |
| 24 | + | if err != nil { | |
| 25 | + | log.Error().Err(err).Msg("scan release assets") | |
| 26 | + | return | |
| 27 | + | } | |
| 28 | + | seen := make(map[int64]bool, len(all)) | |
| 29 | + | for _, a := range all { | |
| 30 | + | seen[a.ID] = true | |
| 31 | + | remove := false | |
| 32 | + | if a.State == domain.ReleaseAssetUploading { | |
| 33 | + | remove = now.Sub(a.UpdatedAt) > domain.ReleaseAssetPendingMax | |
| 34 | + | } else if a.State == domain.ReleaseAssetUploaded { | |
| 35 | + | _, err := r.blobs.Stat(ctx, a.BlobKey()) | |
| 36 | + | switch { | |
| 37 | + | case errors.Is(err, domain.ErrNotFound): | |
| 38 | + | r.misses[a.ID]++ | |
| 39 | + | remove = r.misses[a.ID] >= 2 | |
| 40 | + | if !remove { | |
| 41 | + | log.Error().Int64("asset", a.ID).Msg("asset blob missing") | |
| 42 | + | } | |
| 43 | + | case err != nil: | |
| 44 | + | log.Error().Err(err).Int64("asset", a.ID).Msg("stat release asset") | |
| 45 | + | default: | |
| 46 | + | delete(r.misses, a.ID) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | if !remove { | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | err := r.removeAssetRecord(ctx, a, func(current domain.ReleaseAsset) bool { return current.Version == a.Version }) | |
| 53 | + | if err != nil { | |
| 54 | + | if !errors.Is(err, domain.ErrNotFound) && !errors.Is(err, domain.ErrConflict) { | |
| 55 | + | log.Error().Err(err).Int64("asset", a.ID).Msg("remove stale release asset") | |
| 56 | + | } | |
| 57 | + | continue | |
| 58 | + | } | |
| 59 | + | delete(r.misses, a.ID) | |
| 60 | + | r.removeAssetBlobs(ctx, []domain.ReleaseAsset{a}) | |
| 61 | + | } | |
| 62 | + | for id := range r.misses { | |
| 63 | + | if !seen[id] { | |
| 64 | + | delete(r.misses, id) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | // Re-check the store for each old key: concurrent reservations must not be | |
| 68 | + | // classified as orphans from a stale snapshot. | |
| 69 | + | err = r.blobs.List(ctx, "release/", func(info ports.BlobInfo) error { | |
| 70 | + | if now.Sub(info.ModTime) <= domain.ReleaseAssetPendingMax { | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | // Only a canonical key can name an asset. Check its record directly | |
| 74 | + | // rather than repeating the full metadata scan for every object. | |
| 75 | + | id, parseErr := strconv.ParseInt(strings.TrimPrefix(info.Key, "release/"), 10, 64) | |
| 76 | + | if parseErr == nil && (domain.ReleaseAsset{ID: id}).BlobKey() == info.Key { | |
| 77 | + | _, e := r.store.GetAsset(ctx, id) | |
| 78 | + | if e == nil { | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | if !errors.Is(e, domain.ErrNotFound) { | |
| 82 | + | log.Error().Err(e).Msg("check orphan release blob") | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | if e := r.blobs.Delete(ctx, info.Key); e != nil { | |
| 87 | + | log.Error().Err(e).Str("key", info.Key).Msg("remove orphan release blob") | |
| 88 | + | } | |
| 89 | + | return nil | |
| 90 | + | }) | |
| 91 | + | if err != nil { | |
| 92 | + | log.Error().Err(err).Msg("list release blobs") | |
| 93 | + | } | |
| 94 | + | err = r.blobs.ListMultipart(ctx, "release/", func(info ports.MultipartInfo) error { | |
| 95 | + | if now.Sub(info.Started) > domain.ReleaseAssetPendingMax { | |
| 96 | + | if e := r.blobs.AbortMultipart(ctx, info.Key, info.ID); e != nil { | |
| 97 | + | log.Error().Err(e).Msg("abort abandoned release multipart") | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | return nil | |
| 101 | + | }) | |
| 102 | + | if err != nil { | |
| 103 | + | log.Error().Err(err).Msg("list release multipart uploads") | |
| 104 | + | } | |
| 105 | + | } | |
A internal/usecase/releases_sweep_test.go +147 -0
| @@ -0,0 +1,147 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 15 | + | "bnema.dev/ermite/internal/usecase" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func (r *releaseRig) setClock(t *testing.T, at time.Time, blobs ports.BlobStore) { | |
| 21 | + | t.Helper() | |
| 22 | + | clock := mocks.NewClock(t) | |
| 23 | + | clock.EXPECT().Now().Return(at).Maybe() | |
| 24 | + | bus := mocks.NewEventBus(t) | |
| 25 | + | bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe() | |
| 26 | + | r.clock = clock | |
| 27 | + | r.svc = usecase.NewReleases(r.store.Releases(), blobs, r.store, r.reader, r.tags, r.pushes, bus, clock, ports.ReleaseLimits{}) | |
| 28 | + | } | |
| 29 | + | func TestReleaseSweepPendingAndOrphans(t *testing.T) { | |
| 30 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 31 | + | rel := r.create(t, "v1", false) | |
| 32 | + | pending := r.upload(t, rel, "pending", []byte("abc")) | |
| 33 | + | good := r.upload(t, rel, "good", []byte("xyz")) | |
| 34 | + | fresh := r.upload(t, rel, "fresh", nil) | |
| 35 | + | old, err := r.store.Releases().GetAsset(r.ctx, pending.ID) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | old.State = domain.ReleaseAssetUploading | |
| 38 | + | mp, err := r.blobs.NewMultipart(r.ctx, old.BlobKey()) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | old.MultipartID = mp.ID() | |
| 41 | + | fresh.State = domain.ReleaseAssetUploading | |
| 42 | + | fresh.UpdatedAt = time.Now().Add(25 * time.Hour) | |
| 43 | + | used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{old, fresh}})) | |
| 46 | + | _, err = r.blobs.Put(r.ctx, "release/99999999999999999999", bytes.NewReader([]byte("orphan")), 6) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | // The clock is advanced beyond the disk adapter's real modification times. | |
| 49 | + | at := time.Now().Add(26 * time.Hour) | |
| 50 | + | r.setClock(t, at, r.blobs) | |
| 51 | + | r.svc.Sweep(r.ctx) | |
| 52 | + | _, err = r.store.Releases().GetAsset(r.ctx, pending.ID) | |
| 53 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 54 | + | _, err = r.blobs.Stat(r.ctx, pending.BlobKey()) | |
| 55 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 56 | + | _, err = r.blobs.Stat(r.ctx, "release/99999999999999999999") | |
| 57 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 58 | + | _, err = r.store.Releases().GetAsset(r.ctx, good.ID) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | // The pending record inside the grace period is retained. | |
| 61 | + | _, err = r.store.Releases().GetAsset(r.ctx, fresh.ID) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.NoError(t, r.blobs.ListMultipart(r.ctx, "release/", func(ports.MultipartInfo) error { t.Error("multipart survived"); return nil })) | |
| 64 | + | } | |
| 65 | + | func TestReleaseSweepMissingBlobTwoObservations(t *testing.T) { | |
| 66 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 67 | + | rel := r.create(t, "v1", false) | |
| 68 | + | a := r.upload(t, rel, "binary", []byte("abc")) | |
| 69 | + | require.NoError(t, r.blobs.Delete(r.ctx, a.BlobKey())) | |
| 70 | + | r.svc.Sweep(r.ctx) | |
| 71 | + | _, err := r.store.Releases().GetAsset(r.ctx, a.ID) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | r.svc.Sweep(r.ctx) | |
| 74 | + | _, err = r.store.Releases().GetAsset(r.ctx, a.ID) | |
| 75 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 76 | + | usage, err := r.svc.Usage(r.ctx, domain.Root(), r.repo) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | require.Zero(t, usage.Used) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | type releaseStatOverride struct { | |
| 82 | + | ports.BlobStore | |
| 83 | + | err error | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (b *releaseStatOverride) Stat(ctx context.Context, key string) (ports.BlobInfo, error) { | |
| 87 | + | if b.err != nil { | |
| 88 | + | return ports.BlobInfo{}, b.err | |
| 89 | + | } | |
| 90 | + | return b.BlobStore.Stat(ctx, key) | |
| 91 | + | } | |
| 92 | + | func TestReleaseSweepTransientStatAndRecovery(t *testing.T) { | |
| 93 | + | for _, recover := range []bool{false, true} { | |
| 94 | + | t.Run(map[bool]string{false: "transient preserves miss", true: "success resets miss"}[recover], func(t *testing.T) { | |
| 95 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 96 | + | rel := r.create(t, "v1", false) | |
| 97 | + | a := r.upload(t, rel, "binary", []byte("abc")) | |
| 98 | + | b := &releaseStatOverride{BlobStore: r.blobs, err: domain.ErrNotFound} | |
| 99 | + | r.setClock(t, now, b) | |
| 100 | + | r.svc.Sweep(r.ctx) | |
| 101 | + | b.err = errors.New("outage") | |
| 102 | + | r.svc.Sweep(r.ctx) | |
| 103 | + | _, err := r.store.Releases().GetAsset(r.ctx, a.ID) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | if recover { | |
| 106 | + | b.err = nil | |
| 107 | + | r.svc.Sweep(r.ctx) | |
| 108 | + | } | |
| 109 | + | b.err = domain.ErrNotFound | |
| 110 | + | r.svc.Sweep(r.ctx) | |
| 111 | + | _, err = r.store.Releases().GetAsset(r.ctx, a.ID) | |
| 112 | + | if recover { | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | } else { | |
| 115 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 116 | + | } | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | func TestReleaseSweepGraceAndFailureIsolation(t *testing.T) { | |
| 121 | + | r := newReleaseRig(t, ports.ReleaseLimits{}) | |
| 122 | + | rel := r.create(t, "v1", false) | |
| 123 | + | a := r.upload(t, rel, "pending", nil) | |
| 124 | + | a.State = domain.ReleaseAssetUploading | |
| 125 | + | used, v, err := r.store.Releases().ReleaseUsage(r.ctx, r.repo) | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | require.NoError(t, r.store.Releases().ApplyReleases(r.ctx, ports.ReleaseWrite{Repo: r.repo, Used: used, UsageVersion: v, PutAssets: []domain.ReleaseAsset{a}})) | |
| 128 | + | b := mocks.NewBlobStore(t) | |
| 129 | + | b.EXPECT().List(r.ctx, "release/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.BlobInfo) error) error { | |
| 130 | + | require.NoError(t, fn(ports.BlobInfo{Key: "release/old", ModTime: now.Add(-25 * time.Hour)})) | |
| 131 | + | require.NoError(t, fn(ports.BlobInfo{Key: "release/new", ModTime: now.Add(23 * time.Hour)})) | |
| 132 | + | return nil | |
| 133 | + | }).Once() | |
| 134 | + | b.EXPECT().Delete(r.ctx, "release/old").Return(errors.New("failed deletion")).Once() | |
| 135 | + | b.EXPECT().ListMultipart(r.ctx, "release/", mock.Anything).RunAndReturn(func(_ context.Context, _ string, fn func(ports.MultipartInfo) error) error { | |
| 136 | + | require.NoError(t, fn(ports.MultipartInfo{Key: "release/old", ID: "old", Started: now.Add(-25 * time.Hour)})) | |
| 137 | + | require.NoError(t, fn(ports.MultipartInfo{Key: "release/new", ID: "new", Started: now})) | |
| 138 | + | return nil | |
| 139 | + | }).Once() | |
| 140 | + | b.EXPECT().AbortMultipart(r.ctx, "release/old", "old").Return(nil).Once() | |
| 141 | + | r.setClock(t, now.Add(24*time.Hour), b) | |
| 142 | + | // Use times relative to the sweep, keeping both new objects within grace. | |
| 143 | + | // pending exactly 24h old survives; failures in orphan deletion do not stop multipart GC. | |
| 144 | + | r.svc.Sweep(r.ctx) | |
| 145 | + | _, err = r.store.Releases().GetAsset(r.ctx, a.ID) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | } | |
A internal/usecase/releases_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestReleaseTagDeletionHook(t *testing.T) { | |
| 16 | + | ctx := context.Background() | |
| 17 | + | n := name(t, "tools") | |
| 18 | + | f := newPushFixture(t) | |
| 19 | + | trigger := mocks.NewReleaseTagTrigger(t) | |
| 20 | + | f.pushes.WithReleases(trigger) | |
| 21 | + | updates := []domain.RefUpdate{{Old: oidA, New: domain.ZeroOID, Ref: "refs/tags/v1"}} | |
| 22 | + | expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil) | |
| 23 | + | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 24 | + | trigger.EXPECT().OnRefsMoved(ctx, n, updates).Return().Once() | |
| 25 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 26 | + | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 27 | + | require.NoError(t, f.pushes.Record(ctx, domain.Root(), n, updates)) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestReleaseRepositoryDeletionHook(t *testing.T) { | |
| 31 | + | for _, failed := range []bool{false, true} { | |
| 32 | + | t.Run(map[bool]string{false: "deleted", true: "metadata failure"}[failed], func(t *testing.T) { | |
| 33 | + | ctx := context.Background() | |
| 34 | + | n := name(t, "tools") | |
| 35 | + | f := newFixture(t) | |
| 36 | + | cleaner := mocks.NewReleaseRepoCleaner(t) | |
| 37 | + | f.repos.WithReleases(cleaner) | |
| 38 | + | assets := []domain.ReleaseAsset{{ID: 42, Repo: n, ReleaseID: 1}} | |
| 39 | + | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil) | |
| 40 | + | f.git.EXPECT().Trash(ctx, n).Return(nil) | |
| 41 | + | cleaner.EXPECT().AssetsOf(ctx, n).Return(assets, nil).Once() | |
| 42 | + | if failed { | |
| 43 | + | f.meta.EXPECT().Delete(ctx, n).Return(domain.ErrUnavailable).Once() | |
| 44 | + | require.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), domain.ErrUnavailable) | |
| 45 | + | } else { | |
| 46 | + | deleted := false | |
| 47 | + | f.meta.EXPECT().Delete(ctx, n).Run(func(context.Context, domain.RepoName) { deleted = true }).Return(nil).Once() | |
| 48 | + | cleaner.EXPECT().OnRepoDeleted(mock.Anything, n, assets).Run(func(ctx context.Context, _ domain.RepoName, _ []domain.ReleaseAsset) { | |
| 49 | + | require.True(t, deleted) | |
| 50 | + | require.Nil(t, ctx.Done()) | |
| 51 | + | }).Return().Once() | |
| 52 | + | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 53 | + | require.NoError(t, f.repos.Delete(ctx, domain.Root(), n)) | |
| 54 | + | } | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
M internal/usecase/repos.go +9 -1
| @@ -24,7 +24,8 @@ type Repos struct { | |||
| 24 | 24 | ci ports.CIRunCleaner // nil: no CI logs to remove | |
| 25 | 25 | autoMerge ports.AutoMergeTrigger | |
| 26 | 26 | mergeGate ports.MergeGate | |
| 27 | - | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 27 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 28 | + | releases ports.ReleaseRepoCleaner // nil: no release blobs to remove | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | var _ ports.RepoService = (*Repos)(nil) | |
| @@ -42,6 +43,13 @@ func (r *Repos) WithMirror(t ports.MirrorTrigger) *Repos { | |||
| 42 | 43 | return r | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 46 | + | // WithReleases makes Delete remove release blobs after metadata deletion. | |
| 47 | + | // Call it before the first request; it returns r. | |
| 48 | + | func (r *Repos) WithReleases(c ports.ReleaseRepoCleaner) *Repos { | |
| 49 | + | r.releases = c | |
| 50 | + | return r | |
| 51 | + | } | |
| 52 | + | ||
| 45 | 53 | // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it | |
| 46 | 54 | // before the first request; it returns r. | |
| 47 | 55 | func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos { | |
M internal/usecase/repos_write.go +11 -0
| @@ -226,9 +226,20 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 226 | 226 | log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep") | |
| 227 | 227 | } | |
| 228 | 228 | } | |
| 229 | + | var assets []domain.ReleaseAsset | |
| 230 | + | if r.releases != nil { | |
| 231 | + | var err error | |
| 232 | + | assets, err = r.releases.AssetsOf(ctx, name) | |
| 233 | + | if err != nil { | |
| 234 | + | log.Warn().Err(err).Str("repo", name.String()).Msg("list release assets; orphan sweep will remove their blobs") | |
| 235 | + | } | |
| 236 | + | } | |
| 229 | 237 | if err := r.meta.Delete(ctx, name); err != nil { | |
| 230 | 238 | return fmt.Errorf("delete metadata of %q: %w", name, err) | |
| 231 | 239 | } | |
| 240 | + | if r.releases != nil { | |
| 241 | + | r.releases.OnRepoDeleted(context.WithoutCancel(ctx), name, assets) | |
| 242 | + | } | |
| 232 | 243 | if r.ci != nil { | |
| 233 | 244 | r.ci.ForgetRepo(name) | |
| 234 | 245 | } | |
M proto/ermite/storage/ci_artifact.proto +4 -2
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | 3 | // Internal, unversioned storage schema of the CI artifacts (artifacts v4, | |
| 4 | - | // CI-4 D3). The contents are not stored here: they are files under | |
| 5 | - | // $ERMITE_DATA_DIR/ci/artifacts. | |
| 4 | + | // CI-4 D3). Contents are stored separately, in the blob store or (for | |
| 5 | + | // legacy records) under $ERMITE_DATA_DIR/ci/artifacts. | |
| 6 | 6 | ||
| 7 | 7 | syntax = "proto3"; | |
| 8 | 8 | ||
| @@ -49,6 +49,8 @@ message CIArtifact { | |||
| 49 | 49 | // list_digest: the SHA-256 of the block list of the commit, to recognize a | |
| 50 | 50 | // repeated commit. | |
| 51 | 51 | bytes list_digest = 15; | |
| 52 | + | // "" or "disk": legacy files under ci/artifacts; "blob": the blob store. | |
| 53 | + | string store = 16; | |
| 52 | 54 | } | |
| 53 | 55 | ||
| 54 | 56 | // CIArtifactBlock is stored under "artifactblk/<artifact:020d>/<hex(block id)>": | |
A proto/ermite/storage/release.proto +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | syntax = "proto3"; | |
| 4 | + | package ermite.storage; | |
| 5 | + | import "google/protobuf/timestamp.proto"; | |
| 6 | + | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | |
| 7 | + | ||
| 8 | + | // Release stores release metadata, not object contents. | |
| 9 | + | message Release { | |
| 10 | + | int64 id = 1; | |
| 11 | + | string repo = 2; | |
| 12 | + | string tag = 3; | |
| 13 | + | string title = 4; | |
| 14 | + | string notes = 5; | |
| 15 | + | bool draft = 6; | |
| 16 | + | bool prerelease = 7; | |
| 17 | + | bool make_latest = 8; | |
| 18 | + | string target_commitish = 9; | |
| 19 | + | string author = 10; | |
| 20 | + | google.protobuf.Timestamp created_at = 11; | |
| 21 | + | google.protobuf.Timestamp published_at = 12; | |
| 22 | + | google.protobuf.Timestamp updated_at = 13; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // ReleaseAsset stores release metadata, not object contents. | |
| 26 | + | message ReleaseAsset { | |
| 27 | + | int64 id = 1; | |
| 28 | + | int64 release_id = 2; | |
| 29 | + | string repo = 3; | |
| 30 | + | string name = 4; | |
| 31 | + | string label = 5; | |
| 32 | + | string content_type = 6; | |
| 33 | + | int64 size = 7; | |
| 34 | + | string digest = 8; | |
| 35 | + | string state = 9; | |
| 36 | + | string uploader = 10; | |
| 37 | + | int64 charged = 11; | |
| 38 | + | string multipart_id = 12; | |
| 39 | + | google.protobuf.Timestamp created_at = 13; | |
| 40 | + | google.protobuf.Timestamp updated_at = 14; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // ReleaseUsage stores release metadata, not object contents. | |
| 44 | + | message ReleaseUsage { | |
| 45 | + | int64 used = 1; | |
| 46 | + | } | |
M skills/ermite/CLI.md +20 -0
| @@ -44,6 +44,7 @@ Scopes: | |||
| 44 | 44 | | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` | | |
| 45 | 45 | | `mr:force-merge` | bypass failing or unfinished checks with `--ignore-checks` | | |
| 46 | 46 | | `issue:write` | open, comment on, close and reopen issues | | |
| 47 | + | | `release:write` | create, edit and delete releases and their assets; included in `cli`; refused for branch-limited tokens | | |
| 47 | 48 | | `token:admin` | create, list and revoke tokens | | |
| 48 | 49 | | `ci:write`, `secrets:write`, `runner:register` | reserved for CI | | |
| 49 | 50 | ||
| @@ -93,6 +94,25 @@ Issues share the numbering of merge requests and must be enabled per repository: | |||
| 93 | 94 | ||
| 94 | 95 | Issue JSON: `repo`, `number`, `title`, `body`, `state`, `author` (token label), `created_at`, `updated_at`, `comment_count`. `issue show --json` adds `comments` (`id`, `author`, `text`, `created_at`). `repo show --json` has `issues`. | |
| 95 | 96 | ||
| 97 | + | ## Releases | |
| 98 | + | ||
| 99 | + | Needs the `releases` server feature. Read access follows repository visibility; | |
| 100 | + | drafts need write access. Writes need `release:write`, never a branch-limited | |
| 101 | + | token, or a CI job's `contents: write` permission. Select a release by tag or id; | |
| 102 | + | `view` and `download` also accept `latest`. | |
| 103 | + | ||
| 104 | + | | Command | Notes | | |
| 105 | + | |---|---| | |
| 106 | + | | `release create <repo> <tag> [--title T] [--notes TEXT\|--notes-file F\|-] [--target REF] [--draft] [--prerelease] [--latest] [--asset FILE[#LABEL]] [--json]` | existing tags never move; target creates a missing tag | | |
| 107 | + | | `release list <repo> [--limit N] [--json]` | newest first | | |
| 108 | + | | `release view <repo> <tag\|id\|latest> [--json\|--notes]` | metadata and assets | | |
| 109 | + | | `release edit <repo> <tag\|id> [--title T] [--notes TEXT\|--notes-file F\|-] [--tag T] [--target REF] [--draft\|--publish] [--prerelease\|--no-prerelease] [--latest]` | only supplied fields change | | |
| 110 | + | | `release delete <repo> <tag\|id> [--yes]` | leaves the Git tag | | |
| 111 | + | | `release upload <repo> <tag\|id> <file[#label]>… [--clobber] [--content-type TYPE]` | clobber replaces same-named assets | | |
| 112 | + | | `release download <repo> <tag\|id\|latest> [--name N]… [--pattern GLOB]… [--dir D]` | refuses existing local files | | |
| 113 | + | ||
| 114 | + | See [publishing and storage](../../docs/releases.md). | |
| 115 | + | ||
| 96 | 116 | ## Workflows | |
| 97 | 117 | ||
| 98 | 118 | | Command | Scope | Notes | | |