diff --git a/.env.example b/.env.example index c7f00427..c327153d 100644 --- a/.env.example +++ b/.env.example @@ -154,7 +154,7 @@ FORGE_AUTH__BOOTSTRAP_ADMIN__PASSWORD=changeme # --------------------------------------------------------------------------- # WebAssembly format plugins (optional; docs/format-plugins.md) # Load further package formats from //, each pinned to the SHA-256 of -# its component (a mismatch fails startup). The built-in formats (Helm) need +# its component (a mismatch fails startup). The built-in format (NuGet) needs # nothing here. `cargo xtask plugins` builds components and prints digests. # --------------------------------------------------------------------------- # FORGE_PLUGINS__DIR=/etc/forge/plugins diff --git a/.github/scripts/oci-e2e.sh b/.github/scripts/oci-e2e.sh index 6c807b08..d8ad741a 100755 --- a/.github/scripts/oci-e2e.sh +++ b/.github/scripts/oci-e2e.sh @@ -7,11 +7,13 @@ # 3. crane: ls, manifest, copy; # 4. a proxy repository of a `registry:2` upstream (seeded with crane) and a # group over the hosted and proxy repositories, pulled with docker; -# 5. cosign: a signature and an attestation pushed as OCI 1.1 referrers, +# 5. helm: registry login, a chart pushed, pulled, rendered and shown in +# the browse API, and pulled through the proxy and the group; +# 6. cosign: a signature and an attestation pushed as OCI 1.1 referrers, # found through the referrers API and verified. # # Usage: oci-e2e.sh -# Needs on PATH: docker (with buildx), go, crane, cosign, curl, jq, git. +# Needs on PATH: docker (with buildx), go, crane, helm, cosign, curl, jq, git. # Postgres and S3 come from the environment (defaults match ci.yml): # FORGE_E2E_DATABASE_URL (postgres://forge:forge@localhost:5432/forge) # FORGE_E2E_S3_ENDPOINT (http://localhost:7070), access/secret forge-e2e-key @@ -41,6 +43,7 @@ cleanup() { docker rm -f forge-e2e-upstream >/dev/null 2>&1 || true docker buildx rm forge-e2e >/dev/null 2>&1 || true docker logout "$registry" >/dev/null 2>&1 || true + helm registry logout "$registry" >/dev/null 2>&1 || true cp "$work/server.log" "$results/" 2>/dev/null || true rm -rf "$work" } @@ -143,6 +146,50 @@ docker pull "$registry/docker-group/upstream/multi:1" crane copy --insecure "$image" "$registry/docker-group/e2e/via-group:1" crane digest --insecure "$registry/docker-hosted/e2e/via-group:1" >/dev/null || fail "group push" +step "helm: login, push, pull, template; through the proxy and the group" +# Helm keeps its registry login and cache under these, not in ~/.config. +export HELM_CONFIG_HOME="$work/helm/config" HELM_CACHE_HOME="$work/helm/cache" \ + HELM_DATA_HOME="$work/helm/data" +echo "$token" | helm registry login "$registry" --username "$admin_user" --password-stdin --plain-http +mkdir -p "$work/charts" "$work/pulled"/{hosted,range,proxy,group-charts,group-upstream-charts} +helm create "$work/charts/e2e-chart" >/dev/null +sed -i 's/^version: .*/version: 1.2.3/; s/^appVersion: .*/appVersion: "4.5.6"/' \ + "$work/charts/e2e-chart/Chart.yaml" +printf '# The e2e chart\n' > "$work/charts/e2e-chart/README.md" +helm package "$work/charts/e2e-chart" -d "$work/charts" >/dev/null +packaged="$work/charts/e2e-chart-1.2.3.tgz" +helm push "$packaged" "oci://$registry/docker-hosted/charts" --plain-http +chart="oci://$registry/docker-hosted/charts/e2e-chart" +helm pull "$chart" --version 1.2.3 --plain-http -d "$work/pulled/hosted" +cmp -s "$packaged" "$work/pulled/hosted/e2e-chart-1.2.3.tgz" || fail "helm pull: not the pushed chart" +# A version range resolves against the tags. +helm pull "$chart" --version '~1.2' --plain-http -d "$work/pulled/range" +[ -f "$work/pulled/range/e2e-chart-1.2.3.tgz" ] || fail "helm pull --version ~1.2" +helm show chart "$chart" --version 1.2.3 --plain-http | grep -qx 'appVersion: 4.5.6' \ + || fail "helm show chart" +helm template e2e "$chart" --version 1.2.3 --plain-http | grep -q '^kind: Deployment' \ + || fail "helm template" +# The browse API reads the chart's metadata, README and values. +chart_digest=$(crane digest --insecure "$registry/docker-hosted/charts/e2e-chart:1.2.3") +browsed=$(curl -fsS -b "$work/jar" \ + "$base/browse/chart?repo=docker-hosted&image=charts/e2e-chart&digest=$chart_digest") +jq -e '.metadata.appVersion == "4.5.6" and .readme.text == "# The e2e chart\n" + and (.values.text | contains("replicaCount"))' <<<"$browsed" >/dev/null \ + || fail "browse a chart: $browsed" +# Through the proxy of the registry:2 upstream (no login there), and the +# group, which also takes the push to its writable member. +helm push "$packaged" "oci://localhost:$upstream_port/upstream-charts" --plain-http +helm pull "oci://$registry/docker-proxy/upstream-charts/e2e-chart" --version 1.2.3 \ + --plain-http -d "$work/pulled/proxy" +cmp -s "$packaged" "$work/pulled/proxy/e2e-chart-1.2.3.tgz" || fail "helm pull through the proxy" +for path in charts upstream-charts; do + helm pull "oci://$registry/docker-group/$path/e2e-chart" --version 1.2.3 \ + --plain-http -d "$work/pulled/group-$path" +done +helm push "$packaged" "oci://$registry/docker-group/via-group" --plain-http +crane digest --insecure "$registry/docker-hosted/via-group/e2e-chart:1.2.3" >/dev/null \ + || fail "helm push to the group" + step "cosign: signature and attestation as referrers" # cosign 3 stores both as Sigstore bundles through the OCI 1.1 referrers # API. Signed with a local key and no transparency log, so verification @@ -167,4 +214,4 @@ verify=(--key "$work/cosign.pub" --insecure-ignore-tlog --private-infrastructure cosign verify "${verify[@]}" "$signed" >/dev/null cosign verify-attestation "${verify[@]}" --type slsaprovenance "$signed" >/dev/null -step "OK: conformance, docker, crane, proxy, group and cosign" +step "OK: conformance, docker, crane, proxy, group, helm and cosign" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ca90201..629974f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -357,8 +357,8 @@ jobs: # validates the output against the Kubernetes schemas. kustomize and # kubeconform are pinned by checksum, Helm is built from its pinned Go # module (verified by the Go checksum database; keep HELM_VERSION in step - # with release.yml). Runs when the change touches any of these (the - # kubernetes group, #353). + # with oci-e2e and release.yml). Runs when the change touches any of these + # (the kubernetes group, #353). kubernetes: name: kubernetes runs-on: ubuntu-latest @@ -448,8 +448,9 @@ jobs: # ── Docker / OCI registry e2e (real clients against a live server) ───────── # Boots forge-server and drives the registry with the OCI distribution-spec # conformance suite, docker (buildx multi-arch), crane, a proxy of a - # registry:2 upstream, a group, and cosign signatures/attestations through - # the referrers API (.github/scripts/oci-e2e.sh). Runs on pull requests too: + # registry:2 upstream, a group, Helm charts (helm push, pull, template), and + # cosign signatures/attestations through the referrers API + # (.github/scripts/oci-e2e.sh). Runs on pull requests too: # it is the only check that the registry still speaks to real clients. oci-e2e: name: oci-e2e @@ -473,6 +474,9 @@ jobs: env: SQLX_OFFLINE: "true" CRANE_VERSION: v0.20.2 + # Built from its pinned Go module, as in the kubernetes job (keep it in + # step with that job and release.yml). + HELM_VERSION: v4.3.1 COSIGN_VERSION: v3.0.2 COSIGN_SHA256: 46dbdcb5467a3dfec2526923d0b3365e40c8d9dc00ec23d5aca3437449e8cbfd # The upstream registry the proxy repository caches, and buildx's @@ -500,6 +504,18 @@ jobs: echo "${COSIGN_SHA256} $RUNNER_TEMP/bin/cosign" | sha256sum -c - chmod +x "$RUNNER_TEMP/bin/cosign" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Cache the Helm build + id: helm-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/helm + key: ${{ runner.os }}-helm-${{ env.HELM_VERSION }} + - name: Install Helm + run: | + if [ "${{ steps.helm-cache.outputs.cache-hit }}" != "true" ]; then + GOBIN="$RUNNER_TEMP/helm" GOTOOLCHAIN=auto GOFLAGS=-trimpath go install "helm.sh/helm/v4/cmd/helm@${HELM_VERSION}" + fi + echo "$RUNNER_TEMP/helm" >> "$GITHUB_PATH" - name: Start the S3 gateway and pull the upstream registry image run: | docker run -d --name forge-e2e-s3 -p 7070:7070 \ @@ -508,7 +524,7 @@ jobs: docker pull "$FORGE_E2E_UPSTREAM_IMAGE" - name: cargo build -p forge-server run: cargo build -p forge-server --bin forge-server - - name: Registry e2e (conformance, docker, crane, proxy, group, cosign) + - name: Registry e2e (conformance, docker, crane, proxy, group, helm, cosign) env: GOTOOLCHAIN: auto run: .github/scripts/oci-e2e.sh target/debug/forge-server oci-e2e-results diff --git a/AGENTS.md b/AGENTS.md index 62a0a5b2..2b034646 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ its pull request's description says why. A package format is a crate in `plugins//`, the directory named after its format id, whether it runs built in only, as a WebAssembly component, or both. Built in only is no reason to put it elsewhere: NuGet runs built in -only and lives in `plugins/nuget`, like Helm. Never add a format as a crate +only and lives in `plugins/nuget`. Never add a format as a crate under `crates/` (no `crates/format-`) or as a module of the server: it implements `FormatPlugin` with the layout [plugins/README.md](plugins/README.md) describes, and has a `component` diff --git a/Cargo.lock b/Cargo.lock index 0f76a2b7..c02ebab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2010,7 +2010,6 @@ dependencies = [ "base64 0.23.1", "chrono", "figment", - "flate2", "forge-cache", "forge-core", "forge-db", @@ -2067,6 +2066,7 @@ dependencies = [ "base64 0.23.1", "bytes", "chrono", + "flate2", "forge-cache", "forge-core", "forge-crypto", diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 19892f07..798176bc 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,6 +1,7 @@ # Developing & running Forge locally -Forge is a NuGet, Docker / OCI, Helm and raw file registry written in Rust. +Forge is a NuGet, Docker / OCI (Helm charts included) and raw file registry +written in Rust. Locally it runs as a single `forge-server` process backed by **PostgreSQL** (metadata) and an **S3-compatible** object store (blobs) — both provided by `docker-compose.yml` (versitygw stands in for S3). @@ -253,23 +254,23 @@ groups. See [docs/raw.md](docs/raw.md). Every package format is a format plugin, a crate in [`plugins/`](./plugins) (see [docs/format-plugins.md](./docs/format-plugins.md)), whether it runs built in only, as a WebAssembly component, or both: NuGet runs -built in only, Helm either way. A new format goes there too, never in a -`crates/format-` crate or the server. Docker / OCI (`crates/format-oci`) -and raw (`crates/server/src/raw/`) are not there yet and are to move to -`plugins/docker` and `plugins/raw`. +built in only. `plugins/helm`, classic Helm chart repositories, is the example +plugin the plugin tests run; it is not built in (Helm charts are pushed to +docker repositories, see [docs/docker.md](docs/docker.md#helm-charts)). A new +format goes in `plugins/` too, never in a `crates/format-` crate or the +server. Docker / OCI (`crates/format-oci`) and raw (`crates/server/src/raw/`) +are not there yet and are to move to `plugins/docker` and `plugins/raw`. -Helm is built in: create a repository with `"format": "helm"` (hosted, proxy or -group; a plugin format has proxies when it implements the upstream side). It is -served under `/repository//`, e.g. -`helm repo add local http://localhost:8421/repository/charts`. - -To run a plugin as a WebAssembly component instead: +To run a plugin as a WebAssembly component: ```bash cargo xtask plugins # builds plugins/* as components, prints the [plugins] section for forge.toml ``` -A built-in plugin's component loads only under another id (rename its +A repository of a plugin format is created with `"format": ""` (hosted, +proxy or group; a plugin format has proxies when it implements the upstream +side) and served under `/repository//` unless the plugin names another +mount. A built-in plugin's component loads only under another id (rename its installed directory and the `id` in its `plugin.toml`). ## Configuration @@ -616,8 +617,9 @@ cargo xtask plugins-check # the format plugins' component builds: clippy and b ``` The format-plugin tests (`crates/plugin/tests`, `crates/server/tests/plugins.rs`) -build the Helm plugin as a component themselves into `target/plugins/`; they -need the `wasm32-wasip2` target. +run the example plugin, `plugins/helm`: built in for the test, and built as a +component themselves into `target/plugins/`; they need the `wasm32-wasip2` +target. The e2e suite includes an optional `dotnet` smoke test that is skipped when the .NET SDK isn't installed. diff --git a/README.md b/README.md index 9f3aaf23..76ef9aec 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ formats come as **format plugins**, see below): priority; tolerates a failing member and, by default, keeps public feeds from shadowing internal package ids (dependency-confusion guard). -The same three types exist for container images: hosted image repositories, -pull-through proxies of Docker Hub, GHCR, Quay or any OCI registry, and -groups — see **[docs/docker.md](./docs/docker.md)** — and for plain files +The same three types exist for container images and other OCI artifacts, +Helm charts included (`helm push`, `helm install oci://…`): hosted image +repositories, pull-through proxies of Docker Hub, GHCR, Quay or any OCI +registry, and groups — see **[docs/docker.md](./docs/docker.md)** — and for plain files under arbitrary paths (binaries, installers, fixtures), like Nexus' raw repositories, with ranges, checksum sidecars and proxies of any HTTP(S) download URL such as GitHub release assets — see @@ -35,8 +36,7 @@ token, cache, and audit limitations are documented below. A **retention** engine reaps cold versions and garbage-collects dereferenced blobs. **Format plugins** (preview) add further package formats that reuse all of the above, either built into the server or loaded as sandboxed WebAssembly -components; **Helm chart repositories** are built in -([docs/format-plugins.md](docs/format-plugins.md)). The admin + browse UI is +components ([docs/format-plugins.md](docs/format-plugins.md)). The admin + browse UI is a **TanStack Router SPA** served by the same Rust process on a single origin — there is no Node server in production. @@ -56,7 +56,7 @@ A Cargo workspace of small crates plus the web SPA: | `server` | `forge-server` | Axum HTTP server: the format plugins' feeds (NuGet at `/v3`), the Docker registry, raw file repositories (`/raw`), `/admin` + `/auth` REST, background workers, static SPA serving. | | `e2e` | `forge-e2e` | End-to-end suite (testcontainers + mock upstream/IdP). | | `xtask` | — | The task runner (`cargo xtask …`); replaces Make/Just. | -| `plugins/` | `forge-format-*` | Format plugins, built in or as WebAssembly components: `nuget` (built in only), `helm`. | +| `plugins/` | `forge-format-*` | Format plugins, built in or as WebAssembly components: `nuget` (built in only), and `helm`, the example plugin the plugin tests run (not built in). | | `web/` | — | TanStack Router SPA (pnpm + Vite), built into `web/dist/`. | **Every format is a plugin in `plugins/`.** A package format is a crate in @@ -66,7 +66,7 @@ a WebAssembly component, or both. Docker / OCI (`crates/format-oci`) and raw `plugins/raw`. **Single-origin serving.** `forge-server` mounts each format plugin's feed -(NuGet at `/v3`, Helm at `/repository`), the Docker / OCI registry (`/v2`), +(NuGet at `/v3`, others at `/repository`), the Docker / OCI registry (`/v2`), raw file repositories (`/raw`), the admin/browse APIs (`/admin`, `/browse`, `/auth`) and then serves the built SPA from `web/dist/` via a `ServeDir` SPA fallback. The browser talks to the API on @@ -211,11 +211,11 @@ curl -u admin@example.com: http://localhost:8421/raw/binaries/tools/1.0/t - **[Trusted publishing](./docs/trusted-publishing.md)** — configure short-lived workload identity for GitHub Actions, GitLab CI, and compatible OIDC issuers, and exchange a job's OIDC token for a short-lived API key (`POST /api/v2/token`). -- **[Format plugins](./docs/format-plugins.md)** — further package formats - (Helm charts), built in or as sandboxed WebAssembly components: design, - operation, and writing one. +- **[Format plugins](./docs/format-plugins.md)** — further package formats, + built in or as sandboxed WebAssembly components: design, operation, and + writing one. - **[Importing from Nexus](./docs/nexus-import.md)** — copy a Nexus hosted - repository (NuGet, Helm, Docker) into Forge, with its publish dates: + repository (NuGet, Docker) into Forge, with its publish dates: dry run, conflicts, resuming, and the cutover. Run `cargo xtask help` for the complete command list. diff --git a/crates/e2e/Cargo.toml b/crates/e2e/Cargo.toml index 729653ad..32a23af5 100644 --- a/crates/e2e/Cargo.toml +++ b/crates/e2e/Cargo.toml @@ -60,8 +60,6 @@ aws-sigv4 = "1.6" aws-credential-types = { workspace = true } [dev-dependencies] -# Builds the Helm chart archive the Nexus import scenario uploads to Nexus. -flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] } serde_json = { workspace = true } sqlx = { workspace = true } reqwest = { workspace = true, features = ["multipart"] } diff --git a/crates/e2e/tests/nexus_import.rs b/crates/e2e/tests/nexus_import.rs index 5180c288..9d4fe585 100644 --- a/crates/e2e/tests/nexus_import.rs +++ b/crates/e2e/tests/nexus_import.rs @@ -1,6 +1,6 @@ //! E2E: importing from a real Nexus Repository 3 OSS container //! (`sonatype/nexus3`) seeded with NuGet packages, a multi-platform image -//! with a cosign signature and an SBOM, and a Helm chart. After the import, +//! with a cosign signature and an SBOM. After the import, //! bytes and digests are Nexus' and so are the publish dates; a second run //! copies only what was added to Nexus since. (Resuming a killed import, the //! conflict policies and the dry run are covered against a mock Nexus in @@ -9,7 +9,6 @@ #![cfg(feature = "forge-e2e")] -use std::io::Write; use std::time::Duration; use forge_e2e::{make_nupkg, StackBuilder, TestServer}; @@ -108,17 +107,6 @@ impl Nexus { .await .unwrap(); assert_eq!(resp.status(), 201, "{}", resp.text().await.unwrap()); - let helm = json!({ - "name": "helm-hosted", "online": true, - "storage": {"blobStoreName": "default", "strictContentTypeValidation": true, "writePolicy": "allow_once"}, - }); - let resp = self - .api(reqwest::Method::POST, "repositories/helm/hosted") - .json(&helm) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201, "{}", resp.text().await.unwrap()); } /// Upload an asset through the components API (`.asset`). @@ -220,40 +208,6 @@ impl Nexus { } } -/// A gzipped Helm chart archive (a minimal ustar with a valid checksum). -fn chart(name: &str, version: &str) -> Vec { - let files = [ - ( - format!("{name}/Chart.yaml"), - format!("apiVersion: v2\nname: {name}\nversion: {version}\ndescription: test chart\n"), - ), - (format!("{name}/values.yaml"), "replicas: 1\n".to_string()), - ]; - let mut tar = Vec::new(); - for (path, data) in &files { - let mut header = [0u8; 512]; - header[..path.len()].copy_from_slice(path.as_bytes()); - header[100..108].copy_from_slice(b"0000644\0"); - header[108..116].copy_from_slice(b"0000000\0"); - header[116..124].copy_from_slice(b"0000000\0"); - header[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes()); - header[136..148].copy_from_slice(b"00000000000\0"); - header[156] = b'0'; - header[257..263].copy_from_slice(b"ustar\0"); - header[263..265].copy_from_slice(b"00"); - header[148..156].copy_from_slice(b" "); - let sum: u32 = header.iter().map(|b| u32::from(*b)).sum(); - header[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes()); - tar.extend_from_slice(&header); - tar.extend_from_slice(data.as_bytes()); - tar.resize(tar.len().div_ceil(512) * 512, 0); - } - tar.extend_from_slice(&[0u8; 1024]); - let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); - gz.write_all(&tar).unwrap(); - gz.finish().unwrap() -} - async fn admin( srv: &TestServer, method: reqwest::Method, @@ -347,12 +301,6 @@ async fn import_from_a_real_nexus() { .upload("nuget-hosted", "nuget", "acme.web.2.1.0.nupkg", web.clone()) .await; - // Helm: a chart. - let mychart = chart("mychart", "0.1.0"); - nexus - .upload("helm-hosted", "helm", "mychart-0.1.0.tgz", mychart.clone()) - .await; - // Docker: a two-platform image, its cosign signature (a tag) and an // SBOM pushed by digest (a referrer Nexus lists as an asset only). let image = "team/app"; @@ -410,11 +358,6 @@ async fn import_from_a_real_nexus() { let srv = StackBuilder::default().start().await; create_repo(&srv, json!({"name": "nuget-internal", "kind": "hosted"})).await; - create_repo( - &srv, - json!({"name": "charts", "kind": "hosted", "format": "helm"}), - ) - .await; create_repo( &srv, json!({"name": "images", "kind": "hosted", "format": "docker"}), @@ -455,19 +398,6 @@ async fn import_from_a_real_nexus() { .unwrap(); assert_eq!(created, published); - // Helm. - let done = import(&srv, &nexus, "charts", "helm-hosted").await; - assert_eq!(done["state"], "completed", "{done}"); - assert_eq!(done["progress"]["imported"], 1, "{done}"); - assert_eq!( - download( - &srv, - "/repository/charts/charts/mychart/0.1.0/mychart-0.1.0.tgz" - ) - .await, - mychart - ); - // Docker: digests, the children, the signature tag and both referrers. let done = import(&srv, &nexus, "images", "docker-hosted").await; assert_eq!(done["state"], "completed", "{done}"); diff --git a/crates/format-oci/Cargo.toml b/crates/format-oci/Cargo.toml index bc216994..3c089539 100644 --- a/crates/format-oci/Cargo.toml +++ b/crates/format-oci/Cargo.toml @@ -34,6 +34,8 @@ reqwest = { workspace = true } # Registry Bearer tokens (HS256), the same crate the OIDC verifier uses. jsonwebtoken = { workspace = true } rand = { workspace = true } +# Helm chart archives (.tgz), read for their README and values (`chart`). +flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] } [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/format-oci/src/chart.rs b/crates/format-oci/src/chart.rs new file mode 100644 index 00000000..cc9321bd --- /dev/null +++ b/crates/format-oci/src/chart.rs @@ -0,0 +1,578 @@ +//! Helm charts stored as OCI artifacts (`helm push`, Helm ≥ 3.8): their +//! media types, the chart metadata of a config blob, and the README and +//! default values of a chart archive, for the web UI. +//! +//! A chart is an image manifest whose config blob is the chart's +//! `Chart.yaml` as JSON ([`media::CONFIG`]) and whose layer is the packaged +//! chart, the `.tgz` `helm package` writes ([`media::CONTENT`]). Nothing here +//! decides what the registry stores or serves: a chart is pushed, pulled and +//! kept like any other artifact. + +use std::io::Read; + +use serde_json::{Map, Value}; + +/// The media types of a Helm chart artifact. +pub mod media { + /// The config blob: the chart's metadata (`Chart.yaml`) as JSON. + pub const CONFIG: &str = "application/vnd.cncf.helm.config.v1+json"; + /// The layer holding the packaged chart (`-.tgz`). + pub const CONTENT: &str = "application/vnd.cncf.helm.chart.content.v1.tar+gzip"; + /// The optional layer holding the chart's provenance file (`.prov`). + pub const PROVENANCE: &str = "application/vnd.cncf.helm.chart.provenance.v1.prov"; +} + +/// Largest config blob read for its metadata. +pub const MAX_CONFIG_BYTES: i64 = 1024 * 1024; + +/// Largest chart archive read for its README and values, as stored +/// (compressed). Charts are a few KiB to a few hundred KiB. +pub const MAX_ARCHIVE_BYTES: i64 = 16 * 1024 * 1024; + +/// Most bytes of an archive decompressed while looking for its README and +/// values: Helm's own limit on a whole chart (`MaxDecompressedChartSize`). +const MAX_UNPACKED_BYTES: u64 = 100 * 1024 * 1024; + +/// Most of a README or `values.yaml` shown; the rest is cut. +pub const MAX_FILE_TEXT: usize = 512 * 1024; + +/// Longest string kept from the metadata; a longer one is cut. +const MAX_STRING: usize = 4096; + +/// Most entries of a metadata list (keywords, sources, maintainers, +/// dependencies) kept. +const MAX_LIST: usize = 100; + +/// The `Chart.yaml` keys whose value is a string, kept as they are. +const STRING_KEYS: &[&str] = &[ + "apiVersion", + "name", + "version", + "appVersion", + "description", + "type", + "kubeVersion", + "home", + "icon", +]; + +/// The metadata of a chart config blob, for display: its `Chart.yaml` +/// strings (name, version, appVersion, description, …), `deprecated`, and +/// its keywords, sources, maintainers and dependencies, each bounded. `None` +/// when the blob is not a JSON object. +pub fn metadata(config: &[u8]) -> Option { + let Value::Object(raw) = serde_json::from_slice::(config).ok()? else { + return None; + }; + let mut out = Map::new(); + for key in STRING_KEYS { + if let Some(s) = raw.get(*key).and_then(Value::as_str) { + out.insert((*key).into(), Value::String(cut(s, MAX_STRING))); + } + } + if let Some(deprecated) = raw.get("deprecated").and_then(Value::as_bool) { + out.insert("deprecated".into(), Value::Bool(deprecated)); + } + for key in ["keywords", "sources"] { + let list = strings(raw.get(key)); + if !list.is_empty() { + out.insert(key.into(), Value::from(list)); + } + } + let maintainers = objects(raw.get("maintainers"), &["name", "email", "url"]); + if !maintainers.is_empty() { + out.insert("maintainers".into(), Value::from(maintainers)); + } + let dependencies = objects( + raw.get("dependencies"), + &["name", "version", "repository", "condition", "alias"], + ); + if !dependencies.is_empty() { + out.insert("dependencies".into(), Value::from(dependencies)); + } + Some(Value::Object(out)) +} + +/// The strings of a JSON list, each bounded, at most [`MAX_LIST`]. +fn strings(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .take(MAX_LIST) + .map(|s| Value::String(cut(s, MAX_STRING))) + .collect() +} + +/// The objects of a JSON list with only their string `keys`, each bounded, +/// at most [`MAX_LIST`]. +fn objects(value: Option<&Value>, keys: &[&str]) -> Vec { + value + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_object) + .take(MAX_LIST) + .map(|o| { + let kept: Map = keys + .iter() + .filter_map(|k| { + o.get(*k) + .and_then(Value::as_str) + .map(|s| ((*k).to_string(), Value::String(cut(s, MAX_STRING)))) + }) + .collect(); + Value::Object(kept) + }) + .collect() +} + +/// `s` cut to at most `max` bytes, at a character boundary. +fn cut(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() +} + +/// A text file of a chart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChartFile { + /// Its name in the chart (`README.md`, `values.yaml`). + pub name: String, + /// Its text (invalid UTF-8 replaced), at most [`MAX_FILE_TEXT`] bytes. + pub text: String, + /// Whether the text was cut. + pub truncated: bool, +} + +/// What [`read_files`] found in a chart archive. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChartFiles { + /// The chart's README: the first of `README.md`, `README.txt` or + /// `README` (any case) at the chart's top level, as `helm show readme` + /// picks it. + pub readme: Option, + /// The chart's default values (`values.yaml` at its top level; the last + /// one, as Helm loads it). + pub values: Option, +} + +/// The README and default values of a packaged chart (`.tgz`). Paths are +/// read the way Helm's loader reads them: the archive's first directory is +/// the chart, so `/README.md` is the chart's README and +/// `/charts//README.md` a subchart's. At most +/// [`MAX_UNPACKED_BYTES`] are decompressed; an archive that is not gzip or +/// tar, or ends early, yields what was found before. +pub fn read_files(archive: &[u8]) -> ChartFiles { + let mut found = ChartFiles::default(); + let decoder = flate2::read::MultiGzDecoder::new(archive); + let mut tar = TarReader { + inner: decoder.take(MAX_UNPACKED_BYTES), + }; + let mut long_name: Option = None; + while let Some(header) = tar.header() { + let size = header.size; + match header.kind { + // A GNU long name, or a pax extended header, names the next + // entry. + b'L' => { + let Some(data) = tar.data(size, 64 * 1024) else { + break; + }; + long_name = Some( + String::from_utf8_lossy(&data) + .trim_end_matches('\0') + .to_string(), + ); + continue; + } + b'x' => { + let Some(data) = tar.data(size, 64 * 1024) else { + break; + }; + if let Some(path) = pax_path(&data) { + long_name = Some(path); + } + continue; + } + b'0' | b'\0' | b'7' => { + let name = long_name.take().unwrap_or(header.name); + let wanted = match chart_path(&name) { + Some(path) if is_readme(&path) && found.readme.is_none() => Some(path), + Some(path) if path == "values.yaml" => Some(path), + _ => None, + }; + let Some(path) = wanted else { + if !tar.skip(size) { + break; + } + continue; + }; + let Some(data) = tar.data(size, MAX_FILE_TEXT as u64) else { + break; + }; + let truncated = size > MAX_FILE_TEXT as u64; + let text = String::from_utf8_lossy(&data); + let text = if truncated { + // The cut may split a character the lossy decoding + // replaced: keep only whole characters. + cut(&text, MAX_FILE_TEXT) + } else { + text.into_owned() + }; + let file = ChartFile { + name: path.clone(), + text, + truncated, + }; + if path == "values.yaml" { + found.values = Some(file); + } else { + found.readme = Some(file); + } + } + _ => { + long_name = None; + if !tar.skip(size) { + break; + } + } + } + } + found +} + +/// Whether a chart path is the chart's README (`helm show readme`). +fn is_readme(path: &str) -> bool { + matches!( + path.to_ascii_lowercase().as_str(), + "readme.md" | "readme.txt" | "readme" + ) +} + +/// An archive path within its chart, as Helm's loader reads it: the first +/// component (the chart's directory) dropped, `\` read as `/` in an archive +/// made on Windows, `.` components dropped. `None` for the directory itself, +/// an absolute path, or one leaving the chart. +fn chart_path(name: &str) -> Option { + let delimiter = if name.contains('\\') { '\\' } else { '/' }; + let mut parts = name.split(delimiter); + parts.next()?; + let mut kept = Vec::new(); + for part in parts { + match part { + "" | "." => {} + ".." => return None, + part => kept.push(part), + } + } + if kept.is_empty() { + return None; + } + Some(kept.join("/")) +} + +/// The `path` record of a pax extended header (` path=\n`). +fn pax_path(data: &[u8]) -> Option { + let mut rest = data; + let mut path = None; + while !rest.is_empty() { + let space = rest.iter().position(|&b| b == b' ')?; + let length: usize = std::str::from_utf8(&rest[..space]).ok()?.parse().ok()?; + if length <= space + 1 || length > rest.len() { + return None; + } + let record = &rest[space + 1..length]; + let record = record.strip_suffix(b"\n").unwrap_or(record); + if let Some(value) = record.strip_prefix(b"path=") { + path = Some(String::from_utf8_lossy(value).into_owned()); + } + rest = &rest[length..]; + } + path +} + +/// A tar entry's header: what [`read_files`] needs of it. +struct Header { + name: String, + size: u64, + kind: u8, +} + +/// A minimal reader of a tar stream: ustar and GNU headers, entries read or +/// skipped in order. +struct TarReader { + inner: R, +} + +impl TarReader { + /// The next entry's header; `None` at the end of the archive (a zero + /// block), at the end of the stream, or at a header that does not parse. + fn header(&mut self) -> Option
{ + let mut block = [0u8; 512]; + self.inner.read_exact(&mut block).ok()?; + if block.iter().all(|&b| b == 0) { + return None; + } + // The checksum covers the header with its own field read as spaces, + // its bytes unsigned or, from some old writers, signed. + let stored = octal(&block[148..156])?; + let summed = |i: usize, b: u8| if (148..156).contains(&i) { b' ' } else { b }; + let unsigned: i64 = block + .iter() + .enumerate() + .map(|(i, &b)| i64::from(summed(i, b))) + .sum(); + let signed: i64 = block + .iter() + .enumerate() + .map(|(i, &b)| i64::from(summed(i, b) as i8)) + .sum(); + if ![unsigned, signed].contains(&i64::try_from(stored).ok()?) { + return None; + } + let size = octal(&block[124..136])?; + let name = field(&block[0..100]); + // ustar keeps a path prefix; GNU's header uses those bytes otherwise. + let name = if &block[257..263] == b"ustar\0" { + let prefix = field(&block[345..500]); + if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + } + } else { + name + }; + Some(Header { + name, + size, + kind: block[156], + }) + } + + /// An entry's data, at most `keep` bytes of it, the rest and the padding + /// skipped; `None` when the stream ends first. + fn data(&mut self, size: u64, keep: u64) -> Option> { + let kept = size.min(keep); + let mut data = Vec::with_capacity(usize::try_from(kept).ok()?); + (&mut self.inner).take(kept).read_to_end(&mut data).ok()?; + if data.len() as u64 != kept { + return None; + } + self.skip_raw(padded(size) - kept).then_some(data) + } + + /// Skip an entry's data and padding; `false` when the stream ends first. + fn skip(&mut self, size: u64) -> bool { + self.skip_raw(padded(size)) + } + + fn skip_raw(&mut self, n: u64) -> bool { + std::io::copy(&mut (&mut self.inner).take(n), &mut std::io::sink()) + .is_ok_and(|copied| copied == n) + } +} + +/// `size` rounded up to whole 512-byte blocks. +fn padded(size: u64) -> u64 { + size.div_ceil(512).saturating_mul(512) +} + +/// A NUL-terminated header field. +fn field(bytes: &[u8]) -> String { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).into_owned() +} + +/// An octal header number (NUL- or space-padded). Base-256 numbers (sizes +/// over 8 GiB) are refused: no chart file is that large. +fn octal(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + let text = text.trim_matches(|c: char| c == '\0' || c == ' '); + if text.is_empty() { + return Some(0); + } + u64::from_str_radix(text, 8).ok() +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + + /// A ustar header of a regular file (or `kind`), with its checksum. + fn header(path: &str, size: usize, kind: u8) -> [u8; 512] { + let mut h = [0u8; 512]; + h[..path.len()].copy_from_slice(path.as_bytes()); + h[100..108].copy_from_slice(b"0000644\0"); + h[124..136].copy_from_slice(format!("{size:011o}\0").as_bytes()); + h[156] = kind; + h[257..263].copy_from_slice(b"ustar\0"); + h[263..265].copy_from_slice(b"00"); + h[148..156].fill(b' '); + let sum: u32 = h.iter().map(|&b| u32::from(b)).sum(); + h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes()); + h + } + + fn entry(tar: &mut Vec, path: &str, data: &[u8], kind: u8) { + tar.extend_from_slice(&header(path, data.len(), kind)); + tar.extend_from_slice(data); + tar.resize(tar.len().div_ceil(512) * 512, 0); + } + + fn tgz(entries: &[(&str, &[u8])]) -> Vec { + let mut tar = Vec::new(); + for (path, data) in entries { + entry(&mut tar, path, data, b'0'); + } + gzip(tar) + } + + fn gzip(mut tar: Vec) -> Vec { + tar.extend_from_slice(&[0u8; 1024]); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + gz.write_all(&tar).unwrap(); + gz.finish().unwrap() + } + + #[test] + fn metadata_keeps_the_chart_fields_bounded() { + let long = "x".repeat(MAX_STRING + 10); + let config = serde_json::json!({ + "apiVersion": "v2", "name": "demo", "version": "1.2.3", "appVersion": "2.0", + "description": long, "type": "application", "deprecated": true, + "keywords": ["web", 7, "proxy"], + "maintainers": [{"name": "Ops", "email": "ops@example.com", "extra": "dropped"}], + "dependencies": [{"name": "redis", "version": ">=17", "repository": "oci://r/charts", "import-values": ["a"]}], + "annotations": {"dropped": "yes"}, + "icon": 3, + }); + let m = metadata(config.to_string().as_bytes()).unwrap(); + assert_eq!(m["name"], "demo"); + assert_eq!(m["appVersion"], "2.0"); + assert_eq!(m["description"].as_str().unwrap().len(), MAX_STRING); + assert_eq!(m["deprecated"], true); + assert_eq!(m["keywords"], serde_json::json!(["web", "proxy"])); + assert_eq!( + m["maintainers"], + serde_json::json!([{"name": "Ops", "email": "ops@example.com"}]) + ); + assert_eq!( + m["dependencies"], + serde_json::json!([{"name": "redis", "version": ">=17", "repository": "oci://r/charts"}]) + ); + assert!(m.get("annotations").is_none()); + assert!(m.get("icon").is_none()); + assert!(m.get("sources").is_none()); + assert!(metadata(b"[1]").is_none()); + assert!(metadata(b"not json").is_none()); + } + + #[test] + fn reads_the_top_level_readme_and_values() { + let archive = tgz(&[ + ( + "demo/Chart.yaml", + b"apiVersion: v2\nname: demo\nversion: 1.0.0\n", + ), + ("demo/charts/sub/README.md", b"the subchart"), + ("demo/charts/sub/values.yaml", b"sub: true\n"), + ("demo/Readme.MD", b"# Demo\n"), + ("demo/README.txt", b"second readme"), + ("demo/values.yaml", b"replicas: 1\n"), + ("demo/templates/values.yaml", b"not: this\n"), + ]); + let files = read_files(&archive); + let readme = files.readme.unwrap(); + assert_eq!(readme.name, "Readme.MD"); + assert_eq!(readme.text, "# Demo\n"); + assert!(!readme.truncated); + let values = files.values.unwrap(); + assert_eq!(values.name, "values.yaml"); + assert_eq!(values.text, "replicas: 1\n"); + } + + #[test] + fn long_files_are_cut_and_long_names_followed() { + let mut tar = Vec::new(); + // A GNU long name for the README, a pax path for the values. + let long_dir = format!("{}/", "d".repeat(120)); + let readme = format!("{long_dir}README.md"); + entry( + &mut tar, + "././@LongLink", + format!("{readme}\0").as_bytes(), + b'L', + ); + let big = "a".repeat(MAX_FILE_TEXT + 100); + entry(&mut tar, "truncated-name", big.as_bytes(), b'0'); + let line = format!(" path={long_dir}values.yaml\n"); + let mut length = line.len(); + while format!("{length}{line}").len() != length { + length += 1; + } + let pax = format!("{length}{line}"); + entry(&mut tar, "PaxHeaders/values", pax.as_bytes(), b'x'); + entry(&mut tar, "values.yaml-short", b"x: 1\n", b'0'); + let files = read_files(&gzip(tar)); + let readme = files.readme.unwrap(); + assert!(readme.truncated); + assert_eq!(readme.text.len(), MAX_FILE_TEXT); + assert_eq!(files.values.unwrap().text, "x: 1\n"); + } + + #[test] + fn what_is_not_a_chart_yields_nothing() { + assert_eq!(read_files(b"not gzip"), ChartFiles::default()); + assert_eq!( + read_files(&gzip(b"not a tar header".to_vec())), + ChartFiles::default() + ); + // Paths outside the chart, or the chart's directory alone. + let archive = tgz(&[("demo/../README.md", b"out"), ("README.md", b"top")]); + assert_eq!(read_files(&archive), ChartFiles::default()); + // A truncated archive keeps what came before the cut. + let mut noise = Vec::with_capacity(64 * 1024); + let mut x: u32 = 1; + for _ in 0..64 * 1024 { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + noise.push((x >> 16) as u8); + } + let archive = tgz(&[ + ("demo/README.md", b"kept"), + ("demo/templates/noise", &noise), + ("demo/values.yaml", b"v: 1\n"), + ]); + let files = read_files(&archive[..archive.len() / 2]); + assert_eq!(files.readme.unwrap().text, "kept"); + assert_eq!(files.values, None); + } + + #[test] + fn chart_paths_follow_helms_loader() { + assert_eq!(chart_path("demo/README.md").as_deref(), Some("README.md")); + assert_eq!( + chart_path("./demo/README.md").as_deref(), + Some("demo/README.md") + ); + assert_eq!( + chart_path("demo\\values.yaml").as_deref(), + Some("values.yaml") + ); + assert_eq!( + chart_path("demo/./values.yaml").as_deref(), + Some("values.yaml") + ); + assert_eq!(chart_path("demo/"), None); + assert_eq!(chart_path("demo/a/../../x"), None); + } +} diff --git a/crates/format-oci/src/lib.rs b/crates/format-oci/src/lib.rs index 5e400a1a..5e35fff2 100644 --- a/crates/format-oci/src/lib.rs +++ b/crates/format-oci/src/lib.rs @@ -5,6 +5,8 @@ //! manifest parsing (Docker v2 schema 2 manifests and manifest lists, OCI //! image manifests and indexes, artifacts with `artifactType`/`subject`); //! * [`token`] — the registry Bearer tokens of the Docker token protocol; +//! * [`chart`] — Helm charts stored as artifacts: their metadata, README and +//! values, for the web UI; //! * [`Registry`] — the service answering every `/v2` request; //! * [`import`] — copying images from another registry (a Nexus hosted //! repository) into a hosted repository. @@ -12,6 +14,7 @@ //! Repositories are addressed by path (`//`) or, //! with `config.host`, by host name (`/`). +pub mod chart; pub mod digest; pub mod error; pub mod gc; diff --git a/crates/plugin-api/src/lib.rs b/crates/plugin-api/src/lib.rs index 1271f759..5e57f4f1 100644 --- a/crates/plugin-api/src/lib.rs +++ b/crates/plugin-api/src/lib.rs @@ -10,7 +10,7 @@ //! A plugin is a crate under `plugins//` implementing [`FormatPlugin`], //! with a `plugin.toml` naming it. The same crate runs either way: //! -//! * **built in** — linked into the server and called directly (Helm); +//! * **built in** — linked into the server and called directly (NuGet); //! * **as a WebAssembly component** — built for `wasm32-wasip2` with this //! crate's `component` feature and `export_component!`, then loaded at //! startup into a sandbox, from a digest the operator pinned. diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index dcb00a99..1cb7be01 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -30,7 +30,7 @@ wasmtime-wasi = { workspace = true } [dev-dependencies] forge-plugin = { path = ".", features = ["testing"] } -# The built-in Helm plugin, to compare with its component. +# The Helm example plugin built in, to compare with its component. forge-format-helm = { path = "../../plugins/helm" } flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] } # Hand-built components for the sandbox tests: the version Wasmtime itself diff --git a/crates/plugin/src/format.rs b/crates/plugin/src/format.rs index 52074175..350cc930 100644 --- a/crates/plugin/src/format.rs +++ b/crates/plugin/src/format.rs @@ -19,14 +19,14 @@ use crate::Plugin; /// larger than about 2,700 bytes: a plugin without a limit of its own let a /// multi-kilobyte version through to that insert, so a push answered 500 /// instead of 400, and so did a proxy asked for such a version. 256 is above -/// every built-in plugin's own limit (NuGet's 64 characters, Helm's 128) and, +/// the plugins' own limits (NuGet's 64 characters, Helm's 128) and, /// with [`MAX_ID_LENGTH`], keeps an entry far below PostgreSQL's. const MAX_VERSION_LENGTH: usize = 256; /// The longest package id the host takes from a format plugin, in bytes: /// an id to validate, and a parsed package's id as written and canonical. -/// Ids share the indexes of [`MAX_VERSION_LENGTH`]. 512 is above every -/// built-in plugin's own limit (Helm's 250 ASCII characters, NuGet's 100 +/// Ids share the indexes of [`MAX_VERSION_LENGTH`]. 512 is above the +/// plugins' own limits (Helm's 250 ASCII characters, NuGet's 100 /// characters, at most 400 bytes in UTF-8). const MAX_ID_LENGTH: usize = 512; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 7d22a772..6d56b38b 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -21,9 +21,8 @@ forge-cache = { path = "../cache" } forge-core = { path = "../core" } forge-crypto = { path = "../crypto" } forge-plugin = { path = "../plugin" } -# Built-in format plugins (plugins//), linked in. -forge-format-helm = { path = "../../plugins/helm" } forge-oci = { path = "../format-oci" } +# The built-in format plugin (plugins/nuget), linked in. forge-format-nuget = { path = "../../plugins/nuget" } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs", "io-util", "time", "signal"] } @@ -84,7 +83,9 @@ figment = { version = "0.10", features = ["toml", "env"] } [dev-dependencies] forge-plugin = { path = "../plugin", features = ["testing"] } -# Builds chart archives for the format-plugin tests. +# The example format plugin the format-plugin tests serve repositories with +# (plugins/helm, not built in), and the chart archives they push to it. +forge-format-helm = { path = "../../plugins/helm" } flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs", "io-util", "time", "test-util"] } testcontainers = "0.27" diff --git a/crates/server/src/admin/formats.rs b/crates/server/src/admin/formats.rs index d31b21e3..319bc9ec 100644 --- a/crates/server/src/admin/formats.rs +++ b/crates/server/src/admin/formats.rs @@ -1,6 +1,6 @@ //! `GET /admin/formats` — the package formats this server can create -//! repositories of: the built-in format plugins (NuGet, Helm), Docker, raw -//! files, and every loaded WebAssembly plugin. +//! repositories of: the built-in format plugin (NuGet), Docker, raw files, +//! and every loaded WebAssembly plugin. use axum::extract::State; use axum::response::{IntoResponse, Json, Response}; diff --git a/crates/server/src/admin/proxy_cache.rs b/crates/server/src/admin/proxy_cache.rs index 672c3820..a109e2b5 100644 --- a/crates/server/src/admin/proxy_cache.rs +++ b/crates/server/src/admin/proxy_cache.rs @@ -56,7 +56,7 @@ pub struct InvalidateBody { /// What an invalidation removed. #[derive(Debug, Serialize)] pub struct Invalidated { - /// Not-found answers removed (NuGet and Helm proxies; a docker proxy + /// Not-found answers removed (NuGet and plugin proxies; a docker proxy /// keeps its own in memory, forgotten too but not counted). pub not_found_cleared: u64, /// Tags due for re-validation (docker proxies). diff --git a/crates/server/src/admin/repositories.rs b/crates/server/src/admin/repositories.rs index 2e3b48ef..07f1285f 100644 --- a/crates/server/src/admin/repositories.rs +++ b/crates/server/src/admin/repositories.rs @@ -549,8 +549,8 @@ pub async fn create(State(state): State, Json(body): Json) "hosted" | "proxy" | "group" => body.kind.clone(), other => return bad_request(format!("invalid kind {other:?}")), }; - // The format must be installed: Docker, a built-in format plugin (NuGet, - // Helm) or a loaded WebAssembly one (`GET /admin/formats`), and support + // The format must be installed: Docker, raw, the built-in format plugin + // (NuGet) or a loaded WebAssembly one (`GET /admin/formats`), and support // the kind. It is fixed for the repository's lifetime. let Some(kinds) = state.formats.kinds(&body.format) else { let available: Vec<&str> = state.formats.ids().collect(); diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index f34c4224..7c0bf839 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -107,7 +107,7 @@ pub fn principal_layer(state: &AppState) -> anyhow::Result { /// * `/browse/...` — read-side browse / search (signed-in users only; per-repo /// read authz enforced inside the handlers). /// * `{mount}/{repo}/...` — repositories, under their format's mount (NuGet: -/// `/v3`; Helm and other plugins: `/repository` unless their plugin names +/// `/v3`; other plugins: `/repository` unless their plugin names /// another), each served by its format's plugin ([`crate::plugins`]). /// * everything else — the SPA (when `web.enabled`), whose admin screens live /// under `/manage/*` (see [`crate::web_ui`]). diff --git a/crates/server/src/browse.rs b/crates/server/src/browse.rs index 43eed3f3..e2685d31 100644 --- a/crates/server/src/browse.rs +++ b/crates/server/src/browse.rs @@ -659,6 +659,7 @@ pub fn browse_router() -> Router { .route("/browse/packages", get(search_packages)) .route("/browse/packages/{repo}/{id}", get(package_detail)) .route("/browse/images", get(crate::browse_images::list_images)) + .route("/browse/chart", get(crate::browse_images::chart_detail)) .route( "/browse/images/{repo}/{*image}", get(crate::browse_images::image_detail), diff --git a/crates/server/src/browse_images.rs b/crates/server/src/browse_images.rs index 6de034d3..90ff75a0 100644 --- a/crates/server/src/browse_images.rs +++ b/crates/server/src/browse_images.rs @@ -12,16 +12,22 @@ //! registry, pull_prefix }`. //! * `GET /browse/images/{repo}/{*image}` → the image's tags and manifests, //! with the `docker pull` name. +//! * `GET /browse/chart?repo=&image=&digest=` +//! → a Helm chart pushed as an artifact (`helm push`): its `Chart.yaml` +//! metadata, README and default values, read from the blobs Forge holds. use std::collections::{BTreeMap, HashMap, HashSet}; use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; +use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Json, Response}; use chrono::{DateTime, Utc}; use forge_core::auth::principal::Principal; use forge_db::model::{RepoKind, Repository}; use forge_db::{oci_dao, repository_dao}; +use forge_oci::chart; +use forge_oci::manifest::{self, ManifestKind, MAX_MANIFEST_BYTES}; +use forge_storage::BlobRef; use serde::{Deserialize, Serialize}; use crate::admin::db_error; @@ -384,3 +390,240 @@ fn platforms(summary: &serde_json::Value) -> Vec { .map(|p| vec![p.to_string()]) .unwrap_or_default() } + +#[derive(Debug, Deserialize)] +pub struct ChartQuery { + pub repo: String, + pub image: String, + /// The chart's manifest digest. + pub digest: String, +} + +/// A text file of a chart (README, `values.yaml`). +#[derive(Debug, Serialize)] +pub struct ChartFileView { + pub name: String, + pub text: String, + /// Whether the text was cut at [`chart::MAX_FILE_TEXT`] bytes. + pub truncated: bool, +} + +impl From for ChartFileView { + fn from(file: chart::ChartFile) -> Self { + Self { + name: file.name, + text: file.text, + truncated: file.truncated, + } + } +} + +/// A Helm chart pushed as an OCI artifact. +#[derive(Debug, Serialize)] +pub struct ChartDetail { + pub digest: String, + /// The chart's `Chart.yaml` (name, version, appVersion, description, + /// …), from its config blob; `None` while the blob is not stored here. + pub metadata: Option, + pub readme: Option, + pub values: Option, + /// Whether the chart archive is stored here. A proxy stores a chart's + /// blobs when it is first pulled, so until then it shows no README or + /// values. + pub archive_stored: bool, + /// The member repository it comes from (groups only). + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +/// The chart archive layer's media types: Helm's, and the plain one of the +/// experimental OCI support of Helm before 3.8. +fn is_chart_layer(media_type: &str) -> bool { + media_type == chart::media::CONTENT || media_type == "application/tar+gzip" +} + +/// Why a stored blob could not be read. +enum ReadFailure { + Missing, + Response(Response), +} + +/// Read the blob `digest` linked into `image` of `repo`, if it is stored and +/// at most `max` bytes. +async fn read_linked( + state: &AppState, + repo: &Repository, + image: &str, + digest: &str, + max: i64, +) -> Result, ReadFailure> { + let link = oci_dao::find_link(&state.pool, repo.id, image, digest) + .await + .map_err(|e| ReadFailure::Response(db_error(e)))? + .ok_or(ReadFailure::Missing)?; + if link.size > max { + return Err(ReadFailure::Missing); + } + read_blob(state, &link.blob_sha256, &link.s3_key, link.size) + .await + .map_err(ReadFailure::Response) +} + +/// Read a stored blob into memory. +async fn read_blob( + state: &AppState, + sha256: &str, + s3_key: &str, + size: i64, +) -> Result, Response> { + let blob = BlobRef { + sha256, + s3_key, + size: size.max(0) as u64, + }; + match state.blob_store.read_all(blob).await { + Ok(bytes) if bytes.len() as u64 == blob.size => Ok(bytes), + Ok(bytes) => { + tracing::error!( + s3_key, + read = bytes.len(), + expected = blob.size, + "browse: stored blob has the wrong size" + ); + Err((StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()) + } + Err(error) if error.is_unavailable() => { + tracing::warn!(%error, "browse: object store unavailable"); + Err(( + StatusCode::SERVICE_UNAVAILABLE, + [(header::RETRY_AFTER, "30")], + "object store unavailable", + ) + .into_response()) + } + Err(error) => { + tracing::error!(%error, s3_key, "browse: blob read failed"); + Err((StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()) + } + } +} + +/// `GET /browse/chart?repo=…&image=…&digest=…` +pub async fn chart_detail( + State(state): State, + Query(query): Query, + req: axum::extract::Request, +) -> Response { + let principal = principal_of(&req); + let repo = match readable_docker_repo(&state, &principal, &query.repo).await { + Ok(repo) => repo, + Err(resp) => return resp, + }; + let not_found = || (StatusCode::NOT_FOUND, "chart not found").into_response(); + let image = query.image; + if !forge_oci::reference::valid_name(&image) + || forge_oci::digest::Digest::parse(&query.digest).is_err() + { + return not_found(); + } + let sources = match sources(&state, &principal, &repo).await { + Ok(s) => s, + Err(e) => return db_error(e), + }; + // The manifest the highest-priority member holds, as a pull by digest + // resolves it. + let mut found = None; + for source in sources { + if !state + .authz + .package_filter(&principal, &source) + .await + .allows(&image) + { + continue; + } + match oci_dao::find_manifest(&state.pool, source.id, &image, &query.digest).await { + Ok(Some(row)) => { + found = Some((source, row)); + break; + } + Ok(None) => {} + Err(e) => return db_error(e), + } + } + let Some((source, row)) = found else { + return not_found(); + }; + if usize::try_from(row.size).map_or(true, |size| size > MAX_MANIFEST_BYTES) { + return not_found(); + } + let bytes = match read_blob(&state, &row.blob_sha256, &row.s3_key, row.size).await { + Ok(bytes) => bytes, + Err(resp) => return resp, + }; + let Ok(parsed) = manifest::parse(&bytes, Some(&row.media_type)) else { + return not_found(); + }; + let config = parsed.config.as_ref(); + let is_chart = parsed.kind == ManifestKind::Image + && (config.is_some_and(|c| c.media_type == chart::media::CONFIG) + || parsed.artifact_type.as_deref() == Some(chart::media::CONFIG)); + if !is_chart { + return not_found(); + } + + let mut metadata = None; + if let Some(config) = config.filter(|c| c.media_type == chart::media::CONFIG) { + match read_linked( + &state, + &source, + &image, + config.digest.as_str(), + chart::MAX_CONFIG_BYTES, + ) + .await + { + Ok(bytes) => metadata = chart::metadata(&bytes), + Err(ReadFailure::Missing) => {} + Err(ReadFailure::Response(resp)) => return resp, + } + } + let mut files = chart::ChartFiles::default(); + let mut archive_stored = false; + if let Some(layer) = parsed.layers.iter().find(|l| is_chart_layer(&l.media_type)) { + match read_linked( + &state, + &source, + &image, + layer.digest.as_str(), + chart::MAX_ARCHIVE_BYTES, + ) + .await + { + Ok(archive) => { + archive_stored = true; + files = match tokio::task::spawn_blocking(move || chart::read_files(&archive)).await + { + Ok(files) => files, + Err(e) => { + tracing::error!(error = %e, "browse: reading a chart archive failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, "internal error") + .into_response(); + } + }; + } + Err(ReadFailure::Missing) => {} + Err(ReadFailure::Response(resp)) => return resp, + } + } + let group = repo.kind() == RepoKind::Group; + Json(ChartDetail { + digest: row.digest, + metadata, + readme: files.readme.map(Into::into), + values: files.values.map(Into::into), + archive_stored, + source: group.then(|| source.name.clone()), + }) + .into_response() +} diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index b2b4e7c0..63164ae5 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -65,7 +65,7 @@ pub struct Config { pub nuget: NugetConfig, /// WebAssembly format plugins: further package formats loaded at startup /// (see `docs/format-plugins.md`). None are loaded by default; the - /// built-in plugins (Helm) need no configuration. + /// built-in plugin (NuGet) needs no configuration here. #[serde(default)] pub plugins: PluginsConfig, /// Docker / OCI registry (`/v2`): size limits, registry tokens, upload @@ -1500,7 +1500,7 @@ fn default_nuget_max_nuspec_compression_ratio() -> u64 { /// `plugins.*` — WebAssembly format plugins. /// -/// The format plugins built into the server (Helm) are always installed. +/// The format plugin built into the server (NuGet) is always installed. /// Further ones are WebAssembly components, each listed explicitly with the /// SHA-256 of its component: a directory under `dir` that is not listed is /// never loaded, and a listed one whose component does not match its pin, or @@ -4181,7 +4181,6 @@ mod tests { #[test] fn default_upload_limits_and_the_repository_ceiling() { let cfg = Config::from_figment(fig_from_toml(MINIMAL)).expect("valid"); - assert_eq!(cfg.default_upload_limit("helm"), DEFAULT_MAX_UPLOAD_BYTES); assert_eq!(cfg.default_upload_limit("nuget"), DEFAULT_MAX_UPLOAD_BYTES); assert_eq!( cfg.default_upload_limit("docker"), @@ -4212,7 +4211,7 @@ mod tests { } // `[plugins.max_upload_bytes]` is gone: limits are per repository. - let per_format = format!("{MINIMAL}\n[plugins.max_upload_bytes]\nhelm = 4096\n"); + let per_format = format!("{MINIMAL}\n[plugins.max_upload_bytes]\nnuget = 4096\n"); assert!(Config::from_figment(fig_from_toml(&per_format)).is_err()); } diff --git a/crates/server/src/nexus_import/mod.rs b/crates/server/src/nexus_import/mod.rs index a1ce815c..cbb7a4fd 100644 --- a/crates/server/src/nexus_import/mod.rs +++ b/crates/server/src/nexus_import/mod.rs @@ -1,5 +1,5 @@ //! Importing a Nexus hosted repository into a Forge hosted repository of the -//! same format, one repository at a time (NuGet, Helm, Docker). +//! same format, one repository at a time (NuGet, Docker). //! //! An administrator starts an import on a hosted repository //! ([`crate::admin::imports`]); this worker runs it, on one instance at a @@ -16,7 +16,7 @@ //! too, so referrers (signatures, SBOMs) come along. The scan also //! counts every asset of the repository and its size. //! 2. **Copy.** Pending items are copied, `concurrency` at a time: -//! * a package (NuGet, Helm) is streamed from Nexus into staging, +//! * a package (NuGet) is streamed from Nexus into staging, //! verified against Nexus' checksums (SHA-256 and SHA-512, else SHA-1) //! and size, and written through the hosted write path //! ([`HostedWrite::import_staged`]): format validation, the package @@ -75,7 +75,7 @@ use crate::state::AppState; use nexus::{Listing, Nexus, SourceAsset, SourceItem}; /// The formats an import copies (Nexus calls them by the same names). -pub const FORMATS: &[&str] = &["nuget", "helm", DOCKER_FORMAT]; +pub const FORMATS: &[&str] = &["nuget", DOCKER_FORMAT]; /// How often an idle instance looks for an import to run. const POLL_INTERVAL: Duration = Duration::from_secs(5); @@ -294,7 +294,7 @@ impl Run<'_> { .ok_or_else(|| End::Failed("the repository was deleted".into()))?; if repo.kind() != RepoKind::Hosted || !FORMATS.contains(&repo.format.as_str()) { return Err(End::Failed(format!( - "imports go to a hosted NuGet, Helm or Docker repository; {} is a {} {} repository", + "imports go to a hosted NuGet or Docker repository; {} is a {} {} repository", repo.name, repo.format, repo.kind ))); } @@ -808,15 +808,13 @@ fn published_at(format: &str, asset: &SourceAsset) -> Option/`), Helm's chart `.tgz` (not its `.prov`), -/// any other format's only asset. +/// keeps it under `/`), any other format's only asset. fn package_asset<'a>(format: &str, assets: &'a [SourceAsset]) -> Option<&'a SourceAsset> { match format { "nuget" => assets .iter() .find(|a| a.path.ends_with(".nupkg")) .or_else(|| assets.iter().find(|a| !a.path.ends_with(".nuspec"))), - "helm" => assets.iter().find(|a| a.path.ends_with(".tgz")), _ => match assets { [only] => Some(only), _ => None, @@ -1023,9 +1021,8 @@ mod tests { package_asset("nuget", &nuget).unwrap().path, "Sample.Lib/1.0.0" ); - let helm = [asset("app-1.0.0.tgz.prov"), asset("app-1.0.0.tgz")]; - assert_eq!(package_asset("helm", &helm).unwrap().path, "app-1.0.0.tgz"); - assert!(package_asset("raw", &helm).is_none()); + let two = [asset("app-1.0.0.tgz.prov"), asset("app-1.0.0.tgz")]; + assert!(package_asset("raw", &two).is_none()); } #[test] diff --git a/crates/server/src/plugins.rs b/crates/server/src/plugins.rs index fdcf1994..c0dc68ec 100644 --- a/crates/server/src/plugins.rs +++ b/crates/server/src/plugins.rs @@ -98,18 +98,14 @@ pub const PASSED_RESPONSE_HEADERS: &[&str] = &[ ]; /// The format plugins built into this server: crates under `plugins//`, -/// linked in and always installed. NuGet reads archives under +/// linked in and always installed (NuGet). NuGet reads archives under /// `nuget_limits` (`[nuget]`). pub fn builtin(nuget_limits: forge_format_nuget::NupkgLimits) -> Vec> { - vec![ - Plugin::builtin( - forge_format_nuget::PLUGIN_TOML, - forge_format_nuget::Nuget::with_limits(nuget_limits), - ) - .expect("plugins/nuget/plugin.toml is valid"), - Plugin::builtin(forge_format_helm::PLUGIN_TOML, forge_format_helm::Helm) - .expect("plugins/helm/plugin.toml is valid"), - ] + vec![Plugin::builtin( + forge_format_nuget::PLUGIN_TOML, + forge_format_nuget::Nuget::with_limits(nuget_limits), + ) + .expect("plugins/nuget/plugin.toml is valid")] } /// Load the WebAssembly plugins `config` lists, each from `/`, diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index a7d04acc..68d8c6da 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -55,7 +55,7 @@ pub struct AppState { /// through this. pub authz: Authz, /// Package-format registry, keyed by `Format::id()`: the built-in format - /// plugins (NuGet, Helm) and any loaded WebAssembly plugins. + /// plugin (NuGet) and any loaded WebAssembly plugins. pub formats: Arc, /// Parsed server configuration. pub config: Arc, @@ -139,8 +139,8 @@ impl Instance { } /// Format registry: maps a format id (the `repository.format` column) to its -/// [`Format`] and its plugin. The built-in format plugins -/// ([`crate::plugins::builtin`]: NuGet, Helm), Docker and raw are always +/// [`Format`] and its plugin. The built-in format plugin +/// ([`crate::plugins::builtin`]: NuGet), Docker and raw are always /// there; WebAssembly plugins are added at startup /// ([`FormatRegistry::with_plugin`]). /// @@ -700,7 +700,7 @@ mod tests { assert!(registry.get(DOCKER_FORMAT).is_none()); assert_eq!( registry.ids().collect::>(), - ["docker", "helm", "nuget", "raw"] + ["docker", "nuget", "raw"] ); assert!(registry.is_installed(RAW_FORMAT)); assert!(registry.get(RAW_FORMAT).is_none()); @@ -716,10 +716,7 @@ mod tests { registry.kinds("nuget"), Some(vec!["hosted", "proxy", "group"]) ); - assert_eq!( - registry.kinds("helm"), - Some(vec!["hosted", "proxy", "group"]) - ); + assert_eq!(registry.kinds("helm"), None); assert_eq!(registry.kinds("npm"), None); assert!(!registry.is_installed("npm")); } diff --git a/crates/server/tests/browse.rs b/crates/server/tests/browse.rs index 60632ef2..f11f2e10 100644 --- a/crates/server/tests/browse.rs +++ b/crates/server/tests/browse.rs @@ -363,7 +363,7 @@ async fn browse_repos_by_format_with_client_urls() { let suffix = common::unique(); let created: Vec<(String, &str)> = vec![ (format!("nu{suffix}"), "nuget"), - (format!("charts{suffix}"), "helm"), + (format!("files{suffix}"), "raw"), (format!("images{suffix}"), "docker"), ]; for (name, format) in &created { @@ -407,20 +407,20 @@ async fn browse_repos_by_format_with_client_urls() { nuget["url"], format!("http://test.local/v3/{}", created[0].0) ); - let helm = entry(&created[1].0); - assert_eq!(helm["format"], "helm"); + let raw = entry(&created[1].0); + assert_eq!(raw["format"], "raw"); assert_eq!( - helm["url"], - format!("http://test.local/repository/{}", created[1].0) + raw["url"], + format!("http://test.local/raw/{}", created[1].0) ); let docker = entry(&created[2].0); assert_eq!(docker["format"], "docker"); assert!(docker.get("url").is_none(), "{docker}"); - let charts = list("?format=helm").await; - let charts = charts.as_array().unwrap(); - assert!(charts.iter().all(|r| r["format"] == "helm"), "{charts:?}"); - assert!(charts.iter().any(|r| r["name"] == json!(created[1].0))); + let files = list("?format=raw").await; + let files = files.as_array().unwrap(); + assert!(files.iter().all(|r| r["format"] == "raw"), "{files:?}"); + assert!(files.iter().any(|r| r["name"] == json!(created[1].0))); assert!(list("?format=npm").await.as_array().unwrap().is_empty()); } diff --git a/crates/server/tests/plugin_contract.rs b/crates/server/tests/plugin_contract.rs index d5b26c7c..c5de24bf 100644 --- a/crates/server/tests/plugin_contract.rs +++ b/crates/server/tests/plugin_contract.rs @@ -295,16 +295,16 @@ async fn a_format_is_served_under_its_own_mount_and_kinds() { assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{url}"); assert_ne!(resp.text().await.unwrap(), "nothing here", "{url}"); } - let charts = format!("charts{}", unique()); + let feed = format!("feed{}", unique()); let resp = create_repo( &srv, - json!({ "name": charts, "kind": "hosted", "format": "helm" }), + json!({ "name": feed, "kind": "hosted", "format": "nuget" }), ) .await; assert_eq!(resp.status(), StatusCode::CREATED); let resp = srv .client() - .get(format!("{}/probe/{charts}/nothing", srv.base_url)) + .get(format!("{}/probe/{feed}/nothing", srv.base_url)) .basic_auth(&creds.0, Some(&creds.1)) .send() .await @@ -340,13 +340,13 @@ async fn a_format_is_served_under_its_own_mount_and_kinds() { .unwrap(); assert_eq!(probe["mount"], "/probe"); assert_eq!(probe["kinds"], json!(["hosted"])); - let helm = formats + let nuget = formats .as_array() .unwrap() .iter() - .find(|f| f["id"] == "helm") + .find(|f| f["id"] == "nuget") .unwrap(); - assert_eq!(helm["mount"], "/repository"); + assert_eq!(nuget["mount"], "/v3"); } #[tokio::test] diff --git a/crates/server/tests/plugins.rs b/crates/server/tests/plugins.rs index ec6cf740..30a9d400 100644 --- a/crates/server/tests/plugins.rs +++ b/crates/server/tests/plugins.rs @@ -1,5 +1,6 @@ -//! Format plugins end to end: the built-in Helm plugin (`plugins/helm`), its -//! repositories created through the admin API and used through +//! Format plugins end to end, through the example plugin `plugins/helm` +//! (Helm chart repositories; Forge does not build it in): installed as if +//! built in, its repositories created through the admin API and used through //! `/repository/{repo}` the way Helm clients use a chart repository — and the //! same crate built as a WebAssembly component, loaded at startup. @@ -12,8 +13,8 @@ use reqwest::StatusCode; use serde_json::{json, Value}; use sha2::Digest; -/// The format id the Helm component is installed under (the built-in Helm -/// plugin holds `helm`). +/// The format id the Helm component is installed under (the Helm plugin +/// installed as if built in holds `helm`). const WASM_HELM: &str = "helm-wasm"; /// The `[plugins]` block loading the Helm plugin built as a component, @@ -25,7 +26,15 @@ fn wasm_helm_plugins_block() -> String { format!("[plugins]\ndir = {parent:?}\n\n[plugins.formats]\n{WASM_HELM} = \"sha256:{sha}\"\n") } -async fn start_helm(opts: ServerOpts) -> TestServer { +/// The Helm plugin, to install as if built in. +fn helm_plugin() -> std::sync::Arc { + forge_plugin::Plugin::builtin(forge_format_helm::PLUGIN_TOML, forge_format_helm::Helm) + .expect("plugins/helm/plugin.toml is valid") +} + +/// A server with the Helm plugin installed as if built in. +async fn start_helm(mut opts: ServerOpts) -> TestServer { + opts.extra_plugins.push(helm_plugin()); start_with( common::pool().await, ServerOpts { @@ -422,6 +431,7 @@ async fn plugin_repositories_are_authorized_like_feeds() { common::fresh_pool().await, ServerOpts { bootstrap_admin: Some((format!("admin{}@example.com", unique()), "adminpw".into())), + extra_plugins: vec![helm_plugin()], ..Default::default() }, ) @@ -605,8 +615,9 @@ async fn a_repository_has_an_upload_limit_of_its_own() { #[tokio::test] async fn a_repository_whose_plugin_is_missing_is_unavailable() { - // A server without the plugin, over a repository created with it. - let srv = common::start().await; + // A server without the plugin, over a repository created with it, under + // a mount another plugin serves. + let srv = start_helm(ServerOpts::default()).await; let name = format!("orphan{}", unique()); let mut row = forge_db::repository_dao::NewRepository::hosted(name.clone()); row.format = WASM_HELM.into(); @@ -697,7 +708,7 @@ async fn a_webassembly_plugin_serves_like_a_builtin_one() { /// A WebAssembly plugin never replaces a built-in format. #[test] fn a_component_cannot_take_a_builtin_format_id() { - let dir = forge_plugin::testing::example_plugin("helm"); + let dir = forge_plugin::testing::example_plugin_as("helm", "nuget"); let plugin = forge_plugin::Plugin::load( &dir, &forge_plugin::testing::component_sha256(&dir), @@ -707,7 +718,7 @@ fn a_component_cannot_take_a_builtin_format_id() { let err = forge_server::state::FormatRegistry::new(Default::default()) .with_plugin(plugin) .err() - .expect("helm is taken"); + .expect("nuget is taken"); assert!(err.contains("built into this server"), "{err}"); } diff --git a/crates/server/tests/registry.rs b/crates/server/tests/registry.rs index bb11748b..206f5b15 100644 --- a/crates/server/tests/registry.rs +++ b/crates/server/tests/registry.rs @@ -1113,6 +1113,195 @@ async fn multi_arch_index_and_referrers() { assert_eq!(detail["pull_name"], format!("test.local/{repo}/multi")); } +const HELM_CONFIG: &str = "application/vnd.cncf.helm.config.v1+json"; +const HELM_CHART: &str = "application/vnd.cncf.helm.chart.content.v1.tar+gzip"; + +/// A gzipped ustar archive of `(path, contents)` entries. +fn tgz(entries: &[(&str, &[u8])]) -> Vec { + use std::io::Write as _; + let mut tar = Vec::new(); + for (path, data) in entries { + let mut h = [0u8; 512]; + h[..path.len()].copy_from_slice(path.as_bytes()); + h[100..108].copy_from_slice(b"0000644\0"); + h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes()); + h[156] = b'0'; + h[257..263].copy_from_slice(b"ustar\0"); + h[263..265].copy_from_slice(b"00"); + h[148..156].fill(b' '); + let sum: u32 = h.iter().map(|&b| u32::from(b)).sum(); + h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes()); + tar.extend_from_slice(&h); + tar.extend_from_slice(data); + tar.resize(tar.len().div_ceil(512) * 512, 0); + } + tar.extend_from_slice(&[0u8; 1024]); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + gz.write_all(&tar).unwrap(); + gz.finish().unwrap() +} + +/// A Helm chart as `helm push` stores it: its config blob, chart archive +/// and manifest. +fn helm_chart(name: &str, version: &str) -> (Vec, Vec, Vec) { + let config = json!({ + "apiVersion": "v2", "name": name, "version": version, "appVersion": "2.0", + "description": format!("The {name} chart"), "type": "application", + }) + .to_string() + .into_bytes(); + let chart_yaml = + format!("apiVersion: v2\nname: {name}\nversion: {version}\nappVersion: \"2.0\"\n"); + let archive = tgz(&[ + (&format!("{name}/Chart.yaml"), chart_yaml.as_bytes()), + (&format!("{name}/values.yaml"), b"replicas: 1\n"), + ( + &format!("{name}/README.md"), + format!("# {name}\n").as_bytes(), + ), + (&format!("{name}/charts/sub/README.md"), b"# a subchart\n"), + ]); + let manifest = json!({ + "schemaVersion": 2, + "mediaType": OCI_MANIFEST, + "config": {"mediaType": HELM_CONFIG, "digest": sha(&config), "size": config.len()}, + "layers": [{"mediaType": HELM_CHART, "digest": sha(&archive), "size": archive.len()}], + "annotations": {"org.opencontainers.image.title": name, "org.opencontainers.image.version": version}, + }) + .to_string() + .into_bytes(); + (config, archive, manifest) +} + +/// Helm charts pushed as artifacts: the image page lists them by their +/// config type, and `/browse/chart` reads their metadata, README and values +/// from the blobs Forge holds, through a group too. +#[tokio::test] +async fn helm_charts_are_browsable() { + let srv = common::start().await; + let repo = repo_name("charts"); + let group = repo_name("group"); + create_repo( + &srv, + json!({"name": repo, "kind": "hosted", "format": "docker"}), + ) + .await; + create_repo( + &srv, + json!({"name": group, "kind": "group", "format": "docker"}), + ) + .await; + let resp = admin_post( + &srv, + &format!("/admin/repositories/{group}/members"), + json!({"member": repo, "priority": 10}), + ) + .await; + assert_eq!(resp.status(), 201); + let oci = admin_oci(&srv).await; + let name = format!("{repo}/charts/demo"); + let (config, archive, manifest) = helm_chart("demo", "1.2.3"); + oci.push_blob(&name, &config).await; + oci.push_blob(&name, &archive).await; + let resp = oci + .put_manifest(&name, "1.2.3", OCI_MANIFEST, &manifest) + .await; + assert_eq!(resp.status(), 201, "{}", resp.text().await.unwrap()); + let digest = sha(&manifest); + let image_digest = oci + .push_image(&format!("{repo}/charts/app"), "1", "amd64", b"a layer") + .await; + + let session = srv + .login(srv.admin_user.as_deref().unwrap(), "adminpw") + .await; + let get = |path: String| { + let request = session.apply(srv.client().get(format!("{}{path}", srv.base_url))); + async move { request.send().await.unwrap() } + }; + let detail: Value = get(format!("/browse/images/{repo}/charts/demo")) + .await + .json() + .await + .unwrap(); + assert_eq!(detail["manifests"][0]["artifact_type"], HELM_CONFIG); + assert_eq!(detail["tags"][0]["tag"], "1.2.3"); + + for (r, source) in [(&repo, None), (&group, Some(repo.as_str()))] { + let resp = get(format!( + "/browse/chart?repo={r}&image=charts/demo&digest={digest}" + )) + .await; + assert_eq!(resp.status(), 200); + let chart: Value = resp.json().await.unwrap(); + assert_eq!(chart["digest"], digest.as_str()); + assert_eq!(chart["metadata"]["name"], "demo"); + assert_eq!(chart["metadata"]["version"], "1.2.3"); + assert_eq!(chart["metadata"]["appVersion"], "2.0"); + assert_eq!(chart["metadata"]["description"], "The demo chart"); + assert_eq!(chart["readme"]["name"], "README.md"); + assert_eq!(chart["readme"]["text"], "# demo\n"); + assert_eq!(chart["readme"]["truncated"], false); + assert_eq!(chart["values"]["text"], "replicas: 1\n"); + assert_eq!(chart["archive_stored"], true); + assert_eq!(chart.get("source").and_then(Value::as_str), source); + } + + // Not a chart, not there, not a digest. + for query in [ + format!("repo={repo}&image=charts/app&digest={image_digest}"), + format!("repo={repo}&image=charts/demo&digest={image_digest}"), + format!("repo={repo}&image=charts/demo&digest=latest"), + format!("repo={repo}&image=..&digest={digest}"), + ] { + let resp = get(format!("/browse/chart?{query}")).await; + assert_eq!(resp.status(), 404, "{query}"); + } + // Signed-in users only. + let resp = srv + .client() + .get(format!( + "{}/browse/chart?repo={repo}&image=charts/demo&digest={digest}", + srv.base_url + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); + + // A proxy holds a chart's blobs once they are pulled: until then the + // chart shows neither metadata nor README. + let mock = MockRegistry::start().await; + mock.put("charts/demo", "1.2.3", OCI_MANIFEST, &manifest); + mock.put_blob(&config); + mock.put_blob(&archive); + let proxy = repo_name("proxy"); + create_repo( + &srv, + json!({ + "name": proxy, "kind": "proxy", "format": "docker", "remote_url": mock.base(), + "upstream_username": "upstream", "upstream_password": "secret", + }), + ) + .await; + let proxied = format!("{proxy}/charts/demo"); + assert_eq!(oci.get(&proxied, "manifests/1.2.3").await.status(), 200); + let chart_url = format!("/browse/chart?repo={proxy}&image=charts/demo&digest={digest}"); + let chart: Value = get(chart_url.clone()).await.json().await.unwrap(); + assert_eq!(chart["metadata"], Value::Null); + assert_eq!(chart["readme"], Value::Null); + assert_eq!(chart["archive_stored"], false); + for blob in [&config, &archive] { + let resp = oci.get(&proxied, &format!("blobs/{}", sha(blob))).await; + assert_eq!(resp.status(), 200); + resp.bytes().await.unwrap(); + } + let chart: Value = get(chart_url).await.json().await.unwrap(); + assert_eq!(chart["metadata"]["appVersion"], "2.0"); + assert_eq!(chart["values"]["text"], "replicas: 1\n"); + assert_eq!(chart["archive_stored"], true); +} + #[tokio::test] async fn cross_repository_mount_needs_read_on_the_source() { let srv = common::start().await; diff --git a/docs/changes/2.0.0/228.fixed.md b/docs/changes/2.0.0/228.fixed.md index 8db69a11..0dea4138 100644 --- a/docs/changes/2.0.0/228.fixed.md +++ b/docs/changes/2.0.0/228.fixed.md @@ -1,11 +1,4 @@ -Helm: a version's index entry carried its Chart.yaml metadata whole (up to -1 MiB), and every `index.yaml` request renders every version, so a user who -may push to a Helm repository, even one chart, could push a few hundred -versions with a long description and make every `helm repo update` take -gigabytes of memory until the pod was OOM-killed. A version's summary, and -so its index entry, now takes at most 16 KiB: past that, the description is -cut to 1 KiB, then the largest fields (annotations, maintainers, …) are left -out until it fits, and the fields Helm needs (`apiVersion`, `name`, -`version`, `appVersion`, …) go last. A version pushed before is cut down the -same way when the index is rendered. The host also stores no manifest -summary over 64 KiB from a WebAssembly format plugin. +Format plugins: the host stores no manifest summary over 64 KiB from a +WebAssembly format plugin. Every listing hands each version's summary to +the plugin, so a plugin that summarized without a bound let a publisher make +every listing take gigabytes. diff --git a/docs/changes/2.0.0/231.fixed.md b/docs/changes/2.0.0/231.fixed.md index 3818ebca..0523c03c 100644 --- a/docs/changes/2.0.0/231.fixed.md +++ b/docs/changes/2.0.0/231.fixed.md @@ -1,7 +1,6 @@ -Helm and other format plugins: a version several kilobytes long passed -validation and failed its database insert, so a push answered 500 instead -of 400, and so did a proxy asked for such a version. A Helm chart version -may now be at most 128 characters, an OCI tag's limit, and every format -plugin's ids at most 512 bytes and versions at most 256: a longer one is -refused with 400 on push, answers 404 in a URL, and is left out of a -proxy's upstream version listings. +Format plugins: a version several kilobytes long passed validation and +failed its database insert, so a push answered 500 instead of 400, and so +did a proxy asked for such a version. Every format plugin's ids are now at +most 512 bytes and its versions at most 256: a longer one is refused with +400 on push, answers 404 in a URL, and is left out of a proxy's upstream +version listings. diff --git a/docs/changes/2.0.0/232.security.md b/docs/changes/2.0.0/232.security.md deleted file mode 100644 index 06a88a00..00000000 --- a/docs/changes/2.0.0/232.security.md +++ /dev/null @@ -1,9 +0,0 @@ -Helm: a chart archive holding two `Chart.yaml` files (in two top-level -directories, or the same name twice) was stored and listed under the first -one's name and version, while Helm installs the last one's. A publisher -whose package selector allows only `good` could so publish a chart that Helm -installs as `evil`, and a Helm proxy could cache an upstream chart under a -name and version Helm does not see. Forge now reads the whole archive the -way Helm does and refuses one with more than one top-level directory or -`/Chart.yaml`, or with a `requirements.yaml` that declares more than -dependencies, which Helm reads into the chart's name and version too. diff --git a/docs/changes/2.0.0/233.security.md b/docs/changes/2.0.0/233.security.md index 6bb97219..06e37838 100644 --- a/docs/changes/2.0.0/233.security.md +++ b/docs/changes/2.0.0/233.security.md @@ -3,8 +3,8 @@ address Forge reaches, a loopback or private service included, and read the answer back into what the proxy serves. Forge now sends the requests a format asks for, and downloads its packages, only from the origin of the repository's `remote_url` and from origins that documents fetched from that -upstream name (a NuGet service index's resources on other hosts, a Helm -index's chart URLs); any other request is refused and logged as a warning. +upstream name (a NuGet service index's resources on other hosts); any other +request is refused and logged as a warning. The documents an upstream serves still decide which hosts become reachable, so confine Forge's egress with network policy where internal services must stay out of reach. Each proxy resolves its upstream index once more after diff --git a/docs/changes/2.0.0/283.fixed.md b/docs/changes/2.0.0/283.fixed.md index 08cfe259..16b6fe1b 100644 --- a/docs/changes/2.0.0/283.fixed.md +++ b/docs/changes/2.0.0/283.fixed.md @@ -1,4 +1,4 @@ -Proxies of a format plugin (NuGet, Helm): an upstream that could not be +Proxies of a format plugin (NuGet): an upstream that could not be reached (connection refused, a name that does not resolve, a timeout) or answered a 5xx or 429, with nothing cached for the request, answered 500 and logged an error, as if Forge were broken. It answers 503 with diff --git a/docs/changes/2.0.0/422.security.md b/docs/changes/2.0.0/422.security.md deleted file mode 100644 index 05265c8b..00000000 --- a/docs/changes/2.0.0/422.security.md +++ /dev/null @@ -1,8 +0,0 @@ -Helm: a `Chart.yaml` key that Helm reads as one of its fields without being -its name, such as `verſion` (with a long s) next to `version`, was ignored, -while Helm installs the chart with that key's value. Helm matches the keys -case-insensitively, by Unicode case folding, and the later key in sorted -order wins: so a chart pushed or proxied as `good` 1.0.0 installed as 6.6.6, -past the redeploy policy, retention and the package delay, and -`apiversion: v1` made a v2 chart a v1 chart for Helm. Forge now refuses a -`Chart.yaml` with such a key (400 on a push). diff --git a/docs/changes/2.0.0/43.feature.md b/docs/changes/2.0.0/43.feature.md new file mode 100644 index 00000000..62f5b7d4 --- /dev/null +++ b/docs/changes/2.0.0/43.feature.md @@ -0,0 +1,9 @@ +Helm charts pushed to a docker repository (`helm push`) have a page of their +own in the web UI: the chart's versions, the `helm install` and `helm pull` +commands of the selected one (the newest release by default) with the +`helm registry login` to run first, its `Chart.yaml` facts (appVersion, +description, home, sources, maintainers, dependencies), its README and its +default values. The browse API serves them at +`GET /browse/chart?repo=&image=&digest=`; a +proxy shows them once the chart has been pulled through it. See +[docs/docker.md](docs/docker.md#helm-charts). diff --git a/docs/deployment.md b/docs/deployment.md index e2075b86..b01cf242 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -891,8 +891,8 @@ notices within about 15 s, drops its in-memory cache and resubscribes. them on the outbound proxy or with network policy. - **Format plugins.** Every instance must load the same WebAssembly plugins (the same `[plugins]` section and component files): a repository whose - format is missing on an instance answers 503 there. Built-in plugins - (Helm) come with the image. See + format is missing on an instance answers 503 there. The built-in plugin + (NuGet) comes with the image. See [format-plugins.md](./format-plugins.md#loading-webassembly-plugins). ### Redis is a trust boundary diff --git a/docs/docker.md b/docs/docker.md index 8a0bc917..fc9b5799 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -142,6 +142,29 @@ with its annotations. cosign 3 stores signatures and attestations there by default; older cosign versions use tags (`sha256-.sig`), which work as well. +### Helm charts + +Helm (3.8 and later) stores charts in OCI registries, and Forge serves them +from docker repositories like any other artifact: a chart is a manifest +whose config is the chart's `Chart.yaml` (`application/vnd.cncf.helm.config.v1+json`) +and whose layer is the packaged chart. `helm push` tags it with the chart's +version. Forge serves charts this way only; it has no classic chart +repositories (`index.yaml`, `helm repo add`). + +```bash +helm registry login forge.example.com --username # password: an API token +helm push mychart-1.2.3.tgz oci://forge.example.com/docker-hosted/charts +helm install myrelease oci://forge.example.com/docker-hosted/charts/mychart --version 1.2.3 +helm pull oci://forge.example.com/docker-group/charts/mychart --version '~1.2' +``` + +The chart's name is the last component of the image name +(`docker-hosted/charts/mychart`); content selectors and retention match it +like any image name. Proxies of a registry that serves charts (Docker Hub's +`registry-1.docker.io/bitnamicharts`, GHCR) and groups work as for images. +`helm registry login` keeps its own login, so log in with Helm even when +`docker login` already did. + ## Proxy repositories ```bash @@ -225,6 +248,15 @@ the browse API: `GET /browse/images?repo=` and `GET /browse/images//`. An offline hosted or group repository answers 503 there, as it does to pulls and pushes. +An image of Helm charts shows its charts instead: the versions, the +`helm install` and `helm pull` commands of the selected one (the newest +release by default), its `Chart.yaml` facts (version, appVersion, +description, home, sources, maintainers, dependencies), its README and its +default values. The browse API reads them from the chart's blobs: +`GET /browse/chart?repo=&image=&digest=`. A +proxy stores a chart's blobs when it is first pulled, so until then it shows +the versions and commands only. A README or `values.yaml` over 512 KiB is cut. + The catalog API (`/v2/_catalog`) is not supported. ## Retention and garbage collection diff --git a/docs/format-plugins.md b/docs/format-plugins.md index 71dd4d94..41a3a976 100644 --- a/docs/format-plugins.md +++ b/docs/format-plugins.md @@ -13,13 +13,18 @@ A plugin runs one of two ways, and the server cannot tell them apart: - **Built in.** The crate is linked into the server and called directly, like any other Rust code. Built-in plugins need no configuration. Forge ships - two: **NuGet** ([`plugins/nuget`](../plugins/nuget)) and **Helm chart - repositories** ([`plugins/helm`](../plugins/helm)). + one: **NuGet** ([`plugins/nuget`](../plugins/nuget)). - **As a WebAssembly component.** The same kind of crate, built for `wasm32-wasip2`, loaded at startup from a digest the operator pinned and run in a sandbox. This is how formats that are not part of Forge are added without rebuilding it. +[`plugins/helm`](../plugins/helm), classic Helm chart repositories +(`index.yaml`, ChartMuseum uploads), is the **example plugin**: the plugin +tests run it both ways, and it is the crate to start a new plugin from, but +Forge does not build it in. Helm charts are served by the registry +([docker.md](docker.md#helm-charts)). + > **Status: preview.** The plugin contract is `forge:format@0.3.0`. It may > change before 1.x of the contract; a plugin declares the contract version it > was built for, and the server refuses one it does not implement. @@ -69,7 +74,7 @@ trait's methods, and the host turns a loaded component back into a `FormatPlugin`. So the dispatcher, the hosted write path, groups and retention run the same code for both. The test [`the_component_answers_like_the_builtin_plugin`](../crates/plugin/tests/component.rs) -runs Helm both ways and checks that every answer matches. +runs the example plugin both ways and checks that every answer matches. ## How it fits together @@ -141,12 +146,9 @@ NuGet's). Every listing hands each version's summary to the plugin, so a summary should stay small. The host stores none larger than 64 KiB from a WebAssembly plugin, and logs a warning instead; a built-in plugin bounds its -own. Helm's summary, which holds a version's index entry, takes at most -16 KiB: past that, its description is cut to 1 KiB, then its largest fields -are left out until it fits, and `apiVersion`, `name`, `version`, -`appVersion`, `kubeVersion`, `type` and `deprecated` go last. A Helm summary -stored before this bound is cut down the same way when the index is -rendered. +own. The example plugin's summary, which holds a chart version's index +entry, takes at most 16 KiB: past that, its description is cut to 1 KiB, +then its largest fields are left out until it fits. ## Proxy repositories @@ -195,8 +197,8 @@ logs a warning naming the format and the repository. What the plugin answers names no origin: its index is its own making (and the host keeps fields of its own beside it, so an index may not use the keys `remote_url` and `named_origins`). So a NuGet service index can put its resources on other -hosts (nuget.org's search service is on `azuresearch-*.nuget.org`), and a Helm -index its charts (on a project's GitHub releases), but a plugin cannot make +hosts (nuget.org's search service is on `azuresearch-*.nuget.org`), and a +chart repository's index its charts (on a project's GitHub releases), but a plugin cannot make Forge request a host its upstream does not point at. Redirects are the upstream's to choose, and are followed under the cloud-metadata guard. @@ -211,81 +213,32 @@ cached. ## Using a plugin `GET /admin/formats` lists every format the server can create repositories -of: the built-in plugins (`builtin: true`; NuGet first, then Helm), Docker, -raw files, and the loaded WebAssembly plugins (with the `sha256` of their component), -each with the repository kinds it supports and where its repositories are -served. The web UI's *New repository* form offers them. Plugins serve -**hosted** and **group** repositories, and **proxies** when they implement -the upstream side (NuGet and Helm do). Docker has proxies of its own. - -Helm is built in, so a Helm repository needs no setup beyond creating it: - -```bash -# Create a hosted Helm repository (or use the web UI). -curl -u admin@example.com:$ADMIN_TOKEN -H 'content-type: application/json' \ - -d '{"name": "charts", "kind": "hosted", "format": "helm"}' \ - https://forge.example.com/admin/repositories +of: the built-in plugin (`builtin: true`; NuGet), Docker, raw files, and the +loaded WebAssembly plugins (with the `sha256` of their component), each with +the repository kinds it supports and where its repositories are served. The +web UI's *New repository* form offers them. Plugins serve **hosted** and +**group** repositories, and **proxies** when they implement the upstream +side (NuGet does). Docker has proxies of its own. -# Publish (ChartMuseum API): the body, or the `chart` multipart field. -curl -u ci:$TOKEN --data-binary @mychart-1.2.0.tgz \ - https://forge.example.com/repository/charts/api/charts -helm cm-push mychart/ forge # the helm-push plugin works too - -# Consume. -helm repo add forge https://forge.example.com/repository/charts \ - --username me --password $TOKEN -helm install web forge/mychart --version 1.2.0 - -# Delete a version. -curl -u ci:$TOKEN -X DELETE \ - https://forge.example.com/repository/charts/api/charts/mychart/1.2.0 -``` - -Forge takes a chart's name and version from the `Chart.yaml` Helm loads -when it installs the chart. Helm reads the whole archive and the last -`/Chart.yaml` wins, so Forge reads the whole archive as Helm does, up -to 256 MiB unpacked, and refuses one whose files are not all in one -top-level directory, that holds more than one `/Chart.yaml`, or whose -`requirements.yaml` declares more than `dependencies` (Helm reads it into -the chart's metadata too). It also refuses names Helm refuses (absolute, or -out of the chart's directory) and tar features charts do not use, such as -sparse files. Archives from `helm package` or `tar` pass; a Helm proxy -checks the charts it downloads the same way. - -A Helm **proxy** mirrors another chart repository: its index lists the -upstream's charts, and each chart is downloaded from the upstream on first -use and served from Forge after. +A repository of a plugin's format is created like any other, with the +plugin's format id, and served under the plugin's mount (`/repository` +unless its `plugin.toml` names another): ```bash +# Create a hosted repository of the plugin `example` (or use the web UI). curl -u admin@example.com:$ADMIN_TOKEN -H 'content-type: application/json' \ - -d '{"name": "bitnami", "kind": "proxy", "format": "helm", - "remote_url": "https://charts.bitnami.com/bitnami"}' \ + -d '{"name": "pkgs", "kind": "hosted", "format": "example"}' \ https://forge.example.com/admin/repositories -helm repo add bitnami-via-forge https://forge.example.com/repository/bitnami \ - --username me --password $TOKEN +# Its clients use https://forge.example.com/repository/pkgs/ ``` -Chart versions are SemVer, as Helm reads them (`v1.2` is `1.2.0`), and at -most 128 characters, as written and normalized: Helm pushes a chart to an OCI -registry with its version as the tag, and an OCI tag is at most 128 -characters, so every chart Helm can push there fits. A push of a chart with a -longer version answers 400, a URL naming one answers 404, and a proxy leaves -such versions of its upstream's index out. - -Its index describes each chart's newest version fully and the older ones by -name, version and URL: that is what one search of the upstream says, and -reading the upstream's index once per chart would be slow for large -repositories. A group with a Helm proxy among its members reads the -upstream once per chart for its index; for a large upstream, add the proxy -itself as a Helm repository. - As with every format, the password is an **API token**. A password typed at the web login is not a publishing credential. The web UI's *Browse* screen filters repositories by format -(`GET /browse/repos?format=helm`), and a repository's screen shows its -client commands: `dotnet nuget` for NuGet, `helm repo add` and the push for -Helm, the repository's URL for other plugin formats. +(`GET /browse/repos?format=`), and a repository's screen shows its +client commands: `dotnet nuget` for NuGet, the repository's URL for other +plugin formats. **Upload limits.** Each hosted repository has its own upload limit, the largest push it accepts (413 above it), whatever its format: set it under @@ -332,7 +285,7 @@ FORGE_PLUGINS__FORMATS__EXAMPLE=sha256:c2053ffc… all fail startup. A directory under `dir` that is not listed is never read. - **Built-in formats come first.** A component cannot take the id of Docker, - raw or a built-in plugin (`nuget`, `helm`); configuring one fails startup. + raw or a built-in plugin (`nuget`); configuring one fails startup. - **Every instance loads the same plugins.** A repository whose format is not loaded on an instance answers 503 there; it is never read as another format. The admin API refuses to create a repository of a format that is @@ -375,9 +328,9 @@ The host enforces these for every plugin, built in or not: answers 400, a request naming one answers like one naming an unknown package (404), and a proxy leaves such versions of an upstream listing out. Ids and versions are keys of database indexes, whose entries - PostgreSQL limits to about 2,700 bytes. The built-in plugins allow less: - NuGet 100 characters for an id and 64 for a version, Helm 250 for a chart - name and 128 for a version. + PostgreSQL limits to about 2,700 bytes. The plugins allow less: NuGet 100 + characters for an id and 64 for a version, the example plugin 250 for a + chart name and 128 for a version. - **Upstream requests go where the upstream points.** A proxy's plugin reaches the network only through the GETs the host sends for it, and the host sends them, and downloads packages, only from the origin of the @@ -440,9 +393,9 @@ policy (an outbound proxy, a Kubernetes `NetworkPolicy`). ## Writing a plugin -Start from [`plugins/helm`](../plugins/helm): a crate in `plugins//` -with a `plugin.toml`, a member of the workspace, depending on -`forge-plugin-api` and implementing its trait: +Start from the example plugin, [`plugins/helm`](../plugins/helm): a crate +in `plugins//` with a `plugin.toml`, a member of the workspace, +depending on `forge-plugin-api` and implementing its trait: ```rust use forge_plugin_api::{Action, FormatPlugin, Loaded, Rendered, Repository, Request /* … */}; @@ -526,7 +479,7 @@ A few rules hold either way: documents ([`plugins/nuget/tests/upstream.rs`](../plugins/nuget/tests/upstream.rs)). - **Test both ways.** The crate's unit tests run with the workspace. [`crates/plugin/tests/component.rs`](../crates/plugin/tests/component.rs) - loads the component and compares it with the built-in crate, and + loads the component and compares it with the crate built in, and [`crates/server/tests/plugins.rs`](../crates/server/tests/plugins.rs) runs a server with both; copy them for a new plugin. - **Check it like CI does:** `cargo xtask plugins-check` lints the component @@ -563,27 +516,17 @@ to lift them: - **Prerelease is "contains a `-`".** This is right for SemVer formats; a format with another rule (PEP 440's `1.0a1`) needs a contract function for it. -- **Listings are bounded.** A response may load at most 2,048 answers, so the - Helm index covers at most about 2,000 charts. -- **A Helm upstream is read whole.** Every upstream operation of a Helm - proxy reads the upstream's `index.yaml` (cached for the metadata TTL, and - at most `proxy.max_metadata_bytes`, 32 MiB by default); the contract has - no ranged or streamed reads of an upstream document. The built-in plugin - keeps the parsed index while the upstream serves the same document and - parses one index at a time, so a chart listing through a proxy, or - through a group of several proxies, parses each upstream's index once, - not once per chart. The kept indexes of an instance, of all its Helm - proxies together, may hold 128 MiB, about twice the size of their - documents, so about 60 MB of `index.yaml`. A new index that does not fit - takes the place of kept ones nobody used for a minute; while all of them - are in use, it is parsed for each operation instead of being kept. So - past the budget, a group's listing keeps the indexes that fit and parses - the others once per chart. A WebAssembly build of the plugin keeps - nothing and parses the index for every operation. +- **Listings are bounded.** A response may load at most 2,048 answers, so + a listing of every package of a repository (the example plugin's + `index.yaml`) covers at most about 2,000 packages. +- **An upstream document is read whole.** A proxy reads each upstream + document it needs (cached for the metadata TTL, and at most + `proxy.max_metadata_bytes`, 32 MiB by default); the contract has no ranged + or streamed reads of an upstream document. ### NuGet as a plugin -NuGet is a built-in plugin like Helm: [`plugins/nuget`](../plugins/nuget) +NuGet is a built-in plugin: [`plugins/nuget`](../plugins/nuget) implements `FormatPlugin`, and the dispatcher serves it at `/v3/{repo}/...` with the same paths, status codes, messages and JSON as before. The pieces it needed from the contract are general ones: a mount, its own asset names, @@ -652,7 +595,7 @@ contract: | Installing plugins | Admin API from git or a ZIP, Ed25519-signed when required | Operator configuration, pinned by SHA-256; no install API | | Instances | A new store and instance per call | Instances reused per plugin, a fresh budget per call, recycled after a trap or 10,000 calls | -The Helm plugin shows the difference in practice: a 64 MiB component serves +The example plugin shows the difference in practice: a 64 MiB component serves a repository index of any size the query bounds allow, and a chart is never copied into the sandbox, which streams it through the lent reader and keeps only its `Chart.yaml`. diff --git a/docs/nexus-import.md b/docs/nexus-import.md index 421a1694..1dc7c2d7 100644 --- a/docs/nexus-import.md +++ b/docs/nexus-import.md @@ -9,7 +9,6 @@ a Nexus hosted repository of the same format, and Forge copies its content. | Format | What is copied | | --- | --- | | NuGet | the `.nupkg` of every version | -| Helm | the chart `.tgz` of every version | | Docker | every tag with everything it references: config and layers, the child manifests of a multi-platform index; signatures and SBOMs (cosign's `sha256-.sig` tags, and referrers Nexus holds by digest) | apt follows once Forge serves apt repositories diff --git a/docs/raw.md b/docs/raw.md index 63b717e8..fba034d4 100644 --- a/docs/raw.md +++ b/docs/raw.md @@ -352,6 +352,6 @@ the other curl checks of this page. - **Ranges.** One range per request; a multi-range request is answered in full. - **No Nexus import.** Importing a Nexus raw repository is not supported yet - ([nexus-import.md](nexus-import.md) covers NuGet, Helm and Docker). + ([nexus-import.md](nexus-import.md) covers NuGet and Docker). - **No directory delete over `/raw`.** A directory is deleted through the admin API or the web UI (administrators). diff --git a/forge.toml.example b/forge.toml.example index 52c7205b..f59a9373 100644 --- a/forge.toml.example +++ b/forge.toml.example @@ -160,7 +160,7 @@ max_nuspec_bytes = 1048576 # (default: 1 MiB) also caps an upst max_nuspec_compression_ratio = 200 # (default: 200) # Optional: WebAssembly format plugins — further package formats loaded at -# startup (docs/format-plugins.md). The built-in ones (Helm) need nothing here. +# startup (docs/format-plugins.md). The built-in one (NuGet) needs nothing here. # Only the plugins listed under [plugins.formats] are loaded, each from # // and pinned to its component's SHA-256; a mismatch, or an id a # built-in format holds, fails startup. `cargo xtask plugins` builds the diff --git a/plugins/README.md b/plugins/README.md index df0977ca..ebef6cf4 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -9,7 +9,7 @@ the design, the contract and how to operate them. | Plugin | Format id | Runs | What it serves | | --- | --- | --- | --- | | [`nuget`](nuget) | `nuget` | built in only | NuGet V3 feeds at `/v3`: push, restore, search, unlist and relist; hosted, proxy and group repositories | -| [`helm`](helm) | `helm` | built in | Helm chart repositories: `index.yaml`, chart downloads, ChartMuseum-compatible push, delete and listing; hosted, proxy and group repositories | +| [`helm`](helm) | `helm` | the example plugin: not built in; the plugin tests run it built in and as a component | Classic Helm chart repositories: `index.yaml`, chart downloads, ChartMuseum-compatible push, delete and listing; hosted, proxy and group repositories | A plugin directory holds: diff --git a/plugins/helm/plugin.toml b/plugins/helm/plugin.toml index a9662cfc..e389e057 100644 --- a/plugins/helm/plugin.toml +++ b/plugins/helm/plugin.toml @@ -1,4 +1,5 @@ -# A Forge format plugin: Helm chart repositories (see plugins/README.md). +# The example Forge format plugin: classic Helm chart repositories (see +# plugins/README.md). Forge does not build it in. [plugin] id = "helm" name = "Helm charts" diff --git a/plugins/helm/src/lib.rs b/plugins/helm/src/lib.rs index 66010c9f..b3cd916f 100644 --- a/plugins/helm/src/lib.rs +++ b/plugins/helm/src/lib.rs @@ -21,9 +21,11 @@ //! index lists the upstream's charts, each downloaded and cached on first //! use. //! -//! Forge links this plugin in ([`Helm`], [`PLUGIN_TOML`]). With the -//! `component` feature the same crate builds as a WebAssembly component of -//! the `forge:format` world instead (`cargo xtask plugins`). +//! This is the example plugin (`docs/format-plugins.md`): Forge does not +//! build it in, and serves Helm charts from its OCI registry instead. The +//! plugin tests install it as if built in ([`Helm`], [`PLUGIN_TOML`]); with +//! the `component` feature the same crate builds as a WebAssembly component +//! of the `forge:format` world (`cargo xtask plugins`). mod chart; mod semver; diff --git a/web/src/api/images.ts b/web/src/api/images.ts index 833f8347..6ba7046c 100644 --- a/web/src/api/images.ts +++ b/web/src/api/images.ts @@ -94,6 +94,65 @@ export interface ImageDetail { manifests: ImageManifest[] } +/** A person named by a chart (`maintainers` of its `Chart.yaml`). */ +export interface ChartMaintainer { + name?: string + email?: string + url?: string +} + +/** A chart a chart depends on (`dependencies` of its `Chart.yaml`). */ +export interface ChartDependency { + name?: string + version?: string + repository?: string + condition?: string + alias?: string +} + +/** + * The `Chart.yaml` metadata of a Helm chart, from its config blob: the + * fields the server keeps, each optional (a chart declares what it likes). + */ +export interface ChartMetadata { + apiVersion?: string + name?: string + version?: string + appVersion?: string + description?: string + type?: string + kubeVersion?: string + home?: string + icon?: string + deprecated?: boolean + keywords?: string[] + sources?: string[] + maintainers?: ChartMaintainer[] + dependencies?: ChartDependency[] +} + +/** A text file of a chart: its README or `values.yaml` (`ChartFileView`). */ +export interface ChartFile { + name: string + text: string + /** The file was cut at 512 KiB. */ + truncated: boolean +} + +/** `GET /browse/chart?repo=…&image=…&digest=…` response (`ChartDetail`). */ +export interface ChartDetail { + /** The chart's manifest digest. */ + digest: string + /** `null` while the config blob is not stored here (a proxy before a pull). */ + metadata: ChartMetadata | null + readme: ChartFile | null + values: ChartFile | null + /** Whether the chart archive is stored here; a proxy stores it when pulled. */ + archive_stored: boolean + /** The member repository it comes from (groups only). */ + source?: string +} + /** Search parameters for one repository's image list (`ImagesQuery`). */ export interface ImageSearchParams { /** The docker repository (required by the endpoint). */ @@ -125,6 +184,8 @@ export const imageKeys = { ] as const, detail: (repo: string, image: string) => ['images', 'detail', repo, image] as const, + chart: (repo: string, image: string, digest: string) => + ['images', 'chart', repo, image, digest] as const, } /** Fetch a page of one docker repository's images. */ @@ -159,3 +220,20 @@ export function fetchImageDetail( { signal }, ) } + +/** + * Fetch a Helm chart of an image (a manifest whose config is a Helm chart's): + * its metadata, README and default values. 404 when the manifest is not a + * chart, or not visible. + */ +export function fetchChart( + repo: string, + image: string, + digest: string, + signal?: AbortSignal, +): Promise { + return httpClient.get('/browse/chart', { + query: { repo, image, digest }, + signal, + }) +} diff --git a/web/src/api/imports.ts b/web/src/api/imports.ts index f11c79bf..2fd6cb97 100644 --- a/web/src/api/imports.ts +++ b/web/src/api/imports.ts @@ -15,7 +15,7 @@ import { httpClient } from '~/lib/httpClient' */ /** Formats an import copies (`crates/server/src/nexus_import/mod.rs`). */ -export const IMPORT_FORMATS = ['nuget', 'helm', 'docker'] +export const IMPORT_FORMATS = ['nuget', 'docker'] /** `queued` → `running` → `completed` | `failed` | `cancelled`. */ export type ImportState = diff --git a/web/src/api/repositories.ts b/web/src/api/repositories.ts index 9c15d40f..57af6f08 100644 --- a/web/src/api/repositories.ts +++ b/web/src/api/repositories.ts @@ -183,7 +183,8 @@ export interface UpdateRepo { /** * An installed package format (`FormatView` of `GET /admin/formats`): the - * built-in format plugins (NuGet, Helm) and every loaded WebAssembly plugin. + * built-in format plugin (NuGet), Docker, raw and every loaded WebAssembly + * plugin. */ export interface FormatView { /** The format id (`RepoView.format`). */ @@ -219,7 +220,7 @@ export interface InvalidateProxyCache { /** What a proxy cache invalidation removed (`Invalidated`). */ export interface ProxyCacheInvalidated { - /** Remembered upstream "not found" answers removed (NuGet, Helm). */ + /** Remembered upstream "not found" answers removed (NuGet, plugins). */ not_found_cleared: number /** Cached tags due for re-validation against the upstream (docker). */ tags_expired: number diff --git a/web/src/api/retention.ts b/web/src/api/retention.ts index aaaa3645..39e1e7f7 100644 --- a/web/src/api/retention.ts +++ b/web/src/api/retention.ts @@ -18,7 +18,7 @@ import { httpClient } from '~/lib/httpClient' * * A repository's policy takes one of three shapes, by its format * (`crates/core/src/retention/{model,files,images}.rs`): package rules for - * NuGet, Helm and other package formats (hosted rules, or on a proxy + * NuGet and other package formats (hosted rules, or on a proxy * `scope: proxy_cached` eviction rules), file rules for raw repositories, * image rules for docker. Every shape has the same envelope. Unknown fields * are refused (422); an invalid rule is a 400 naming it. diff --git a/web/src/components/ChartView.tsx b/web/src/components/ChartView.tsx new file mode 100644 index 00000000..9dd2f99d --- /dev/null +++ b/web/src/components/ChartView.tsx @@ -0,0 +1,313 @@ +import type { ReactNode } from 'react' +import { Link } from '@tanstack/react-router' + +import type { ChartDetail, ChartMetadata, ImageDetail } from '~/api/images' +import { isRepositoryOffline } from '~/api/packages' +import { chartVersion, chartVersions, defaultChartVersion, httpUrl } from '~/lib/oci' +import { useChart } from '~/queries/useChart' +import { ManifestTable, TagTable } from '~/components/ImageTables' +import { ErrorState, RepositoryOfflineState } from '~/components/ErrorState' +import { EmptyState } from '~/components/EmptyState' +import { + Chip, + Code, + CodeBlock, + Columns, + CommandSnippet, + FactList, + Notice, + Panel, + SelectField, + Skeleton, + StatusBadge, +} from '~/components/ui' + +/** + * The image page of a Helm chart pushed as an OCI artifact (`helm push`): + * the `helm install` / `helm pull` commands of a version (the selected one, + * else the newest release), the version's `Chart.yaml` facts, README and + * default values, then the versions and manifests of the image. + */ +export function ChartView({ + detail, + selectedTag, + onSelectTag, +}: { + detail: ImageDetail + selectedTag: string | undefined + onSelectTag: (tag: string) => void +}) { + const versions = chartVersions(detail.tags, detail.manifests) + const current = + versions.find((t) => t.tag === selectedTag) ?? defaultChartVersion(versions) + const chart = useChart(detail.repo, detail.image, current?.digest) + + let body: ReactNode + if (!current) { + body = null + } else if (chart.isLoading) { + body = + } else if (chart.isError) { + body = isRepositoryOffline(chart.error) ? ( + void chart.refetch()} /> + ) : ( + void chart.refetch()} + /> + ) + } else if (chart.data) { + body = + } + + return ( + + t.tag)} + current={current?.tag} + onSelectTag={onSelectTag} + /> + {chart.data?.metadata && ( + + + + )} + + } + asideLabel="Install and chart details" + > + {chart.data?.metadata?.deprecated && ( + + The chart's maintainers marked this version deprecated. + + )} + {body} + + {detail.tags.length === 0 ? ( + + This chart has only untagged manifests; Helm installs charts by + version. + + ) : ( + + )} + + + + + + ) +} + +/** + * `helm install` and `helm pull` of the selected version, with the login + * hint. Helm needs a version: a chart without tags gets no commands. + */ +function InstallPanel({ + detail, + versions, + current, + onSelectTag, +}: { + detail: ImageDetail + versions: string[] + current: string | undefined + onSelectTag: (tag: string) => void +}) { + const ref = `oci://${detail.pull_name}` + const version = current ? chartVersion(current) : undefined + return ( + + Log in first with{' '} + helm registry login {detail.registry} --username <username>, + using an API token from{' '} + + Your tokens + {' '} + as the password. + + } + > + {versions.length > 1 && current && ( + onSelectTag(e.target.value)} + > + {versions.map((tag) => ( + + ))} + + )} + {version ? ( + <> + ${ref} --version ${version}`} + copyLabel="Copy install command" + /> + + + ) : ( +

+ No version of this chart is tagged; push one with{' '} + helm push. +

+ )} +
+ ) +} + +/** A link when `url` is an http(s) URL, else the text. */ +function MaybeLink({ url }: { url: string }) { + const href = httpUrl(url) + return href ? ( + + {url} + + ) : ( + {url} + ) +} + +/** The `Chart.yaml` facts of a version, the ones it declares. */ +function ChartFacts({ metadata }: { metadata: ChartMetadata }) { + const items: { term: ReactNode; value: ReactNode; mono?: boolean }[] = [] + const add = (term: string, value: ReactNode, mono = false) => { + if (value !== undefined && value !== null && value !== '') items.push({ term, value, mono }) + } + add('Name', metadata.name, true) + add('Version', metadata.version, true) + add('App version', metadata.appVersion, true) + add('Description', metadata.description) + add('Type', metadata.type) + add('Kubernetes', metadata.kubeVersion, true) + if (metadata.home) add('Home', ) + if (metadata.sources?.length) { + add( + 'Sources', + + {metadata.sources.map((s) => ( + + ))} + , + ) + } + if (metadata.maintainers?.length) { + add( + 'Maintainers', + + {metadata.maintainers.map((m, i) => ( + + {m.name} + {m.email && <{m.email}>} + + ))} + , + ) + } + if (metadata.keywords?.length) { + add( + 'Keywords', + + {metadata.keywords.map((k) => ( + {k} + ))} + , + ) + } + if (metadata.dependencies?.length) { + add( + 'Dependencies', + + {metadata.dependencies.map((d, i) => ( + + + {d.alias ?? d.name} + {d.version && ` ${d.version}`} + + {d.repository && from {d.repository}} + + ))} + , + ) + } + if (metadata.deprecated) add('Status', deprecated) + return +} + +/** The README and default values of a version. */ +function ChartFiles({ chart }: { chart: ChartDetail }) { + const cut = ( +

+ Cut at 512 KiB; helm show prints all of it. +

+ ) + return ( + <> + {!chart.archive_stored && ( + + This repository has not stored the chart yet: its README and default + values show once it is pulled through Forge. + + )} + {chart.archive_stored && ( + + {chart.readme ? ( + <> +
+                {chart.readme.text}
+              
+ {chart.readme.truncated && cut} + + ) : ( + + The chart has no README. + + )} +
+ )} + {chart.archive_stored && ( + + {chart.values ? ( + <> + + {chart.values.truncated && cut} + + ) : ( + + The chart has no values.yaml. + + )} + + )} + + ) +} diff --git a/web/src/components/PackageConnect.test.ts b/web/src/components/PackageConnect.test.ts index f9840fa1..312f236e 100644 --- a/web/src/components/PackageConnect.test.ts +++ b/web/src/components/PackageConnect.test.ts @@ -17,14 +17,8 @@ describe('connectSnippets', () => { it('leaves the push command out unless the repository takes pushes', () => { const labels = (canPush: boolean) => - connectSnippets('helm', 'charts', 'https://f/repository/charts', canPush).map( - (s) => s.label, - ) - expect(labels(true)).toEqual([ - 'Add the repository', - 'Install a chart', - 'Push a chart', - ]) - expect(labels(false)).toEqual(['Add the repository', 'Install a chart']) + connectSnippets('nuget', 'feed', 'https://f/v3/feed', canPush).map((s) => s.label) + expect(labels(true)).toEqual(['Add the source', 'Push a package']) + expect(labels(false)).toEqual(['Add the source']) }) }) diff --git a/web/src/components/PackageConnect.tsx b/web/src/components/PackageConnect.tsx index 56b93978..16c67105 100644 --- a/web/src/components/PackageConnect.tsx +++ b/web/src/components/PackageConnect.tsx @@ -39,28 +39,6 @@ export function connectSnippets( ] : []), ] - case 'helm': - return [ - { - label: 'Add the repository', - command: `helm repo add ${repo} ${url} --username --password `, - copyLabel: 'Copy repo-add command', - }, - { - label: 'Install a chart', - command: `helm install ${repo}/`, - copyLabel: 'Copy install command', - }, - ...(canPush - ? [ - { - label: 'Push a chart', - command: `curl -u : --data-binary @-.tgz ${url}/api/charts`, - copyLabel: 'Copy push command', - }, - ] - : []), - ] default: return [ { @@ -73,7 +51,7 @@ export function connectSnippets( } /** - * "Connect" panel of a package repository (NuGet, Helm, or another format + * "Connect" panel of a package repository (NuGet, or another format * plugin's): the address to copy, the client commands for it, and where the * password comes from — clients authenticate with an API token (from the * Your tokens screen), not the UI sign-in. Docker repositories have diff --git a/web/src/components/RepoFormatBadge.tsx b/web/src/components/RepoFormatBadge.tsx index 65067bc0..b9970a95 100644 --- a/web/src/components/RepoFormatBadge.tsx +++ b/web/src/components/RepoFormatBadge.tsx @@ -3,7 +3,7 @@ import { FormatTag, formatLabel } from '~/components/ui' export { formatLabel } /** - * The repository format (`nuget` | `docker` | `helm` | `raw`) with its display + * The repository format (`nuget` | `docker` | `raw`) with its display * name and a generic glyph. Other formats (WebAssembly plugins) show their * raw id, like `RepoKindBadge`. */ diff --git a/web/src/components/RepositoryForm.test.tsx b/web/src/components/RepositoryForm.test.tsx index c1c699aa..8ce41414 100644 --- a/web/src/components/RepositoryForm.test.tsx +++ b/web/src/components/RepositoryForm.test.tsx @@ -30,14 +30,14 @@ import { * diffs them (an emptied host / writable member clears it with `null`). */ -const helm: FormatView = { - id: 'helm', - name: 'Helm charts', - builtin: true, +const example: FormatView = { + id: 'example', + name: 'Example packages', + builtin: false, kinds: ['hosted', 'group'], mount: '/repository', version: '0.1.0', - description: 'Helm chart repositories.', + description: 'Example package repositories.', } function lastValues(onSubmit: ReturnType): RepoFormValues { @@ -83,7 +83,7 @@ describe('RepositoryForm', () => { render( {}} @@ -95,25 +95,25 @@ describe('RepositoryForm', () => { expect(screen.getByText('/v3//index.json')).toBeInTheDocument() await user.selectOptions(screen.getByLabelText('Kind'), 'proxy') - await user.selectOptions(screen.getByLabelText('Format'), 'helm') + await user.selectOptions(screen.getByLabelText('Format'), 'example') // A plugin format has no proxies; the kind falls back to hosted. expect(screen.queryByRole('option', { name: 'proxy' })).not.toBeInTheDocument() expect(screen.getByLabelText('Kind')).toHaveValue('hosted') expect(screen.getByText('/repository//')).toBeInTheDocument() - expect(screen.getByText(helm.description!)).toBeInTheDocument() + expect(screen.getByText(example.description!)).toBeInTheDocument() - await user.type(screen.getByLabelText('Name'), 'charts') + await user.type(screen.getByLabelText('Name'), 'pkgs') await user.click(screen.getByRole('button', { name: 'Create repository' })) expect(toCreateBody(lastValues(onSubmit))).toMatchObject({ - name: 'charts', + name: 'pkgs', kind: 'hosted', - format: 'helm', + format: 'example', }) }) it('shows a stored format read-only on edit', () => { - renderEdit({ ...storedProxy, kind: 'hosted', format: 'helm' }) - expect(screen.getByLabelText('Format')).toHaveValue('helm') + renderEdit({ ...storedProxy, kind: 'hosted', format: 'example' }) + expect(screen.getByLabelText('Format')).toHaveValue('example') expect(screen.getByLabelText('Format')).toHaveAttribute('readonly') expect(screen.getByText('/repository//')).toBeInTheDocument() }) diff --git a/web/src/components/RetentionPolicyForm.test.ts b/web/src/components/RetentionPolicyForm.test.ts index 3b5deb95..6490faed 100644 --- a/web/src/components/RetentionPolicyForm.test.ts +++ b/web/src/components/RetentionPolicyForm.test.ts @@ -16,7 +16,7 @@ import { describe('retention policy model', () => { it('picks the policy shape of a repository', () => { expect(policyShape({ format: 'nuget', kind: 'hosted' })).toBe('package') - expect(policyShape({ format: 'helm', kind: 'proxy' })).toBe('proxy') + expect(policyShape({ format: 'nuget', kind: 'proxy' })).toBe('proxy') expect(policyShape({ format: 'raw', kind: 'proxy' })).toBe('file') expect(policyShape({ format: 'docker', kind: 'hosted' })).toBe('image') expect(policyShape({ format: 'nuget', kind: 'group' })).toBeNull() diff --git a/web/src/components/RetentionPolicyForm.tsx b/web/src/components/RetentionPolicyForm.tsx index 48b54006..e99c5f61 100644 --- a/web/src/components/RetentionPolicyForm.tsx +++ b/web/src/components/RetentionPolicyForm.tsx @@ -18,7 +18,7 @@ import { * The retention policy editor and its model. A repository's policy takes one * of four shapes, by its format and kind (`crates/core/src/retention/`): * - * - `package`: hosted NuGet, Helm and plugin-format repositories. Rules + * - `package`: hosted NuGet and plugin-format repositories. Rules * unlist or delete versions, with filters, protections and triggers. * - `proxy`: the same formats' proxies. Rules (`scope: proxy_cached`) evict * cached versions nobody pulled for a while; they stay listed. diff --git a/web/src/components/ui/badges.tsx b/web/src/components/ui/badges.tsx index cf0707dd..7a1a14ea 100644 --- a/web/src/components/ui/badges.tsx +++ b/web/src/components/ui/badges.tsx @@ -34,7 +34,6 @@ export function KindBadge({ const FORMATS: Record = { nuget: { label: 'NuGet', icon: 'Package' }, docker: { label: 'Docker', icon: 'Layers' }, - helm: { label: 'Helm', icon: 'Wheel' }, raw: { label: 'Raw', icon: 'Folder' }, } diff --git a/web/src/components/ui/code.tsx b/web/src/components/ui/code.tsx index 3fb6d274..505c7bab 100644 --- a/web/src/components/ui/code.tsx +++ b/web/src/components/ui/code.tsx @@ -149,7 +149,7 @@ export function CommandSnippet({ /** * A multi-line file with a title bar (usually the file name) and a copy - * button: a `NuGet.config`, a `PackageReference`, a Helm `values.yaml`. + * button: a `NuGet.config`, a `PackageReference`, a Helm chart's `values.yaml`. */ export function CodeBlock({ code, diff --git a/web/src/components/ui/icons.tsx b/web/src/components/ui/icons.tsx index 94f5100a..f72fbbeb 100644 --- a/web/src/components/ui/icons.tsx +++ b/web/src/components/ui/icons.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' * 14px in small buttons and badges, 20px in headers and empty states. * * Formats use generic glyphs with their names, never trademark logos: NuGet - * `Package`, Docker `Layers`, Helm `Wheel`. `Folder`, `File`, `Mail` and + * `Package`, Docker `Layers`, Helm charts `Wheel`. `Folder`, `File`, `Mail` and * `Overview` are the app's additions in the same style. */ diff --git a/web/src/lib/oci.test.ts b/web/src/lib/oci.test.ts index 7da222d6..03354d84 100644 --- a/web/src/lib/oci.test.ts +++ b/web/src/lib/oci.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it } from 'vitest' import { + HELM_CHART_CONFIG, + chartVersion, + chartVersions, + defaultChartVersion, defaultPullTag, formatBytes, formatDateTime, + httpUrl, + isChartImage, isReferrerTag, manifestKind, referrerKind, @@ -177,3 +183,65 @@ describe('formatDateTime', () => { expect(formatDateTime('2026-10-21T10:58:44Z')).not.toBe('—') }) }) + +describe('Helm charts', () => { + const chart = (digest: string, extra: { subject_digest?: string; children?: string[] } = {}) => ({ + digest, + artifact_type: HELM_CHART_CONFIG, + subject_digest: extra.subject_digest ?? null, + children: extra.children ?? [], + }) + const image = (digest: string) => ({ + ...chart(digest), + artifact_type: 'application/vnd.oci.image.config.v1+json', + }) + + it('an image of charts holds only charts at its top level', () => { + const sig = { + ...chart('sha256:sig', { subject_digest: 'sha256:a' }), + artifact_type: 'application/vnd.dev.cosign.artifact.sig.v1+json', + } + expect(isChartImage([chart('sha256:a'), chart('sha256:b'), sig])).toBe(true) + expect(isChartImage([chart('sha256:a'), image('sha256:b')])).toBe(false) + expect(isChartImage([sig])).toBe(false) + expect(isChartImage([])).toBe(false) + }) + + it('orders versions by SemVer precedence, newest first', () => { + const manifests = ['a', 'b', 'c', 'd', 'e', 'f'].map((d) => chart(`sha256:${d}`)) + const tags = [ + { tag: '1.10.0', digest: 'sha256:a' }, + { tag: '1.9.0', digest: 'sha256:b' }, + { tag: '2.0.0-rc.10', digest: 'sha256:c' }, + { tag: '2.0.0-rc.9', digest: 'sha256:d' }, + { tag: 'nightly', digest: 'sha256:e' }, + { tag: '1.10.0_build.5', digest: 'sha256:f' }, + // Not a chart's tag. + { tag: '9.9.9', digest: 'sha256:image' }, + ] + const ordered = chartVersions(tags, [...manifests, image('sha256:image')]) + expect(ordered.map((t) => t.tag)).toEqual([ + '2.0.0-rc.10', + '2.0.0-rc.9', + '1.10.0', + '1.10.0_build.5', + '1.9.0', + 'nightly', + ]) + expect(defaultChartVersion(ordered)?.tag).toBe('1.10.0') + expect(defaultChartVersion(ordered.slice(0, 2))?.tag).toBe('2.0.0-rc.10') + expect(defaultChartVersion([])).toBeUndefined() + }) + + it('reads build metadata back from a tag', () => { + expect(chartVersion('1.0.0_build.5')).toBe('1.0.0+build.5') + expect(chartVersion('1.0.0')).toBe('1.0.0') + }) + + it('links only http(s) URLs', () => { + expect(httpUrl('https://example.com/chart')).toBe('https://example.com/chart') + expect(httpUrl('javascript:alert(1)')).toBeNull() + expect(httpUrl('not a url')).toBeNull() + expect(httpUrl(undefined)).toBeNull() + }) +}) diff --git a/web/src/lib/oci.ts b/web/src/lib/oci.ts index 06570b65..f1dfcea3 100644 --- a/web/src/lib/oci.ts +++ b/web/src/lib/oci.ts @@ -140,3 +140,116 @@ export function formatDateTime(iso: string | null | undefined): string { ? '—' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) } + +/** The config media type of a Helm chart pushed as an artifact (`helm push`). */ +export const HELM_CHART_CONFIG = 'application/vnd.cncf.helm.config.v1+json' + +/** Whether a manifest is a Helm chart (and not a referrer of one). */ +export function isChart(manifest: { + artifact_type: string | null + subject_digest: string | null +}): boolean { + return manifest.artifact_type === HELM_CHART_CONFIG && !manifest.subject_digest +} + +/** + * Whether an image holds Helm charts: its top-level manifests (neither an + * index's children nor referrers such as signatures) are all charts, as + * `helm push` leaves `oci:////`. + */ +export function isChartImage( + manifests: { + digest: string + artifact_type: string | null + subject_digest: string | null + children: string[] + }[], +): boolean { + const children = new Set(manifests.flatMap((m) => m.children)) + const top = manifests.filter((m) => !m.subject_digest && !children.has(m.digest)) + return top.length > 0 && top.every(isChart) +} + +/** + * The chart version a tag names: `helm push` writes a version's build + * metadata (`+`, which a tag cannot hold) as `_`, and Helm reads it back. + */ +export function chartVersion(tag: string): string { + return tag.replaceAll('_', '+') +} + +const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/ + +/** A version's numbers and prerelease identifiers, or `null` if not SemVer. */ +function semver(version: string): { core: number[]; pre: string[] } | null { + const m = SEMVER.exec(version) + if (!m) return null + return { core: [m[1], m[2], m[3]].map(Number), pre: m[4] ? m[4].split('.') : [] } +} + +/** SemVer 2 precedence of two prerelease identifier lists (empty: a release). */ +function comparePre(a: string[], b: string[]): number { + if (a.length === 0 || b.length === 0) return b.length - a.length + for (let i = 0; i < Math.min(a.length, b.length); i++) { + const x = a[i] + const y = b[i] + const xn = /^\d+$/.test(x) + const yn = /^\d+$/.test(y) + if (xn && yn) { + const d = Number(x) - Number(y) + if (d !== 0) return d + } else if (xn !== yn) { + return xn ? -1 : 1 + } else if (x !== y) { + return x < y ? -1 : 1 + } + } + return a.length - b.length +} + +/** + * The chart versions of an image: the tags of its charts, newest first. + * Tags that are SemVer versions (as `helm push` tags a chart) come by + * precedence, the others after them by name. + */ +export function chartVersions( + tags: T[], + manifests: { digest: string; artifact_type: string | null; subject_digest: string | null }[], +): T[] { + const charts = new Set(manifests.filter(isChart).map((m) => m.digest)) + const parsed = tags + .filter((t) => charts.has(t.digest)) + .map((t) => ({ t, v: semver(chartVersion(t.tag)) })) + parsed.sort((a, b) => { + if (a.v && b.v) { + for (let i = 0; i < 3; i++) { + const d = b.v.core[i] - a.v.core[i] + if (d !== 0) return d + } + return comparePre(b.v.pre, a.v.pre) + } + if (a.v || b.v) return a.v ? -1 : 1 + return a.t.tag < b.t.tag ? -1 : a.t.tag > b.t.tag ? 1 : 0 + }) + return parsed.map((p) => p.t) +} + +/** + * The version the install commands start with: the newest release, as + * `helm install` without `--version` picks it, else the newest prerelease. + * `versions` are {@link chartVersions}, newest first. + */ +export function defaultChartVersion(versions: T[]): T | undefined { + return versions.find((t) => semver(chartVersion(t.tag))?.pre.length === 0) ?? versions[0] +} + +/** `url` as a link target when it is an http(s) URL; `null` otherwise. */ +export function httpUrl(url: string | undefined): string | null { + if (!url) return null + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : null + } catch { + return null + } +} diff --git a/web/src/queries/useChart.ts b/web/src/queries/useChart.ts new file mode 100644 index 00000000..d162d1d9 --- /dev/null +++ b/web/src/queries/useChart.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' + +import type { ApiError } from '~/lib/httpClient' +import { fetchChart, imageKeys, type ChartDetail } from '~/api/images' + +/** + * A Helm chart of an image (`GET /browse/chart`): the `Chart.yaml` metadata, + * README and default values of the manifest `digest`. Disabled until a + * digest is known. + */ +export function useChart(repo: string, image: string, digest: string | undefined) { + return useQuery({ + queryKey: imageKeys.chart(repo, image, digest ?? ''), + queryFn: ({ signal }) => fetchChart(repo, image, digest ?? '', signal), + enabled: !!digest, + }) +} diff --git a/web/src/routes/Browse.test.tsx b/web/src/routes/Browse.test.tsx index 25bdd957..7cd6e16d 100644 --- a/web/src/routes/Browse.test.tsx +++ b/web/src/routes/Browse.test.tsx @@ -60,11 +60,11 @@ const repos: BrowseRepo[] = [ { id: 4, name: 'docker-hosted', kind: 'hosted', online: true, format: 'docker' }, { id: 5, - name: 'charts', + name: 'pkgs', kind: 'hosted', online: true, - format: 'helm', - url: 'https://forge.example.com/repository/charts', + format: 'acme', + url: 'https://forge.example.com/repository/pkgs', }, ] @@ -240,18 +240,18 @@ describe('Browse root (repository folders)', () => { within(filter) .getAllByRole('radio') .map((o) => o.textContent?.replace(/ \d+$/, '')), - ).toEqual(['All', 'Docker', 'Helm', 'NuGet']) + ).toEqual(['All', 'acme', 'Docker', 'NuGet']) expect(within(filter).getByRole('radio', { name: /^All/ })).toBeChecked() - await user.click(within(filter).getByRole('radio', { name: /^Helm/ })) + await user.click(within(filter).getByRole('radio', { name: /^acme/ })) await waitFor(() => - expect(router.state.location.search).toEqual({ format: 'helm' }), + expect(router.state.location.search).toEqual({ format: 'acme' }), ) - expect(await main().findByRole('link', { name: /charts/ })).toBeInTheDocument() + expect(await main().findByRole('link', { name: /pkgs/ })).toBeInTheDocument() expect(main().queryByRole('link', { name: /internal/ })).toBeNull() expect(main().queryByRole('link', { name: /docker-hosted/ })).toBeNull() - expect(within(filter).getByRole('radio', { name: /^Helm/ })).toBeChecked() + expect(within(filter).getByRole('radio', { name: /^acme/ })).toBeChecked() await user.click(within(filter).getByRole('radio', { name: /^All/ })) @@ -544,24 +544,17 @@ describe('RepoBrowse connect snippets of package repositories', () => { expect(within(connect).queryByText(/dotnet nuget push/)).toBeNull() }) - it('shows the helm commands of a Helm repository', async () => { + it('shows the URL of a plugin format repository', async () => { mockApi(() => jsonResponse(page(0, []))) - await renderBrowse('/repos/charts') + await renderBrowse('/repos/pkgs') const connect = await screen.findByRole('region', { name: 'Connect' }) expect( - within(connect).getByText( - textContent('helm repo add charts https://forge.example.com/repository/charts --username --password '), - ), - ).toBeInTheDocument() - expect( - within(connect).getByText(textContent('helm install charts/')), + within(connect).getByText(textContent('https://forge.example.com/repository/pkgs')), ).toBeInTheDocument() expect( - within(connect).getByText( - textContent('curl -u : --data-binary @-.tgz https://forge.example.com/repository/charts/api/charts'), - ), + within(connect).getByRole('button', { name: 'Copy repository URL' }), ).toBeInTheDocument() }) }) diff --git a/web/src/routes/ImageDetail.test.tsx b/web/src/routes/ImageDetail.test.tsx index 2613dc11..45e3f6e5 100644 --- a/web/src/routes/ImageDetail.test.tsx +++ b/web/src/routes/ImageDetail.test.tsx @@ -10,7 +10,8 @@ import { import { clearSession } from '~/lib/auth' import type { Me } from '~/api/me' -import type { ImageDetail } from '~/api/images' +import type { ChartDetail, ImageDetail } from '~/api/images' +import { textContent } from '~/test/text' import { routeTree } from '~/routeTree.gen' import { searchOptions } from '~/lib/search' @@ -113,14 +114,25 @@ const hostedDetail: ImageDetail = { const fetchMock = vi.fn() -/** Route the mock: `/admin/me` -> identity, `/browse/images/{repo}/{image…}`. */ -function mockApi(detail: (repo: string, image: string) => Response) { +/** + * Route the mock: `/admin/me` -> identity, `/browse/images/{repo}/{image…}`, + * and `/browse/chart` -> `chart` (404 without one). + */ +function mockApi( + detail: (repo: string, image: string) => Response, + chart?: (query: URLSearchParams) => Response, +) { fetchMock.mockImplementation((input: RequestInfo | URL) => { const raw = typeof input === 'string' ? input : input.toString() const url = new URL(raw, 'http://localhost') if (url.pathname.endsWith('/admin/me')) { return Promise.resolve(jsonResponse(readerMe)) } + if (url.pathname.endsWith('/browse/chart')) { + return Promise.resolve( + chart ? chart(url.searchParams) : new Response('chart not found', { status: 404 }), + ) + } const m = url.pathname.match(/\/browse\/images\/([^/]+)\/(.+)$/) if (m) return Promise.resolve(detail(m[1], m[2])) return Promise.resolve(new Response('not found', { status: 404 })) @@ -444,4 +456,140 @@ describe('ImageDetail', () => { expect(within(alert).getByText('Could not load image')).toBeInTheDocument() expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() }) + + describe('a Helm chart', () => { + const CHART_OLD = 'sha256:5555555555ee' + '0'.repeat(52) + const CHART_NEW = 'sha256:6666666666ff' + '0'.repeat(52) + const HELM = 'application/vnd.cncf.helm.config.v1+json' + const chartManifest = (digest: string, tag: string, created_at: string) => ({ + digest, + media_type: 'application/vnd.oci.image.manifest.v1+json', + artifact_type: HELM, + subject_digest: null, + manifest_size: 400, + size: 3_210, + platforms: [], + tags: [tag], + children: [], + created_at, + last_pulled_at: null, + }) + const chartDetail: ImageDetail = { + repo: 'charts', + repo_kind: 'hosted', + image: 'stable/web', + registry: 'forge.example.com', + pull_name: 'forge.example.com/charts/stable/web', + tags: [ + { tag: '1.10.0', digest: CHART_NEW, media_type: 'application/vnd.oci.image.manifest.v1+json', size: 3_210, platforms: [] }, + { tag: '1.9.0', digest: CHART_OLD, media_type: 'application/vnd.oci.image.manifest.v1+json', size: 3_210, platforms: [] }, + ], + manifests: [ + // The older version was pushed last: the newest version still leads. + chartManifest(CHART_NEW, '1.10.0', '2026-10-20T10:00:00Z'), + chartManifest(CHART_OLD, '1.9.0', '2026-10-21T10:00:00Z'), + ], + } + const stored = (digest: string): ChartDetail => ({ + digest, + metadata: { + name: 'web', + version: digest === CHART_NEW ? '1.10.0' : '1.9.0', + appVersion: '2.4.1', + description: 'A web server', + home: 'https://example.com/web', + sources: ['javascript:alert(1)'], + maintainers: [{ name: 'Ops', email: 'ops@example.com' }], + dependencies: [{ name: 'redis', version: '>=17', repository: 'oci://registry/charts' }], + }, + readme: { name: 'README.md', text: `# web ${digest === CHART_NEW ? 'new' : 'old'}`, truncated: false }, + values: { name: 'values.yaml', text: 'replicas: 1\n', truncated: true }, + archive_stored: true, + }) + + it('shows the helm commands, facts, README and values of the newest version', async () => { + const asked: string[] = [] + mockApi( + () => jsonResponse(chartDetail), + (q) => { + asked.push(`${q.get('repo')}|${q.get('image')}|${q.get('digest')}`) + return jsonResponse(stored(q.get('digest') ?? '')) + }, + ) + + const user = userEvent.setup() + const router = await renderDetail('/images/charts/stable/web') + + expect(await screen.findByText('Helm chart')).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Pull' })).toBeNull() + const install = screen.getByRole('region', { name: 'Install' }) + expect( + within(install).getByText( + textContent('helm install oci://forge.example.com/charts/stable/web --version 1.10.0'), + ), + ).toBeInTheDocument() + expect( + within(install).getByText('helm pull oci://forge.example.com/charts/stable/web --version 1.10.0'), + ).toBeInTheDocument() + expect( + within(install).getByText( + textContent('helm registry login forge.example.com --username '), + ), + ).toBeInTheDocument() + + expect(await screen.findByText('# web new')).toBeInTheDocument() + expect(asked).toEqual([`charts|stable/web|${CHART_NEW}`]) + const facts = screen.getByRole('region', { name: 'Chart' }) + expect(within(facts).getByText('2.4.1')).toBeInTheDocument() + expect(within(facts).getByRole('link', { name: 'https://example.com/web' })).toHaveAttribute( + 'href', + 'https://example.com/web', + ) + // Only http(s) URLs become links. + expect(within(facts).getByText('javascript:alert(1)').closest('a')).toBeNull() + expect(within(facts).getByText('ops@example.com', { exact: false })).toBeInTheDocument() + const values = screen.getByRole('region', { name: 'Default values' }) + expect(within(values).getByText('values.yaml')).toBeInTheDocument() + expect(within(values).getByText(/Cut at 512 KiB/)).toBeInTheDocument() + + await user.selectOptions(within(install).getByLabelText('Version'), '1.9.0') + expect(await screen.findByText('# web old')).toBeInTheDocument() + expect( + within(install).getByText('helm pull oci://forge.example.com/charts/stable/web --version 1.9.0'), + ).toBeInTheDocument() + expect(router.state.location.search).toMatchObject({ tag: '1.9.0' }) + }) + + it('says when a proxy has not stored the chart yet', async () => { + mockApi( + () => jsonResponse({ ...chartDetail, repo_kind: 'proxy' }), + (q) => + jsonResponse({ + digest: q.get('digest'), + metadata: null, + readme: null, + values: null, + archive_stored: false, + }), + ) + + await renderDetail('/images/charts/stable/web') + + expect(await screen.findByText('Not stored yet')).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'README' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Chart' })).toBeNull() + }) + + it('renders an error state when the chart cannot be read', async () => { + mockApi( + () => jsonResponse(chartDetail), + () => new Response('boom', { status: 500 }), + ) + + await renderDetail('/images/charts/stable/web') + + const alert = await screen.findByRole('alert') + expect(within(alert).getByText('Could not load the chart')).toBeInTheDocument() + }) + }) }) diff --git a/web/src/routes/ImageDetail.tsx b/web/src/routes/ImageDetail.tsx index 31cc1bab..6c60bbeb 100644 --- a/web/src/routes/ImageDetail.tsx +++ b/web/src/routes/ImageDetail.tsx @@ -4,8 +4,9 @@ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import type { ImageDetail } from '~/api/images' import { isRepositoryOffline } from '~/api/packages' import { ApiError } from '~/lib/httpClient' -import { defaultPullTag, formatDateTime } from '~/lib/oci' +import { defaultPullTag, formatDateTime, isChartImage } from '~/lib/oci' import { useImageDetail } from '~/queries/useImageDetail' +import { ChartView } from '~/components/ChartView' import { ManifestTable, TagTable } from '~/components/ImageTables' import { ErrorState, RepositoryOfflineState } from '~/components/ErrorState' import { EmptyState } from '~/components/EmptyState' @@ -40,7 +41,9 @@ export const Route = createFileRoute('/_app/images/$repo/$')({ * Image-detail view of a docker repository: the pull commands (by tag and by * digest), the image's tags, and every manifest it holds — images, indexes * and referrers such as signatures — with sizes, platforms and timestamps. - * Loading / not-found / error states mirror the package-detail screen. + * An image of Helm charts (`helm push`) shows its charts instead + * ({@link ChartView}). Loading / not-found / error states mirror the + * package-detail screen. */ function ImageDetailPage() { const { repo, _splat } = Route.useParams() @@ -97,34 +100,50 @@ function ImageDetailPage() { .filter((t): t is string => !!t) .sort() .at(-1) + const chart = isChartImage(img.manifests) + const selectTag = (next: string) => + void navigate({ search: { tag: next }, replace: true }) + const header = ( + + ) + + if (chart) { + return ( + + {header} + + + ) + } return ( - - - - void navigate({ search: { tag: next }, replace: true }) - } - /> + {header} + + defaultVersion(versions)) - if (format !== 'nuget' && format !== 'helm') return null + if (format !== 'nuget') return null return ( )} - {format === 'nuget' ? ( - <> - - `} - placeholders={[]} - /> - - ) : ( - ${repo}/${id} --version ${version}`} - copyLabel="Copy install command" - /> - )} + + `} + placeholders={[]} + /> ) } diff --git a/web/src/routes/RepoBrowse.tsx b/web/src/routes/RepoBrowse.tsx index ffed436c..a8f7da9c 100644 --- a/web/src/routes/RepoBrowse.tsx +++ b/web/src/routes/RepoBrowse.tsx @@ -105,7 +105,7 @@ export const Route = createFileRoute('/_app/repos/$repo')({ /** * One repository's contents: the second level of the folder view, with the * per-repo search box — packages (plus the client snippets of the format: - * `dotnet nuget`, `helm repo add`, or the URL) for a package repository, + * `dotnet nuget`, or the URL) for a package repository, * images (plus the `docker login` / `pull` snippets) for a docker one. The query is debounced * into the URL search params (so it is shareable and survives reload) and fed * to `usePackageSearch` / `useImageSearch` scoped to this repository, which diff --git a/web/src/routes/Tokens.tsx b/web/src/routes/Tokens.tsx index 1ffd26da..172065e8 100644 --- a/web/src/routes/Tokens.tsx +++ b/web/src/routes/Tokens.tsx @@ -65,8 +65,9 @@ function MyTokens() { description={ <> Registry tokens authenticate NuGet clients (dotnet restore,{' '} - push), Helm and Docker clients as you: use one as the - password of docker login with your username. They + push), Docker and Helm clients as you: use one as the + password of docker login or{' '} + helm registry login with your username. They cannot be used to sign in here. The secret is shown only at creation time. diff --git a/web/src/routes/admin/repositories/Edit.tsx b/web/src/routes/admin/repositories/Edit.tsx index 0acf57d3..7ffa689c 100644 --- a/web/src/routes/admin/repositories/Edit.tsx +++ b/web/src/routes/admin/repositories/Edit.tsx @@ -39,9 +39,9 @@ export const Route = createFileRoute('/_app/manage/repositories/$repo')({ * is **write-only**: it is never seeded from the GET and is only included in * the update when the admin typed a new one (an empty field leaves the stored * secret unchanged); "Remove stored credentials" clears it explicitly. The - * header links group repos to the Members editor, hosted NuGet, Helm and - * Docker repositories to the import from Nexus, and every other repository to - * its retention policy. Beside the form: for proxies, + * header links group repos to the Members editor, hosted NuGet and Docker + * repositories to the import from Nexus, and every other repository to its + * retention policy. Beside the form: for proxies, * their cache invalidation, and the repository's anonymous access (the flag * together with the anonymous role's grants that cover it, read-only, linking * to the role). diff --git a/web/src/routes/admin/repositories/Import.tsx b/web/src/routes/admin/repositories/Import.tsx index b1e161c3..fc00c9b2 100644 --- a/web/src/routes/admin/repositories/Import.tsx +++ b/web/src/routes/admin/repositories/Import.tsx @@ -126,7 +126,7 @@ function ImportRepository() { {!supported ? ( - Imports go to a hosted NuGet, Helm or Docker repository; this is a{' '} + Imports go to a hosted NuGet or Docker repository; this is a{' '} {repo.data.format} {repo.data.kind} repository. ) : active ? ( diff --git a/web/src/routes/admin/repositories/repositories.test.tsx b/web/src/routes/admin/repositories/repositories.test.tsx index c4259e2e..264b6048 100644 --- a/web/src/routes/admin/repositories/repositories.test.tsx +++ b/web/src/routes/admin/repositories/repositories.test.tsx @@ -226,33 +226,31 @@ describe('admin repositories', () => { expect(posted).not.toHaveProperty('remote_url') }) - it('creates a repository of a built-in format plugin', async () => { + it('creates a repository of a format plugin', async () => { let posted: unknown = null mockApi({ repositories: [], formats: [ { id: 'nuget', name: 'NuGet', builtin: true, kinds: ['hosted', 'proxy', 'group'], mount: '/v3' }, - { id: 'helm', name: 'Helm charts', builtin: true, kinds: ['hosted', 'group'], mount: '/repository' }, { id: 'acme', name: 'Acme packages', builtin: false, kinds: ['hosted', 'group'], mount: '/repository' }, ], onCreate: (body) => { posted = body - return json(repo({ name: (body as RepoView).name, format: 'helm' }), 201) + return json(repo({ name: (body as RepoView).name, format: 'acme' }), 201) }, }) const user = userEvent.setup() await renderAt('/manage/repositories/new') - await user.type(await screen.findByLabelText('Name'), 'charts') - await screen.findByRole('option', { name: 'Helm charts' }) + await user.type(await screen.findByLabelText('Name'), 'pkgs') // A WebAssembly plugin is named with its id. - expect(screen.getByRole('option', { name: 'Acme packages (plugin acme)' })).toBeInTheDocument() - await user.selectOptions(screen.getByLabelText('Format'), 'helm') + await screen.findByRole('option', { name: 'Acme packages (plugin acme)' }) + await user.selectOptions(screen.getByLabelText('Format'), 'acme') await user.click(screen.getByRole('button', { name: 'Create repository' })) await waitFor(() => expect(posted).not.toBeNull()) - expect(posted).toMatchObject({ name: 'charts', kind: 'hosted', format: 'helm' }) + expect(posted).toMatchObject({ name: 'pkgs', kind: 'hosted', format: 'acme' }) }) it('lists the format of each repository', async () => { diff --git a/web/src/styles/forge-ui.css b/web/src/styles/forge-ui.css index 61c7cff2..e64b8348 100644 --- a/web/src/styles/forge-ui.css +++ b/web/src/styles/forge-ui.css @@ -115,6 +115,9 @@ .fg-codeblock-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: 6px 6px 6px 12px; border-bottom: 1px solid var(--code-line); background: var(--code-raised); } .fg-codeblock-title { color: var(--code-muted); font-family: var(--font-mono); font-size: 12px; line-height: 16px; } .fg-codeblock-pre { margin: 0; padding: 12px 14px; overflow-x: auto; color: var(--code-ink); font-family: var(--font-mono); font-size: 13px; line-height: 20px; font-weight: 450; } + /* A chart's README and default values: long files scroll inside the panel. */ + .fg-readme { margin: 0; max-height: 640px; overflow: auto; color: var(--ink); font-family: var(--font-mono); font-size: 13px; line-height: 20px; white-space: pre-wrap; overflow-wrap: anywhere; } + .fg-chart-values .fg-codeblock-pre { max-height: 640px; overflow: auto; } .fg-copyfield { display: flex; min-width: 0; } .fg-copyfield-input { flex: 1; min-width: 0; border-top-right-radius: 0; border-bottom-right-radius: 0; background: var(--surface-sunken); } .fg-copyfield-btn { display: inline-flex; align-items: center; gap: 6px; flex: none; height: var(--control-md); padding: 0 12px; box-sizing: border-box; border: 1px solid var(--line-strong); border-left: 0; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; background: var(--surface); color: var(--ink); font-family: var(--font-sans); font-size: 13px; font-weight: 500; cursor: pointer; } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 06d0cdf1..60977781 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -772,11 +772,16 @@ fn plugin_component(dir: &std::path::Path) -> Result { } /// Whether the server links the plugin in `dir` (a path dependency of -/// forge-server): its id is then taken by the built-in plugin. +/// forge-server, not only of its tests): its id is then taken by the +/// built-in plugin. fn plugin_is_builtin(dir: &std::path::Path) -> bool { let server = std::fs::read_to_string("crates/server/Cargo.toml").unwrap_or_default(); + let dependencies = server + .split("[dev-dependencies]") + .next() + .unwrap_or_default(); let path = format!("\"../../{}\"", dir.display()).replace('\\', "/"); - server.contains(&path) + dependencies.contains(&path) } /// The SHA-256 of `file` (`sha256sum`, or `shasum -a 256` on macOS).