diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3c9923bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +.git +.git/** +.worktrees +.worktrees/** + +**/__pycache__ +**/*.py[cod] +.pytest_cache +**/.pytest_cache +**/.cache + +**/target +**/target/** + +.env +.env.* +!.env.example +!.env.*.example + +.DS_Store +**/.DS_Store +**/*~ +**/*.swp +**/*.swo +**/*.tmp +**/*.temp +.idea +**/.idea +.vscode +**/.vscode diff --git a/.gitignore b/.gitignore index 1f8b4eaa..93bb3b51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .omc/** image/jar/** .env +.worktrees/ diff --git a/.tekton/build-base-image.yaml b/.tekton/build-base-image.yaml index 7cc32cc2..2ab661b7 100644 --- a/.tekton/build-base-image.yaml +++ b/.tekton/build-base-image.yaml @@ -1,3 +1,13 @@ +# nexus-ce-base: the JDK17+Node22+Yarn build-time environment image used to +# compile the Nexus server app (build-nexus-app step in build-image.yaml). +# Not shipped to customers -- amd64-only (the compile step always runs on a +# single build node regardless of the target server-image platforms). +# +# Option-D (DEVOPS-44489): hub `alauda` catalog's clone-image-build-test-scan@0.2 +# (type:tekton) is unserved on edge-build (memory edge-build-hub-resolver-gap). +# Resolved as a native inline pipelineSpec -- no top-level pipelineRef, inner +# taskRefs `resolver: hub` (no `type:`) from catalog/extras at served versions, +# matching the gitlab/toolbox/nexus-ce-operator precedent. apiVersion: tekton.dev/v1 kind: PipelineRun metadata: @@ -5,82 +15,134 @@ metadata: annotations: pipelinesascode.tekton.dev/on-comment: "^((/test-all)|(/build-base-image))$" pipelinesascode.tekton.dev/max-keep-runs: "5" + pipelinesascode.tekton.dev/cancel-in-progress: "true" spec: - pipelineRef: - resolver: hub - params: - - name: catalog - value: alauda - - name: type - value: tekton - - name: kind - value: pipeline - - name: name - value: clone-image-build-test-scan - - name: version - value: "0.2" + timeouts: + pipeline: 1h params: - name: git-url value: "{{ repo_url }}" - name: git-revision value: "{{ source_branch }}" - - name: git-commit - value: "{{ revision }}" - name: pull-request-number value: "{{ pull_request_number }}" - - name: image-repository - value: build-harbor.alauda.cn/devops/nexus-ce-base + pipelineSpec: + description: >- + Inline pipeline for nexus-ce-base (build-base-image.yaml): git-clone -> + buildctl (amd64-only build environment image, no scan -- matches source + pipeline's ignore-trivy-scan: true). - - name: containerfile-path - value: image/Containerfile.base + params: + - name: git-url + type: string + - name: git-revision + type: string + - name: pull-request-number + type: string + default: "" - - name: context - value: "image" + tasks: + - name: git-clone + timeout: 30m + retries: 3 + taskRef: + resolver: hub + params: + - {name: catalog, value: catalog} + - {name: kind, value: task} + - {name: name, value: git-clone} + - {name: version, value: "0.10"} + params: + - name: url + value: $(params.git-url) + - name: revision + value: $(params.git-revision) + - name: pr-number + value: $(params.pull-request-number) + workspaces: + - name: output + workspace: source + - name: basic-auth + workspace: basic-auth - - name: tags - value: - - latest + # image-repository: build-harbor.alauda.cn/devops/nexus-ce-base -> + # {{ registry_url }}/alauda-devops-toolchain/nexus/nexus-ce-base (P1/P2, + # output-path convention shared with nexus-ce-operator). + - name: buildctl + runAfter: [git-clone] + timeout: 45m + retries: 1 + taskRef: + resolver: hub + params: + - {name: catalog, value: extras} + - {name: kind, value: task} + - {name: name, value: buildctl} + - {name: version, value: "0.1"} + params: + - name: imageURL + value: "{{ registry_url }}/alauda-devops-toolchain/nexus/nexus-ce-base" + - name: imageTags + value: + - latest + - name: containerfile + value: Containerfile.base + - name: context + value: image + - name: platform + value: + - linux/amd64 + workspaces: + - name: source + workspace: source + - name: registry-config + workspace: registry-config - - name: ignore-trivy-scan - value: "true" + workspaces: + - name: source + description: Shared source checkout workspace. + - name: basic-auth + description: Git auth credentials injected by the code-alauda-io connector. + - name: registry-config + description: Docker registry auth mounted as config.json (registry-alauda-io). - - name: file-list-for-commit-sha - value: - - .tekton/build-base-image.yaml - - image/Containerfile.base workspaces: - name: source volumeClaimTemplate: spec: + storageClassName: sc-topolvm accessModes: - - ReadWriteMany + - ReadWriteOnce resources: requests: - storage: 1Gi - - name: registryconfig - secret: - secretName: build-harbor.kauto.docfj - # This secret will be replaced by the pac controller + storage: 2Gi - name: basic-auth + csi: + driver: connectors-csi + readOnly: true + volumeAttributes: + configuration.names: gitconfig + connector.name: code-alauda-io + connector.namespace: alauda-devops-toolchain + token.expiration: 30m + - name: registry-config secret: - secretName: "{{ git_auth_secret }}" - - name: gitversion-config - configMap: - name: gitversion-config + secretName: "{{ registry_secret }}" taskRunTemplate: - # 让所有任务都以非 root 用户运行。 podTemplate: securityContext: runAsUser: 65532 runAsGroup: 65532 fsGroup: 65532 fsGroupChangePolicy: "OnRootMismatch" + imagePullSecrets: + - name: "{{ registry_secret }}" taskRunSpecs: - - pipelineTaskName: prepare-build + - pipelineTaskName: buildctl computeResources: limits: cpu: "4" diff --git a/.tekton/build-image.yaml b/.tekton/build-image.yaml index 43c35037..db025ad3 100644 --- a/.tekton/build-image.yaml +++ b/.tekton/build-image.yaml @@ -1,3 +1,14 @@ +# nexus-image: builds + pushes the Nexus Repository Manager SERVER image +# (the deliverable the operator/e2e/packaging need on registry-dev). +# +# Option-D (DEVOPS-44489): the source pipeline pointed at a separate in-cluster +# `Pipeline` CR (.tekton/pipeline/nexus-image-build.yaml, no resolver -- expected +# to already exist as a cluster object) whose own tasks used the hub `alauda` +# catalog (unserved) and buildx (not in the catalog at all -- only buildctl is). +# Inlined as a native pipelineSpec here (no top-level pipelineRef, no separate +# Pipeline CR) -- matches gitlab/toolbox/nexus-ce-operator precedent. The old +# .tekton/pipeline/nexus-image-build.yaml Pipeline CR file is removed; its task +# bodies are folded into this pipelineSpec's `tasks:` list unchanged in intent. apiVersion: tekton.dev/v1 kind: PipelineRun metadata: @@ -10,54 +21,486 @@ metadata: ( "source/**".pathChanged() || "image/**".pathChanged() || - ".tekton/build-image.yaml".pathChanged() || - ".tekton/pipeline/nexus-image-build".pathChanged() + ".tekton/build-image.yaml".pathChanged() ) pipelinesascode.tekton.dev/max-keep-runs: "5" + pipelinesascode.tekton.dev/cancel-in-progress: "true" spec: timeouts: pipeline: "2h" - pipelineRef: - name: nexus-image-build - params: + - name: git-url + value: "{{ repo_url }}" - name: git-revision - value: - url: "{{ repo_url }}" - branch: "{{ source_branch }}" - commit: "{{ revision }}" - pull-request-number: "{{ pull_request_number }}" - pull-request-source: "{{ source_branch }}" - pull-request-target: "{{ target_branch }}" + value: "{{ source_branch }}" + - name: pull-request-number + value: "{{ pull_request_number }}" - name: clean-cache value: "{{ clean-cache }}" + pipelineSpec: + description: >- + Inline pipeline for the Nexus server image: git-clone -> get-git-meta + (short sha for the image tag) -> build-nexus-app (yarn+maven compile, + runs in the nexus-ce-base image built by build-base-image.yaml) -> + buildctl (multi-arch Alpine server image) -> trivy-scanner -> + update-chart-values (auto-commit the new tag into chart/values.yaml, + consumed by nexus-ce-operator's charts/current submodule). + + params: + - name: git-url + type: string + - name: git-revision + type: string + - name: pull-request-number + type: string + default: "" + - name: clean-cache + type: string + default: "false" + + tasks: + - name: git-clone + timeout: 30m + retries: 3 + taskRef: + resolver: hub + params: + - {name: catalog, value: catalog} + - {name: kind, value: task} + - {name: name, value: git-clone} + - {name: version, value: "0.10"} + params: + - name: url + value: $(params.git-url) + - name: revision + value: $(params.git-revision) + - name: pr-number + value: $(params.pull-request-number) + workspaces: + - name: output + workspace: source + - name: basic-auth + workspace: basic-auth + + # image param rewritten: registry.alauda.cn:60080/devops/nonroot/chainguard/git:latest + # -> registry-dev.alauda.io/platform-edge/tekton-catalog-incubator/script-tools:latest + # (proven live fix, gitops FIX4 commit d85bb3d: port 60080 is unreachable from + # alauda-devops-toolchain-build; script-tools carries plain git/bash, which is + # all this step's script uses -- unlike update-chart-values below, no baked + # custom scripts are needed here). + - name: get-git-meta + timeout: 10m + retries: 0 + runAfter: + - git-clone + params: + - name: image + value: registry-dev.alauda.io/platform-edge/tekton-catalog-incubator/script-tools:latest + - name: imagePullPolicy + value: Always + - name: script + value: | + #!/bin/bash + set -e + git config --global --add safe.directory $(workspaces.source.path) + cat < $(results.object-result.path) + { + "commit_date": "$(git log -1 --pretty=%ct)", + "commit_sha": "$(git rev-parse HEAD)", + "short_id": "$(git rev-parse --short HEAD)", + "message": "gitlab commit meta" + } + EOM + taskRef: + resolver: hub + params: + - {name: catalog, value: catalog} + - {name: kind, value: task} + - {name: name, value: run-script} + - {name: version, value: "0.1"} + workspaces: + - name: source + workspace: source + + # image rewritten: build-harbor.alauda.cn/devops/nexus-ce-base:latest -> + # {{ registry_url }}/alauda-devops-toolchain/nexus/nexus-ce-base:latest + # (the image build-base-image.yaml publishes -- P1/P2). This image MUST + # already be built and pushed before this task runs; build-base-image.yaml + # fires independently on image/** changes, so a fresh nexus-build clone + # relies on a prior green build-base-image run having published :latest. + - name: build-nexus-app + timeout: 2h + retries: 0 + runAfter: + - git-clone + params: + - name: script + value: | + #!/bin/bash + set -xe + + mkdir -p $(workspaces.cache.path)/yarncache + mkdir -p $(workspaces.cache.path)/m2cache + + rm -rf $HOME/.m2/repository + rm -rf $(workspaces.source.path)/source/.yarn/cache + mkdir -p $(workspaces.source.path)/source/.yarn + mkdir -p $HOME/.m2 + + ln -s $(workspaces.cache.path)/m2cache $HOME/.m2/repository + ln -s $(workspaces.cache.path)/yarncache $(workspaces.source.path)/source/.yarn/cache + + if [ "$(params.clean-cache)" == "true" ]; then + rm -rf $(workspaces.cache.path)/yarncache/* + rm -rf $(workspaces.cache.path)/m2cache/* + fi + + cd $(workspaces.source.path)/source/ + yarn -v + yarn install + + ./mvnw -pl plugins/nexus-coreui-plugin,components/nexus-ui-plugin,components/nexus-rapture,components/nexus-swagger-filter -am clean install -Dpublic -DskipTests -s ./settings.xml + + mkdir -p $(workspaces.source.path)/image/jar/ + cp $(workspaces.source.path)/source/components/nexus-ui-plugin/target/nexus-ui-plugin-3.76.0-03.jar $(workspaces.source.path)/image/jar/nexus-ui-plugin-3.76.0-03.jar + cp $(workspaces.source.path)/source/components/nexus-rapture/target/nexus-rapture-3.76.0-03.jar $(workspaces.source.path)/image/jar/nexus-rapture-3.76.0-03.jar + cp $(workspaces.source.path)/source/plugins/nexus-coreui-plugin/target/nexus-coreui-plugin-3.76.0-03.jar $(workspaces.source.path)/image/jar/nexus-coreui-plugin-3.76.0-03.jar + + # SwaggerAccessFilter: block unauthenticated access to Swagger API docs (DEVOPS-43719) + cp $(workspaces.source.path)/source/components/nexus-swagger-filter/target/nexus-swagger-filter-3.76.0-03.jar $(workspaces.source.path)/image/jar/swagger-access-filter.jar + workspaces: + - name: source + workspace: source + - name: cache + workspace: cache + taskSpec: + workspaces: + - name: source + - name: cache + params: + - name: script + type: string + default: "" + steps: + - name: run-script + image: "{{ registry_url }}/alauda-devops-toolchain/nexus/nexus-ce-base:latest" + imagePullPolicy: Always + # HOME explicitly set: UID 65532 (pod-level runAsUser, required by + # PodSecurity restricted) has no passwd entry / home dir in the + # nexus-ce-base image (plain apt-installed JDK on Ubuntu, no USER + # directive) -- $HOME defaulted to "/" (unwritable), confirmed live + # ("mkdir: cannot create directory '//.m2': Permission denied"). + # Pointed at the source workspace (PVC-backed, writable regardless + # of UID) -- the script already mkdir's under here for .yarn/.m2. + # + # MAVEN_OPTS -Duser.home explicitly set: even with $HOME correct, + # the JVM's own user.home system property does NOT come from + # $HOME on Linux when getpwuid_r() finds no /etc/passwd entry for + # the running UID (65532, confirmed live: `java -XshowSettings: + # properties` -> "user.home = ?", a literal one-character "?"). + # Maven's default localRepository is ${user.home}/.m2/repository, + # so with user.home broken it resolved to a bogus *relative* + # "?/.m2/repository" under the mvnw invocation's cwd + # (source/source/?/.m2/repository/...) instead of our intended + # $HOME/.m2/repository symlink to the cache PVC -- confirmed live + # in nexus-image-zb7cr's frontend-maven-plugin download log line. + # -Duser.home on the JVM command line overrides the broken + # auto-detection outright (confirmed via the same live repro). + env: + - name: HOME + value: "$(workspaces.source.path)/home" + - name: MAVEN_OPTS + value: "-Duser.home=$(workspaces.source.path)/home" + # PodSecurity "restricted" (edge-build namespace policy) requires + # these explicitly at container level -- pod-level runAsUser/fsGroup + # (taskRunTemplate.podTemplate, below) doesn't cover + # allowPrivilegeEscalation/capabilities (not inheritable fields; + # confirmed live PodAdmissionFailed on the first smoke). The + # catalog-provided tasks (git-clone, run-script@0.1) already bake + # this in; this is the one custom inline taskSpec, so it needs it too. + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + computeResources: + requests: + cpu: "1" + memory: "1Gi" + limits: + cpu: "4" + memory: "4Gi" + script: | + #!/bin/sh + RUN_ON=`cat /proc/$$/comm` + if [ "$RUN_ON" != "bash" ] && command -v bash >/dev/null 2>&1; then + exec bash "$0" $@ + fi + set -eu + + $(params.script) + + # buildx -> buildctl (buildx not in the catalog; buildctl is the served + # equivalent, extras/0.1). image-url rewritten: + # build-harbor.alauda.cn/devops/sonatype-nexus3 -> + # {{ registry_url }}/alauda-devops-toolchain/nexus/sonatype-nexus3 (P1/P2, + # THE server-image output path the operator's chart/values.yaml + e2e + # testdata expect -- confirmed via nexus-ce-operator's own + # integration-test-runtime-image-missing escalation, which names this + # exact repository path as the missing mirror). No `platform` override: + # buildctl's own default (linux/amd64,linux/arm64) applies, matching + # chart/values.yaml's `support_arm: true` flag on this image. + - name: build-nexus-image + timeout: 1h + retries: 2 + taskRef: + resolver: hub + params: + - {name: catalog, value: extras} + - {name: kind, value: task} + - {name: name, value: buildctl} + - {name: version, value: "0.1"} + runAfter: + - build-nexus-app + workspaces: + - name: source + workspace: source + - name: registry-config + workspace: registry-config + params: + - name: imageURL + value: "{{ registry_url }}/alauda-devops-toolchain/nexus/sonatype-nexus3" + - name: imageTags + value: + - v3.76.0-g$(tasks.get-git-meta.results.object-result.short_id) + - name: containerfile + value: Containerfile.alpine.java17 + - name: context + value: image + + # katanomi.hub trivy-image-scan -> catalog trivy-scanner@0.6 (katanomi.hub + # resolver is unserved on edge-build, same family of gap as the type:tekton + # hub pipelines above). toolImage / dbRepository left at the task's own + # defaults (registry.alauda.cn:60070/... -- already the confirmed-reachable + # host+port per gitops FIX4; build-harbor.alauda.cn/ops/aquasecurity/trivy-db + # from the source pipeline was NOT ported, it was the unreachable host). + # BLOCKING gate (no onError set): confirmed live on nexus-image-q77l7 + # AFTER the stepSpecs resource fix let it schedule -- step-trivy-scan + # exited 1 (6 HIGH findings, trivy-summary result "status=findings"), + # failing the whole PipelineRun. The earlier "non-blocking / default-off + # quality gate" note in this comment described the SOURCE pipeline's + # setting, not this task's live behavior with severity:[HIGH,CRITICAL] + # set -- corrected here. + # + # DEVOPS-44489: PR-ONLY via the `when` below. A routine branch push + # (e.g. a chart/values.yaml tag bump from a prior run, or any main/ + # release-*/alauda-* push) was turning into a hard failure on + # pre-existing image findings with no PR to fix them against. Trivy + # still gates PRs as a blocking check (an author can address findings + # before merge, same severity/ignoreUnfixed as before); branch pushes + # skip it and proceed straight to update-chart-values. PaC leaves + # `pull_request_number` empty on a branch push and set on a PR, so + # `notin [""]` runs the task only when it's a PR. + - name: trivy-scanner + timeout: 60m + retries: 2 + runAfter: + - build-nexus-image + when: + - input: "$(params.pull-request-number)" + operator: notin + values: [""] + taskRef: + resolver: hub + params: + - {name: catalog, value: catalog} + - {name: kind, value: task} + - {name: name, value: trivy-scanner} + - {name: version, value: "0.6"} + params: + - name: scanType + value: image + - name: scanTargets + value: + - "{{ registry_url }}/alauda-devops-toolchain/nexus/sonatype-nexus3:v3.76.0-g$(tasks.get-git-meta.results.object-result.short_id)" + - name: severity + value: + - HIGH + - CRITICAL + - name: ignoreUnfixed + value: "true" + workspaces: + - name: trivy-config + workspace: trivy-config + - name: registry-config + workspace: registry-config + + # git-cli extras/0.4 -> catalog/0.5 (gitops fix commit 570a825: extras/0.4 is + # unresolvable on edge-build's hub shim, catalog/0.5 has 50 historical + # resolution successes there). 0.5's params are lowerCamelCase (breaking + # change from 0.4) and it declares NO `commit` result (unlike 0.4) -- the + # old --output-path $(results.commit.path) arg is dropped. basic-auth + # workspace switched from the old `github-credentials` secret to the + # code-alauda-io CSI connector (P2 managed-connector; nexus-build now + # lives on code.alauda.io, not github). + # + # DEVOPS-44489: `baseImage` re-pointed away from + # registry.alauda.cn:60070/devops/nonroot/chainguard/git:latest -- + # UNREACHABLE from edge-build (confirmed via 2 real TaskRun failures, + # TCP connect timeouts to that host). Note 0.5's OWN default baseImage + # (registry.alauda.cn:60070/devops/tektoncd/hub/git-init:v1.1) is the + # SAME unreachable host, so simply dropping the override does not fix + # this -- an explicit reachable override is required either way. Now + # -> registry-dev.alauda.io/platform-edge/tekton-catalog-incubator/ + # script-tools:latest, matching the SAME proven fix already applied to + # get-git-meta above (gitops FIX4 commit d85bb3d) -- keeps this + # pipeline's tool images on one consistent, verified-reachable family + # instead of introducing a second one-off reachable host. Verified live + # from an edge-build debug pod before committing: image pulls, runs as + # its built-in nonroot UID 65532 with no extra securityContext.runAsUser + # needed (satisfies git-cli@0.5's runAsNonRoot requirement), and carries + # both `git` (2.53.0) and `bash` -- everything this step's script uses. + # `userHome` also moved /home/git -> /home/nonroot alongside it: the + # OLD chainguard/git image ships its nonroot user as "git" with HOME + # /home/git, but script-tools' nonroot user is "nonroot" with HOME + # /home/nonroot (confirmed live: `id` -> uid=65532(nonroot), `cat + # /etc/passwd` -> nonroot:...:/home/nonroot). Left at the old value + # this failed live on THIS branch (smoke nexus-image-smoke-mg2ms): + # `cp: cannot create regular file '/home/git/.gitconfig': No such + # file or directory` -- /home/git simply doesn't exist in this image. + - name: update-chart-values + timeout: 5m + retries: 2 + runAfter: + - trivy-scanner + taskRef: + resolver: hub + params: + - {name: catalog, value: catalog} + - {name: kind, value: task} + - {name: name, value: git-cli} + - {name: version, value: "0.5"} + workspaces: + - name: source + workspace: source + - name: basic-auth + workspace: basic-auth + when: + - input: $(params.pull-request-number) + operator: in + values: + - "" + - " " + params: + - name: baseImage + value: registry-dev.alauda.io/platform-edge/tekton-catalog-incubator/script-tools:latest + - name: gitUserName + value: "Alauda Bot" + - name: gitUserEmail + value: "alaudabot@alauda.io" + - name: userHome + value: "/home/nonroot" + - name: verbose + value: "true" + - name: gitScript + value: |- + set -ex + + cd $(workspaces.source.path) + git config --global --add safe.directory $(workspaces.source.path) + + images=( + "{{ registry_url }}/alauda-devops-toolchain/nexus/sonatype-nexus3:v3.76.0-g$(tasks.get-git-meta.results.object-result.short_id)" + ) + + for image in "${images[@]}"; do + echo "===> update chart values $image" + bash ./hack/update-image-tag.sh $image chart/values.yaml + done + + git status + + if git diff --quiet -- chart/values.yaml; then + echo "No changes to commit" + git status + exit 0 + fi + + git add chart/values.yaml + git checkout . + + COMMIT_MESSAGE="Auto-commit by alaudabot in edge [ci skip] - $(context.taskRun.namespace)/$(context.taskRun.name)" + + git-push-commit.sh \ + --revision $(params.git-revision) \ + --source-path $(workspaces.source.path) \ + --message "${COMMIT_MESSAGE}" + + workspaces: + - name: source + description: Shared source checkout workspace. + - name: basic-auth + description: Git auth credentials injected by the code-alauda-io connector. + - name: registry-config + description: Docker registry auth mounted as config.json (registry-alauda-io). + - name: cache + description: Yarn/Maven build cache. + - name: trivy-config + description: ConfigMap with Trivy scanner configuration. + workspaces: - name: source + # 2Gi -> 10Gi: confirmed live (nexus-image-g8gxl) "No space left on + # device" extracting node-v18.17.1-linux-x64.tar.gz (~160M unpacked) + # for the SECOND frontend-maven-plugin module (nexus-rapture) -- this + # is a fixed-size RWO topolvm (LVM) volume, not the elastic RWX cephfs + # cache PVC, so it genuinely fills: node gets unpacked once per module + # (4 modules: nexus-coreui-plugin/nexus-ui-plugin/nexus-rapture/ + # nexus-swagger-filter) plus yarn's own node_modules per module plus + # full target/ compile output across the whole `-am` reactor (~60+ + # transitively-built modules). 10Gi matches the same-cohort precedent + # (nexus-ce-operator's integration-test source workspace, also + # bumped from 1Gi -> 10Gi for the same class of build-cache sizing). volumeClaimTemplate: spec: + storageClassName: sc-topolvm accessModes: - - ReadWriteMany + - ReadWriteOnce resources: requests: - storage: 2Gi + storage: 10Gi - name: basic-auth + csi: + driver: connectors-csi + readOnly: true + volumeAttributes: + configuration.names: gitconfig + connector.name: code-alauda-io + connector.namespace: alauda-devops-toolchain + token.expiration: 30m + - name: registry-config secret: - secretName: github-credentials - - name: registryconfig - secret: - secretName: build-harbor.kauto.docfj + secretName: "{{ registry_secret }}" - name: cache persistentVolumeClaim: claimName: build-cache-nexus + - name: trivy-config + configMap: + name: trivy-config taskRunTemplate: - # 让所有任务都以非 root 用户运行。 podTemplate: securityContext: + runAsUser: 65532 + runAsGroup: 65532 fsGroup: 65532 fsGroupChangePolicy: "OnRootMismatch" + imagePullSecrets: + - name: "{{ registry_secret }}" taskRunSpecs: - pipelineTaskName: build-nexus-app @@ -70,8 +513,39 @@ spec: limits: cpu: "2" memory: 2Gi - - pipelineTaskName: image-scan - computeResources: - limits: - cpu: "4" - memory: 4Gi + # DEVOPS-44489: the top-level `computeResources` field here only sets + # limits -- it does NOT override the catalog task's own per-step + # `computeResources` DEFAULTS, which trivy-scanner@0.6 sets to + # cpu:4/memory:4Gi REQUESTS on *both* steps (prepare-context AND + # trivy-scan), confirmed live via the pending pod's actual container specs + # (step-prepare-context and step-trivy-scan both requests+limits + # cpu:4/memory:4Gi -- 8 CPU/8Gi total for the pod). No edge-build node has + # 4 free CPU (`kubectl describe nodes` busiest node ~52% of a shared + # pool), so the pod is permanently unschedulable + # (FailedScheduling/ExceededNodeResources, "0/19 nodes ... 6 Insufficient + # cpu"), and the pipeline never reaches a terminal state. No proven green + # trivy-scanner run exists anywhere on edge-build to match (checked + # cluster-wide: only this repo's Pending/Cancelled runs). Fixed with a + # `stepSpecs` (v1) override per step, sized to the step's actual job + # (prepare-context is a light context-prep step; trivy-scan needs enough + # for the trivy DB + a ~900MB image) and well within a single node's + # capacity -- keeps the gate blocking (no change to scanType/severity/ + # exitCode), just makes it schedulable. + - pipelineTaskName: trivy-scanner + stepSpecs: + - name: prepare-context + computeResources: + requests: + cpu: "250m" + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + - name: trivy-scan + computeResources: + requests: + cpu: "500m" + memory: 2Gi + limits: + cpu: "2" + memory: 4Gi diff --git a/.tekton/integration-test.yaml b/.tekton/integration-test.yaml index 2ab2b181..4652dfb9 100644 --- a/.tekton/integration-test.yaml +++ b/.tekton/integration-test.yaml @@ -55,7 +55,7 @@ spec: image-repository: build-harbor.alauda.cn/devops/nexus-ce-test containerfile-path: testing/Containerfile - name: tag-template - value: "v3.76.0-g${ShortSha}" + value: "devops-44609-g${ShortSha}" - name: test value: command: | diff --git a/.tekton/pipeline/nexus-image-build.yaml b/.tekton/pipeline/nexus-image-build.yaml deleted file mode 100644 index 7c8b70bf..00000000 --- a/.tekton/pipeline/nexus-image-build.yaml +++ /dev/null @@ -1,343 +0,0 @@ -apiVersion: tekton.dev/v1 -kind: Pipeline -metadata: - name: nexus-image-build -spec: - workspaces: - - name: source - description: "Workspace for storing source code" - - name: basic-auth - description: "Workspace for git basic authentication" - - name: registryconfig - description: "Workspace for Container configuration" - - name: cache - description: "Workspace for cache files" - params: - - name: git-revision - description: | - Git revision object with url, branch, commit, and pull request information. - * url: The url of the git repository - * branch: The source branch of the git repository - * commit: The commit of the git repository - * pull-request-number: The pull request number - * pull-request-source: The source branch of the pull request - * pull-request-target: The target branch of the pull request - type: object - properties: - url: { type: string } - branch: { type: string } - commit: { type: string } - pull-request-number: {} # Pull request number. - pull-request-source: {} # Pull request source. - pull-request-target: {} # Pull request target branch. - default: - pull-request-number: "" - pull-request-source: "" - pull-request-target: "" - - name: image-scan-gate-enabled - description: Determine whether to skip the image scan step - type: string - default: "false" - - name: clean-cache - type: string - default: "false" - tasks: - # git-clone - - name: git-clone - params: - - name: url - value: $(params.git-revision.url) - - name: revision - value: $(params.git-revision.branch) - - name: depth - value: 1 - - name: pr-number - value: $(params.git-revision.pull-request-number) - taskRef: - resolver: hub - params: - - name: catalog - value: catalog - - name: kind - value: task - - name: name - value: git-clone - - name: version - value: "0.9" - timeout: 10m0s - workspaces: - - name: output - workspace: source - - name: basic-auth - workspace: basic-auth - - name: get-git-meta - timeout: 10m - retries: 0 - runAfter: - - git-clone - params: - - name: image - value: registry.alauda.cn:60080/devops/nonroot/chainguard/git:latest - - name: imagePullPolicy - value: Always - - name: script - value: | - #!/bin/bash - set -e - - # Add the source directory to the safe.directory list - git config --global --add safe.directory $(workspaces.source.path) - - - cat < $(results.object-result.path) - { - "commit_date": "$(git log -1 --pretty=%ct)", - "commit_sha": "$(git rev-parse HEAD)", - "short_id": "$(git rev-parse --short HEAD)", - "message": "gitlab commit meta" - } - EOF - taskRef: - resolver: hub - params: - - name: catalog - value: catalog - - name: kind - value: task - - name: name - value: run-script - - name: version - value: "0.1" - workspaces: - - name: source - workspace: source - - name: build-nexus-app - timeout: 2h - retries: 0 - runAfter: - - git-clone - params: - - name: script - value: | - #!/bin/bash - set -xe - - # Create cache source directories - mkdir -p $(workspaces.cache.path)/yarncache - mkdir -p $(workspaces.cache.path)/m2cache - - # Remove target paths if they exist - rm -rf $HOME/.m2/repository - rm -rf $(workspaces.source.path)/source/.yarn/cache - mkdir -p $(workspaces.source.path)/source/.yarn - mkdir -p $HOME/.m2 - - # Create symlinks - ln -s $(workspaces.cache.path)/m2cache $HOME/.m2/repository - ln -s $(workspaces.cache.path)/yarncache $(workspaces.source.path)/source/.yarn/cache - - if [ "$(params.clean-cache)" == "true" ]; then - rm -rf $(workspaces.cache.path)/yarncache/* - rm -rf $(workspaces.cache.path)/m2cache/* - fi - - cd $(workspaces.source.path)/source/ - yarn -v - yarn install - - ./mvnw -pl plugins/nexus-coreui-plugin,components/nexus-ui-plugin,components/nexus-rapture,components/nexus-swagger-filter -am clean install -Dpublic -DskipTests -s ./settings.xml - - # nexus-public only contains nexus code and cannot be used directly. - # To upgrade the problematic js plugins, we still built the complete jar package. Only copy the jar packages that contain dependencies on the vulnerable js packages to the image directory. - # When building the image, replace the corresponding jar packages in the image. - mkdir -p $(workspaces.source.path)/image/jar/ - cp $(workspaces.source.path)/source/components/nexus-ui-plugin/target/nexus-ui-plugin-3.76.0-03.jar $(workspaces.source.path)/image/jar/nexus-ui-plugin-3.76.0-03.jar - cp $(workspaces.source.path)/source/components/nexus-rapture/target/nexus-rapture-3.76.0-03.jar $(workspaces.source.path)/image/jar/nexus-rapture-3.76.0-03.jar - cp $(workspaces.source.path)/source/plugins/nexus-coreui-plugin/target/nexus-coreui-plugin-3.76.0-03.jar $(workspaces.source.path)/image/jar/nexus-coreui-plugin-3.76.0-03.jar - - # SwaggerAccessFilter: block unauthenticated access to Swagger API docs (DEVOPS-43719) - cp $(workspaces.source.path)/source/components/nexus-swagger-filter/target/nexus-swagger-filter-3.76.0-03.jar $(workspaces.source.path)/image/jar/swagger-access-filter.jar - workspaces: - - name: source - workspace: source - - name: cache - workspace: cache - taskSpec: - workspaces: - - name: source - - name: cache - params: - - name: script - description: >- - Customized script to execute. - The task will enforce running the script as a non-root user. - If the image used in this task defaults to starting with the root user, - additional configuration will be required to set up a non-root user. - Please refer to the task's README.md document for details. - type: string - default: - steps: - - name: run-script - # 需要提前通过 build-base-image 流水线构建,基础镜像。 - image: build-harbor.alauda.cn/devops/nexus-ce-base:latest - imagePullPolicy: Always - computeResources: - requests: - cpu: "1" - memory: "1Gi" - limits: - cpu: "4" - memory: "4Gi" - script: | - #!/bin/sh - RUN_ON=`cat /proc/$$/comm` - if [ "$RUN_ON" != "bash" ] && command -v bash >/dev/null 2>&1; then - exec bash "$0" $@ - fi - set -eu - - $(params.script) - - name: build-nexus-image - timeout: 1h - retries: 2 - taskRef: - resolver: hub - params: - - name: catalog - value: extras - - name: kind - value: task - - name: name - value: buildx - - name: version - value: "0.1" - runAfter: - - build-nexus-app - workspaces: - - name: source - workspace: source - - name: registryconfig - workspace: registryconfig - params: - - name: reuse-artifact - value: "true" - - name: buildx-image - value: registry.alauda.cn:60080/devops/nonroot/alauda-buildx:latest - - name: image-url - value: build-harbor.alauda.cn/devops/sonatype-nexus3 - - name: image-tags - value: - - v3.76.0-g$(tasks.get-git-meta.results.object-result.short_id) - - name: containerfile - value: image/Containerfile.alpine.java17 - - name: context - value: image - - name: image-scan - timeout: 60m - retries: 2 - taskRef: - resolver: katanomi.hub - params: - - name: kind - value: task - - name: name - value: trivy-image-scan - workspaces: - - name: source - workspace: source - params: - - name: targets-result-limit - value: 6 - - name: targets - value: - - $(tasks.build-nexus-image.results.image-digests[0]) - - name: quality-gate-rules - value: - - severity=High - - name: quality-gate - value: "$(params.image-scan-gate-enabled)" - - name: scan-flags - value: - - timeout=30m - - vulnerability.ignore-unfixed=true - - db.skip-update=false - - db.repository=build-harbor.alauda.cn/ops/aquasecurity/trivy-db - - name: update-chart-values - timeout: 5m - retries: 2 - runAfter: - - image-scan - taskRef: - resolver: hub - params: - - name: catalog - value: extras - - name: kind - value: task - - name: name - value: git-cli - - name: version - value: "0.4" - workspaces: - - name: source - workspace: source - - name: basic-auth - workspace: basic-auth - when: - - input: $(params.git-revision.pull-request-number) - operator: in - values: - - "" - - " " - params: - - name: BASE_IMAGE - value: registry.alauda.cn:60080/devops/nonroot/chainguard/git:latest - - name: GIT_USER_NAME - value: "Alauda Bot" - - name: GIT_USER_EMAIL - value: "alaudabot@alauda.io" - - name: USER_HOME - value: "/home/git" - - name: VERBOSE - value: "true" - - name: GIT_SCRIPT - value: |- - set -ex - - cd $(workspaces.source.path) - git config --global --add safe.directory $(workspaces.source.path) - - images=( - $(tasks.build-nexus-image.results.image-digests[0]) - ) - - for image in "${images[@]}"; do - echo "===> update chart values $image" - bash ./hack/update-image-tag.sh $image chart/values.yaml - done - - git status - - # check if the chart values is changed - if git diff --quiet -- chart/values.yaml; then - echo "No changes to commit" - git status - exit 0 - fi - - git add chart/values.yaml - - # Reset working directory to match staging area. - # This ensures only chart/values.yaml changes are committed, - # while cleaning any accidental modifications to other files. - # Since chart/values.yaml is already staged, its changes are preserved. - git checkout . - - COMMIT_MESSAGE="Auto-commit by alaudabot in edge [ci skip] - $(context.taskRun.namespace)/$(context.taskRun.name)" - - # Commit the changes - git-push-commit.sh \ - --revision $(params.git-revision.branch) \ - --source-path $(workspaces.source.path) \ - --output-path $(results.commit.path) \ - --message "${COMMIT_MESSAGE}" diff --git a/.tekton/pr-manage.yaml b/.tekton/pr-manage.yaml deleted file mode 100644 index f99bbb21..00000000 --- a/.tekton/pr-manage.yaml +++ /dev/null @@ -1,72 +0,0 @@ -apiVersion: tekton.dev/v1 -kind: PipelineRun -metadata: - name: pr-manage - annotations: - pipelinesascode.tekton.dev/pipeline: "https://raw.githubusercontent.com/AlaudaDevops/toolbox/main/pr-cli/pipeline/pr-manage.yaml" - pipelinesascode.tekton.dev/on-comment: "^/(help|rebase|lgtm|remove-lgtm|cherry-?pick|assign|merge|ready|unassign|label|unlabel|check|retest|close|batch)($|\\s.*)" - pipelinesascode.tekton.dev/max-keep-runs: "5" -spec: - pipelineRef: - name: pr-manage - params: - - name: trigger_comment - value: "{{ trigger_comment }}" - - name: repo_owner - value: "{{ repo_owner }}" - - name: repo_name - value: "{{ repo_name }}" - - name: pull_request_number - value: "{{ pull_request_number }}" - - name: comment_sender - value: "{{ sender }}" - - name: git_auth_secret - value: "{{ git_auth_secret }}" - # - # Optional parameters (value is the default): - # - # The key in git_auth_secret that contains the token (default: git-provider-token) - # - name: git_auth_secret_key - # value: "git-provider-token" - # - # Container image for pr-cli tool (default: registry.alauda.cn:60070/devops/toolbox/pr-cli:latest) - # - name: image - # value: "registry.alauda.cn:60070/devops/toolbox/pr-cli:latest" - # - # The /lgtm threshold needed of approvers for a PR to be approved (default: 1) - # - name: lgtm_threshold - # value: "1" - # - # The permissions the user need to trigger a lgtm (default: admin,write) - # - name: lgtm_permissions - # value: "admin,write" - # - # The review event when lgtm is triggered, can be APPROVE, - # REQUEST_CHANGES, or COMMENT if setting to empty string it will be set as - # PENDING (default: APPROVE) - # - name: lgtm_review_event - # value: "APPROVE" - # - # The merge method to use. Can be one of: merge, squash, rebase (default: squash) - # - name: merge_method - # value: "squash" - # - # The name used for self-check status (default: pr-manage) - # - name: self_check_name - # value: "pr-manage" - # - # Enable debug mode (skip validation, allow PR creator self-approval) (default: false) - # - name: debug - # value: "false" - # - # Enable verbose logging (debug level logs) (default: false) - # - name: verbose - # value: "false" - # - # The platform to use, can be one of: github, gitlab, gitee (default: github) - # - name: platform - # value: "github" - # - # The robot accounts for managing bot approval reviews. - # - name: robot_accounts - # value: "alaudabot,dependabot,renovate" diff --git a/chart/values.yaml b/chart/values.yaml index b2ef9d94..806f21a0 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -3,15 +3,25 @@ global: initContainer: enabled: true registry: - address: registry.alauda.cn:60080 + # DEVOPS-44489: registry.alauda.cn:60080 -> registry-dev.alauda.io (P1/P2). + # Not a PaC-templated file (consumed by Helm + hack/update-image-tag.sh at + # commit time), so this is a literal host, not {{ registry_url }}. + address: registry-dev.alauda.io images: nexus: - repository: devops/sonatype-nexus3 - tag: v3.76.0-ge428676 + # devops/sonatype-nexus3 -> alauda-devops-toolchain/nexus/sonatype-nexus3: + # the path build-image.yaml's buildctl task now publishes to. This is the + # exact repository path nexus-ce-operator's e2e/packaging expect (see its + # integration-test-runtime-image-missing escalation, which names this + # path as the missing mirror this migration provides). + repository: alauda-devops-toolchain/nexus/sonatype-nexus3 + tag: v3.76.0-g82552ad support_arm: true thirdparty: true busybox: - repository: ops/busybox + # ops/busybox -> base-images/busybox (confirmed live on registry-dev via + # skopeo; "ops/busybox" does not exist there). + repository: base-images/busybox tag: stable support_arm: true thirdparty: true diff --git a/docs/superpowers/plans/2026-08-06-nexus-e2e-maven-upstream.md b/docs/superpowers/plans/2026-08-06-nexus-e2e-maven-upstream.md new file mode 100644 index 00000000..0a98aa00 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-nexus-e2e-maven-upstream.md @@ -0,0 +1,1072 @@ +# Nexus E2E Maven Upstream Bundle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Package the Maven dependencies required by Nexus E2E in the testing image, import them into an authenticated second Nexus, and configure the tested Nexus proxy to download from it. + +**Architecture:** Build a verified Maven2-layout bundle by replaying the actual E2E Maven lifecycles in an isolated local repository. A standalone Python importer creates or validates an upstream hosted repository and uploads that bundle idempotently. The Python E2E suite gains a typed upstream configuration and passes optional Basic Authentication to the tested Nexus proxy while preserving the legacy anonymous mirror path. + +**Tech Stack:** Python 3.12, pytest, requests, Maven 3.9.14, Nexus Repository REST API, Docker/Containerfile + +--- + +## File Map + +- Create `testing/nexus-e2e/libs/maven_upstream.py`: parse and validate upstream environment variables and compose the hosted repository URL. +- Create `testing/nexus-e2e/unit/test_maven_upstream.py`: unit tests for new and legacy upstream selection. +- Modify `testing/nexus-e2e/conftest.py`: expose the upstream configuration as a pytest fixture. +- Modify `testing/nexus-e2e/libs/nexus_client.py`: add optional Basic Authentication to Maven proxy configuration. +- Create `testing/nexus-e2e/unit/test_nexus_client.py`: assert exact authenticated and unauthenticated Nexus REST payloads. +- Modify `testing/nexus-e2e/test_maven_repo.py`: select the authenticated upstream for the proxy scenario. +- Create `testing/hack/import-maven-e2e-dependencies.py`: standalone, idempotent bundle importer. +- Create `testing/nexus-e2e/unit/test_import_maven_e2e_dependencies.py`: importer tests using a local fake Nexus HTTP server. +- Create `testing/hack/prepare-maven-e2e-bundle.sh`: build, sanitize, and offline-verify the Maven repository bundle. +- Create `testing/nexus-e2e/unit/test_prepare_maven_e2e_bundle.py`: static and behavioral tests for bundle filtering and command contract. +- Modify `testing/Containerfile`: build the bundle and install the importer in the final image. +- Modify `testing/README.md`: document import and authenticated proxy execution. + +The production units stay independent: E2E configuration does not import the standalone importer, and the importer does not depend on pytest or repository source layout. + +### Task 1: Add typed upstream Maven configuration + +**Files:** +- Create: `testing/nexus-e2e/libs/maven_upstream.py` +- Create: `testing/nexus-e2e/unit/test_maven_upstream.py` +- Modify: `testing/nexus-e2e/conftest.py:1-30` + +- [ ] **Step 1: Write failing configuration tests** + +Create `testing/nexus-e2e/unit/test_maven_upstream.py`: + +```python +import pytest + +from libs.maven_upstream import MavenUpstreamConfig, load_maven_upstream + + +def test_loads_authenticated_upstream_with_default_repository(): + config = load_maven_upstream({ + "MAVEN_UPSTREAM_URL": "https://upstream.example/nexus/", + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + }) + + assert config == MavenUpstreamConfig( + url="https://upstream.example/nexus", + repository="maven-e2e-external", + username="reader", + password="secret", + ) + assert config.repository_url == ( + "https://upstream.example/nexus/repository/maven-e2e-external/" + ) + + +def test_repository_name_can_be_overridden(): + config = load_maven_upstream({ + "MAVEN_UPSTREAM_URL": "https://upstream.example", + "MAVEN_UPSTREAM_REPOSITORY": "team-maven-seed", + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + }) + + assert config.repository == "team-maven-seed" + + +@pytest.mark.parametrize( + "missing", + ["MAVEN_UPSTREAM_USERNAME", "MAVEN_UPSTREAM_PASSWORD"], +) +def test_partial_authenticated_configuration_is_rejected(missing): + environment = { + "MAVEN_UPSTREAM_URL": "https://upstream.example", + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + } + del environment[missing] + + with pytest.raises(ValueError, match=missing): + load_maven_upstream(environment) + + +def test_absent_upstream_uses_legacy_mode(): + assert load_maven_upstream({}) is None +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_maven_upstream.py -q +``` + +Expected: collection fails with `ModuleNotFoundError: No module named 'libs.maven_upstream'`. + +- [ ] **Step 3: Implement the configuration model** + +Create `testing/nexus-e2e/libs/maven_upstream.py`: + +```python +from dataclasses import dataclass +from typing import Mapping, Optional + + +DEFAULT_REPOSITORY = "maven-e2e-external" + + +@dataclass(frozen=True) +class MavenUpstreamConfig: + url: str + repository: str + username: str + password: str + + @property + def repository_url(self) -> str: + return f"{self.url}/repository/{self.repository}/" + + +def load_maven_upstream( + environment: Mapping[str, str], +) -> Optional[MavenUpstreamConfig]: + url = environment.get("MAVEN_UPSTREAM_URL", "").strip() + if not url: + return None + + required = ("MAVEN_UPSTREAM_USERNAME", "MAVEN_UPSTREAM_PASSWORD") + missing = [name for name in required if not environment.get(name)] + if missing: + raise ValueError( + "authenticated Maven upstream requires " + ", ".join(missing) + ) + + repository = environment.get( + "MAVEN_UPSTREAM_REPOSITORY", DEFAULT_REPOSITORY + ).strip() + if not repository or "/" in repository: + raise ValueError("MAVEN_UPSTREAM_REPOSITORY must be a repository name") + + return MavenUpstreamConfig( + url=url.rstrip("/"), + repository=repository, + username=environment["MAVEN_UPSTREAM_USERNAME"], + password=environment["MAVEN_UPSTREAM_PASSWORD"], + ) +``` + +Modify `testing/nexus-e2e/conftest.py` to import `load_maven_upstream` and add: + +```python +@pytest.fixture(scope="session") +def maven_upstream_config(): + return load_maven_upstream(os.environ) +``` + +- [ ] **Step 4: Run the tests and verify GREEN** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_maven_upstream.py -q +``` + +Expected: `5 passed`. + +- [ ] **Step 5: Commit the configuration unit** + +```bash +git add testing/nexus-e2e/libs/maven_upstream.py \ + testing/nexus-e2e/unit/test_maven_upstream.py testing/nexus-e2e/conftest.py +git commit -m "feat(testing): add authenticated Maven upstream config" +``` + +### Task 2: Configure authenticated Nexus proxy requests + +**Files:** +- Modify: `testing/nexus-e2e/libs/nexus_client.py:6-83` +- Create: `testing/nexus-e2e/unit/test_nexus_client.py` + +- [ ] **Step 1: Write failing proxy payload tests** + +Create `testing/nexus-e2e/unit/test_nexus_client.py`: + +```python +from libs.nexus_client import _get_repository_config + + +def test_proxy_config_includes_basic_authentication(): + config = _get_repository_config( + "maven", + "maven-central", + "proxy", + "https://upstream.example/repository/maven-e2e-external/", + remote_username="reader", + remote_password="secret", + ) + + assert config["httpClient"]["authentication"] == { + "type": "username", + "username": "reader", + "password": "secret", + } + + +def test_proxy_config_omits_authentication_for_legacy_remote(): + config = _get_repository_config( + "maven", + "maven-central", + "proxy", + "https://artifacts.example/repository/maven-central/", + ) + + assert "authentication" not in config["httpClient"] +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_nexus_client.py -q +``` + +Expected: the authenticated test fails with `unexpected keyword argument 'remote_username'`. + +- [ ] **Step 3: Add optional authentication to repository configuration** + +Change the signature of `_get_repository_config` to: + +```python +def _get_repository_config( + repo_format, + repo_name, + repo_type="hosted", + remote_url=None, + remote_username=None, + remote_password=None, +): +``` + +After building the proxy `httpClient` dictionary, add: + +```python + if remote_username is not None and remote_password is not None: + base_config["httpClient"]["authentication"] = { + "type": "username", + "username": remote_username, + "password": remote_password, + } +``` + +Extend `NexusClient.update_proxy_config`: + +```python + def update_proxy_config( + self, + repo_format, + repo_name, + repo_type="proxy", + remote_url=None, + remote_username=None, + remote_password=None, + ): + endpoint = f"service/rest/v1/repositories/{repo_format}/{repo_type}/{repo_name}" + config = _get_repository_config( + repo_format, + repo_name, + repo_type, + remote_url, + remote_username, + remote_password, + ) + response = self.session.put(urljoin(self.base_url, endpoint), json=config) + response.raise_for_status() + return response +``` + +- [ ] **Step 4: Run the tests and verify GREEN** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_nexus_client.py -q +``` + +Expected: `2 passed`. + +- [ ] **Step 5: Commit authenticated proxy support** + +```bash +git add testing/nexus-e2e/libs/nexus_client.py \ + testing/nexus-e2e/unit/test_nexus_client.py +git commit -m "feat(testing): support authentication for Maven proxy remotes" +``` + +### Task 3: Route the Maven proxy E2E through the second Nexus + +**Files:** +- Modify: `testing/nexus-e2e/test_maven_repo.py:29-52,123-144` +- Modify: `testing/nexus-e2e/unit/test_maven_upstream.py` + +- [ ] **Step 1: Write failing remote-selection tests** + +Append to `testing/nexus-e2e/unit/test_maven_upstream.py`: + +```python +from libs.maven_upstream import select_proxy_remote + + +def test_authenticated_config_wins_over_legacy_mirror(): + environment = { + "MAVEN_UPSTREAM_URL": "https://upstream.example/", + "MAVEN_UPSTREAM_REPOSITORY": "seed", + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + "MACVEN_MIRROR_REGISTRY": "https://legacy.example/maven", + } + + remote = select_proxy_remote(environment) + + assert remote.url == "https://upstream.example/repository/seed/" + assert remote.username == "reader" + assert remote.password == "secret" + + +def test_legacy_mirror_remains_unauthenticated(): + remote = select_proxy_remote({ + "MACVEN_MIRROR_REGISTRY": "https://legacy.example/maven/", + }) + + assert remote.url == "https://legacy.example/maven/" + assert remote.username is None + assert remote.password is None +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_maven_upstream.py -q +``` + +Expected: collection fails because `select_proxy_remote` does not exist. + +- [ ] **Step 3: Implement proxy remote selection** + +Append to `testing/nexus-e2e/libs/maven_upstream.py`: + +```python +@dataclass(frozen=True) +class MavenProxyRemote: + url: str + username: Optional[str] = None + password: Optional[str] = None + + +def select_proxy_remote(environment: Mapping[str, str]) -> MavenProxyRemote: + upstream = load_maven_upstream(environment) + if upstream: + return MavenProxyRemote( + url=upstream.repository_url, + username=upstream.username, + password=upstream.password, + ) + + legacy = environment.get( + "MACVEN_MIRROR_REGISTRY", + "https://artifacts.alauda.io/repository/maven-central", + ) + return MavenProxyRemote(url=f"{legacy.rstrip('/')}/") +``` + +- [ ] **Step 4: Use the selected remote in `test_maven_proxy`** + +Import `select_proxy_remote` and replace the proxy update with: + +```python + remote = select_proxy_remote(os.environ) + nexus_client.update_proxy_config( + "maven", + "maven-central", + "proxy", + remote.url, + remote.username, + remote.password, + ) +``` + +Keep `_maven_central_mirror_url()` for publish scenarios so existing behavior +is unchanged. Do not log the `remote` object because it contains a password. + +- [ ] **Step 5: Run focused and existing Python tests** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_maven_upstream.py unit/test_nexus_client.py -q +python -m pytest --collect-only test_maven_repo.py -q +``` + +Expected: unit tests pass; the E2E module collects `test_maven_publish` and +`test_maven_proxy` without import errors. + +- [ ] **Step 6: Commit E2E routing** + +```bash +git add testing/nexus-e2e/libs/maven_upstream.py \ + testing/nexus-e2e/test_maven_repo.py \ + testing/nexus-e2e/unit/test_maven_upstream.py +git commit -m "feat(testing): route Maven proxy E2E to authenticated upstream" +``` + +### Task 4: Build the standalone idempotent importer + +**Files:** +- Create: `testing/hack/import-maven-e2e-dependencies.py` +- Create: `testing/nexus-e2e/unit/test_import_maven_e2e_dependencies.py` + +- [ ] **Step 1: Write failing importer tests with a fake Nexus session** + +Create `testing/nexus-e2e/unit/test_import_maven_e2e_dependencies.py`. Load the +hyphenated script through `importlib.util.spec_from_file_location`, then test +its public `ImportConfig`, `ensure_repository`, and `upload_bundle` units: + +```python +import importlib.util +from pathlib import Path +import sys + +import pytest +import requests + + +SCRIPT = Path(__file__).parents[2] / "hack/import-maven-e2e-dependencies.py" +SPEC = importlib.util.spec_from_file_location("maven_importer", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class Response: + def __init__(self, status_code, content=b""): + self.status_code = status_code + self.content = content + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(response=self) + + def json(self): + return self._json + + +class Session: + def __init__(self, responses): + self.responses = iter(responses) + self.calls = [] + + def request(self, method, url, **kwargs): + self.calls.append((method, url, kwargs)) + return next(self.responses) + + +def test_creates_missing_maven_hosted_repository(): + session = Session([Response(404), Response(201)]) + config = MODULE.ImportConfig("https://nexus.example", "seed", "u", "p") + + MODULE.ensure_repository(session, config) + + method, url, kwargs = session.calls[1] + assert method == "POST" + assert url.endswith("/service/rest/v1/repositories/maven/hosted") + assert kwargs["json"]["name"] == "seed" + assert kwargs["json"]["maven"] == { + "versionPolicy": "MIXED", + "layoutPolicy": "STRICT", + "contentDisposition": "INLINE", + } + + +def test_rejects_existing_repository_with_wrong_type(): + response = Response(200) + response._json = {"format": "maven2", "type": "proxy"} + session = Session([response]) + config = MODULE.ImportConfig("https://nexus.example", "seed", "u", "p") + + with pytest.raises(RuntimeError, match="Maven hosted"): + MODULE.ensure_repository(session, config) + + +def test_upload_skips_equal_content_and_rejects_conflict(tmp_path): + artifact = tmp_path / "junit/junit/4.11/junit-4.11.pom" + artifact.parent.mkdir(parents=True) + artifact.write_bytes(b"pom") + config = MODULE.ImportConfig("https://nexus.example", "seed", "u", "p") + + equal = Session([Response(200, b"pom")]) + assert MODULE.upload_bundle(equal, config, tmp_path) == (0, 1, 0) + + conflict = Session([Response(200, b"different")]) + with pytest.raises(RuntimeError, match="conflicting content"): + MODULE.upload_bundle(conflict, config, tmp_path) + + +def test_password_is_not_rendered_in_error(tmp_path): + config = MODULE.ImportConfig( + "https://nexus.example", "seed", "reader", "do-not-print" + ) + session = Session([Response(401)]) + + with pytest.raises(RuntimeError) as error: + MODULE.ensure_repository(session, config) + + assert "do-not-print" not in str(error.value) +``` + +- [ ] **Step 2: Run importer tests and verify RED** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_import_maven_e2e_dependencies.py -q +``` + +Expected: collection fails because the importer file does not exist. + +- [ ] **Step 3: Implement importer configuration and repository validation** + +Create `testing/hack/import-maven-e2e-dependencies.py` with: + +```python +#!/usr/bin/env python3 +import argparse +import getpass +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +import requests + + +DEFAULT_REPOSITORY = "maven-e2e-external" +DEFAULT_BUNDLE = Path("/opt/nexus-e2e/maven-repository") + + +@dataclass(frozen=True) +class ImportConfig: + url: str + repository: str + username: str + password: str + + @property + def repository_url(self): + return f"{self.url}/repository/{self.repository}/" + + +def request(session, method, url, **kwargs): + response = session.request(method, url, timeout=60, **kwargs) + if response.status_code in (401, 403): + raise RuntimeError(f"authentication or authorization failed for {url}") + return response + + +def ensure_repository(session, config): + item_url = ( + f"{config.url}/service/rest/v1/repositories/" + f"maven/hosted/{config.repository}" + ) + response = request(session, "GET", item_url) + if response.status_code == 200: + data = response.json() + if data.get("format") not in ("maven", "maven2") or data.get("type") != "hosted": + raise RuntimeError( + f"repository {config.repository!r} is not a Maven hosted repository" + ) + return + if response.status_code != 404: + response.raise_for_status() + + payload = { + "name": config.repository, + "online": True, + "storage": { + "blobStoreName": "default", + "strictContentTypeValidation": True, + "writePolicy": "ALLOW_ONCE", + }, + "maven": { + "versionPolicy": "MIXED", + "layoutPolicy": "STRICT", + "contentDisposition": "INLINE", + }, + } + created = request( + session, + "POST", + f"{config.url}/service/rest/v1/repositories/maven/hosted", + json=payload, + ) + created.raise_for_status() +``` + +- [ ] **Step 4: Implement digest comparison, upload, verification, and CLI** + +Continue the same file with: + +```python +def upload_bundle(session, config, bundle): + uploaded = skipped = failed = 0 + for artifact in sorted(path for path in bundle.rglob("*") if path.is_file()): + relative = artifact.relative_to(bundle).as_posix() + target = f"{config.repository_url}{relative}" + existing = request(session, "GET", target) + if existing.status_code == 200: + if existing.content == artifact.read_bytes(): + skipped += 1 + continue + raise RuntimeError(f"conflicting content already exists at {target}") + if existing.status_code != 404: + existing.raise_for_status() + + uploaded_response = request( + session, "PUT", target, data=artifact.read_bytes() + ) + if uploaded_response.status_code not in (200, 201, 204): + failed += 1 + uploaded_response.raise_for_status() + uploaded += 1 + return uploaded, skipped, failed + + +def load_config(environment: Mapping[str, str], password_prompt=getpass.getpass): + url = environment.get("MAVEN_UPSTREAM_URL", "").strip().rstrip("/") + username = environment.get("MAVEN_UPSTREAM_USERNAME", "").strip() + password = environment.get("MAVEN_UPSTREAM_PASSWORD", "") + repository = environment.get( + "MAVEN_UPSTREAM_REPOSITORY", DEFAULT_REPOSITORY + ).strip() + if not url or not username: + raise RuntimeError( + "MAVEN_UPSTREAM_URL and MAVEN_UPSTREAM_USERNAME are required" + ) + if not password: + password = password_prompt("Maven upstream password: ") + if not password: + raise RuntimeError("MAVEN_UPSTREAM_PASSWORD is required") + if not repository or "/" in repository: + raise RuntimeError("invalid MAVEN_UPSTREAM_REPOSITORY") + return ImportConfig(url, repository, username, password) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--bundle", type=Path, default=DEFAULT_BUNDLE) + args = parser.parse_args() + if not args.bundle.is_dir(): + raise RuntimeError(f"bundle directory does not exist: {args.bundle}") + + config = load_config(os.environ) + session = requests.Session() + session.auth = (config.username, config.password) + ensure_repository(session, config) + uploaded, skipped, failed = upload_bundle(session, config, args.bundle) + print( + f"Imported Maven E2E bundle into {config.repository_url}: " + f"uploaded={uploaded} skipped={skipped} failed={failed}" + ) + + +if __name__ == "__main__": + main() +``` + +Add focused tests for `load_config`, password prompting, path preservation, a +successful PUT, and a 403 response. Assertions must check that neither the +password nor an Authorization value appears in stdout, stderr, or exceptions. + +- [ ] **Step 5: Run importer tests and verify GREEN** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_import_maven_e2e_dependencies.py -q +``` + +Expected: all importer tests pass. + +- [ ] **Step 6: Verify executable syntax and permissions** + +Run: + +```bash +chmod 755 testing/hack/import-maven-e2e-dependencies.py +python -m py_compile testing/hack/import-maven-e2e-dependencies.py +``` + +Expected: exit 0 and no output. + +- [ ] **Step 7: Commit the importer** + +```bash +git add testing/hack/import-maven-e2e-dependencies.py \ + testing/nexus-e2e/unit/test_import_maven_e2e_dependencies.py +git commit -m "feat(testing): add Maven E2E bundle importer" +``` + +### Task 5: Build and offline-verify the Maven bundle + +**Files:** +- Create: `testing/hack/prepare-maven-e2e-bundle.sh` +- Create: `testing/nexus-e2e/unit/test_prepare_maven_e2e_bundle.py` + +- [ ] **Step 1: Write failing bundle-script contract tests** + +Create `testing/nexus-e2e/unit/test_prepare_maven_e2e_bundle.py`: + +```python +import subprocess +from pathlib import Path + + +SCRIPT = Path(__file__).parents[2] / "hack/prepare-maven-e2e-bundle.sh" + + +def test_bundle_script_requires_source_and_destination(): + result = subprocess.run( + ["bash", str(SCRIPT)], text=True, capture_output=True + ) + assert result.returncode != 0 + assert "usage:" in result.stderr.lower() + + +def test_bundle_script_filters_resolver_state_and_generated_snapshot(): + content = SCRIPT.read_text() + assert "_remote.repositories" in content + assert "*.lastUpdated" in content + assert "resolver-status.properties" in content + assert "com/nexus/test/test-publish" in content + assert "mvn -o" in content or "--offline" in content +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_prepare_maven_e2e_bundle.py -q +``` + +Expected: tests fail because the script does not exist. + +- [ ] **Step 3: Implement the bundle preparation script** + +Create `testing/hack/prepare-maven-e2e-bundle.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +project_dir=$1 +bundle_dir=$2 +repository_dir="${bundle_dir}/repository" +deployment_dir="${bundle_dir}/deployment" +settings_file="${bundle_dir}/settings.xml" + +rm -rf "${bundle_dir}" +mkdir -p "${repository_dir}" "${deployment_dir}" + +cat > "${settings_file}" < + ${repository_dir} + +EOF + +mvn -B -s "${settings_file}" -f "${project_dir}/publish.xml" \ + -DaltDeploymentRepository="bundle::default::file://${deployment_dir}" \ + clean deploy +mvn -B -s "${settings_file}" -f "${project_dir}/download.xml" package + +mvn -B -o -s "${settings_file}" -f "${project_dir}/publish.xml" \ + -DaltDeploymentRepository="bundle::default::file://${deployment_dir}" \ + clean deploy +mvn -B -o -s "${settings_file}" -f "${project_dir}/download.xml" package + +find "${repository_dir}" -type f \( \ + -name '_remote.repositories' -o \ + -name '*.lastUpdated' -o \ + -name 'resolver-status.properties' \ +\) -delete +rm -rf "${repository_dir}/com/nexus/test/test-publish" +rm -rf "${deployment_dir}" "${settings_file}" + +if ! find "${repository_dir}" -type f -name 'junit-4.11.jar' -print -quit | grep -q .; then + echo "bundle verification failed: junit-4.11.jar is absent" >&2 + exit 1 +fi +``` + +- [ ] **Step 4: Run contract tests and verify GREEN** + +Run: + +```bash +chmod 755 testing/hack/prepare-maven-e2e-bundle.sh +cd testing/nexus-e2e +python -m pytest unit/test_prepare_maven_e2e_bundle.py -q +``` + +Expected: `2 passed`. + +- [ ] **Step 5: Run a real isolated online/offline bundle build** + +Run from the repository root: + +```bash +bundle_tmp=$(mktemp -d) +testing/hack/prepare-maven-e2e-bundle.sh \ + testing/nexus-e2e/test_projects/maven "${bundle_tmp}/bundle" +find "${bundle_tmp}/bundle/repository" -type f | sort +``` + +Expected: the script exits 0 after both offline Maven commands; the listing +contains `junit/junit/4.11/junit-4.11.jar` and +`org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar`, contains required Maven +plugin artifacts, and contains none of the filtered resolver-state files or +`com/nexus/test/test-publish`. + +Keep the temporary directory until Task 6 image inspection is complete, then +remove only that exact `mktemp` directory. + +- [ ] **Step 6: Commit the bundle builder** + +```bash +git add testing/hack/prepare-maven-e2e-bundle.sh \ + testing/nexus-e2e/unit/test_prepare_maven_e2e_bundle.py +git commit -m "feat(testing): prepare verified Maven E2E dependency bundle" +``` + +### Task 6: Package the bundle and importer in the testing image + +**Files:** +- Modify: `testing/Containerfile:1-108` + +- [ ] **Step 1: Write a failing Containerfile contract test** + +Append to `testing/nexus-e2e/unit/test_prepare_maven_e2e_bundle.py`: + +```python +def test_containerfile_packages_bundle_and_importer(): + containerfile = SCRIPT.parents[1] / "Containerfile" + content = containerfile.read_text() + assert "prepare-maven-e2e-bundle.sh" in content + assert "/opt/nexus-e2e/maven-repository" in content + assert "/usr/local/bin/import-maven-e2e-dependencies" in content +``` + +- [ ] **Step 2: Run the contract test and verify RED** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_prepare_maven_e2e_bundle.py \ + -k containerfile -q +``` + +Expected: FAIL because the Containerfile has none of the three integration +paths. + +- [ ] **Step 3: Add a Maven bundle build stage** + +Refactor the Maven download so Maven is available to a new `maven-bundle` +stage, or add a dedicated stage based on the final Python/JDK base. The stage +must: + +```dockerfile +COPY testing/nexus-e2e/test_projects/maven /work/maven-project +COPY testing/hack/prepare-maven-e2e-bundle.sh /usr/local/bin/ +RUN /usr/local/bin/prepare-maven-e2e-bundle.sh \ + /work/maven-project /opt/nexus-e2e +``` + +Use the same `MAVEN_VERSION=3.9.14` and trusted build-time mirror convention as +the final image. Do not copy `.m2` from a developer or prior build context. + +- [ ] **Step 4: Copy assets into the final image** + +Add to the final stage: + +```dockerfile +COPY --from=maven-bundle /opt/nexus-e2e/maven-repository \ + /opt/nexus-e2e/maven-repository +COPY testing/hack/import-maven-e2e-dependencies.py \ + /usr/local/bin/import-maven-e2e-dependencies +RUN chmod 755 /usr/local/bin/import-maven-e2e-dependencies && \ + test -f /opt/nexus-e2e/maven-repository/junit/junit/4.11/junit-4.11.jar +``` + +Do not change the existing `ENTRYPOINT ["nexus.test"]` or `CMD`. + +- [ ] **Step 5: Run Containerfile contract tests and build the image** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit/test_prepare_maven_e2e_bundle.py -q +cd ../../ +docker build -f testing/Containerfile -t nexus-e2e-maven-bundle:test . +``` + +Expected: tests pass and the image build exits 0, including the offline Maven +verification layer. If Docker is unavailable, run the repository's supported +Containerfile builder and record the exact limitation rather than claiming the +image was built. + +- [ ] **Step 6: Inspect the built image as non-root** + +Run: + +```bash +docker run --rm --user 65532 --entrypoint /bin/sh \ + nexus-e2e-maven-bundle:test -c \ + 'test -r /opt/nexus-e2e/maven-repository/junit/junit/4.11/junit-4.11.jar && \ + test -x /usr/local/bin/import-maven-e2e-dependencies && \ + /usr/local/bin/import-maven-e2e-dependencies --help >/dev/null' +``` + +Expected: exit 0 with no credential prompts. + +- [ ] **Step 7: Commit image integration** + +```bash +git add testing/Containerfile \ + testing/nexus-e2e/unit/test_prepare_maven_e2e_bundle.py +git commit -m "feat(testing): package Maven E2E dependencies in test image" +``` + +### Task 7: Document usage and run complete verification + +**Files:** +- Modify: `testing/README.md:10-45` + +- [ ] **Step 1: Add import and proxy usage documentation** + +Add a section containing these commands, using placeholders rather than real +credentials: + +```bash +docker run --rm \ + --entrypoint /usr/local/bin/import-maven-e2e-dependencies \ + -e MAVEN_UPSTREAM_URL=https://upstream-nexus.example \ + -e MAVEN_UPSTREAM_REPOSITORY=maven-e2e-external \ + -e MAVEN_UPSTREAM_USERNAME=admin \ + -e MAVEN_UPSTREAM_PASSWORD \ + "${TESTING_IMAGE}" +``` + +Document that `MAVEN_UPSTREAM_REPOSITORY` defaults to +`maven-e2e-external`, that the password should be injected by the runtime or +secret store, and that the same four variables must be present for the Maven +proxy E2E test. Explain the two Nexus roles and retain the legacy +`MACVEN_MIRROR_REGISTRY` note. + +- [ ] **Step 2: Run the complete Python unit suite** + +Run: + +```bash +cd testing/nexus-e2e +python -m pytest unit -q +``` + +Expected: all unit tests pass with zero failures. + +- [ ] **Step 3: Run repository-level static verification** + +Run: + +```bash +git diff --check +bash -n testing/hack/prepare-maven-e2e-bundle.sh +python -m py_compile \ + testing/hack/import-maven-e2e-dependencies.py \ + testing/nexus-e2e/libs/maven_upstream.py \ + testing/nexus-e2e/libs/nexus_client.py \ + testing/nexus-e2e/test_maven_repo.py +``` + +Expected: every command exits 0 with no syntax errors or whitespace errors. + +- [ ] **Step 4: Run existing Go test compilation** + +Run: + +```bash +cd testing +go test ./... +``` + +Expected: exit 0. If this suite requires an external test environment, run +`go test -run '^$' ./...` to prove compilation and report the environment-bound +tests separately. + +- [ ] **Step 5: Run two-Nexus acceptance when disposable instances are available** + +With `NEXUS_PASSWORD` and `MAVEN_UPSTREAM_PASSWORD` already loaded from the +approved secret store (without shell tracing), import the built image bundle +into the upstream Nexus, then run: + +```bash +cd testing/nexus-e2e +NEXUS_URL=https://tested-nexus.example \ +NEXUS_USERNAME=admin \ +MAVEN_UPSTREAM_URL=https://upstream-nexus.example \ +MAVEN_UPSTREAM_REPOSITORY=maven-e2e-external \ +MAVEN_UPSTREAM_USERNAME=reader \ +python -m pytest test_maven_repo.py -k test_maven_proxy -q +``` + +Expected: `test_maven_proxy` passes; the tested Nexus reports the JUnit artifact +in its `maven-central` cache. Do not run this step without explicit target +instances and credentials. If they are unavailable, report this acceptance +step as outstanding. + +- [ ] **Step 6: Commit documentation** + +```bash +git add testing/README.md +git commit -m "docs(testing): explain authenticated Maven E2E upstream" +``` + +- [ ] **Step 7: Review requirements and final diff** + +Run: + +```bash +git status --short +git log --oneline --decorate -8 +git diff HEAD~6 --stat +``` + +Confirm that the diff contains only the planned testing image, importer, +upstream authentication, tests, and documentation changes. Verify that no URL +contains embedded credentials and no secret-bearing local file is tracked. diff --git a/docs/superpowers/plans/2026-08-16-nexus-lynx-entrypoint.md b/docs/superpowers/plans/2026-08-16-nexus-lynx-entrypoint.md new file mode 100644 index 00000000..65c8f3e5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-nexus-lynx-entrypoint.md @@ -0,0 +1,85 @@ +# Nexus Lynx Test Entrypoint Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an executable `/app/lynx-entrypoint.sh` to the Nexus test image that authenticates to an ACP Region, installs the exact listed Nexus Operator through OLM, runs the existing Godog E2E, and preserves reports and failure status. + +**Architecture:** Keep the fixed entrypoint as a small phase orchestrator. Put common validation/polling, ACP authentication, OLM installation, E2E/report handling, and diagnostics in separate sourceable shell libraries under `testing/lynx/`, with subprocess-based pytest tests and fake command binaries. + +**Tech Stack:** Bash 5, kubectl/OLM, ACP OIDC HTTP APIs, jq/yq, Python 3.12 pytest, Godog `nexus.test`, Allure CLI, OCI Containerfile. + +--- + +### Task 1: Contract tests and common helpers + +**Files:** +- Create: `testing/nexus-e2e/unit/test_lynx_entrypoint.py` +- Create: `testing/lynx/common.sh` + +- [ ] **Step 1: Write failing tests** for timestamped phase logging, required variables, integer timeout validation, exact version extraction from `L5_PLUGINS_VERSION`, and bounded polling. The test helper invokes Bash with `source testing/lynx/common.sh` and captures stdout/stderr/return code. +- [ ] **Step 2: Verify RED** with `/tmp/nexus-lynx-venv/bin/python -m pytest unit/test_lynx_entrypoint.py -q`; expect failure because `testing/lynx/common.sh` does not exist. +- [ ] **Step 3: Implement minimal helpers:** `log`, `fatal`, `require_env`, `require_command`, `require_positive_integer`, `listed_operator_version`, and `wait_for_value`. `fatal` returns non-zero without echoing environment values; polling uses `SECONDS`, a deadline, and a configurable heartbeat. +- [ ] **Step 4: Verify GREEN** with the focused pytest command; expect all Task 1 tests to pass. +- [ ] **Step 5: Commit** `test_lynx_entrypoint.py` and `common.sh` with `test: define Lynx entrypoint runtime contract`. + +### Task 2: ACP authentication and safe target configuration + +**Files:** +- Modify: `testing/nexus-e2e/unit/test_lynx_entrypoint.py` +- Create: `testing/lynx/auth.sh` + +- [ ] **Step 1: Write failing tests** proving pre-issued `TOKEN` bypasses password login, missing both authentication methods fails, proxy kubeconfig points at `${API_URL}/kubernetes/${REGION_NAME}`, generated files are mode 0600, and captured output does not contain password/token values. +- [ ] **Step 2: Verify RED** and confirm failure is caused by missing `auth.sh` functions. +- [ ] **Step 3: Implement** `resolve_access_token`, ACP Dex password login using `/dex/pubkey` RSA PKCS#1 encryption, `write_proxy_kubeconfig`, and `write_bdd_config`. Use temporary files, no xtrace, curl deadlines, and never print usernames or credential payloads. +- [ ] **Step 4: Verify GREEN** with the focused pytest command and `bash -n testing/lynx/auth.sh`. +- [ ] **Step 5: Commit** with `feat(testing): add secure ACP authentication for Lynx`. + +### Task 3: Idempotent exact-version OLM installer + +**Files:** +- Modify: `testing/nexus-e2e/unit/test_lynx_entrypoint.py` +- Create: `testing/lynx/olm.sh` + +- [ ] **Step 1: Write failing tests** using a stateful fake `kubectl` for: exact PackageManifest channel/CSV resolution, version mismatch, CatalogSource Ready, zero/one/multiple OperatorGroups, Manual Subscription creation with `startingCSV`, InstallPlan approval, terminal Subscription conditions, already-installed CSV, CSV timeout, dynamic Deployment discovery, and established CRD. +- [ ] **Step 2: Verify RED** and confirm missing OLM functions are the reason. +- [ ] **Step 3: Implement** `resolve_operator_catalog`, `ensure_operator_group`, `ensure_subscription`, `wait_for_install_plan`, `wait_for_csv`, `wait_for_deployment`, `wait_for_nexus_crd`, and `install_operator`. Read source/channel/currentCSV from the PackageManifest; require it to match the version parsed from `L5_PLUGINS_VERSION`; use Manual approval and reject incompatible existing resources. +- [ ] **Step 4: Verify GREEN** with pytest and `bash -n testing/lynx/olm.sh`. +- [ ] **Step 5: Commit** with `feat(testing): install listed Nexus operator through OLM`. + +### Task 4: E2E, reports, and failure diagnostics + +**Files:** +- Modify: `testing/nexus-e2e/unit/test_lynx_entrypoint.py` +- Create: `testing/lynx/e2e.sh` +- Create: `testing/lynx/diagnostics.sh` + +- [ ] **Step 1: Write failing tests** proving `nexus.test` receives `--godog.tags=${LYNX_E2E_TAGS:-@e2e}`, its non-zero exit code is returned, Allure results are copied to `${RESULT_DIR}/allure-result`, empty results fail, Allure report generation is attempted after non-empty results, and diagnostics query only OLM/workload/Event status without reading Secrets. +- [ ] **Step 2: Verify RED** for missing E2E/diagnostic functions. +- [ ] **Step 3: Implement** `run_e2e`, `collect_allure_results`, `generate_allure_report`, and `collect_diagnostics`. Run from a writable temporary copy when `/app/testing` is read-only, set `E2E_CONFIG`, and retain the real test exit status. Permit best-effort `|| true` only in diagnostics/report collection. +- [ ] **Step 4: Verify GREEN** with pytest and `bash -n` for both libraries. +- [ ] **Step 5: Commit** with `feat(testing): run Nexus E2E and retain Lynx diagnostics`. + +### Task 5: Entrypoint orchestration and image contract + +**Files:** +- Modify: `testing/nexus-e2e/unit/test_lynx_entrypoint.py` +- Create: `testing/lynx-entrypoint.sh` +- Modify: `testing/Containerfile` +- Modify: `testing/README.md` + +- [ ] **Step 1: Write failing tests** asserting the top-level phase order, required-variable return code, EXIT diagnostic behavior, fixed `/app/lynx-entrypoint.sh` executable contract, explicit library copy, and absence of xtrace or success masking around tests. +- [ ] **Step 2: Verify RED** because the entrypoint and Containerfile clauses do not exist. +- [ ] **Step 3: Implement** the orchestrator: validate inputs and tools; create the result directory; authenticate; preflight/install/wait; run E2E; collect/generate reports; clean only temporary credentials; emit `[DONE]`; preserve failures through the EXIT trap. Add explicit Containerfile `COPY`/`chmod`/`test -x` and document inputs/output paths. +- [ ] **Step 4: Verify GREEN** with focused pytest, `bash -n testing/lynx-entrypoint.sh testing/lynx/*.sh`, and `git diff --check`. +- [ ] **Step 5: Commit** with `feat(testing): add Nexus Lynx image entrypoint`. + +### Task 6: Full verification and PR + +**Files:** +- Verify all files above; no new production scope. + +- [ ] **Step 1: Run** `/tmp/nexus-lynx-venv/bin/python -m pytest unit -q`; expect all existing and new unit tests to pass. +- [ ] **Step 2: Run** shell syntax checks and a test-image build if a compatible local container builder is available. If unavailable, record that the PR pipeline is the image-build evidence. +- [ ] **Step 3: Inspect** `git diff --check`, executable modes, staged diff, and secret-pattern scan; ensure no generated files are tracked. +- [ ] **Step 4: Push** `codex/devops-44609-lynx-entrypoint` and open a draft MR targeting `alauda-76.0`, describing the two-stage delivery and explicitly stating that the operator gitlink is not updated yet. +- [ ] **Step 5: Monitor** the MR pipeline read-only and report failures; do not merge or start the operator update until user review. diff --git a/docs/superpowers/specs/2026-08-06-nexus-e2e-maven-upstream-design.md b/docs/superpowers/specs/2026-08-06-nexus-e2e-maven-upstream-design.md new file mode 100644 index 00000000..d55de36f --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-nexus-e2e-maven-upstream-design.md @@ -0,0 +1,243 @@ +# Nexus E2E Maven Upstream Bundle Design + +## Goal + +Package every external Maven artifact required by the Nexus Maven E2E tests +inside the testing image. Provide a script that imports those artifacts into a +second, authenticated Nexus instance. The Nexus instance under test then uses +that second Nexus as the remote for its `maven-central` proxy repository. + +The resulting flow must not require the Nexus instance under test to reach a +public Maven repository. + +## Scope + +This change covers: + +- collecting the Maven dependencies and build plugins used by + `testing/nexus-e2e/test_projects/maven/publish.xml` and `download.xml`; +- packaging the collected Maven2 repository layout in `testing/Containerfile`; +- importing the bundle into a Maven hosted repository on a second Nexus; +- configuring the tested Nexus proxy with an authenticated second-Nexus + remote; and +- automated tests and an offline build verification for this workflow. + +It does not make the testing image itself buildable without network access. +The image build may continue to download its existing OS packages, tools, +Python packages, and Maven artifacts from configured build-time sources. + +## Architecture + +The testing image contains a read-only Maven2 repository bundle at: + +```text +/opt/nexus-e2e/maven-repository +``` + +It also exposes this executable: + +```text +/usr/local/bin/import-maven-e2e-dependencies +``` + +The import script creates or reuses a Maven hosted repository on the upstream +Nexus and uploads the bundle while preserving every Maven2 relative path. + +At test time, `test_maven_proxy` updates the tested Nexus repository named +`maven-central`. Its remote points to the hosted repository on the upstream +Nexus, and its HTTP client configuration contains Basic Authentication for +that upstream. + +```text +testing image bundle + | + | import script (authenticated upload) + v +upstream Nexus / repository/maven-e2e-external + ^ + | authenticated proxy download + | +tested Nexus / repository/maven-central + ^ + | Maven E2E resolution + | +test process +``` + +The upstream and tested Nexus instances are distinct. The workflow never +attempts to upload content directly into a Nexus proxy repository. + +## Configuration Interface + +The import script and Maven proxy test share these variables: + +| Variable | Required | Default | Meaning | +| --- | --- | --- | --- | +| `MAVEN_UPSTREAM_URL` | For the new workflow | none | Base URL of the second Nexus | +| `MAVEN_UPSTREAM_REPOSITORY` | No | `maven-e2e-external` | Maven hosted repository name | +| `MAVEN_UPSTREAM_USERNAME` | Yes | none | Second Nexus username | +| `MAVEN_UPSTREAM_PASSWORD` | Yes | none | Second Nexus password | + +URLs are normalized by removing a trailing slash before appending service or +repository paths. The upstream repository URL is: + +```text +${MAVEN_UPSTREAM_URL}/repository/${MAVEN_UPSTREAM_REPOSITORY}/ +``` + +Credentials must be supplied through environment variables or a silent +interactive prompt. The password is not accepted as a positional command-line +argument because process listings and shell history can expose it. The script +must not enable shell tracing or print authorization headers. + +For backward compatibility, when `MAVEN_UPSTREAM_URL` is absent the tests keep +their current behavior: `MACVEN_MIRROR_REGISTRY`, including its historical +misspelling, overrides the existing anonymous mirror URL. The new variables +take precedence when present. + +## Building the Maven Bundle + +The bundle must be derived from actual E2E Maven lifecycles rather than a +manually maintained artifact list. The explicit business dependency is +`junit:junit:4.11`, with `org.hamcrest:hamcrest-core:1.3` transitively, but the +tests also require Maven lifecycle plugins and their dependency closures. + +During the image build: + +1. Create a new, empty Maven local repository dedicated to the bundle. +2. Use a build-time settings file whose mirror is the configured trusted Maven + source. +3. Run `clean deploy` for `publish.xml` with deployment redirected to a local + file repository. This exercises clean, compile, test, jar, install, and + deploy plugin resolution without contacting a real Nexus. +4. Make the produced `com.nexus.test:test-publish:1.0-SNAPSHOT` available to + `download.xml`, then run its `package` lifecycle. +5. Repeat the required lifecycles with Maven offline mode enabled and the same + isolated repository. A failure proves that the dependency closure is + incomplete and fails the image build. +6. Remove resolver-local state that must not be imported, including + `_remote.repositories`, `*.lastUpdated`, and `resolver-status.properties`. +7. Copy the remaining Maven2 layout into + `/opt/nexus-e2e/maven-repository` in the final image. + +The E2E-generated `com.nexus.test:test-publish:1.0-SNAPSHOT` is not an external +dependency and must be excluded from the import bundle. It is created and +deployed by the hosted-repository E2E scenario itself. + +Using the real lifecycles is preferred to relying only on +`dependency:go-offline`: `go-offline` can resolve unused reporting and +plugin-management content while missing dynamically selected runtime plugin +components such as a Surefire provider. + +## Import Script Behavior + +The script performs these phases: + +1. Validate required configuration and the bundle directory. +2. Verify authentication against the upstream Nexus REST API. +3. Inspect the configured repository name. +4. If it does not exist, create a Maven hosted repository with `MIXED` version + policy and `STRICT` layout policy. +5. If it exists, verify that it is a Maven hosted repository. Fail rather than + uploading into a repository with a different format or type. +6. Traverse regular files under the bundle directory and upload each one with + an authenticated HTTP PUT to its matching Maven2 path. +7. Verify representative artifacts through authenticated GET requests, + including JUnit, Hamcrest, and selected lifecycle plugin POM/JAR files. +8. Print a summary containing the repository URL and uploaded, skipped, and + failed file counts. Do not print credentials. + +The default repository name is `maven-e2e-external`. Users can override it +with `MAVEN_UPSTREAM_REPOSITORY`. + +The script is safe to rerun. Before uploading a path that already exists, it +compares the remote content digest with the bundled file. Equal content is +skipped. Different content is reported as a conflict and causes a nonzero exit +instead of overwriting an immutable or inconsistent artifact. HTTP 401 and 403 +responses are reported as authentication or authorization failures without +including response headers that may contain sensitive information. + +The existing image entrypoint remains unchanged so current BDD execution is +not disrupted. Users invoke the importer by overriding the container entrypoint +or by selecting the executable as the container command in their runtime. + +## E2E Code Changes + +`testing/nexus-e2e/conftest.py` exposes a typed upstream Maven configuration +fixture populated from the new environment variables. + +`testing/nexus-e2e/libs/nexus_client.py` extends Maven proxy configuration to +accept optional upstream Basic Authentication. Authentication is serialized +into the Nexus REST request only when all new upstream settings are present. +The existing unauthenticated request shape remains unchanged for legacy runs. + +`testing/nexus-e2e/test_maven_repo.py` selects the remote as follows: + +1. When `MAVEN_UPSTREAM_URL` is set, compose the hosted repository URL from the + new configuration and pass the upstream credentials to the proxy update. +2. Otherwise, preserve the current `MACVEN_MIRROR_REGISTRY` or default mirror + behavior without upstream authentication. + +The test still clears Maven's local repository before `test_maven_proxy` and +resolves through the tested Nexus. This proves that the tested Nexus can +authenticate to the second Nexus, proxy the Maven artifacts, and serve them to +the E2E client. + +## Error Handling and Security + +- Missing URL, username, or password causes an early, actionable error in the + import workflow. +- Repository format/type mismatches fail before any upload. +- Network errors include the operation and sanitized target URL but no + credentials or authorization headers. +- Temporary settings and response files are removed on exit. +- Shell tracing is prohibited in the importer. +- The bundle and importer are readable/executable by the non-root UID used by + integration tests. +- No credentials are baked into the image, repository, logs, or generated + bundle. + +## Testing and Verification + +Automated tests cover: + +- default and overridden upstream repository names; +- URL normalization and repository URL composition; +- repository creation, compatible repository reuse, and incompatible + repository rejection; +- authenticated Nexus proxy request generation; +- backward-compatible unauthenticated proxy request generation; +- upload success, identical-content skip, conflict, and authentication error; +- absence of password and authorization values in script output; and +- filtering resolver-local files and the E2E-generated SNAPSHOT from the + bundle. + +Image-build verification runs the required Maven lifecycles in offline mode. +Script integration verification uses a disposable HTTP test server or mock +Nexus API that checks request paths and authentication without requiring a +shared external Nexus. + +A final manual acceptance run against two disposable Nexus instances performs: + +1. Build the testing image. +2. Run the importer against the upstream Nexus. +3. Confirm the upstream hosted repository contains representative artifacts. +4. Run `test_maven_proxy` against the tested Nexus with the four upstream + variables configured. +5. Confirm Maven resolved JUnit from the tested Nexus and that the tested + Nexus cached the artifact from the authenticated upstream. + +If disposable Nexus instances are unavailable locally, the automated test and +offline image-build evidence are required, and the two-Nexus acceptance run is +reported as outstanding rather than inferred to have passed. + +## Compatibility and Non-Goals + +- Existing callers that set only `MACVEN_MIRROR_REGISTRY` continue to work. +- The fixed tested-repository name `maven-central` remains unchanged. +- Existing image `ENTRYPOINT` and normal integration-test commands remain + unchanged. +- This change does not import NPM or PyPI dependencies. +- This change does not modify Nexus blob stores or internal databases. +- This change does not create, modify, or deploy any real Nexus instance while + building the image. diff --git a/docs/superpowers/specs/2026-08-16-nexus-lynx-entrypoint-design.md b/docs/superpowers/specs/2026-08-16-nexus-lynx-entrypoint-design.md new file mode 100644 index 00000000..7d59c234 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-nexus-lynx-entrypoint-design.md @@ -0,0 +1,142 @@ +# Nexus Lynx Test Image Entrypoint Design + +## Scope + +This change is limited to the `nexus-build` testing image. It adds the Lynx +runtime contract and its automated tests, but does not update the +`nexus-ce-operator` submodule pointer, release-config, artifacts metadata, or +trigger a release build. Those follow only after this pull request is reviewed +and merged. + +The image must expose an executable `/app/lynx-entrypoint.sh`. Lynx runs this +command in an IDP-side test Pod after the Nexus Operator bundle has been listed +through `l5_plugin_packages`. The entrypoint installs the listed Operator in the +target Region through OLM, runs the existing `@e2e` Godog scenario, and leaves +Allure results and failure diagnostics in the Lynx result directory. + +## File boundaries + +The implementation is split by responsibility: + +- `testing/lynx-entrypoint.sh` validates inputs and orchestrates the phases. +- `testing/lynx/common.sh` provides timestamped logging, required-variable + validation, bounded polling, temporary-file cleanup, and safe command checks. +- `testing/lynx/auth.sh` performs ACP OIDC login and creates a mode-0600 proxy + kubeconfig for `${API_URL}/kubernetes/${REGION_NAME}` without logging + credentials or tokens. +- `testing/lynx/olm.sh` validates the exact listed Nexus Operator version, + creates the Namespace, OperatorGroup, and Subscription idempotently, approves + a Manual InstallPlan, and waits for the CSV, Deployment, and Nexus CRD. +- `testing/lynx/e2e.sh` writes the existing BDD `config.yaml`, runs + `nexus.test` with Godog tags, preserves its exit code, and normalizes Allure + output into the Lynx result directory. +- `testing/lynx/diagnostics.sh` writes bounded, status-only OLM, workload, and + Event diagnostics after a failure. It never reads Secrets or dumps + kubeconfigs. +- `testing/nexus-e2e/unit/test_lynx_entrypoint.py` supplies behavioral and + contract tests using temporary fake executables and subprocesses. +- `testing/Containerfile` explicitly copies the entrypoint and libraries to + `/app` and verifies that the fixed command path is executable. + +The top-level entrypoint remains small enough to review as the execution +contract, while authentication, OLM, E2E, and diagnostics can be tested and +changed independently. + +## Inputs and version selection + +Required inputs are `API_URL`, `USERNAME`/`PASSWORD` or a pre-issued `TOKEN`, +`REGION_NAME`, and a writable result directory. The result directory accepts +`RESULT_DIR` and the existing Lynx `TEST_RESULT_DIR`, with `RESULT_DIR` taking +precedence. + +The exact Operator version is derived from the Lynx-provided +`L5_PLUGINS_VERSION` JSON entry for `nexus-ce-operator`; it is not hard-coded in +the image. The script rejects a missing version and rejects a PackageManifest +whose selected channel does not resolve to that version. Defaults are: + +- package: `nexus-ce-operator`; +- namespace: the bundle's suggested namespace `nexus-ce-operator`; +- channel: `stable`; +- CatalogSource: read from the PackageManifest; +- install plan approval: `Manual`, followed by an idempotent approval patch; +- install timeout: 900 seconds, configurable with `LYNX_INSTALL_TIMEOUT`; +- E2E tags: `@e2e`, configurable with `LYNX_E2E_TAGS`. + +The expected CSV is obtained from the matching PackageManifest channel and is +used as `startingCSV`. This binds the clean environment to the bundle version +that the RTP listed while avoiding a hard-coded full CSV name. + +## Runtime flow + +1. **Validate** required variables, commands, writable result directory, and + timeout values. +2. **Authenticate** through ACP OIDC (or use `TOKEN`) and create a temporary + proxy kubeconfig and BDD config. Passwords, encrypted payloads, tokens, and + kubeconfig contents are never logged. +3. **Preflight** the Region API, Namespace permissions, PackageManifest, and + CatalogSource readiness. A missing or mismatched listed version fails rather + than silently selecting another version. +4. **Install** a dedicated all-namespaces OperatorGroup and a Manual + Subscription with the expected `startingCSV`. Existing compatible resources + are reused; incompatible OperatorGroups or Subscriptions fail clearly. +5. **Wait** on state, not fixed sleeps: Subscription conditions, InstallPlan + `Complete`, expected CSV `Succeeded`, the CSV-owned Deployment `Available`, + and `nexuses.operator.alaudadevops.io` established and discoverable. +6. **Test** by running the image's existing `nexus.test` binary with Godog + `@e2e`. That scenario creates a uniquely named Nexus CR, waits for the + instance, and invokes the Python Maven/PyPI repository checks. The real test + exit code is preserved. +7. **Report** by retaining raw Allure results, generating the Allure report, + and writing a small phase log. Empty Allure results are an infrastructure + failure. +8. **Diagnose** failures with status-only OLM/workload/Event snapshots. The + Operator installation is retained by default so the environment remains + debuggable. + +Every poll has a deadline and emits a heartbeat only when state changes or at a +bounded interval. The script has no unbounded wait and no success-masking +`|| true` around installation or test operations. Best-effort diagnostic +commands may use `|| true` only inside the failure trap. + +## Idempotency and cleanup + +On a second run, an existing expected `Succeeded` CSV and Available Deployment +are accepted. Namespace and OperatorGroup creation is declarative. Subscription +configuration is applied repeatedly only when its package, source, channel, and +starting CSV match the requested bundle version. An existing incompatible +resource fails instead of being overwritten silently. + +Temporary authentication files and BDD configuration are always removed. +Operator, Subscription, InstallPlan, CSV, and CRDs are not removed on failure or +success. Test-resource cleanup remains the responsibility of the existing BDD +scenario; this PR does not introduce full Operator uninstall behavior. + +## Test strategy + +Tests are written before implementation and run in the existing pytest unit +suite. They cover: + +- the fixed executable image path and Containerfile contract; +- missing required variables returning non-zero without exposing values; +- acceptance of `TOKEN` and fallback to username/password authentication; +- exact version extraction from `L5_PLUGINS_VERSION`; +- PackageManifest version mismatch and unhealthy CatalogSource failures; +- first-install and already-installed OLM paths using fake `kubectl` state; +- Manual InstallPlan approval, terminal Subscription conditions, and timeouts; +- dynamic CSV and Deployment discovery; +- propagation of the Godog E2E exit code; +- non-empty Allure result enforcement and report generation; +- diagnostics that contain no Secret reads or credential output. + +Local verification consists of `bash -n`, the pytest unit suite, Containerfile +contract tests, and an image build when the local builder is available. The PR +pipeline is expected to build the test image and run the repository integration +checks; no release tag is consumed by operator or release-config until review. + +## Follow-up after review + +After this PR merges to `alauda-76.0`, a separate `nexus-ce-operator` PR updates +the `charts/current` gitlink to the merged commit. Its integration pipeline +builds the version-paired `nexus-ce-test` image, and the release branch's +manifest updater records the exact test image. Only then should artifacts and +release-config consume the new Operator/test-image pair. diff --git a/image/Containerfile.alpine.java17 b/image/Containerfile.alpine.java17 index c7a29a88..a60ee7ca 100644 --- a/image/Containerfile.alpine.java17 +++ b/image/Containerfile.alpine.java17 @@ -12,7 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM build-harbor.alauda.cn/ops/alpine:3.21.5-alauda-202512020944 as builder +# BASE_IMAGE declared before the first FROM (global scope, survives across +# stages). Rewritten from build-harbor.alauda.cn/ops/alpine:3.21.5-alauda-202512020944 +# (DEVOPS-44489, P1/P2 containerfile-base-rewrite): registry-dev.alauda.io/base-images/alpine +# does not carry the exact 3.21.5-alauda-202512020944 build; 3.21.7-alauda-202607221130 is +# the latest maintained tag in the same 3.21.x line (confirmed live via skopeo). +ARG BASE_IMAGE=registry-dev.alauda.io/base-images/alpine:3.21.7-alauda-202607221130 +FROM ${BASE_IMAGE} as builder LABEL name="Nexus Repository Manager" \ maintainer="Sonatype " \ @@ -37,7 +43,9 @@ LABEL name="Nexus Repository Manager" \ io.openshift.tags="Sonatype,Nexus,Repository Manager" ARG NEXUS_VERSION=3.76.0-03 -ARG NEXUS_DOWNLOAD_URL=https://build-nexus.alauda.cn/repository/alauda/files/nexus-${NEXUS_VERSION}-unix.tar.gz +# public Sonatype mirror -- byte-identical to the old build-nexus.alauda.cn artifact +# (SHA256 d336a1c1...), verified 2026-07-22; edge-build has internet egress. +ARG NEXUS_DOWNLOAD_URL=https://download.sonatype.com/nexus/3/nexus-${NEXUS_VERSION}-unix.tar.gz ARG NEXUS_DOWNLOAD_SHA256_HASH=d336a1c1fa3c26ee977ef720707d7bbca660aee5bf7369a9037293910c63c672 # configure nexus runtime @@ -217,7 +225,7 @@ RUN apk del gzip shadow RUN set +e; \ ls -d /usr/bin/* | grep -E "cpp|readelf|gcc|nc|netcat" | xargs rm -FROM build-harbor.alauda.cn/ops/alpine:3.21.5-alauda-202512020944 +FROM ${BASE_IMAGE} COPY --from=builder / / diff --git a/image/Containerfile.base b/image/Containerfile.base index 2778264e..afd28c42 100644 --- a/image/Containerfile.base +++ b/image/Containerfile.base @@ -1,4 +1,16 @@ -FROM hub-mirrors.alauda.cn/library/eclipse-temurin:17.0.15_6-jdk-jammy +# BASE_IMAGE declared before the first FROM (global scope) so it survives +# across build stages -- proven pattern from toolbox/pr-cli + gitlab-ce-operator +# + nexus-ce-operator's Containerfile (edge-build live smoke lesson). +# +# Rewritten from hub-mirrors.alauda.cn/library/eclipse-temurin:17.0.15_6-jdk-jammy +# (DEVOPS-44489, P1/P2 containerfile-base-rewrite): no eclipse-temurin mirror +# exists under registry-dev.alauda.io/base-images/ (checked: base-images/eclipse-temurin, +# /openjdk, /jdk all 404). Swapped to the plain Ubuntu 22.04 (jammy) mirror that IS present +# + install temurin-17-jdk via the SAME Adoptium apt repo this Containerfile already +# configures for temurin-8-jdk below -- no new external dependency introduced, just +# stops relying on the base image to provide the JDK. +ARG BASE_IMAGE=registry-dev.alauda.io/base-images/ubuntu:22.04-alauda-202607220420 +FROM ${BASE_IMAGE} RUN export DEBIAN_FRONTEND=noninteractive && \ echo 'Acquire::AllowReleaseInfoChange::Suite "true";' > /etc/apt/apt.conf.d/allow_release_info_change.conf && \ @@ -27,9 +39,15 @@ RUN export DEBIAN_FRONTEND=noninteractive && \ jq \ expect \ temurin-8-jdk \ + temurin-17-jdk \ xmlstarlet && \ # Install yarn 1.x first (needed to bootstrap yarn 4.x) npm install -g yarn && \ yarn set version 4.9.2 && \ corepack enable && \ corepack prepare yarn@4.9.2 --activate + +# eclipse-temurin's own base image set these; restore them explicitly now that +# the JDK comes from an apt package instead of the base image (DEVOPS-44489). +ENV JAVA_HOME=/usr/lib/jvm/temurin-17-jdk-amd64 +ENV PATH="${JAVA_HOME}/bin:${PATH}" diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 00000000..b3919b27 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,29 @@ +# PaC Repository CR registering alauda-devops-toolchain/nexus-build with +# Pipelines-as-Code. PaC watches code.alauda.io/alauda-devops-toolchain/nexus-build +# and fires .tekton/build-base-image.yaml + .tekton/build-image.yaml + +# .tekton/integration-test.yaml (deferred/inert, DEVOPS-44489) on push/MR/comment/ +# tag events (4-event webhook registered separately via GitLab API -- Push, +# Merge-request, Note/comment, Tag-push). +# +# ZERO per-Repository params -- inherits all PaC globals (registry_url, +# codescan_url, artifacts_url, code_url, gomod_proxy, registry_secret, ...) from +# the PaC global config. Matches repository-toolbox.yaml / repository-helm-operator-sdk.yaml +# precedent in this same gitops repo/cluster (adding per-repo params here would +# drop ALL globals -- acp L-11 / pilot-lessons B reference). +# +# No namespace: -- ArgoCD pins this CR to alauda-devops-toolchain-build. +apiVersion: pipelinesascode.tekton.dev/v1alpha1 +kind: Repository +metadata: + name: nexus-build +spec: + url: https://code.alauda.io/alauda-devops-toolchain/nexus-build + git_provider: + type: gitlab + url: https://code.alauda.io + secret: + name: gitlab-token + key: password + webhook_secret: + name: pac-webhook + key: secret diff --git a/source/.yarnrc.yml b/source/.yarnrc.yml index 3999f350..e9b58966 100644 --- a/source/.yarnrc.yml +++ b/source/.yarnrc.yml @@ -4,4 +4,13 @@ enableGlobalCache: false nodeLinker: node-modules -npmRegistryServer: https://build-nexus.alauda.cn/repository/npm-proxy-ali/ +# DEVOPS-44489: build-nexus.alauda.cn (internal Alauda npm proxy) is +# unreachable from edge-build (confirmed live: yarn install ETIMEDOUT +# 192.168.156.101:443, a private IP that host resolves to). Swapped to the +# public default registry -- edge-build's own network already proved +# reachable to the public npm ecosystem moments earlier in the same run +# (corepack successfully fetched https://repo.yarnpkg.com/... just before +# this config is read). Same shape of fix as the cohort's established +# GOPROXY rewrite (gomod.alauda.cn -> gomod.alauda.io): internal Alauda +# artifact mirror unreachable -> swap to the reachable public equivalent. +npmRegistryServer: https://registry.npmjs.org/ diff --git a/source/settings.xml b/source/settings.xml index f0f51bdd..390e0583 100644 --- a/source/settings.xml +++ b/source/settings.xml @@ -5,12 +5,20 @@ http://maven.apache.org/xsd/settings-1.0.0.xsd"> + - alauda-central + central central - Alauda Central Repository - https://build-nexus.alauda.cn/repository/maven-central/ + Maven Central Repository + https://repo.maven.apache.org/maven2/ diff --git a/testing/Containerfile b/testing/Containerfile index df64d8c1..62fc8da3 100644 --- a/testing/Containerfile +++ b/testing/Containerfile @@ -1,4 +1,4 @@ -FROM registry-dev.alauda.io/platform-edge/tekton-catalog-incubator/golang:v1.25 AS builder +FROM hub-mirrors.alauda.cn/library/golang:1.25.4-bookworm AS builder WORKDIR /tools RUN mkdir -p /tools/bin @@ -9,15 +9,9 @@ RUN set -eux; \ cd /app && \ CGO_ENABLED=0 go test -c -o /tools/bin/nexus.test ./ -# ---- test-base, inlined from testing/Containerfile.base (no external -# registry.alauda.cn:60070/devops/nexus-ce-test-base image -- that host has no -# registry-dev mirror and is unreachable from edge-build's buildkit builders; see -# migrations/nexus/changes/nexus-ce-operator/state.yaml decision -# integration-test-base-image-merged, ported from the alauda-81.1 line commit -# ea564b6/f424f0e/13536db for release-3.76 DEVOPS-44489). hub-mirrors.alauda.cn -# is ALSO unreachable from the same buildkit builders (only registry-dev.alauda.io -# mirrors are reachable) -- use the registry-dev platform-edge mirror (same tag). ---- -FROM registry-dev.alauda.io/platform-edge/python:3.12-slim +# IDC-only build branch: use the legacy registries reachable from the IDC +# BuildKit workers. The GitLab/edge-build branch continues to use registry-dev. +FROM registry.alauda.cn:60070/devops/nexus-ce-test-base:latest AS test-base WORKDIR /tools RUN mkdir -p /tools/bin @@ -40,6 +34,7 @@ RUN apt-get update && apt-get install -y ca-certificates tzdata bash locales mak nodejs \ npm \ openjdk-17-jdk \ + libnss-wrapper \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && locale-gen en_US.UTF-8 \ @@ -83,13 +78,45 @@ RUN wget https://github.com/allure-framework/allure2/releases/download/${ALLURE_ && ln -s /opt/allure-${ALLURE_VERSION}/bin/allure /usr/local/bin/allure \ && rm allure-${ALLURE_VERSION}.tgz +# ---- Maven E2E dependency bundle ---- +FROM test-base AS maven-bundle + +ARG MAVEN_BUNDLE_MIRROR_URL=https://artifacts.alauda.io/repository/maven-central +COPY testing/nexus-e2e/test_projects/maven /tmp/nexus-e2e-maven +COPY testing/hack/prepare-maven-e2e-bundle.sh /usr/local/bin/prepare-maven-e2e-bundle +RUN chmod 755 /usr/local/bin/prepare-maven-e2e-bundle && \ + PATH=/tools/bin/maven/bin:$PATH MAVEN_BUNDLE_MIRROR_URL=$MAVEN_BUNDLE_MIRROR_URL \ + /usr/local/bin/prepare-maven-e2e-bundle /tmp/nexus-e2e-maven /opt/nexus-e2e/maven-bundle && \ + mv /opt/nexus-e2e/maven-bundle/repository /opt/nexus-e2e/maven-repository && \ + rmdir /opt/nexus-e2e/maven-bundle && \ + find /opt/nexus-e2e/maven-repository -type d -exec chmod 755 {} + && \ + find /opt/nexus-e2e/maven-repository -type f -exec chmod 644 {} + + # ---- test-image, original testing/Containerfile final stage ---- +FROM test-base AS test-image + RUN mkdir -p /tools/bin COPY --from=builder /tools/bin /tools/bin -COPY . /app +COPY --from=maven-bundle /opt/nexus-e2e/maven-repository /opt/nexus-e2e/maven-repository +COPY testing/hack/import-maven-e2e-dependencies.py /usr/local/bin/import-maven-e2e-dependencies +RUN chmod 755 /usr/local/bin/import-maven-e2e-dependencies && \ + test -r /opt/nexus-e2e/maven-repository/junit/junit/4.11/junit-4.11.jar && \ + test -r /opt/nexus-e2e/maven-repository/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar && \ + test -r /opt/nexus-e2e/maven-repository/org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.jar && \ + test -r /opt/nexus-e2e/maven-repository/org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.pom && \ + test -x /usr/local/bin/import-maven-e2e-dependencies + +COPY testing/features /app/testing/features +COPY testing/testdata /app/testing/testdata +COPY testing/hack /app/testing/hack +COPY testing/nexus-e2e /app/testing/nexus-e2e +COPY testing/lynx /app/lynx +COPY testing/lynx-entrypoint.sh /app/lynx-entrypoint.sh -RUN chmod -R 755 /app +RUN chmod -R 755 /app && \ + chmod 755 /app/lynx-entrypoint.sh && \ + test -x /app/lynx-entrypoint.sh WORKDIR /app/testing @@ -103,5 +130,4 @@ ENV LANG=zh_CN.UTF-8 ENV LC_ALL=zh_CN.UTF-8 ENV TEST_COMMAND="nexus.test" -ENTRYPOINT ["nexus.test"] -CMD ["--godog.concurrency=2", "--godog.format=allure", "--godog.tags= ~@e2e"] +ENTRYPOINT ["/app/lynx-entrypoint.sh"] diff --git a/testing/README.md b/testing/README.md index 8c481195..7e6075fe 100644 --- a/testing/README.md +++ b/testing/README.md @@ -32,7 +32,7 @@ make test-all ```yaml acp: - baseUrl: https://192.168.129.179 # acp 的 url + baseUrl: https://acp.example.test # acp 的 url token: xxxx # acp 的 token cluster: business-1 # 运行测试的集群名称 ``` @@ -43,3 +43,63 @@ acp: ```bash make test-e2e ``` + +### Lynx 测试镜像入口 + +测试镜像固定以 `/app/lynx-entrypoint.sh` 启动。必须设置 `API_URL`、 +`REGION_NAME`、`L5_PLUGINS_VERSION`,并通过 `TOKEN` 或 +`USERNAME`/`PASSWORD` 提供认证。结果目录使用 `RESULT_DIR`;未设置时兼容 +`TEST_RESULT_DIR`。可通过 `LYNX_INSTALL_TIMEOUT` 调整 Operator 安装超时, +通过 `LYNX_E2E_TAGS` 调整 Godog 标签。 + +原始 Allure 数据会规范化到 `${RESULT_DIR}/allure-result`,生成的报告位于 +`${RESULT_DIR}/allure-report`。失败时还会写入不包含 Secret 内容的 +`${RESULT_DIR}/diagnostics.log`。临时 kubeconfig 和 BDD 认证配置在退出时删除, +安装后的 OLM 资源会保留以便诊断。 + +### 认证 Maven 上游的离线 E2E + +这个场景使用两个独立的 Nexus: + +- **upstream Nexus(第二 Nexus)**:使用 hosted Maven 仓库承载测试镜像中预置的离线依赖 bundle,默认仓库名为 `maven-e2e-external`。 +- **tested Nexus**:当前被测 Nexus;测试会将其 `maven-central` proxy 的 remote 指向 upstream Nexus 的 hosted 仓库,并为该 remote 配置 Basic auth。 + +upstream Nexus 由以下环境变量配置: + +- `MAVEN_UPSTREAM_URL`:upstream Nexus 的基础 URL。 +- `MAVEN_UPSTREAM_REPOSITORY`:hosted Maven 仓库名,默认为 `maven-e2e-external`。 +- `MAVEN_UPSTREAM_USERNAME`:upstream Nexus 用户名。 +- `MAVEN_UPSTREAM_PASSWORD`:upstream Nexus 密码。 + +密码应由容器运行时、CI secret 或 Kubernetes Secret 注入环境,不要将密码值直接写在命令行、脚本或版本库中。运行导入命令前,宿主环境需要已准备 `TESTING_IMAGE`、`MAVEN_UPSTREAM_URL`、`MAVEN_UPSTREAM_USERNAME` 和由安全 secret 注入的 `MAVEN_UPSTREAM_PASSWORD`;`MAVEN_UPSTREAM_REPOSITORY` 可选,未设置时使用 `maven-e2e-external`。例如,CI 或运行时先导出这些变量,以下命令负责校验并透传,不在命令行中展开密码: + +```bash +: "${TESTING_IMAGE:?TESTING_IMAGE is required}" +: "${MAVEN_UPSTREAM_URL:?MAVEN_UPSTREAM_URL is required}" +: "${MAVEN_UPSTREAM_USERNAME:?MAVEN_UPSTREAM_USERNAME is required}" +: "${MAVEN_UPSTREAM_PASSWORD:?MAVEN_UPSTREAM_PASSWORD is required}" +MAVEN_UPSTREAM_REPOSITORY="${MAVEN_UPSTREAM_REPOSITORY:-maven-e2e-external}" +export MAVEN_UPSTREAM_REPOSITORY + +docker run --rm \ + --entrypoint /usr/local/bin/import-maven-e2e-dependencies \ + -e MAVEN_UPSTREAM_URL \ + -e MAVEN_UPSTREAM_REPOSITORY \ + -e MAVEN_UPSTREAM_USERNAME \ + -e MAVEN_UPSTREAM_PASSWORD \ + "$TESTING_IMAGE" +``` + +导入器会自动创建或复用 hosted Maven 仓库。导入阶段的账户需要 repository list 权限、仓库不存在时的 create 权限,以及该 hosted 仓库内容的 GET/PUT 权限。重复导入相同内容是幂等的;如果目标路径已存在但内容不同,则视为冲突并以非零状态退出。可用同样的 entrypoint override 查看帮助: + +```bash +docker run --rm \ + --entrypoint /usr/local/bin/import-maven-e2e-dependencies \ + "$TESTING_IMAGE" --help +``` + +之所以需要 `--entrypoint`,是因为测试镜像的默认 entrypoint 是 `/app/lynx-entrypoint.sh`,而非导入器;仅需直接运行已编译测试时也可以用 `--entrypoint nexus.test` 覆盖。导入器使用 `requests` 访问 HTTPS upstream,默认校验 TLS 证书。当前测试进程到 tested Nexus 的 `NexusClient` 连接使用 `verify=False`,会跳过该段证书校验。tested Nexus 到 upstream 的 proxy remote 未启用 Nexus custom trust store(`useTrustStore=false`),因此依赖 Nexus 运行环境的 JVM 默认信任链;使用私有 CA 时需在该运行环境中另行配置,不在本 E2E 流程的自动配置范围内。 + +运行 pytest 中的 Maven proxy E2E 时,必须向测试进程传入 `MAVEN_UPSTREAM_URL`、`MAVEN_UPSTREAM_USERNAME` 和 `MAVEN_UPSTREAM_PASSWORD`;`MAVEN_UPSTREAM_REPOSITORY` 可省略并使用默认值。proxy 运行阶段应改用独立的 upstream 只读账户,只授予读取该 hosted 仓库的权限,避免将导入阶段的创建/写入账户保存到 tested Nexus 的 proxy 配置中。两个阶段可以轮换同名 `MAVEN_UPSTREAM_USERNAME` 和 `MAVEN_UPSTREAM_PASSWORD` 的值:导入完成后,在启动 pytest 前将它们替换为只读账户凭据。tested Nexus 自身的连接信息仍通过独立的 `NEXUS_URL`、`NEXUS_USERNAME` 和 `NEXUS_PASSWORD` 配置,不要与 upstream Nexus 凭据混用。测试会用 upstream 的 URL 和只读凭据配置 tested Nexus 的 `maven-central` Basic-auth remote,然后通过 tested Nexus 验证依赖下载。 + +为了兼容旧配置,仅在未设置 `MAVEN_UPSTREAM_URL` 时,测试才会使用历史变量 `MACVEN_MIRROR_REGISTRY`(保留原有拼写)作为匿名 mirror fallback。该 fallback 不会配置 upstream Basic auth。 diff --git a/testing/features/network.feature b/testing/features/network.feature index 6fd27799..da89e0d6 100644 --- a/testing/features/network.feature +++ b/testing/features/network.feature @@ -12,6 +12,9 @@ 并且 执行 "添加本地域名解析" 脚本成功 | command | | ./hack/add-host.sh nexus-test-ingress-http-.example.com | + 并且 已添加域名解析 + | domain | ip | + | nexus-test-ingress-http-.example.com | | 并且 命名空间 "testing-nexus-network-http-" 已存在 并且 已导入 "password" 资源: "./testdata/resources/secret-password.yaml" 当 使用 helm 部署实例到 "testing-nexus-network-http-" 命名空间 @@ -27,14 +30,14 @@ 并且 "nexus" 可以正常访问 """ url: http://admin:Nexus12345@nexus-test-ingress-http-.example.com/service/rest/v1/status/check - timeout: 10m + timeout: 15m """ 并且 Pod 资源检查通过 | name | path | value | | nexus-http-nxrm-ha-0 | $.status.conditions[?(@.type == 'Ready')][0].status | True | 并且 执行 "Nexus maven e2e" 脚本成功 | command | - | ./hack/run-e2e.sh http://nexus-test-ingress-http-.example.com admin Nexus12345 "test_maven_repo.py -k test_maven_proxy" | + | ./hack/run-e2e.sh http://nexus-test-ingress-http-.example.com admin Nexus12345 "test_maven_repo.py -k test_maven_proxy" nexus-test-ingress-http-.example.com: | @automated @priority-high @@ -45,6 +48,9 @@ 并且 执行 "添加本地域名解析" 脚本成功 | command | | ./hack/add-host.sh nexus-test-ingress-https-.example.com | + 并且 已添加域名解析 + | domain | ip | + | nexus-test-ingress-https-.example.com | | 并且 命名空间 "testing-nexus-network-https-" 已存在 并且 已导入 "password" 资源: "./testdata/resources/secret-password.yaml" 并且 已导入 "tls 证书" 资源: "./testdata/resources/secret-tls-cert.yaml" @@ -60,14 +66,14 @@ 并且 "nexus" 可以正常访问 """ url: https://admin:Nexus12345@nexus-test-ingress-https-.example.com/service/rest/v1/status/check - timeout: 10m + timeout: 15m """ 并且 Pod 资源检查通过 | name | path | value | | nexus-https-nxrm-ha-0 | $.status.conditions[?(@.type == 'Ready')][0].status | True | 并且 执行 "Nexus npm e2e" 脚本成功 | command | - | ./hack/run-e2e.sh https://nexus-test-ingress-https-.example.com admin Nexus12345 test_npm_repo.py | + | ./hack/run-e2e.sh https://nexus-test-ingress-https-.example.com admin Nexus12345 test_npm_repo.py nexus-test-ingress-https-.example.com: | @automated @priority-high @@ -88,7 +94,7 @@ 并且 "nexus" 可以正常访问 """ url: http://admin:Nexus12345@:/service/rest/v1/status/check - timeout: 10m + timeout: 15m """ 并且 Pod 资源检查通过 | name | path | value | diff --git a/testing/features/storage.feature b/testing/features/storage.feature index f594ed60..ef5cae87 100644 --- a/testing/features/storage.feature +++ b/testing/features/storage.feature @@ -25,7 +25,7 @@ 并且 "nexus" 可以正常访问 """ url: http://admin:Nexus12345@:/service/rest/v1/status/check - timeout: 10m + timeout: 15m """ 并且 Pod 资源检查通过 | name | path | value | @@ -51,7 +51,7 @@ 并且 "nexus" 可以正常访问 """ url: http://admin:Nexus12345@:/service/rest/v1/status/check - timeout: 10m + timeout: 15m """ 并且 Pod 资源检查通过 | name | path | value | @@ -81,7 +81,7 @@ 并且 "nexus" 可以正常访问 """ url: http://admin:Nexus12345@:/service/rest/v1/status/check - timeout: 10m + timeout: 15m """ 并且 Pod 资源检查通过 | name | path | value | diff --git a/testing/hack/add-host.sh b/testing/hack/add-host.sh index 47e4b32b..69b0985f 100755 --- a/testing/hack/add-host.sh +++ b/testing/hack/add-host.sh @@ -4,16 +4,24 @@ # Parameters: # $1: Domain name # $2: IP address +# +# DEVOPS-44489: best-effort. Non-root/PSA-restricted environments cannot +# write /etc/hosts -- warn and exit 0 rather than failing the scenario; +# those environments rely on the bdd fake resolver + nss_wrapper fallback +# instead (see network.feature's "已添加域名解析" step + run-e2e.sh). DOMAIN=$1 IP=$2 echo "Adding local Hosts: $DOMAIN -> $IP" -# Add local domain resolution -if ! grep -q "$IP $DOMAIN" /etc/hosts; then - echo "$IP $DOMAIN" >> /etc/hosts +# Add local domain resolution (best-effort) +if grep -q "$IP $DOMAIN" /etc/hosts 2>/dev/null; then + echo "Local Hosts already exists: $DOMAIN -> $IP" +elif echo "$IP $DOMAIN" >> /etc/hosts 2>/dev/null; then echo "Local Hosts added successfully: $DOMAIN -> $IP" else - echo "Local Hosts already exists: $DOMAIN -> $IP" + echo "WARN: /etc/hosts not writable (non-root PSA env); relying on bdd fake resolver + nss_wrapper" fi + +exit 0 diff --git a/testing/hack/import-maven-e2e-dependencies.py b/testing/hack/import-maven-e2e-dependencies.py new file mode 100755 index 00000000..3f096782 --- /dev/null +++ b/testing/hack/import-maven-e2e-dependencies.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Import the Maven E2E dependency bundle into a Nexus repository.""" + +from __future__ import annotations + +import argparse +import getpass +import os +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Mapping, Sequence, TextIO +from urllib.parse import quote, urlsplit + +import requests + + +DEFAULT_BUNDLE = Path("/opt/nexus-e2e/maven-repository") +DEFAULT_REPOSITORY = "maven-e2e-external" +REPOSITORY_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +REPRESENTATIVE_ARTIFACTS = ( + "junit/junit/4.11/junit-4.11.jar", + "org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.jar", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.pom", +) +HTTP_TIMEOUT = 60 +COMPARE_CHUNK_SIZE = 64 * 1024 + + +class ImportError(RuntimeError): + """A safe-to-display import failure.""" + + +@dataclass(frozen=True) +class ImportConfig: + base_url: str + repository: str + username: str + password: str = field(repr=False) + bundle: Path = DEFAULT_BUNDLE + + @property + def repository_url(self) -> str: + return f"{self.base_url}/repository/{self.repository}" + + +@dataclass(frozen=True) +class ImportResult: + uploaded: int + skipped: int + failed: int + + +def load_config( + argv: Sequence[str] | None = None, + environ: Mapping[str, str] | None = None, +) -> ImportConfig: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bundle", type=Path, default=DEFAULT_BUNDLE) + args = parser.parse_args(argv) + values = os.environ if environ is None else environ + + base_url = values.get("MAVEN_UPSTREAM_URL", "").strip().rstrip("/") + parsed = urlsplit(base_url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ImportError("MAVEN_UPSTREAM_URL must be an http or https URL") + + repository = values.get("MAVEN_UPSTREAM_REPOSITORY", DEFAULT_REPOSITORY).strip() + if not REPOSITORY_NAME.fullmatch(repository): + raise ImportError("MAVEN_UPSTREAM_REPOSITORY contains invalid characters") + + username = values.get("MAVEN_UPSTREAM_USERNAME", "").strip() + if not username: + raise ImportError("MAVEN_UPSTREAM_USERNAME is required") + password = values.get("MAVEN_UPSTREAM_PASSWORD", "") + if not password: + password = getpass.getpass("Maven upstream password: ") + if not password: + raise ImportError("MAVEN_UPSTREAM_PASSWORD is required") + return ImportConfig(base_url, repository, username, password, args.bundle) + + +def _check_response(response, expected: set[int], operation: str, safe_url: str) -> None: + if response.status_code in expected: + return + if response.status_code in {401, 403}: + raise ImportError( + f"authentication/authorization failed during {operation} " + f"for {safe_url} (HTTP {response.status_code})" + ) + if response.status_code >= 400: + raise ImportError(f"{operation} failed for {safe_url} (HTTP {response.status_code})") + raise ImportError(f"unexpected response during {operation} for {safe_url} (HTTP {response.status_code})") + + +def _request(session, method: str, url: str, **kwargs): + return session.request( + method, + url, + timeout=HTTP_TIMEOUT, + allow_redirects=False, + **kwargs, + ) + + +def _ensure_repository(config: ImportConfig, session) -> None: + api_url = f"{config.base_url}/service/rest/v1/repositories" + response = _request(session, "GET", api_url) + _check_response(response, {200}, "repository lookup", api_url) + repositories = response.json() + existing = next((item for item in repositories if item.get("name") == config.repository), None) + if existing: + if existing.get("format") not in {"maven", "maven2"} or existing.get("type") != "hosted": + raise ImportError(f"repository {config.repository!r} is incompatible; expected hosted Maven") + return + + create_url = f"{api_url}/maven/hosted" + payload = { + "name": config.repository, + "online": True, + "storage": { + "blobStoreName": "default", + "strictContentTypeValidation": True, + "writePolicy": "ALLOW_ONCE", + }, + "maven": { + "versionPolicy": "MIXED", + "layoutPolicy": "STRICT", + "contentDisposition": "INLINE", + }, + } + response = _request(session, "POST", create_url, json=payload) + _check_response(response, {201, 204}, "repository creation", create_url) + + +def _artifact_url(config: ImportConfig, relative: str) -> str: + return f"{config.repository_url}/{quote(relative, safe='/')}" + + +def _is_regular_file(path: Path) -> bool: + return path.is_file() and not path.is_symlink() + + +def _response_matches_file(response, path: Path) -> bool: + try: + with path.open("rb") as local: + for remote_chunk in response.iter_content(chunk_size=COMPARE_CHUNK_SIZE): + if remote_chunk and local.read(len(remote_chunk)) != remote_chunk: + return False + return local.read(1) == b"" + finally: + response.close() + + +def import_bundle(config: ImportConfig, session=None) -> ImportResult: + missing = [ + item for item in REPRESENTATIVE_ARTIFACTS if not _is_regular_file(config.bundle / item) + ] + if missing: + raise ImportError("bundle is missing representative artifacts: " + ", ".join(missing)) + + client = requests.Session() if session is None else session + client.auth = (config.username, config.password) + _ensure_repository(config, client) + + uploaded = skipped = failed = 0 + for path in sorted(item for item in config.bundle.rglob("*") if _is_regular_file(item)): + relative = path.relative_to(config.bundle).as_posix() + url = _artifact_url(config, relative) + response = _request(client, "GET", url, stream=True) + if response.status_code == 404: + response.close() + with path.open("rb") as local: + upload = _request(client, "PUT", url, data=local) + _check_response(upload, {200, 201, 204}, "artifact upload", url) + uploaded += 1 + elif response.status_code == 200: + if _response_matches_file(response, path): + skipped += 1 + else: + failed += 1 + else: + try: + _check_response(response, {200, 404}, "artifact lookup", url) + finally: + response.close() + + for relative in REPRESENTATIVE_ARTIFACTS: + url = _artifact_url(config, relative) + response = _request(client, "GET", url, stream=True) + if response.status_code != 200: + try: + _check_response(response, {200}, "artifact verification", url) + finally: + response.close() + if not _response_matches_file(response, config.bundle / relative): + raise ImportError(f"artifact verification failed for {url}") + return ImportResult(uploaded, skipped, failed) + + +def main(argv: Sequence[str] | None = None, *, output: TextIO = sys.stdout) -> int: + try: + config = load_config(argv) + result = import_bundle(config) + except (ImportError, requests.RequestException, OSError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + print(config.repository_url, file=output) + print( + f"uploaded={result.uploaded} skipped={result.skipped} failed={result.failed}", + file=output, + ) + return 1 if result.failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testing/hack/prepare-maven-e2e-bundle.sh b/testing/hack/prepare-maven-e2e-bundle.sh new file mode 100755 index 00000000..6c0327b2 --- /dev/null +++ b/testing/hack/prepare-maven-e2e-bundle.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $0 " >&2 +} + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +canonical_path() { + local path=$1 + mkdir -p -- "$path" + cd -- "$path" + pwd -P +} + +xml_escape() { + sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' \ + -e "s/'/\\\'/g" +} + +manage_bundle() { + local operation=$1 + python3 - "$bundle" "$operation" <<'PY' +import os +import shutil +import stat +import sys + +bundle, operation = sys.argv[1:] +flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + + +def remove_entry(parent_fd, name): + try: + info = os.lstat(name, dir_fd=parent_fd) + except FileNotFoundError: + return + if stat.S_ISLNK(info.st_mode): + raise RuntimeError(f"unsafe symlink in bundle: {name}") + if stat.S_ISDIR(info.st_mode): + shutil.rmtree(name, dir_fd=parent_fd) + else: + os.unlink(name, dir_fd=parent_fd) + + +def open_directory(parent_fd, name): + return os.open(name, flags, dir_fd=parent_fd) + + +def remove_relative_tree(root_fd, parts): + opened = [] + parent_fd = root_fd + try: + for part in parts[:-1]: + try: + parent_fd = open_directory(parent_fd, part) + except FileNotFoundError: + return + opened.append(parent_fd) + remove_entry(parent_fd, parts[-1]) + finally: + for descriptor in reversed(opened): + os.close(descriptor) + + +def filter_metadata(directory_fd): + with os.scandir(directory_fd) as entries: + for entry in entries: + info = entry.stat(follow_symlinks=False) + if stat.S_ISLNK(info.st_mode): + raise RuntimeError(f"unsafe symlink in Maven repository: {entry.name}") + if stat.S_ISDIR(info.st_mode): + child_fd = open_directory(directory_fd, entry.name) + try: + filter_metadata(child_fd) + finally: + os.close(child_fd) + elif entry.name == "_remote.repositories" or entry.name == "resolver-status.properties" or entry.name.endswith(".lastUpdated"): + os.unlink(entry.name, dir_fd=directory_fd) + + +def verify_regular_file(root_fd, relative): + parts = relative.split("/") + opened = [] + parent_fd = root_fd + try: + for part in parts[:-1]: + parent_fd = open_directory(parent_fd, part) + opened.append(parent_fd) + info = os.stat(parts[-1], dir_fd=parent_fd, follow_symlinks=False) + if not stat.S_ISREG(info.st_mode): + raise RuntimeError(f"missing or unsafe representative artifact: {relative}") + finally: + for descriptor in reversed(opened): + os.close(descriptor) + + +bundle_fd = os.open(bundle, flags) +try: + if operation == "reset": + for child in ("repository", "deployment", "settings.xml"): + remove_entry(bundle_fd, child) + os.mkdir("repository", dir_fd=bundle_fd) + os.mkdir("deployment", dir_fd=bundle_fd) + elif operation == "finalize": + repository_fd = open_directory(bundle_fd, "repository") + try: + filter_metadata(repository_fd) + remove_relative_tree(repository_fd, ["com", "nexus", "test", "test-publish"]) + for artifact in ( + "junit/junit/4.11/junit-4.11.jar", + "org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.jar", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.pom", + ): + verify_regular_file(repository_fd, artifact) + finally: + os.close(repository_fd) + remove_entry(bundle_fd, "deployment") + remove_entry(bundle_fd, "settings.xml") + else: + raise RuntimeError(f"unknown bundle operation: {operation}") +finally: + os.close(bundle_fd) +PY +} + +if [[ $# -ne 2 ]]; then + usage + exit 2 +fi + +project_input=$1 +bundle_input=$2 + +[[ -d "$project_input" ]] || fail "Maven project directory does not exist: $project_input" +[[ -f "$project_input/publish.xml" ]] || fail "Maven project is missing publish.xml" +[[ -f "$project_input/download.xml" ]] || fail "Maven project is missing download.xml" +[[ -n "$bundle_input" ]] || fail "unsafe empty bundle directory" +bundle_probe=$bundle_input +while [[ "$bundle_probe" != "/" && "$bundle_probe" == */ ]]; do + bundle_probe=${bundle_probe%/} +done +[[ ! -L "$bundle_probe" ]] || fail "unsafe symlink bundle directory: $bundle_input" + +project=$(cd -- "$project_input" && pwd -P) +bundle=$(canonical_path "$bundle_input") +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +source_root=$(cd -- "$script_dir/../.." && pwd -P) +case "$bundle" in + /|"$project"|"$source_root") fail "unsafe bundle directory: $bundle" ;; +esac + +repository="$bundle/repository" +deployment="$bundle/deployment" +settings="$bundle/settings.xml" + +manage_bundle reset + +mirror_url=${MAVEN_BUNDLE_MIRROR_URL:-${MACVEN_MIRROR_REGISTRY:-https://artifacts.alauda.io/repository/maven-central}} +[[ "$mirror_url" != *$'\n'* && "$mirror_url" != *$'\r'* ]] || fail "mirror URL contains a newline" +escaped_repository=$(printf '%s' "$repository" | xml_escape) +escaped_mirror=$(printf '%s' "$mirror_url" | xml_escape) + +{ + printf '%s\n' '' + printf '%s\n' '' + printf ' %s\n' "$escaped_repository" + printf '%s\n' ' ' ' ' ' bundle-central' ' central' + printf ' %s\n' "$escaped_mirror" + printf '%s\n' ' ' ' ' '' +} >"$settings" + +common_args=(-s "$settings" "-Dmaven.repo.local=$repository") +deploy_url="file://$deployment" + +mvn "${common_args[@]}" -f "$project/publish.xml" \ + "-DaltDeploymentRepository=bundle::default::$deploy_url" clean deploy +mvn "${common_args[@]}" -f "$project/download.xml" package +mvn -o "${common_args[@]}" -f "$project/publish.xml" clean install +mvn -o "${common_args[@]}" -f "$project/download.xml" package + +manage_bundle finalize diff --git a/testing/hack/run-e2e.sh b/testing/hack/run-e2e.sh index 43eb38b3..1d009ce7 100755 --- a/testing/hack/run-e2e.sh +++ b/testing/hack/run-e2e.sh @@ -5,6 +5,32 @@ export NEXUS_URL=$1 export NEXUS_USERNAME=$2 export NEXUS_PASSWORD=$3 export CASE_PARAM=$4 +# DEVOPS-44489: optional 5th arg, "domain:ip[,domain:ip...]" -- the ingress +# hostname(s) this run's NEXUS_URL (and any proxy/publish target pytest shells +# out to via mvn/npm) needs to resolve. run-test's own container is non-root +# (PSA-restricted, uid 65532) so it can't write /etc/hosts; bdd's in-process +# fake resolver (steps/dns) already covers the framework's own HTTP client +# (network.feature's "已添加域名解析" step feeds it), but mvn/npm are child +# subprocesses os.system()'d from pytest -- those need OS-level resolution. +# nss_wrapper (LD_PRELOAD + NSS_WRAPPER_HOSTS) overrides glibc's NSS hosts +# lookup for this process tree without touching the real /etc/hosts, same +# fix proven for gitlab-chart's git/git-lfs subprocess (testing/steps/ +# gitlab_lfs.go's gitHostEnv, DEVOPS-44461 2026-07-20) -- mvn (JVM) and npm +# (Node) both resolve via glibc getaddrinfo by default on Linux, so no +# GODEBUG=netdns=cgo override is needed here (that's only for Go binaries, +# which default to the pure-Go resolver that ignores nss_wrapper). +EXTRA_HOSTS=$5 +if [ -n "${EXTRA_HOSTS:-}" ] && [ -f /usr/lib/x86_64-linux-gnu/libnss_wrapper.so ]; then + NSS_HOSTS_FILE=$(mktemp) + IFS=',' read -ra HOST_PAIRS <<< "$EXTRA_HOSTS" + for pair in "${HOST_PAIRS[@]}"; do + domain="${pair%%:*}" + ip="${pair##*:}" + echo "${ip} ${domain}" >> "$NSS_HOSTS_FILE" + done + export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libnss_wrapper.so + export NSS_WRAPPER_HOSTS="$NSS_HOSTS_FILE" +fi cd nexus-e2e python -m pytest --alluredir ../allure-results $CASE_PARAM diff --git a/testing/lynx-entrypoint.sh b/testing/lynx-entrypoint.sh new file mode 100755 index 00000000..c406f844 --- /dev/null +++ b/testing/lynx-entrypoint.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=testing/lynx/common.sh +source "$script_dir/lynx/common.sh" +# shellcheck source=testing/lynx/auth.sh +source "$script_dir/lynx/auth.sh" +# shellcheck source=testing/lynx/olm.sh +source "$script_dir/lynx/olm.sh" +# shellcheck source=testing/lynx/e2e.sh +source "$script_dir/lynx/e2e.sh" +# shellcheck source=testing/lynx/diagnostics.sh +source "$script_dir/lynx/diagnostics.sh" + +credential_dir= + +on_exit() { + local status=$? + trap - EXIT + if ((status != 0)) && [[ -n ${RESULT_DIR:-} && -d ${RESULT_DIR:-} ]]; then + collect_diagnostics || log "Failure diagnostics could not be collected" + fi + if [[ -n $credential_dir ]]; then + rm -rf -- "$credential_dir" + fi + exit "$status" +} +trap on_exit EXIT + +require_env API_URL +require_env REGION_NAME +require_env L5_PLUGINS_VERSION +if [[ -z ${TOKEN:-} && (-z ${USERNAME:-} || -z ${PASSWORD:-}) ]]; then + fatal "authentication requires TOKEN or both USERNAME and PASSWORD" +fi + +RESULT_DIR=${RESULT_DIR:-${TEST_RESULT_DIR:-}} +[[ -n $RESULT_DIR ]] || fatal "RESULT_DIR or TEST_RESULT_DIR is required" +export RESULT_DIR +require_positive_integer LYNX_INSTALL_TIMEOUT "${LYNX_INSTALL_TIMEOUT:-900}" +require_positive_integer LYNX_DIAGNOSTICS_TIMEOUT "${LYNX_DIAGNOSTICS_TIMEOUT:-10}" +for command in jq kubectl timeout allure; do + require_command "$command" +done + +mkdir -p "$RESULT_DIR" || fatal "result directory could not be created" +[[ -d $RESULT_DIR && -w $RESULT_DIR ]] || fatal "result directory is not writable" +credential_dir=$(mktemp -d "$RESULT_DIR/.lynx-credentials.XXXXXX") \ + || fatal "temporary credential directory could not be created" +chmod 0700 "$credential_dir" + +access_token=$(resolve_access_token) +LYNX_BDD_CONFIG=$credential_dir/config.yaml +KUBECONFIG=$credential_dir/proxy.kubeconfig +export LYNX_BDD_CONFIG KUBECONFIG +write_proxy_kubeconfig "$KUBECONFIG" "$access_token" +write_bdd_config "$LYNX_BDD_CONFIG" "$access_token" +unset access_token + +install_operator + +set +o errexit +run_e2e +test_status=$? +collect_allure_results +result_status=$? +set -o errexit +generate_allure_report + +if ((test_status != 0)); then + exit "$test_status" +fi +if ((result_status != 0)); then + exit "$result_status" +fi +log "[DONE]" diff --git a/testing/lynx/auth.sh b/testing/lynx/auth.sh new file mode 100644 index 00000000..e5fde470 --- /dev/null +++ b/testing/lynx/auth.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash + +_auth_login_with_password() ( + set +x + set -o pipefail + + local api_url=${API_URL%/} + local request_timeout=${LYNX_HTTP_TIMEOUT:-30} + local workdir + workdir=$(mktemp -d) || fatal "could not create authentication workspace" + trap 'rm -rf -- "$workdir"' EXIT + + local cookie_jar=$workdir/cookies + local public_key=$workdir/public.pem + local curl_options=( + --silent + --show-error + --fail + --connect-timeout "$request_timeout" + --max-time "$request_timeout" + --cookie "$cookie_jar" + --cookie-jar "$cookie_jar" + ) + if [[ -n ${LYNX_CA_BUNDLE:-} ]]; then + curl_options+=(--cacert "$LYNX_CA_BUNDLE") + elif [[ ${LYNX_TLS_INSECURE:-} == true ]]; then + curl_options+=(--insecure) + fi + local login_json auth_url authorize_json request_id + local pubkey_json password_payload encrypted_password credentials_json + local local_login_json redirect_url code state callback_json token + local -a auth_parameters=() + + login_json=$(curl "${curl_options[@]}" --get \ + --data-urlencode "redirect_uri=$api_url/console-platform" \ + "$api_url/console-platform/api/v1/token/login" 2>/dev/null) \ + || fatal "ACP token login request failed" + auth_url=$(printf '%s' "$login_json" | jq -er '.auth_url | select(type == "string" and length > 0)' 2>/dev/null) \ + || fatal "ACP token login response is invalid" + mapfile -d '' -t auth_parameters < <(python3 -c ' +import sys +from urllib.parse import parse_qsl, urlsplit + +for key, value in parse_qsl(urlsplit(sys.argv[1]).query, keep_blank_values=True): + sys.stdout.buffer.write(f"{key}={value}".encode() + b"\0") +' "$auth_url") + ((${#auth_parameters[@]} > 0)) || fatal "ACP token login response is invalid" + + local -a authorize_options=(--get) + local parameter + for parameter in "${auth_parameters[@]}"; do + authorize_options+=(--data-urlencode "$parameter") + done + authorize_json=$(curl "${curl_options[@]}" "${authorize_options[@]}" \ + "$api_url/dex/api/v1/authorize" 2>/dev/null) \ + || fatal "ACP Dex authorization request failed" + request_id=$(printf '%s' "$authorize_json" | jq -er '.req | select(type == "string" and length > 0)' 2>/dev/null) \ + || fatal "ACP Dex authorization response is invalid" + + pubkey_json=$(curl "${curl_options[@]}" "$api_url/dex/pubkey" 2>/dev/null) \ + || fatal "ACP Dex public key request failed" + printf '%s' "$pubkey_json" | jq -er '.pubkey | select(type == "string" and length > 0)' \ + >"$public_key" 2>/dev/null || fatal "ACP Dex public key response is invalid" + password_payload=$(printf '%s' "$pubkey_json" | jq -cer --arg password "$PASSWORD" \ + '{ts: .ts, password: $password} | select(.ts != null)' 2>/dev/null) \ + || fatal "ACP Dex public key response is invalid" + encrypted_password=$(printf '%s' "$password_payload" \ + | openssl pkeyutl -encrypt -pubin -inkey "$public_key" \ + -pkeyopt rsa_padding_mode:pkcs1 \ + | openssl base64 -A) || fatal "ACP password encryption failed" + [[ -n $encrypted_password ]] || fatal "ACP password encryption failed" + + credentials_json=$(jq -cn --arg account "$USERNAME" --arg password "$encrypted_password" \ + '{account: $account, password: $password}') \ + || fatal "ACP login request could not be created" + local_login_json=$(printf '%s' "$credentials_json" | curl "${curl_options[@]}" \ + --url-query "req=$request_id" \ + --request POST --header 'Content-Type: application/json' --data-binary @- \ + "$api_url/dex/api/v1/authorize/local" 2>/dev/null) \ + || fatal "ACP identity provider login failed" + redirect_url=$(printf '%s' "$local_login_json" | jq -er \ + '.redirect_url | select(type == "string" and length > 0)' 2>/dev/null) \ + || fatal "ACP identity provider response is invalid" + code=$(python3 -c ' +import sys +from urllib.parse import parse_qs, urlsplit + +values = parse_qs(urlsplit(sys.argv[1]).query, keep_blank_values=True).get("code", []) +if len(values) != 1 or not values[0]: + raise SystemExit(1) +sys.stdout.write(values[0]) +' "$redirect_url" 2>/dev/null) || fatal "ACP identity provider response is invalid" + state=$(python3 -c ' +import sys +from urllib.parse import parse_qs, urlsplit + +values = parse_qs(urlsplit(sys.argv[1]).query, keep_blank_values=True).get("state", []) +if len(values) != 1 or not values[0]: + raise SystemExit(1) +sys.stdout.write(values[0]) +' "$redirect_url" 2>/dev/null) || fatal "ACP identity provider response is invalid" + [[ -n $code && -n $state ]] || fatal "ACP identity provider response is invalid" + + callback_json=$(curl "${curl_options[@]}" --get \ + --data-urlencode "code=$code" --data-urlencode "state=$state" \ + "$api_url/console-platform/api/v1/token/callback" 2>/dev/null) \ + || fatal "ACP token callback request failed" + token=$(printf '%s' "$callback_json" | jq -er \ + '.id_token | select(type == "string" and length > 0)' 2>/dev/null) \ + || fatal "ACP token callback response is invalid" + printf '%s' "$token" +) + +resolve_access_token() { + set +x + if [[ -n ${TOKEN:-} ]]; then + printf '%s' "$TOKEN" + return + fi + + [[ -n ${USERNAME:-} && -n ${PASSWORD:-} ]] \ + || fatal "authentication requires TOKEN or both USERNAME and PASSWORD" + require_env API_URL + require_command curl + require_command jq + require_command openssl + require_command python3 + require_positive_integer LYNX_HTTP_TIMEOUT "${LYNX_HTTP_TIMEOUT:-30}" + if [[ ${LYNX_TLS_INSECURE+x} ]]; then + [[ $LYNX_TLS_INSECURE == true || $LYNX_TLS_INSECURE == false ]] \ + || fatal "LYNX_TLS_INSECURE must be true or false when set" + fi + [[ -z ${LYNX_CA_BUNDLE:-} || ${LYNX_TLS_INSECURE:-} != true ]] \ + || fatal "LYNX_CA_BUNDLE and LYNX_TLS_INSECURE cannot be used together" + _auth_login_with_password +} + +write_proxy_kubeconfig() ( + set +x + local destination=$1 + local token=$2 + local api_url=${API_URL%/} + local temporary= + + cleanup_proxy_kubeconfig_temp() { + [[ -z $temporary ]] || rm -f -- "$temporary" + } + trap cleanup_proxy_kubeconfig_temp EXIT INT TERM + + require_env API_URL + require_env REGION_NAME + require_command jq + umask 077 + temporary=$(mktemp "${destination}.tmp.XXXXXX") \ + || fatal "could not create proxy kubeconfig temporary file" + jq -n --arg server "$api_url/kubernetes/$REGION_NAME" --arg token "$token" ' + { + apiVersion: "v1", + kind: "Config", + clusters: [{name: "target", cluster: {server: $server}}], + users: [{name: "target", user: {token: $token}}], + contexts: [{name: "target", context: {cluster: "target", user: "target"}}], + "current-context": "target" + } + ' >"$temporary" || fatal "could not write proxy kubeconfig" + chmod 0600 "$temporary" || fatal "could not protect proxy kubeconfig" + mv -f -- "$temporary" "$destination" || fatal "could not install proxy kubeconfig" + temporary= +) + +write_bdd_config() ( + set +x + local destination=$1 + local token=$2 + local api_url=${API_URL%/} + local temporary= + + cleanup_bdd_config_temp() { + [[ -z $temporary ]] || rm -f -- "$temporary" + } + trap cleanup_bdd_config_temp EXIT INT TERM + + require_env API_URL + require_env REGION_NAME + require_command jq + umask 077 + temporary=$(mktemp "${destination}.tmp.XXXXXX") \ + || fatal "could not create BDD configuration temporary file" + jq -n --arg base_url "$api_url" --arg token "$token" --arg cluster "$REGION_NAME" ' + {acp: {baseUrl: $base_url, token: $token, cluster: $cluster}} + ' >"$temporary" || fatal "could not write BDD configuration" + chmod 0600 "$temporary" || fatal "could not protect BDD configuration" + mv -f -- "$temporary" "$destination" || fatal "could not install BDD configuration" + temporary= +) diff --git a/testing/lynx/common.sh b/testing/lynx/common.sh new file mode 100644 index 00000000..77c0b851 --- /dev/null +++ b/testing/lynx/common.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +log() { + printf '[%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*" >&2 +} + +fatal() { + log "ERROR: $*" + exit 1 +} + +require_env() { + local name=$1 + + [[ -n ${!name:-} ]] || fatal "required environment variable ${name} is not set" +} + +require_command() { + local name=$1 + + command -v -- "$name" >/dev/null 2>&1 || fatal "required command ${name} was not found" +} + +require_positive_integer() { + local name=$1 + local value=$2 + + if [[ ! $value =~ ^[0-9]+$ || ! $value =~ [1-9] ]]; then + fatal "${name} must be a positive integer" + fi +} + +listed_operator_version() { + local version + + if ! version=$(printf '%s' "${L5_PLUGINS_VERSION:-}" | + jq -er ' + if type == "object" + and (.["nexus-ce-operator"] | type) == "string" + and (.["nexus-ce-operator"] | length) > 0 + then .["nexus-ce-operator"] + else error("missing operator version") + end + ' 2>/dev/null); then + log "ERROR: nexus-ce-operator version is missing from L5_PLUGINS_VERSION" + return 1 + fi + + printf '%s\n' "$version" +} + +wait_for_value() { + local description=$1 + local expected=$2 + local timeout_seconds=$3 + shift 3 + + local poll_interval=${LYNX_POLL_INTERVAL-5} + local heartbeat_interval=${LYNX_WAIT_HEARTBEAT-30} + require_positive_integer LYNX_POLL_INTERVAL "$poll_interval" + require_positive_integer LYNX_WAIT_HEARTBEAT "$heartbeat_interval" + require_positive_integer timeout "$timeout_seconds" + poll_interval=$((10#$poll_interval)) + heartbeat_interval=$((10#$heartbeat_interval)) + timeout_seconds=$((10#$timeout_seconds)) + require_command timeout + + local started_at=$SECONDS + local now next_heartbeat current remaining sleep_for + next_heartbeat=$started_at + + while :; do + now=$SECONDS + remaining=$((timeout_seconds - (now - started_at))) + if ((remaining <= 0)); then + log "Timed out after ${timeout_seconds}s waiting for ${description}" + return 1 + fi + + current=$(timeout "${remaining}s" "$@" 2>/dev/null) || current= + if [[ $current == "$expected" ]]; then + return 0 + fi + + now=$SECONDS + remaining=$((timeout_seconds - (now - started_at))) + if ((remaining <= 0)); then + log "Timed out after ${timeout_seconds}s waiting for ${description}" + return 1 + fi + + if ((now >= next_heartbeat)); then + log "Waiting for ${description} (${now-started_at}s elapsed)" + next_heartbeat=$((now + heartbeat_interval)) + fi + sleep_for=$poll_interval + if ((sleep_for > remaining)); then + sleep_for=$remaining + fi + sleep "$sleep_for" + done +} diff --git a/testing/lynx/diagnostics.sh b/testing/lynx/diagnostics.sh new file mode 100644 index 00000000..3763cdcc --- /dev/null +++ b/testing/lynx/diagnostics.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +_mask_diagnostic_output() { + awk ' + { + lower = tolower($0) + if (lower ~ /(token|password|credential|authorization)/) next + if ((index(lower, "http://") || index(lower, "https://")) && lower ~ /:[^@[:space:]]+@/) next + print + } + ' +} + +collect_diagnostics() { + local namespace=${OPERATOR_NAMESPACE:-nexus-ce-operator} + local result_dir=${RESULT_DIR:?RESULT_DIR is required} + local request_timeout=${LYNX_DIAGNOSTICS_TIMEOUT:-10} + local output=$result_dir/diagnostics.log + local stage + local collection_status=0 + + require_positive_integer LYNX_DIAGNOSTICS_TIMEOUT "$request_timeout" + mkdir -p "$result_dir" || return 1 + [[ ! -L $output ]] || { + log "ERROR: diagnostics destination must not be a symbolic link" + return 1 + } + stage=$(mktemp "$result_dir/.diagnostics.tmp.XXXXXX") || return 1 + + _diagnostic_query() { + local title=$1 + shift + { + printf '## %s\n' "$title" + timeout "${request_timeout}s" kubectl --request-timeout="${request_timeout}s" "$@" 2>/dev/null || true + printf '\n' + } | _mask_diagnostic_output >>"$stage" + } + + _diagnostic_query "OLM subscriptions" get subscriptions -n "$namespace" \ + -o 'custom-columns=NAME:.metadata.name,CURRENT:.status.currentCSV,INSTALLED:.status.installedCSV,STATE:.status.state' || collection_status=1 + _diagnostic_query "OLM install plans" get installplans -n "$namespace" \ + -o 'custom-columns=NAME:.metadata.name,PHASE:.status.phase' || collection_status=1 + _diagnostic_query "OLM CSVs" get clusterserviceversions -n "$namespace" \ + -o 'custom-columns=NAME:.metadata.name,PHASE:.status.phase,REASON:.status.reason' || collection_status=1 + _diagnostic_query "Deployments" get deployments -n "$namespace" \ + -o 'custom-columns=NAME:.metadata.name,READY:.status.readyReplicas,AVAILABLE:.status.availableReplicas,UNAVAILABLE:.status.unavailableReplicas' || collection_status=1 + _diagnostic_query "Pods" get pods -n "$namespace" \ + -o 'custom-columns=NAME:.metadata.name,PHASE:.status.phase,REASON:.status.reason' || collection_status=1 + _diagnostic_query "Events" get events -n "$namespace" --sort-by=.lastTimestamp \ + -o 'custom-columns=NAMESPACE:.metadata.namespace,LAST:.lastTimestamp,EVENT:.eventTime,COUNT:.count,TYPE:.type,REASON:.reason,KIND:.involvedObject.kind,OBJECT:.involvedObject.name' || collection_status=1 + + if ((collection_status != 0)); then + rm -f -- "$stage" + return 1 + fi + + if ! mv -- "$stage" "$output"; then + rm -f -- "$stage" + return 1 + fi +} diff --git a/testing/lynx/e2e.sh b/testing/lynx/e2e.sh new file mode 100644 index 00000000..6d4c63e1 --- /dev/null +++ b/testing/lynx/e2e.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +run_e2e() { + local testing_dir=${LYNX_TESTING_DIR:-/app/testing} + local config=${LYNX_BDD_CONFIG:-${E2E_CONFIG:-}} + local tags=${LYNX_E2E_TAGS:-@e2e} + local run_dir=$testing_dir + local temporary_run_dir= + local test_command=nexus.test + local test_status raw_copy copy_status=0 result_dir + + [[ -n $config ]] || { + log "ERROR: E2E configuration path is not set" + return 1 + } + [[ -f $config ]] || { + log "ERROR: E2E configuration file does not exist" + return 1 + } + + if [[ ! -w $testing_dir ]]; then + temporary_run_dir=$(mktemp -d) || return 1 + cp -R "$testing_dir"/. "$temporary_run_dir"/ || { + rm -rf -- "$temporary_run_dir" + return 1 + } + run_dir=$temporary_run_dir + fi + + if [[ -x $run_dir/nexus.test ]]; then + test_command=./nexus.test + fi + ( + cd "$run_dir" || exit 1 + E2E_CONFIG=$config "$test_command" \ + --godog.concurrency=2 \ + --godog.format=allure \ + "--godog.tags=${tags}" + ) + test_status=$? + + if [[ -n $temporary_run_dir ]]; then + if [[ -d $run_dir/allure-results ]]; then + result_dir=${RESULT_DIR:-${TEST_RESULT_DIR:-}} + if [[ -z $result_dir ]] || ! mkdir -p "$result_dir"; then + copy_status=1 + elif raw_copy=$(mktemp -d "$result_dir/.lynx-raw-allure.XXXXXX"); then + if cp -R "$run_dir/allure-results"/. "$raw_copy"/; then + LYNX_RAW_ALLURE_DIR=$raw_copy + LYNX_RAW_ALLURE_TEMP=true + export LYNX_RAW_ALLURE_DIR LYNX_RAW_ALLURE_TEMP + else + rm -rf -- "$raw_copy" + copy_status=1 + fi + else + copy_status=1 + fi + fi + rm -rf -- "$temporary_run_dir" + else + LYNX_RAW_ALLURE_DIR=${LYNX_RAW_ALLURE_DIR:-$run_dir/allure-results} + export LYNX_RAW_ALLURE_DIR + fi + + ((test_status != 0)) && return "$test_status" + return "$copy_status" +} + +collect_allure_results() { + local raw_dir=${LYNX_RAW_ALLURE_DIR:-${LYNX_TESTING_DIR:-/app/testing}/allure-results} + local result_dir=${RESULT_DIR:?RESULT_DIR is required} + local destination=$result_dir/allure-result + local stage backup status=0 cleanup_raw=false + + [[ ${LYNX_RAW_ALLURE_TEMP:-false} == true && $raw_dir == "$result_dir"/.lynx-raw-allure.* ]] && cleanup_raw=true + + if [[ ! -d $raw_dir ]] || ! find "$raw_dir" -type f -print -quit | grep -q .; then + log "ERROR: raw Allure results are empty" + status=1 + elif ! mkdir -p "$result_dir"; then + status=1 + elif [[ -L $destination ]]; then + log "ERROR: Allure result destination must not be a symbolic link" + status=1 + elif ! stage=$(mktemp -d "$result_dir/.allure-result.tmp.XXXXXX"); then + status=1 + elif ! cp -R "$raw_dir"/. "$stage"/; then + rm -rf -- "$stage" + status=1 + else + backup=$result_dir/.allure-result.old.$$ + if [[ -e $destination ]]; then + if [[ -e $backup ]] || ! mv -- "$destination" "$backup"; then + rm -rf -- "$stage" + status=1 + fi + fi + if ((status == 0)) && ! mv -- "$stage" "$destination"; then + [[ -e $backup ]] && mv -- "$backup" "$destination" || true + rm -rf -- "$stage" + status=1 + fi + [[ -e $backup ]] && rm -rf -- "$backup" + fi + + if [[ $cleanup_raw == true ]]; then + rm -rf -- "$raw_dir" + unset LYNX_RAW_ALLURE_DIR LYNX_RAW_ALLURE_TEMP + fi + return "$status" +} + +generate_allure_report() { + local results=${RESULT_DIR:?RESULT_DIR is required}/allure-result + local report=${RESULT_DIR}/allure-report + + if [[ ! -d $results ]] || ! find "$results" -type f -print -quit | grep -q .; then + log "Allure report generation skipped because normalized results are empty" + return 0 + fi + if [[ -L $report ]]; then + log "ERROR: Allure report destination must not be a symbolic link" + return 1 + fi + + allure generate "$results" --clean -o "$report" || { + log "Allure report generation failed; raw results were retained" + return 0 + } +} diff --git a/testing/lynx/olm.sh b/testing/lynx/olm.sh new file mode 100644 index 00000000..834ed9d7 --- /dev/null +++ b/testing/lynx/olm.sh @@ -0,0 +1,325 @@ +#!/usr/bin/env bash + +_olm_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=testing/lynx/common.sh +source "${_olm_dir}/common.sh" +unset _olm_dir + +OPERATOR_PACKAGE=${OPERATOR_PACKAGE:-nexus-ce-operator} +OPERATOR_NAMESPACE=${OPERATOR_NAMESPACE:-nexus-ce-operator} +OPERATOR_CHANNEL=${OPERATOR_CHANNEL:-stable} +INSTALL_TIMEOUT=${INSTALL_TIMEOUT:-${LYNX_INSTALL_TIMEOUT:-900}} +export OPERATOR_PACKAGE OPERATOR_NAMESPACE OPERATOR_CHANNEL INSTALL_TIMEOUT + +_olm_timeout() { + require_positive_integer INSTALL_TIMEOUT "$INSTALL_TIMEOUT" + printf '%d\n' "$((10#$INSTALL_TIMEOUT))" +} + +_olm_kubectl_for() { + local request_timeout=$1 + shift + timeout "${request_timeout}s" kubectl "$@" +} + +_olm_kubectl() { + local request_timeout + request_timeout=$(_olm_timeout) || return 1 + _olm_kubectl_for "$request_timeout" "$@" +} + +_olm_wait_value() { + local description=$1 expected=$2 timeout_seconds=$3 + shift 3 + local started=$SECONDS current remaining sleep_for probe_timeout + local poll_interval=${LYNX_POLL_INTERVAL:-5} + local heartbeat_interval=${LYNX_WAIT_HEARTBEAT:-30} + local next_heartbeat=$SECONDS + require_positive_integer LYNX_POLL_INTERVAL "$poll_interval" + require_positive_integer LYNX_WAIT_HEARTBEAT "$heartbeat_interval" + require_positive_integer timeout "$timeout_seconds" + require_command timeout + poll_interval=$((10#$poll_interval)) + heartbeat_interval=$((10#$heartbeat_interval)) + timeout_seconds=$((10#$timeout_seconds)) + while :; do + remaining=$((timeout_seconds - (SECONDS - started))) + ((remaining > 0)) || { log "Timed out after ${timeout_seconds}s waiting for ${description}"; return 1; } + if ((SECONDS >= next_heartbeat)); then + log "Waiting for ${description} (${SECONDS-started}s elapsed)" + next_heartbeat=$((SECONDS + heartbeat_interval)) + fi + probe_timeout=$((next_heartbeat - SECONDS)) + ((probe_timeout < 1)) && probe_timeout=1 + ((probe_timeout > remaining)) && probe_timeout=$remaining + current=$(_olm_timed_probe "$probe_timeout" "$@") || current= + [[ $current == "$expected" ]] && return 0 + remaining=$((timeout_seconds - (SECONDS - started))) + ((remaining > 0)) || { log "Timed out after ${timeout_seconds}s waiting for ${description}"; return 1; } + sleep_for=$poll_interval + ((sleep_for > remaining)) && sleep_for=$remaining + sleep "$sleep_for" + done +} + +_olm_timed_probe() { + local remaining=$1 probe=$2 + shift 2 + # shellcheck disable=SC2163 + export -f "$probe" _olm_kubectl _olm_kubectl_for _olm_timeout + timeout "${remaining}s" bash -c '"$@"' bash "$probe" "$@" +} + +resolve_operator_catalog() { + local manifests resolved + manifests=$(_olm_kubectl get packagemanifests -A -o json) || { + log "ERROR: failed to list OLM PackageManifests" + return 1 + } + resolved=$(printf '%s' "$manifests" | jq -er \ + --arg package "$OPERATOR_PACKAGE" --arg channel "$OPERATOR_CHANNEL" ' + [.items[] | select(.metadata.name == $package)] as $matches + | if ($matches | length) != 1 then error("package must resolve exactly once") else $matches[0] end + | . as $manifest + | [.status.channels[]? | select(.name == $channel)] as $channels + | if ($channels | length) != 1 then error("channel must resolve exactly once") else $channels[0] end + | [$channels[0].currentCSV, $manifest.status.catalogSource, + $manifest.status.catalogSourceNamespace] + | if any(.[]; type != "string" or length == 0) then error("incomplete catalog data") else @tsv end + ' 2>/dev/null) || { + log "ERROR: package ${OPERATOR_PACKAGE} channel ${OPERATOR_CHANNEL} could not be resolved uniquely" + return 1 + } + IFS=$'\t' read -r OPERATOR_CSV CATALOG_SOURCE CATALOG_NAMESPACE <<<"$resolved" + export OPERATOR_CSV CATALOG_SOURCE CATALOG_NAMESPACE +} + +_catalog_ready() { + _olm_kubectl get catalogsource "$CATALOG_SOURCE" -n "$CATALOG_NAMESPACE" -o json 2>/dev/null | + jq -r '.status.connectionState.lastObservedState // ""' +} + +ensure_operator_group() { + local groups count valid + groups=$(_olm_kubectl get operatorgroups -n "$OPERATOR_NAMESPACE" -o json) || return 1 + count=$(printf '%s' "$groups" | jq '.items | length') || return 1 + case $count in + 0) + _olm_kubectl apply -f - </dev/null); then + log "ERROR: failed to inspect existing Subscription" + return 1 + fi + if [[ -n $existing ]]; then + compatible=$(printf '%s' "$existing" | jq -r \ + --arg package "$OPERATOR_PACKAGE" --arg source "$CATALOG_SOURCE" \ + --arg source_ns "$CATALOG_NAMESPACE" --arg channel "$OPERATOR_CHANNEL" \ + --arg csv "$OPERATOR_CSV" ' + .spec.name == $package and .spec.source == $source + and .spec.sourceNamespace == $source_ns and .spec.channel == $channel + and .spec.startingCSV == $csv and .spec.installPlanApproval == "Manual"') || return 1 + [[ $compatible == true ]] || { + log "ERROR: existing Subscription is incompatible with the requested operator" + return 1 + } + fi + + _olm_kubectl apply -f - </dev/null) || { + printf 'waiting\n' + return + } + terminal=$(printf '%s' "$subscription" | jq -r ' + [.status.conditions[]? + | select(.status == "True" and (.type == "ResolutionFailed" or .type == "CatalogSourcesUnhealthy")) + | .type] | first // ""') || return 1 + if [[ -n $terminal ]]; then + printf 'terminal:%s\n' "$terminal" + return + fi + ref=$(printf '%s' "$subscription" | jq -r '.status.installPlanRef.name // ""') || return 1 + if [[ -n $ref ]]; then printf 'ready:%s\n' "$ref"; else printf 'waiting\n'; fi +} + +wait_for_install_plan() { + local timeout_seconds started state install_plan phase remaining sleep_for probe_timeout + local poll_interval=${LYNX_POLL_INTERVAL:-5} + local heartbeat_interval=${LYNX_WAIT_HEARTBEAT:-30} + local next_heartbeat=$SECONDS + timeout_seconds=$(_olm_timeout) || return 1 + require_positive_integer LYNX_POLL_INTERVAL "$poll_interval" + require_positive_integer LYNX_WAIT_HEARTBEAT "$heartbeat_interval" + require_command timeout + poll_interval=$((10#$poll_interval)) + heartbeat_interval=$((10#$heartbeat_interval)) + started=$SECONDS + while :; do + remaining=$((timeout_seconds - (SECONDS - started))) + ((remaining > 0)) || { log "Timed out after ${timeout_seconds}s waiting for InstallPlan reference"; return 1; } + if ((SECONDS >= next_heartbeat)); then + log "Waiting for InstallPlan reference (${SECONDS-started}s elapsed)" + next_heartbeat=$((SECONDS + heartbeat_interval)) + fi + probe_timeout=$((next_heartbeat - SECONDS)) + ((probe_timeout < 1)) && probe_timeout=1 + ((probe_timeout > remaining)) && probe_timeout=$remaining + state=$(_olm_timed_probe "$probe_timeout" _subscription_state) || state=waiting + case $state in + terminal:*) log "ERROR: Subscription reported ${state#terminal:}"; return 1 ;; + ready:*) install_plan=${state#ready:}; break ;; + esac + remaining=$((timeout_seconds - (SECONDS - started))) + ((remaining > 0)) || { log "Timed out after ${timeout_seconds}s waiting for InstallPlan reference"; return 1; } + sleep_for=$poll_interval + ((sleep_for > remaining)) && sleep_for=$remaining + sleep "$sleep_for" + done + remaining=$((timeout_seconds - (SECONDS - started))) + ((remaining > 0)) || { log "Timed out after ${timeout_seconds}s before InstallPlan approval"; return 1; } + _olm_kubectl_for "$remaining" patch installplan "$install_plan" -n "$OPERATOR_NAMESPACE" \ + --type merge -p '{"spec":{"approved":true}}' >/dev/null || { + log "ERROR: failed to approve InstallPlan within the installation deadline" + return 1 + } + next_heartbeat=$SECONDS + while :; do + remaining=$((timeout_seconds - (SECONDS - started))) + ((remaining > 0)) || { log "Timed out after ${timeout_seconds}s waiting for InstallPlan completion"; return 1; } + if ((SECONDS >= next_heartbeat)); then + log "Waiting for InstallPlan completion (${SECONDS-started}s elapsed)" + next_heartbeat=$((SECONDS + heartbeat_interval)) + fi + probe_timeout=$((next_heartbeat - SECONDS)) + ((probe_timeout < 1)) && probe_timeout=1 + ((probe_timeout > remaining)) && probe_timeout=$remaining + state=$(_olm_timed_probe "$probe_timeout" _subscription_state) || state=waiting + case $state in terminal:*) log "ERROR: Subscription reported ${state#terminal:}"; return 1 ;; esac + remaining=$((timeout_seconds - (SECONDS - started))) + ((remaining > 0)) || { log "Timed out after ${timeout_seconds}s waiting for InstallPlan completion"; return 1; } + probe_timeout=$((next_heartbeat - SECONDS)) + ((probe_timeout < 1)) && probe_timeout=1 + ((probe_timeout > remaining)) && probe_timeout=$remaining + phase=$(_olm_kubectl_for "$probe_timeout" get installplan "$install_plan" -n "$OPERATOR_NAMESPACE" -o json 2>/dev/null | + jq -r '.status.phase // ""') + [[ $phase == Complete ]] && return 0 + [[ $phase == Failed ]] && { log "ERROR: InstallPlan failed"; return 1; } + remaining=$((timeout_seconds - (SECONDS - started))) + ((remaining > 0)) || { log "Timed out after ${timeout_seconds}s waiting for InstallPlan completion"; return 1; } + sleep_for=$poll_interval + ((sleep_for > remaining)) && sleep_for=$remaining + sleep "$sleep_for" + done +} + +_csv_phase() { + _olm_kubectl get clusterserviceversion "$OPERATOR_CSV" -n "$OPERATOR_NAMESPACE" -o json 2>/dev/null | + jq -r '.status.phase // ""' +} + +wait_for_csv() { + local timeout_seconds + timeout_seconds=$(_olm_timeout) || return 1 + _olm_wait_value "CSV ${OPERATOR_CSV} to succeed" Succeeded "$timeout_seconds" _csv_phase +} + +wait_for_deployment() { + local csv deployments deployment timeout_seconds + csv=$(_olm_kubectl get clusterserviceversion "$OPERATOR_CSV" -n "$OPERATOR_NAMESPACE" -o json) || return 1 + deployments=$(printf '%s' "$csv" | jq -r '.spec.install.spec.deployments // [] | length') || return 1 + [[ $deployments == 1 ]] || { + log "ERROR: expected the CSV to own exactly one Deployment" + return 1 + } + deployment=$(printf '%s' "$csv" | jq -r '.spec.install.spec.deployments[0].name') || return 1 + OLM_DEPLOYMENT=$deployment + export OLM_DEPLOYMENT + # Invoked indirectly by _olm_wait_value. + # shellcheck disable=SC2329 + _deployment_available() { + _olm_kubectl get deployment "$OLM_DEPLOYMENT" -n "$OPERATOR_NAMESPACE" -o json 2>/dev/null | + jq -r 'any(.status.conditions[]?; .type == "Available" and .status == "True")' + } + timeout_seconds=$(_olm_timeout) || return 1 + _olm_wait_value "Deployment ${deployment} to become Available" true "$timeout_seconds" _deployment_available +} + +_nexus_crd_ready() { + local crd conditions served discovered + crd=$(_olm_kubectl get crd nexuses.operator.alaudadevops.io -o json 2>/dev/null) || { printf 'false\n'; return; } + conditions=$(printf '%s' "$crd" | jq -r ' + any(.status.conditions[]?; .type == "Established" and .status == "True") + and any(.status.conditions[]?; .type == "NamesAccepted" and .status == "True")') || return 1 + served=$(printf '%s' "$crd" | jq -r 'any(.spec.versions[]?; .name == "v1alpha1" and .served == true)') || return 1 + discovered=$(_olm_kubectl api-resources --api-group=operator.alaudadevops.io -o name 2>/dev/null | + awk '$0 == "nexuses.operator.alaudadevops.io" { found=1 } END { print found ? "true" : "false" }') + [[ $conditions == true && $served == true && $discovered == true ]] && printf 'true\n' || printf 'false\n' +} + +wait_for_nexus_crd() { + local timeout_seconds + timeout_seconds=$(_olm_timeout) || return 1 + _olm_wait_value "Nexus v1alpha1 CRD discovery" true "$timeout_seconds" _nexus_crd_ready +} + +install_operator() { + local timeout_seconds namespace_yaml + require_command kubectl + require_command jq + timeout_seconds=$(_olm_timeout) || return 1 + namespace_yaml=$(_olm_kubectl create namespace "$OPERATOR_NAMESPACE" --dry-run=client -o yaml) || return 1 + printf '%s\n' "$namespace_yaml" | _olm_kubectl apply -f - >/dev/null || return 1 + resolve_operator_catalog || return 1 + _olm_wait_value "CatalogSource ${CATALOG_SOURCE} readiness" READY "$timeout_seconds" _catalog_ready || return 1 + ensure_operator_group || return 1 + ensure_subscription || return 1 + if [[ $(_csv_phase) != Succeeded ]]; then + wait_for_install_plan || return 1 + wait_for_csv || return 1 + fi + wait_for_deployment || return 1 + wait_for_nexus_crd +} diff --git a/testing/nexus-e2e/conftest.py b/testing/nexus-e2e/conftest.py index 4829effc..a07e555d 100644 --- a/testing/nexus-e2e/conftest.py +++ b/testing/nexus-e2e/conftest.py @@ -1,7 +1,9 @@ import os from dataclasses import dataclass +from typing import Optional import pytest from dotenv import load_dotenv +from libs.maven_upstream import MavenUpstreamConfig, load_maven_upstream from libs.nexus_client import NexusClient load_dotenv() @@ -20,6 +22,10 @@ def nexus_config() -> NexusConfig: password=os.getenv("NEXUS_PASSWORD", "") ) +@pytest.fixture(scope="session") +def maven_upstream_config() -> Optional[MavenUpstreamConfig]: + return load_maven_upstream(os.environ) + @pytest.fixture(scope="session") def nexus_client(nexus_config: NexusConfig) -> NexusClient: """创建Nexus客户端实例""" diff --git a/testing/nexus-e2e/libs/maven_upstream.py b/testing/nexus-e2e/libs/maven_upstream.py new file mode 100644 index 00000000..a8326587 --- /dev/null +++ b/testing/nexus-e2e/libs/maven_upstream.py @@ -0,0 +1,86 @@ +import re +from dataclasses import dataclass, field +from typing import Mapping, Optional +from urllib.parse import urlsplit + + +DEFAULT_REPOSITORY = "maven-e2e-external" +DEFAULT_MAVEN_MIRROR = "https://artifacts.alauda.io/repository/maven-central" + + +@dataclass(frozen=True) +class MavenProxyRemote: + url: str + username: Optional[str] = None + password: Optional[str] = field(default=None, repr=False) + + +@dataclass(frozen=True) +class MavenUpstreamConfig: + url: str + repository: str + username: str + password: str = field(repr=False) + + @property + def repository_url(self) -> str: + return f"{self.url}/repository/{self.repository}/" + + +def load_maven_upstream( + environment: Mapping[str, str], +) -> Optional[MavenUpstreamConfig]: + url = environment.get("MAVEN_UPSTREAM_URL", "").strip().rstrip("/") + if not url: + return None + + try: + parsed_url = urlsplit(url) + invalid_url = ( + parsed_url.scheme not in {"http", "https"} + or not parsed_url.netloc + or parsed_url.username is not None + or parsed_url.password is not None + or parsed_url.query + or parsed_url.fragment + ) + except ValueError: + invalid_url = True + if invalid_url: + raise ValueError("MAVEN_UPSTREAM_URL must be an HTTP(S) base URL") + + repository = environment.get( + "MAVEN_UPSTREAM_REPOSITORY", DEFAULT_REPOSITORY + ).strip() + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repository): + raise ValueError("MAVEN_UPSTREAM_REPOSITORY contains invalid characters") + + username = environment.get("MAVEN_UPSTREAM_USERNAME", "") + if not username: + raise ValueError("MAVEN_UPSTREAM_USERNAME is required when MAVEN_UPSTREAM_URL is set") + + password = environment.get("MAVEN_UPSTREAM_PASSWORD", "") + if not password: + raise ValueError("MAVEN_UPSTREAM_PASSWORD is required when MAVEN_UPSTREAM_URL is set") + + return MavenUpstreamConfig( + url=url, + repository=repository, + username=username, + password=password, + ) + + +def select_proxy_remote(environment: Mapping[str, str]) -> MavenProxyRemote: + upstream = load_maven_upstream(environment) + if upstream is not None: + return MavenProxyRemote( + url=upstream.repository_url, + username=upstream.username, + password=upstream.password, + ) + + legacy_url = environment.get("MACVEN_MIRROR_REGISTRY", "").strip() + if not legacy_url: + legacy_url = DEFAULT_MAVEN_MIRROR + return MavenProxyRemote(url=f"{legacy_url.rstrip('/')}/") diff --git a/testing/nexus-e2e/libs/nexus_client.py b/testing/nexus-e2e/libs/nexus_client.py index ae628ead..1830d18a 100644 --- a/testing/nexus-e2e/libs/nexus_client.py +++ b/testing/nexus-e2e/libs/nexus_client.py @@ -3,7 +3,14 @@ from urllib.parse import urljoin -def _get_repository_config(repo_format, repo_name, repo_type="hosted", remote_url=None): +def _get_repository_config( + repo_format, + repo_name, + repo_type="hosted", + remote_url=None, + remote_username=None, + remote_password=None, +): base_config = { "name": repo_name, "online": True, @@ -23,6 +30,11 @@ def _get_repository_config(repo_format, repo_name, repo_type="hosted", remote_ur } if repo_type == "proxy": + if (remote_username is None) != (remote_password is None): + raise ValueError( + "remote_username and remote_password must be provided together" + ) + base_config.update({ "proxy": { "remoteUrl": remote_url, @@ -46,6 +58,12 @@ def _get_repository_config(repo_format, repo_name, repo_type="hosted", remote_ur } } }) + if remote_username is not None: + base_config["httpClient"]["authentication"] = { + "type": "username", + "username": remote_username, + "password": remote_password, + } if repo_format == "maven": base_config.update({ @@ -74,10 +92,25 @@ def create_repository(self, repo_format, repo_name, repo_type="hosted", remote_u response.raise_for_status() return response - def update_proxy_config(self, repo_format, repo_name, repo_type="proxy", remote_url=None): + def update_proxy_config( + self, + repo_format, + repo_name, + repo_type="proxy", + remote_url=None, + remote_username=None, + remote_password=None, + ): """更新maven代理配置""" endpoint = f"service/rest/v1/repositories/{repo_format}/{repo_type}/{repo_name}" - config = _get_repository_config(repo_format, repo_name, repo_type, remote_url) + config = _get_repository_config( + repo_format, + repo_name, + repo_type, + remote_url, + remote_username, + remote_password, + ) response = self.session.put(urljoin(self.base_url, endpoint), json=config) response.raise_for_status() return response diff --git a/testing/nexus-e2e/test_maven_repo.py b/testing/nexus-e2e/test_maven_repo.py index 3182da0c..a1995047 100644 --- a/testing/nexus-e2e/test_maven_repo.py +++ b/testing/nexus-e2e/test_maven_repo.py @@ -6,6 +6,54 @@ import allure from pathlib import Path +from libs.maven_upstream import select_proxy_remote + +# DEVOPS-44489: force Maven's own ${user.home} to agree with $HOME. The +# run-test container runs as a non-root UID with no /etc/passwd entry, so +# the JVM's user.home auto-detection (getpwuid_r()) falls back to a literal +# "?" regardless of $HOME being set correctly -- exactly the same root +# cause already diagnosed and fixed for the BUILD pipeline's build-nexus-app +# step (see changes.patch's jvm-user-home-broken-uid-no-passwd decision: +# MAVEN_OPTS=-Duser.home=...). Confirmed live here too (nexus-integration- +# test-l5gsh, test_maven_publish): `mvn ... package` itself exits 0, but +# Python's Path.home() (correctly $HOME-based) can't find the resulting jar +# -- Maven silently resolved its default localRepository +# (${user.home}/.m2/repository) to a bogus path under mvn's cwd instead of +# $HOME/.m2/repository. Setting MAVEN_OPTS here (module import time, before +# any mvn invocation via os.system) makes every mvn call in this file agree +# with Path.home(). +if os.environ.get("HOME"): + _user_home_opt = f"-Duser.home={os.environ['HOME']}" + if _user_home_opt not in os.environ.get("MAVEN_OPTS", ""): + os.environ["MAVEN_OPTS"] = f"{os.environ.get('MAVEN_OPTS', '')} {_user_home_opt}".strip() + + +def _maven_central_mirror_url(): + """DEVOPS-44489: the default "central" mirror below (ucloud-nexus.alauda.cn, + an Alauda-internal Nexus proxy) is unreachable from edge-build build pods + -- confirmed live via a debug pod (`curl http://ucloud-nexus.alauda.cn:8081/` + -> "Connection timed out"). An earlier iteration of this fix pointed the + default at Maven Central's own public origin (repo.maven.apache.org) -- + superseded per driver decision: use artifacts.alauda.io (an external, + anonymously-reachable Alauda mirror) as the standard anonymous mirror for + this cohort's e2e suites, matching the same host now used for the npm + proxy scenario below. Confirmed anonymously reachable (HTTP 200, no + auth) at https://artifacts.alauda.io/repository/maven-central and + /repository/maven-public; its REST API + (https://artifacts.alauda.io/service/rest/v1/repositories) is + anonymously listable and was used to confirm the exact repo path. + Overridable via MACVEN_MIRROR_REGISTRY, which the original code already + anticipated. Returns the FULL mirror URL (no trailing slash) -- callers + append "/" themselves -- since artifacts.alauda.io isn't Nexus-shaped + like ucloud-nexus.alauda.cn (no "/repository//" path convention + beyond the one baked into this URL). + """ + override = os.environ.get("MACVEN_MIRROR_REGISTRY") + if override: + return override.rstrip("/") + return "https://artifacts.alauda.io/repository/maven-central" + + def test_maven_publish(nexus_client, nexus_config, hosted_repo): with allure.step('Build and deploy Maven project'): project_path = Path(f'test_projects/maven') @@ -14,7 +62,7 @@ def test_maven_publish(nexus_client, nexus_config, hosted_repo): create_server_config("nexus", nexus_config.username, nexus_config.password), ] mirrors_configs = [ - create_mirror_config("ucloud", "central", os.environ.get("MACVEN_MIRROR_REGISTRY", "http://ucloud-nexus.alauda.cn:8081"), "maven-central") + create_mirror_config("ucloud", "central", _maven_central_mirror_url(), None) ] settings_path = create_settings(server_configs, mirrors_configs) publish_xml_path = project_path / 'publish.xml' @@ -58,7 +106,7 @@ def test_maven_publish(nexus_client, nexus_config, hosted_repo): ] mirrors_configs = [ create_mirror_config("nexus","nexus", nexus_config.url, hosted_repo), - create_mirror_config("ucloud", "central", os.environ.get("MACVEN_MIRROR_REGISTRY", "http://ucloud-nexus.alauda.cn:8081"), "maven-central") + create_mirror_config("ucloud", "central", _maven_central_mirror_url(), None) ] settings_path = create_settings(server_configs, mirrors_configs) project_path = Path(f'test_projects/maven') @@ -76,11 +124,14 @@ def test_maven_publish(nexus_client, nexus_config, hosted_repo): def test_maven_proxy(nexus_client, nexus_config): with allure.step('Set nexus proxy config'): + remote = select_proxy_remote(os.environ) nexus_client.update_proxy_config( "maven", "maven-central", "proxy", - r"{}/repository/maven-central/".format(os.environ.get("MACVEN_MIRROR_REGISTRY", "http://ucloud-nexus.alauda.cn:8081")) + remote.url, + remote.username, + remote.password, ) with allure.step('Download dependency'): @@ -161,7 +212,16 @@ def create_mirror_config(id, replace, nexus_url, repo_name): mirror_of.text = replace mirror_url = ElementTree.SubElement(mirror, 'url') - mirror_url.text = f"{nexus_url}/repository/{repo_name}/" + # DEVOPS-44489: repo_name=None means nexus_url is ALREADY the complete + # mirror endpoint (used for the _maven_central_mirror_url() override -- + # a public host like Maven Central isn't Nexus-shaped, so the + # "/repository//" convention below doesn't apply to it). Existing + # callers keep passing repo_name and get the original composition + # unchanged. + if repo_name is None: + mirror_url.text = f"{nexus_url}/" + else: + mirror_url.text = f"{nexus_url}/repository/{repo_name}/" return mirror @@ -177,5 +237,5 @@ def hosted_repo(nexus_client): def proxy_repo(nexus_client): t = time.strftime("%Y%m%d-%H%M%S") repo_name = f"maven-test-repo-{t}" - nexus_client.create_repository("maven", repo_name, "proxy", r"{}/repository/maven-central/".format(os.environ.get("MACVEN_MIRROR_REGISTRY", "http://ucloud-nexus.alauda.cn:8081"))) + nexus_client.create_repository("maven", repo_name, "proxy", f"{_maven_central_mirror_url()}/") return repo_name diff --git a/testing/nexus-e2e/unit/test_import_maven_e2e_dependencies.py b/testing/nexus-e2e/unit/test_import_maven_e2e_dependencies.py new file mode 100644 index 00000000..a58b9d54 --- /dev/null +++ b/testing/nexus-e2e/unit/test_import_maven_e2e_dependencies.py @@ -0,0 +1,330 @@ +import importlib.util +import io +import sys +from pathlib import Path + +import pytest + + +SCRIPT = Path(__file__).parents[2] / "hack" / "import-maven-e2e-dependencies.py" +TEST_REPRESENTATIVES = ( + "junit/junit/4.11/junit-4.11.jar", + "org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.jar", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.pom", +) +SPEC = importlib.util.spec_from_file_location("maven_bundle_importer", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class Response: + def __init__(self, status=200, *, content=b"", json_data=None, url="https://nexus.test/x"): + self.status_code = status + self.content = content + self._json = json_data + self.url = url + self.closed = False + + def json(self): + return self._json + + def iter_content(self, chunk_size): + for offset in range(0, len(self.content), chunk_size): + yield self.content[offset:offset + chunk_size] + + def close(self): + self.closed = True + + +class FakeSession: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + self.auth = None + self.uploaded_data = [] + + def _call(self, method, url, **kwargs): + self.calls.append((method, url, kwargs)) + data = kwargs.get("data") + if method == "PUT" and hasattr(data, "read"): + self.uploaded_data.append(data.read()) + data.seek(0) + return self.responses.pop(0) + + def get(self, url, **kwargs): + return self._call("GET", url, **kwargs) + + def request(self, method, url, **kwargs): + return self._call(method.upper(), url, **kwargs) + + def post(self, url, **kwargs): + return self._call("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self._call("PUT", url, **kwargs) + + +def env(**updates): + values = { + "MAVEN_UPSTREAM_URL": " https://nexus.test/ ", + "MAVEN_UPSTREAM_USERNAME": "alice", + "MAVEN_UPSTREAM_PASSWORD": "secret-value", + } + values.update(updates) + return values + + +def bundle(tmp_path): + root = tmp_path / "bundle" + artifacts = { + "junit/junit/4.11/junit-4.11.jar": b"junit", + "org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar": b"hamcrest", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.jar": b"deploy-jar", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.pom": b"deploy-pom", + } + for relative, content in artifacts.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return root, artifacts + + +def scan_responses(artifacts): + return [Response(200, content=artifacts[path]) for path in sorted(artifacts)] + + +def verification_responses(artifacts): + return [Response(200, content=artifacts[path]) for path in TEST_REPRESENTATIVES] + + +def test_config_defaults_override_and_hidden_password(tmp_path): + config = MODULE.load_config(["--bundle", str(tmp_path)], env()) + assert config.base_url == "https://nexus.test" + assert config.repository == "maven-e2e-external" + assert config.bundle == tmp_path + assert config.repository_url == "https://nexus.test/repository/maven-e2e-external" + assert "secret-value" not in repr(config) + with pytest.raises(Exception): + config.repository = "changed" + + +@pytest.mark.parametrize("url", ["", "ftp://nexus.test", "nexus.test"]) +def test_config_rejects_missing_or_invalid_url(url): + with pytest.raises(MODULE.ImportError): + MODULE.load_config([], env(MAVEN_UPSTREAM_URL=url)) + + +def test_config_rejects_credentials_embedded_in_url(): + with pytest.raises(MODULE.ImportError): + MODULE.load_config([], env(MAVEN_UPSTREAM_URL="https://alice:url-secret@nexus.test")) + + +def test_config_rejects_invalid_repository_name(): + with pytest.raises(MODULE.ImportError): + MODULE.load_config([], env(MAVEN_UPSTREAM_REPOSITORY="bad/name")) + + +def test_password_prompts_without_echo_when_environment_is_missing(monkeypatch): + monkeypatch.setattr(MODULE.getpass, "getpass", lambda prompt: "prompt-secret") + config = MODULE.load_config([], env(MAVEN_UPSTREAM_PASSWORD="")) + assert config.password == "prompt-secret" + + +def test_username_and_prompted_password_are_required(monkeypatch): + with pytest.raises(MODULE.ImportError, match="USERNAME"): + MODULE.load_config([], env(MAVEN_UPSTREAM_USERNAME="")) + monkeypatch.setattr(MODULE.getpass, "getpass", lambda prompt: "") + with pytest.raises(MODULE.ImportError, match="PASSWORD"): + MODULE.load_config([], env(MAVEN_UPSTREAM_PASSWORD="")) + + +def test_create_repository_payload_and_basic_auth(tmp_path): + root, artifacts = bundle(tmp_path) + session = FakeSession([ + Response(200, json_data=[]), Response(201), + *[response for _ in sorted(artifacts) for response in (Response(404), Response(201))], + *verification_responses(artifacts), + ]) + result = MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + assert session.auth == ("alice", "secret-value") + post = next(call for call in session.calls if call[0] == "POST") + assert post[2]["json"] == { + "name": "maven-e2e-external", "online": True, + "storage": {"blobStoreName": "default", "strictContentTypeValidation": True, "writePolicy": "ALLOW_ONCE"}, + "maven": {"versionPolicy": "MIXED", "layoutPolicy": "STRICT", "contentDisposition": "INLINE"}, + } + assert result == MODULE.ImportResult(uploaded=4, skipped=0, failed=0) + assert session.responses == [] + + +def test_all_http_requests_disable_redirects_and_have_timeout(tmp_path): + root, artifacts = bundle(tmp_path) + session = FakeSession([ + Response(200, json_data=[]), Response(201), + *[response for _ in sorted(artifacts) for response in (Response(404), Response(201))], + *verification_responses(artifacts), + ]) + + MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + + assert {call[0] for call in session.calls} == {"GET", "POST", "PUT"} + assert all(call[2]["timeout"] == 60 for call in session.calls) + assert all(call[2]["allow_redirects"] is False for call in session.calls) + artifact_gets = [call for call in session.calls if call[0] == "GET" and "/repository/" in call[1]] + assert all(call[2]["stream"] is True for call in artifact_gets) + put_calls = [call for call in session.calls if call[0] == "PUT"] + assert all(hasattr(call[2]["data"], "read") for call in put_calls) + assert all(call[2]["data"].closed for call in put_calls) + assert session.uploaded_data == [artifacts[path] for path in sorted(artifacts)] + + +def test_artifact_upload_redirect_fails_without_counting_upload(tmp_path): + root, _ = bundle(tmp_path) + session = FakeSession([ + Response(200, json_data=[{"name": "maven-e2e-external", "format": "maven2", "type": "hosted"}]), + Response(404), Response(302), + ]) + + with pytest.raises(MODULE.ImportError, match="unexpected|redirect"): + MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + + assert len([call for call in session.calls if call[0] == "PUT"]) == 1 + + +def test_reuses_compatible_repository_and_puts_relative_posix_path(tmp_path): + root, artifacts = bundle(tmp_path) + session = FakeSession([ + Response(200, json_data=[{"name": "maven-e2e-external", "format": "maven2", "type": "hosted"}]), + *[response for _ in sorted(artifacts) for response in (Response(404), Response(204))], + *verification_responses(artifacts), + ]) + MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + assert not any(call[0] == "POST" for call in session.calls) + put_urls = [call[1] for call in session.calls if call[0] == "PUT"] + assert "https://nexus.test/repository/maven-e2e-external/junit/junit/4.11/junit-4.11.jar" in put_urls + + +@pytest.mark.parametrize("repository", [ + {"name": "maven-e2e-external", "format": "raw", "type": "hosted"}, + {"name": "maven-e2e-external", "format": "maven2", "type": "proxy"}, +]) +def test_rejects_incompatible_repository_before_upload(tmp_path, repository): + root, _ = bundle(tmp_path) + session = FakeSession([Response(200, json_data=[repository])]) + with pytest.raises(MODULE.ImportError, match="incompatible"): + MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + assert not any(call[0] == "PUT" for call in session.calls) + + +def test_identical_is_skipped_and_different_is_conflict(tmp_path): + root, artifacts = bundle(tmp_path) + session = FakeSession([ + Response(200, json_data=[{"name": "maven-e2e-external", "format": "maven", "type": "hosted"}]), + *[ + Response(200, content=b"different" if "hamcrest-core" in relative else artifacts[relative]) + for relative in sorted(artifacts) + ], + *verification_responses(artifacts), + ]) + result = MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + assert result == MODULE.ImportResult(uploaded=0, skipped=3, failed=1) + assert not any(call[0] == "PUT" for call in session.calls) + artifact_gets = [call[1] for call in session.calls if call[0] == "GET"][1:] + assert artifact_gets.count( + "https://nexus.test/repository/maven-e2e-external/junit/junit/4.11/junit-4.11.jar" + ) == 2 + assert artifact_gets.count( + "https://nexus.test/repository/maven-e2e-external/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar" + ) == 2 + + +def test_symlinked_file_is_not_imported(tmp_path): + root, artifacts = bundle(tmp_path) + outside = tmp_path / "outside.jar" + outside.write_bytes(b"outside") + (root / "linked.jar").symlink_to(outside) + session = FakeSession([ + Response(200, json_data=[{"name": "maven-e2e-external", "format": "maven2", "type": "hosted"}]), + *scan_responses(artifacts), + *verification_responses(artifacts), + ]) + + result = MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + + assert result == MODULE.ImportResult(uploaded=0, skipped=4, failed=0) + assert all("linked.jar" not in call[1] for call in session.calls) + + +@pytest.mark.parametrize("status", [401, 403]) +def test_authentication_errors_are_sanitized(tmp_path, status): + root, _ = bundle(tmp_path) + session = FakeSession([Response(status, url="https://alice:secret-value@nexus.test/service/rest/v1/repositories")]) + with pytest.raises(MODULE.ImportError) as caught: + MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + message = str(caught.value) + assert "authentication/authorization" in message + assert "secret-value" not in message + assert "Authorization" not in message + assert "alice:" not in message + + +def test_missing_representative_artifact_fails_before_network(tmp_path): + root = tmp_path / "bundle" + root.mkdir() + session = FakeSession([]) + with pytest.raises(MODULE.ImportError, match="missing representative"): + MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + assert session.calls == [] + + +def test_missing_lifecycle_plugin_fails_before_network(tmp_path): + root, _ = bundle(tmp_path) + plugin = root / "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.jar" + plugin.unlink() + session = FakeSession([]) + + with pytest.raises(MODULE.ImportError, match="maven-deploy-plugin-2.8.2.jar"): + MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + + assert session.calls == [] + + +def test_symlinked_representative_artifact_fails_before_network(tmp_path): + root, _ = bundle(tmp_path) + representative = root / "junit/junit/4.11/junit-4.11.jar" + representative.unlink() + outside = tmp_path / "outside-junit.jar" + outside.write_bytes(b"outside") + representative.symlink_to(outside) + session = FakeSession([]) + + with pytest.raises(MODULE.ImportError, match="missing representative"): + MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + + assert session.calls == [] + + +def test_post_import_verification_detects_lifecycle_plugin_mismatch(tmp_path): + root, artifacts = bundle(tmp_path) + session = FakeSession([ + Response(200, json_data=[{"name": "maven-e2e-external", "format": "maven2", "type": "hosted"}]), + *scan_responses(artifacts), + Response(200, content=artifacts["junit/junit/4.11/junit-4.11.jar"]), + Response(200, content=artifacts["org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar"]), + Response(200, content=b"corrupt-plugin"), + ]) + with pytest.raises(MODULE.ImportError, match="maven-deploy-plugin-2.8.2.jar"): + MODULE.import_bundle(MODULE.load_config(["--bundle", str(root)], env()), session) + + +def test_main_summary_contains_only_url_counts_and_no_password(tmp_path, monkeypatch): + root, _ = bundle(tmp_path) + config = MODULE.load_config(["--bundle", str(root)], env()) + monkeypatch.setattr(MODULE, "load_config", lambda argv=None, environ=None: config) + monkeypatch.setattr(MODULE, "import_bundle", lambda config: MODULE.ImportResult(2, 3, 0)) + output = io.StringIO() + assert MODULE.main([], output=output) == 0 + assert output.getvalue() == "https://nexus.test/repository/maven-e2e-external\nuploaded=2 skipped=3 failed=0\n" + assert "secret-value" not in output.getvalue() diff --git a/testing/nexus-e2e/unit/test_lynx_entrypoint.py b/testing/nexus-e2e/unit/test_lynx_entrypoint.py new file mode 100644 index 00000000..e0f91a67 --- /dev/null +++ b/testing/nexus-e2e/unit/test_lynx_entrypoint.py @@ -0,0 +1,1341 @@ +import json +import os +import re +import subprocess +import time +from pathlib import Path + +import pytest + + +COMMON = Path(__file__).parents[2] / "lynx" / "common.sh" +AUTH = Path(__file__).parents[2] / "lynx" / "auth.sh" +OLM = Path(__file__).parents[2] / "lynx" / "olm.sh" +E2E = Path(__file__).parents[2] / "lynx" / "e2e.sh" +DIAGNOSTICS = Path(__file__).parents[2] / "lynx" / "diagnostics.sh" +ENTRYPOINT = Path(__file__).parents[2] / "lynx-entrypoint.sh" +CONTAINERFILE = Path(__file__).parents[2] / "Containerfile" +INTEGRATION_PIPELINE = Path(__file__).parents[3] / ".tekton" / "integration-test.yaml" +TIMESTAMP = r"\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\]" + + +def run_bash(script, *, env=None, timeout=5): + command_env = os.environ.copy() + command_env["LC_ALL"] = "C" + if env: + command_env.update(env) + return subprocess.run( + ["bash", "-c", f'source "$1"\n{script}', "bash", str(COMMON)], + text=True, + capture_output=True, + env=command_env, + timeout=timeout, + ) + + +def run_auth_bash(script, *, env=None, timeout=5): + return run_bash(f'source "{AUTH}"\n{script}', env=env, timeout=timeout) + + +def run_olm_bash(script, *, env=None, timeout=5): + return run_bash(f'source "{OLM}"\n{script}', env=env, timeout=timeout) + + +def run_e2e_bash(script, *, env=None, timeout=5): + return run_bash(f'source "{E2E}"\n{script}', env=env, timeout=timeout) + + +def run_diagnostics_bash(script, *, env=None, timeout=5): + return run_bash(f'source "{DIAGNOSTICS}"\n{script}', env=env, timeout=timeout) + + +def write_fake_kubectl(tmp_path, body): + fake = tmp_path / "kubectl" + fake.write_text("#!/usr/bin/env bash\nset -eu\n" + body) + fake.chmod(0o755) + return fake + + +def olm_env(tmp_path, **extra): + return { + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "L5_PLUGINS_VERSION": '{"nexus-ce-operator":"nexus-ce-operator.v4.2.1"}', + "LYNX_POLL_INTERVAL": "1", + "LYNX_WAIT_HEARTBEAT": "1", + "LYNX_INSTALL_TIMEOUT": "2", + **extra, + } + + +def test_resolve_operator_catalog_uses_selected_channel_and_exact_listed_csv(tmp_path): + write_fake_kubectl( + tmp_path, + ''' +[[ "$*" == *"get packagemanifests"* ]] || exit 91 +cat <<'JSON' +{"items":[{"metadata":{"name":"other"}},{"metadata":{"name":"nexus-ce-operator"},"status":{"catalogSource":"nexus-catalog","catalogSourceNamespace":"olm","channels":[{"name":"fast","currentCSV":"wrong.v9"},{"name":"stable","currentCSV":"nexus-ce-operator.v4.2.1"}]}}]} +JSON +''', + ) + result = run_olm_bash( + 'resolve_operator_catalog && printf "%s|%s|%s\\n" "$OPERATOR_CSV" "$CATALOG_SOURCE" "$CATALOG_NAMESPACE"', + env=olm_env(tmp_path), + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "nexus-ce-operator.v4.2.1|nexus-catalog|olm\n" + + +def test_resolve_operator_catalog_uses_channel_csv_when_listed_version_differs(tmp_path): + write_fake_kubectl( + tmp_path, + '''cat <<'JSON' +{"items":[{"metadata":{"name":"nexus-ce-operator"},"status":{"catalogSource":"catalog","catalogSourceNamespace":"olm","channels":[{"name":"stable","currentCSV":"nexus-ce-operator.v9.9.9"}]}}]} +JSON +''', + ) + result = run_olm_bash( + 'resolve_operator_catalog && printf "%s|%s|%s\\n" "$OPERATOR_CSV" "$CATALOG_SOURCE" "$CATALOG_NAMESPACE"', + env=olm_env(tmp_path), + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "nexus-ce-operator.v9.9.9|catalog|olm\n" + + +@pytest.mark.parametrize(("operator_groups", "target_namespaces", "ok"), [("0", "", True), ("1", "", True), ("1", "other", False), ("2", "", False)]) +def test_ensure_operator_group_requires_one_dedicated_all_namespaces_group( + tmp_path, operator_groups, target_namespaces, ok +): + calls = tmp_path / "calls" + write_fake_kubectl( + tmp_path, + ''' +printf '%s\n' "$*" >> "$CALLS" +if [[ "$*" == *"get operatorgroups"* ]]; then + printf '{"items":[' + if [[ "$OG_COUNT" != 0 ]]; then printf '{"spec":{"targetNamespaces":%s}}' "${OG_TARGET:-[]}"; fi + if [[ "$OG_COUNT" == 2 ]]; then printf ',{"spec":{}}'; fi + printf ']}\n' +elif [[ "$*" == *"apply -f -"* ]]; then cat >/dev/null +fi +''', + ) + target = json.dumps([target_namespaces]) if target_namespaces else "[]" + result = run_olm_bash( + "ensure_operator_group", + env=olm_env(tmp_path, CALLS=str(calls), OG_COUNT=operator_groups, OG_TARGET=target), + ) + + assert (result.returncode == 0) is ok + if operator_groups == "0": + assert "apply -f -" in calls.read_text() + + +def test_ensure_subscription_rejects_incompatible_existing_resource(tmp_path): + write_fake_kubectl( + tmp_path, + ''' +if [[ "$*" == *"get subscription"* ]]; then +cat <<'JSON' +{"spec":{"name":"nexus-ce-operator","source":"wrong-catalog","sourceNamespace":"olm","channel":"stable","startingCSV":"nexus-ce-operator.v4.2.1","installPlanApproval":"Manual"}} +JSON +fi +''', + ) + result = run_olm_bash( + 'OPERATOR_CSV=nexus-ce-operator.v4.2.1; CATALOG_SOURCE=catalog; CATALOG_NAMESPACE=olm; ensure_subscription', + env=olm_env(tmp_path), + ) + + assert result.returncode != 0 + assert "incompatible" in result.stderr + + +def test_ensure_subscription_applies_only_after_successful_not_found_get(tmp_path): + calls = tmp_path / "calls" + write_fake_kubectl( + tmp_path, + ''' +printf '%s\n' "$*" >> "$CALLS" +if [[ "$*" == *"get subscription"* ]]; then + [[ "$*" == *"--ignore-not-found"* ]] || exit 88 +elif [[ "$*" == *"apply -f -"* ]]; then + cat >/dev/null +fi +''', + ) + result = run_olm_bash( + 'OPERATOR_CSV=nexus-ce-operator.v4.2.1; CATALOG_SOURCE=catalog; CATALOG_NAMESPACE=olm; ensure_subscription', + env=olm_env(tmp_path, CALLS=str(calls)), + ) + + assert result.returncode == 0, result.stderr + assert "apply -f -" in calls.read_text() + + +def test_ensure_subscription_does_not_apply_after_operational_get_failure(tmp_path): + calls = tmp_path / "calls" + write_fake_kubectl( + tmp_path, + ''' +printf '%s\n' "$*" >> "$CALLS" +if [[ "$*" == *"get subscription"* ]]; then exit 73 +elif [[ "$*" == *"apply -f -"* ]]; then cat >/dev/null +fi +''', + ) + result = run_olm_bash( + 'OPERATOR_CSV=nexus-ce-operator.v4.2.1; CATALOG_SOURCE=catalog; CATALOG_NAMESPACE=olm; ensure_subscription', + env=olm_env(tmp_path, CALLS=str(calls)), + ) + + assert result.returncode != 0 + assert "apply -f -" not in calls.read_text() + + +def test_wait_for_install_plan_fails_on_terminal_subscription_condition(tmp_path): + write_fake_kubectl( + tmp_path, + ''' +if [[ "$*" == *"get subscription"* ]]; then + printf '%s\n' '{"status":{"conditions":[{"type":"ResolutionFailed","status":"True","reason":"ConstraintsNotSatisfiable"}]}}' +fi +''', + ) + result = run_olm_bash("wait_for_install_plan", env=olm_env(tmp_path)) + + assert result.returncode != 0 + assert "ResolutionFailed" in result.stderr + + +def test_install_operator_is_idempotent_and_verifies_dynamic_deployment_and_crd(tmp_path): + calls = tmp_path / "calls" + state = tmp_path / "state" + write_fake_kubectl( + tmp_path, + ''' +printf '%s\n' "$*" >> "$CALLS" +case "$*" in + *"get packagemanifests"*) printf '%s\n' '{"items":[{"metadata":{"name":"nexus-ce-operator"},"status":{"catalogSource":"catalog","catalogSourceNamespace":"olm","channels":[{"name":"stable","currentCSV":"nexus-ce-operator.v4.2.1"}]}}]}' ;; + *"get catalogsource catalog -n olm"*) printf '%s\n' '{"status":{"connectionState":{"lastObservedState":"READY"}}}' ;; + *"get operatorgroups"*) if [[ -f "$STATE" ]]; then printf '%s\n' '{"items":[{"spec":{}}]}'; else printf '%s\n' '{"items":[]}'; fi ;; + *"get subscription nexus-ce-operator"*) + if [[ -f "$STATE" ]]; then printf '%s\n' '{"spec":{"name":"nexus-ce-operator","source":"catalog","sourceNamespace":"olm","channel":"stable","startingCSV":"nexus-ce-operator.v4.2.1","installPlanApproval":"Manual"},"status":{"installPlanRef":{"name":"ip-one"}}}'; fi ;; + *"get installplan ip-one"*) printf '%s\n' '{"status":{"phase":"Complete"}}' ;; + *"patch installplan ip-one"*) touch "$PLAN" ;; + *"get clusterserviceversion nexus-ce-operator.v4.2.1"*) if [[ -f "$PLAN" ]]; then printf '%s\n' '{"status":{"phase":"Succeeded"},"spec":{"install":{"spec":{"deployments":[{"name":"nexus-operator-controller-manager"}]}}}}'; else printf '%s\n' '{"status":{"phase":"Pending"}}'; fi ;; + *"get deployment nexus-operator-controller-manager"*) printf '%s\n' '{"status":{"conditions":[{"type":"Available","status":"True"}]}}' ;; + *"get crd nexuses.operator.alaudadevops.io"*) printf '%s\n' '{"spec":{"versions":[{"name":"v1alpha1","served":true}]},"status":{"conditions":[{"type":"Established","status":"True"},{"type":"NamesAccepted","status":"True"}]}}' ;; + *"api-resources"*) printf '%s\n' 'nexuses.operator.alaudadevops.io' ;; + *"create namespace"*) printf '%s\n' 'apiVersion: v1' 'kind: Namespace' ;; + *"apply -f -"*) cat >/dev/null; touch "$STATE" ;; + *) exit 92 ;; +esac +''', + ) + env = olm_env(tmp_path, CALLS=str(calls), STATE=str(state), PLAN=str(tmp_path / "plan")) + + first = run_olm_bash("install_operator", env=env, timeout=8) + second = run_olm_bash("install_operator", env=env, timeout=8) + + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + all_calls = calls.read_text() + assert "patch installplan ip-one" in all_calls + assert "get deployment nexus-operator-controller-manager" in all_calls + assert "api-resources" in all_calls + + +def test_wait_for_csv_times_out_with_bounded_polling(tmp_path): + write_fake_kubectl(tmp_path, "printf '%s\\n' '{\"status\":{\"phase\":\"Pending\"}}'\n") + result = run_olm_bash( + "OPERATOR_CSV=nexus-ce-operator.v4.2.1; wait_for_csv", + env=olm_env(tmp_path, LYNX_INSTALL_TIMEOUT="1"), + timeout=3, + ) + + assert result.returncode != 0 + assert "timed out" in result.stderr.lower() + + +def test_install_operator_skips_install_plan_for_succeeded_exact_csv_but_verifies_runtime(tmp_path): + marker = tmp_path / "verified" + write_fake_kubectl( + tmp_path, + ''' +if [[ "$*" == *"create namespace"* ]]; then printf '%s\n' 'kind: Namespace' +elif [[ "$*" == *"apply -f -"* ]]; then cat >/dev/null +else exit 90 +fi +''', + ) + result = run_olm_bash( + f''' +resolve_operator_catalog() {{ OPERATOR_CSV=nexus-ce-operator.v4.2.1; CATALOG_SOURCE=c; CATALOG_NAMESPACE=n; }} +_catalog_ready() {{ printf READY; }} +ensure_operator_group() {{ :; }} +ensure_subscription() {{ :; }} +_csv_phase() {{ printf Succeeded; }} +wait_for_install_plan() {{ log "InstallPlan must not be required"; return 77; }} +wait_for_deployment() {{ printf deployment >> "{marker}"; }} +wait_for_nexus_crd() {{ printf crd >> "{marker}"; }} +install_operator +''', + env=olm_env(tmp_path), + ) + + assert result.returncode == 0, result.stderr + assert marker.read_text() == "deploymentcrd" + + +def test_olm_wait_value_bounds_hung_probe_and_emits_heartbeat(): + started_at = time.monotonic() + result = run_olm_bash( + '_hung_olm_probe() { sleep 10; printf ready; }; _olm_wait_value "hung OLM probe" ready 3 _hung_olm_probe', + env={"LYNX_POLL_INTERVAL": "1", "LYNX_WAIT_HEARTBEAT": "1"}, + timeout=5, + ) + + assert result.returncode != 0 + assert time.monotonic() - started_at < 5 + assert result.stderr.lower().count("waiting") >= 2 + assert "timed out" in result.stderr.lower() + + +def test_wait_for_install_plan_bounds_hung_kubectl_and_emits_heartbeat(tmp_path): + write_fake_kubectl(tmp_path, "sleep 10\n") + started_at = time.monotonic() + result = run_olm_bash( + "wait_for_install_plan", + env=olm_env(tmp_path, LYNX_INSTALL_TIMEOUT="3"), + timeout=5, + ) + + assert result.returncode != 0 + assert time.monotonic() - started_at < 5 + assert result.stderr.lower().count("waiting") >= 2 + assert "timed out" in result.stderr.lower() + + +def test_wait_for_install_plan_bounds_hung_approval_patch(tmp_path): + write_fake_kubectl( + tmp_path, + ''' +if [[ "$*" == *"get subscription"* ]]; then + printf '%s\n' '{"status":{"installPlanRef":{"name":"ip-one"}}}' +elif [[ "$*" == *"patch installplan ip-one"* ]]; then + sleep 10 +fi +''', + ) + started_at = time.monotonic() + result = run_olm_bash( + "wait_for_install_plan", + env=olm_env(tmp_path, LYNX_INSTALL_TIMEOUT="2"), + timeout=4, + ) + + assert result.returncode != 0 + assert time.monotonic() - started_at < 4 + assert "deadline" in result.stderr.lower() + + +def test_install_operator_bounds_hung_namespace_precheck(tmp_path): + write_fake_kubectl(tmp_path, "sleep 10\n") + started_at = time.monotonic() + result = run_olm_bash( + "install_operator", + env=olm_env(tmp_path, LYNX_INSTALL_TIMEOUT="1"), + timeout=3, + ) + + assert result.returncode != 0 + assert time.monotonic() - started_at < 3 + + +def test_log_and_fatal_write_timestamped_messages_to_stderr(): + result = run_bash('log "starting"; (fatal "stopped")') + + assert result.returncode != 0 + assert re.fullmatch(f"{TIMESTAMP} starting\n{TIMESTAMP} ERROR: stopped\n", result.stderr) + assert result.stdout == "" + + +@pytest.mark.parametrize("state", ["unset", "empty"]) +def test_require_env_rejects_unset_and_empty_values(state): + setup = "unset LYNX_TEST_SECRET" if state == "unset" else "export LYNX_TEST_SECRET=" + result = run_bash( + f'{setup}\nrequire_env LYNX_TEST_SECRET', + ) + + assert result.returncode != 0 + assert "LYNX_TEST_SECRET" in result.stderr + + +def test_require_env_accepts_a_nonempty_value_without_printing_it(): + secret = "should-never-be-printed" + result = run_bash( + 'require_env LYNX_TEST_SECRET', + env={"LYNX_TEST_SECRET": secret}, + ) + + assert result.returncode == 0 + assert secret not in result.stdout + result.stderr + + +def test_run_bash_bounds_test_processes(): + with pytest.raises(subprocess.TimeoutExpired): + run_bash("sleep 0.2", timeout=0.01) + + +def test_require_command_accepts_present_command_and_rejects_missing_command(): + present = run_bash("require_command bash") + missing = run_bash("require_command definitely-not-a-real-lynx-command") + + assert present.returncode == 0 + assert missing.returncode != 0 + assert "definitely-not-a-real-lynx-command" in missing.stderr + + +@pytest.mark.parametrize("value", ["", "0", "-1", "+1", "1.0", " 1", "1 ", "abc"]) +def test_require_positive_integer_rejects_invalid_values(value): + result = run_bash(f"require_positive_integer RETRIES {json.dumps(value)}") + + assert result.returncode != 0 + assert "RETRIES" in result.stderr + + +@pytest.mark.parametrize("value", ["1", "42", "0007", "0008", "0009"]) +def test_require_positive_integer_accepts_digit_only_nonzero_values(value): + result = run_bash(f"require_positive_integer RETRIES {value}") + + assert result.returncode == 0 + assert value not in result.stdout + result.stderr + + +def test_listed_operator_version_selects_only_exact_operator_name(): + plugins = json.dumps( + { + "other-nexus-ce-operator": "secret-wrong", + "nexus-ce-operator": "v4.2.1", + "nexus-ce-operator-addon": "secret-addon", + } + ) + result = run_bash("listed_operator_version", env={"L5_PLUGINS_VERSION": plugins}) + + assert result.returncode == 0, result.stderr + assert result.stdout == "v4.2.1\n" + assert "secret-wrong" not in result.stderr + assert "secret-addon" not in result.stderr + + +@pytest.mark.parametrize( + "plugins", + ["not-json", "{}", '{"nexus-ce-operator": null}', '{"nexus-ce-operator": ""}'], +) +def test_listed_operator_version_fails_safely_for_invalid_or_missing_listing(plugins): + result = run_bash("listed_operator_version", env={"L5_PLUGINS_VERSION": plugins}) + + assert result.returncode != 0 + assert plugins not in result.stdout + result.stderr + + +def test_wait_for_value_returns_when_command_output_exactly_matches(tmp_path): + attempts = tmp_path / "attempts" + probe = tmp_path / "probe" + probe.write_text( + f'''#!/usr/bin/env bash +count=$(wc -l < "{attempts}" 2>/dev/null || printf 0) +printf 'x\\n' >> "{attempts}" +if (( count >= 1 )); then printf 'ready\\n'; else printf 'not-ready\\n'; fi +''' + ) + probe.chmod(0o755) + result = run_bash( + f'wait_for_value "operator readiness" ready 3 "{probe}"', + env={"LYNX_POLL_INTERVAL": "1", "LYNX_WAIT_HEARTBEAT": "1"}, + ) + + assert result.returncode == 0, result.stderr + assert len(attempts.read_text().splitlines()) >= 2 + assert "not-ready" not in result.stdout + result.stderr + + +def test_wait_for_value_is_bounded_and_heartbeats_without_command_output(): + secret_output = "sensitive-current-value" + result = run_bash( + f'''wait_for_value "operator readiness" ready 1 \ + bash -c "printf '%s\\n' {secret_output}"''', + env={"LYNX_POLL_INTERVAL": "1", "LYNX_WAIT_HEARTBEAT": "1"}, + ) + + assert result.returncode == 1 + assert "operator readiness" in result.stderr + assert "waiting" in result.stderr.lower() + assert "timed out" in result.stderr.lower() + assert secret_output not in result.stdout + result.stderr + + +@pytest.mark.parametrize("name", ["LYNX_POLL_INTERVAL", "LYNX_WAIT_HEARTBEAT"]) +@pytest.mark.parametrize("value", ["", "0", "-1", "1.5", "abc"]) +def test_wait_for_value_rejects_invalid_timing_settings(name, value): + result = run_bash( + 'wait_for_value "operator readiness" ready 1 printf ready', + env={name: value}, + ) + + assert result.returncode != 0 + assert name in result.stderr + + +@pytest.mark.parametrize("name", ["LYNX_POLL_INTERVAL", "LYNX_WAIT_HEARTBEAT"]) +def test_wait_for_value_does_not_evaluate_malicious_timing_settings(tmp_path, name): + sentinel = tmp_path / "arithmetic-was-evaluated" + malicious = f'1+$(touch "{sentinel}")' + result = run_bash( + 'wait_for_value "operator readiness" ready 1 printf ready', + env={name: malicious}, + ) + + assert result.returncode != 0 + assert name in result.stderr + assert not sentinel.exists() + assert malicious not in result.stdout + result.stderr + + +def test_wait_for_value_times_out_a_hanging_probe(): + started_at = time.monotonic() + result = run_bash( + 'wait_for_value "hanging probe" ready 1 bash -c "sleep 10; printf ready"', + env={"LYNX_POLL_INTERVAL": "1", "LYNX_WAIT_HEARTBEAT": "1"}, + timeout=3, + ) + + assert result.returncode == 1 + assert time.monotonic() - started_at < 3 + assert "timed out" in result.stderr.lower() + + +@pytest.mark.parametrize("timeout", ["0008", "0009"]) +def test_wait_for_value_treats_leading_zero_timeout_as_decimal(timeout): + result = run_bash( + f'wait_for_value "operator readiness" ready {timeout} printf ready', + env={"LYNX_POLL_INTERVAL": "1", "LYNX_WAIT_HEARTBEAT": "1"}, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("name", ["LYNX_POLL_INTERVAL", "LYNX_WAIT_HEARTBEAT"]) +@pytest.mark.parametrize("value", ["0008", "0009"]) +def test_wait_for_value_treats_leading_zero_timing_settings_as_decimal(name, value): + result = run_bash( + 'wait_for_value "operator readiness" ready 1 printf not-ready', + env={name: value}, + timeout=3, + ) + + assert result.returncode == 1 + assert "timed out" in result.stderr.lower() + + +def test_resolve_access_token_uses_preissued_token_without_password_login(tmp_path): + fake_curl = tmp_path / "curl" + fake_curl.write_text("#!/usr/bin/env bash\nprintf 'curl must not run\\n' >&2\nexit 99\n") + fake_curl.chmod(0o755) + token = "preissued-secret-token" + + result = run_auth_bash( + "resolve_access_token", + env={"PATH": f"{tmp_path}:{os.environ['PATH']}", "TOKEN": token}, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == token + assert result.stderr == "" + + +def test_resolve_access_token_requires_a_complete_authentication_method(): + result = run_auth_bash( + "unset TOKEN USERNAME PASSWORD\nresolve_access_token", + env={"API_URL": "https://acp.example.test"}, + ) + + assert result.returncode != 0 + assert "TOKEN" in result.stderr + assert "USERNAME" in result.stderr + assert "PASSWORD" in result.stderr + + +@pytest.mark.parametrize( + ("tls_env", "expected_tls_option"), + [ + ({}, None), + ({"LYNX_TLS_INSECURE": "false"}, None), + ({"LYNX_CA_BUNDLE": "/trusted/acp-ca.pem"}, "--cacert /trusted/acp-ca.pem"), + ({"LYNX_TLS_INSECURE": "true"}, "--insecure"), + ], +) +def test_password_login_uses_secure_bounded_acp_dex_flow_without_leaking_credentials( + tmp_path, tls_env, expected_tls_option +): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + calls = tmp_path / "curl.calls" + fake_curl = fake_bin / "curl" + fake_curl.write_text( + '''#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$LYNX_TEST_CURL_CALLS" +case "$*" in + *'/console-platform/api/v1/token/login'*) + printf '%s\\n' '{"auth_url":"https://external/dex/auth?client_id=console%2Bui&state=opaque%26state#ignored-fragment"}' ;; + *'/dex/api/v1/authorize/local'*) + printf '%s\\n' '{"redirect_url":"https://acp.example.test/console-platform?code=a%26b%23c%2Bd%3De%25f&state=s%26t%23u%2Bv%3Dw%25x#ignored"}' ;; + *'/dex/api/v1/authorize'*) printf '%s\\n' '{"req":"request&#+=%value"}' ;; + *'/dex/pubkey'*) printf '%s\\n' '{"pubkey":"PUBLIC KEY DATA","ts":"123"}' ;; + *'/console-platform/api/v1/token/callback'*) printf '%s\\n' '{"id_token":"issued-secret-token"}' ;; + *) exit 88 ;; +esac +''' + ) + fake_curl.chmod(0o755) + fake_openssl = fake_bin / "openssl" + fake_openssl.write_text( + '''#!/usr/bin/env bash +if [[ $1 == pkeyutl ]]; then + [[ "$*" == *'-pkeyopt rsa_padding_mode:pkcs1'* ]] || exit 91 + cat >/dev/null + printf cipher +elif [[ $1 == base64 ]]; then + cat >/dev/null + printf encrypted-password +else + exit 92 +fi +''' + ) + fake_openssl.chmod(0o755) + password = "password-must-stay-secret" + username = "username-must-stay-secret" + + result = run_auth_bash( + "resolve_access_token", + env={ + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "API_URL": "https://acp.example.test/", + "USERNAME": username, + "PASSWORD": password, + "LYNX_TEST_CURL_CALLS": str(calls), + "LYNX_HTTP_TIMEOUT": "7", + **tls_env, + }, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "issued-secret-token" + assert password not in result.stdout + result.stderr + assert username not in result.stdout + result.stderr + curl_calls = calls.read_text() + assert "/console-platform/api/v1/token/login" in curl_calls + assert "/dex/api/v1/authorize" in curl_calls + assert "/dex/pubkey" in curl_calls + assert "/dex/api/v1/authorize/local" in curl_calls + assert "/console-platform/api/v1/token/callback" in curl_calls + assert "--max-time 7" in curl_calls + assert "--data-urlencode client_id=console+ui" in curl_calls + assert "--data-urlencode state=opaque&state" in curl_calls + assert "ignored-fragment" not in curl_calls + assert "--url-query req=request&#+=%value" in curl_calls + assert "--data-urlencode code=a&b#c+d=e%f" in curl_calls + assert "--data-urlencode state=s&t#u+v=w%x" in curl_calls + if expected_tls_option: + assert expected_tls_option in curl_calls + else: + assert "--insecure" not in curl_calls + assert "--cacert" not in curl_calls + + +@pytest.mark.parametrize("value", ["1", "yes", "TRUE", ""]) +def test_password_login_rejects_invalid_tls_insecure_values_without_curl(tmp_path, value): + fake_curl = tmp_path / "curl" + fake_curl.write_text("#!/usr/bin/env bash\nprintf called >&2\nexit 99\n") + fake_curl.chmod(0o755) + + result = run_auth_bash( + "resolve_access_token", + env={ + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "API_URL": "https://acp.example.test", + "USERNAME": "user", + "PASSWORD": "secret", + "LYNX_TLS_INSECURE": value, + }, + ) + + assert result.returncode != 0 + assert "LYNX_TLS_INSECURE" in result.stderr + assert "called" not in result.stderr + + +def test_write_proxy_kubeconfig_uses_region_proxy_and_mode_0600(tmp_path): + kubeconfig = tmp_path / "proxy.kubeconfig" + token = "kubeconfig-secret-token" + result = run_auth_bash( + f'write_proxy_kubeconfig "{kubeconfig}" "$TOKEN"', + env={ + "API_URL": "https://acp.example.test/", + "REGION_NAME": "region-one", + "TOKEN": token, + }, + ) + + assert result.returncode == 0, result.stderr + config = json.loads(kubeconfig.read_text()) + assert config["clusters"][0]["cluster"]["server"] == ( + "https://acp.example.test/kubernetes/region-one" + ) + assert config["users"][0]["user"]["token"] == token + assert kubeconfig.stat().st_mode & 0o777 == 0o600 + assert token not in result.stdout + result.stderr + + +def test_write_bdd_config_uses_acp_target_and_mode_0600(tmp_path): + config_path = tmp_path / "config.yaml" + token = "bdd-secret-token" + result = run_auth_bash( + f'write_bdd_config "{config_path}" "$TOKEN"', + env={ + "API_URL": "https://acp.example.test/", + "REGION_NAME": "region-one", + "TOKEN": token, + }, + ) + + assert result.returncode == 0, result.stderr + config = json.loads(config_path.read_text()) + assert config == { + "acp": { + "baseUrl": "https://acp.example.test", + "token": token, + "cluster": "region-one", + } + } + assert config_path.stat().st_mode & 0o777 == 0o600 + assert token not in result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("function_name", "filename"), + [("write_proxy_kubeconfig", "proxy.kubeconfig"), ("write_bdd_config", "config.yaml")], +) +@pytest.mark.parametrize("failed_command", ["chmod", "mv"]) +def test_secret_config_writers_clean_temporary_files_on_command_failure( + tmp_path, function_name, filename, failed_command +): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + for command in ("chmod", "mv"): + fake_command = fake_bin / command + if command == failed_command: + fake_command.write_text("#!/usr/bin/env bash\nexit 73\n") + else: + fake_command.write_text(f'#!/usr/bin/env bash\nexec /bin/{command} "$@"\n') + fake_command.chmod(0o755) + destination = tmp_path / filename + token = "cleanup-secret-token" + + result = run_auth_bash( + f'{function_name} "{destination}" "$TOKEN"', + env={ + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "API_URL": "https://acp.example.test/", + "REGION_NAME": "region-one", + "TOKEN": token, + }, + ) + + assert result.returncode != 0 + assert not list(tmp_path.glob(f"{filename}.tmp.*")) + assert token not in result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("function_name", "filename"), + [("write_proxy_kubeconfig", "proxy.kubeconfig"), ("write_bdd_config", "config.yaml")], +) +def test_secret_config_writers_use_secure_destination_local_temporary_files( + tmp_path, function_name, filename +): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + mktemp_calls = tmp_path / "mktemp.calls" + fake_mktemp = fake_bin / "mktemp" + fake_mktemp.write_text( + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> "$LYNX_TEST_MKTEMP_CALLS"\n' + 'exec /usr/bin/mktemp "$@"\n' + ) + fake_mktemp.chmod(0o755) + destination = tmp_path / filename + protected_target = tmp_path / "protected-target" + protected_target.write_text("must-not-change") + destination.symlink_to(protected_target) + + result = run_auth_bash( + f'{function_name} "{destination}" "$TOKEN"', + env={ + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "API_URL": "https://acp.example.test/", + "REGION_NAME": "region-one", + "TOKEN": "temporary-file-secret", + "LYNX_TEST_MKTEMP_CALLS": str(mktemp_calls), + }, + ) + + assert result.returncode == 0, result.stderr + assert mktemp_calls.read_text().strip() == f"{destination}.tmp.XXXXXX" + assert protected_target.read_text() == "must-not-change" + assert not destination.is_symlink() + assert not list(tmp_path.glob(f"{filename}.tmp.*")) + + +@pytest.mark.parametrize( + ("function_name", "filename"), + [("write_proxy_kubeconfig", "proxy.kubeconfig"), ("write_bdd_config", "config.yaml")], +) +def test_secret_config_writers_clean_temporary_files_when_signalled( + tmp_path, function_name, filename +): + fake_jq = tmp_path / "jq" + fake_jq.write_text( + "#!/usr/bin/env bash\n" + "printf partial-secret-output\n" + 'kill -TERM "$PPID"\n' + ) + fake_jq.chmod(0o755) + destination = tmp_path / filename + + result = run_auth_bash( + f'{function_name} "{destination}" "$TOKEN"', + env={ + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "API_URL": "https://acp.example.test/", + "REGION_NAME": "region-one", + "TOKEN": "signal-cleanup-secret", + }, + ) + + assert result.returncode != 0 + assert not list(tmp_path.glob(f"{filename}.tmp.*")) + + +def test_run_e2e_uses_godog_tags_config_and_preserves_test_exit(tmp_path): + testing_dir = tmp_path / "testing" + testing_dir.mkdir() + calls = tmp_path / "nexus.calls" + nexus = testing_dir / "nexus.test" + nexus.write_text( + "#!/usr/bin/env bash\n" + 'printf \'%s|%s|%s\\n\' "$PWD" "$E2E_CONFIG" "$*" > "$LYNX_TEST_CALLS"\n' + "exit 37\n" + ) + nexus.chmod(0o755) + config = tmp_path / "config.yaml" + config.write_text("{}\n") + + result = run_e2e_bash( + "run_e2e", + env={ + "LYNX_TESTING_DIR": str(testing_dir), + "LYNX_BDD_CONFIG": str(config), + "LYNX_E2E_TAGS": "@e2e && ~@slow", + "LYNX_TEST_CALLS": str(calls), + }, + ) + + assert result.returncode == 37 + cwd, e2e_config, args = calls.read_text().strip().split("|", 2) + assert cwd == str(testing_dir) + assert e2e_config == str(config) + assert args == ( + "--godog.concurrency=2 --godog.format=allure " + "--godog.tags=@e2e && ~@slow" + ) + + +def test_run_e2e_defaults_to_e2e_tag_and_uses_writable_copy(tmp_path): + testing_dir = tmp_path / "read-only-testing" + testing_dir.mkdir() + calls = tmp_path / "nexus.calls" + nexus = testing_dir / "nexus.test" + nexus.write_text( + "#!/usr/bin/env bash\n" + 'printf \'%s|%s\\n\' "$PWD" "$*" > "$LYNX_TEST_CALLS"\n' + ) + nexus.chmod(0o555) + testing_dir.chmod(0o555) + config = tmp_path / "config.yaml" + config.write_text("{}\n") + + result = run_e2e_bash( + "run_e2e", + env={ + "LYNX_TESTING_DIR": str(testing_dir), + "LYNX_BDD_CONFIG": str(config), + "LYNX_TEST_CALLS": str(calls), + }, + ) + + assert result.returncode == 0, result.stderr + cwd, args = calls.read_text().strip().split("|", 1) + assert cwd != str(testing_dir) + assert args == "--godog.concurrency=2 --godog.format=allure --godog.tags=@e2e" + assert Path(cwd).exists() is False + + +def test_collect_allure_results_normalizes_raw_results(tmp_path): + raw = tmp_path / "raw" / "allure-results" + raw.mkdir(parents=True) + (raw / "one-result.json").write_text('{"status":"passed"}\n') + result_dir = tmp_path / "results" + + result = run_e2e_bash( + "collect_allure_results", + env={"LYNX_RAW_ALLURE_DIR": str(raw), "RESULT_DIR": str(result_dir)}, + ) + + assert result.returncode == 0, result.stderr + assert (result_dir / "allure-result" / "one-result.json").is_file() + + +def test_collect_allure_results_replaces_stale_destination(tmp_path): + raw = tmp_path / "raw" + raw.mkdir() + (raw / "new.json").write_text("new\n") + destination = tmp_path / "results" / "allure-result" + destination.mkdir(parents=True) + (destination / "stale.json").write_text("stale\n") + + result = run_e2e_bash( + "collect_allure_results", + env={"LYNX_RAW_ALLURE_DIR": str(raw), "RESULT_DIR": str(tmp_path / "results")}, + ) + + assert result.returncode == 0, result.stderr + assert sorted(path.name for path in destination.iterdir()) == ["new.json"] + + +def test_collect_allure_results_rejects_symlink_destination(tmp_path): + raw = tmp_path / "raw" + raw.mkdir() + (raw / "new.json").write_text("new\n") + protected = tmp_path / "protected" + protected.mkdir() + (protected / "keep").write_text("unchanged\n") + result_dir = tmp_path / "results" + result_dir.mkdir() + (result_dir / "allure-result").symlink_to(protected) + + result = run_e2e_bash( + "collect_allure_results", + env={"LYNX_RAW_ALLURE_DIR": str(raw), "RESULT_DIR": str(result_dir)}, + ) + + assert result.returncode != 0 + assert (protected / "keep").read_text() == "unchanged\n" + assert not (protected / "new.json").exists() + + +def test_collect_allure_results_copy_failure_keeps_old_results_and_cleans_stage(tmp_path): + raw = tmp_path / "raw" + raw.mkdir() + (raw / "new.json").write_text("new\n") + result_dir = tmp_path / "results" + destination = result_dir / "allure-result" + destination.mkdir(parents=True) + (destination / "old.json").write_text("old\n") + fake_cp = tmp_path / "cp" + fake_cp.write_text( + "#!/usr/bin/env bash\n" + 'destination=${!#}\nmkdir -p "$destination"\nprintf partial > "$destination/partial"\nexit 74\n' + ) + fake_cp.chmod(0o755) + + result = run_e2e_bash( + "collect_allure_results", + env={ + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "LYNX_RAW_ALLURE_DIR": str(raw), + "RESULT_DIR": str(result_dir), + }, + ) + + assert result.returncode != 0 + assert sorted(path.name for path in destination.iterdir()) == ["old.json"] + assert not list(result_dir.glob(".allure-result.tmp.*")) + + +def test_collect_allure_results_fails_when_raw_results_are_empty(tmp_path): + raw = tmp_path / "allure-results" + raw.mkdir() + + result = run_e2e_bash( + "collect_allure_results", + env={"LYNX_RAW_ALLURE_DIR": str(raw), "RESULT_DIR": str(tmp_path / "results")}, + ) + + assert result.returncode != 0 + assert "empty" in result.stderr.lower() + + +def test_generate_allure_report_is_attempted_for_nonempty_results(tmp_path): + result_dir = tmp_path / "results" + raw = result_dir / "allure-result" + raw.mkdir(parents=True) + (raw / "one-result.json").write_text("{}\n") + calls = tmp_path / "allure.calls" + fake_allure = tmp_path / "allure" + fake_allure.write_text( + "#!/usr/bin/env bash\n" + 'printf \'%s\\n\' "$*" > "$LYNX_TEST_CALLS"\n' + "exit 29\n" + ) + fake_allure.chmod(0o755) + + result = run_e2e_bash( + "generate_allure_report", + env={ + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "RESULT_DIR": str(result_dir), + "LYNX_TEST_CALLS": str(calls), + }, + ) + + assert result.returncode == 0, result.stderr + assert calls.read_text().strip() == ( + f"generate {raw} --clean -o {result_dir / 'allure-report'}" + ) + + +def test_generate_allure_report_rejects_symlink_destination_without_invoking_allure(tmp_path): + result_dir = tmp_path / "results" + raw = result_dir / "allure-result" + raw.mkdir(parents=True) + (raw / "one-result.json").write_text("{}\n") + protected = tmp_path / "protected-report" + protected.mkdir() + (protected / "keep").write_text("unchanged\n") + (result_dir / "allure-report").symlink_to(protected) + marker = tmp_path / "allure-called" + fake_allure = tmp_path / "allure" + fake_allure.write_text( + "#!/usr/bin/env bash\n" + 'touch "$LYNX_TEST_MARKER"\n' + "exit 99\n" + ) + fake_allure.chmod(0o755) + + result = run_e2e_bash( + "generate_allure_report", + env={ + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "RESULT_DIR": str(result_dir), + "LYNX_TEST_MARKER": str(marker), + }, + ) + + assert result.returncode != 0 + assert (protected / "keep").read_text() == "unchanged\n" + assert not marker.exists() + + +@pytest.mark.parametrize("test_exit", [0, 37]) +def test_run_e2e_raw_copy_failure_is_not_masked_and_preserves_test_failure(tmp_path, test_exit): + testing_dir = tmp_path / "testing" + testing_dir.mkdir() + nexus = testing_dir / "nexus.test" + nexus.write_text( + "#!/usr/bin/env bash\nmkdir -p allure-results\nprintf result > allure-results/result.json\n" + f"exit {test_exit}\n" + ) + nexus.chmod(0o555) + testing_dir.chmod(0o555) + config = tmp_path / "config.yaml" + config.write_text("{}\n") + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_cp = fake_bin / "cp" + fake_cp.write_text( + "#!/usr/bin/env bash\n" + 'if [[ "$*" == *allure-results* ]]; then exit 74; fi\nexec /bin/cp "$@"\n' + ) + fake_cp.chmod(0o755) + + result = run_e2e_bash( + "run_e2e", + env={ + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "LYNX_TESTING_DIR": str(testing_dir), + "LYNX_BDD_CONFIG": str(config), + "RESULT_DIR": str(tmp_path / "results"), + }, + ) + + assert result.returncode == (test_exit or 1) + assert not list((tmp_path / "results").glob(".lynx-raw-allure.*")) + + +def test_collect_allure_results_cleans_temporary_raw_copy(tmp_path): + testing_dir = tmp_path / "testing" + testing_dir.mkdir() + nexus = testing_dir / "nexus.test" + nexus.write_text( + "#!/usr/bin/env bash\nmkdir -p allure-results\nprintf result > allure-results/result.json\n" + ) + nexus.chmod(0o555) + testing_dir.chmod(0o555) + config = tmp_path / "config.yaml" + config.write_text("{}\n") + raw_path = tmp_path / "raw-path" + + result = run_e2e_bash( + f'run_e2e && printf %s "$LYNX_RAW_ALLURE_DIR" > "{raw_path}" && collect_allure_results', + env={ + "LYNX_TESTING_DIR": str(testing_dir), + "LYNX_BDD_CONFIG": str(config), + "RESULT_DIR": str(tmp_path / "results"), + }, + ) + + assert result.returncode == 0, result.stderr + assert not Path(raw_path.read_text()).exists() + + +def test_collect_diagnostics_queries_only_bounded_status_resources(tmp_path): + calls = tmp_path / "kubectl.calls" + write_fake_kubectl( + tmp_path, + '''printf '%s\n' "$*" >> "$LYNX_TEST_CALLS" +printf '%s\n' 'status output containing token diagnostic-secret-token' +printf '%s\n' 'endpoint https://diagnostic-user:diagnostic-password@example.test/repository' +printf '%s\n' 'credential diagnostic-generic-credential' +printf '%s\n' 'TOKEN=DIAGNOSTIC-UPPER-TOKEN' +printf '%s\n' 'token="diagnostic quoted token"' +printf '%s\n' 'Authorization: Bearer diagnostic-bearer remainder-secret' +printf '%s\n' 'nexus-ce-operator Succeeded Available' +printf '%s\n' 'stderr diagnostic-stderr-secret' >&2 +''', + ) + result_dir = tmp_path / "results" + + result = run_diagnostics_bash( + "collect_diagnostics", + env={ + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "KUBECONFIG": str(tmp_path / "proxy.kubeconfig"), + "OPERATOR_NAMESPACE": "nexus-ce-operator", + "RESULT_DIR": str(result_dir), + "LYNX_DIAGNOSTICS_TIMEOUT": "2", + "LYNX_TEST_CALLS": str(calls), + }, + ) + + assert result.returncode == 0, result.stderr + all_calls = calls.read_text().lower() + assert "--request-timeout=2s" in all_calls + assert any(resource in all_calls for resource in ("subscription", "clusterserviceversion")) + assert "deployment" in all_calls + assert "event" in all_calls + assert "secret" not in all_calls + assert "configmap" not in all_calls + event_call = next(line for line in all_calls.splitlines() if "get events" in line) + assert ".message" not in event_call + diagnostic = (result_dir / "diagnostics.log").read_text() + assert "## OLM subscriptions" in diagnostic + assert "nexus-ce-operator Succeeded Available" in diagnostic + assert "diagnostic-secret-token" not in diagnostic + assert "diagnostic-user" not in diagnostic + assert "diagnostic-password" not in diagnostic + assert "diagnostic-generic-credential" not in diagnostic + assert "DIAGNOSTIC-UPPER-TOKEN" not in diagnostic + assert "diagnostic quoted token" not in diagnostic + assert "diagnostic-bearer" not in diagnostic + assert "remainder-secret" not in diagnostic + assert "diagnostic-stderr-secret" not in diagnostic + + +def test_collect_diagnostics_rejects_symlink_log_destination(tmp_path): + protected = tmp_path / "protected.log" + protected.write_text("unchanged\n") + result_dir = tmp_path / "results" + result_dir.mkdir() + (result_dir / "diagnostics.log").symlink_to(protected) + + result = run_diagnostics_bash( + "collect_diagnostics", + env={"RESULT_DIR": str(result_dir)}, + ) + + assert result.returncode != 0 + assert protected.read_text() == "unchanged\n" + assert not list(result_dir.glob(".diagnostics.tmp.*")) + + +def test_collect_diagnostics_does_not_publish_when_sanitizer_fails(tmp_path): + result_dir = tmp_path / "results" + result_dir.mkdir() + output = result_dir / "diagnostics.log" + output.write_text("previous diagnostics\n") + + result = run_diagnostics_bash( + "_mask_diagnostic_output() { return 71; }; collect_diagnostics", + env={"RESULT_DIR": str(result_dir)}, + ) + + assert result.returncode != 0 + assert output.read_text() == "previous diagnostics\n" + assert not list(result_dir.glob(".diagnostics.tmp.*")) + + +def write_entrypoint_fixture(tmp_path, functions): + fixture = tmp_path / "fixture" + libraries = fixture / "lynx" + libraries.mkdir(parents=True) + (fixture / "lynx-entrypoint.sh").write_bytes(ENTRYPOINT.read_bytes()) + for name in ("common", "auth", "olm", "e2e", "diagnostics"): + (libraries / f"{name}.sh").write_text(functions) + return fixture / "lynx-entrypoint.sh" + + +def test_entrypoint_runs_phases_in_order_and_cleans_credentials(tmp_path): + calls = tmp_path / "calls" + result_dir = tmp_path / "results" + functions = ''' +log() { printf '%s\n' "$*" >> "$CALLS"; } +fatal() { log "ERROR: $*"; exit 1; } +require_env() { [[ -n ${!1:-} ]] || fatal "missing $1"; } +require_command() { :; } +require_positive_integer() { :; } +resolve_access_token() { printf token; } +write_proxy_kubeconfig() { printf kubeconfig > "$1"; log auth; } +write_bdd_config() { printf config > "$1"; } +install_operator() { log install; } +run_e2e() { log e2e; } +collect_allure_results() { log collect; } +generate_allure_report() { log report; } +collect_diagnostics() { log diagnostics; } +''' + entrypoint = write_entrypoint_fixture(tmp_path, functions) + + result = subprocess.run( + ["bash", str(entrypoint)], + text=True, + capture_output=True, + env={ + **os.environ, + "API_URL": "https://acp.example.test", + "REGION_NAME": "region-one", + "TOKEN": "secret-token", + "L5_PLUGINS_VERSION": '{"nexus-ce-operator":"nexus-ce-operator.v4.2.1"}', + "RESULT_DIR": str(result_dir), + "CALLS": str(calls), + }, + ) + + assert result.returncode == 0, result.stderr + assert calls.read_text().splitlines() == [ + "auth", "install", "e2e", "collect", "report", "[DONE]" + ] + assert not list(result_dir.glob(".lynx-credentials.*")) + + +def test_entrypoint_failure_preserves_status_collects_diagnostics_and_cleans_credentials(tmp_path): + calls = tmp_path / "calls" + result_dir = tmp_path / "results" + functions = ''' +log() { printf '%s\n' "$*" >> "$CALLS"; } +fatal() { log "ERROR: $*"; exit 1; } +require_env() { [[ -n ${!1:-} ]] || fatal "missing $1"; } +require_command() { :; } +require_positive_integer() { :; } +resolve_access_token() { printf token; } +write_proxy_kubeconfig() { printf kubeconfig > "$1"; } +write_bdd_config() { printf config > "$1"; } +install_operator() { :; } +run_e2e() { return 37; } +collect_allure_results() { log collect; } +generate_allure_report() { log report; } +collect_diagnostics() { log diagnostics; } +''' + entrypoint = write_entrypoint_fixture(tmp_path, functions) + + result = subprocess.run( + ["bash", str(entrypoint)], + text=True, + capture_output=True, + env={ + **os.environ, + "API_URL": "https://acp.example.test", + "REGION_NAME": "region-one", + "TOKEN": "secret-token", + "L5_PLUGINS_VERSION": '{"nexus-ce-operator":"nexus-ce-operator.v4.2.1"}', + "RESULT_DIR": str(result_dir), + "CALLS": str(calls), + }, + ) + + assert result.returncode == 37 + assert calls.read_text().splitlines() == ["collect", "report", "diagnostics"] + assert not list(result_dir.glob(".lynx-credentials.*")) + + +def test_entrypoint_requires_target_and_authentication_without_leaking_values(tmp_path): + result = subprocess.run( + ["bash", str(ENTRYPOINT)], text=True, capture_output=True, + env={"PATH": os.environ["PATH"], "RESULT_DIR": str(tmp_path / "results")}, + ) + + assert result.returncode != 0 + assert "API_URL" in result.stderr + + +def test_containerfile_installs_fixed_executable_entrypoint_and_libraries_explicitly(): + text = CONTAINERFILE.read_text() + entrypoint = ENTRYPOINT.read_text() + + assert re.search(r"COPY\s+testing/lynx-entrypoint\.sh\s+/app/lynx-entrypoint\.sh", text) + assert re.search(r"COPY\s+testing/lynx\s+/app/lynx", text) + assert "chmod 755 /app/lynx-entrypoint.sh" in text + assert "test -x /app/lynx-entrypoint.sh" in text + assert "ENTRYPOINT [\"/app/lynx-entrypoint.sh\"]" in text + assert "set -x" not in entrypoint + assert not re.search(r"run_e2e\s*\|\|\s*true", entrypoint) + + +def test_integration_pipeline_supplies_complete_test_image_build_context(): + text = INTEGRATION_PIPELINE.read_text() + build_test_image = text.split("- name: buildTestImage", 1)[1].split( + "- name: test", 1 + )[0] + + assert 'containerfilePath: testing/Containerfile' in build_test_image + assert 'context: "."' in build_test_image + assert 'workingDir: "."' in build_test_image + + +def test_integration_pipeline_supplies_complete_report_upload_object(): + text = INTEGRATION_PIPELINE.read_text() + report_upload = text.split("- name: reportUpload", 1)[1].split( + "- name: vmLabels", 1 + )[0] + + for field in ( + "endpoint", + "bucket", + "component", + "dirs", + "pathTemplate", + "preCommand", + "baseURL", + ): + assert re.search(rf"^\s*{field}:", report_upload, re.MULTILINE) diff --git a/testing/nexus-e2e/unit/test_maven_upstream.py b/testing/nexus-e2e/unit/test_maven_upstream.py new file mode 100644 index 00000000..5129df8d --- /dev/null +++ b/testing/nexus-e2e/unit/test_maven_upstream.py @@ -0,0 +1,183 @@ +import pytest +from libs import maven_upstream + +from libs.maven_upstream import ( + DEFAULT_REPOSITORY, + MavenUpstreamConfig, + load_maven_upstream, +) + + +def test_loads_default_repository_and_normalizes_url(): + config = load_maven_upstream( + { + "MAVEN_UPSTREAM_URL": " https://maven.example.test/// ", + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + } + ) + + assert config == MavenUpstreamConfig( + url="https://maven.example.test", + repository=DEFAULT_REPOSITORY, + username="reader", + password="secret", + ) + assert config.repository_url == ( + "https://maven.example.test/repository/maven-e2e-external/" + ) + + +def test_allows_repository_override(): + config = load_maven_upstream( + { + "MAVEN_UPSTREAM_URL": "https://maven.example.test", + "MAVEN_UPSTREAM_REPOSITORY": " custom-repository ", + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + } + ) + + assert config.repository == "custom-repository" + assert config.repository_url.endswith("/repository/custom-repository/") + + +def test_password_is_excluded_from_repr(): + config = MavenUpstreamConfig( + url="https://maven.example.test", + repository=DEFAULT_REPOSITORY, + username="reader", + password="secret", + ) + + assert "secret" not in repr(config) + + +def test_authenticated_upstream_takes_priority_over_legacy_mirror(): + remote = maven_upstream.select_proxy_remote( + { + "MAVEN_UPSTREAM_URL": "https://maven.example.test", + "MAVEN_UPSTREAM_REPOSITORY": "private-proxy", + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + "MACVEN_MIRROR_REGISTRY": "https://legacy.example.test", + } + ) + + assert remote == maven_upstream.MavenProxyRemote( + url="https://maven.example.test/repository/private-proxy/", + username="reader", + password="secret", + ) + + +def test_uses_legacy_mirror_override_and_normalizes_trailing_slash(): + remote = maven_upstream.select_proxy_remote( + {"MACVEN_MIRROR_REGISTRY": "https://legacy.example.test///"} + ) + + assert remote == maven_upstream.MavenProxyRemote(url="https://legacy.example.test/") + + +def test_uses_default_legacy_mirror(): + remote = maven_upstream.select_proxy_remote({}) + + assert remote == maven_upstream.MavenProxyRemote( + url="https://artifacts.alauda.io/repository/maven-central/" + ) + + +def test_proxy_remote_password_is_excluded_from_repr(): + remote = maven_upstream.MavenProxyRemote( + url="https://maven.example.test/repository/private-proxy/", + username="reader", + password="secret", + ) + + assert "secret" not in repr(remote) + + +@pytest.mark.parametrize( + ("missing_variable", "environment"), + [ + ( + "MAVEN_UPSTREAM_USERNAME", + { + "MAVEN_UPSTREAM_URL": "https://maven.example.test", + "MAVEN_UPSTREAM_PASSWORD": "secret", + }, + ), + ( + "MAVEN_UPSTREAM_PASSWORD", + { + "MAVEN_UPSTREAM_URL": "https://maven.example.test", + "MAVEN_UPSTREAM_USERNAME": "reader", + }, + ), + ], +) +def test_requires_credentials_when_url_is_set(missing_variable, environment): + with pytest.raises(ValueError, match=missing_variable): + load_maven_upstream(environment) + + +def test_returns_none_without_url(): + assert load_maven_upstream({}) is None + assert load_maven_upstream({"MAVEN_UPSTREAM_URL": ""}) is None + + +@pytest.mark.parametrize( + "url", + [ + "ftp://maven.example.test", + "maven.example.test", + "https://reader:secret@maven.example.test", + "https://maven.example.test?token=secret", + "https://maven.example.test#secret", + "https://[secret", + ], +) +def test_rejects_invalid_upstream_url_without_echoing_input(url): + with pytest.raises(ValueError) as error: + load_maven_upstream( + { + "MAVEN_UPSTREAM_URL": url, + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + } + ) + + assert str(error.value) == "MAVEN_UPSTREAM_URL must be an HTTP(S) base URL" + assert url not in str(error.value) + assert "secret" not in str(error.value) + + +def test_allows_upstream_url_with_base_path_and_port(): + config = load_maven_upstream( + { + "MAVEN_UPSTREAM_URL": " http://maven.example.test:8081/nexus/ ", + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + } + ) + + assert config.url == "http://maven.example.test:8081/nexus" + assert config.repository_url == ( + "http://maven.example.test:8081/nexus/repository/maven-e2e-external/" + ) + + +@pytest.mark.parametrize( + "repository", + ["", " ", "nested/repository", "repository?query", "repository#fragment"], +) +def test_rejects_invalid_repository_name(repository): + with pytest.raises(ValueError, match="MAVEN_UPSTREAM_REPOSITORY"): + load_maven_upstream( + { + "MAVEN_UPSTREAM_URL": "https://maven.example.test", + "MAVEN_UPSTREAM_REPOSITORY": repository, + "MAVEN_UPSTREAM_USERNAME": "reader", + "MAVEN_UPSTREAM_PASSWORD": "secret", + } + ) diff --git a/testing/nexus-e2e/unit/test_nexus_client.py b/testing/nexus-e2e/unit/test_nexus_client.py new file mode 100644 index 00000000..8b138595 --- /dev/null +++ b/testing/nexus-e2e/unit/test_nexus_client.py @@ -0,0 +1,89 @@ +import pytest + +from libs.nexus_client import NexusClient, _get_repository_config + + +def test_proxy_config_includes_remote_authentication(): + config = _get_repository_config( + "maven", + "authenticated-proxy", + repo_type="proxy", + remote_url="https://maven.example.test/repository/releases/", + remote_username="reader", + remote_password="upstream-secret", + ) + + assert config["httpClient"]["authentication"] == { + "type": "username", + "username": "reader", + "password": "upstream-secret", + } + + +def test_proxy_config_without_credentials_omits_authentication(): + config = _get_repository_config( + "maven", + "anonymous-proxy", + repo_type="proxy", + remote_url="https://repo.maven.apache.org/maven2/", + ) + + assert "authentication" not in config["httpClient"] + + +@pytest.mark.parametrize( + ("remote_username", "remote_password"), + [("reader", None), (None, "upstream-secret")], +) +def test_proxy_config_rejects_partial_credentials_without_exposing_password( + remote_username, remote_password +): + with pytest.raises(ValueError) as exc_info: + _get_repository_config( + "maven", + "invalid-proxy", + repo_type="proxy", + remote_url="https://maven.example.test/repository/releases/", + remote_username=remote_username, + remote_password=remote_password, + ) + + assert "upstream-secret" not in str(exc_info.value) + assert "upstream-secret" not in repr(exc_info.value) + + +def test_update_proxy_config_sends_remote_authentication_without_changing_session_auth( + monkeypatch, +): + client = NexusClient("https://nexus.example.test/base/", "admin", "nexus-secret") + original_auth = client.session.auth + request = {} + + class FakeResponse: + def raise_for_status(self): + return None + + def fake_put(url, json): + request.update(url=url, json=json) + return FakeResponse() + + monkeypatch.setattr(client.session, "put", fake_put) + + client.update_proxy_config( + "maven", + "authenticated-proxy", + remote_url="https://maven.example.test/repository/releases/", + remote_username="reader", + remote_password="upstream-secret", + ) + + assert request["url"] == ( + "https://nexus.example.test/base/service/rest/v1/repositories/" + "maven/proxy/authenticated-proxy" + ) + assert request["json"]["httpClient"]["authentication"] == { + "type": "username", + "username": "reader", + "password": "upstream-secret", + } + assert client.session.auth == original_auth == ("admin", "nexus-secret") diff --git a/testing/nexus-e2e/unit/test_prepare_maven_e2e_bundle.py b/testing/nexus-e2e/unit/test_prepare_maven_e2e_bundle.py new file mode 100644 index 00000000..66b13aa9 --- /dev/null +++ b/testing/nexus-e2e/unit/test_prepare_maven_e2e_bundle.py @@ -0,0 +1,387 @@ +import json +import os +import stat +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + + +SCRIPT = Path(__file__).parents[2] / "hack" / "prepare-maven-e2e-bundle.sh" +CONTAINERFILE = SCRIPT.parents[1] / "Containerfile" +DOCKERIGNORE = SCRIPT.parents[2] / ".dockerignore" + + +def dockerfile_instructions(): + instructions = [] + current = "" + for raw_line in CONTAINERFILE.read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + current = f"{current} {line}".strip() + if current.endswith("\\"): + current = current[:-1].rstrip() + else: + instructions.append(current) + current = "" + assert not current + return instructions + + +def dockerfile_stage(name): + instructions = dockerfile_instructions() + start = instructions.index(f"FROM test-base AS {name}") + end = next( + (index for index in range(start + 1, len(instructions)) if instructions[index].startswith("FROM ")), + len(instructions), + ) + return instructions[start:end] + + +def run_script(*args, env=None): + return subprocess.run( + [str(SCRIPT), *(str(arg) for arg in args)], + text=True, + capture_output=True, + env=env, + ) + + +def make_project(tmp_path): + project = tmp_path / "project" + project.mkdir() + (project / "publish.xml").write_text("") + (project / "download.xml").write_text("") + return project + + +def fake_maven_environment(tmp_path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + log = tmp_path / "mvn.jsonl" + settings_capture = tmp_path / "settings.xml" + fake = bin_dir / "mvn" + fake.write_text( + """#!/usr/bin/env python3 +import json +import os +import pathlib +import shutil +import sys + +with open(os.environ["FAKE_MVN_LOG"], "a") as stream: + stream.write(json.dumps(sys.argv[1:]) + "\\n") +if "-o" in sys.argv and "deploy" in sys.argv: + sys.exit(99) +settings = pathlib.Path(sys.argv[sys.argv.index("-s") + 1]) +shutil.copyfile(settings, os.environ["FAKE_SETTINGS_CAPTURE"]) + +repo_arg = next(arg for arg in sys.argv if arg.startswith("-Dmaven.repo.local=")) +repo = pathlib.Path(repo_arg.split("=", 1)[1]) +if not (repo / "junit/junit/4.11/junit-4.11.jar").exists(): + files = { + "junit/junit/4.11/junit-4.11.jar": "junit", + "org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar": "hamcrest", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.jar": "deploy", + "org/apache/maven/plugins/maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.pom": "deploy", + "junit/junit/4.11/_remote.repositories": "metadata", + "bad/example/1/example-1.jar.lastUpdated": "metadata", + "resolver-status.properties": "metadata", + "com/nexus/test/test-publish/1.0-SNAPSHOT/test-publish.jar": "own", + } + for relative, value in files.items(): + path = repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value) +""" + ) + fake.chmod(fake.stat().st_mode | stat.S_IXUSR) + env = os.environ.copy() + env.update( + PATH=f"{bin_dir}{os.pathsep}{env['PATH']}", + FAKE_MVN_LOG=str(log), + FAKE_SETTINGS_CAPTURE=str(settings_capture), + ) + return env, log, settings_capture + + +def test_requires_exactly_two_arguments_and_prints_usage(): + result = run_script() + assert result.returncode != 0 + assert "Usage:" in result.stderr + + +@pytest.mark.parametrize("target", ["/", "same-as-project", "source-root"]) +def test_rejects_dangerous_bundle_targets(tmp_path, target): + project = make_project(tmp_path) + if target == "same-as-project": + bundle = project + elif target == "source-root": + bundle = SCRIPT.parents[2] + else: + bundle = target + result = run_script(project, bundle) + assert result.returncode != 0 + assert "unsafe" in result.stderr.lower() + + +def add_cleanup_sentinels(bundle): + sentinels = [ + bundle / "repository" / "sentinel", + bundle / "deployment" / "sentinel", + bundle / "settings.xml", + ] + for sentinel in sentinels: + sentinel.parent.mkdir(parents=True, exist_ok=True) + sentinel.write_text("do-not-delete") + return sentinels + + +def cleanup_sentinels(bundle, sentinels): + for sentinel in sentinels: + sentinel.unlink(missing_ok=True) + for directory in [bundle / "repository", bundle / "deployment"]: + if directory.exists(): + directory.rmdir() + + +@pytest.mark.parametrize("spelling", ["dot", "child-parent"]) +def test_rejects_project_alias_before_deleting_sentinels(tmp_path, spelling): + project = make_project(tmp_path) + (project / "child").mkdir() + bundle = f"{project}/." if spelling == "dot" else f"{project}/child/.." + sentinels = add_cleanup_sentinels(project) + + result = run_script(project, bundle) + + assert result.returncode != 0 + assert "unsafe" in result.stderr.lower() + assert all(sentinel.read_text() == "do-not-delete" for sentinel in sentinels) + + +def test_rejects_source_root_dot_before_deleting_sentinels(tmp_path): + project = make_project(tmp_path) + source_root = SCRIPT.parents[2] + assert all(not path.exists() for path in [ + source_root / "repository", source_root / "deployment", source_root / "settings.xml" + ]) + sentinels = add_cleanup_sentinels(source_root) + try: + result = run_script(project, f"{source_root}/.") + assert result.returncode != 0 + assert "unsafe" in result.stderr.lower() + assert all(sentinel.read_text() == "do-not-delete" for sentinel in sentinels) + finally: + cleanup_sentinels(source_root, sentinels) + + +@pytest.mark.parametrize("destination", ["project", "source-root"]) +def test_rejects_alias_through_symlinked_ancestor(tmp_path, destination): + project = make_project(tmp_path) + target = project if destination == "project" else SCRIPT.parents[2] + alias_parent = tmp_path / "alias-parent" + alias_parent.symlink_to(target.parent, target_is_directory=True) + bundle = alias_parent / target.name + if target == SCRIPT.parents[2]: + assert all(not path.exists() for path in [ + target / "repository", target / "deployment", target / "settings.xml" + ]) + sentinels = add_cleanup_sentinels(target) + try: + result = run_script(project, bundle) + assert result.returncode != 0 + assert "unsafe" in result.stderr.lower() + assert all(sentinel.read_text() == "do-not-delete" for sentinel in sentinels) + finally: + if target == SCRIPT.parents[2]: + cleanup_sentinels(target, sentinels) + + +@pytest.mark.parametrize("suffix", ["", "/"]) +def test_rejects_direct_bundle_symlink(tmp_path, suffix): + project = make_project(tmp_path) + target = tmp_path / "bundle-target" + target.mkdir() + sentinels = add_cleanup_sentinels(target) + alias = tmp_path / "bundle-alias" + alias.symlink_to(target, target_is_directory=True) + + result = run_script(project, f"{alias}{suffix}") + + assert result.returncode != 0 + assert "unsafe" in result.stderr.lower() + assert all(sentinel.read_text() == "do-not-delete" for sentinel in sentinels) + + +def test_requires_project_files(tmp_path): + project = tmp_path / "project" + project.mkdir() + result = run_script(project, tmp_path / "bundle") + assert result.returncode != 0 + assert "publish.xml" in result.stderr + + +def test_builds_and_verifies_container_independent_bundle(tmp_path): + project = make_project(tmp_path) + bundle = tmp_path / "bundle" + env, log, settings_capture = fake_maven_environment(tmp_path) + env["MAVEN_BUNDLE_MIRROR_URL"] = "https://mirror.test/repository/public?a=1&b=2" + + result = run_script(project, bundle, env=env) + + assert result.returncode == 0, result.stderr + calls = [json.loads(line) for line in log.read_text().splitlines()] + assert len(calls) == 4 + assert ["clean", "deploy"] == calls[0][-2:] + assert calls[1][-1] == "package" + assert ["clean", "install"] == calls[2][-2:] + assert calls[3][-1] == "package" + assert all(any(arg.startswith("-Dmaven.repo.local=") for arg in call) for call in calls) + assert all("-o" not in call for call in calls[:2]) + assert all("-o" in call for call in calls[2:]) + deploy = next(arg for arg in calls[0] if arg.startswith("-DaltDeploymentRepository=")) + assert deploy.startswith("-DaltDeploymentRepository=bundle::default::file://") + + root = ET.parse(settings_capture).getroot() + ns = {"m": "http://maven.apache.org/SETTINGS/1.0.0"} + assert root.findtext("m:localRepository", namespaces=ns) == str(bundle / "repository") + assert root.findtext("m:mirrors/m:mirror/m:mirrorOf", namespaces=ns) == "central" + assert root.findtext("m:mirrors/m:mirror/m:url", namespaces=ns) == env["MAVEN_BUNDLE_MIRROR_URL"] + assert root.find("m:servers", ns) is None + + repo = bundle / "repository" + assert (repo / "junit/junit/4.11/junit-4.11.jar").is_file() + assert not (repo / "junit/junit/4.11/junit-4.11.jar").is_symlink() + assert (repo / "org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar").is_file() + deploy_plugin = repo / "org/apache/maven/plugins/maven-deploy-plugin/2.8.2" + assert (deploy_plugin / "maven-deploy-plugin-2.8.2.jar").is_file() + assert (deploy_plugin / "maven-deploy-plugin-2.8.2.pom").is_file() + assert not list(repo.rglob("_remote.repositories")) + assert not list(repo.rglob("*.lastUpdated")) + assert not list(repo.rglob("resolver-status.properties")) + assert not (repo / "com/nexus/test/test-publish").exists() + assert not (bundle / "deployment").exists() + assert not (bundle / "settings.xml").exists() + + +def test_uses_legacy_mirror_environment_fallback(tmp_path): + project = make_project(tmp_path) + bundle = tmp_path / "bundle" + env, _, settings_capture = fake_maven_environment(tmp_path) + env.pop("MAVEN_BUNDLE_MIRROR_URL", None) + env["MACVEN_MIRROR_REGISTRY"] = "https://legacy-mirror.test/maven" + result = run_script(project, bundle, env=env) + assert result.returncode == 0, result.stderr + assert "https://legacy-mirror.test/maven" in settings_capture.read_text() + + +def test_rejects_repository_symlink_without_deleting_external_files(tmp_path): + project = make_project(tmp_path) + bundle = tmp_path / "bundle" + bundle.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + sentinel = outside / "sentinel" + sentinel.write_text("do-not-delete") + (bundle / "repository").symlink_to(outside, target_is_directory=True) + env, _, _ = fake_maven_environment(tmp_path) + + result = run_script(project, bundle, env=env) + + assert result.returncode != 0 + assert "symlink" in result.stderr.lower() or "unsafe" in result.stderr.lower() + assert sentinel.read_text() == "do-not-delete" + + +def test_bundle_swap_before_cleanup_does_not_delete_external_files(tmp_path): + project = make_project(tmp_path) + bundle = tmp_path / "bundle" + outside = tmp_path / "outside" + (outside / "repository").mkdir(parents=True) + sentinel = outside / "repository" / "sentinel" + sentinel.write_text("do-not-delete") + env, _, _ = fake_maven_environment(tmp_path) + swapper = tmp_path / "bin" / "python3" + swapper.write_text( + f"""#!{sys.executable} +import os +import pathlib +import sys + +marker = pathlib.Path(os.environ["PYTHON_SWAP_MARKER"]) +if not marker.exists(): + marker.write_text("swapped") + bundle = pathlib.Path(os.environ["SWAP_BUNDLE"]) + bundle.rename(bundle.with_name("bundle-original")) + bundle.symlink_to(os.environ["SWAP_OUTSIDE"], target_is_directory=True) +os.execv({sys.executable!r}, [{sys.executable!r}, *sys.argv[1:]]) +""" + ) + swapper.chmod(swapper.stat().st_mode | stat.S_IXUSR) + env.update( + PYTHON_SWAP_MARKER=str(tmp_path / "python-swap-marker"), + SWAP_BUNDLE=str(bundle), + SWAP_OUTSIDE=str(outside), + ) + + result = run_script(project, bundle, env=env) + + assert result.returncode != 0 + assert sentinel.read_text() == "do-not-delete" + + +def test_containerfile_builds_maven_bundle_in_an_isolated_stage(): + instructions = dockerfile_instructions() + bundle_stage = "\n".join(dockerfile_stage("maven-bundle")) + final_stage = "\n".join(dockerfile_stage("test-image")) + + assert "FROM registry-dev.alauda.io/platform-edge/python:3.12-slim AS test-base" in instructions + assert "ARG MAVEN_BUNDLE_MIRROR_URL=https://artifacts.alauda.io/repository/maven-central" in bundle_stage + assert "MAVEN_BUNDLE_MIRROR_URL=$MAVEN_BUNDLE_MIRROR_URL" in bundle_stage + assert "PATH=/tools/bin/maven/bin:$PATH" in bundle_stage + assert "testing/nexus-e2e/test_projects/maven" in bundle_stage + assert "testing/hack/prepare-maven-e2e-bundle.sh" in bundle_stage + assert "/opt/nexus-e2e/maven-bundle/repository /opt/nexus-e2e/maven-repository" in bundle_stage + assert "/opt/nexus-e2e/maven-repository" in bundle_stage + assert "MAVEN_BUNDLE_MIRROR_URL" not in final_stage + + +def test_containerfile_final_image_packages_only_runtime_bundle_assets(): + final_stage = "\n".join(dockerfile_stage("test-image")) + + assert "COPY --from=maven-bundle /opt/nexus-e2e/maven-repository /opt/nexus-e2e/maven-repository" in final_stage + assert "COPY testing/hack/import-maven-e2e-dependencies.py /usr/local/bin/import-maven-e2e-dependencies" in final_stage + assert "chmod 755 /usr/local/bin/import-maven-e2e-dependencies" in final_stage + assert "junit/junit/4.11/junit-4.11.jar" in final_stage + assert "org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar" in final_stage + assert "maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.jar" in final_stage + assert "maven-deploy-plugin/2.8.2/maven-deploy-plugin-2.8.2.pom" in final_stage + assert "ENTRYPOINT [\"/app/lynx-entrypoint.sh\"]" in final_stage + assert not any(line.startswith("CMD ") for line in final_stage.splitlines()) + assert final_stage.count("ENTRYPOINT") == 1 + assert final_stage.count("CMD") == 0 + assert "settings.xml" not in final_stage + assert "deployment" not in final_stage + assert "com/nexus/test/test-publish" not in final_stage + + +def test_dockerignore_excludes_workspace_artifacts_without_excluding_build_inputs(): + patterns = { + line.strip() + for line in DOCKERIGNORE.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + + assert {".git", ".git/**", ".worktrees", "**/__pycache__", "**/*.py[cod]"} <= patterns + assert {"**/target", "**/target/**", ".env", ".env.*"} <= patterns + assert {".pytest_cache", "**/.pytest_cache", ".DS_Store", "**/.DS_Store"} <= patterns + assert not { + "go.mod", + "testing", + "testing/Containerfile", + "testing/hack/prepare-maven-e2e-bundle.sh", + "testing/nexus-e2e/test_projects/maven", + } & patterns diff --git a/testing/script/prepare-cluster.sh b/testing/script/prepare-cluster.sh new file mode 100755 index 00000000..b49f6a84 --- /dev/null +++ b/testing/script/prepare-cluster.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# DEVOPS-44489: ported byte-for-byte (functions unchanged below) from +# gitlab-ce-operator's migration branch (alauda-devops-toolchain/gitlab-ce-operator, +# testing/script/prepare-cluster.sh) -- same shared ctyun-vm-integration-test harness, +# same throwaway single-node-VM ingress/RWX gaps, already proven green there. Driver +# direction (2026-07-23): the migrated e2e's case scope must match the ORIGINAL github +# pipeline exactly (it used vcluster-integration-test, which synced real ingress from +# the host) -- so network.feature's ingress scenarios must actually pass here, not be +# tagged @e2e to route around the gap. See .tekton/integration-test.yaml's `deploy` +# param for how this gets invoked. +# +# DEVOPS-44461 (original gitlab-ce-operator ticket, comments below unchanged): prepare +# the throwaway ctyun-vm k3s cluster for the gitlab-chart smoke +# suite. Best-effort by DESIGN -- deploy has no onError:continue, and a hard failure here +# would SKIP run-test entirely (runAfter) and burn the whole VM cycle with zero test +# signal, which is worse than a patch silently not helping. So every step is allowed to +# fail and the script always returns 0. +# +# Env: SOURCE_PATH (workspace source root, required); KUBECTL_IMAGE (image for the on-VM +# pull-probe, required). KUBECONFIG is derived from SOURCE_PATH. +set -ux +: "${SOURCE_PATH:?SOURCE_PATH required}" +export KUBECONFIG="${SOURCE_PATH}/.git/ctyun-vm/kubeconfig" + +# ---- RWX storage for the HA smoke scenario ---------------------------------------------- +# (rationale in testdata/resources/rwx-hostpath.yaml) A static hostPath PV advertised as +# ReadWriteMany + a no-provisioner `nfs` StorageClass: on the single-node VM hostPath is +# shared RW by all pods, a real RWX equivalent WITHOUT needing an NFS client on the node. +rwx_storage() { + kubectl apply -f "${SOURCE_PATH}/testing/testdata/resources/rwx-hostpath.yaml" || echo "WARN: rwx-hostpath apply failed" + # loop-generate the RWX PV pool (RWX_PV_POOL, default 40) instead of writing N PVs out; + # PVs advertise all access modes (RWO+RWX+ROX) so any PVC (uploads RWX, gitaly/redis RWO) binds. + __pool="${RWX_PV_POOL:-40}" + { __i=0; while [ "$__i" -lt "$__pool" ]; do __n=$(printf '%02d' "$__i"); cat < returns the default first, and + # local-path (RWO) can't back the shared uploads PVC. Drop local-path's default flag. + kubectl patch storageclass local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}' 2>/dev/null || true + kubectl patch storageclass nfs -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' 2>/dev/null || true + kubectl get storageclass || true + echo "nfs RWX PVs: $(kubectl get pv -l pool=nfs-rwx --no-headers 2>/dev/null | wc -l)" +} + +# ---- make the ingress controller publish a status IP ------------------------------------ +# bdd's StepExistIngressController creates a CLASSLESS probe Ingress and polls its +# .status.loadBalancer.ingress[0].ip for 2min; the baked controller neither watches +# classless Ingresses nor publishes an address, so ha(135)/https(211) time out. Annotate +# the nginx IngressClass default + add --publish-status-address= + +# --watch-ingress-without-class=true, then roll out. Diagnostics land in deploy-diag.log +# (uploaded with allure-results). +fix_ingress() { + local NS=ingress-nginx DIAG NODE_IP ICLASS CDEPLOY CARGS SVC + DIAG="${SOURCE_PATH}/testing/deploy-diag.log" + mkdir -p "$(dirname "${DIAG}")" 2>/dev/null || true + : > "${DIAG}" 2>/dev/null || true + + echo "=== live cluster state before run-test (diagnostic) ===" + kubectl get pods -A || true + kubectl get ingressclass || true + + NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}' 2>/dev/null || true) + echo "VM node InternalIP: ${NODE_IP:-}" + SVC=$(kubectl get svc -n "${NS}" -o jsonpath='{.items[*].metadata.name}' 2>/dev/null | tr ' ' '\n' | grep -E 'controller$' | head -1 || true) + ICLASS=$(kubectl get ingressclass -o jsonpath='{.items[*].metadata.name}' 2>/dev/null | tr ' ' '\n' | grep -iE 'nginx' | head -1 || true) + echo "nginx IngressClass: ${ICLASS:-} / controller Service: ${NS}/${SVC:-}" + [ -n "${ICLASS:-}" ] && kubectl annotate ingressclass "${ICLASS}" ingressclass.kubernetes.io/is-default-class=true --overwrite || echo "WARN: default-class annotate skipped/failed" + + CDEPLOY=$(kubectl get deploy -n "${NS}" -o jsonpath='{.items[*].metadata.name}' 2>/dev/null | tr ' ' '\n' | grep -E 'controller$' | head -1 || true) + echo "ingress-nginx controller Deployment: ${NS}/${CDEPLOY:-}" + if [ -n "${CDEPLOY:-}" ] && [ -n "${NODE_IP:-}" ]; then + CARGS=$(kubectl get deploy "${CDEPLOY}" -n "${NS}" -o jsonpath='{.spec.template.spec.containers[0].args}' 2>/dev/null || true) + case "${CARGS}" in + *publish-status-address*) echo "controller already has --publish-status-address, skipping arg patch" ;; + *) kubectl patch deploy "${CDEPLOY}" -n "${NS}" --type=json -p "[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/args/-\",\"value\":\"--publish-status-address=${NODE_IP}\"},{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/args/-\",\"value\":\"--watch-ingress-without-class=true\"}]" || echo "WARN: controller args patch failed" ;; + esac + kubectl rollout status deploy/"${CDEPLOY}" -n "${NS}" --timeout=120s || echo "WARN: controller rollout not complete" + fi + { echo "=== deploy-diag: ingressclasses ==="; kubectl get ingressclass -o yaml; echo "=== controller deploy args ==="; kubectl get deploy -n "${NS}" -o jsonpath='{range .items[*]}{.metadata.name}{": "}{.spec.template.spec.containers[0].args}{"\n"}{end}'; echo; echo "=== ingress-nginx pods ==="; kubectl get pods -n "${NS}" -o wide; echo "=== ingress-nginx svc ==="; kubectl get svc -n "${NS}" -o wide; } >> "${DIAG}" 2>&1 || true +} + +# ---- reproduce the CNG image pull directly on the VM k3s --------------------------------- +# The shared-secrets Helm pre-install hook has failed live with "image can't be pulled" for +# this same kubectl image on the VM's own k3s (a different net/auth path than this step, +# which runs on edge-build). Reproduce the pull here so a real VM-side gap surfaces with a +# clear reason instead of a silent 5-minute helm timeout. +pull_probe() { + : "${KUBECTL_IMAGE:?KUBECTL_IMAGE required}" + echo "=== reproducing the CNG image pull directly on the VM k3s ===" + kubectl delete pod deploy-step-pull-probe -n default --ignore-not-found --wait=true || true + kubectl run deploy-step-pull-probe -n default --restart=Never --image="${KUBECTL_IMAGE}" --command -- sleep 30 || echo "WARN: probe pod create failed" + for i in $(seq 1 18); do + phase=$(kubectl get pod deploy-step-pull-probe -n default -o jsonpath='{.status.phase}' 2>/dev/null || true) + echo "pull-probe phase (poll $i): ${phase:-}" + [ "${phase}" = "Running" ] && { echo "PULL-PROBE OK: image pulled and running"; break; } + sleep 5 + done + kubectl describe pod deploy-step-pull-probe -n default 2>&1 | tail -30 || true + kubectl delete pod deploy-step-pull-probe -n default --ignore-not-found || true +} + +# ---- also make port 80/443 reachable on the node IP + belt-and-suspenders status IP ---- +# --publish-status-address (above) makes the readiness probe pass; this additionally sets +# the controller Service externalIPs to the node IP so http(s)://:80/443 routes to +# ingress-nginx for the ha/https scenarios, and (Service flipped to LoadBalancer so the +# status subresource is writable) patches status.loadBalancer.ingress directly as a second +# path to the same IP. Kept from the proven-green config; NodePort access is unaffected. +publish_service_status() { + local NS=ingress-nginx SVC NODE_IP ip i + NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}' 2>/dev/null || true) + SVC=$(kubectl get svc -n "${NS}" -o jsonpath='{.items[*].metadata.name}' 2>/dev/null | tr ' ' '\n' | grep -E 'controller$' | head -1 || true) + [ -n "${SVC:-}" ] && [ -n "${NODE_IP:-}" ] || { echo "WARN: no ingress controller Service or node IP -- skipping externalIPs patch"; return 0; } + kubectl patch svc "${SVC}" -n "${NS}" --type=merge -p "{\"spec\":{\"externalIPs\":[\"${NODE_IP}\"]}}" || echo "WARN: externalIPs patch failed" + kubectl patch svc "${SVC}" -n "${NS}" --type=merge -p "{\"spec\":{\"type\":\"LoadBalancer\"}}" || echo "WARN: type=LoadBalancer patch failed" + kubectl patch svc "${SVC}" -n "${NS}" --type=merge --subresource=status -p "{\"status\":{\"loadBalancer\":{\"ingress\":[{\"ip\":\"${NODE_IP}\"}]}}}" || echo "WARN: status.loadBalancer.ingress patch failed" + echo "=== verifying a throwaway Ingress gets a status IP (mirrors StepExistIngressController) ===" + kubectl create ingress deploy-step-ingress-probe -n default --rule="deploy-step-probe.example.com/=example-service:80" --dry-run=client -o yaml 2>/dev/null | kubectl apply -f - || echo "WARN: probe ingress create failed" + for i in $(seq 1 12); do + ip=$(kubectl get ingress deploy-step-ingress-probe -n default -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || true) + [ -n "${ip}" ] && { echo "PROBE OK: ingress status IP = ${ip}"; break; } + echo "probe wait $i (no status IP yet)"; sleep 5 + done + kubectl delete ingress deploy-step-ingress-probe -n default --ignore-not-found || true +} + + +rwx_storage +fix_ingress +publish_service_status +exit 0 diff --git a/testing/testdata/resources/rwx-hostpath.yaml b/testing/testdata/resources/rwx-hostpath.yaml new file mode 100644 index 00000000..442b895b --- /dev/null +++ b/testing/testdata/resources/rwx-hostpath.yaml @@ -0,0 +1,13 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: nfs + annotations: + storageclass.kubernetes.io/is-default-class: "true" +# DEVOPS-44461: nfs is the DEFAULT SC so bdd's (default-first) resolves to it for +# every component; local-path is RWO-only (verified: v0.0.31 rejects RWM). no-provisioner; the RWX +# PV pool is loop-generated in prepare-cluster.sh (RWX_PV_POOL, default 40) -- PVs advertise all +# access modes (RWO+RWX+ROX) so any PVC binds. hostPath capacity is a binding label, not allocation. +provisioner: kubernetes.io/no-provisioner +volumeBindingMode: Immediate +reclaimPolicy: Retain diff --git a/testing/testdata/snippets/base-values.yaml b/testing/testdata/snippets/base-values.yaml index 45e34fbd..1ee0b030 100644 --- a/testing/testdata/snippets/base-values.yaml +++ b/testing/testdata/snippets/base-values.yaml @@ -1,9 +1,35 @@ global: + # DEVOPS-44489: enabled: true (chart default) -- this suite's ctyun-vm + # single-node k3s throwaway cluster uses an nfs-default-backed + # StorageClass for PVC storage (added by this migration's + # prepare-cluster.sh RWX fix). fsGroup does NOT trigger a recursive + # chown on NFS-backed volumes (a well-known k8s limitation -- fsGroup + # only works for volume types that support SecurityContext ownership + # management, which excludes NFS), so nxrm-app's non-root UID (200) + # cannot mkdir/write into a freshly-provisioned NFS PV, and the + # chart's own init-log-dir init container CrashLoopBackOffs + # ("mkdir: Permission denied", confirmed live on + # nexus-integration-test-mfpcw's nodeport/storage-sc/storage-pvc + # scenarios -- Pod stuck Pending, init-log-dir exitCode 1 x7 + # restarts). The chart already ships a matching fix for exactly this + # class of environment: global.permission.initContainer.enabled + # gates an explicit "volume-permissions" init container that chowns + # /nexus-data as root BEFORE init-log-dir runs (chart/templates/ + # statefulset.yaml) -- same principle as the gitlab-chart cohort's + # hostPath-no-chown -> chown-inits fix for the same ctyun-vm/non-root + # NFS class of issue. This upstream snippet had it hardcoded false + # (commit 04a5c86, predates this migration) which only worked on + # whatever storage backend upstream's own CI provided (values- + # storage-hostpath.yaml already independently re-enables it to true + # for its own explicit hostPath volume, which is a different volume + # type that inherits node directory perms rather than PVC/NFS + # provisioner defaults -- hostpath is the only storage.feature + # scenario that self-heals without this base-level flip). permission: initContainer: - enabled: false + enabled: true registry: - address: + address: secret: nexusAdminSecret: enabled: true