Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions modules/har/pkg/har/migrate/adapter/harbor/adapter.go
Original file line number Diff line number Diff line change
@@ -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 "<project>/<repo>" 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:
// <host>/<project>/<repo>
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")
}
173 changes: 173 additions & 0 deletions modules/har/pkg/har/migrate/adapter/harbor/client.go
Original file line number Diff line number Diff line change
@@ -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 "<project>/<repo>" 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 "<project>/" prefix Harbor prepends to repository names.
func repoShortName(project, fullName string) string {
return strings.TrimPrefix(fullName, project+"/")
}
43 changes: 43 additions & 0 deletions modules/har/pkg/har/migrate/adapter/harbor/keychain.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions modules/har/pkg/har/migrate/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
Loading
Loading