commit ab6edbd967d725f114ea98c67b129dd629424b79 Author: Konstantin Demin Date: Tue May 28 14:41:14 2024 +0300 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e4655a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.vscode/* +publish-nexus* +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33cf34d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/publish-nexus* diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ac58dc0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "yaml.schemas": { + "https://github.com/woodpecker-ci/woodpecker/raw/main/pipeline/frontend/yaml/linter/schema/schema.json": + ".woodpecker/.*.yml" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..54e8e6b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +ARG GO_IMAGE=docker.io/library/golang:1.22.3-bookworm +ARG BASE_IMAGE=gcr.io/distroless/static-debian12:debug-nonroot + +## --- + +FROM ${GO_IMAGE} as build +SHELL [ "/bin/sh", "-ec" ] + +ARG GOPROXY +ARG GOSUMDB +ARG GOPRIVATE + +ARG RELMODE + +WORKDIR /go/src + +COPY . . + +ENV GOMAXPROCS=4 \ + MALLOC_ARENA_MAX=4 + +RUN go env | grep -F -e GOPROXY -e GOSUMDB ; \ + make OUTDIR=/go/bin ; \ + make ci-clean + +## --- + +FROM ${BASE_IMAGE} + +COPY --from=build /go/bin/publish-nexus /bin/ + +ENV GOMAXPROCS=4 \ + MALLOC_ARENA_MAX=4 + +ENTRYPOINT [ ] +CMD [ "/bin/publish-nexus" ] + +USER nonroot:nonroot diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..67db858 --- /dev/null +++ b/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..71e0921 --- /dev/null +++ b/Makefile @@ -0,0 +1,66 @@ +#!/usr/bin/make -f +# SPDX-License-Identifier: Apache-2.0 +# (c) 2024, Konstantin Demin + +SHELL :=/bin/sh +.SHELLFLAGS :=-ec + +.NOTPARALLEL: + +BIN := publish-nexus + +OUTDIR ?= . +OUTSFX ?= +OUTBIN ?= $(OUTDIR)/$(BIN)$(OUTSFX) + +export GO ?= go +export CGO_ENABLED ?= 0 +TAGS ?= +LDFLAGS ?= +GO_BUILDFLAGS ?= +GO_LDFLAGS := -w $(LDFLAGS) + +comma :=, +ifeq ($(RELMODE),1) + ## not ready yet + # TAGS := nodebug$(if $(strip $(TAGS)),$(comma)$(strip $(TAGS))) + GO_LDFLAGS += -s +endif + +.PHONY: all +all: build + +.PHONY: clean build dev-build ci-clean + +clean: + $(if $(wildcard $(OUTBIN)),rm -fv $(OUTBIN),:) + +build: $(OUTBIN) + +test_git = git -c log.showsignature=false show -s --format=%H:%ct + +$(OUTBIN): + @:; \ + GO_BUILDFLAGS='$(strip $(GO_BUILDFLAGS))' ; \ + if ! $(test_git) >/dev/null 2>&1 ; then \ + echo "!!! git information is asbent !!!" >&2 ; \ + GO_BUILDFLAGS="-buildvcs=false $${GO_BUILDFLAGS}" ; \ + fi ; \ + $(GO) build -o $@ \ + $${GO_BUILDFLAGS} \ + $(if $(strip $(TAGS)),-tags '$(strip $(TAGS))') \ + $(if $(strip $(GO_LDFLAGS)),-ldflags '$(strip $(GO_LDFLAGS))') \ + $(if $(VERBOSE),-v) ; \ + $(GO) version -m $@ + +dev-build: GO_BUILDFLAGS := -race $(GO_BUILDFLAGS) +dev-build: CGO_ENABLED := 1 +dev-build: RELMODE := 0 +dev-build: build + +ci-clean: + for d in '$(shell $(GO) env GOCACHE)' '$(shell $(GO) env GOMODCACHE)' ; do \ + [ -n "$$d" ] || continue ; \ + [ -d "$$d" ] || continue ; \ + rm -rf "$$d" ; \ + done diff --git a/README.md b/README.md new file mode 100644 index 0000000..4249a77 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# plugin-sonatype-nexus + +[![goreport](https://goreportcard.com/badge/git.krd.sh/krd/woodpecker-sonatype-nexus)](https://goreportcard.com/report/git.krd.sh/krd/woodpecker-sonatype-nexus) +[![docker-pulls](https://img.shields.io/docker/pulls/rockdrilla/woodpecker-sonatype-nexus)](https://hub.docker.com/r/rockdrilla/woodpecker-sonatype-nexus) +[![license](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +Woodpecker CI plugin/standalone executable to publish artifacts to Sonatype Nexus. + +Example `.woodpecker.yml`: + +```yaml +steps: +- name: publish + image: rockdrilla/woodpecker-sonatype-nexus + settings: + url: https://nexus.domain.com + auth_base64: + # consult with #3406 for that syntax + # ref: https://github.com/woodpecker-ci/woodpecker/pull/3406 + from_secret: nexus-auth-b64 + upload: + - repository: project-apt + paths: + - dist/all/*.deb + - dist/amd64/*.deb + - repository: project-raw + paths: + - dist/raw/all-in-one.tar.xz + # property from upload specification for "raw" repository + directory: /build/ + - repository: project-r + paths: + - dist/r/*.tar.gz + # property from upload specification for "r" repository + pathId: /src/contrib/ +``` + +Example `.gitlab-ci.yml`: + +```yaml +publish R: + stage: publish + image: rockdrilla/woodpecker-sonatype-nexus + variables: + NEXUS_URL: https://nexus.domain.com + #NEXUS_AUTH_BASE64 is stored as CI variable + NEXUS_REPOSITORY: project-r + NEXUS_PATHS: "dist/r/*.tar.gz" + NEXUS_PROPERTIES: "pathId=/src/contrib/" +``` + +Example manual invocation (within `rockdrilla/woodpecker-sonatype-nexus` container): + +```sh +# publish R +publish-nexus \ + --nexus.url https://nexus.domain.com \ + --nexus.auth 'upload-user:super-$ecret-passw0rd' \ + --nexus.repository project-r \ + --nexus.paths 'dist/r/*.tar.gz' \ + --nexus.properties 'pathId=/src/contrib/' +``` + +## Woodpecker CI plugin + +Plugin documentation is provided in [separate document](./docs.md). + +## Other CI systems / standalone executable + +### Environment + +| Environment variable | Required | Description | +|----------------------|----------|---------------------------------------------------------------------------------------------------| +| `NEXUS_URL` | **yes** | Sonatype Nexus URL (e.g. `https://nexus.domain.com`) | +| `NEXUS_AUTH` | *no* \* | HTTP Basic Authentication (plain-text, in form `{username}:{password}`) | +| `NEXUS_AUTH_BASE64` | *no* \* | HTTP Basic Authentication (base64-encoded) | +| `NEXUS_AUTH_HEADER` | *no* \* | generic HTTP authentication header (in form `{Header}={Value}`) | +| `NEXUS_REPOSITORY` | **yes** | Repository name (of type "hosted") | +| `NEXUS_PATHS` | **yes** | Comma-separated list of files to upload (accepts [globs](https://pkg.go.dev/path/filepath#Match)) | +| `NEXUS_PROPERTIES` | *no* | Comma-separated list of additional repository-specific properties (in form `{key}={value}`) | + +### Command-line flags + +| Flag | Required | Multiple times? | Description | +|-----------------------|----------|-----------------|-----------------------------------------------------------------------------------| +| `--nexus.url` | **yes** | *no* | Sonatype Nexus URL (e.g. `https://nexus.domain.com`) | +| `--nexus.auth` | *no* \* | *no* | HTTP Basic Authentication (plain-text, in form `{username}:{password}`) | +| `--nexus.auth.base64` | *no* \* | *no* | HTTP Basic Authentication (base64-encoded) | +| `--nexus.auth.header` | *no* \* | *no* | generic HTTP authentication header (in form `{Header}={Value}`) | +| `--nexus.repository` | **yes** | *no* | Repository name (of type "hosted") | +| `--nexus.paths` | **yes** | **yes** | List of files to upload (accepts [globs](https://pkg.go.dev/path/filepath#Match)) | +| `--nexus.properties` | *no* | **yes** | Additional repository-specific properties (in form `{key}={value}`) | + +## Notes + +- At least one authentication setting **must** be provided. + + If there are more than one setting were specified then setting is selected in order of priority (from most to least): + + - `NEXUS_AUTH_HEADER` + - `NEXUS_AUTH_BASE64` + - `NEXUS_AUTH` + +- Preferred setting for HTTP Basic Authentication is `NEXUS_AUTH_BASE64` as there is minimal chance for breaking value during serialization/deserialization. + +- Generic authentication setting `NEXUS_AUTH_HEADER` is provided for cases where authentication differs from HTTP Basic Authentication. + +- The one may use [User Tokens](https://help.sonatype.com/en/user-tokens.html) for HTTP Basic Authentication. + + There is no need for special handling as tokens are conform to scheme: + + `{token name code}:{token pass code}` + +- The one may consult with Sonatype Nexus REST API for repository-specific properties for component uploads. + + Sonatype Nexus REST API is available via: + + - Web UI - `https://nexus.domain.com/#admin/system/api` + - Swagger - `https://nexus.domain.com/service/rest/swagger.json` + + Points of interest are: + + - `/v1/formats/upload-specs` + - `/v1/components` (with `POST` method) + + Also, there is [fallback upload spec](./nexus/upload_spec/fallback.go): + + - if component/asset field does not specify `Optional: true` then this field is **required**. + +## Known limitations + +- No more than 32 assets may be uploaded at once (if destination repository type supports multiple upload). + + This is (merely) artificial limit for **single** upload - plugin will upload all listed files but via several calls. + + If you suppose that Sonatype Nexus is viable to receive more assets at once - feel free to contact me. diff --git a/docs.md b/docs.md new file mode 100644 index 0000000..70751f7 --- /dev/null +++ b/docs.md @@ -0,0 +1,115 @@ +--- +name: Sonatype Nexus +description: Plugin to publish artifacts to Sonatype Nexus +author: Konstantin Demin +tags: [publish, Sonatype, Nexus] +containerImage: rockdrilla/woodpecker-sonatype-nexus +containerImageUrl: https://hub.docker.com/r/rockdrilla/woodpecker-sonatype-nexus +url: https://github.com/rockdrilla/woodpecker-sonatype-nexus +icon: https://www.sonatype.com/hubfs/2-2023-Product%20Logos/Repo%20Nav%20Icon%20updated.png +--- + +Woodpecker CI plugin to publish artifacts to Sonatype Nexus. + +## Settings + +| Name | Required | Default value | Description | +|---------------|----------|---------------|-------------------------------------------------------------------------| +| `url` | **yes** | *none* | Sonatype Nexus URL (e.g. `https://nexus.domain.com`) | +| `auth` | *no* \* | *none* | HTTP Basic Authentication (plain-text, in form `{username}:{password}`) | +| `auth.base64` | *no* \* | *none* | HTTP Basic Authentication (base64-encoded) | +| `auth.header` | *no* \* | *none* | generic HTTP authentication header (in form `{Header}={Value}`) | +| `upload` | **yes** | `[]` | List of upload rules (JSON array, see below) | + +**Notes:** + +- At least one authentication setting **must** be provided. + + If there are more than one setting were specified then setting is selected in order of priority (from most to least): + + - `auth.header` + - `auth.base64` + - `auth` + +- Setting names above are "short" variants. + + Full-qualified setting name looks like "`nexus.{short_name}`" + and has higher priority if short variant is specified too. + +- Dots in setting names are NOT mandatory. + + The one may replace dots ("`.`") with hyphens ("`-`") or underscores ("`_`"). + +### Upload settings + +`upload` list consists of elements with following properties: + +| Name | Required | Default value | Description | +|---------------|----------|---------------|-----------------------------------------------------------------------------------| +| `repository` | **yes** | *none* | Repository name (of type "hosted") | +| `paths` | **yes** | *none* | List of files to upload (accepts [globs](https://pkg.go.dev/path/filepath#Match)) | + +Additional (repository-specific) properties may be specified right with settings specified above. + +## Example + +```yaml +steps: +- name: publish + image: rockdrilla/woodpecker-sonatype-nexus + settings: + url: https://nexus.domain.com + auth.base64: + from_secret: nexus-auth-b64 + upload: + - repository: project-apt + paths: + - dist/all/*.deb + - dist/amd64/*.deb + - repository: project-raw + paths: + - dist/raw/all-in-one.tar.xz + # property from upload specification for "raw" repository + directory: /build/ + - repository: project-r + paths: + - dist/r/*.tar.gz + # property from upload specification for "r" repository + pathId: /src/contrib/ +``` + +## Notes + +- Preferred setting for HTTP Basic Authentication is `auth.base64` as there is minimal chance for breaking value during serialization/deserialization. + +- Generic setting `auth.header` is provided for cases where authentication differs from HTTP Basic Authentication. + +- The one may use [User Tokens](https://help.sonatype.com/en/user-tokens.html) for HTTP Basic Authentication. + + There is no need for special handling as tokens are conform to scheme: + + `{token name code}:{token pass code}` + +- The one may consult with Sonatype Nexus REST API for repository-specific properties for component uploads. + + Sonatype Nexus REST API is available via: + + - Web UI - `https://nexus.domain.com/#admin/system/api` + - Swagger - `https://nexus.domain.com/service/rest/swagger.json` + + Points of interest are: + + - `/v1/formats/upload-specs` + - `/v1/components` (with `POST` method) + + Also, there is [fallback upload spec](https://github.com/rockdrilla/woodpecker-sonatype-nexus/blob/main/nexus/upload_spec/fallback.go): + + - if component/asset field does not specify `Optional: true` then this field is **required**. + +## Known limitations + +- No more than 32 assets may be uploaded at once (if destination repository type supports multiple upload). + + This is (merely) artificial limit for **single** upload - plugin will upload all listed files but via several calls. + + If you suppose that Sonatype Nexus is viable to receive more assets at once - feel free to contact me. diff --git a/error.go b/error.go new file mode 100644 index 0000000..b5f53a3 --- /dev/null +++ b/error.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +type ErrEmpty struct { +} + +func (e *ErrEmpty) Error() string { + return "empty" +} + +type ErrMalformed struct { +} + +func (e *ErrMalformed) Error() string { + return "malformed" +} + +type ErrMissing struct { +} + +func (e *ErrMissing) Error() string { + return "missing" +} diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..16ea86f --- /dev/null +++ b/flags.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +import ( + "github.com/urfave/cli/v2" +) + +type Settings struct { + flags []cli.Flag + + RootUrl string + + AuthPlain string + AuthBase64 string + AuthHttpHeader string + + RawUploads string + + // used only when "nexus.upload" is not set + Repository string + Paths cli.StringSlice + Properties cli.StringSlice +} + +func (p *Plugin) Flags() []cli.Flag { + if len(p.Settings.flags) == 0 { + p.flags() + } + return p.Settings.flags +} + +func (p *Plugin) flags() { + p.Settings.flags = []cli.Flag{ + &cli.StringFlag{ + Name: "nexus.url", + Usage: "Sonatype Nexus URL (e.g. \"https://nexus.domain.com\")", + EnvVars: []string{"PLUGIN_NEXUS_URL", "NEXUS_URL"}, + Destination: &p.Settings.RootUrl, + // avoid setting "Required" to "true" in order to achieve much shorter output + }, + + // https://help.sonatype.com/en/user-tokens.html#use-user-token-for-repository-authentication + &cli.StringFlag{ + Name: "nexus.auth", + Usage: "Sonatype Nexus - HTTP Basic Authentication (plain-text, either {username}:{password} or {token name}:{token pass})", + EnvVars: []string{"PLUGIN_NEXUS_AUTH", "PLUGIN_AUTH", "NEXUS_AUTH"}, + Destination: &p.Settings.AuthPlain, + }, + &cli.StringFlag{ + Name: "nexus.auth.base64", + Usage: "Sonatype Nexus - HTTP Basic Authentication (base64-encoded, preferred)", + EnvVars: []string{"PLUGIN_NEXUS_AUTH_BASE64", "PLUGIN_AUTH_BASE64", "NEXUS_AUTH_BASE64"}, + Destination: &p.Settings.AuthBase64, + }, + &cli.StringFlag{ + Name: "nexus.auth.header", + Usage: "Sonatype Nexus - generic HTTP authentication header (in form {Header}={Value})", + EnvVars: []string{"PLUGIN_NEXUS_AUTH_HEADER", "PLUGIN_AUTH_HEADER", "NEXUS_AUTH_HEADER"}, + Destination: &p.Settings.AuthHttpHeader, + }, + + &cli.StringFlag{ + Name: "nexus.upload", + Usage: "List of upload rules (JSON array)", + EnvVars: []string{"PLUGIN_NEXUS_UPLOAD", "PLUGIN_UPLOAD", "NEXUS_UPLOAD"}, + Destination: &p.Settings.RawUploads, + Value: "[]", + }, + + // used only when "nexus.upload" is not set + &cli.StringFlag{ + Name: "nexus.repository", + Usage: "Repository name", + EnvVars: []string{"NEXUS_REPOSITORY"}, + Destination: &p.Settings.Repository, + }, + &cli.StringSliceFlag{ + Name: "nexus.paths", + Usage: "Comma-separated list of paths/globs", + EnvVars: []string{"NEXUS_PATHS"}, + Destination: &p.Settings.Paths, + }, + &cli.StringSliceFlag{ + Name: "nexus.properties", + Usage: "Comma-separated list of properties (in form {key}={value})", + EnvVars: []string{"NEXUS_PROPERTIES"}, + Destination: &p.Settings.Properties, + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0791d16 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +module git.krd.sh/krd/woodpecker-sonatype-nexus + +go 1.22 + +require ( + codeberg.org/woodpecker-plugins/go-plugin v0.4.1 + github.com/rs/zerolog v1.33.0 + github.com/urfave/cli/v2 v2.27.2 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2d539c4 --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +codeberg.org/woodpecker-plugins/go-plugin v0.4.1 h1:h7W+ZERxqHzeuG3NywG8oISaZx/TzHqRVg45eWeY8Fw= +codeberg.org/woodpecker-plugins/go-plugin v0.4.1/go.mod h1:o8nMzJsFYfAvW1tRjerPqIMzHaOqi4KO1NQbe5iCToA= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= +github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/impl.go b/impl.go new file mode 100644 index 0000000..6a97ae8 --- /dev/null +++ b/impl.go @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + + "github.com/rs/zerolog/log" + + uspec "git.krd.sh/krd/woodpecker-sonatype-nexus/nexus/upload_spec" + f "git.krd.sh/krd/woodpecker-sonatype-nexus/nexus/upload_spec/field" + ftype "git.krd.sh/krd/woodpecker-sonatype-nexus/nexus/upload_spec/field_type" +) + +func (p *Plugin) Execute(ctx context.Context) error { + var err error + + err = p.parseSettings() + if err != nil { + return err + } + + // this is logically unreachable code + if len(p.Uploads) == 0 { + log.Warn().Msg("nothing to upload") + return nil + } + + err = p.GetNexusStatus(ctx) + if err != nil { + return err + } + + err = p.getUploadSpecs(ctx) + if err != nil { + return err + } + + repos := make(map[string]NexusRepo) + var seen bool + + var repo NexusRepo + var spec uspec.UploadSpec + + // validation + for i := range p.Uploads { + repo, seen = repos[p.Uploads[i].Repository] + if !seen { + repo, err = p.GetNexusRepo(ctx, p.Uploads[i].Repository) + if err != nil { + return err + } + + repos[p.Uploads[i].Repository] = repo + } + + spec, seen = p.UploadSpecs[repo.Format] + if !seen { + if p.UploadSpecFallback { + log.Error().Msgf("upload[%d] has format which is not known by upload-specs while using fallback upload-specs", i) + } else { + log.Error().Msgf("upload[%d] has format which is not known by upload-specs (this shouldn't happen!)", i) + } + return errors.ErrUnsupported + } + + if len(spec.AllFieldNames) != 0 { + del_props := make([]string, 0) + for k := range p.Uploads[i].Properties { + del := isInternalField(k) + + _, seen = spec.AllFieldNames[k] + if !seen { + del = true + } + if !del { + continue + } + + del_props = append(del_props, k) + if seen { + log.Info().Msgf("upload[%d]: %q is handled internally", i, k) + } else { + log.Info().Msgf("upload[%d]: %q is not used in %q spec", i, k, repo.Format) + } + } + for _, k := range del_props { + delete(p.Uploads[i].Properties, k) + } + del_props = nil + } + + for _, cf := range spec.ComponentFields { + err = p.verifyUploadField(ctx, i, cf) + if err != nil { + return err + } + } + + for _, af := range spec.AssetFields { + err = p.verifyUploadField(ctx, i, af) + if err != nil { + return err + } + } + } + + for i := range p.Uploads { + repo = repos[p.Uploads[i].Repository] + spec = p.UploadSpecs[repo.Format] + + // naive capacity assumption + assets := make([]string, 0, len(p.Uploads[i].Paths)) + // TODO: use xxhash(path) as key?.. + seen_paths := make(map[string]bool) + for k, patt := range p.Uploads[i].Paths { + paths, err := filepath.Glob(patt) + if err != nil { + // this shouldn't happen + log.Error().Msgf("upload[%d].paths[%d]: bad pattern %q", i, k, patt) + return err + } + + if len(paths) == 0 { + log.Warn().Msgf("upload[%d].paths[%d]: empty match for %q", i, k, patt) + continue + } + + for _, path := range paths { + _, seen := seen_paths[path] + if seen { + log.Info().Msgf("upload[%d].paths[%d]: already seen %q", i, k, path) + continue + } + + err = verifyFilePath(path, fmt.Sprintf("upload[%d].paths[%d]:", i, k)) + if err != nil { + return err + } + + seen_paths[path] = true + assets = append(assets, path) + } + } + seen_paths = nil + + if len(assets) == 0 { + // TODO: less strict mode? + log.Error().Msgf("upload[%d].paths[]: no elements", i) + return &ErrEmpty{} + } + + if spec.MultipleUpload { + s_end := 0 + for s_start := 0; s_start < len(assets); s_start += MaxAssetsPerUpload { + s_end += MaxAssetsPerUpload + if s_end > len(assets) { + s_end = len(assets) + } + log.Info().Msgf("upload[%d]: sending %d files at once", i, s_end-s_start+1) + err = p.uploadToNexus(ctx, &p.Uploads[i], &repo, &spec, assets[s_start:s_end]...) + if err != nil { + return err + } + } + } else { + for _, a := range assets { + err = p.uploadToNexus(ctx, &p.Uploads[i], &repo, &spec, a) + if err != nil { + return err + } + } + } + } + + log.Info().Msg("done") + return nil +} + +func isInternalField(fieldName string) bool { + switch strings.ToLower(fieldName) { + case "asset", "filename": + return true + } + return false +} + +func verifyFilePath(filePath, errorPrefix string) error { + if filePath == "" { + log.Panic().Msg("empty file path") + } + if errorPrefix == "" { + log.Panic().Msg("empty error prefix") + } + + fpath, err := filepath.EvalSymlinks(filePath) + if err != nil { + log.Error().Msgf("%s is required but missing: %q", errorPrefix, filePath) + return err + } + + if !filepath.IsLocal(fpath) { + log.Error().Msgf("%s is pointing outside of current working directory: %q", errorPrefix, filePath) + return &ErrMalformed{} + } + + finfo, err := os.Stat(fpath) + if err != nil { + log.Error().Msgf("%s is required but missing: %q", errorPrefix, filePath) + return err + } + + if !finfo.Mode().IsRegular() { + log.Error().Msgf("%s is required but not a regular file: %q", errorPrefix, filePath) + return &ErrMalformed{} + } + + return nil +} + +func (p *Plugin) verifyUploadField(ctx context.Context, uploadNum int, field f.UploadField) error { + if isInternalField(field.Name) { + // generated on per-artifact basis + return nil + } + + prop, seen := p.Uploads[uploadNum].Properties[field.Name] + if !seen { + if field.Optional { + return nil + } + + log.Error().Msgf("upload[%d]: %q is required but missing", uploadNum, field.Name) + return &ErrMissing{} + } + + rkind1 := reflect.TypeOf(prop).Kind() + rkind2 := field.Type.ToReflectKind() + if rkind1 != rkind2 { + log.Error().Msgf("upload[%d]: %q has wrong type: %v != %v", uploadNum, field.Name, rkind1, rkind2) + return errors.ErrUnsupported + } + + switch field.Type { + case ftype.String, ftype.File: + s := prop.(string) + if s == "" { + if !field.Optional { + log.Error().Msgf("upload[%d]: %q is required but empty", uploadNum, field.Name) + return &ErrEmpty{} + } + + log.Info().Msgf("upload[%d]: %q is set but empty - deleting optional empty field", uploadNum, field.Name) + delete(p.Uploads[uploadNum].Properties, field.Name) + return nil + } + + if field.Type == ftype.String { + // done with String + return nil + } + + err := verifyFilePath(s, fmt.Sprintf("upload[%d]: file %q", uploadNum, field.Name)) + if err != nil { + return err + } + } + + return nil +} + +func (p *Plugin) uploadToNexus(ctx context.Context, upload *UploadRule, repo *NexusRepo, spec *uspec.UploadSpec, assets ...string) error { + var err error + + buf := new(bytes.Buffer) + w := multipart.NewWriter(buf) + + var postField string + + for _, cf := range spec.ComponentFields { + postField = fmt.Sprintf("%s.%s", repo.Format, cf.Name) + + prop, seen := upload.Properties[cf.Name] + if !seen { + continue + } + + err = writeFormFieldType(w, postField, cf.Type, prop) + if err != nil { + return err + } + } + + var assetField string + for i, a := range assets { + if spec.MultipleUpload { + assetField = fmt.Sprintf("%s.asset%d", repo.Format, i+1) + } else { + assetField = fmt.Sprintf("%s.asset", repo.Format) + } + err = writeFormFile(w, assetField, a) + if err != nil { + return err + } + + for _, af := range spec.AssetFields { + switch strings.ToLower(af.Name) { + case "asset": + //ignored + continue + } + + postField = fmt.Sprintf("%s.%s", assetField, af.Name) + + switch strings.ToLower(af.Name) { + case "filename": + err = writeFormFieldType(w, postField, ftype.String, filepath.Base(a)) + if err != nil { + return err + } + continue + } + + prop, seen := upload.Properties[af.Name] + if !seen { + continue + } + + err = writeFormFieldType(w, postField, af.Type, prop) + if err != nil { + return err + } + } + } + + err = w.Close() + if err != nil { + log.Error().Msg("HTTP POST: unable to finish request") + return err + } + + res, err := p.NexusRequestEx(ctx, http.MethodPost, "v1/components?repository="+upload.Repository, buf, func(r *http.Request) { + r.Header.Set("Content-Type", w.FormDataContentType()) + }) + if err != nil { + return err + } + + return GenericResponseHandler(res) +} + +func writeFormFile(w *multipart.Writer, fieldName string, fileName string) error { + data, err := os.ReadFile(fileName) + if err != nil { + log.Error().Msgf("HTTP POST: unable to read file %q for field %q", fileName, fieldName) + return err + } + + part, err := w.CreateFormFile(fieldName, filepath.Base(fileName)) + if err != nil { + log.Error().Msgf("HTTP POST: unable to prepare file %q for field %q", fileName, fieldName) + return err + } + + _, err = part.Write(data) + if err != nil { + log.Error().Msgf("HTTP POST: unable to write file %q for field %q", fileName, fieldName) + } + + return err +} + +func writeFormFieldType(w *multipart.Writer, fieldName string, fieldType ftype.UploadFieldType, fieldValue any) error { + var err error + + switch fieldType { + case ftype.File: + err = writeFormFile(w, fieldName, fieldValue.(string)) + case ftype.String: + err = w.WriteField(fieldName, fieldValue.(string)) + case ftype.Boolean: + err = w.WriteField(fieldName, strconv.FormatBool(fieldValue.(bool))) + default: + log.Error().Msgf("HTTP POST: refusing to write %q (of type %q)", fieldName, fieldType.String()) + return errors.ErrUnsupported + } + + if err != nil { + log.Error().Msgf("HTTP POST: unable to write %q (of type %q)", fieldName, fieldType.String()) + } + + return err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..feb11ae --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +import ( + "codeberg.org/woodpecker-plugins/go-plugin" + + uspec "git.krd.sh/krd/woodpecker-sonatype-nexus/nexus/upload_spec" +) + +const ( + MaxAssetsPerUpload = 32 +) + +type Plugin struct { + *plugin.Plugin + Settings *Settings + + RestUrl string + AuthHeader string + AuthValue string + + UploadSpecs map[string]uspec.UploadSpec + UploadSpecFallback bool + + Uploads []UploadRule +} + +func main() { + p := &Plugin{ + Settings: &Settings{}, + } + p.Plugin = plugin.New(plugin.Options{ + Name: "woodpecker-sonatype-nexus", + Description: "Woodpecker CI plugin to publish artifacts to Sonatype Nexus", + Version: "0.0.1", + Flags: p.Flags(), + Execute: p.Execute, + }) + p.Run() +} diff --git a/nexus-repo.go b/nexus-repo.go new file mode 100644 index 0000000..4277761 --- /dev/null +++ b/nexus-repo.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/rs/zerolog/log" +) + +type NexusRepo struct { + Name string `json:"name"` + Format string `json:"format"` + Type string `json:"type"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +func (p *Plugin) GetNexusRepo(ctx context.Context, repoName string) (NexusRepo, error) { + if repoName == "" { + log.Panic().Msg("empty repository name") + } + + var empty NexusRepo + + res, err := p.NexusRequest(ctx, "v1/repositories/"+repoName) + if err != nil { + log.Error().Msgf("unable to retrieve information for repository %q", repoName) + return empty, err + } + + defer res.Body.Close() + if res.StatusCode == http.StatusNotFound { + log.Error().Msgf("repository %q does not exist", repoName) + return empty, errors.New("notfound") + } + + err = GenericResponseHandler(res) + if err != nil { + log.Error().Msgf("unable to retrieve information for repository %q", repoName) + return empty, err + } + + var repo NexusRepo + dec := json.NewDecoder(res.Body) + err = dec.Decode(&repo) + if err != nil { + log.Error().Msgf("unable to decode information for repository %q", repoName) + return empty, err + } + + switch repo.Type { + case "proxy", "group": + log.Error().Msgf("repository %q is type of %q", repoName, repo.Type) + return empty, errors.ErrUnsupported + } + + return repo, nil +} diff --git a/nexus-request.go b/nexus-request.go new file mode 100644 index 0000000..7408a78 --- /dev/null +++ b/nexus-request.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/rs/zerolog/log" +) + +func (p *Plugin) NexusRequest(ctx context.Context, requestUrl string) (*http.Response, error) { + return p.NexusRequestEx(ctx, http.MethodGet, requestUrl, nil, nil) +} + +func (p *Plugin) NexusRequestEx(ctx context.Context, requestMethod string, requestUrl string, requestBody io.Reader, requestSetup func(*http.Request)) (*http.Response, error) { + if requestMethod == "" { + log.Panic().Msg("empty request method") + } + if requestUrl == "" { + log.Panic().Msg("empty request url") + } + + c := p.HTTPClient() + if c == nil { + log.Panic().Msg("broken HTTP client") + } + + req, err := http.NewRequestWithContext(ctx, requestMethod, p.RestUrl+requestUrl, requestBody) + if err != nil { + log.Error().Msgf("unable to create HTTP request: %q %q", requestMethod, "/"+requestUrl) + return nil, err + } + if req == nil { + log.Panic().Msg("nil request") + // make analysis tools happy + panic(1) + } + + if requestSetup != nil { + requestSetup(req) + } + + req.Header.Set(p.AuthHeader, p.AuthValue) + + res, err := c.Do(req) + if err != nil { + log.Error().Msgf("unable to perform HTTP request: %q %q", requestMethod, "/"+requestUrl) + return nil, err + } + if res == nil { + log.Panic().Msg("nil response") + // make analysis tools happy + panic(1) + } + + switch res.StatusCode { + case http.StatusUnauthorized: + defer res.Body.Close() + log.Error().Msgf("authentication is declined for HTTP %s %q", requestMethod, "/"+requestUrl) + return nil, errors.New("unauthorized") + case http.StatusForbidden: + defer res.Body.Close() + log.Error().Msgf("insufficient permissions for HTTP %s %q", requestMethod, "/"+requestUrl) + return nil, errors.New("forbidden") + } + + return res, err +} + +func GenericResponseHandler(response *http.Response) error { + if response == nil { + log.Panic().Msg("nil response") + // make analysis tools happy + panic(1) + } + + if (response.StatusCode >= http.StatusOK) && (response.StatusCode < http.StatusMultipleChoices) { + return nil + } + + if strings.Contains(response.Status, " ") { + return fmt.Errorf("HTTP %s", response.Status) + } + + // "unlikely" branch + + s := http.StatusText(response.StatusCode) + if s != "" { + return fmt.Errorf("HTTP %d %s", response.StatusCode, s) + } + + return fmt.Errorf("HTTP %d Unknown return code", response.StatusCode) +} diff --git a/nexus-status.go b/nexus-status.go new file mode 100644 index 0000000..e0fd906 --- /dev/null +++ b/nexus-status.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +import ( + "context" + "encoding/json" + "errors" + + "github.com/rs/zerolog/log" +) + +type ReadOnlyStatus struct { + Frozen bool `json:"frozen"` + SystemInitiated bool `json:"systemInitiated"` + Reason string `json:"summaryReason"` +} + +func (p *Plugin) GetNexusStatus(ctx context.Context) error { + res, err := p.NexusRequest(ctx, "v1/status/writable") + if err == nil { + defer res.Body.Close() + err = GenericResponseHandler(res) + } + if err != nil { + log.Error().Msg("Nexus is not writable") + return err + } + + res, err = p.NexusRequest(ctx, "v1/read-only") + if err == nil { + defer res.Body.Close() + err = GenericResponseHandler(res) + } + if err != nil { + log.Error().Msg("Nexus is unable to report it's \"read-only\" status") + return err + } + + var roStatus ReadOnlyStatus + dec := json.NewDecoder(res.Body) + err = dec.Decode(&roStatus) + if err != nil { + log.Error().Msg("unable to decode information for \"read-only\" status") + return err + } + + if roStatus.Frozen { + if roStatus.Reason == "" { + log.Error().Msgf("Nexus is read-only (system-initiated: %v)", roStatus.SystemInitiated) + } else { + log.Error().Msgf("Nexus is read-only (system-initiated: %v), reason: %q", roStatus.SystemInitiated, roStatus.Reason) + } + return errors.New("readonly") + } + + //TODO: determine early whether supplied credentials allows one to proceed with Sonatype Nexus + + return nil +} diff --git a/nexus/upload_spec/fallback.go b/nexus/upload_spec/fallback.go new file mode 100644 index 0000000..42b256c --- /dev/null +++ b/nexus/upload_spec/fallback.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package upload_spec + +import ( + f "git.krd.sh/krd/woodpecker-sonatype-nexus/nexus/upload_spec/field" + ftype "git.krd.sh/krd/woodpecker-sonatype-nexus/nexus/upload_spec/field_type" +) + +var ( + // keep map keys sorted + + fallbackUploadSpec = map[string]UploadSpec{ + "maven2": { + MultipleUpload: true, + ComponentFields: []f.UploadField{ + { + Name: "groupId", + Type: ftype.String, + }, + { + Name: "artifactId", + Type: ftype.String, + }, + { + Name: "version", + Type: ftype.String, + }, + { + Name: "generate-pom", + Type: ftype.Boolean, + + Optional: true, + }, + { + Name: "packaging", + Type: ftype.String, + + Optional: true, + }, + }, + AssetFields: []f.UploadField{ + { + Name: "extension", + Type: ftype.String, + }, + { + Name: "classifier", + Type: ftype.String, + + Optional: true, + }, + }, + }, + "r": { + AssetFields: []f.UploadField{ + { + Name: "pathId", + Type: ftype.String, + }, + }, + }, + "raw": { + MultipleUpload: true, + ComponentFields: []f.UploadField{ + { + Name: "directory", + Type: ftype.String, + }, + }, + AssetFields: []f.UploadField{ + { + Name: "filename", + Type: ftype.String, + }, + }, + }, + "yum": { + ComponentFields: []f.UploadField{ + { + Name: "directory", + Type: ftype.String, + + Optional: true, + }, + }, + AssetFields: []f.UploadField{ + { + Name: "filename", + Type: ftype.String, + }, + }, + }, + } + + // keep array values sorted + + fallbackSimpleSpecs = []string{ + "apt", + "docker", + "helm", + "npm", + "nuget", + "pypi", + "rubygems", + } +) + +func GetFallbackSpecs() map[string]UploadSpec { + rv := make(map[string]UploadSpec) + + for t := range fallbackUploadSpec { + spec := fallbackUploadSpec[t] + spec.Format = t + rv[t] = spec + } + + for _, t := range fallbackSimpleSpecs { + _, seen := rv[t] + if seen { + continue + } + rv[t] = UploadSpec{Format: t} + } + + return rv +} diff --git a/nexus/upload_spec/field/field.go b/nexus/upload_spec/field/field.go new file mode 100644 index 0000000..b6761d7 --- /dev/null +++ b/nexus/upload_spec/field/field.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package field + +import ( + ftype "git.krd.sh/krd/woodpecker-sonatype-nexus/nexus/upload_spec/field_type" +) + +// repo: https://github.com/sonatype/nexus-public.git +// file: components/nexus-repository-services/src/main/java/org/sonatype/nexus/repository/upload/UploadFieldDefinition.java +type UploadField struct { + Name string `json:"name"` + Type ftype.UploadFieldType `json:"type,string"` + Optional bool `json:"optional"` + + // optional fields + // Group string `json:"group,omitempty"` + // Description string `json:"description,omitempty"` +} diff --git a/nexus/upload_spec/field_type/type.go b/nexus/upload_spec/field_type/type.go new file mode 100644 index 0000000..643215b --- /dev/null +++ b/nexus/upload_spec/field_type/type.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package field_type + +import ( + "errors" + "reflect" + "strings" +) + +type UploadFieldType uint8 + +// repo: https://github.com/sonatype/nexus-public.git +// file: components/nexus-repository-services/src/main/java/org/sonatype/nexus/repository/upload/UploadFieldDefinition.java +const ( + // internal values + _Invariant UploadFieldType = iota + _Invalid + + File + String + Boolean +) + +var ( + uploadFieldType_to_str map[UploadFieldType]string = map[UploadFieldType]string{ + _Invariant: "", + _Invalid: "INVALID", + + File: "file", + String: "string", + Boolean: "boolean", + } + + uploadFieldType_to_reflect map[UploadFieldType]reflect.Kind = map[UploadFieldType]reflect.Kind{ + _Invariant: reflect.Invalid, + _Invalid: reflect.Invalid, + + File: reflect.String, + String: reflect.String, + Boolean: reflect.Bool, + } + + uploadFieldType_from_str map[string]UploadFieldType = map[string]UploadFieldType{ + "file": File, + "string": String, + "boolean": Boolean, + } +) + +func (x UploadFieldType) IsInvariant() bool { + return x == _Invariant +} + +func (x UploadFieldType) IsValid() bool { + switch x { + case File, String, Boolean: + return true + } + return false +} + +func (x UploadFieldType) String() string { + s, ok := uploadFieldType_to_str[x] + if ok { + return s + } + return "INVARIANT" +} + +func (x UploadFieldType) ToReflectKind() reflect.Kind { + t, ok := uploadFieldType_to_reflect[x] + if ok { + return t + } + return reflect.Invalid +} + +func StringToUploadFieldType(s string) UploadFieldType { + if s == "" { + return _Invariant + } + + x, ok := uploadFieldType_from_str[strings.ToLower(s)] + if ok { + return x + } + return _Invalid +} + +func (x *UploadFieldType) UnmarshalJSON(b []byte) error { + s := string(b) + t := StringToUploadFieldType(s) + if !t.IsInvariant() { + if t.IsValid() { + *x = t + return nil + } + return errors.ErrUnsupported + } + return nil +} diff --git a/nexus/upload_spec/spec.go b/nexus/upload_spec/spec.go new file mode 100644 index 0000000..32b384e --- /dev/null +++ b/nexus/upload_spec/spec.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package upload_spec + +import ( + f "git.krd.sh/krd/woodpecker-sonatype-nexus/nexus/upload_spec/field" +) + +// repo: https://github.com/sonatype/nexus-public.git +// files: +// - components/nexus-repository-services/src/main/java/org/sonatype/nexus/repository/rest/api/UploadDefinitionXO.groovy +// - components/nexus-repository-services/src/main/java/org/sonatype/nexus/repository/upload/UploadDefinition.java +type UploadSpec struct { + Format string `json:"format"` + MultipleUpload bool `json:"multipleUpload"` + ComponentFields []f.UploadField `json:"componentFields,omitempty"` + AssetFields []f.UploadField `json:"assetFields,omitempty"` + + AllFieldNames map[string]bool +} diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..e15d938 --- /dev/null +++ b/settings.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +import ( + "encoding/base64" + "net/url" + "os" + "strings" + + "github.com/rs/zerolog/log" + "github.com/urfave/cli/v2" +) + +func (p *Plugin) parseSettings() error { + var err error + + if p.Settings.RootUrl == "" { + return reportEmptySetting("nexus.url") + } + p.Settings.RootUrl = strings.TrimRight(p.Settings.RootUrl, "/") + if p.Settings.RootUrl == "" { + return reportMalformedSetting("nexus.url", "only slashes") + } + + p.RestUrl = p.Settings.RootUrl + "/service/rest/" + _, err = url.Parse(p.RestUrl) + if err != nil { + log.Error().Msg("unable to construct URL for REST API") + return err + } + + if (p.Settings.AuthPlain == "") && (p.Settings.AuthBase64 == "") && (p.Settings.AuthHttpHeader == "") { + log.Error().Msg("missing \"nexus.auth\"/\"nexus.auth.*\"") + return &ErrEmpty{} + } + if p.Settings.AuthHttpHeader != "" { + reportSupersedingSetting("nexus.auth.header", "nexus.auth", p.Settings.AuthPlain != "") + reportSupersedingSetting("nexus.auth.header", "nexus.auth.base64", p.Settings.AuthBase64 != "") + + if !strings.Contains(p.Settings.AuthHttpHeader, "=") { + return reportMalformedSetting("nexus.auth.header", "does not contain '='") + } + + parts := strings.SplitN(p.Settings.AuthHttpHeader, "=", 2) + if parts[0] == "" { + return reportMalformedSetting("nexus.auth.header", "empty Header") + } + if parts[1] == "" { + return reportMalformedSetting("nexus.auth.header", "empty Value") + } + p.AuthHeader = parts[0] + p.AuthValue = parts[1] + } else { + // proceed with HTTP Basic auth + p.AuthHeader = "Authorization" + + if p.Settings.AuthBase64 != "" { + reportSupersedingSetting("nexus.auth.base64", "nexus.auth", p.Settings.AuthPlain != "") + } else { + if !strings.Contains(p.Settings.AuthPlain, ":") { + return reportMalformedSetting("nexus.auth", "does not contain ':'") + } + + p.Settings.AuthBase64 = base64.StdEncoding.EncodeToString([]byte(p.Settings.AuthPlain)) + } + + p.AuthValue = "Basic " + p.Settings.AuthBase64 + } + + // + for i := range p.Settings.flags { + f, ok := p.Settings.flags[i].(cli.DocGenerationFlag) + if !ok { + continue + } + for _, v := range f.GetEnvVars() { + _ = os.Unsetenv(v) + } + } + p.Settings.AuthHttpHeader = "" + p.Settings.AuthPlain = "" + p.Settings.AuthBase64 = "" + // + + err = p.processRawUploads() + if err != nil { + _ = reportMalformedSetting("nexus.upload", "parse error") + return err + } + + if len(p.Uploads) != 0 { + reportSupersedingSetting("nexus.upload", "nexus.repository", p.Settings.Repository != "") + reportSupersedingSetting("nexus.upload", "nexus.paths", len(p.Settings.Paths.Value()) != 0) + reportSupersedingSetting("nexus.upload", "nexus.properties", len(p.Settings.Properties.Value()) != 0) + + return nil + } + + log.Info().Msg("\"nexus.upload\" is empty - trying to fill it with \"inline\" parameters") + + var ur UploadRule + + ur.Repository = p.Settings.Repository + ur.Paths = make([]string, len(p.Settings.Paths.Value())) + copy(ur.Paths, p.Settings.Paths.Value()) + + if ur.Repository == "" { + return reportEmptySetting("nexus.repository") + } + if len(ur.Paths) == 0 { + return reportEmptySetting("nexus.paths") + } + + rawProps := p.Settings.Properties.Value() + if len(rawProps) != 0 { + if rawProps[0] == "" { + return reportEmptySetting("nexus.properties") + } + // very naive + for i := range rawProps { + if rawProps[i] == "" { + continue + } + switch rawProps[i][0] { + case '{', '[': + return reportMalformedSetting("nexus.properties", "must be plain comma-separated list, not JSON-like object") + } + } + } + + ur.Properties = make(map[string]any) + for i := range rawProps { + if rawProps[i] == "" { + log.Warn().Msgf("nexus.properties[%d]: empty part", i) + continue + } + if !strings.Contains(rawProps[i], "=") { + log.Warn().Msgf("nexus.properties[%d]: value does not contain '='", i) + continue + } + + parts := strings.SplitN(rawProps[i], "=", 2) + _, seen := ur.Properties[parts[0]] + if seen { + log.Warn().Msgf("nexus.properties[%d]: overriding previous value of %q", i, parts[0]) + } + ur.Properties[parts[0]] = parts[1] + } + + p.Uploads = append(p.Uploads, ur) + + return nil +} + +func reportEmptySetting(name string) error { + log.Error().Msgf("\"%s\" is empty", name) + return &ErrEmpty{} +} + +func reportMalformedSetting(name, message string) error { + log.Error().Msgf("\"%s\" is malformed: %s", name, message) + return &ErrMalformed{} +} + +func reportSupersedingSetting(settingName, supersededName string, condition bool) { + if !condition { + return + } + + log.Info().Msgf("\"%s\": ignored while \"%s\" is in effect", settingName, supersededName) +} diff --git a/upload-spec.go b/upload-spec.go new file mode 100644 index 0000000..12ad4b3 --- /dev/null +++ b/upload-spec.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +import ( + "context" + "encoding/json" + + "github.com/rs/zerolog/log" + + uspec "git.krd.sh/krd/woodpecker-sonatype-nexus/nexus/upload_spec" +) + +func (p *Plugin) getUploadSpecs(ctx context.Context) error { + res, err := p.NexusRequest(ctx, "v1/formats/upload-specs") + if err == nil { + defer res.Body.Close() + err = GenericResponseHandler(res) + } + + for { + if err != nil { + p.UploadSpecFallback = true + log.Error().Msg("unable to retrieve upload-specs") + break + } + + var rawspecs []uspec.UploadSpec + dec := json.NewDecoder(res.Body) + err = dec.Decode(&rawspecs) + if err != nil { + p.UploadSpecFallback = true + log.Error().Msg("unable to decode information for upload-specs") + break + } + + if len(rawspecs) == 0 { + p.UploadSpecFallback = true + log.Error().Msg("empty upload-specs") + break + } + + p.UploadSpecs = make(map[string]uspec.UploadSpec) + for _, s := range rawspecs { + p.UploadSpecs[s.Format] = s + } + //lint:ignore SA4004 this is correct + break + } + + if p.UploadSpecFallback { + log.Warn().Msg("using fallback upload-specs") + p.UploadSpecs = uspec.GetFallbackSpecs() + } + + keys := make([]string, 0, len(p.UploadSpecs)) + for k := range p.UploadSpecs { + keys = append(keys, k) + } + + // refill UploadSpecs + for _, k := range keys { + s := p.UploadSpecs[k] + s.AllFieldNames = make(map[string]bool) + var seen bool + for _, f := range s.ComponentFields { + _, seen = s.AllFieldNames[f.Name] + if seen { + continue + } + s.AllFieldNames[f.Name] = true + } + for _, f := range s.AssetFields { + _, seen = s.AllFieldNames[f.Name] + if seen { + continue + } + s.AllFieldNames[f.Name] = true + } + + p.UploadSpecs[k] = s + } + + return nil +} diff --git a/upload.go b/upload.go new file mode 100644 index 0000000..3f4bd4d --- /dev/null +++ b/upload.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +// (c) 2024, Konstantin Demin + +package main + +import ( + "encoding/json" + "fmt" + "path/filepath" + "reflect" + "strings" + + "github.com/rs/zerolog/log" +) + +type UploadRuleBase struct { + Repository string `json:"repository"` + Paths []string `json:"paths"` +} + +type UploadRule struct { + UploadRuleBase + + Properties map[string]any +} + +func (p *Plugin) processRawUploads() error { + var err error + + b := []byte(p.Settings.RawUploads) + + var base []UploadRuleBase + err = json.Unmarshal(b, &base) + if err != nil { + log.Error().Msg("unable to parse upload rules") + return err + } + if len(base) == 0 { + return nil + } + + var raw []any + err = json.Unmarshal(b, &raw) + if err != nil { + log.Error().Msg("unable to parse upload rules") + return err + } + if len(raw) == 0 { + return nil + } + + // just in case + b = nil + + if len(raw) != len(base) { + // just in case + log.Error().Msgf("upload[] deserialization error: array length mismatch: %d != %d", len(base), len(raw)) + return &ErrMalformed{} + } + + result := make([]UploadRule, 0, len(raw)) + for i := range raw { + if base[i].Repository == "" { + return reportEmptySetting(fmt.Sprintf("upload[%d].repository", i)) + } + if len(base[i].Paths) == 0 { + return reportEmptySetting(fmt.Sprintf("upload[%d].paths", i)) + } + + for k, patt := range base[i].Paths { + _, err = filepath.Glob(patt) + if err != nil { + return reportMalformedSetting(fmt.Sprintf("upload[%d].paths[%d]", i, k), fmt.Sprintf("bad pattern %q: %v", patt, err)) + } + } + + rtype := reflect.TypeOf(raw[i]) + if rtype.Kind() != reflect.Map { + return reportMalformedSetting(fmt.Sprintf("upload[%d]", i), fmt.Sprintf("not a map[string]any but %v", rtype.Kind())) + } + if rtype.Key().Kind() != reflect.String { + return reportMalformedSetting(fmt.Sprintf("upload[%d]", i), fmt.Sprintf("not a map[string]any but map[%v]any", rtype.Key().Kind())) + } + + m := raw[i].(map[string]any) + ur := UploadRule{} + ur.Repository = base[i].Repository + + ur.Paths = make([]string, len(base[i].Paths)) + copy(ur.Paths, base[i].Paths) + + for k := range m { + switch strings.ToLower(k) { + case "repository", "paths": + log.Info().Msgf("upload[%d]: %q is handled by type %q", i, k, "UploadRuleBase") + continue + case "asset", "filename": + log.Info().Msgf("upload[%d]: %q is handled internally on per-artifact basis", i, k) + continue + } + + rtype = reflect.TypeOf(m[k]) + switch rtype.Kind() { + case reflect.Invalid, + reflect.Array, + reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.Map, + reflect.Pointer, + reflect.Slice, + reflect.Struct, + reflect.UnsafePointer: + // + return reportMalformedSetting(fmt.Sprintf("upload[%d]", i), fmt.Sprintf("%q is type of %q", k, rtype.String())) + } + ur.Properties[k] = m[k] + } + + result = append(result, ur) + } + + p.Uploads = result + + return nil +}