blob: 928939b8dec4025394a542b0f0a92fdc26923ece [file]
// Copyright 2026 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package project
import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"go.fuchsia.dev/jiri"
"go.fuchsia.dev/jiri/log"
)
// MigrateLegacyGroupCache inspects legacy package-group cache entries (.jiri_root/packages/<hex64>),
// adopts individual package instances into instances/<instance_id>, and immediately removes each legacy
// directory upon adoption.
// This completes the migration and retires the old schema in ~1-2s with 0 bytes of network traffic.
func MigrateLegacyGroupCache(ctx context.Context, cache *PackageCache) error {
cacheRoot := cache.Root()
entries, err := os.ReadDir(cacheRoot)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
instancesDir := filepath.Join(cacheRoot, "instances")
cipdPkgsDir := filepath.Join(cacheRoot, ".cipd", "pkgs")
// Fast check: return immediately if no legacy directories exist
hasLegacy := false
for _, entry := range entries {
if len(entry.Name()) == 64 && isHex(entry.Name()) && entry.IsDir() {
hasLegacy = true
break
}
}
if !hasLegacy {
return nil
}
if err := os.MkdirAll(instancesDir, 0755); err != nil {
return err
}
if err := os.MkdirAll(cipdPkgsDir, 0755); err != nil {
return err
}
pkgIndex := nextCIPDPkgIndex(cipdPkgsDir)
adoptedInstances := make(map[string]bool)
for _, entry := range entries {
name := entry.Name()
if len(name) != 64 || !isHex(name) || !entry.IsDir() {
continue
}
legacyDir := filepath.Join(cacheRoot, name)
legacyCipdPkgs := filepath.Join(legacyDir, ".cipd", "pkgs")
var allSuccess bool
pkgIndex, allSuccess = adoptCIPDPackages(ctx, legacyCipdPkgs, instancesDir, cipdPkgsDir, pkgIndex, adoptedInstances)
// Immediately remove the legacy directory after adopting its packages
if allSuccess {
log.Debugf(ctx, "Retiring legacy group cache entry: %s", name)
if err := os.RemoveAll(legacyDir); err != nil {
log.Warningf(ctx, "Failed to remove legacy directory %s: %v", legacyDir, err)
}
} else {
log.Warningf(ctx, "Preserving legacy directory %s due to adoption errors", name)
}
}
return nil
}
// MigrateStandaloneToPackageCache adopts package instances from a standalone CIPD checkout (<root>/.cipd)
// into the package cache (instances/<id>), replicates metadata into .cipd/pkgs, and removes <root>/.cipd.
func MigrateStandaloneToPackageCache(ctx context.Context, cache *PackageCache) error {
checkout := jiri.CheckoutFromContext(ctx)
standaloneCipd := filepath.Join(checkout.Root, ".cipd")
standalonePkgs := filepath.Join(standaloneCipd, "pkgs")
if fi, err := os.Stat(standalonePkgs); err != nil || !fi.IsDir() {
return nil
}
cacheRoot := cache.Root()
instancesDir := filepath.Join(cacheRoot, "instances")
cipdPkgsDir := filepath.Join(cacheRoot, ".cipd", "pkgs")
if err := os.MkdirAll(instancesDir, 0755); err != nil {
return err
}
if err := os.MkdirAll(cipdPkgsDir, 0755); err != nil {
return err
}
pkgIndex := nextCIPDPkgIndex(cipdPkgsDir)
adoptedInstances := make(map[string]bool)
pkgIndex, allSuccess := adoptCIPDPackages(ctx, standalonePkgs, instancesDir, cipdPkgsDir, pkgIndex, adoptedInstances)
if allSuccess {
log.Debugf(ctx, "Retiring standalone .cipd directory after adoption")
_ = os.RemoveAll(standaloneCipd)
} else {
log.Warningf(ctx, "Preserving standalone .cipd directory due to adoption errors")
}
return nil
}
// MigratePackageCacheToStandalone migrates a checkout from package-cache=true to package-cache=false.
//
// For directories whose cache stamp matches the target instances, it detaches the directory
// in-place by removing .jiri_cache_hash.
// For missing destination directories whose instances exist in cache, it restores the directory
// via local hardlinks without downloading.
// It returns the subset of packages requiring a direct CIPD fetch (e.g. version rolls or uncached packages).
func MigratePackageCacheToStandalone(ctx context.Context, activePkgs Packages, cache *PackageCache) (Packages, error) {
checkout := jiri.CheckoutFromContext(ctx)
fetchPkgs := make(Packages)
index, err := LoadWorktreePackageIndex(ctx, activePkgs)
if err != nil {
return nil, err
}
dests := index.DestinationsFor(checkout.Root)
pkgsByRel := make(map[string][]Package)
for key, pkg := range activePkgs {
rel, err := pkg.ResolvePath()
if err != nil {
fetchPkgs[key] = pkg
continue
}
pkgsByRel[rel] = append(pkgsByRel[rel], pkg)
}
// Process destinations in deterministic parent-first order so parent checks and
// restorations occur before nested child destinations.
for _, rel := range slices.Sorted(maps.Keys(pkgsByRel)) {
wsDir := filepath.Join(checkout.Root, rel)
stamp := NewCacheStamp(wsDir)
var dest WorkspaceDestination
for _, d := range dests {
if d.RelPath == rel {
dest = d
break
}
}
// In-place detachment: if matching target is already present, detach stamp.
// Older stamps are also detached in-place if canAttachInPlace confirms the files match the cache.
if stamp.MatchesDestination(dest) || (stamp.MatchesInstances(dest) && dest.canAttachInPlace(checkout.Root, cache)) {
stamp.Remove()
continue
}
// Cache-assisted restoration: if instances exist in cache, restore clean files locally
// without network traffic when the directory is missing or contained mismatched files.
if cache.HasAllInstances(dest.Instances) {
if err := dest.validatePath(checkout.Root); err != nil {
return nil, err
}
_ = os.RemoveAll(wsDir)
restoreErr := func() error {
if err := os.MkdirAll(filepath.Dir(wsDir), 0755); err != nil {
return err
}
for _, inst := range dest.Instances {
instDir := cache.InstanceDir(inst)
if err := linkDirectoryTreeWithOverwrite(instDir, wsDir); err != nil {
return err
}
}
if err := fixSymlinksAfterRestoration(ctx, wsDir, cache); err != nil && err != errBrokenSymlink {
return err
}
return nil
}()
if restoreErr != nil {
log.Warningf(ctx, "Failed to restore destination %s from cache to %s: %v", rel, wsDir, restoreErr)
os.RemoveAll(wsDir)
} else {
continue
}
}
// Version roll or missing cache entry: remove stale stamp and queue for CIPD download
stamp.Remove()
for _, pkg := range pkgsByRel[rel] {
fetchPkgs[pkg.Key()] = pkg
}
}
return fetchPkgs, nil
}
// nextCIPDPkgIndex calculates the next available integer index for CIPD package metadata
// directories in `.cipd/pkgs/`. CIPD stores installed package metadata in consecutively
// numbered directories ("0", "1", "2", ...).
func nextCIPDPkgIndex(cipdPkgsDir string) int {
pkgIndex := 0
if existing, err := os.ReadDir(cipdPkgsDir); err == nil {
for _, e := range existing {
if idx, err := strconv.Atoi(e.Name()); err == nil && idx >= pkgIndex {
pkgIndex = idx + 1
}
}
}
return pkgIndex
}
// adoptCIPDPackages scans a source CIPD package directory (such as from a legacy
// package-group cache or a standalone `.cipd` directory) and adopts its packages into
// the central package cache `instances/<instance_id>` and `.cipd/pkgs/<index>`.
// By deduplicating on instanceID, each instance gets a single clean slot in `.cipd/pkgs`,
// completely avoiding duplicate directory warnings and eliminating the ~3 minute verification overhead.
func adoptCIPDPackages(ctx context.Context, sourceCipdPkgsDir, instancesDir, cipdPkgsDir string, pkgIndex int, adoptedInstances map[string]bool) (int, bool) {
allSuccess := true
pkgEntries, err := os.ReadDir(sourceCipdPkgsDir)
if err != nil {
return pkgIndex, false
}
type cipdPkgInfo struct {
pDir string
packageName string
subdir string
cleanSubdir string
}
var validPkgs []cipdPkgInfo
subdirCounts := make(map[string]int)
// Parse description.json once and count how many packages share each destination subdirectory.
// When multiple packages share the same destination (or point to root "."), the deployed files
// on disk are merged together.
for _, pEntry := range pkgEntries {
if !pEntry.IsDir() {
continue
}
pDir := filepath.Join(sourceCipdPkgsDir, pEntry.Name())
descBytes, err := os.ReadFile(filepath.Join(pDir, "description.json"))
if err != nil {
allSuccess = false
continue
}
var desc struct {
PackageName string `json:"package_name"`
Subdir string `json:"subdir"`
}
if err := json.Unmarshal(descBytes, &desc); err != nil || desc.PackageName == "" {
allSuccess = false
continue
}
cleanSubdir := filepath.Clean(desc.Subdir)
subdirCounts[cleanSubdir]++
validPkgs = append(validPkgs, cipdPkgInfo{
pDir: pDir,
packageName: desc.PackageName,
subdir: desc.Subdir,
cleanSubdir: cleanSubdir,
})
}
sourceRootDir := filepath.Dir(filepath.Dir(sourceCipdPkgsDir))
for _, pkg := range validPkgs {
pDir := pkg.pDir
cleanSubdir := pkg.cleanSubdir
instEntries, _ := os.ReadDir(pDir)
for _, instEntry := range instEntries {
instID := instEntry.Name()
if len(instID) < 20 || !instEntry.IsDir() {
continue
}
// Deduplicate: if this exact instance ID has already been adopted into a slot, skip creating a duplicate slot.
if adoptedInstances[instID] {
continue
}
srcInstDir := filepath.Join(pDir, instID)
targetInstDir := filepath.Join(instancesDir, instID)
// If the destination subdirectory is shared by multiple packages, encloses a nested package
// destination, or points to the root directory ".", skip local adoption entirely. Adopting
// from a shared, parent, or root directory would contaminate the instance cache with foreign
// files from other packages or the workspace. CIPD will download clean, isolated instances
// during cache sync.
hasNestedSubdir := false
for sub := range subdirCounts {
if isNestedUnder(sub, cleanSubdir) {
hasNestedSubdir = true
break
}
}
if subdirCounts[cleanSubdir] > 1 || hasNestedSubdir || cleanSubdir == "." {
log.Debugf(ctx, "Skipping standalone adoption for %s (shares or encloses destination %s with multiple packages); CIPD will install cleanly", pkg.packageName, pkg.subdir)
continue
}
// Single-package destination: adopt from the deployed subdirectory, falling back to srcInstDir.
actualSourceDir := filepath.Join(sourceRootDir, pkg.subdir)
if fi, err := os.Stat(actualSourceDir); err == nil && fi.IsDir() {
if _, err := os.Stat(targetInstDir); os.IsNotExist(err) {
if err := linkDirectoryTree(actualSourceDir, targetInstDir, ".cipd"); err != nil {
log.Warningf(ctx, "Skipping adoption for %s: %v", instID, err)
allSuccess = false
continue
}
_ = os.Remove(filepath.Join(targetInstDir, ".jiri_cache_hash"))
}
} else if _, err := os.Stat(targetInstDir); os.IsNotExist(err) {
// Fall back to linking from the CIPD package slot directory if the deployed
// package directory does not exist, preserving any available package metadata.
if err := linkDirectoryTree(srcInstDir, targetInstDir, ".cipdpkg"); err != nil {
log.Warningf(ctx, "Skipping adoption for %s: %v", instID, err)
allSuccess = false
continue
}
}
targetPkgDir := filepath.Join(cipdPkgsDir, strconv.Itoa(pkgIndex))
if _, err := os.Stat(targetPkgDir); os.IsNotExist(err) {
if err := os.MkdirAll(targetPkgDir, 0755); err != nil {
log.Warningf(ctx, "Failed to create metadata dir for %s: %v", instID, err)
allSuccess = false
continue
}
newDesc := fmt.Sprintf("{\n \"subdir\": \"instances/%s\",\n \"package_name\": %q\n}\n", instID, pkg.packageName)
if err := os.WriteFile(filepath.Join(targetPkgDir, "description.json"), []byte(newDesc), 0644); err != nil {
log.Warningf(ctx, "Failed to write description.json for %s: %v", instID, err)
allSuccess = false
continue
}
if err := os.WriteFile(filepath.Join(targetPkgDir, ".lock"), nil, 0644); err != nil {
log.Warningf(ctx, "Failed to write .lock for %s: %v", instID, err)
allSuccess = false
continue
}
if err := os.Symlink(instID, filepath.Join(targetPkgDir, "_current")); err != nil {
log.Warningf(ctx, "Failed to symlink _current for %s: %v", instID, err)
allSuccess = false
continue
}
// Copy only the small .cipdpkg metadata manifest into targetPkgDir/<instID>/.cipdpkg/
srcCipdPkg := filepath.Join(srcInstDir, ".cipdpkg")
if _, err := os.Stat(srcCipdPkg); err == nil {
targetCipdPkg := filepath.Join(targetPkgDir, instID, ".cipdpkg")
_ = os.MkdirAll(targetCipdPkg, 0755)
_ = copyTreeWithHardlinks(srcCipdPkg, targetCipdPkg)
}
}
adoptedInstances[instID] = true
pkgIndex++
}
}
return pkgIndex, allSuccess
}
func isNestedUnder(child, parent string) bool {
rel, err := filepath.Rel(parent, child)
return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// isHex checks whether a string contains only valid hexadecimal characters.
// It is used to identify legacy 64-character SHA-256 package group directories.
func isHex(s string) bool {
for i := 0; i < len(s); i++ {
b := s[i]
if !(b >= '0' && b <= '9' || b >= 'a' && b <= 'f' || b >= 'A' && b <= 'F') {
return false
}
}
return true
}