@@ -20,6 +20,7 @@ import (
20
20
rtmocks "github.com/stacklok/toolhive/pkg/container/runtime/mocks"
21
21
"github.com/stacklok/toolhive/pkg/core"
22
22
"github.com/stacklok/toolhive/pkg/logger"
23
+ "github.com/stacklok/toolhive/pkg/process"
23
24
stateMocks "github.com/stacklok/toolhive/pkg/state/mocks"
24
25
)
25
26
@@ -1682,3 +1683,232 @@ func TestFileStatusManager_ResetWorkloadPID_WithSlashes(t *testing.T) {
1682
1683
assert .Equal (t , "started" , statusFileData .StatusContext )
1683
1684
assert .Equal (t , 0 , statusFileData .ProcessID ) // PID should be reset to 0
1684
1685
}
1686
+
1687
+ // TestFileStatusManager_GetWorkload_PIDMigration tests PID migration from legacy PID files to status files
1688
+ func TestFileStatusManager_GetWorkload_PIDMigration (t * testing.T ) {
1689
+ t .Parallel ()
1690
+
1691
+ tests := []struct {
1692
+ name string
1693
+ setupPIDFile bool
1694
+ pidValue int
1695
+ workloadStatus rt.WorkloadStatus
1696
+ processID int
1697
+ expectMigration bool
1698
+ expectPIDFile bool // whether PID file should exist after operation
1699
+ }{
1700
+ {
1701
+ name : "migrates PID when status is running and ProcessID is 0" ,
1702
+ setupPIDFile : true ,
1703
+ pidValue : 12345 ,
1704
+ workloadStatus : rt .WorkloadStatusRunning ,
1705
+ processID : 0 ,
1706
+ expectMigration : true ,
1707
+ expectPIDFile : true , // PID file is NOT deleted (see TODO comment in migration code)
1708
+ },
1709
+ {
1710
+ name : "no migration when status is not running" ,
1711
+ setupPIDFile : true ,
1712
+ pidValue : 12345 ,
1713
+ workloadStatus : rt .WorkloadStatusStopped ,
1714
+ processID : 0 ,
1715
+ expectMigration : false ,
1716
+ expectPIDFile : true ,
1717
+ },
1718
+ {
1719
+ name : "no migration when ProcessID is not 0" ,
1720
+ setupPIDFile : true ,
1721
+ pidValue : 12345 ,
1722
+ workloadStatus : rt .WorkloadStatusRunning ,
1723
+ processID : 98765 ,
1724
+ expectMigration : false ,
1725
+ expectPIDFile : true ,
1726
+ },
1727
+ {
1728
+ name : "no migration when no PID file exists" ,
1729
+ setupPIDFile : false ,
1730
+ pidValue : 0 ,
1731
+ workloadStatus : rt .WorkloadStatusRunning ,
1732
+ processID : 0 ,
1733
+ expectMigration : false ,
1734
+ expectPIDFile : false ,
1735
+ },
1736
+ }
1737
+
1738
+ for _ , tt := range tests {
1739
+ t .Run (tt .name , func (t * testing.T ) {
1740
+ t .Parallel ()
1741
+
1742
+ ctrl := gomock .NewController (t )
1743
+ defer ctrl .Finish ()
1744
+
1745
+ manager , mockRuntime , mockRunConfigStore := newTestFileStatusManager (t , ctrl )
1746
+ ctx := context .Background ()
1747
+ workloadName := fmt .Sprintf ("test-workload-migration-%d" , time .Now ().UnixNano ()) // unique name to avoid locking conflicts
1748
+
1749
+ // Mock the run config store to return false for exists (not a remote workload)
1750
+ mockRunConfigStore .EXPECT ().Exists (gomock .Any (), workloadName ).Return (false , nil ).AnyTimes ()
1751
+ mockRunConfigStore .EXPECT ().GetReader (gomock .Any (), workloadName ).Return (nil , errors .New ("not found" )).AnyTimes ()
1752
+
1753
+ // Mock GetWorkloadInfo for runtime validation (when status is running after migration)
1754
+ if tt .workloadStatus == rt .WorkloadStatusRunning {
1755
+ // Mock the container info that would be returned during validation
1756
+ containerInfo := rt.ContainerInfo {
1757
+ Name : workloadName ,
1758
+ Image : "test-image:latest" ,
1759
+ Status : "running" ,
1760
+ State : rt .WorkloadStatusRunning ,
1761
+ Labels : make (map [string ]string ),
1762
+ }
1763
+ mockRuntime .EXPECT ().GetWorkloadInfo (gomock .Any (), workloadName ).Return (containerInfo , nil ).AnyTimes ()
1764
+ }
1765
+
1766
+ // Create status file with specified status and ProcessID
1767
+ err := manager .setWorkloadStatusInternal (ctx , workloadName , tt .workloadStatus , "test context" , & tt .processID )
1768
+ require .NoError (t , err )
1769
+
1770
+ // Setup PID file if needed
1771
+ var pidFilePath string
1772
+ if tt .setupPIDFile {
1773
+ // Create PID file using the process package
1774
+ err = process .WritePIDFile (workloadName , tt .pidValue )
1775
+ require .NoError (t , err )
1776
+
1777
+ // Get the path for cleanup verification
1778
+ pidFilePath , err = process .GetPIDFilePathWithFallback (workloadName )
1779
+ require .NoError (t , err )
1780
+ }
1781
+
1782
+ // Call GetWorkload which should trigger migration if conditions are met
1783
+ workload , err := manager .GetWorkload (ctx , workloadName )
1784
+ require .NoError (t , err )
1785
+
1786
+ // Verify workload properties
1787
+ assert .Equal (t , workloadName , workload .Name )
1788
+ assert .Equal (t , tt .workloadStatus , workload .Status )
1789
+
1790
+ if tt .expectMigration {
1791
+ // Read the status file to verify PID was migrated
1792
+ statusFilePath := manager .getStatusFilePath (workloadName )
1793
+ data , err := os .ReadFile (statusFilePath )
1794
+ require .NoError (t , err )
1795
+
1796
+ var statusFile workloadStatusFile
1797
+ err = json .Unmarshal (data , & statusFile )
1798
+ require .NoError (t , err )
1799
+
1800
+ assert .Equal (t , tt .pidValue , statusFile .ProcessID , "PID should be migrated to status file" )
1801
+ } else {
1802
+ // Read the status file to verify PID was NOT changed
1803
+ statusFilePath := manager .getStatusFilePath (workloadName )
1804
+ data , err := os .ReadFile (statusFilePath )
1805
+ require .NoError (t , err )
1806
+
1807
+ var statusFile workloadStatusFile
1808
+ err = json .Unmarshal (data , & statusFile )
1809
+ require .NoError (t , err )
1810
+
1811
+ assert .Equal (t , tt .processID , statusFile .ProcessID , "PID should remain unchanged" )
1812
+ }
1813
+
1814
+ // Verify PID file existence
1815
+ if tt .setupPIDFile {
1816
+ _ , err := os .Stat (pidFilePath )
1817
+ if tt .expectPIDFile {
1818
+ assert .NoError (t , err , "PID file should still exist" )
1819
+ } else {
1820
+ assert .True (t , os .IsNotExist (err ), "PID file should be deleted" )
1821
+ }
1822
+ }
1823
+
1824
+ // Cleanup
1825
+ if tt .setupPIDFile {
1826
+ _ = process .RemovePIDFile (workloadName )
1827
+ }
1828
+ })
1829
+ }
1830
+ }
1831
+
1832
+ // TestFileStatusManager_ListWorkloads_PIDMigration tests PID migration during list operations
1833
+ func TestFileStatusManager_ListWorkloads_PIDMigration (t * testing.T ) {
1834
+ t .Parallel ()
1835
+
1836
+ ctrl := gomock .NewController (t )
1837
+ defer ctrl .Finish ()
1838
+
1839
+ manager , mockRuntime , mockRunConfigStore := newTestFileStatusManager (t , ctrl )
1840
+ ctx := context .Background ()
1841
+
1842
+ // Mock runtime to return empty list (no running containers)
1843
+ mockRuntime .EXPECT ().ListWorkloads (gomock .Any ()).Return ([]rt.ContainerInfo {}, nil )
1844
+
1845
+ // Create two workloads: one that should migrate, one that shouldn't
1846
+ workloadMigrate := fmt .Sprintf ("workload-migrate-%d" , time .Now ().UnixNano ())
1847
+ workloadNoMigrate := fmt .Sprintf ("workload-no-migrate-%d" , time .Now ().UnixNano ())
1848
+
1849
+ // Mock the run config store for both workloads
1850
+ mockRunConfigStore .EXPECT ().Exists (gomock .Any (), workloadMigrate ).Return (false , nil ).AnyTimes ()
1851
+ mockRunConfigStore .EXPECT ().GetReader (gomock .Any (), workloadMigrate ).Return (nil , errors .New ("not found" )).AnyTimes ()
1852
+ mockRunConfigStore .EXPECT ().Exists (gomock .Any (), workloadNoMigrate ).Return (false , nil ).AnyTimes ()
1853
+ mockRunConfigStore .EXPECT ().GetReader (gomock .Any (), workloadNoMigrate ).Return (nil , errors .New ("not found" )).AnyTimes ()
1854
+
1855
+ // Setup workload that should trigger migration (running + ProcessID = 0)
1856
+ err := manager .setWorkloadStatusInternal (ctx , workloadMigrate , rt .WorkloadStatusRunning , "running" , & []int {0 }[0 ])
1857
+ require .NoError (t , err )
1858
+
1859
+ // Setup workload that shouldn't trigger migration (running + ProcessID != 0)
1860
+ existingPID := 54321
1861
+ err = manager .setWorkloadStatusInternal (ctx , workloadNoMigrate , rt .WorkloadStatusRunning , "running" , & existingPID )
1862
+ require .NoError (t , err )
1863
+
1864
+ // Create PID files for both workloads
1865
+ migrationPID := 12345
1866
+ err = process .WritePIDFile (workloadMigrate , migrationPID )
1867
+ require .NoError (t , err )
1868
+ defer process .RemovePIDFile (workloadMigrate )
1869
+
1870
+ err = process .WritePIDFile (workloadNoMigrate , 99999 )
1871
+ require .NoError (t , err )
1872
+ defer process .RemovePIDFile (workloadNoMigrate )
1873
+
1874
+ // Call ListWorkloads
1875
+ workloads , err := manager .ListWorkloads (ctx , true , nil )
1876
+ require .NoError (t , err )
1877
+
1878
+ // Should have 2 workloads
1879
+ require .Len (t , workloads , 2 )
1880
+
1881
+ // Find the workloads in results
1882
+ var migrateWorkload , noMigrateWorkload * core.Workload
1883
+ for i := range workloads {
1884
+ switch workloads [i ].Name {
1885
+ case workloadMigrate :
1886
+ migrateWorkload = & workloads [i ]
1887
+ case workloadNoMigrate :
1888
+ noMigrateWorkload = & workloads [i ]
1889
+ }
1890
+ }
1891
+
1892
+ require .NotNil (t , migrateWorkload , "should find workload that should migrate" )
1893
+ require .NotNil (t , noMigrateWorkload , "should find workload that should not migrate" )
1894
+
1895
+ // Verify migration occurred for first workload
1896
+ statusFilePath1 := manager .getStatusFilePath (workloadMigrate )
1897
+ data1 , err := os .ReadFile (statusFilePath1 )
1898
+ require .NoError (t , err )
1899
+
1900
+ var statusFile1 workloadStatusFile
1901
+ err = json .Unmarshal (data1 , & statusFile1 )
1902
+ require .NoError (t , err )
1903
+ assert .Equal (t , migrationPID , statusFile1 .ProcessID , "PID should be migrated for first workload" )
1904
+
1905
+ // Verify no migration for second workload
1906
+ statusFilePath2 := manager .getStatusFilePath (workloadNoMigrate )
1907
+ data2 , err := os .ReadFile (statusFilePath2 )
1908
+ require .NoError (t , err )
1909
+
1910
+ var statusFile2 workloadStatusFile
1911
+ err = json .Unmarshal (data2 , & statusFile2 )
1912
+ require .NoError (t , err )
1913
+ assert .Equal (t , existingPID , statusFile2 .ProcessID , "PID should remain unchanged for second workload" )
1914
+ }
0 commit comments