From 7a5b76b84a7bc8e714666d93a952f5158b098c5f Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Wed, 29 Jul 2026 13:18:58 +0530 Subject: [PATCH] Feat: Added registry migration from Harbor --- .../pkg/har/migrate/adapter/harbor/adapter.go | 164 +++++++++++++++++ .../pkg/har/migrate/adapter/harbor/client.go | 173 ++++++++++++++++++ .../har/migrate/adapter/harbor/keychain.go | 43 +++++ modules/har/pkg/har/migrate/migration.go | 1 + modules/har/pkg/har/migrate/types/config.go | 3 +- 5 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 modules/har/pkg/har/migrate/adapter/harbor/adapter.go create mode 100644 modules/har/pkg/har/migrate/adapter/harbor/client.go create mode 100644 modules/har/pkg/har/migrate/adapter/harbor/keychain.go diff --git a/modules/har/pkg/har/migrate/adapter/harbor/adapter.go b/modules/har/pkg/har/migrate/adapter/harbor/adapter.go new file mode 100644 index 0000000..03a9c0b --- /dev/null +++ b/modules/har/pkg/har/migrate/adapter/harbor/adapter.go @@ -0,0 +1,164 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package harbor + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + + adp "github.com/harness/cli/modules/har/pkg/har/migrate/adapter" + "github.com/harness/cli/modules/har/pkg/har/migrate/types" + "github.com/harness/cli/modules/har/pkg/har/migrate/util" + + "github.com/google/go-containerregistry/pkg/authn" +) + +func init() { + if err := adp.RegisterFactory(types.HARBOR, new(factory)); err != nil { + return + } +} + +type factory struct{} + +func (f factory) Create(_ context.Context, config types.RegistryConfig) (adp.Adapter, error) { + return newAdapter(config) +} + +type adapter struct { + client *client + reg types.RegistryConfig +} + +func newAdapter(config types.RegistryConfig) (adp.Adapter, error) { + return &adapter{client: newClient(&config), reg: config}, nil +} + +// assertOCISupported guards against non-OCI artifact types — Harbor source supports +// only DOCKER and HELM (OCI-based); all other types are not implemented. +func assertOCISupported(artifactType types.ArtifactType) error { + if artifactType == types.DOCKER || artifactType == types.HELM { + return nil + } + return fmt.Errorf("HARBOR source supports only OCI artifact types (DOCKER, HELM); got %s", artifactType) +} + +func (a *adapter) GetKeyChain(sourcePackageHostname string) (authn.Keychain, error) { + host := sourcePackageHostname + if host == "" { + parsed, err := url.Parse(a.reg.Endpoint) + if err != nil { + return nil, fmt.Errorf("failed to parse endpoint %q: %w", a.reg.Endpoint, err) + } + host = parsed.Host + } + return NewHarborKeychain(a.reg.Credentials.Username, a.reg.Credentials.Password, host), nil +} + +func (a *adapter) GetConfig() types.RegistryConfig { + return a.reg +} + +func (a *adapter) ValidateCredentials() (bool, error) { + if err := a.client.health(); err != nil { + return false, fmt.Errorf("failed to validate Harbor credentials: %w", err) + } + return true, nil +} + +func (a *adapter) GetRegistry(_ context.Context, registry string) (types.RegistryInfo, error) { + project, err := a.client.getProject(registry) + if err != nil { + return types.RegistryInfo{}, fmt.Errorf("failed to get Harbor project %q: %w", registry, err) + } + return types.RegistryInfo{ + Type: "harbor", + URL: a.reg.Endpoint, + Path: project.Name, + }, nil +} + +func (a *adapter) CreateRegistryIfDoesntExist(_ string) (bool, error) { + return false, nil +} + +func (a *adapter) GetFiles(_ string) ([]types.File, error) { + return []types.File{}, nil +} + +func (a *adapter) GetPackages(registry string, artifactType types.ArtifactType, _ *types.TreeNode) ([]types.Package, error) { + if err := assertOCISupported(artifactType); err != nil { + return nil, err + } + repos, err := a.client.listRepositories(registry) + if err != nil { + return nil, fmt.Errorf("failed to list repositories in Harbor project %q: %w", registry, err) + } + packages := make([]types.Package, 0, len(repos)) + for _, repo := range repos { + // Harbor returns full "/" names; strip the project prefix so + // crane image references are constructed correctly downstream. + packages = append(packages, types.Package{ + Registry: registry, + Path: "/", + Name: repoShortName(registry, repo.Name), + Size: -1, + }) + } + return packages, nil +} + +// GetOCIImagePath builds a crane-compatible image reference for a Harbor repository: +// // +func (a *adapter) GetOCIImagePath(registry, packageHostname, image string) (string, error) { + host := packageHostname + if host == "" { + parsed, err := url.Parse(a.reg.Endpoint) + if err != nil { + return "", fmt.Errorf("failed to parse endpoint: %w", err) + } + host = parsed.Host + } + return util.GenOCIImagePath(host, registry, image), nil +} + +// --- Stubs for operations not applicable to Harbor (OCI-only source) --- + +func (a *adapter) GetVersions(_ types.Package, _ *types.TreeNode, _, _ string, artifactType types.ArtifactType) ([]types.Version, error) { + if err := assertOCISupported(artifactType); err != nil { + return nil, err + } + return nil, fmt.Errorf("GetVersions not implemented for HARBOR (OCI uses crane)") +} + +func (a *adapter) DownloadFile(_ string, _ string) (io.ReadCloser, http.Header, error) { + return nil, nil, fmt.Errorf("DownloadFile not implemented for HARBOR") +} + +func (a *adapter) UploadFile(_ string, _ io.ReadCloser, _ *types.File, _ http.Header, _, _ string, _ types.ArtifactType, _ map[string]interface{}) error { + return fmt.Errorf("UploadFile not implemented for HARBOR") +} + +func (a *adapter) AddNPMTag(_, _, _, _ string) error { + return nil +} + +func (a *adapter) VersionExists(_ context.Context, _ types.Package, _, _, _ string, _ types.ArtifactType) (bool, error) { + return false, fmt.Errorf("VersionExists not implemented for HARBOR") +} + +func (a *adapter) FileExists(_ context.Context, _, _, _ string, _ *types.File, _ types.ArtifactType) (bool, error) { + return false, fmt.Errorf("FileExists not implemented for HARBOR") +} + +func (a *adapter) GetAllFilesForVersion(_ context.Context, _, _, _ string) ([]string, error) { + return nil, fmt.Errorf("GetAllFilesForVersion not implemented for HARBOR") +} + +func (a *adapter) CreateVersion(_ string, _ string, _ string, _ types.ArtifactType, _ []*types.PackageFiles, _ map[string]interface{}) error { + return fmt.Errorf("CreateVersion not implemented for HARBOR") +} diff --git a/modules/har/pkg/har/migrate/adapter/harbor/client.go b/modules/har/pkg/har/migrate/adapter/harbor/client.go new file mode 100644 index 0000000..8d75ede --- /dev/null +++ b/modules/har/pkg/har/migrate/adapter/harbor/client.go @@ -0,0 +1,173 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package harbor + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/harness/cli/modules/har/pkg/har/migrate/types" +) + +const ( + harborAPIVersion = "v2.0" + pageSize = 100 +) + +// HarborProject represents a Harbor project. +type HarborProject struct { + Name string `json:"name"` + ID int `json:"id"` +} + +// HarborRepository represents a repository within a Harbor project. +// Name is the full "/" string returned by the API. +type HarborRepository struct { + Name string `json:"name"` + ArtifactCount int64 `json:"artifact_count"` +} + +type basicTransport struct { + base http.RoundTripper + username string + password string +} + +func (t *basicTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + if t.username != "" { + req.SetBasicAuth(t.username, t.password) + } + return t.base.RoundTrip(req) +} + +type client struct { + http *http.Client + url string +} + +func newClient(reg *types.RegistryConfig) *client { + tlsCfg := &tls.Config{InsecureSkipVerify: reg.Insecure} //nolint:gosec + return &client{ + http: &http.Client{ + Transport: &basicTransport{ + base: &http.Transport{TLSClientConfig: tlsCfg}, + username: reg.Credentials.Username, + password: reg.Credentials.Password, + }, + }, + url: strings.TrimSuffix(reg.Endpoint, "/"), + } +} + +func (c *client) do(req *http.Request) (*http.Response, error) { + return c.http.Do(req) +} + +// health checks Harbor connectivity via the health endpoint. +func (c *client) health() error { + u := fmt.Sprintf("%s/api/%s/health", c.url, harborAPIVersion) + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + resp, err := c.do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +// getProject retrieves project metadata by name. +func (c *client) getProject(project string) (HarborProject, error) { + u := fmt.Sprintf("%s/api/%s/projects/%s", c.url, harborAPIVersion, project) + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return HarborProject{}, fmt.Errorf("create request: %w", err) + } + resp, err := c.do(req) + if err != nil { + return HarborProject{}, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return HarborProject{}, fmt.Errorf("project %q not found", project) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return HarborProject{}, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + var p HarborProject + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + return HarborProject{}, fmt.Errorf("decode response: %w", err) + } + return p, nil +} + +// listRepositories returns all repositories for a Harbor project, following pagination. +func (c *client) listRepositories(project string) ([]HarborRepository, error) { + var all []HarborRepository + page := 1 + for { + u := fmt.Sprintf("%s/api/%s/projects/%s/repositories?page=%d&page_size=%d", + c.url, harborAPIVersion, project, page, pageSize) + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + var repos []HarborRepository + if err := json.Unmarshal(body, &repos); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + all = append(all, repos...) + // Stop if no rel=next Link header or fewer results than page size + if nextPageURL(resp.Header.Get("Link")) == "" || len(repos) < pageSize { + break + } + page++ + } + return all, nil +} + +// nextPageURL extracts the next-page URL from a Link header (rel=next). +func nextPageURL(linkHeader string) string { + for _, part := range strings.Split(linkHeader, ",") { + part = strings.TrimSpace(part) + segments := strings.Split(part, ";") + if len(segments) < 2 { + continue + } + rel := strings.TrimSpace(segments[1]) + if strings.EqualFold(rel, `rel="next"`) || strings.EqualFold(rel, "rel=next") { + return strings.Trim(strings.TrimSpace(segments[0]), "<>") + } + } + return "" +} + +// repoShortName strips the "/" prefix Harbor prepends to repository names. +func repoShortName(project, fullName string) string { + return strings.TrimPrefix(fullName, project+"/") +} diff --git a/modules/har/pkg/har/migrate/adapter/harbor/keychain.go b/modules/har/pkg/har/migrate/adapter/harbor/keychain.go new file mode 100644 index 0000000..b36546f --- /dev/null +++ b/modules/har/pkg/har/migrate/adapter/harbor/keychain.go @@ -0,0 +1,43 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package harbor + +import ( + "net/url" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" +) + +type harborKeychain struct { + username string + password string + hostname string +} + +// NewHarborKeychain returns an authn.Keychain that authenticates against Harbor +// using basic auth when the requested resource hostname matches. +func NewHarborKeychain(username, password, hostname string) authn.Keychain { + return harborKeychain{username: username, password: password, hostname: hostname} +} + +func (h harborKeychain) Resolve(r authn.Resource) (authn.Authenticator, error) { + serverURL, err := url.Parse("https://" + r.String()) + if err != nil { + return authn.Anonymous, nil + } + if h.username == "" || h.password == "" { + return authn.Anonymous, nil + } + if strings.EqualFold(serverURL.Hostname(), h.hostname) { + return harborAuthenticator{h.username, h.password}, nil + } + return authn.Anonymous, nil +} + +type harborAuthenticator struct{ username, password string } + +func (h harborAuthenticator) Authorization() (*authn.AuthConfig, error) { + return &authn.AuthConfig{Username: h.username, Password: h.password}, nil +} diff --git a/modules/har/pkg/har/migrate/migration.go b/modules/har/pkg/har/migrate/migration.go index bb9be3c..ae67ae1 100644 --- a/modules/har/pkg/har/migrate/migration.go +++ b/modules/har/pkg/har/migrate/migration.go @@ -18,6 +18,7 @@ import ( "github.com/rs/zerolog/log" _ "github.com/harness/cli/modules/har/pkg/har/migrate/adapter/har" + _ "github.com/harness/cli/modules/har/pkg/har/migrate/adapter/harbor" _ "github.com/harness/cli/modules/har/pkg/har/migrate/adapter/jfrog" _ "github.com/harness/cli/modules/har/pkg/har/migrate/adapter/nexus" ) diff --git a/modules/har/pkg/har/migrate/types/config.go b/modules/har/pkg/har/migrate/types/config.go index 7fdb4fe..b2fef1f 100644 --- a/modules/har/pkg/har/migrate/types/config.go +++ b/modules/har/pkg/har/migrate/types/config.go @@ -14,6 +14,7 @@ var ( JFROG RegistryType = "JFROG" MOCK_JFROG RegistryType = "MOCK_JFROG" NEXUS RegistryType = "NEXUS" + HARBOR RegistryType = "HARBOR" ) type ArtifactType string @@ -159,7 +160,7 @@ func validateCredentials(registry RegistryConfig) error { // Check supported registry types switch registry.Type { - case HAR, JFROG, NEXUS, MOCK_JFROG: + case HAR, JFROG, NEXUS, MOCK_JFROG, HARBOR: // These are supported default: return fmt.Errorf("unsupported registry type: %s", registry.Type)