diff --git a/api/ar_pkg/openapi.yaml b/api/ar_pkg/openapi.yaml index 00f15e6..9f9bc4d 100644 --- a/api/ar_pkg/openapi.yaml +++ b/api/ar_pkg/openapi.yaml @@ -105,7 +105,55 @@ paths: description: Missing or invalid `x-api-key` '5XX': description: Server-side error - + /pkg/{accountId}/{registry}/go/upload: + put: + summary: Upload a go package + operationId: uploadGoPackage + parameters: + - name: accountId + in: path + required: true + schema: + type: string + - name: registry + in: path + required: true + schema: + type: string + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - zip + - mod + - info + properties: + info: + type: string + format: binary + description: Package .info file to upload + mod: + type: string + format: binary + description: Package .mod file to upload + zip: + type: string + format: binary + description: Package .zip file to upload + responses: + '200': + description: Package uploaded successfully + '400': + description: Invalid request parameters + '401': + description: Missing or invalid `x-api-key` + '5XX': + description: Server-side error components: securitySchemes: ApiKeyAuth: diff --git a/cmd/ar/command/push_go.go b/cmd/ar/command/push_go.go new file mode 100644 index 0000000..1136df0 --- /dev/null +++ b/cmd/ar/command/push_go.go @@ -0,0 +1,149 @@ +package command + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + + "github.com/harness/harness-cli/config" + client "github.com/harness/harness-cli/internal/api/ar" + pkgclient "github.com/harness/harness-cli/internal/api/ar_pkg" + "github.com/harness/harness-cli/module/ar/packages/gopkg" + "github.com/harness/harness-cli/util" + "github.com/harness/harness-cli/util/common/auth" + "github.com/harness/harness-cli/util/common/errors" + p "github.com/harness/harness-cli/util/common/progress" + + "github.com/spf13/cobra" +) + +func NewPushGoCmd(c *client.ClientWithResponses) *cobra.Command { + var dir = "." + var output = "/tmp/go-package" + var pkgURL string + cmd := &cobra.Command{ + Use: "go ", + Short: "Push Go Artifacts", + Long: "Push Go Artifacts to Harness Artifact Registry", + Args: cobra.ExactArgs(2), + PreRun: func(cmd *cobra.Command, args []string) { + if pkgURL != "" { + config.Global.Registry.PkgURL = util.GetPkgUrl(pkgURL) + } else { + config.Global.Registry.PkgURL = util.GetPkgUrl(config.Global.APIBaseURL) + } + }, + RunE: func(cmd *cobra.Command, args []string) error { + registryName := args[0] + version := args[1] + + // Create progress reporter + progress := p.NewConsoleReporter() + + // Validate Registry Name and Version + progress.Start("Validating input parameters") + if registryName == "" { + progress.Error("Registry name is required") + return errors.NewValidationError("registry_name", "registry name is required") + } + if version == "" { + progress.Error("Version is required") + return errors.NewValidationError("version", "version is required") + } + progress.Success("Input parameters validated") + + // Generate package files + generator := gopkg.NewGenerator(dir, output, version) + packageName, err := generator.Generate(progress) + if err != nil { + return err + } + + // Create form data + progress.Step("Preparing package upload") + var formData bytes.Buffer + var formWriter = multipart.NewWriter(&formData) + + // Add files to form + var files = []struct { + name string + filename string + }{ + {"mod", packageName + ".mod"}, + {"info", packageName + ".info"}, + {"zip", packageName + ".zip"}, + } + + // Upload files + progress.Step("Preparing package files") + for _, file := range files { + progress.Step(fmt.Sprintf("Adding %s to upload", file.filename)) + f, openErr := os.Open(filepath.Join(output, file.filename)) + if openErr != nil { + progress.Error(fmt.Sprintf("Failed to open %s", file.filename)) + return openErr + } + defer f.Close() + + part, formErr := formWriter.CreateFormFile(file.name, file.filename) + if formErr != nil { + progress.Error(fmt.Sprintf("Failed to create form field for %s", file.filename)) + return formErr + } + + if _, copyErr := io.Copy(part, f); copyErr != nil { + progress.Error(fmt.Sprintf("Failed to copy %s content", file.filename)) + return copyErr + } + } + + // Close multipart writer + if closeErr := formWriter.Close(); closeErr != nil { + progress.Error("Failed to finalize form data") + return closeErr + } + + // Initialize the package client + pkgClient, err := pkgclient.NewClientWithResponses(config.Global.Registry.PkgURL, + auth.GetXApiKeyOptionARPKG()) + if err != nil { + return fmt.Errorf("failed to create package client: %w", err) + } + + // Upload package + progress.Step("Uploading package to registry") + bufferSize := int64(formData.Len()) + reader, closer := p.Reader(bufferSize, &formData, "go") + defer closer() + + resp, err := pkgClient.UploadGoPackageWithBodyWithResponse( + context.Background(), + config.Global.AccountID, + registryName, + formWriter.FormDataContentType(), + reader, + ) + if err != nil { + progress.Error("Failed to upload package") + return err + } + + // Check response + if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated { + progress.Error("Upload failed") + return fmt.Errorf("failed to push package: %s \n response: %s", resp.Status(), resp.Body) + } + + progress.Success(fmt.Sprintf("Successfully uploaded package %s", packageName)) + return nil + }, + } + + cmd.Flags().StringVar(&pkgURL, "pkg-url", "", "Base URL for the Packages") + return cmd +} diff --git a/cmd/ar/root.go b/cmd/ar/root.go index 151e253..4926231 100644 --- a/cmd/ar/root.go +++ b/cmd/ar/root.go @@ -47,6 +47,7 @@ func GetRootCmd() *cobra.Command { getPushCommand( commands.NewPushGenericCmd(client), commands.NewPushMavenCmd(client), + commands.NewPushGoCmd(client), ), ) diff --git a/go.mod b/go.mod index 30f53cf..f4092ed 100644 --- a/go.mod +++ b/go.mod @@ -18,99 +18,65 @@ require ( golang.org/x/net v0.40.0 golang.org/x/term v0.32.0 gopkg.in/yaml.v3 v3.0.1 - helm.sh/helm/v3 v3.18.3 + helm.sh/helm/v3 v3.18.4 ) require ( - atomicgo.dev/cursor v0.2.0 // indirect - atomicgo.dev/keyboard v0.2.9 // indirect - atomicgo.dev/schedule v0.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/containerd/console v1.0.3 // indirect github.com/containerd/containerd v1.7.27 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/docker/cli v28.2.2+incompatible // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-logr/logr v1.4.3 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/gookit/color v1.5.4 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect - github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/rivo/uniseg v0.4.4 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/vbatts/tar-split v0.12.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/grpc v1.68.1 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/api v0.33.1 // indirect - k8s.io/apiextensions-apiserver v0.33.1 // indirect - k8s.io/apimachinery v0.33.1 // indirect - k8s.io/cli-runtime v0.33.1 // indirect - k8s.io/client-go v0.33.1 // indirect - k8s.io/component-base v0.33.1 // indirect + k8s.io/api v0.33.2 // indirect + k8s.io/apiextensions-apiserver v0.33.2 // indirect + k8s.io/apimachinery v0.33.2 // indirect + k8s.io/cli-runtime v0.33.2 // indirect + k8s.io/client-go v0.33.2 // indirect + k8s.io/component-base v0.33.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/kubectl v0.33.1 // indirect + k8s.io/kubectl v0.33.2 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect oras.land/oras-go/v2 v2.6.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect @@ -120,3 +86,43 @@ require ( sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) + +require ( + atomicgo.dev/cursor v0.2.0 // indirect + atomicgo.dev/keyboard v0.2.9 // indirect + atomicgo.dev/schedule v0.1.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/containerd/console v1.0.3 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect + github.com/docker/cli v28.2.2+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gookit/color v1.5.4 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lithammer/fuzzysearch v1.1.8 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/mod v0.26.0 + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + gopkg.in/ini.v1 v1.67.0 +) diff --git a/go.sum b/go.sum index 5d51eb2..4b487ac 100644 --- a/go.sum +++ b/go.sum @@ -376,8 +376,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -460,6 +460,8 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= @@ -470,26 +472,26 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= -helm.sh/helm/v3 v3.18.3 h1:+cvyGKgs7Jt7BN3Klmb4SsG4IkVpA7GAZVGvMz6VO4I= -helm.sh/helm/v3 v3.18.3/go.mod h1:wUc4n3txYBocM7S9RjTeZBN9T/b5MjffpcSsWEjSIpw= -k8s.io/api v0.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw= -k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= -k8s.io/apiextensions-apiserver v0.33.1 h1:N7ccbSlRN6I2QBcXevB73PixX2dQNIW0ZRuguEE91zI= -k8s.io/apiextensions-apiserver v0.33.1/go.mod h1:uNQ52z1A1Gu75QSa+pFK5bcXc4hq7lpOXbweZgi4dqA= -k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= -k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/cli-runtime v0.33.1 h1:TvpjEtF71ViFmPeYMj1baZMJR4iWUEplklsUQ7D3quA= -k8s.io/cli-runtime v0.33.1/go.mod h1:9dz5Q4Uh8io4OWCLiEf/217DXwqNgiTS/IOuza99VZE= -k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4= -k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= -k8s.io/component-base v0.33.1 h1:EoJ0xA+wr77T+G8p6T3l4efT2oNwbqBVKR71E0tBIaI= -k8s.io/component-base v0.33.1/go.mod h1:guT/w/6piyPfTgq7gfvgetyXMIh10zuXA6cRRm3rDuY= +helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ= +helm.sh/helm/v3 v3.18.4/go.mod h1:WVnwKARAw01iEdjpEkP7Ii1tT1pTPYfM1HsakFKM3LI= +k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= +k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= +k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8= +k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= +k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= +k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/cli-runtime v0.33.2 h1:koNYQKSDdq5AExa/RDudXMhhtFasEg48KLS2KSAU74Y= +k8s.io/cli-runtime v0.33.2/go.mod h1:gnhsAWpovqf1Zj5YRRBBU7PFsRc6NkEkwYNQE+mXL88= +k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= +k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= +k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0= +k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/kubectl v0.33.1 h1:OJUXa6FV5bap6iRy345ezEjU9dTLxqv1zFTVqmeHb6A= -k8s.io/kubectl v0.33.1/go.mod h1:Z07pGqXoP4NgITlPRrnmiM3qnoo1QrK1zjw85Aiz8J0= +k8s.io/kubectl v0.33.2 h1:7XKZ6DYCklu5MZQzJe+CkCjoGZwD1wWl7t/FxzhMz7Y= +k8s.io/kubectl v0.33.2/go.mod h1:8rC67FB8tVTYraovAGNi/idWIK90z2CHFNMmGJZJ3KI= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= diff --git a/internal/api/ar_pkg/client_gen.go b/internal/api/ar_pkg/client_gen.go index a8b5362..09c2e1a 100644 --- a/internal/api/ar_pkg/client_gen.go +++ b/internal/api/ar_pkg/client_gen.go @@ -36,9 +36,24 @@ type UploadGenericPackageMultipartBody struct { Filename string `json:"filename"` } +// UploadGoPackageMultipartBody defines parameters for UploadGoPackage. +type UploadGoPackageMultipartBody struct { + // Info Package .info file to upload + Info openapi_types.File `json:"info"` + + // Mod Package .mod file to upload + Mod openapi_types.File `json:"mod"` + + // Zip Package .zip file to upload + Zip openapi_types.File `json:"zip"` +} + // UploadGenericPackageMultipartRequestBody defines body for UploadGenericPackage for multipart/form-data ContentType. type UploadGenericPackageMultipartRequestBody UploadGenericPackageMultipartBody +// UploadGoPackageMultipartRequestBody defines body for UploadGoPackage for multipart/form-data ContentType. +type UploadGoPackageMultipartRequestBody UploadGoPackageMultipartBody + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -117,6 +132,9 @@ type ClientInterface interface { // UploadGenericPackageWithBody request with any body UploadGenericPackageWithBody(ctx context.Context, accountId string, registry string, pPackage string, version string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // UploadGoPackageWithBody request with any body + UploadGoPackageWithBody(ctx context.Context, accountId string, registry string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) DownloadGenericPackage(ctx context.Context, accountId string, registry string, pPackage string, version string, params *DownloadGenericPackageParams, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -143,6 +161,18 @@ func (c *Client) UploadGenericPackageWithBody(ctx context.Context, accountId str return c.Client.Do(req) } +func (c *Client) UploadGoPackageWithBody(ctx context.Context, accountId string, registry string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUploadGoPackageRequestWithBody(c.Server, accountId, registry, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewDownloadGenericPackageRequest generates requests for DownloadGenericPackage func NewDownloadGenericPackageRequest(server string, accountId string, registry string, pPackage string, version string, params *DownloadGenericPackageParams) (*http.Request, error) { var err error @@ -273,6 +303,49 @@ func NewUploadGenericPackageRequestWithBody(server string, accountId string, reg return req, nil } +// NewUploadGoPackageRequestWithBody generates requests for UploadGoPackage with any type of body +func NewUploadGoPackageRequestWithBody(server string, accountId string, registry string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "accountId", runtime.ParamLocationPath, accountId) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "registry", runtime.ParamLocationPath, registry) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/pkg/%s/%s/go/upload", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -321,6 +394,9 @@ type ClientWithResponsesInterface interface { // UploadGenericPackageWithBodyWithResponse request with any body UploadGenericPackageWithBodyWithResponse(ctx context.Context, accountId string, registry string, pPackage string, version string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadGenericPackageResp, error) + + // UploadGoPackageWithBodyWithResponse request with any body + UploadGoPackageWithBodyWithResponse(ctx context.Context, accountId string, registry string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadGoPackageResp, error) } type DownloadGenericPackageResp struct { @@ -365,6 +441,27 @@ func (r UploadGenericPackageResp) StatusCode() int { return 0 } +type UploadGoPackageResp struct { + Body []byte + HTTPResponse *http.Response +} + +// Status returns HTTPResponse.Status +func (r UploadGoPackageResp) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UploadGoPackageResp) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + // DownloadGenericPackageWithResponse request returning *DownloadGenericPackageResp func (c *ClientWithResponses) DownloadGenericPackageWithResponse(ctx context.Context, accountId string, registry string, pPackage string, version string, params *DownloadGenericPackageParams, reqEditors ...RequestEditorFn) (*DownloadGenericPackageResp, error) { rsp, err := c.DownloadGenericPackage(ctx, accountId, registry, pPackage, version, params, reqEditors...) @@ -383,6 +480,15 @@ func (c *ClientWithResponses) UploadGenericPackageWithBodyWithResponse(ctx conte return ParseUploadGenericPackageResp(rsp) } +// UploadGoPackageWithBodyWithResponse request with arbitrary body returning *UploadGoPackageResp +func (c *ClientWithResponses) UploadGoPackageWithBodyWithResponse(ctx context.Context, accountId string, registry string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadGoPackageResp, error) { + rsp, err := c.UploadGoPackageWithBody(ctx, accountId, registry, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUploadGoPackageResp(rsp) +} + // ParseDownloadGenericPackageResp parses an HTTP response from a DownloadGenericPackageWithResponse call func ParseDownloadGenericPackageResp(rsp *http.Response) (*DownloadGenericPackageResp, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -414,3 +520,19 @@ func ParseUploadGenericPackageResp(rsp *http.Response) (*UploadGenericPackageRes return response, nil } + +// ParseUploadGoPackageResp parses an HTTP response from a UploadGoPackageWithResponse call +func ParseUploadGoPackageResp(rsp *http.Response) (*UploadGoPackageResp, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UploadGoPackageResp{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} diff --git a/module/ar/packages/gopkg/generator.go b/module/ar/packages/gopkg/generator.go new file mode 100644 index 0000000..3accfdd --- /dev/null +++ b/module/ar/packages/gopkg/generator.go @@ -0,0 +1,160 @@ +// Package gopkg provides functionality for generating and managing Go packages. +package gopkg + +import ( + "fmt" + "path/filepath" + + "github.com/harness/harness-cli/util/common/errors" + "github.com/harness/harness-cli/util/common/fileutil" + "github.com/harness/harness-cli/util/common/progress" + + "golang.org/x/mod/modfile" +) + +// Generator handles Go package generation operations. +// It coordinates the validation, file generation, and metadata collection +// processes using the provided interfaces. +type Generator struct { + sourceDir string + outputDir string + version string + + validator ModuleValidator + fileGen FileGenerator + vcsProvider VCSMetadataProvider +} + +// NewGenerator creates a new Generator instance with default implementations. +// It uses the DefaultGenerator to provide the standard implementations of +// ModuleValidator, FileGenerator, and VCSMetadataProvider. +func NewGenerator(sourceDir, outputDir, version string) *Generator { + defGen := NewDefaultGenerator() + return &Generator{ + sourceDir: sourceDir, + outputDir: outputDir, + version: version, + validator: defGen.validator, + fileGen: defGen.fileGen, + vcsProvider: defGen.vcsProvider, + } +} + +// Generate creates the package files and returns the package name. +// It performs the following steps: +// 1. Validates the version format and module path +// 2. Prepares the output directory +// 3. Generates all required package files +// Returns the package name (version) on success, or an error if any step fails. +func (g *Generator) Generate(reporter progress.Reporter) (string, error) { + if reporter == nil { + reporter = &progress.NopReporter{} + } + + // Start package generation + reporter.Start("Generating Go package") + defer reporter.End() + + // Validate version and module path + reporter.Step("Validating package version") + if err := g.validator.ValidateVersion(g.version); err != nil { + reporter.Error("Invalid version format") + return "", errors.NewPackageError("validate_version", g.version, "", err) + } + + goModPath := filepath.Join(g.sourceDir, "go.mod") + reporter.Step("Extracting module path") + modulePath, err := g.extractModulePath(goModPath) + if err != nil { + reporter.Error("Failed to extract module path") + return "", errors.NewPackageError("extract_module", g.version, "", err) + } + + reporter.Step("Validating module path") + if err := g.validator.ValidateModulePath(modulePath, g.version); err != nil { + reporter.Error("Invalid module path") + return "", errors.NewPackageError("validate_module", modulePath, g.version, err) + } + + // Prepare output directory + reporter.Step("Preparing output directory") + if err := fileutil.ResetDir(g.outputDir); err != nil { + reporter.Error("Failed to prepare output directory") + return "", errors.NewPackageError("prepare_output", g.version, "", err) + } + + // Generate package files + reporter.Step("Generating package files") + if err := g.generatePackageFiles(modulePath, reporter); err != nil { + reporter.Error("Failed to generate package files") + return "", err + } + + reporter.Success("Package generated successfully") + return g.version, nil +} + +// validatePackage performs all validation checks before package generation + +// prepareOutputDirectory prepares the output directory for package generation + +// generatePackageFiles generates all required package files +// generatePackageFiles coordinates the generation of all required package files. +// It generates three files: +// 1. go.mod file - copied from the source directory +// 2. .info file - contains package metadata +// 3. .zip file - contains all package files +func (g *Generator) generatePackageFiles(modulePath string, progress progress.Reporter) error { + // Generate go.mod file + progress.Step("Generating go.mod file") + goModPath := filepath.Join(g.sourceDir, "go.mod") + modOutPath := filepath.Join(g.outputDir, g.version+".mod") + if err := g.fileGen.GenerateModFile(goModPath, modOutPath); err != nil { + progress.Error("Failed to generate go.mod file") + return errors.NewPackageError("write_mod", g.version, "", err) + } + + // Generate .info file + progress.Step("Generating package info file") + infoPath := filepath.Join(g.outputDir, g.version+".info") + originMetadata, err := g.vcsProvider.GetMetadata(g.sourceDir) + if err != nil { + progress.Error(fmt.Sprintf("Failed to get VCS metadata: %s", err.Error())) + originMetadata = nil + } + if err := g.fileGen.GenerateInfoFile(infoPath, g.version, originMetadata); err != nil { + progress.Error("Failed to generate info file") + return errors.NewPackageError("write_info", g.version, "", err) + } + + // Generate zip file + progress.Step("Creating package archive") + zipPath := filepath.Join(g.outputDir, g.version+".zip") + if err := g.fileGen.GenerateZipFile(g.sourceDir, zipPath, modulePath, g.version); err != nil { + progress.Error("Failed to create package archive") + return errors.NewPackageError("write_zip", g.version, "", err) + } + + return nil +} + +// extractModulePath reads and parses the go.mod file to extract the module path. +// It validates that the file exists, is properly formatted, and contains +// a module directive. +func (g *Generator) extractModulePath(goModPath string) (string, error) { + data, err := fileutil.ReadFile(goModPath) + if err != nil { + return "", errors.NewFileError(goModPath, "read", err) + } + + modFile, err := modfile.Parse("go.mod", data, nil) + if err != nil { + return "", errors.NewValidationError("go.mod", "invalid module file format") + } + + if modFile.Module == nil { + return "", errors.NewValidationError("go.mod", "module directive not found") + } + + return modFile.Module.Mod.Path, nil +} diff --git a/module/ar/packages/gopkg/impl.go b/module/ar/packages/gopkg/impl.go new file mode 100644 index 0000000..96a6568 --- /dev/null +++ b/module/ar/packages/gopkg/impl.go @@ -0,0 +1,328 @@ +// Package gopkg provides functionality for generating and managing Go packages. +package gopkg + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/harness/harness-cli/util/common/errors" + "github.com/harness/harness-cli/util/common/fileutil" + "github.com/harness/harness-cli/util/common/vcs" +) + +const ( + ModulePathRegex = `^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+(?:/[a-z0-9][a-z0-9._-]*)*$` +) + +// DefaultGenerator provides the default implementation of package generation interfaces. +// It combines the default implementations of ModuleValidator, FileGenerator, and +// VCSMetadataProvider to provide a complete package generation solution. +type DefaultGenerator struct { + validator ModuleValidator + fileGen FileGenerator + vcsProvider VCSMetadataProvider +} + +// NewDefaultGenerator creates a new DefaultGenerator with default implementations +func NewDefaultGenerator() *DefaultGenerator { + return &DefaultGenerator{ + validator: &defaultModuleValidator{}, + fileGen: &defaultFileGenerator{}, + vcsProvider: &defaultVCSProvider{}, + } +} + +// defaultModuleValidator provides the default implementation of ModuleValidator. +// It validates Go module version numbers and module paths according to Go module +// conventions, ensuring that version numbers follow semantic versioning and +// module paths include appropriate version suffixes. +type defaultModuleValidator struct{} + +// ValidateVersion checks if the version string follows semantic versioning format. +// The version must be in the form vX.Y.Z where X, Y, and Z are non-negative integers. +// For example: v1.0.0, v2.1.3 +// Returns a validation error if the format is invalid. +func (v *defaultModuleValidator) ValidateVersion(version string) error { + re := regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)$`) + if !re.MatchString(version) { + return errors.NewValidationError("version", "must be in form vX.Y.Z") + } + return nil +} + +// ValidateModulePath validates the module path against the version number. +// For v0 and v1 versions, the module path must not have a /vN suffix. +// For v2+ versions, the module path must have a /vN suffix matching the major version. +// For example: +// - For v1.0.0: github.com/user/module (no suffix) +// - For v2.0.0: github.com/user/module/v2 +// - For v3.0.0: github.com/user/module/v3 +func (v *defaultModuleValidator) ValidateModulePath(modulePath, version string) error { + major, err := v.extractMajor(version) + if err != nil { + return errors.NewValidationError("version", err.Error()) + } + + if !regexp.MustCompile(ModulePathRegex).MatchString(modulePath) { + return errors.NewValidationError("module_path", "module path must match regex "+ModulePathRegex) + } + + if major <= 1 { + if strings.HasSuffix(modulePath, "/v2") || strings.HasSuffix(modulePath, fmt.Sprintf("/v%d", major+1)) { + return errors.NewValidationError("module_path", "module path must not have /vN suffix for v1 or below") + } + return nil + } + + expected := fmt.Sprintf("/v%d", major) + if !strings.HasSuffix(modulePath, expected) { + return errors.NewValidationError("module_path", "module path must end with major version suffix") + } + return nil +} + +// extractMajor extracts the major version number from a semantic version string. +// The version string must start with 'v' followed by the major version number. +// +// Parameters: +// - version: version string in the form vX.Y.Z +// +// Returns: +// - major version number as an integer +// - error if the version format is invalid +// +// Example: +// - "v1.2.3" -> 1 +// - "v2.0.0" -> 2 +// - "invalid" -> error +func (v *defaultModuleValidator) extractMajor(version string) (int, error) { + re := regexp.MustCompile(`^v(\d+)\.`) + match := re.FindStringSubmatch(version) + if len(match) < 2 { + return 0, errors.NewValidationError("version", "invalid version format") + } + var major int + fmt.Sscanf(match[1], "%d", &major) + return major, nil +} + +// defaultFileGenerator provides the default implementation of FileGenerator. +// It handles the generation of all required files for a Go package, including +// the go.mod file, package info file, and the package zip archive. The generator +// follows Go module proxy conventions for file layout and naming. +type defaultFileGenerator struct{} + +// GenerateModFile copies the go.mod file from the source to the output location. +// The file is copied as-is to preserve the original module definition and dependencies. +// The go.mod file is required for the Go module proxy to understand the module's +// dependencies and version requirements. +// +// Parameters: +// - sourcePath: path to the source go.mod file +// - outputPath: path where the go.mod file should be written +func (g *defaultFileGenerator) GenerateModFile(sourcePath, outputPath string) error { + data, err := fileutil.ReadFile(sourcePath) + if err != nil { + return errors.NewFileError(sourcePath, "read", err) + } + + if err := fileutil.WriteFile(outputPath, data); err != nil { + return errors.NewFileError(outputPath, "write", err) + } + return nil +} + +// GenerateInfoFile creates a JSON file containing package metadata. +// The info file includes: +// - Version: the package version +// - Time: timestamp when the package was created (RFC3339 format) +// - Origin: VCS metadata if available (Git repository info) +// +// This file is used by the Go module proxy to provide information about +// the package version and its origin. +// +// Parameters: +// - outputPath: path where the info file should be written +// - version: version of the package +func (g *defaultFileGenerator) GenerateInfoFile(outputPath, version string, origin *Origin) error { + info := PackageMetadata{ + Version: version, + Time: time.Now().Format(time.RFC3339), + } + if origin != nil { + info.Origin = *origin + } + + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return errors.NewPackageError("marshal_info", version, "", err) + } + + if err := fileutil.WriteFile(outputPath, data); err != nil { + return errors.NewFileError(outputPath, "write", err) + } + return nil +} + +// GenerateZipFile creates a zip archive containing all package files. +// The zip file follows Go module proxy conventions for file layout: +// - Files are stored under a prefix of "$modulePath@$version/" +// - All paths use forward slashes, even on Windows +// - Files maintain their relative paths from the source directory +// +// Parameters: +// - sourcePath: root directory of the package source +// - outputPath: path where the zip file should be written +// - modulePath: import path of the module +// - version: version of the package +// +// For example, for module "github.com/user/module" at version "v1.0.0": +// - Source file: src/foo/bar.go +// - In zip as: github.com/user/module@v1.0.0/foo/bar.go +func (g *defaultFileGenerator) GenerateZipFile(sourcePath, outputPath, modulePath, version string) error { + fzip, err := os.Create(outputPath) + if err != nil { + return errors.NewFileError(outputPath, "create", err) + } + defer fzip.Close() + + zw := zip.NewWriter(fzip) + defer zw.Close() + + entries, err := g.collectZipEntries(sourcePath, modulePath, version) + if err != nil { + return errors.NewPackageError("collect_files", version, "", err) + } + + // Add each file to the zip archive + for _, entry := range entries { + if err := g.addFileToZip(zw, entry); err != nil { + return errors.NewPackageError("add_to_zip", version, "", err) + } + } + + return nil +} + +// collectZipEntries walks the source directory and collects files to be added to the zip. +// For each file, it creates a zipEntry that maps the source file path to its +// corresponding path in the zip archive. The zip path follows Go module proxy +// conventions by prefixing files with "$modulePath@$version/". +// +// Parameters: +// - sourcePath: root directory to collect files from +// - modulePath: import path of the module +// - version: version of the package +// +// Returns: +// - list of zipEntry structs mapping source paths to zip paths +// - error if walking the directory or getting relative paths fails +// +// Example: +// +// For module "example.com/pkg" at version "v1.0.0": +// - Source: /path/to/src/foo/bar.go +// - Zip path: example.com/pkg@v1.0.0/foo/bar.go +func (g *defaultFileGenerator) collectZipEntries(sourcePath, modulePath, version string) ([]zipEntry, error) { + var entries []zipEntry + prefix := fmt.Sprintf("%s@%s/", modulePath, version) + + err := filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + + rel, err := filepath.Rel(sourcePath, path) + if err != nil { + return errors.NewFileError(path, "get_relative_path", err) + } + + entries = append(entries, zipEntry{ + sourcePath: path, + zipPath: prefix + filepath.ToSlash(rel), + }) + return nil + }) + + if err != nil { + return nil, errors.NewFileError(sourcePath, "walk", err) + } + + return entries, nil +} + +// addFileToZip adds a single file to the zip archive. +// It opens the source file, creates a new entry in the zip archive with +// the specified path, and copies the file content. The function handles +// proper error handling and resource cleanup. +// +// Parameters: +// - zw: zip writer to add the file to +// - entry: zipEntry containing source and destination paths +// +// Returns: +// - error if opening the source file, creating the zip entry, +// or copying the content fails +// +// The function ensures that: +// - Source file is properly opened and closed +// - Zip entry is created with the correct path +// - File content is copied efficiently using io.Copy +func (g *defaultFileGenerator) addFileToZip(zw *zip.Writer, entry zipEntry) error { + src, err := os.Open(entry.sourcePath) + if err != nil { + return errors.NewFileError(entry.sourcePath, "open", err) + } + defer src.Close() + + w, err := zw.Create(entry.zipPath) + if err != nil { + return errors.NewFileError(entry.zipPath, "create_zip_entry", err) + } + + if _, err := io.Copy(w, src); err != nil { + return errors.NewFileError(entry.zipPath, "write_zip_entry", err) + } + + return nil +} + +// defaultVCSProvider provides the default implementation of VCSMetadataProvider. +// It extracts version control information from Git repositories, including +// repository URL, current branch or reference, and commit hash. This information +// is used to track the origin of package versions. +type defaultVCSProvider struct{} + +// GetMetadata extracts VCS metadata from a Git repository. +// The metadata includes: +// - VCS: the version control system type (always "git") +// - URL: the remote repository URL (e.g., "https://github.com/user/repo.git") +// - Ref: the current branch or tag (e.g., "main", "v1.0.0") +// - Hash: the full commit hash +// +// This information helps users track where a package version came from and +// verify its authenticity. +// +// Parameters: +// - path: root directory of the Git repository +func (p *defaultVCSProvider) GetMetadata(path string) (*Origin, error) { + repo := vcs.NewGitRepository(path) + gitInfo, err := repo.GetInfo() + if err != nil { + return nil, errors.NewPackageError("get_vcs_info", path, "", err) + } + + return &Origin{ + VCS: gitInfo.VCS, + URL: gitInfo.URL, + Ref: gitInfo.Ref, + Hash: gitInfo.Hash, + }, nil +} diff --git a/module/ar/packages/gopkg/interfaces.go b/module/ar/packages/gopkg/interfaces.go new file mode 100644 index 0000000..1dfefb9 --- /dev/null +++ b/module/ar/packages/gopkg/interfaces.go @@ -0,0 +1,70 @@ +package gopkg + +// PackageGenerator defines the interface for Go package generation. +// Implementations of this interface should handle the complete process +// of generating a Go package, including validation, file generation, +// and metadata collection. +type PackageGenerator interface { + // Generate creates the package files and returns the package name. + // It performs all necessary validation and file generation steps, + // creating the .mod, .info, and .zip files in the output directory. + // Returns the package name (version) on success, or an error if any + // step in the generation process fails. + Generate() (string, error) +} + +// ModuleValidator defines the interface for Go module validation. +// This interface provides methods to validate Go module version numbers +// and module paths according to Go module conventions. +type ModuleValidator interface { + // ValidateVersion validates the package version format. + // The version must be in the form vX.Y.Z where X, Y, and Z are non-negative integers. + // Returns an error if the version format is invalid. + ValidateVersion(version string) error + + // ValidateModulePath validates the module path against version. + // For v0 and v1 versions, the module path must not have a /vN suffix. + // For v2+ versions, the module path must have a /vN suffix matching the major version. + // Returns an error if the module path does not follow these conventions. + ValidateModulePath(modulePath, version string) error +} + +// FileGenerator defines the interface for package file generation. +// This interface provides methods to generate all required files for +// a Go package, including the go.mod file, package info file, and +// the package zip archive. +type FileGenerator interface { + // GenerateModFile generates the go.mod file by copying it from the source + // to the output location. The file is copied as-is to preserve the original + // module definition and dependencies. + // sourcePath: path to the source go.mod file + // outputPath: path where the go.mod file should be written + GenerateModFile(sourcePath, outputPath string) error + + // GenerateInfoFile generates the package info file containing metadata + // about the package, including version information and VCS details. + // outputPath: path where the info file should be written + // version: version of the package + GenerateInfoFile(outputPath, version string, origin *Origin) error + + // GenerateZipFile generates the package zip file containing all package files. + // The zip file follows Go module proxy conventions for file layout. + // sourcePath: root directory of the package source + // outputPath: path where the zip file should be written + // modulePath: import path of the module + // version: version of the package + GenerateZipFile(sourcePath, outputPath, modulePath, version string) error +} + +// VCSMetadataProvider defines the interface for version control system metadata. +// This interface provides methods to extract version control information +// from a package's repository, such as Git commit hashes, branch names, +// and remote URLs. +type VCSMetadataProvider interface { + // GetMetadata returns VCS metadata for the package. + // It extracts information such as the VCS type (e.g., git), + // repository URL, current branch or reference, and commit hash. + // path: root directory of the package repository + // Returns the VCS metadata or an error if extraction fails. + GetMetadata(path string) (*Origin, error) +} diff --git a/module/ar/packages/gopkg/types.go b/module/ar/packages/gopkg/types.go new file mode 100644 index 0000000..dc1dfdd --- /dev/null +++ b/module/ar/packages/gopkg/types.go @@ -0,0 +1,22 @@ +package gopkg + +// Origin represents the VCS metadata of a Go package +type Origin struct { + VCS string `json:"VCS,omitempty"` + URL string `json:"URL,omitempty"` + Ref string `json:"Ref,omitempty"` + Hash string `json:"Hash,omitempty"` +} + +// PackageMetadata represents the metadata of a Go package +type PackageMetadata struct { + Version string `json:"Version"` + Time string `json:"Time"` + Origin Origin `json:"Origin,omitempty"` +} + +// zipEntry represents a file to be added to the zip archive +type zipEntry struct { + sourcePath string + zipPath string +} diff --git a/util/common/errors/errors.go b/util/common/errors/errors.go new file mode 100644 index 0000000..d21c393 --- /dev/null +++ b/util/common/errors/errors.go @@ -0,0 +1,142 @@ +package errors + +import ( + "errors" + "fmt" +) + +// Common errors that can be used across packages +var ( + ErrNotFound = errors.New("resource not found") + ErrInvalidArgument = errors.New("invalid argument") + ErrInvalidOperation = errors.New("invalid operation") + ErrUnauthorized = errors.New("unauthorized") + ErrInternal = errors.New("internal error") +) + +// ValidationError represents an error that occurs during validation +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Message) +} + +// NewValidationError creates a new ValidationError +func NewValidationError(field, message string) error { + return &ValidationError{ + Field: field, + Message: message, + } +} + +// FileError represents an error that occurs during file operations +type FileError struct { + Path string + Op string + Wrapped error +} + +func (e *FileError) Error() string { + if e.Wrapped != nil { + return fmt.Sprintf("%s operation failed on %s: %v", e.Op, e.Path, e.Wrapped) + } + return fmt.Sprintf("%s operation failed on %s", e.Op, e.Path) +} + +func (e *FileError) Unwrap() error { + return e.Wrapped +} + +// NewFileError creates a new FileError +func NewFileError(path, op string, wrapped error) error { + return &FileError{ + Path: path, + Op: op, + Wrapped: wrapped, + } +} + +// VCSError represents an error that occurs during version control operations +type VCSError struct { + Op string + Path string + Wrapped error +} + +func (e *VCSError) Error() string { + if e.Wrapped != nil { + return fmt.Sprintf("VCS %s operation failed for %s: %v", e.Op, e.Path, e.Wrapped) + } + return fmt.Sprintf("VCS %s operation failed for %s", e.Op, e.Path) +} + +func (e *VCSError) Unwrap() error { + return e.Wrapped +} + +// NewVCSError creates a new VCSError +func NewVCSError(op, path string, wrapped error) error { + return &VCSError{ + Op: op, + Path: path, + Wrapped: wrapped, + } +} + +// PackageError represents an error that occurs during package operations +type PackageError struct { + Op string + Package string + Version string + Wrapped error +} + +func (e *PackageError) Error() string { + if e.Version != "" { + if e.Wrapped != nil { + return fmt.Sprintf("package %s operation failed for %s@%s: %v", e.Op, e.Package, e.Version, e.Wrapped) + } + return fmt.Sprintf("package %s operation failed for %s@%s", e.Op, e.Package, e.Version) + } + if e.Wrapped != nil { + return fmt.Sprintf("package %s operation failed for %s: %v", e.Op, e.Package, e.Wrapped) + } + return fmt.Sprintf("package %s operation failed for %s", e.Op, e.Package) +} + +func (e *PackageError) Unwrap() error { + return e.Wrapped +} + +// NewPackageError creates a new PackageError +func NewPackageError(op, pkg, version string, wrapped error) error { + return &PackageError{ + Op: op, + Package: pkg, + Version: version, + Wrapped: wrapped, + } +} + +// Is reports whether target matches err. +// It enables errors.Is() to work with our custom error types. +func Is(err, target error) bool { + return errors.Is(err, target) +} + +// As finds the first error in err's chain that matches target. +// It enables errors.As() to work with our custom error types. +func As(err error, target interface{}) bool { + return errors.As(err, target) +} + +// Wrap wraps an error with additional context +func Wrap(err error, message string) error { + if err == nil { + return nil + } + return fmt.Errorf("%s: %w", message, err) +} diff --git a/util/common/fileutil/fileutil.go b/util/common/fileutil/fileutil.go new file mode 100644 index 0000000..33d7b23 --- /dev/null +++ b/util/common/fileutil/fileutil.go @@ -0,0 +1,200 @@ +package fileutil + +import ( + "io" + "os" + "path/filepath" + "strings" + + "github.com/harness/harness-cli/util/common/errors" +) + +// validatePath checks if a path is valid and accessible. +// Returns an error if the path is empty, contains invalid characters, +// or if the parent directory is not accessible. +func validatePath(path string) error { + if path == "" { + return errors.NewValidationError("path", "path cannot be empty") + } + + // Check for invalid characters in path + if strings.ContainsAny(path, "<>:|?*\\") { + return errors.NewValidationError("path", "path contains invalid characters") + } + + // Check if parent directory exists and is accessible + parent := filepath.Dir(path) + if parent != "." { + if _, err := os.Stat(parent); err != nil { + return errors.NewFileError(parent, "access", err) + } + } + + return nil +} + +// validateWritePermissions checks if a directory is writable. +// Returns an error if the directory is not writable or if testing +// write permissions fails. +func validateWritePermissions(dir string) error { + // Create a temporary file to test write permissions + testFile := filepath.Join(dir, ".write_test") + f, err := os.OpenFile(testFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666) + if err != nil { + return errors.NewFileError(dir, "write_permission", err) + } + f.Close() + os.Remove(testFile) + return nil +} + +// ResetDir removes a directory if it exists and creates a fresh empty one. +// It validates the path and checks write permissions before proceeding. +func ResetDir(path string) error { + // Validate path + if err := validatePath(path); err != nil { + return err + } + + // Remove existing directory if it exists + if err := os.RemoveAll(path); err != nil { + return errors.NewFileError(path, "remove", err) + } + + // Create new directory + if err := os.MkdirAll(path, 0755); err != nil { + return errors.NewFileError(path, "create", err) + } + + // Verify write permissions + if err := validateWritePermissions(path); err != nil { + return err + } + + return nil +} + +// ReadFile reads the entire file and returns its contents. +// It validates the path and checks if the file exists and is readable. +func ReadFile(path string) ([]byte, error) { + // Validate path + if err := validatePath(path); err != nil { + return nil, err + } + + // Check if file exists and is readable + info, err := os.Stat(path) + if err != nil { + return nil, errors.NewFileError(path, "stat", err) + } + if info.IsDir() { + return nil, errors.NewValidationError("path", "path is a directory, expected a file") + } + + // Read file contents + data, err := os.ReadFile(path) + if err != nil { + return nil, errors.NewFileError(path, "read", err) + } + return data, nil +} + +// WriteFile writes data to a file, creating it if necessary. +// It validates the path, creates parent directories if needed, +// and verifies write permissions before writing. +func WriteFile(path string, data []byte) error { + // Validate path + if err := validatePath(path); err != nil { + return err + } + + // Create parent directories if needed + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return errors.NewFileError(path, "create_dir", err) + } + + // Verify write permissions + if err := validateWritePermissions(dir); err != nil { + return err + } + + // Write file contents + if err := os.WriteFile(path, data, 0644); err != nil { + return errors.NewFileError(path, "write", err) + } + return nil +} + +// CopyFile copies a file from src to dst. +// It validates both paths, ensures the source exists and is readable, +// creates parent directories if needed, and verifies write permissions. +func CopyFile(src, dst string) error { + // Validate source path + if err := validatePath(src); err != nil { + return err + } + + // Validate destination path + if err := validatePath(dst); err != nil { + return err + } + + // Check if source exists and is readable + srcInfo, err := os.Stat(src) + if err != nil { + return errors.NewFileError(src, "stat", err) + } + if srcInfo.IsDir() { + return errors.NewValidationError("src", "source path is a directory, expected a file") + } + + // Create parent directories if needed + dstDir := filepath.Dir(dst) + if err := os.MkdirAll(dstDir, 0755); err != nil { + return errors.NewFileError(dst, "create_dir", err) + } + + // Verify write permissions + if err := validateWritePermissions(dstDir); err != nil { + return err + } + + // Open source file + srcFile, err := os.Open(src) + if err != nil { + return errors.NewFileError(src, "open", err) + } + defer srcFile.Close() + + // Create destination file + dstFile, err := os.Create(dst) + if err != nil { + return errors.NewFileError(dst, "create", err) + } + defer dstFile.Close() + + // Copy file contents + if _, err := io.Copy(dstFile, srcFile); err != nil { + return errors.NewFileError(dst, "copy", err) + } + return nil +} + +// Exists checks if a file or directory exists +func Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// IsDir checks if the path is a directory +func IsDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// IsFile checks if the path is a regular file +func IsFile(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/util/common/progress/progress.go b/util/common/progress/progress.go new file mode 100644 index 0000000..e1e16cf --- /dev/null +++ b/util/common/progress/progress.go @@ -0,0 +1,64 @@ +// Package progress provides progress reporting functionality +package progress + +import "fmt" + +// Reporter defines the interface for reporting progress. +// It provides methods to report different stages of an operation +// and its status. +type Reporter interface { + // Start begins progress reporting with an initial message + Start(message string) + + // Step reports a new step in the operation + Step(message string) + + // Error reports an error condition + Error(message string) + + // Success reports successful completion + Success(message string) + + // End finalizes progress reporting + End() +} + +// ConsoleReporter implements Reporter by printing messages to console +type ConsoleReporter struct{} + +// NewConsoleReporter creates a new ConsoleReporter +func NewConsoleReporter() *ConsoleReporter { + return &ConsoleReporter{} +} + +func (r *ConsoleReporter) Start(message string) { + fmt.Printf("⚡ %s...\n", message) +} + +func (r *ConsoleReporter) Step(message string) { + fmt.Printf(" ▶ %s...\n", message) +} + +func (r *ConsoleReporter) Error(message string) { + fmt.Printf(" ❌ %s\n", message) +} + +func (r *ConsoleReporter) Success(message string) { + fmt.Printf(" ✅ %s\n", message) +} + +func (r *ConsoleReporter) End() {} + +// NopReporter implements Reporter with no-op operations +type NopReporter struct{} + +// NewNopReporter creates a new NopReporter +func NewNopReporter() *NopReporter { + return &NopReporter{} +} + +func (r *NopReporter) Start(message string) {} +func (r *NopReporter) Step(message string) {} +func (r *NopReporter) Error(message string) {} +func (r *NopReporter) Success(message string) {} +func (r *NopReporter) End() {} diff --git a/util/common/vcs/git.go b/util/common/vcs/git.go new file mode 100644 index 0000000..9d94391 --- /dev/null +++ b/util/common/vcs/git.go @@ -0,0 +1,291 @@ +package vcs + +import ( + "os" + "path/filepath" + "strings" + + "github.com/harness/harness-cli/util/common/errors" + "github.com/harness/harness-cli/util/common/fileutil" + "github.com/harness/harness-cli/util/common/progress" + + "gopkg.in/ini.v1" +) + +// GitInfo represents Git repository metadata +type GitInfo struct { + VCS string `json:"VCS,omitempty"` + URL string `json:"URL,omitempty"` + Ref string `json:"Ref,omitempty"` + Hash string `json:"Hash,omitempty"` +} + +// validateGitPath checks if a path is valid and points to a Git repository. +// Returns an error if the path is empty, contains invalid characters, +// or if the path is not accessible. +func validateGitPath(path string) error { + if path == "" { + return errors.NewValidationError("path", "path cannot be empty") + } + + // Check for invalid characters in path + if strings.ContainsAny(path, "<>:|?*\\") { + return errors.NewValidationError("path", "path contains invalid characters") + } + + // Check if path exists and is accessible + info, err := os.Stat(path) + if err != nil { + return errors.NewFileError(path, "access", err) + } + + // Check if path is a directory + if !info.IsDir() { + return errors.NewValidationError("path", "path is not a directory") + } + + // Check if path contains a .git directory + gitDir := filepath.Join(path, ".git") + gitInfo, err := os.Stat(gitDir) + if err != nil { + return errors.NewVCSError("validate", path, errors.ErrInvalidOperation) + } + if !gitInfo.IsDir() { + return errors.NewVCSError("validate", path, errors.ErrInvalidOperation) + } + + return nil +} + +// GitRepository represents a Git repository +type GitRepository struct { + path string + progress progress.Reporter +} + +// NewGitRepository creates a new GitRepository instance. +// It validates that the provided path points to a valid Git repository. +// Returns a GitRepository instance or nil if validation fails. +func NewGitRepository(path string) *GitRepository { + if err := validateGitPath(path); err != nil { + return nil + } + // Create progress reporter + progress := progress.NewConsoleReporter() + defer progress.End() + + return &GitRepository{ + path: path, + progress: progress, + } +} + +// GetInfo returns Git repository information. +// It validates the repository state and extracts information about +// the current HEAD, remote URL, and commit hash. +// +// The returned GitInfo includes: +// - VCS: the version control system type (always "git") +// - URL: the remote repository URL +// - Ref: the current branch or reference +// - Hash: the full commit hash +// +// Returns an error if: +// - The repository is invalid or inaccessible +// - Required Git files cannot be read +// - Repository state is inconsistent +func (g *GitRepository) GetInfo() (*GitInfo, error) { + // Validate repository path + if g == nil || g.path == "" { + return nil, errors.NewVCSError("validate", "", errors.ErrInvalidOperation) + } + + // Check Git directory + gitDir := filepath.Join(g.path, ".git") + if !fileutil.IsDir(gitDir) { + return nil, errors.NewVCSError("validate", g.path, errors.ErrInvalidOperation) + } + + info := &GitInfo{ + VCS: "git", + } + + // Read and validate HEAD + headPath := filepath.Join(gitDir, "HEAD") + headBytes, err := fileutil.ReadFile(headPath) + if err != nil { + g.progress.Error("Failed to read HEAD file") + } + var head string + if headBytes != nil { + head = strings.TrimSpace(string(headBytes)) + if head == "" { + g.progress.Error("Invalid HEAD file") + } + } + + // Extract ref and hash + if strings.HasPrefix(head, "ref: ") { + ref := strings.TrimPrefix(head, "ref: ") + info.Ref = ref + + // Validate and read ref file + refPath := filepath.Join(gitDir, ref) + if _, err := os.Stat(refPath); err != nil { + g.progress.Error("Failed to read ref file") + } + + shaBytes, err := fileutil.ReadFile(refPath) + if err != nil { + g.progress.Error("Failed to read ref file") + } + + hash := strings.TrimSpace(string(shaBytes)) + if !isValidSHA(hash) { + g.progress.Error("Invalid ref file") + } + info.Hash = hash + } else { + // Validate detached HEAD hash + if !isValidSHA(head) { + g.progress.Error("Invalid detached HEAD hash") + } + info.Hash = head + } + + // Get and validate remote URL + url, err := g.GetRemoteURL() + if err != nil { + g.progress.Error("Failed to get remote URL") + } + info.URL = url + + return info, nil +} + +// IsGitRepository checks if the given path is a Git repository +func IsGitRepository(path string) bool { + gitDir := filepath.Join(path, ".git") + return fileutil.IsDir(gitDir) +} + +// isValidSHA checks if a string is a valid Git SHA-1 hash. +// A valid SHA-1 hash is 40 characters long and contains only hexadecimal digits. +func isValidSHA(hash string) bool { + if len(hash) != 40 { + return false + } + for _, c := range hash { + if !strings.ContainsRune("0123456789abcdef", c) { + return false + } + } + return true +} + +// GetCurrentBranch returns the current branch name. +// If HEAD points to a branch, returns the branch name. +// If HEAD is detached, returns an empty string. +// +// Returns an error if: +// - The repository is invalid or inaccessible +// - HEAD file cannot be read +// - HEAD content is invalid +func (g *GitRepository) GetCurrentBranch() (string, error) { + // Validate repository + if g == nil || g.path == "" { + return "", errors.NewVCSError("validate", "", errors.ErrInvalidOperation) + } + + // Read HEAD file + gitDir := filepath.Join(g.path, ".git") + headPath := filepath.Join(gitDir, "HEAD") + + headBytes, err := fileutil.ReadFile(headPath) + if err != nil { + return "", errors.NewVCSError("read_head", g.path, err) + } + + // Parse and validate HEAD content + head := strings.TrimSpace(string(headBytes)) + if head == "" { + return "", errors.NewVCSError("validate_head", g.path, errors.ErrInvalidOperation) + } + + // Extract branch name if HEAD points to a branch + if strings.HasPrefix(head, "ref: refs/heads/") { + branch := strings.TrimPrefix(head, "ref: refs/heads/") + if branch == "" { + return "", errors.NewVCSError("validate_branch", g.path, errors.ErrInvalidOperation) + } + return branch, nil + } + + // HEAD is detached (points directly to a commit) + return "", nil +} + +// GetRemoteURL returns the remote URL of the repository. +// It reads the repository's config file and extracts the URL +// of the 'origin' remote. +// +// Returns an error if: +// - The repository is invalid or inaccessible +// - Config file cannot be read or parsed +// - Remote 'origin' is not configured +func (g *GitRepository) GetRemoteURL() (string, error) { + // Validate repository + if g == nil || g.path == "" { + return "", errors.NewVCSError("validate", "", errors.ErrInvalidOperation) + } + + // Check config file + gitDir := filepath.Join(g.path, ".git") + configPath := filepath.Join(gitDir, "config") + + if !fileutil.IsFile(configPath) { + return "", errors.NewVCSError("read_config", g.path, errors.ErrNotFound) + } + + // Read and parse config file + cfg, err := ini.Load(configPath) + if err != nil { + return "", errors.NewVCSError("read_config", g.path, err) + } + + // Get and validate remote URL + section := cfg.Section(`remote "origin"`) + if section == nil { + return "", errors.NewVCSError("validate_remote", g.path, errors.ErrNotFound) + } + + url := section.Key("url").String() + if url == "" { + return "", errors.NewVCSError("validate_remote", g.path, errors.ErrNotFound) + } + return url, nil +} + +// GetCommitHash returns the current commit hash +func (g *GitRepository) GetCommitHash() (string, error) { + gitDir := filepath.Join(g.path, ".git") + headPath := filepath.Join(gitDir, "HEAD") + + headBytes, err := fileutil.ReadFile(headPath) + if err != nil { + return "", errors.NewVCSError("read_head", g.path, err) + } + + head := strings.TrimSpace(string(headBytes)) + if strings.HasPrefix(head, "ref: ") { + ref := strings.TrimPrefix(head, "ref: ") + refPath := filepath.Join(gitDir, ref) + shaBytes, err := fileutil.ReadFile(refPath) + if err != nil { + return "", errors.NewVCSError("read_ref", g.path, err) + } + return strings.TrimSpace(string(shaBytes)), nil + } + + return head, nil // Detached HEAD, HEAD is already a commit hash +} diff --git a/util/utils.go b/util/utils.go index b7fe90b..f8fe03a 100644 --- a/util/utils.go +++ b/util/utils.go @@ -111,3 +111,11 @@ func ProgressBar(current, total int, width int) string { return fmt.Sprintf("%s %.1f%%", bar, percentage*100) } + +// GetPkgUrl returns the URL for the packages +func GetPkgUrl(url string) string { + if !strings.Contains(url, "://") { + url = "https://" + url + } + return url +}