diff --git a/modules/har/pkg/har/migrate/adapter/har/adapter.go b/modules/har/pkg/har/migrate/adapter/har/adapter.go index 5fac4e6..b2fbad7 100644 --- a/modules/har/pkg/har/migrate/adapter/har/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/har/adapter.go @@ -2,10 +2,12 @@ package har import ( "context" + "errors" "fmt" "io" "net/http" "net/url" + "path" "strings" adp "github.com/harness/cli/modules/har/pkg/har/migrate/adapter" @@ -112,10 +114,21 @@ func (a *harAdapter) UploadFile( err = a.client.uploadDartFile(registry, artifactName, version, f, file) case types.RAW: err = a.client.uploadRawFile(registry, f, file) + case types.DEBIAN: + err = a.client.uploadDebianFile(registry, f, file, metadata) + case types.PUPPET: + err = a.client.uploadPuppetFile(registry, f, file) + case types.CONAN: + err = a.client.uploadConanFile(registry, file, metadata) + default: + return fmt.Errorf("unsupported artifact type for upload: %s", artifactType) } if err != nil { + if errors.Is(err, types.ErrArtifactAlreadyExists) { + return err + } a.logger.Error().Err(err).Msgf("Failed to upload file %s to registry: %s", f.Uri, registry) - return fmt.Errorf("failed to upload file %s to registry: %s, %v", f.Uri, registry, err) + return fmt.Errorf("failed to upload file %s to registry: %s, %w", f.Uri, registry, err) } return nil } @@ -133,6 +146,10 @@ func (a *harAdapter) VersionExists(ctx context.Context, p types.Package, registr if artifactType == types.HELM_LEGACY { artifactType = types.HELM } + if artifactType == types.HELM_HTTP { + // HAR stores the chart by its leaf name; nested names like "team-a/abc" must resolve to "abc". + pkg = path.Base(pkg) + } return a.client.artifactVersionExists(ctx, registryRef, pkg, version, artifactType) } diff --git a/modules/har/pkg/har/migrate/adapter/har/client.go b/modules/har/pkg/har/migrate/adapter/har/client.go index 6e29c01..9dbeccd 100644 --- a/modules/har/pkg/har/migrate/adapter/har/client.go +++ b/modules/har/pkg/har/migrate/adapter/har/client.go @@ -147,7 +147,7 @@ func (c *client) uploadRawFile(registry string, f *types.File, file io.ReadClose fileUri := strings.TrimPrefix(f.Uri, "/") defer file.Close() - _, err := c.pkgClient.UploadGenericFileToPathWithBodyWithResponse( + resp, err := c.pkgClient.UploadGenericFileToPathWithBodyWithResponse( context.Background(), c.accountID, registry, @@ -158,6 +158,12 @@ func (c *client) uploadRawFile(registry string, f *types.File, file io.ReadClose if err != nil { return fmt.Errorf("failed to upload raw file '%s': %w", fileUri, err) } + if resp.StatusCode() == http2.StatusConflict { + return types.ErrArtifactAlreadyExists + } + if resp.StatusCode() < 200 || resp.StatusCode() > 299 { + return fmt.Errorf("failed to upload raw file '%s', status %d", fileUri, resp.StatusCode()) + } return nil } @@ -814,6 +820,9 @@ func (c *client) artifactVersionExists( if err != nil { return false, fmt.Errorf("failed to get artifact versions: %w", err) } + if response.StatusCode() == http2.StatusNotFound { + return false, nil + } if response.StatusCode() != http2.StatusOK { return false, fmt.Errorf("failed to get artifact versions: %s", response.Status()) } @@ -902,3 +911,180 @@ func (c *client) createGoVersion( return nil } + +func (c *client) uploadDebianFile( + registry string, + f *types.File, + file io.ReadCloser, + metadata map[string]interface{}, +) error { + distribution, _ := metadata["distribution"].(string) + component, _ := metadata["component"].(string) + fileType, _ := metadata["fileType"].(string) + packageName, _ := metadata["packageName"].(string) + version, _ := metadata["version"].(string) + + if distribution == "" { + return fmt.Errorf("distribution is required in metadata for debian upload") + } + if component == "" { + return fmt.Errorf("component is required in metadata for debian upload") + } + if fileType == "" { + fileType = "deb" + } + if fileType == "src" { + if packageName == "" { + return fmt.Errorf("packageName is required in metadata for debian src upload") + } + if version == "" { + return fmt.Errorf("version is required in metadata for debian src upload") + } + } + + endpoint := fileType // "deb", "dsc", or "src" + baseURL := fmt.Sprintf("%s/pkg/%s/%s/debian/%s?distribution=%s&component=%s", + c.url, c.accountID, registry, endpoint, + strings.ReplaceAll(distribution, " ", "%20"), + strings.ReplaceAll(component, " ", "%20"), + ) + if fileType == "src" { + baseURL += "&package=" + packageName + "&version=" + version + } + + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + go func() { + defer pw.Close() + defer writer.Close() + part, err := writer.CreateFormFile("file", f.Name) + if err != nil { + pw.CloseWithError(err) + return + } + if _, err := io.Copy(part, file); err != nil { + pw.CloseWithError(err) + } + }() + + req, err := http2.NewRequest(http2.MethodPut, baseURL, pr) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to upload debian file '%s': %w", f.Name, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to upload debian file '%s', status %d: %s", f.Name, resp.StatusCode, string(body)) + } + return nil +} + +func (c *client) uploadPuppetFile( + registry string, + f *types.File, + file io.ReadCloser, +) error { + uploadURL := fmt.Sprintf("%s/pkg/%s/%s/puppet/upload", c.url, c.accountID, registry) + + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + go func() { + defer pw.Close() + defer writer.Close() + part, err := writer.CreateFormFile("file", f.Name) + if err != nil { + pw.CloseWithError(err) + return + } + if _, err := io.Copy(part, file); err != nil { + pw.CloseWithError(err) + } + }() + + req, err := http2.NewRequest(http2.MethodPut, uploadURL, pr) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to upload puppet file '%s': %w", f.Name, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to upload puppet file '%s', status %d: %s", f.Name, resp.StatusCode, string(body)) + } + return nil +} + +func (c *client) uploadConanFile( + registry string, + file io.ReadCloser, + metadata map[string]interface{}, +) error { + defer file.Close() + + name, _ := metadata["name"].(string) + version, _ := metadata["version"].(string) + rrev, _ := metadata["rrev"].(string) + filename, _ := metadata["filename"].(string) + user, _ := metadata["user"].(string) + channel, _ := metadata["channel"].(string) + sha1, _ := metadata["sha1"].(string) + layer, _ := metadata["layer"].(string) + pkgid, _ := metadata["pkgid"].(string) + prev, _ := metadata["prev"].(string) + + if name == "" || version == "" || rrev == "" || filename == "" { + return fmt.Errorf("uploadConanFile: missing required metadata fields (name=%q, version=%q, rrev=%q, filename=%q)", + name, version, rrev, filename) + } + if layer == "package" && (pkgid == "" || prev == "") { + return fmt.Errorf("uploadConanFile: package layer requires pkgid and prev (pkgid=%q, prev=%q)", pkgid, prev) + } + + if user == "" { + user = "_" + } + if channel == "" { + channel = "_" + } + + var uploadURL string + if layer == "package" { + uploadURL = fmt.Sprintf("%s/pkg/%s/%s/conan/v2/conans/%s/%s/%s/%s/revisions/%s/packages/%s/revisions/%s/files/%s", + c.url, c.accountID, registry, name, version, user, channel, rrev, pkgid, prev, filename) + } else { + uploadURL = fmt.Sprintf("%s/pkg/%s/%s/conan/v2/conans/%s/%s/%s/%s/revisions/%s/files/%s", + c.url, c.accountID, registry, name, version, user, channel, rrev, filename) + } + + req, err := http2.NewRequest(http2.MethodPut, uploadURL, file) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + if sha1 != "" { + req.Header.Set("X-Checksum-Sha1", sha1) + } + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to upload conan file '%s': %w", filename, err) + } + defer resp.Body.Close() + if resp.StatusCode == http2.StatusConflict { + return types.ErrArtifactAlreadyExists + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to upload conan file '%s', status %d: %s", filename, resp.StatusCode, string(body)) + } + return nil +} diff --git a/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go b/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go index cc09ef1..d02ea8d 100644 --- a/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go @@ -384,6 +384,164 @@ func (a *adapter) GetPackages(registry string, artifactType types.ArtifactType, Size: -1, }) return packages, nil + } else if artifactType == types.PUPPET { + files, err := tree.GetAllFiles(root) + if err != nil { + return nil, fmt.Errorf("get all files: %w", err) + } + pkgMap := make(map[string]bool) + for _, file := range files { + if file.Folder { + continue + } + pkgName, _, ok := util.ParsePuppetFileNameWithPath(file.Uri) + if !ok { + continue + } + pkgMap[pkgName] = true + } + for pkgName := range pkgMap { + packages = append(packages, types.Package{ + Registry: registry, + Path: "/", + Name: pkgName, + Size: -1, + }) + } + log.Info().Msgf("Found %d PUPPET packages", len(packages)) + return packages, nil + } else if artifactType == types.CONAN { + files, err := tree.GetAllFiles(root) + if err != nil { + return nil, fmt.Errorf("get all files: %w", err) + } + packages = append(packages, util.GetConanPackages(files, registry)...) + log.Info().Msgf("Found %d CONAN packages", len(packages)) + return packages, nil + } else if artifactType == types.HELM_HTTP { + seenPath := make(map[string]bool) + files, err := tree.GetAllFiles(root) + if err != nil { + return nil, fmt.Errorf("get all files: %w", err) + } + for _, f := range files { + if f.Folder { + continue + } + if !util.IsHelmChartArchive(f.Uri) { + continue + } + leafName, ver, ok := util.ParseChartFileName(f.Name) + if !ok { + log.Warn().Msgf("HELM_HTTP: skipping chart file with non-conforming name: %s", f.Uri) + continue + } + relPath := strings.TrimPrefix(f.Uri, "/") + seenPath[relPath] = true + nestedName := leafName + if dir := strings.Trim(path.Dir(relPath), "/"); dir != "" && dir != "." { + nestedName = dir + "/" + leafName + } + packages = append(packages, types.Package{ + Registry: registry, + Path: "/", + Name: nestedName, + Version: ver, + Size: f.Size, + URL: f.Uri, + }) + } + indexPkgs, err := a.enumerateHelmIndex(registry, root) + if err != nil { + log.Warn().Err(err).Msgf("HELM_HTTP: failed to enumerate index.yaml for registry %s; relying on tree sweep only", registry) + } + for _, pkg := range indexPkgs { + relPath := chartRepoRelPath(pkg.URL) + if seenPath[relPath] { + continue + } + seenPath[relPath] = true + packages = append(packages, pkg) + } + return packages, nil + } else if artifactType == types.DEBIAN { + distsNode, err := tree.GetNodeForPath(root, "/dists") + if err != nil { + return nil, fmt.Errorf("get dists folder: %w", err) + } + for _, distNode := range distsNode.Children { + if distNode.IsLeaf { + continue + } + releaseNode, err := tree.GetNodeForPath(&distNode, "InRelease") + if err != nil { + releaseNode, err = tree.GetNodeForPath(&distNode, "Release") + if err != nil { + log.Warn().Msgf("Skipping %s: no Release or InRelease file found", distNode.Name) + continue + } + } + releaseFile, _, err := a.DownloadFile(registry, releaseNode.File.Uri) + if err != nil { + log.Warn().Msgf("Failed to download Release file for %s: %v", distNode.Name, err) + continue + } + components, architectures, err := parseDebianRelease(releaseFile) + releaseFile.Close() + if err != nil { + log.Warn().Msgf("Failed to parse Release file for %s: %v", distNode.Name, err) + continue + } + for _, component := range components { + for _, arch := range architectures { + packagesPath := fmt.Sprintf("/dists/%s/%s/binary-%s/Packages", distNode.Name, component, arch) + packagesNode, err := tree.GetNodeForPath(root, packagesPath) + if err != nil { + packagesPath = fmt.Sprintf("/dists/%s/%s/binary-%s/Packages.gz", distNode.Name, component, arch) + packagesNode, err = tree.GetNodeForPath(root, packagesPath) + if err != nil { + log.Warn().Msgf("Packages file not found: %s", packagesPath) + continue + } + } + packagesFile, _, err := a.DownloadFile(registry, packagesNode.File.Uri) + if err != nil { + log.Warn().Msgf("Failed to download Packages file %s: %v", packagesPath, err) + continue + } + debPackages, err := extractDebianPackages(packagesFile, registry, distNode.Name, component, strings.HasSuffix(packagesPath, ".gz")) + packagesFile.Close() + if err != nil { + log.Warn().Msgf("Failed to extract Debian packages from %s: %v", packagesPath, err) + continue + } + packages = append(packages, debPackages...) + } + sourcesPath := fmt.Sprintf("/dists/%s/%s/source/Sources", distNode.Name, component) + sourcesNode, err := tree.GetNodeForPath(root, sourcesPath) + if err != nil { + sourcesPath = fmt.Sprintf("/dists/%s/%s/source/Sources.gz", distNode.Name, component) + sourcesNode, err = tree.GetNodeForPath(root, sourcesPath) + if err != nil { + log.Debug().Msgf("Sources file not found: %s", sourcesPath) + continue + } + } + sourcesFile, _, err := a.DownloadFile(registry, sourcesNode.File.Uri) + if err != nil { + log.Warn().Msgf("Failed to download Sources file %s: %v", sourcesPath, err) + continue + } + sourcePackages, err := extractDebianSourcePackages(sourcesFile, registry, distNode.Name, component, strings.HasSuffix(sourcesPath, ".gz")) + sourcesFile.Close() + if err != nil { + log.Warn().Msgf("Failed to extract Debian source packages from %s: %v", sourcesPath, err) + continue + } + packages = append(packages, sourcePackages...) + } + } + return packages, nil } else { return []types.Package{}, errors.New("unknown artifact type") } @@ -391,6 +549,240 @@ func (a *adapter) GetPackages(registry string, artifactType types.ArtifactType, return packages, nil } +func (a *adapter) enumerateHelmIndex(registry string, root *types.TreeNode) ([]types.Package, error) { + node, err := tree.GetNodeForPath(root, "/index.yaml") + if err != nil { + return nil, fmt.Errorf("get node for path: %w", err) + } + file, _, err := a.DownloadFile(registry, node.File.Uri) + if err != nil { + return nil, fmt.Errorf("download index.yaml: %w", err) + } + defer file.Close() + + tmp, err := os.CreateTemp("", "index-*.yaml") + if err != nil { + return nil, fmt.Errorf("create temp index file: %w", err) + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + + if _, err := io.Copy(tmp, file); err != nil { + return nil, fmt.Errorf("copy index.yaml to temp: %w", err) + } + index, err := repo.LoadIndexFile(tmp.Name()) + if err != nil { + return nil, fmt.Errorf("load index file: %w", err) + } + + var packages []types.Package + for name, entries := range index.Entries { + for _, ver := range entries { + if len(ver.URLs) == 0 { + log.Warn().Msgf("Skipping helm chart with no URLs: %s %s", name, ver.Version) + continue + } + nestedName, err2 := getNestedName(name, ver.URLs) + if err2 != nil { + log.Error().Err(err2).Msgf("Failed to get package name: %s %s", name, ver.Version) + continue + } + chartUrl := ver.URLs[0] + if strings.HasPrefix(chartUrl, "local://") { + chartUrl = strings.TrimPrefix(chartUrl, "local://") + } + packages = append(packages, types.Package{ + Registry: registry, + Path: "/", + Name: nestedName, + Size: -1, + URL: chartUrl, + Version: ver.Version, + }) + } + } + return packages, nil +} + +func chartRepoRelPath(chartURL string) string { + parsed, err := url.Parse(chartURL) + if err != nil { + return strings.TrimPrefix(chartURL, "/") + } + p := parsed.Path + if strings.HasPrefix(p, "/") { + splits := strings.Split(p, "/") + if len(splits) >= 4 { + return strings.Join(splits[3:], "/") + } + return strings.TrimPrefix(p, "/") + } + return p +} + +func parseDebianRelease(file io.Reader) ([]string, []string, error) { + data, err := io.ReadAll(file) + if err != nil { + return nil, nil, fmt.Errorf("read Release file: %w", err) + } + var components, architectures []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Components:") { + components = strings.Fields(strings.TrimSpace(strings.TrimPrefix(line, "Components:"))) + } else if strings.HasPrefix(line, "Architectures:") { + architectures = strings.Fields(strings.TrimSpace(strings.TrimPrefix(line, "Architectures:"))) + } + } + if len(components) == 0 { + return nil, nil, fmt.Errorf("no components found in Release file") + } + if len(architectures) == 0 { + return nil, nil, fmt.Errorf("no architectures found in Release file") + } + return components, architectures, nil +} + +func extractDebianPackages(file io.Reader, registry, distribution, component string, isGzipped bool) ([]types.Package, error) { + var reader io.Reader = file + if isGzipped { + gz, err := gzip.NewReader(file) + if err != nil { + return nil, fmt.Errorf("create gzip reader: %w", err) + } + defer gz.Close() + reader = gz + } + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read Packages file: %w", err) + } + var packages []types.Package + for _, paragraph := range strings.Split(string(data), "\n\n") { + if strings.TrimSpace(paragraph) == "" { + continue + } + var filename string + var size int + for _, line := range strings.Split(paragraph, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Filename:") { + filename = strings.TrimSpace(strings.TrimPrefix(line, "Filename:")) + } else if strings.HasPrefix(line, "Size:") { + fmt.Sscanf(strings.TrimSpace(strings.TrimPrefix(line, "Size:")), "%d", &size) + } + } + if filename != "" { + packages = append(packages, types.Package{ + Registry: registry, + Path: "/", + Name: path.Base(filename), + URL: filename, + Size: size, + Metadata: map[string]string{ + "distribution": distribution, + "component": component, + }, + }) + } + } + return packages, nil +} + +func extractDebianSourcePackages(file io.Reader, registry, distribution, component string, isGzipped bool) ([]types.Package, error) { + var reader io.Reader = file + if isGzipped { + gz, err := gzip.NewReader(file) + if err != nil { + return nil, fmt.Errorf("create gzip reader: %w", err) + } + defer gz.Close() + reader = gz + } + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read Sources file: %w", err) + } + var packages []types.Package + for _, paragraph := range strings.Split(string(data), "\n\n") { + if strings.TrimSpace(paragraph) == "" { + continue + } + var packageName, version, directory string + var dscFiles []types.Package + var allFiles []string + inFilesSection := false + for _, line := range strings.Split(paragraph, "\n") { + if strings.HasPrefix(line, "Package:") { + packageName = strings.TrimSpace(strings.TrimPrefix(line, "Package:")) + inFilesSection = false + continue + } + if strings.HasPrefix(line, "Version:") { + version = strings.TrimSpace(strings.TrimPrefix(line, "Version:")) + inFilesSection = false + continue + } + if strings.HasPrefix(line, "Files:") { + inFilesSection = true + continue + } + if strings.HasPrefix(line, "Directory:") { + directory = strings.TrimSpace(strings.TrimPrefix(line, "Directory:")) + inFilesSection = false + continue + } + if inFilesSection && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + inFilesSection = false + } + if inFilesSection && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { + fields := strings.Fields(line) + if len(fields) >= 3 { + filename := fields[2] + allFiles = append(allFiles, filename) + if strings.HasSuffix(filename, ".dsc") { + var size int + fmt.Sscanf(fields[1], "%d", &size) + filePath := filename + if directory != "" { + filePath = directory + "/" + filename + } + dscFiles = append(dscFiles, types.Package{ + Registry: registry, + Path: "/", + Name: filename, + URL: filePath, + Size: size, + Metadata: map[string]string{ + "distribution": distribution, + "component": component, + "packageName": packageName, + "fullVersion": version, + }, + }) + } + } + } + } + for i := range dscFiles { + var sourceFiles []string + for _, filename := range allFiles { + if strings.Contains(filename, ".orig.tar.") || strings.Contains(filename, ".tar.") { + sourceFiles = append(sourceFiles, filename) + } + } + if len(sourceFiles) > 0 { + dscFiles[i].Metadata["sourceFiles"] = strings.Join(sourceFiles, ",") + } + if directory != "" { + dscFiles[i].Metadata["directory"] = directory + } + } + packages = append(packages, dscFiles...) + } + return packages, nil +} + func getNestedName(packageName string, urls []string) (string, error) { if urls == nil || len(urls) == 0 { return "", fmt.Errorf("url is invalid") @@ -563,7 +955,7 @@ func (a *adapter) GetVersions( return versions, nil } - if artifactType == types.HELM_LEGACY { + if artifactType == types.HELM_LEGACY || artifactType == types.HELM_HTTP { return []types.Version{ { Registry: registry, @@ -575,6 +967,36 @@ func (a *adapter) GetVersions( }, nil } + if artifactType == types.PUPPET { + files, err := tree.GetAllFiles(node) + if err != nil { + return nil, fmt.Errorf("get all files: %w", err) + } + versionMap := make(map[string]bool) + for _, file := range files { + if file.Folder { + continue + } + pkgName, version, ok := util.ParsePuppetFileNameWithPath(file.Uri) + if !ok || pkgName != pkg { + continue + } + versionMap[version] = true + } + var versions []types.Version + for version := range versionMap { + versions = append(versions, types.Version{ + Registry: registry, + Pkg: pkg, + Path: "/", + Name: version, + Size: -1, + }) + } + log.Info().Msgf("Found %d versions for PUPPET package %s", len(versions), pkg) + return versions, nil + } + if artifactType == types.PYTHON { var versions []types.Version indexPath := fmt.Sprintf(".pypi/%s/%s.html", pkg, pkg) diff --git a/modules/har/pkg/har/migrate/migratable/file.go b/modules/har/pkg/har/migrate/migratable/file.go index f690f46..25a8b3f 100644 --- a/modules/har/pkg/har/migrate/migratable/file.go +++ b/modules/har/pkg/har/migrate/migratable/file.go @@ -172,7 +172,7 @@ func (r *File) Migrate(ctx context.Context) error { return fmt.Errorf("OCI migrate file is not supported") } - if r.artifactType == types.GENERIC || r.artifactType == types.RAW || r.artifactType == types.MAVEN || r.artifactType == types.NUGET { + if r.artifactType == types.GENERIC || r.artifactType == types.RAW || r.artifactType == types.MAVEN || r.artifactType == types.NUGET || r.artifactType == types.PUPPET { downloadFile, header, err := r.srcAdapter.DownloadFile(r.srcRegistry, r.file.Uri) defer downloadFile.Close() if err != nil { diff --git a/modules/har/pkg/har/migrate/migratable/package.go b/modules/har/pkg/har/migrate/migratable/package.go index 5110270..dc58be7 100644 --- a/modules/har/pkg/har/migrate/migratable/package.go +++ b/modules/har/pkg/har/migrate/migratable/package.go @@ -1,6 +1,7 @@ package migratable import ( + "bytes" "context" "encoding/json" "errors" @@ -121,7 +122,7 @@ func (r *Package) Pre(ctx context.Context) error { return nil } - if !r.config.Overwrite && (r.artifactType == types.HELM_LEGACY && r.pkg.Name != "" && r.pkg.Version != "") { + if !r.config.Overwrite && ((r.artifactType == types.HELM_LEGACY || r.artifactType == types.HELM_HTTP) && r.pkg.Name != "" && r.pkg.Version != "") { exists, err := r.destAdapter.VersionExists(ctx, r.pkg, r.registry.Path, r.pkg.Name, r.pkg.Version, r.artifactType) @@ -285,6 +286,12 @@ func (r *Package) Migrate(ctx context.Context) error { } else if r.artifactType == types.HELM_LEGACY { // TODO: Replace by providing function to this migration job instead of complete implementation here. r.migrateLegacyHelm(ctx) + } else if r.artifactType == types.HELM_HTTP { + r.migrateHelmHTTP(ctx) + } else if r.artifactType == types.DEBIAN { + r.migrateDebian(ctx) + } else if r.artifactType == types.CONAN { + r.migrateConan(ctx) } else if r.artifactType == types.RPM { r.migrateRPM(ctx) } else if r.artifactType == types.CONDA { @@ -699,3 +706,304 @@ func (r *Package) Post(ctx context.Context) error { Msg("Completed registry post-migration step") return nil } + +func (r *Package) migrateHelmHTTP(ctx context.Context) error { + if r.config.DryRun { + return nil + } + file, header, err := r.srcAdapter.DownloadFile(r.srcRegistry, r.pkg.URL) + if err != nil { + log.Error().Ctx(ctx).Err(err).Msgf("Failed to download helm chart %s", r.pkg.URL) + pterm.Error.Println(fmt.Sprintf("Failed to download helm chart %s", r.pkg.URL)) + r.stats.FileStats = append(r.stats.FileStats, types.FileStat{ + Name: r.pkg.Name, + Registry: r.srcRegistry, + Uri: r.pkg.URL, + Size: int64(r.pkg.Size), + Status: types.StatusFail, + Error: err.Error(), + }) + return err + } + chartFile := util.GetChartFileName(r.pkg.Name, r.pkg.Version) + title := fmt.Sprintf("%s (%s)", r.pkg.Name, sizeutil.GetSize(int64(r.pkg.Size))) + pterm.Info.Println(fmt.Sprintf("Copying helm chart %s from %s to %s", chartFile, r.srcRegistry, r.destRegistry)) + err = r.destAdapter.UploadFile( + r.destRegistry, file, + &types.File{Name: chartFile, Uri: chartFile}, + header, r.pkg.Name, r.pkg.Version, types.RAW, nil, + ) + stat := types.FileStat{ + Name: r.pkg.Name, + Registry: r.srcRegistry, + Uri: r.pkg.URL, + Size: int64(r.pkg.Size), + Status: types.StatusSuccess, + } + if err != nil { + if errors.Is(err, types.ErrArtifactAlreadyExists) { + stat.Status = types.StatusSkip + pterm.Info.Println(fmt.Sprintf("%s already exists, skipping", title)) + r.stats.FileStats = append(r.stats.FileStats, stat) + return nil + } + r.logger.Error().Err(err).Msg("Failed to upload helm chart") + stat.Status = types.StatusFail + stat.Error = err.Error() + pterm.Error.Println(title) + r.stats.FileStats = append(r.stats.FileStats, stat) + return err + } + pterm.Success.Println(title) + r.stats.FileStats = append(r.stats.FileStats, stat) + r.migrateHelmHTTPProv(ctx) + return nil +} + +func (r *Package) migrateHelmHTTPProv(ctx context.Context) { + provURL := r.pkg.URL + ".prov" + provFile, header, err := r.srcAdapter.DownloadFile(r.srcRegistry, provURL) + if err != nil { + r.logger.Debug().Err(err).Msgf("No provenance file for chart %s (skipping)", r.pkg.URL) + return + } + provBytes, err := io.ReadAll(provFile) + _ = provFile.Close() + if err != nil { + r.logger.Debug().Err(err).Msgf("Could not read provenance file for chart %s (skipping)", r.pkg.URL) + return + } + provName := util.GetChartProvFileName(r.pkg.Name, r.pkg.Version) + pterm.Info.Println(fmt.Sprintf("Copying provenance %s from %s to %s", provName, r.srcRegistry, r.destRegistry)) + err = r.destAdapter.UploadFile( + r.destRegistry, + io.NopCloser(bytes.NewReader(provBytes)), + &types.File{Name: provName, Uri: provName}, + header, r.pkg.Name, r.pkg.Version, types.RAW, nil, + ) + stat := types.FileStat{ + Name: r.pkg.Name, + Registry: r.srcRegistry, + Uri: provURL, + Size: int64(len(provBytes)), + Status: types.StatusSuccess, + } + if err != nil { + if errors.Is(err, types.ErrArtifactAlreadyExists) { + stat.Status = types.StatusSkip + pterm.Info.Println(fmt.Sprintf("Provenance %s already exists, skipping", provName)) + } else { + r.logger.Error().Err(err).Msgf("Failed to upload provenance %s", provName) + stat.Status = types.StatusFail + stat.Error = err.Error() + pterm.Error.Println(fmt.Sprintf("Failed to upload provenance %s", provName)) + } + } else { + pterm.Success.Println(fmt.Sprintf("Successfully uploaded provenance %s", provName)) + } + r.stats.FileStats = append(r.stats.FileStats, stat) + _ = ctx +} + +func (r *Package) migrateDebian(ctx context.Context) error { + if r.config.DryRun { + log.Info().Ctx(ctx).Msgf("Dry-run: would migrate Debian package %s", r.pkg.URL) + return nil + } + file, header, err := r.srcAdapter.DownloadFile(r.srcRegistry, r.pkg.URL) + if err != nil { + log.Error().Ctx(ctx).Err(err).Msgf("Failed to download Debian package %s", r.pkg.URL) + pterm.Error.Println(fmt.Sprintf("Failed to download Debian package %s", r.pkg.URL)) + r.stats.FileStats = append(r.stats.FileStats, types.FileStat{ + Name: r.pkg.Name, + Registry: r.srcRegistry, + Uri: r.pkg.URL, + Size: int64(r.pkg.Size), + Status: types.StatusFail, + Error: err.Error(), + }) + return err + } + defer file.Close() + + title := fmt.Sprintf("%s (%s)", r.pkg.Name, sizeutil.GetSize(int64(r.pkg.Size))) + pterm.Info.Println(fmt.Sprintf("Copying file %s from %s to %s", r.pkg.Name, r.srcRegistry, r.destRegistry)) + + metadata := map[string]interface{}{ + "distribution": r.pkg.Metadata["distribution"], + "component": r.pkg.Metadata["component"], + } + isDscFile := strings.HasSuffix(r.pkg.Name, ".dsc") + if isDscFile { + metadata["fileType"] = "dsc" + if sf, ok := r.pkg.Metadata["sourceFiles"]; ok && sf != "" { + metadata["sourceFiles"] = sf + } + } else { + metadata["fileType"] = "deb" + } + + err = r.destAdapter.UploadFile(r.destRegistry, file, &types.File{Uri: r.pkg.URL}, header, + r.pkg.Name, r.pkg.Name, r.artifactType, metadata) + stat := types.FileStat{ + Name: r.pkg.Name, + Registry: r.srcRegistry, + Uri: r.pkg.URL, + Size: int64(r.pkg.Size), + Status: types.StatusSuccess, + } + if err != nil { + r.logger.Error().Err(err).Msg("Failed to upload debian file") + stat.Status = types.StatusFail + stat.Error = err.Error() + pterm.Error.Println(title) + } else { + pterm.Success.Println(title) + } + r.stats.FileStats = append(r.stats.FileStats, stat) + + if isDscFile && err == nil { + if sourceFilesStr, ok := r.pkg.Metadata["sourceFiles"]; ok && sourceFilesStr != "" { + directory := r.pkg.Metadata["directory"] + for _, srcFileName := range strings.Split(sourceFilesStr, ",") { + srcFileName = strings.TrimSpace(srcFileName) + if srcFileName == "" { + continue + } + srcFilePath := srcFileName + if directory != "" { + srcFilePath = directory + "/" + srcFileName + } + srcFile, srcHeader, dlErr := r.srcAdapter.DownloadFile(r.srcRegistry, srcFilePath) + if dlErr != nil { + log.Error().Ctx(ctx).Err(dlErr).Msgf("Failed to download source file %s", srcFilePath) + pterm.Error.Println(fmt.Sprintf("Failed to download source file %s", srcFilePath)) + r.stats.FileStats = append(r.stats.FileStats, types.FileStat{ + Name: srcFileName, + Registry: r.srcRegistry, + Uri: srcFilePath, + Status: types.StatusFail, + Error: dlErr.Error(), + }) + continue + } + srcMeta := map[string]interface{}{ + "distribution": r.pkg.Metadata["distribution"], + "component": r.pkg.Metadata["component"], + "fileType": "src", + "packageName": r.pkg.Metadata["packageName"], + } + fullVersion := r.pkg.Metadata["fullVersion"] + if strings.Contains(srcFileName, ".orig.tar.") { + srcMeta["version"] = extractUpstreamVersion(fullVersion) + } else { + srcMeta["version"] = fullVersion + } + ulErr := r.destAdapter.UploadFile(r.destRegistry, srcFile, + &types.File{Name: srcFileName, Uri: srcFilePath}, srcHeader, + srcFileName, srcFileName, r.artifactType, srcMeta) + srcFile.Close() + srcStat := types.FileStat{ + Name: srcFileName, + Registry: r.srcRegistry, + Uri: srcFilePath, + Status: types.StatusSuccess, + } + if ulErr != nil { + r.logger.Error().Err(ulErr).Msgf("Failed to upload source file %s", srcFileName) + srcStat.Status = types.StatusFail + srcStat.Error = ulErr.Error() + pterm.Error.Println(fmt.Sprintf("source file %s", srcFileName)) + } else { + pterm.Success.Println(fmt.Sprintf("source file %s", srcFileName)) + } + r.stats.FileStats = append(r.stats.FileStats, srcStat) + } + } + } + return nil +} + +func (r *Package) migrateConan(ctx context.Context) error { + if r.config.DryRun { + r.logger.Info().Msgf("Dry-run: skipping Conan migration for reference %s", r.pkg.Name) + return nil + } + files, err := tree.GetAllFiles(r.node) + if err != nil { + log.Error().Ctx(ctx).Err(err).Msgf("Failed to list files for Conan reference %s", r.pkg.Name) + return fmt.Errorf("get files for conan reference %s: %w", r.pkg.Name, err) + } + entries := util.ParseConanEntries(files) + if len(entries) == 0 { + r.logger.Warn().Msgf("No Conan files found for reference %s", r.pkg.Name) + return nil + } + for _, entry := range entries { + file, header, dlErr := r.srcAdapter.DownloadFile(r.srcRegistry, entry.Uri) + if dlErr != nil { + log.Error().Ctx(ctx).Err(dlErr).Msgf("Failed to download Conan file %s", entry.Uri) + pterm.Error.Println(fmt.Sprintf("Failed to download Conan file %s", entry.Uri)) + r.stats.FileStats = append(r.stats.FileStats, types.FileStat{ + Name: entry.FileName, + Registry: r.srcRegistry, + Uri: entry.Uri, + Size: int64(entry.Size), + Status: types.StatusFail, + Error: dlErr.Error(), + }) + continue + } + metadata := map[string]interface{}{ + "layer": string(entry.Layer), + "name": entry.Reference.Name, + "version": entry.Reference.Version, + "user": entry.Reference.User, + "channel": entry.Reference.Channel, + "rrev": entry.RRev, + "pkgid": entry.PkgID, + "prev": entry.PRev, + "filename": entry.FileName, + "sha1": entry.SHA1, + } + title := fmt.Sprintf("%s (%s)", entry.FileName, sizeutil.GetSize(int64(entry.Size))) + pterm.Info.Println(fmt.Sprintf("Copying Conan file %s from %s to %s", entry.FileName, r.srcRegistry, r.destRegistry)) + ulErr := r.destAdapter.UploadFile(r.destRegistry, file, + &types.File{Name: entry.FileName, Uri: entry.Uri}, header, + entry.Reference.Name, entry.Reference.Version, types.CONAN, metadata) + stat := types.FileStat{ + Name: entry.FileName, + Registry: r.srcRegistry, + Uri: entry.Uri, + Size: int64(entry.Size), + Status: types.StatusSuccess, + } + if ulErr != nil { + if errors.Is(ulErr, types.ErrArtifactAlreadyExists) { + stat.Status = types.StatusSkip + pterm.Info.Println(fmt.Sprintf("%s already exists, skipping", title)) + } else { + r.logger.Error().Err(ulErr).Msgf("Failed to upload Conan file %s", entry.FileName) + stat.Status = types.StatusFail + stat.Error = ulErr.Error() + pterm.Error.Println(title) + } + } else { + pterm.Success.Println(title) + } + r.stats.FileStats = append(r.stats.FileStats, stat) + } + return nil +} + +// extractUpstreamVersion strips the Debian revision and epoch from a version string. +// e.g. "2:1.5.0-1ubuntu2" -> "1.5.0" +func extractUpstreamVersion(version string) string { + if idx := strings.Index(version, ":"); idx != -1 { + version = version[idx+1:] + } + if idx := strings.LastIndex(version, "-"); idx != -1 { + version = version[:idx] + } + return version +} diff --git a/modules/har/pkg/har/migrate/migratable/version.go b/modules/har/pkg/har/migrate/migratable/version.go index 603ea7d..fd51939 100644 --- a/modules/har/pkg/har/migrate/migratable/version.go +++ b/modules/har/pkg/har/migrate/migratable/version.go @@ -144,7 +144,7 @@ func (r *Version) Migrate(ctx context.Context) error { var jobs []engine.Job if r.artifactType == types.GENERIC || r.artifactType == types.RAW || r.artifactType == types.MAVEN || r.artifactType == types.PYTHON || - r.artifactType == types.NUGET || r.artifactType == types.NPM || r.artifactType == types.DART { + r.artifactType == types.NUGET || r.artifactType == types.NPM || r.artifactType == types.DART || r.artifactType == types.PUPPET { files, err := tree.GetAllFiles(r.node) if err != nil { logger.Error().Err(err).Msg("Failed to get files from tree") @@ -166,6 +166,14 @@ func (r *Version) Migrate(ctx context.Context) error { continue } } + // For PUPPET, skip files that don't match current module name and version + if r.artifactType == types.PUPPET { + pkgName, version, ok := util.ParsePuppetFileNameWithPath(file.Uri) + if !ok || pkgName != r.pkg.Name || version != r.version.Name { + logger.Debug().Msgf("Skipping file %s for PUPPET (pkg=%s ver=%s)", file.Uri, r.pkg.Name, r.version.Name) + continue + } + } // Check if file already exists in destination lowerCaseNormalizeFileName := strings.ToLower(file.Name) diff --git a/modules/har/pkg/har/migrate/types/config.go b/modules/har/pkg/har/migrate/types/config.go index 7fdb4fe..a6c061f 100644 --- a/modules/har/pkg/har/migrate/types/config.go +++ b/modules/har/pkg/har/migrate/types/config.go @@ -34,6 +34,10 @@ var ( DART ArtifactType = "DART" RAW ArtifactType = "RAW" SWIFT ArtifactType = "SWIFT" + DEBIAN ArtifactType = "DEBIAN" + CONAN ArtifactType = "CONAN" + PUPPET ArtifactType = "PUPPET" + HELM_HTTP ArtifactType = "HELM_HTTP" ) // Config represents the top-level configuration structure diff --git a/modules/har/pkg/har/migrate/types/types.go b/modules/har/pkg/har/migrate/types/types.go index c0fa30d..49657ec 100644 --- a/modules/har/pkg/har/migrate/types/types.go +++ b/modules/har/pkg/har/migrate/types/types.go @@ -13,6 +13,7 @@ var ( ErrArtifactNotFound = errors.New("artifact not found") ErrRegistryNotFound = errors.New("ar not found") ErrInvalidCredentials = errors.New("invalid credentials") + ErrArtifactAlreadyExists = errors.New("artifact already exists") ) type File struct { @@ -41,6 +42,7 @@ type Package struct { Size int URL string Version string + Metadata map[string]string } type Version struct { diff --git a/modules/har/pkg/har/migrate/util/conan_util.go b/modules/har/pkg/har/migrate/util/conan_util.go new file mode 100644 index 0000000..e71e36a --- /dev/null +++ b/modules/har/pkg/har/migrate/util/conan_util.go @@ -0,0 +1,245 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "path" + "sort" + "strings" + + "github.com/harness/cli/modules/har/pkg/har/migrate/types" +) + +// Conan v2 JFrog storage layout (repo-relative Uri): +// +// {name}/{version}/{user}/{channel}/{rrev}/export/{file} -> recipe layer +// {name}/{version}/{user}/{channel}/{rrev}/package/{pkgid}/{prev}/{file} -> package layer +// +// "_" is the placeholder JFrog uses for an absent user/channel. +const ( + ConanPlaceholder = "_" + ConanManifestFile = "conanmanifest.txt" + ConanFilePy = "conanfile.py" + ConanInfoTxt = "conaninfo.txt" + + ConanTarballExport = "conan_export" + ConanTarballSources = "conan_sources" + ConanTarballPackage = "conan_package" + + conanExportMarker = "export" + conanPkgMarker = "package" + + // segments preceding the layer marker: name/version/user/channel/rrev + conanRefSegmentsBeforeMarker = 5 +) + +var conanTarballExtensions = map[string]bool{".tgz": true, ".txz": true, ".tzst": true} + +// ConanLayer identifies whether a file belongs to the recipe or package layer. +type ConanLayer string + +const ( + ConanLayerRecipe ConanLayer = "recipe" + ConanLayerPackage ConanLayer = "package" +) + +// ConanRef holds the coordinates of a Conan reference (name/version[@user/channel]). +type ConanRef struct { + Name string + Version string + User string + Channel string +} + +// BasePath is the repo-relative subtree path: /name/version/user/channel. +func (r ConanRef) BasePath() string { + return "/" + r.Name + "/" + r.Version + "/" + r.User + "/" + r.Channel +} + +// Display renders the reference as name/version[@user/channel], omitting placeholder user/channel. +func (r ConanRef) Display() string { + if r.User == ConanPlaceholder && r.Channel == ConanPlaceholder { + return r.Name + "/" + r.Version + } + return r.Name + "/" + r.Version + "@" + r.User + "/" + r.Channel +} + +// ConanFileEntry is a single migratable Conan file with the coordinates needed to re-upload it. +type ConanFileEntry struct { + Reference ConanRef + Layer ConanLayer + RRev string + PkgID string + PRev string + FileName string + Uri string + SHA1 string + Size int +} + +// ParseConanFileURI parses a repo-relative JFrog Conan v2 Uri into a ConanFileEntry. +// ok is false for paths that are not canonical recipe or package files. +func ParseConanFileURI(uri string) (ConanFileEntry, bool) { + trimmed := strings.Trim(uri, "/") + if trimmed == "" { + return ConanFileEntry{}, false + } + parts := strings.Split(trimmed, "/") + + marker := -1 + for i, p := range parts { + if p == conanExportMarker || p == conanPkgMarker { + marker = i + break + } + } + if marker < conanRefSegmentsBeforeMarker { + return ConanFileEntry{}, false + } + + ref := ConanRef{ + Name: parts[marker-5], + Version: parts[marker-4], + User: parts[marker-3], + Channel: parts[marker-2], + } + rrev := parts[marker-1] + filename := parts[len(parts)-1] + + switch parts[marker] { + case conanExportMarker: + if len(parts) <= marker+1 { + return ConanFileEntry{}, false + } + if !IsConanRecipeFile(filename) { + return ConanFileEntry{}, false + } + return ConanFileEntry{ + Reference: ref, + Layer: ConanLayerRecipe, + RRev: rrev, + FileName: filename, + Uri: uri, + }, true + case conanPkgMarker: + if len(parts) < marker+4 { + return ConanFileEntry{}, false + } + if !IsConanPackageFile(filename) { + return ConanFileEntry{}, false + } + return ConanFileEntry{ + Reference: ref, + Layer: ConanLayerPackage, + RRev: rrev, + PkgID: parts[marker+1], + PRev: parts[marker+2], + FileName: filename, + Uri: uri, + }, true + default: + return ConanFileEntry{}, false + } +} + +// GetConanPackages returns one package per distinct Conan reference found in the file list. +func GetConanPackages(files []*types.File, registry string) []types.Package { + seen := make(map[string]bool) + var packages []types.Package + for _, f := range files { + if f == nil || f.Folder { + continue + } + entry, ok := ParseConanFileURI(f.Uri) + if !ok { + continue + } + key := entry.Reference.BasePath() + if seen[key] { + continue + } + seen[key] = true + packages = append(packages, types.Package{ + Registry: registry, + Path: key, + Name: entry.Reference.Display(), + Size: -1, + Metadata: map[string]string{ + "name": entry.Reference.Name, + "version": entry.Reference.Version, + "user": entry.Reference.User, + "channel": entry.Reference.Channel, + }, + }) + } + return packages +} + +// ParseConanEntries converts a reference's files into upload-ready entries, +// ordered so every conanmanifest.txt is uploaded last within its layer/revision group. +func ParseConanEntries(files []*types.File) []ConanFileEntry { + var entries []ConanFileEntry + for _, f := range files { + if f == nil || f.Folder { + continue + } + entry, ok := ParseConanFileURI(f.Uri) + if !ok { + continue + } + entry.SHA1 = f.SHA1 + entry.Size = f.Size + entries = append(entries, entry) + } + sortConanEntries(entries) + return entries +} + +func sortConanEntries(entries []ConanFileEntry) { + sort.SliceStable(entries, func(i, j int) bool { + a, b := entries[i], entries[j] + if a.Layer != b.Layer { + return a.Layer == ConanLayerRecipe + } + aKey := a.RRev + "|" + a.PkgID + "|" + a.PRev + bKey := b.RRev + "|" + b.PkgID + "|" + b.PRev + if aKey != bKey { + return aKey < bKey + } + aManifest := a.FileName == ConanManifestFile + bManifest := b.FileName == ConanManifestFile + if aManifest != bManifest { + return !aManifest + } + return a.FileName < b.FileName + }) +} + +// IsConanRecipeFile reports whether name is a canonical recipe-layer file. +func IsConanRecipeFile(name string) bool { + name = path.Base(name) + if name == ConanFilePy || name == ConanManifestFile { + return true + } + prefix, ok := conanTarballPrefix(name) + return ok && (prefix == ConanTarballExport || prefix == ConanTarballSources) +} + +// IsConanPackageFile reports whether name is a canonical package-layer file. +func IsConanPackageFile(name string) bool { + name = path.Base(name) + if name == ConanInfoTxt || name == ConanManifestFile { + return true + } + prefix, ok := conanTarballPrefix(name) + return ok && prefix == ConanTarballPackage +} + +func conanTarballPrefix(name string) (string, bool) { + ext := path.Ext(name) + if !conanTarballExtensions[ext] { + return "", false + } + return strings.TrimSuffix(name, ext), true +} diff --git a/modules/har/pkg/har/migrate/util/helm_util.go b/modules/har/pkg/har/migrate/util/helm_util.go new file mode 100644 index 0000000..604b722 --- /dev/null +++ b/modules/har/pkg/har/migrate/util/helm_util.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "path" + "strings" + + "github.com/Masterminds/semver/v3" +) + +const helmChartExt = ".tgz" +const helmProvExt = ".prov" + +// ParseChartFileName splits a Helm chart file name like "{name}-{version}.tgz" +// (or "{name}-{version}.tgz.prov") into its name and version components. +// The version boundary is found by trying each hyphen left-to-right and +// accepting the first split whose right-hand side is a valid SemVer 2 string. +func ParseChartFileName(filename string) (name, version string, ok bool) { + base := path.Base(filename) + base = strings.TrimSuffix(base, helmProvExt) + base = strings.TrimSuffix(base, helmChartExt) + + for i := 0; i < len(base); i++ { + if base[i] != '-' { + continue + } + candidateName := base[:i] + candidateVersion := base[i+1:] + if candidateName == "" || candidateVersion == "" { + continue + } + if _, err := semver.NewVersion(candidateVersion); err == nil { + return candidateName, candidateVersion, true + } + } + return "", "", false +} + +// GetChartFileName returns the canonical chart archive file name: "-.tgz". +func GetChartFileName(name, version string) string { + return name + "-" + version + helmChartExt +} + +// GetChartProvFileName returns the provenance sidecar file name: "-.tgz.prov". +func GetChartProvFileName(name, version string) string { + return GetChartFileName(name, version) + helmProvExt +} + +// IsHelmChartArchive reports whether a file name is a Helm chart archive (.tgz) +// and not a provenance sidecar (.tgz.prov). +func IsHelmChartArchive(name string) bool { + return strings.HasSuffix(name, helmChartExt) && !strings.HasSuffix(name, helmChartExt+helmProvExt) +} diff --git a/modules/har/pkg/har/migrate/util/pattern_util.go b/modules/har/pkg/har/migrate/util/pattern_util.go index b8bdaae..de4fc2a 100644 --- a/modules/har/pkg/har/migrate/util/pattern_util.go +++ b/modules/har/pkg/har/migrate/util/pattern_util.go @@ -132,7 +132,16 @@ func IsFileLevelFilterableArtifact(artifactType types.ArtifactType) bool { func IsPackageLevelFilterableArtifact(artifactType types.ArtifactType) bool { switch artifactType { - case types.DOCKER, types.HELM, types.HELM_LEGACY, types.RPM, types.CONDA, types.COMPOSER, types.SWIFT: + case types.DOCKER, types.HELM, types.HELM_LEGACY, types.HELM_HTTP, types.RPM, types.CONDA, types.COMPOSER, types.SWIFT, types.CONAN: + return true + default: + return false + } +} + +func IsMetadataDrivenArtifact(artifactType types.ArtifactType) bool { + switch artifactType { + case types.RPM, types.DEBIAN: return true default: return false diff --git a/modules/har/pkg/har/migrate/util/puppet_util.go b/modules/har/pkg/har/migrate/util/puppet_util.go new file mode 100644 index 0000000..dfb8fdd --- /dev/null +++ b/modules/har/pkg/har/migrate/util/puppet_util.go @@ -0,0 +1,50 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "path" + "regexp" + "strings" +) + +const puppetTarGzExt = ".tar.gz" + +var puppetSemVerRegex = regexp.MustCompile( + `^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)` + + `(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)` + + `(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?` + + `(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`, +) + +var puppetModuleNameRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*-[a-zA-Z][a-zA-Z0-9_]*$`) + +// ParsePuppetFileNameWithPath parses a Puppet module tarball filename of the +// form "--.tar.gz" and returns the module name +// ("-") plus version. The version boundary is found by scanning +// hyphen positions left-to-right and accepting the first split where both +// sides match their respective patterns. +func ParsePuppetFileNameWithPath(filePath string) (string, string, bool) { + fileName := path.Base(filePath) + if !strings.HasSuffix(fileName, puppetTarGzExt) { + return "", "", false + } + base := strings.TrimSuffix(fileName, puppetTarGzExt) + + for i := 0; i < len(base); i++ { + if base[i] != '-' { + continue + } + candidateName := base[:i] + candidateVersion := base[i+1:] + if !puppetModuleNameRegex.MatchString(candidateName) { + continue + } + if !puppetSemVerRegex.MatchString(candidateVersion) { + continue + } + return candidateName, candidateVersion, true + } + return "", "", false +}