@@ -240,6 +240,8 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
240240 testExportLocalForcePlatformSplit ,
241241 testExportLocalModeCopyKeepsStaleDestinationFiles ,
242242 testExportLocalModeDeleteRemovesStaleDestinationFiles ,
243+ testExportLocalModeCopyMultiPlatformKeepsAllPlatforms ,
244+ testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms ,
243245 testExportLocalModeInvalid ,
244246 testSolverOptLocalDirsStillWorks ,
245247 testOCIIndexMediatype ,
@@ -7706,6 +7708,132 @@ func testExportLocalModeDeleteRemovesStaleDestinationFiles(t *testing.T, sb inte
77067708 require .ErrorIs (t , err , os .ErrNotExist )
77077709}
77087710
7711+ func testExportLocalModeCopyMultiPlatformKeepsAllPlatforms (t * testing.T , sb integration.Sandbox ) {
7712+ testExportLocalModeMultiPlatformKeepsAllPlatforms (t , sb , false )
7713+ }
7714+
7715+ func testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms (t * testing.T , sb integration.Sandbox ) {
7716+ testExportLocalModeMultiPlatformKeepsAllPlatforms (t , sb , true )
7717+ }
7718+
7719+ func testExportLocalModeMultiPlatformKeepsAllPlatforms (t * testing.T , sb integration.Sandbox , deleteMode bool ) {
7720+ workers .CheckFeatureCompat (t , sb , workers .FeatureOCIExporter , workers .FeatureMultiPlatform )
7721+ c , err := New (sb .Context (), sb .Address ())
7722+ require .NoError (t , err )
7723+ defer c .Close ()
7724+
7725+ const filesPerPlatform = 20
7726+ platformsToTest := []string {"linux/amd64" , "linux/arm64" , "linux/arm/v7" , "linux/s390x" }
7727+
7728+ frontend := func (ctx context.Context , c gateway.Client ) (* gateway.Result , error ) {
7729+ res := gateway .NewResult ()
7730+ expPlatforms := & exptypes.Platforms {
7731+ Platforms : make ([]exptypes.Platform , len (platformsToTest )),
7732+ }
7733+ for i , platform := range platformsToTest {
7734+ st := llb .Scratch ()
7735+ for j := range filesPerPlatform {
7736+ st = st .File (
7737+ llb .Mkfile (fmt .Sprintf ("file-%03d.txt" , j ), 0600 , fmt .Appendf (nil , "%s-%d" , platform , j )),
7738+ )
7739+ }
7740+
7741+ def , err := st .Marshal (ctx )
7742+ if err != nil {
7743+ return nil , err
7744+ }
7745+
7746+ r , err := c .Solve (ctx , gateway.SolveRequest {
7747+ Definition : def .ToPB (),
7748+ })
7749+ if err != nil {
7750+ return nil , err
7751+ }
7752+
7753+ ref , err := r .SingleRef ()
7754+ if err != nil {
7755+ return nil , err
7756+ }
7757+
7758+ _ , err = ref .ToState ()
7759+ if err != nil {
7760+ return nil , err
7761+ }
7762+ res .AddRef (platform , ref )
7763+
7764+ expPlatforms .Platforms [i ] = exptypes.Platform {
7765+ ID : platform ,
7766+ Platform : platforms .MustParse (platform ),
7767+ }
7768+ }
7769+ dt , err := json .Marshal (expPlatforms )
7770+ if err != nil {
7771+ return nil , err
7772+ }
7773+ res .AddMeta (exptypes .ExporterPlatformsKey , dt )
7774+
7775+ return res , nil
7776+ }
7777+
7778+ destDir := t .TempDir ()
7779+
7780+ // Pre-populate dest with directories matching the platform-split naming
7781+ // convention. This is critical for exposing the race in delete mode:
7782+ // each platform's fsutil.Receive does a dest-walk at the start and, with
7783+ // Merge=false (mode=delete), flags everything not in its own stream for
7784+ // deletion — including other platforms' pre-existing directories. Without
7785+ // pre-population the dest starts empty and the concurrent dest-walks all
7786+ // see nothing to delete, hiding the cross-platform deletion race.
7787+ err = os .WriteFile (filepath .Join (destDir , "stale.txt" ), []byte ("stale" ), 0600 )
7788+ require .NoError (t , err )
7789+ for _ , platform := range platformsToTest {
7790+ platDir := filepath .Join (destDir , strings .ReplaceAll (platform , "/" , "_" ))
7791+ err = os .MkdirAll (platDir , 0755 )
7792+ require .NoError (t , err )
7793+ for j := range filesPerPlatform {
7794+ err = os .WriteFile (filepath .Join (platDir , fmt .Sprintf ("old-%03d.txt" , j )), []byte ("old" ), 0600 )
7795+ require .NoError (t , err )
7796+ }
7797+ }
7798+
7799+ attrs := map [string ]string {}
7800+ if deleteMode {
7801+ attrs ["mode" ] = "delete"
7802+ }
7803+
7804+ _ , err = c .Build (sb .Context (), SolveOpt {
7805+ Exports : []ExportEntry {
7806+ {
7807+ Type : ExporterLocal ,
7808+ OutputDir : destDir ,
7809+ Attrs : attrs ,
7810+ },
7811+ },
7812+ }, "" , frontend , nil )
7813+ require .NoError (t , err )
7814+
7815+ if deleteMode {
7816+ // Stale top-level file must be gone.
7817+ _ , err = os .Stat (filepath .Join (destDir , "stale.txt" ))
7818+ require .ErrorIs (t , err , os .ErrNotExist )
7819+ }
7820+
7821+ // Every platform's build output must survive (no cross-platform deletion).
7822+ for _ , platform := range platformsToTest {
7823+ platformDir := filepath .Join (destDir , strings .ReplaceAll (platform , "/" , "_" ))
7824+ for j := range filesPerPlatform {
7825+ dt , err := os .ReadFile (filepath .Join (platformDir , fmt .Sprintf ("file-%03d.txt" , j )))
7826+ require .NoError (t , err , "missing build output file-%03d.txt for %s" , j , platform )
7827+ require .Equal (t , fmt .Sprintf ("%s-%d" , platform , j ), string (dt ))
7828+ }
7829+ if deleteMode {
7830+ // Pre-existing files within each platform dir must be cleaned up.
7831+ _ , err = os .Stat (filepath .Join (platformDir , "old-000.txt" ))
7832+ require .ErrorIs (t , err , os .ErrNotExist , "stale file not removed for %s" , platform )
7833+ }
7834+ }
7835+ }
7836+
77097837func testExportLocalModeInvalid (t * testing.T , sb integration.Sandbox ) {
77107838 c , err := New (sb .Context (), sb .Address ())
77117839 require .NoError (t , err )
0 commit comments