From 2a4accc8b2fdf7465ce277c75c8f7fc79aa9c17e Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Mon, 3 Aug 2026 17:05:27 +0530 Subject: [PATCH 1/5] feat(migrate): port DateFilter types and thread-safe DryRunStats [e0fa78e AH-4341, d0745a1 AH-4458] --- modules/har/pkg/har/migrate/types/config.go | 27 ++++- modules/har/pkg/har/migrate/types/types.go | 119 +++++++++++++++++++- 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/modules/har/pkg/har/migrate/types/config.go b/modules/har/pkg/har/migrate/types/config.go index 39b53f2..94949f9 100644 --- a/modules/har/pkg/har/migrate/types/config.go +++ b/modules/har/pkg/har/migrate/types/config.go @@ -3,7 +3,10 @@ package types import ( "fmt" "os" + "time" + "github.com/pterm/pterm" + "github.com/rs/zerolog/log" "go.yaml.in/yaml/v3" ) @@ -63,6 +66,22 @@ type RegistryConfig struct { APIBaseURL string `yaml:"-"` } +type DateFilterMatch string + +const ( + DateFilterMatchAny DateFilterMatch = "ANY" + DateFilterMatchAll DateFilterMatch = "ALL" +) + +// DateFilter defines time-based filtering criteria for a registry mapping. +// Files are included when their creation or download timestamp satisfies the +// configured thresholds, combined via Match ANY/ALL logic. +type DateFilter struct { + Match DateFilterMatch `yaml:"match"` + CreatedAfter *time.Time `yaml:"createdAfter"` + DownloadedAfter *time.Time `yaml:"downloadedAfter"` +} + // RegistryMapping defines the mapping between source and destination registries // Slashes are used to defined the scope. The format would be // - "registry": Create registry at Account level @@ -76,7 +95,8 @@ type RegistryMapping struct { IncludePatterns []string `yaml:"includePatterns"` ExcludePatterns []string `yaml:"excludePatterns"` //Optional - SourcePackageHostname string `yaml:"sourcePackageHostname"` + SourcePackageHostname string `yaml:"sourcePackageHostname"` + DateFilter *DateFilter `yaml:"dateFilter"` } // CredentialsConfig defines the credential configuration @@ -146,6 +166,11 @@ func validateConfig(config *Config) error { if mapping.DestinationRegistry == "" { return fmt.Errorf("mapping %d: destination registry cannot be empty", i) } + if mapping.ArtifactType == MAVEN && mapping.DateFilter != nil { + msg := fmt.Sprintf("mapping %d: date filter is enabled for %s — maven-metadata.xml may not be in sync with the migrated artifacts", i, MAVEN) + log.Warn().Msg(msg) + pterm.Warning.Println(msg) + } } return nil diff --git a/modules/har/pkg/har/migrate/types/types.go b/modules/har/pkg/har/migrate/types/types.go index 49657ec..8b84a77 100644 --- a/modules/har/pkg/har/migrate/types/types.go +++ b/modules/har/pkg/har/migrate/types/types.go @@ -4,6 +4,7 @@ import ( "errors" "io" "net/http" + "sync" "time" ) @@ -142,8 +143,124 @@ type DryRunVersionEntry struct { Files []DryRunVersionFileEntry `json:"files"` } -// DryRunStats holds the dry-run statistics +// SearchedFile represents a file entry returned by the JFrog AQL search API. +type SearchedFile struct { + Repo string `json:"repo"` + Path string `json:"path"` + Name string `json:"name"` + Created string `json:"created"` + Modified string `json:"modified"` + Stats []DownloadStat `json:"stats"` +} + +// DownloadStat holds a single download timestamp for a SearchedFile. +type DownloadStat struct { + Downloaded string `json:"downloaded"` +} + +// DryRunStats holds the dry-run statistics. +// +// A single DryRunStats is shared across the registry/package/version/file jobs +// that the migration engine runs concurrently, so all access to the Files slice +// and the Directories map (and the nested maps/slices) must go through the +// methods below, which are guarded by mu. type DryRunStats struct { + mu sync.Mutex Files []DryRunFileEntry // All files from GetFiles Directories map[string]*DryRunDirectoryEntry // Directory structure built incrementally } + +// AddFiles appends the given file entries to the shared Files slice. +func (s *DryRunStats) AddFiles(entries ...DryRunFileEntry) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.Files = append(s.Files, entries...) +} + +func (s *DryRunStats) ensureRegistryLocked(registry string) *DryRunDirectoryEntry { + if s.Directories == nil { + s.Directories = make(map[string]*DryRunDirectoryEntry) + } + dirEntry := s.Directories[registry] + if dirEntry == nil { + dirEntry = &DryRunDirectoryEntry{ + Registry: registry, + Packages: make(map[string]*DryRunPackageEntry), + } + s.Directories[registry] = dirEntry + } + return dirEntry +} + +// EnsureRegistry creates the directory entry for the given registry if it does +// not already exist. +func (s *DryRunStats) EnsureRegistry(registry string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.ensureRegistryLocked(registry) +} + +// EnsurePackage creates the registry and package entries if they do not already exist. +func (s *DryRunStats) EnsurePackage(registry, pkg string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + dirEntry := s.ensureRegistryLocked(registry) + if dirEntry.Packages[pkg] == nil { + dirEntry.Packages[pkg] = &DryRunPackageEntry{ + Name: pkg, + Versions: make(map[string]*DryRunVersionEntry), + } + } +} + +// EnsureVersion creates the registry, package and version entries if they do not already exist. +func (s *DryRunStats) EnsureVersion(registry, pkg, version string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.ensureVersionLocked(registry, pkg, version) +} + +func (s *DryRunStats) ensureVersionLocked(registry, pkg, version string) *DryRunVersionEntry { + dirEntry := s.ensureRegistryLocked(registry) + pkgEntry := dirEntry.Packages[pkg] + if pkgEntry == nil { + pkgEntry = &DryRunPackageEntry{ + Name: pkg, + Versions: make(map[string]*DryRunVersionEntry), + } + dirEntry.Packages[pkg] = pkgEntry + } + versionEntry := pkgEntry.Versions[version] + if versionEntry == nil { + versionEntry = &DryRunVersionEntry{ + Name: version, + Files: make([]DryRunVersionFileEntry, 0), + } + pkgEntry.Versions[version] = versionEntry + } + return versionEntry +} + +// AddVersionFile appends a file entry to the given registry/package/version, +// creating the intermediate entries if necessary. +func (s *DryRunStats) AddVersionFile(registry, pkg, version string, file DryRunVersionFileEntry) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + versionEntry := s.ensureVersionLocked(registry, pkg, version) + versionEntry.Files = append(versionEntry.Files, file) +} From 3f88052a0631fe3779a6c1ab6d0fb528f3b791d2 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Mon, 3 Aug 2026 17:06:23 +0530 Subject: [PATCH 2/5] feat(migrate): add SearchFiles to adapter interface with JFrog AQL impl [e0fa78e AH-4341] --- .../har/pkg/har/migrate/adapter/adapter.go | 1 + .../pkg/har/migrate/adapter/har/adapter.go | 4 ++ .../pkg/har/migrate/adapter/harbor/adapter.go | 4 ++ .../pkg/har/migrate/adapter/jfrog/adapter.go | 9 +++++ .../pkg/har/migrate/adapter/jfrog/client.go | 37 +++++++++++++++++++ .../migrate/adapter/mock_jfrog/mock_client.go | 14 +++++++ .../pkg/har/migrate/adapter/nexus/adapter.go | 4 ++ 7 files changed, 73 insertions(+) diff --git a/modules/har/pkg/har/migrate/adapter/adapter.go b/modules/har/pkg/har/migrate/adapter/adapter.go index 07b9471..a91c7fb 100644 --- a/modules/har/pkg/har/migrate/adapter/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/adapter.go @@ -66,6 +66,7 @@ type Adapter interface { files []*types.PackageFiles, metadata map[string]interface{}, ) error + SearchFiles(registry string) ([]types.SearchedFile, error) } var registry = map[types.RegistryType]Factory{} diff --git a/modules/har/pkg/har/migrate/adapter/har/adapter.go b/modules/har/pkg/har/migrate/adapter/har/adapter.go index b2fbad7..6c711fa 100644 --- a/modules/har/pkg/har/migrate/adapter/har/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/har/adapter.go @@ -172,3 +172,7 @@ func (a *harAdapter) CreateVersion(registry string, artifactName string, version return fmt.Errorf("not implemented") } } + +func (a *harAdapter) SearchFiles(_ string) ([]types.SearchedFile, error) { + return nil, fmt.Errorf("date filter (SearchFiles) is not supported for this source adapter") +} diff --git a/modules/har/pkg/har/migrate/adapter/harbor/adapter.go b/modules/har/pkg/har/migrate/adapter/harbor/adapter.go index 03a9c0b..834ebfd 100644 --- a/modules/har/pkg/har/migrate/adapter/harbor/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/harbor/adapter.go @@ -162,3 +162,7 @@ func (a *adapter) GetAllFilesForVersion(_ context.Context, _, _, _ string) ([]st func (a *adapter) CreateVersion(_ string, _ string, _ string, _ types.ArtifactType, _ []*types.PackageFiles, _ map[string]interface{}) error { return fmt.Errorf("CreateVersion not implemented for HARBOR") } + +func (a *adapter) SearchFiles(_ string) ([]types.SearchedFile, error) { + return nil, fmt.Errorf("date filter (SearchFiles) is not supported for this source adapter") +} diff --git a/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go b/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go index d02ea8d..5caf21d 100644 --- a/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go @@ -1243,6 +1243,15 @@ func (a *adapter) CreateVersion( return nil } +func (a *adapter) SearchFiles(registry string) ([]types.SearchedFile, error) { + files, err := a.client.SearchFiles(registry) + if err != nil { + log.Error().Msgf("Failed to search files from registry: %v", err) + return nil, fmt.Errorf("failed to search files from registry: %w", err) + } + return files, nil +} + // getPythonVersionsFromTree extracts Python package versions by scanning the // file tree. This is used as a fallback when the .pypi index HTML files are // not available (e.g. packages deployed directly, not via the PyPI API). diff --git a/modules/har/pkg/har/migrate/adapter/jfrog/client.go b/modules/har/pkg/har/migrate/adapter/jfrog/client.go index 8013985..d930198 100644 --- a/modules/har/pkg/har/migrate/adapter/jfrog/client.go +++ b/modules/har/pkg/har/migrate/adapter/jfrog/client.go @@ -49,6 +49,7 @@ type Client interface { GetFile(registry string, path string) (io.ReadCloser, http.Header, error) GetFiles(registry string) ([]types.File, error) GetCatalog(registry string) ([]string, error) + SearchFiles(registry string) ([]types.SearchedFile, error) } // newClient constructs a jfrog client @@ -291,3 +292,39 @@ func (c *client) catalog(url string) ([]string, string, error) { } return repositories.Repositories, parseNextLink(resp.Header.Get("Link")), nil } + +func (c *client) SearchFiles(registry string) ([]types.SearchedFile, error) { + aqlURL := fmt.Sprintf("%s/artifactory/api/search/aql", c.url) + query := fmt.Sprintf(`items.find({"repo": "%s", "type": "file"}).include("repo", "path", "name", "created", "modified", "stat.downloaded")`, registry) + + req, err := http.NewRequest(http.MethodPost, aqlURL, strings.NewReader(query)) + if err != nil { + return nil, fmt.Errorf("failed to create AQL request: %w", err) + } + req.Header.Set("Content-Type", "text/plain") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute AQL search for registry %q: %w", registry, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("AQL search failed for registry %q, status: %d", registry, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read AQL response: %w", err) + } + + type aqlResponse struct { + Results []types.SearchedFile `json:"results"` + } + var result aqlResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse AQL response: %w", err) + } + + return result.Results, nil +} diff --git a/modules/har/pkg/har/migrate/adapter/mock_jfrog/mock_client.go b/modules/har/pkg/har/migrate/adapter/mock_jfrog/mock_client.go index 8d5ee3d..71fecda 100644 --- a/modules/har/pkg/har/migrate/adapter/mock_jfrog/mock_client.go +++ b/modules/har/pkg/har/migrate/adapter/mock_jfrog/mock_client.go @@ -26,6 +26,7 @@ type mockClient struct { catalogs map[string][]string fileContent map[string][]byte // keyed by "registry/path" binaryContent map[string][]byte + searchedFiles map[string][]types.SearchedFile } // NewMockClient creates a mock implementation of jfrog.Client backed by @@ -37,6 +38,7 @@ func NewMockClient() jfrog.Client { catalogs: make(map[string][]string), fileContent: make(map[string][]byte), binaryContent: make(map[string][]byte), + searchedFiles: make(map[string][]types.SearchedFile), } c.loadRegistries() c.loadFiles() @@ -46,6 +48,11 @@ func NewMockClient() jfrog.Client { return c } +// SetSearchedFiles registers per-registry AQL result data for date-filter tests. +func (c *mockClient) SetSearchedFiles(registry string, files []types.SearchedFile) { + c.searchedFiles[registry] = files +} + func (c *mockClient) loadRegistries() { data, err := testdataFS.ReadFile("testdata/registries.json") if err != nil { @@ -302,6 +309,13 @@ func (c *mockClient) GetFiles(registry string) ([]types.File, error) { }, nil } +func (c *mockClient) SearchFiles(registry string) ([]types.SearchedFile, error) { + if files, exists := c.searchedFiles[registry]; exists { + return files, nil + } + return nil, fmt.Errorf("no search data found for registry '%s'", registry) +} + func (c *mockClient) GetCatalog(registry string) ([]string, error) { if catalog, exists := c.catalogs[registry]; exists { return catalog, nil diff --git a/modules/har/pkg/har/migrate/adapter/nexus/adapter.go b/modules/har/pkg/har/migrate/adapter/nexus/adapter.go index 07c7ea8..0bb5ca0 100644 --- a/modules/har/pkg/har/migrate/adapter/nexus/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/nexus/adapter.go @@ -440,3 +440,7 @@ func (a *adapter) CreateVersion( ) error { return nil } + +func (a *adapter) SearchFiles(_ string) ([]types.SearchedFile, error) { + return nil, fmt.Errorf("date filter (SearchFiles) is not supported for this source adapter") +} From 629ad07d88885cfc1c3b25a37d3fd9683484a6d5 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Mon, 3 Aug 2026 17:06:52 +0530 Subject: [PATCH 3/5] feat(migrate): apply date filter with PyPI index preservation and atomic version recovery [2938d97, baae503 AH-4518] --- .../har/pkg/har/migrate/migratable/package.go | 31 +--- .../pkg/har/migrate/migratable/registry.go | 97 ++++++++-- .../har/pkg/har/migrate/migratable/version.go | 44 ++--- .../pkg/har/migrate/util/datefilter_util.go | 174 ++++++++++++++++++ 4 files changed, 286 insertions(+), 60 deletions(-) create mode 100644 modules/har/pkg/har/migrate/util/datefilter_util.go diff --git a/modules/har/pkg/har/migrate/migratable/package.go b/modules/har/pkg/har/migrate/migratable/package.go index dc58be7..330fcae 100644 --- a/modules/har/pkg/har/migrate/migratable/package.go +++ b/modules/har/pkg/har/migrate/migratable/package.go @@ -53,6 +53,7 @@ type Package struct { config *types.Config registry types.RegistryInfo dryRunStats *types.DryRunStats + unfilteredRoot *types.TreeNode } func NewPackageJob( @@ -69,6 +70,7 @@ func NewPackageJob( config *types.Config, registry types.RegistryInfo, dryRunStats *types.DryRunStats, + unfilteredRoot *types.TreeNode, ) engine.Job { jobID := uuid.New().String() @@ -95,6 +97,7 @@ func NewPackageJob( config: config, registry: registry, dryRunStats: dryRunStats, + unfilteredRoot: unfilteredRoot, } } @@ -311,11 +314,13 @@ func (r *Package) Migrate(ctx context.Context) error { for _, version := range versions { versionNode, err := tree.GetNodeForPath(r.node, version.Path) if err != nil { - logger.Error().Msg("Failed to get node for version") - return fmt.Errorf("get version failed: %w", err) + // Version path not in the filtered tree — it was entirely pruned by date + // or pattern filters. Skip gracefully rather than aborting the package. + logger.Debug().Str("version", version.Name).Msg("version not in filtered tree, skipping (out of date-filter window)") + continue } job := NewVersionJob(r.srcAdapter, r.destAdapter, r.srcRegistry, r.destRegistry, r.artifactType, r.pkg, - version, versionNode, r.stats, r.mapping, r.config, r.registry, r.dryRunStats) + version, versionNode, r.stats, r.mapping, r.config, r.registry, r.dryRunStats, r.unfilteredRoot) jobs = append(jobs, job) } @@ -664,28 +669,12 @@ func check(err error, context string) { } } -// addPackageToDryRunDirectory adds package to the directory structure +// addPackageToDryRunDirectory adds package to the directory structure (thread-safe). func (r *Package) addPackageToDryRunDirectory() { if r.dryRunStats == nil { return } - - // Ensure registry entry exists (should already be created by Registry) - if r.dryRunStats.Directories[r.srcRegistry] == nil { - r.dryRunStats.Directories[r.srcRegistry] = &types.DryRunDirectoryEntry{ - Registry: r.srcRegistry, - Packages: make(map[string]*types.DryRunPackageEntry), - } - } - dirEntry := r.dryRunStats.Directories[r.srcRegistry] - - // Add package entry if not exists - if dirEntry.Packages[r.pkg.Name] == nil { - dirEntry.Packages[r.pkg.Name] = &types.DryRunPackageEntry{ - Name: r.pkg.Name, - Versions: make(map[string]*types.DryRunVersionEntry), - } - } + r.dryRunStats.EnsurePackage(r.srcRegistry, r.pkg.Name) } // Post Any post processing work diff --git a/modules/har/pkg/har/migrate/migratable/registry.go b/modules/har/pkg/har/migrate/migratable/registry.go index e71f137..84434f4 100644 --- a/modules/har/pkg/har/migrate/migratable/registry.go +++ b/modules/har/pkg/har/migrate/migratable/registry.go @@ -12,6 +12,7 @@ import ( "github.com/harness/cli/modules/har/pkg/har/migrate/util" "github.com/google/uuid" + "github.com/pterm/pterm" "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -136,32 +137,83 @@ func (r *Registry) Migrate(ctx context.Context) error { return fmt.Errorf("get files from registry %s failed: %w", r.srcRegistry, err2) } - // In dry-run mode, collect all files and initialize directory entry for this registry + pterm.Info.Println(fmt.Sprintf("Pulled %d file(s) from registry %s", len(files), r.srcRegistry)) + + // Keep a copy of the original file list before any filtering. This is used + // to build an unfilteredRoot for PYTHON so version enumeration is not broken + // when date filtering prunes .pypi index files. + originalFiles := files + + // In dry-run mode, collect all files using thread-safe methods. if r.config.DryRun && r.dryRunStats != nil { - // Add all files to the file list + var entries []types.DryRunFileEntry for _, file := range files { - entry := types.DryRunFileEntry{ + entries = append(entries, types.DryRunFileEntry{ Registry: r.srcRegistry, Name: file.Name, Uri: file.Uri, Size: file.Size, LastModified: file.LastModified, - } - r.dryRunStats.Files = append(r.dryRunStats.Files, entry) + }) } + r.dryRunStats.AddFiles(entries...) + r.dryRunStats.EnsureRegistry(r.srcRegistry) logger.Info().Msgf("Dry-run: collected %d files from registry %s", len(files), r.srcRegistry) + } - // Initialize directory entry for this registry - if r.dryRunStats.Directories[r.srcRegistry] == nil { - r.dryRunStats.Directories[r.srcRegistry] = &types.DryRunDirectoryEntry{ - Registry: r.srcRegistry, - Packages: make(map[string]*types.DryRunPackageEntry), + // Date-based filtering: query the source for file timestamps and narrow the + // file list to files that satisfy the configured createdAfter/downloadedAfter + // thresholds. Index/metadata files (PyPI .pypi/) are always preserved so + // package enumeration is not broken. + currArtifactType := r.artifactType + var dateFilteredFiles []types.File + dateFilterActive := false + + if util.IsTimeBasedFilterPresent(r.mapping) { + dateFilterActive = true + df := r.mapping.DateFilter + if err := util.ValidateDateFilter(df); err != nil { + logger.Error().Err(err).Msg("Date filter validation failed") + return err + } + + searchedFiles, searchErr := r.srcAdapter.SearchFiles(r.srcRegistry) + if searchErr != nil { + logger.Error().Msgf("Failed to search files from registry %s", r.srcRegistry) + return fmt.Errorf("search files from registry %s failed: %w", r.srcRegistry, searchErr) + } + logger.Info().Msgf("Applying time based filter (match: %s)", df.Match) + filteredURIs := util.CreateMapOfFilteredFile(searchedFiles, r.mapping) + logger.Info().Msgf("Time-based filter includes %d file(s) out of %d", len(filteredURIs), len(searchedFiles)) + + // Preserve index/metadata files regardless of date — enumeration reads them. + indexCount := 0 + for _, f := range files { + if util.IsPackageIndexFile(r.artifactType, f.Uri) { + if _, ok := filteredURIs[f.Uri]; !ok { + filteredURIs[f.Uri] = struct{}{} + indexCount++ + } } } + if indexCount > 0 { + logger.Info().Msgf("Preserving %d index/metadata file(s) exempt from date filter", indexCount) + } + + dateFilteredFiles = util.FilterFilesByDate(files, filteredURIs) + logger.Info().Msgf("Count of filtered files by date filter: %d -> %d", len(files), len(dateFilteredFiles)) + skippedByFilter := len(files) - len(dateFilteredFiles) + pterm.Info.Println(fmt.Sprintf("Registry %s: %d file(s) pulled, %d under skip condition (date/pattern filters)", + r.srcRegistry, len(files), skippedByFilter)) + + // Narrow the tree for all types EXCEPT metadata-driven types (RPM, DEBIAN) + // which need the full file tree to read repomd.xml / Packages.gz metadata. + if !util.IsMetadataDrivenArtifact(currArtifactType) { + files = dateFilteredFiles + } } // Filter files based on include/exclude patterns - currArtifactType := r.artifactType if util.IsFileLevelFilterableArtifact(currArtifactType) { if len(r.mapping.IncludePatterns) > 0 || len(r.mapping.ExcludePatterns) > 0 { originalCount := len(files) @@ -174,12 +226,33 @@ func (r *Registry) Migrate(ctx context.Context) error { root := tree.TransformToTree(files) + // For PYTHON (IsAtomicVersionArtifact), build an unfilteredRoot from the + // original file list so version.go can recover distributions that were pruned + // by date or pattern filters. Other types pass nil. + var unfilteredRoot *types.TreeNode + if dateFilterActive && util.IsAtomicVersionArtifact(currArtifactType) { + recoveryFiles := originalFiles + if util.IsFileLevelFilterableArtifact(currArtifactType) && + (len(r.mapping.IncludePatterns) > 0 || len(r.mapping.ExcludePatterns) > 0) { + recoveryFiles = util.FilterFilesByPatterns(originalFiles, r.mapping.IncludePatterns, r.mapping.ExcludePatterns) + } + unfilteredRoot = tree.TransformToTree(recoveryFiles) + } + pkgs, err := r.srcAdapter.GetPackages(r.srcRegistry, r.artifactType, root) if err != nil { logger.Error().Msg("Failed to get packages") return fmt.Errorf("get packages failed: %w", err) } + // For metadata-driven types, re-apply date filter at the package level. + if dateFilterActive && util.IsMetadataDrivenArtifact(currArtifactType) { + originalPkgCount := len(pkgs) + pkgs = util.FilterPackagesByFileName(pkgs, dateFilteredFiles) + logger.Info().Msgf("Date filter (post-GetPackages): %d -> %d packages for %s", + originalPkgCount, len(pkgs), currArtifactType) + } + // applying package level filter if util.IsPackageLevelFilterableArtifact(currArtifactType) { if len(r.mapping.IncludePatterns) > 0 || len(r.mapping.ExcludePatterns) > 0 { @@ -199,7 +272,7 @@ func (r *Registry) Migrate(ctx context.Context) error { return fmt.Errorf("get node for path %s failed: %w", pkg.Path, err2) } job := NewPackageJob(r.srcAdapter, r.destAdapter, r.srcRegistry, r.sourcePackageHostname, r.destRegistry, r.artifactType, pkg, treeNode, - r.stats, r.mapping, r.config, r.registry, r.dryRunStats) + r.stats, r.mapping, r.config, r.registry, r.dryRunStats, unfilteredRoot) jobs = append(jobs, job) } diff --git a/modules/har/pkg/har/migrate/migratable/version.go b/modules/har/pkg/har/migrate/migratable/version.go index fd51939..9bb0e1c 100644 --- a/modules/har/pkg/har/migrate/migratable/version.go +++ b/modules/har/pkg/har/migrate/migratable/version.go @@ -34,6 +34,7 @@ type Version struct { registry types.RegistryInfo existingFileMap map[string]bool dryRunStats *types.DryRunStats + unfilteredRoot *types.TreeNode } func NewVersionJob( @@ -50,6 +51,7 @@ func NewVersionJob( config *types.Config, registry types.RegistryInfo, dryRunStats *types.DryRunStats, + unfilteredRoot *types.TreeNode, ) engine.Job { jobID := uuid.New().String() @@ -78,6 +80,7 @@ func NewVersionJob( registry: registry, existingFileMap: make(map[string]bool), dryRunStats: dryRunStats, + unfilteredRoot: unfilteredRoot, } } @@ -145,7 +148,18 @@ func (r *Version) Migrate(ctx context.Context) error { 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.PUPPET { - files, err := tree.GetAllFiles(r.node) + // For PYTHON, use unfilteredRoot so distribution files pruned by the date filter + // are still enumerated — prevents partial versions from being published. + fileNode := r.node + if r.artifactType == types.PYTHON && r.unfilteredRoot != nil { + if unfilteredPkgNode, e := tree.GetNodeForPath(r.unfilteredRoot, r.pkg.Path); e == nil { + if unfilteredVersionNode, e2 := tree.GetNodeForPath(unfilteredPkgNode, r.version.Path); e2 == nil { + fileNode = unfilteredVersionNode + logger.Debug().Str("version", r.version.Name).Msg("recovered distribution files from unfiltered tree") + } + } + } + files, err := tree.GetAllFiles(fileNode) if err != nil { logger.Error().Err(err).Msg("Failed to get files from tree") return fmt.Errorf("get files from tree failed: %w", err) @@ -264,36 +278,12 @@ func (r *Version) Migrate(ctx context.Context) error { return nil } -// addVersionToDryRunDirectory adds version to the directory structure +// addVersionToDryRunDirectory adds version to the directory structure (thread-safe). func (r *Version) addVersionToDryRunDirectory() { if r.dryRunStats == nil { return } - - // Ensure registry and package entries exist - if r.dryRunStats.Directories[r.srcRegistry] == nil { - r.dryRunStats.Directories[r.srcRegistry] = &types.DryRunDirectoryEntry{ - Registry: r.srcRegistry, - Packages: make(map[string]*types.DryRunPackageEntry), - } - } - dirEntry := r.dryRunStats.Directories[r.srcRegistry] - - if dirEntry.Packages[r.pkg.Name] == nil { - dirEntry.Packages[r.pkg.Name] = &types.DryRunPackageEntry{ - Name: r.pkg.Name, - Versions: make(map[string]*types.DryRunVersionEntry), - } - } - pkgEntry := dirEntry.Packages[r.pkg.Name] - - // Add version entry if not exists - if pkgEntry.Versions[r.version.Name] == nil { - pkgEntry.Versions[r.version.Name] = &types.DryRunVersionEntry{ - Name: r.version.Name, - Files: make([]types.DryRunVersionFileEntry, 0), - } - } + r.dryRunStats.EnsureVersion(r.srcRegistry, r.pkg.Name, r.version.Name) } // Post Any post processing work diff --git a/modules/har/pkg/har/migrate/util/datefilter_util.go b/modules/har/pkg/har/migrate/util/datefilter_util.go new file mode 100644 index 0000000..a0fc09f --- /dev/null +++ b/modules/har/pkg/har/migrate/util/datefilter_util.go @@ -0,0 +1,174 @@ +package util + +import ( + "fmt" + "strings" + "time" + + "github.com/harness/cli/modules/har/pkg/har/migrate/types" + + "github.com/rs/zerolog/log" +) + +// IsTimeBasedFilterPresent reports whether the mapping has a non-nil DateFilter. +func IsTimeBasedFilterPresent(mapping *types.RegistryMapping) bool { + return mapping.DateFilter != nil +} + +// ValidateDateFilter returns an error if the DateFilter is misconfigured. +func ValidateDateFilter(df *types.DateFilter) error { + if df.Match != types.DateFilterMatchAny && df.Match != types.DateFilterMatchAll { + log.Error().Msgf("dateFilter.match must be 'ANY' or 'ALL', got %q", df.Match) + return fmt.Errorf("dateFilter.match must be 'ANY' or 'ALL', got %q", df.Match) + } + if df.CreatedAfter == nil && df.DownloadedAfter == nil { + log.Error().Msg("dateFilter is present but neither createdAfter nor downloadedAfter is specified") + return fmt.Errorf("dateFilter is present but neither createdAfter nor downloadedAfter is specified") + } + return nil +} + +// CreateMapOfFilteredFile builds a URI set of files that satisfy the date filter. +func CreateMapOfFilteredFile(searchedFiles []types.SearchedFile, mapping *types.RegistryMapping) map[string]struct{} { + result := map[string]struct{}{} + if mapping.DateFilter == nil { + return result + } + + df := mapping.DateFilter + hasCreated := df.CreatedAfter != nil + hasDownloaded := df.DownloadedAfter != nil + + log.Info().Msgf("Filtering files by dateFilter (match: %s, createdAfter: %v, downloadedAfter: %v)", + df.Match, df.CreatedAfter, df.DownloadedAfter) + + for _, f := range searchedFiles { + var matchedCreated, matchedDownloaded bool + + if hasCreated { + created, err := parseDate(f.Created) + if err != nil { + log.Warn().Msgf("File %s: failed to parse created date %q: %v", f.Name, f.Created, err) + } else { + matchedCreated = onOrAfter(created, *df.CreatedAfter) + } + } + + if hasDownloaded { + for _, stat := range f.Stats { + downloaded, err := parseDate(stat.Downloaded) + if err != nil { + log.Warn().Msgf("File %s: failed to parse downloaded date %q: %v", f.Name, stat.Downloaded, err) + continue + } + if onOrAfter(downloaded, *df.DownloadedAfter) { + matchedDownloaded = true + break + } + } + } + + var include bool + switch df.Match { + case types.DateFilterMatchAny: + include = (hasCreated && matchedCreated) || (hasDownloaded && matchedDownloaded) + case types.DateFilterMatchAll: + include = true + if hasCreated && !matchedCreated { + include = false + } + if hasDownloaded && !matchedDownloaded { + include = false + } + } + + if include { + result[buildURI(f.Path, f.Name)] = struct{}{} + } + } + + return result +} + +// FilterFilesByDate returns only files whose URI is in filteredURIs. +func FilterFilesByDate(files []types.File, filteredURIs map[string]struct{}) []types.File { + var result []types.File + for _, f := range files { + if _, ok := filteredURIs[f.Uri]; ok { + result = append(result, f) + } + } + return result +} + +// FilterPackagesByFileName keeps packages whose bare URI matches any date-filtered file. +// Used for metadata-driven types (RPM, DEBIAN) where GetPackages reads a metadata file +// that lists every package regardless of the filtered tree. +func FilterPackagesByFileName(pkgs []types.Package, dateFilteredFiles []types.File) []types.Package { + uriSet := make(map[string]struct{}, len(dateFilteredFiles)) + for _, f := range dateFilteredFiles { + uriSet[strings.TrimPrefix(f.Uri, "/")] = struct{}{} + } + + var result []types.Package + for _, pkg := range pkgs { + if _, ok := uriSet[strings.TrimPrefix(pkg.URL, "/")]; ok { + result = append(result, pkg) + } + } + return result +} + +// IsPackageIndexFile reports whether uri is a repository index/metadata file exempt from +// date filtering. Such files are needed for package enumeration and are typically too old +// to survive a createdAfter/downloadedAfter cutoff. +func IsPackageIndexFile(artifactType types.ArtifactType, uri string) bool { + normalized := strings.TrimPrefix(uri, "/") + switch artifactType { + case types.PYTHON: + return strings.HasPrefix(normalized, ".pypi/") + default: + return false + } +} + +// IsAtomicVersionArtifact reports whether a single logical version of this type may span +// multiple distribution files (e.g. PyPI sdist + wheels). For such types, date filtering +// can keep some distributions and prune others; Package.Migrate uses an unfilteredRoot to +// recover pruned distributions so partial versions are never published. +func IsAtomicVersionArtifact(artifactType types.ArtifactType) bool { + switch artifactType { + case types.PYTHON: + return true + default: + return false + } +} + +func parseDate(s string) (time.Time, error) { + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.000Z07:00", + "2006-01-02T15:04:05Z07:00", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, s); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unable to parse date: %q", s) +} + +func buildURI(path, name string) string { + path = strings.TrimPrefix(path, "/") + path = strings.TrimSuffix(path, "/") + if path == "" || path == "." { + return "/" + name + } + return "/" + path + "/" + name +} + +func onOrAfter(t, threshold time.Time) bool { + return !t.Before(threshold) +} From 5eddbbeaaf51f16f45632bf4df5cfab687154dfd Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Mon, 3 Aug 2026 17:07:16 +0530 Subject: [PATCH 4/5] feat(migrate): enrich dry-run summary with per-type fallback label and nil guards [c055bf4 AH-4430] --- modules/har/pkg/har/migrate/migration.go | 51 ++++++++++++++++++++---- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/modules/har/pkg/har/migrate/migration.go b/modules/har/pkg/har/migrate/migration.go index ae67ae1..65384b8 100644 --- a/modules/har/pkg/har/migrate/migration.go +++ b/modules/har/pkg/har/migrate/migration.go @@ -114,30 +114,65 @@ func (m *MigrationService) writeDryRunOutput(logger zerolog.Logger) error { return fmt.Errorf("failed to create output directory: %w", err) } + files := m.dryRunStats.Files + dirs := m.dryRunStats.Directories + fileListPath := filepath.Join(outputDir, fmt.Sprintf("file_list_%s.json", timestamp)) - fileListData, err := json.MarshalIndent(m.dryRunStats.Files, "", " ") + fileListData, err := json.MarshalIndent(files, "", " ") if err != nil { return fmt.Errorf("failed to marshal file list: %w", err) } if err := os.WriteFile(fileListPath, fileListData, 0644); err != nil { return fmt.Errorf("failed to write file list: %w", err) } - logger.Info().Str("path", fileListPath).Int("total_files", len(m.dryRunStats.Files)).Msg("File list written") + logger.Info().Str("path", fileListPath).Int("total_files", len(files)).Msg("File list written") dirStructPath := filepath.Join(outputDir, fmt.Sprintf("directory_structure_%s.json", timestamp)) - dirStructData, err := json.MarshalIndent(m.dryRunStats.Directories, "", " ") + dirStructData, err := json.MarshalIndent(dirs, "", " ") if err != nil { return fmt.Errorf("failed to marshal directory structure: %w", err) } if err := os.WriteFile(dirStructPath, dirStructData, 0644); err != nil { return fmt.Errorf("failed to write directory structure: %w", err) } - logger.Info().Str("path", dirStructPath).Int("total_registries", len(m.dryRunStats.Directories)).Msg("Directory structure written") + logger.Info().Str("path", dirStructPath).Int("total_registries", len(dirs)).Msg("Directory structure written") + + // Tally totals from the directory tree. + totalRegistries := len(dirs) + var totalPackages, totalVersions, totalVersionFiles int + for _, reg := range dirs { + if reg == nil { + continue + } + totalPackages += len(reg.Packages) + for _, pkg := range reg.Packages { + if pkg == nil { + continue + } + totalVersions += len(pkg.Versions) + for _, ver := range pkg.Versions { + if ver == nil { + continue + } + totalVersionFiles += len(ver.Files) + } + } + } + + migratedCount := totalVersionFiles + migratedCountLabel := "Total files (filtered):" + if migratedCount == 0 && totalPackages > 0 { + migratedCount = totalPackages + migratedCountLabel = "Total packages (filtered):" + } - fmt.Printf("\n=== Dry Run Complete ===\n") - fmt.Printf("Total files found: %d\n", len(m.dryRunStats.Files)) - fmt.Printf("File list written to: %s\n", fileListPath) - fmt.Printf("Directory structure written to: %s\n", dirStructPath) + fmt.Printf("\n==== Dry Run Summary ====\n") + fmt.Printf("%-30s %d\n", "Total registries:", totalRegistries) + fmt.Printf("%-30s %d\n", "Total packages:", totalPackages) + fmt.Printf("%-30s %d\n", "Total versions:", totalVersions) + fmt.Printf("%-30s %d\n", migratedCountLabel, migratedCount) + fmt.Printf("%-30s %s\n", "File list:", fileListPath) + fmt.Printf("%-30s %s\n", "Directory structure:", dirStructPath) return nil } From 045330c25a440832869bd6bc78d72188143485fe Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Wed, 5 Aug 2026 01:18:58 +0530 Subject: [PATCH 5/5] Merge commit merge Feat/8-har->main --- modules/har/pkg/har/migrate/adapter/har/adapter.go | 1 + modules/har/pkg/har/migrate/adapter/harbor/adapter.go | 1 + modules/har/pkg/har/migrate/adapter/jfrog/adapter.go | 1 + modules/har/pkg/har/migrate/adapter/nexus/adapter.go | 1 + 4 files changed, 4 insertions(+) diff --git a/modules/har/pkg/har/migrate/adapter/har/adapter.go b/modules/har/pkg/har/migrate/adapter/har/adapter.go index 1596adb..bad6274 100644 --- a/modules/har/pkg/har/migrate/adapter/har/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/har/adapter.go @@ -175,6 +175,7 @@ func (a *harAdapter) CreateVersion(registry string, artifactName string, version func (a *harAdapter) SearchFiles(_ string) ([]types.SearchedFile, error) { return nil, fmt.Errorf("date filter (SearchFiles) is not supported for this source adapter") +} func (a *harAdapter) BuildExistingIndex(ctx context.Context, registryRef string, concurrency int) (*types.ExistingIndex, error) { return a.client.buildExistingIndex(ctx, registryRef, concurrency) } diff --git a/modules/har/pkg/har/migrate/adapter/harbor/adapter.go b/modules/har/pkg/har/migrate/adapter/harbor/adapter.go index 05285e5..15662a9 100644 --- a/modules/har/pkg/har/migrate/adapter/harbor/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/harbor/adapter.go @@ -165,6 +165,7 @@ func (a *adapter) CreateVersion(_ string, _ string, _ string, _ types.ArtifactTy func (a *adapter) SearchFiles(_ string) ([]types.SearchedFile, error) { return nil, fmt.Errorf("date filter (SearchFiles) is not supported for this source adapter") +} func (a *adapter) BuildExistingIndex(_ context.Context, _ string, _ int) (*types.ExistingIndex, error) { return nil, nil } diff --git a/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go b/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go index 239c3f0..6ac7fbc 100644 --- a/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/jfrog/adapter.go @@ -1250,6 +1250,7 @@ func (a *adapter) SearchFiles(registry string) ([]types.SearchedFile, error) { return nil, fmt.Errorf("failed to search files from registry: %w", err) } return files, nil +} func (a *adapter) BuildExistingIndex(_ context.Context, _ string, _ int) (*types.ExistingIndex, error) { return nil, nil } diff --git a/modules/har/pkg/har/migrate/adapter/nexus/adapter.go b/modules/har/pkg/har/migrate/adapter/nexus/adapter.go index 1aadd83..b6c2bee 100644 --- a/modules/har/pkg/har/migrate/adapter/nexus/adapter.go +++ b/modules/har/pkg/har/migrate/adapter/nexus/adapter.go @@ -443,6 +443,7 @@ func (a *adapter) CreateVersion( func (a *adapter) SearchFiles(_ string) ([]types.SearchedFile, error) { return nil, fmt.Errorf("date filter (SearchFiles) is not supported for this source adapter") +} func (a *adapter) BuildExistingIndex(_ context.Context, _ string, _ int) (*types.ExistingIndex, error) { return nil, nil }