@@ -2,13 +2,15 @@ package api
22
33import (
44 "context"
5+ "fmt"
56 "os"
67 "testing"
78 "time"
89
910 "github.com/c2h5oh/datasize"
1011 "github.com/kernel/hypeman/lib/hypervisor"
1112 "github.com/kernel/hypeman/lib/instances"
13+ mw "github.com/kernel/hypeman/lib/middleware"
1214 "github.com/kernel/hypeman/lib/oapi"
1315 "github.com/kernel/hypeman/lib/paths"
1416 "github.com/kernel/hypeman/lib/system"
@@ -137,6 +139,24 @@ type captureCreateManager struct {
137139 lastReq * instances.CreateInstanceRequest
138140}
139141
142+ type captureForkManager struct {
143+ instances.Manager
144+ lastID string
145+ lastReq * instances.ForkInstanceRequest
146+ result * instances.Instance
147+ err error
148+ }
149+
150+ func (m * captureForkManager ) ForkInstance (ctx context.Context , id string , req instances.ForkInstanceRequest ) (* instances.Instance , error ) {
151+ reqCopy := req
152+ m .lastID = id
153+ m .lastReq = & reqCopy
154+ if m .err != nil {
155+ return nil , m .err
156+ }
157+ return m .result , nil
158+ }
159+
140160func (m * captureCreateManager ) CreateInstance (ctx context.Context , req instances.CreateInstanceRequest ) (* instances.Instance , error ) {
141161 reqCopy := req
142162 m .lastReq = & reqCopy
@@ -190,6 +210,185 @@ func TestCreateInstance_OmittedHotplugSizeDefaultsToZero(t *testing.T) {
190210 assert .Equal (t , int64 (0 ), int64 (hotplugBytes ), "response should report zero hotplug_size when omitted" )
191211}
192212
213+ func TestForkInstance_Success (t * testing.T ) {
214+ svc := newTestService (t )
215+
216+ now := time .Now ()
217+ source := instances.Instance {
218+ StoredMetadata : instances.StoredMetadata {
219+ Id : "src-instance" ,
220+ Name : "src-instance" ,
221+ Image : "docker.io/library/alpine:latest" ,
222+ CreatedAt : now ,
223+ HypervisorType : hypervisor .TypeCloudHypervisor ,
224+ },
225+ State : instances .StateStopped ,
226+ }
227+
228+ forked := & instances.Instance {
229+ StoredMetadata : instances.StoredMetadata {
230+ Id : "forked-instance" ,
231+ Name : "forked-instance" ,
232+ Image : "docker.io/library/alpine:latest" ,
233+ CreatedAt : now ,
234+ HypervisorType : hypervisor .TypeCloudHypervisor ,
235+ },
236+ State : instances .StateStopped ,
237+ }
238+
239+ mockMgr := & captureForkManager {
240+ Manager : svc .InstanceManager ,
241+ result : forked ,
242+ }
243+ svc .InstanceManager = mockMgr
244+
245+ resp , err := svc .ForkInstance (
246+ mw .WithResolvedInstance (ctx (), source .Id , source ),
247+ oapi.ForkInstanceRequestObject {
248+ Id : source .Id ,
249+ Body : & oapi.ForkInstanceRequest {
250+ Name : "forked-instance" ,
251+ },
252+ },
253+ )
254+ require .NoError (t , err )
255+
256+ created , ok := resp .(oapi.ForkInstance201JSONResponse )
257+ require .True (t , ok , "expected 201 response" )
258+ assert .Equal (t , "forked-instance" , created .Name )
259+ assert .Equal (t , source .Id , mockMgr .lastID )
260+ require .NotNil (t , mockMgr .lastReq )
261+ assert .Equal (t , "forked-instance" , mockMgr .lastReq .Name )
262+ assert .False (t , mockMgr .lastReq .FromRunning )
263+ assert .Equal (t , instances .State ("" ), mockMgr .lastReq .TargetState )
264+ }
265+
266+ func TestForkInstance_NotSupported (t * testing.T ) {
267+ svc := newTestService (t )
268+
269+ source := instances.Instance {
270+ StoredMetadata : instances.StoredMetadata {
271+ Id : "src-instance" ,
272+ Name : "src-instance" ,
273+ Image : "docker.io/library/alpine:latest" ,
274+ CreatedAt : time .Now (),
275+ HypervisorType : hypervisor .TypeQEMU ,
276+ },
277+ State : instances .StateStopped ,
278+ }
279+
280+ mockMgr := & captureForkManager {
281+ Manager : svc .InstanceManager ,
282+ err : instances .ErrNotSupported ,
283+ }
284+ svc .InstanceManager = mockMgr
285+
286+ resp , err := svc .ForkInstance (
287+ mw .WithResolvedInstance (ctx (), source .Id , source ),
288+ oapi.ForkInstanceRequestObject {
289+ Id : source .Id ,
290+ Body : & oapi.ForkInstanceRequest {
291+ Name : "forked-instance" ,
292+ },
293+ },
294+ )
295+ require .NoError (t , err )
296+
297+ notSupported , ok := resp .(oapi.ForkInstance501JSONResponse )
298+ require .True (t , ok , "expected 501 response" )
299+ assert .Equal (t , "not_supported" , notSupported .Code )
300+ }
301+
302+ func TestForkInstance_InvalidRequest (t * testing.T ) {
303+ svc := newTestService (t )
304+
305+ source := instances.Instance {
306+ StoredMetadata : instances.StoredMetadata {
307+ Id : "src-instance" ,
308+ Name : "src-instance" ,
309+ Image : "docker.io/library/alpine:latest" ,
310+ CreatedAt : time .Now (),
311+ HypervisorType : hypervisor .TypeCloudHypervisor ,
312+ },
313+ State : instances .StateStopped ,
314+ }
315+
316+ mockMgr := & captureForkManager {
317+ Manager : svc .InstanceManager ,
318+ err : fmt .Errorf ("%w: name is required" , instances .ErrInvalidRequest ),
319+ }
320+ svc .InstanceManager = mockMgr
321+
322+ resp , err := svc .ForkInstance (
323+ mw .WithResolvedInstance (ctx (), source .Id , source ),
324+ oapi.ForkInstanceRequestObject {
325+ Id : source .Id ,
326+ Body : & oapi.ForkInstanceRequest {
327+ Name : "" ,
328+ },
329+ },
330+ )
331+ require .NoError (t , err )
332+
333+ badReq , ok := resp .(oapi.ForkInstance400JSONResponse )
334+ require .True (t , ok , "expected 400 response" )
335+ assert .Equal (t , "invalid_request" , badReq .Code )
336+ }
337+
338+ func TestForkInstance_FromRunningFlagForwarded (t * testing.T ) {
339+ svc := newTestService (t )
340+
341+ now := time .Now ()
342+ source := instances.Instance {
343+ StoredMetadata : instances.StoredMetadata {
344+ Id : "src-instance" ,
345+ Name : "src-instance" ,
346+ Image : "docker.io/library/alpine:latest" ,
347+ CreatedAt : now ,
348+ HypervisorType : hypervisor .TypeCloudHypervisor ,
349+ },
350+ State : instances .StateRunning ,
351+ }
352+
353+ forked := & instances.Instance {
354+ StoredMetadata : instances.StoredMetadata {
355+ Id : "forked-instance" ,
356+ Name : "forked-instance" ,
357+ Image : "docker.io/library/alpine:latest" ,
358+ CreatedAt : now ,
359+ HypervisorType : hypervisor .TypeCloudHypervisor ,
360+ },
361+ State : instances .StateStandby ,
362+ }
363+
364+ mockMgr := & captureForkManager {
365+ Manager : svc .InstanceManager ,
366+ result : forked ,
367+ }
368+ svc .InstanceManager = mockMgr
369+
370+ fromRunning := true
371+ targetState := oapi .ForkTargetStateRunning
372+ resp , err := svc .ForkInstance (
373+ mw .WithResolvedInstance (ctx (), source .Id , source ),
374+ oapi.ForkInstanceRequestObject {
375+ Id : source .Id ,
376+ Body : & oapi.ForkInstanceRequest {
377+ Name : "forked-instance" ,
378+ FromRunning : & fromRunning ,
379+ TargetState : & targetState ,
380+ },
381+ },
382+ )
383+ require .NoError (t , err )
384+
385+ _ , ok := resp .(oapi.ForkInstance201JSONResponse )
386+ require .True (t , ok , "expected 201 response" )
387+ require .NotNil (t , mockMgr .lastReq )
388+ assert .True (t , mockMgr .lastReq .FromRunning )
389+ assert .Equal (t , instances .StateRunning , mockMgr .lastReq .TargetState )
390+ }
391+
193392func TestInstanceLifecycle_StopStart (t * testing.T ) {
194393 // Require KVM access for VM creation
195394 if _ , err := os .Stat ("/dev/kvm" ); os .IsNotExist (err ) {
0 commit comments