From 7bdd152db9aec3eb0797c09aca41a0875745a83d Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 20 Sep 2026 20:46:26 +0000 Subject: [PATCH] =?UTF-8?q?=E7=BA=B3=E7=AE=A1=E5=B9=B6=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=20Nexus=20OCI=20=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/nexus/README.md | 46 ++++++++++---- apps/nexus/terraform/oci/oci-hosted.json | 17 +++++ apps/nexus/terraform/oci/oci-proxy.json | 27 ++++++++ apps/nexus/terraform/oci/oci-public.json | 15 +++++ apps/nexus/terraform/reconcile-oci.sh | 80 ++++++++++++++++++++++++ apps/nexus/terraform/security.tf | 25 ++++++++ 6 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 apps/nexus/terraform/oci/oci-hosted.json create mode 100644 apps/nexus/terraform/oci/oci-proxy.json create mode 100644 apps/nexus/terraform/oci/oci-public.json create mode 100755 apps/nexus/terraform/reconcile-oci.sh diff --git a/apps/nexus/README.md b/apps/nexus/README.md index f13d000..af5485a 100644 --- a/apps/nexus/README.md +++ b/apps/nexus/README.md @@ -16,10 +16,11 @@ - 不套 Authelia forward-auth;它会破坏 Go、Ansible 与 OCI 非浏览器客户端。 - 现有 zot 保持不变。Nexus 完成 OCI、BuildKit cache 和恢复验收前不得迁移或删除 zot。 -POC 先由 Terraform 创建 `ansible-public` 和 `go-public`。Nexus 3.94 才加入的原生 OCI -repository 已有 REST API,但当前锁定的 community provider 尚未暴露 OCI resource;不能 -因此在 UI 中创建一个无人管理的长期 repository。POC 上线后先根据实例 Swagger 固定 -请求 schema,再补 Terraform provider resource 或受测试的声明式 REST 适配。 +Terraform provider 创建 `ansible-public`、`go-public`、最小匿名权限与 OCI Bearer Token +Realm。Nexus 3.94 才加入的原生 OCI repository 已有 REST API,但当前锁定的 community +provider 尚未暴露 OCI resource;`terraform/reconcile-oci.sh` 因此根据 3.96.1 实例 Swagger +固定的 JSON schema,幂等调和 `oci-hosted`、`oci-proxy` 与 `oci-public`。不得绕过该入口在 +UI 中创建无人管理的长期 repository。 ## 部署 @@ -73,17 +74,25 @@ terraform -chdir=apps/nexus/terraform init -backend=false terraform -chdir=apps/nexus/terraform validate ``` +先以 `--check` 查看 OCI repository 漂移,再明确 apply;脚本只从上述环境变量取得凭据, +用临时 `0600` netrc 调用 REST API,退出时删除: + +```bash +apps/nexus/terraform/reconcile-oci.sh --check +apps/nexus/terraform/reconcile-oci.sh --apply +``` + Terraform 同时把内置 `anonymous` 用户从默认的全仓库 `nx-anonymous` 角色收窄到 -`ansible-public`、其返回制品 URL 使用的 `ansible-galaxy-proxy`,以及 `go-public` 的 -`browse/read` 权限。首次接管已有实例时先执行 +`ansible-public`、其返回制品 URL 使用的 `ansible-galaxy-proxy`、`go-public`,以及 +`oci-public`/`oci-proxy` 的 `browse/read` 权限。`oci-hosted` 不向匿名用户开放。首次接管 +已有实例时先执行 `terraform import sonatyperepo_user.anonymous anonymous,default`,再 apply;不要先启用默认的 全仓库匿名读取。 2026-09-20 的 POC 现场验收已确认:Flux 与 Pod Ready、PVC Bound、HTTPRoute 通过 HTTPS 返回 Nexus 状态 200,Samba DNS 已幂等收敛。全新客户端目录通过匿名入口下载 `community.general:11.2.0` 时冷缓存为 8.49 秒、热缓存为 1.89 秒,两次 tarball SHA-256 -一致;`golang.org/x/text@v0.30.0` 为 2.92 秒与 1.51 秒。该结果只证明 Ansible/Go -代理链路,不代表 OCI、BuildKit cache、备份恢复或正式数据库已经验收。 +一致;`golang.org/x/text@v0.30.0` 为 2.92 秒与 1.51 秒。 ## 客户端验收 @@ -112,10 +121,23 @@ GOPROXY=https://nexus.ad.ddupan.top/repository/go-public/ go mod download 私有 `git.ddupan.top/*` module 的 `GOPRIVATE`、凭据与是否允许 `direct` fallback 在实际 workflow 中单独决定;不要让私有 module path 意外发往公共 proxy。 -OCI 阶段至少验证普通 push/pull、multi-arch、OCI 1.1 referrers、Cosign、Helm OCI, -以及 BuildKit `registry` cache 的 `mode=max` 导入导出。Nexus 上游已有 BuildKit cache -兼容性问题记录,因此该项失败时不能用普通镜像 push 成功代替。全部通过并完成备份恢复 -演练后,才评估 zot 迁移。 +OCI 使用 path-based routing:匿名公共拉取地址形如 +`nexus.ad.ddupan.top/oci-public/library/alpine:3.22`,认证写入地址形如 +`nexus.ad.ddupan.top/oci-hosted//:`。2026-09-20 现场验收结果: + +- `oci-public` 匿名代理拉取 Alpine 冷缓存 4.75 秒、热缓存 0.80 秒,digest 一致; +- `oci-hosted` 认证 push/pull 成功,匿名 pull 返回 401; +- amd64/arm64 OCI image index push 成功,两个平台 manifest 可见; +- Helm chart push/pull digest 与本地 tarball SHA-256 一致; +- Cosign 3.1.3 使用一次性密钥签名并验证成功,OCI 1.1 referrers API 返回一个 + `application/vnd.dev.sigstore.bundle.v0.3+json` artifact; +- BuildKit `registry` cache 以 `mode=max` 导出成功,销毁首个 builder 后由新 builder 导入, + 两个 `RUN` step 均明确命中 `CACHED`。 + +本机安装的测试客户端包括 `/usr/local/bin/cosign` 3.1.3;安装时核对官方 Linux amd64 +binary SHA-256 `4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71`。 +上述结果仍不代表备份恢复、外部 PostgreSQL 或正式 publisher service account 已完成; +这些项目通过前不得迁移或删除 zot。 ## 数据与恢复 diff --git a/apps/nexus/terraform/oci/oci-hosted.json b/apps/nexus/terraform/oci/oci-hosted.json new file mode 100644 index 0000000..da88af7 --- /dev/null +++ b/apps/nexus/terraform/oci/oci-hosted.json @@ -0,0 +1,17 @@ +{ + "name": "oci-hosted", + "online": true, + "storage": { + "blobStoreName": "default", + "strictContentTypeValidation": true, + "writePolicy": "ALLOW", + "latestPolicy": false + }, + "oci": { + "v1Enabled": false, + "forceBasicAuth": false, + "pathEnabled": true + }, + "component": { "proprietaryComponents": false }, + "cosign": { "enforcement": "NONE" } +} diff --git a/apps/nexus/terraform/oci/oci-proxy.json b/apps/nexus/terraform/oci/oci-proxy.json new file mode 100644 index 0000000..15be61f --- /dev/null +++ b/apps/nexus/terraform/oci/oci-proxy.json @@ -0,0 +1,27 @@ +{ + "name": "oci-proxy", + "online": true, + "storage": { + "blobStoreName": "default", + "strictContentTypeValidation": true + }, + "oci": { + "v1Enabled": false, + "forceBasicAuth": false, + "pathEnabled": true + }, + "ociProxy": { + "indexType": "HUB", + "cacheForeignLayers": false, + "foreignLayerUrlWhitelist": [] + }, + "proxy": { + "remoteUrl": "https://registry-1.docker.io", + "contentMaxAge": 1440, + "metadataMaxAge": 60, + "preserveEncodedCharacters": false + }, + "negativeCache": { "enabled": true, "timeToLive": 60 }, + "httpClient": { "blocked": false, "autoBlock": true }, + "cosign": { "enforcement": "NONE" } +} diff --git a/apps/nexus/terraform/oci/oci-public.json b/apps/nexus/terraform/oci/oci-public.json new file mode 100644 index 0000000..d2c9654 --- /dev/null +++ b/apps/nexus/terraform/oci/oci-public.json @@ -0,0 +1,15 @@ +{ + "name": "oci-public", + "online": true, + "storage": { + "blobStoreName": "default", + "strictContentTypeValidation": true + }, + "group": { "memberNames": ["oci-proxy"] }, + "oci": { + "v1Enabled": false, + "forceBasicAuth": false, + "pathEnabled": true + }, + "cosign": { "enforcement": "NONE" } +} diff --git a/apps/nexus/terraform/reconcile-oci.sh b/apps/nexus/terraform/reconcile-oci.sh new file mode 100755 index 0000000..616e9b3 --- /dev/null +++ b/apps/nexus/terraform/reconcile-oci.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:---check}" +case "$mode" in + --check | --apply) ;; + *) echo "usage: $0 [--check|--apply]" >&2; exit 2 ;; +esac + +: "${TF_VAR_nexus_url:?set TF_VAR_nexus_url}" +: "${TF_VAR_nexus_username:?set TF_VAR_nexus_username}" +: "${TF_VAR_nexus_password:?set TF_VAR_nexus_password}" + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +auth_file="$(mktemp /tmp/nexus-oci-auth.XXXXXX)" +trap 'rm -f -- "$auth_file"' EXIT +chmod 0600 "$auth_file" +printf 'machine %s\nlogin %s\npassword %s\n' \ + "${TF_VAR_nexus_url#*://}" "$TF_VAR_nexus_username" \ + "$TF_VAR_nexus_password" >"$auth_file" + +drift=0 +for entry in \ + "hosted:$script_dir/oci/oci-hosted.json" \ + "proxy:$script_dir/oci/oci-proxy.json" \ + "group:$script_dir/oci/oci-public.json"; do + repository_type="${entry%%:*}" + desired_file="${entry#*:}" + repository_name="$(jq -er '.name' "$desired_file")" + endpoint="$TF_VAR_nexus_url/service/rest/v1/repositories/oci/$repository_type" + current_file="$(mktemp /tmp/nexus-oci-current.XXXXXX)" + + status="$(curl --silent --show-error --netrc-file "$auth_file" \ + --output "$current_file" --write-out '%{http_code}' \ + "$endpoint/$repository_name")" + + if [[ "$status" == 404 ]]; then + drift=1 + if [[ "$mode" == --apply ]]; then + curl --fail --silent --show-error --netrc-file "$auth_file" \ + --header 'Content-Type: application/json' \ + --data-binary "@$desired_file" "$endpoint" + echo "created $repository_name" + else + echo "missing $repository_name" >&2 + fi + elif [[ "$status" == 200 ]]; then + if jq -e --slurpfile desired "$desired_file" ' + def subset($actual; $wanted): + if ($wanted | type) == "object" then + all($wanted | keys[]; + ($actual[.] != null) and subset($actual[.]; $wanted[.])) + else + $actual == $wanted + end; + subset(.; $desired[0]) + ' "$current_file" >/dev/null; then + echo "in sync $repository_name" + else + drift=1 + if [[ "$mode" == --apply ]]; then + curl --fail --silent --show-error --netrc-file "$auth_file" \ + --request PUT --header 'Content-Type: application/json' \ + --data-binary "@$desired_file" "$endpoint/$repository_name" + echo "updated $repository_name" + else + echo "drifted $repository_name" >&2 + fi + fi + else + cat "$current_file" >&2 + echo "unexpected HTTP $status for $repository_name" >&2 + exit 1 + fi + rm -f -- "$current_file" +done + +if [[ "$mode" == --check && "$drift" -ne 0 ]]; then + exit 1 +fi diff --git a/apps/nexus/terraform/security.tf b/apps/nexus/terraform/security.tf index 0fe69bd..b432c00 100644 --- a/apps/nexus/terraform/security.tf +++ b/apps/nexus/terraform/security.tf @@ -22,6 +22,22 @@ resource "sonatyperepo_privilege_repository_view" "anonymous_go" { repository = sonatyperepo_repository_go_group.public.name } +resource "sonatyperepo_privilege_repository_view" "anonymous_oci_public" { + name = "ci-anonymous-oci-public-read" + description = "Anonymous read access to the public OCI group" + actions = ["BROWSE", "READ"] + format = "oci" + repository = "oci-public" +} + +resource "sonatyperepo_privilege_repository_view" "anonymous_oci_proxy" { + name = "ci-anonymous-oci-proxy-read" + description = "Anonymous read access to the OCI proxy member" + actions = ["BROWSE", "READ"] + format = "oci" + repository = "oci-proxy" +} + resource "sonatyperepo_role" "anonymous_ci" { id = "ci-anonymous-read" name = "CI anonymous read" @@ -30,6 +46,8 @@ resource "sonatyperepo_role" "anonymous_ci" { sonatyperepo_privilege_repository_view.anonymous_ansible.name, sonatyperepo_privilege_repository_view.anonymous_ansible_proxy.name, sonatyperepo_privilege_repository_view.anonymous_go.name, + sonatyperepo_privilege_repository_view.anonymous_oci_public.name, + sonatyperepo_privilege_repository_view.anonymous_oci_proxy.name, ] roles = [] } @@ -48,3 +66,10 @@ resource "sonatyperepo_system_anonymous_access" "ci" { user_id = sonatyperepo_user.anonymous.user_id realm_name = "NexusAuthorizingRealm" } + +resource "sonatyperepo_security_realms" "active" { + active = [ + "NexusAuthenticatingRealm", + "OciBearerToken", + ] +}