Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions cmd/api/api/auto_standby.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package api

import (
"fmt"

"github.com/kernel/hypeman/lib/autostandby"
"github.com/kernel/hypeman/lib/oapi"
"github.com/samber/lo"
)

func toDomainAutoStandbyPolicy(policy *oapi.AutoStandbyPolicy) (*autostandby.Policy, error) {
if policy == nil {
return nil, nil
}

out := &autostandby.Policy{}
if policy.Enabled != nil {
out.Enabled = *policy.Enabled
}
if policy.IdleTimeout != nil {
out.IdleTimeout = *policy.IdleTimeout
}
if policy.IgnoreSourceCidrs != nil {
out.IgnoreSourceCIDRs = append([]string(nil), (*policy.IgnoreSourceCidrs)...)
}
if policy.IgnoreDestinationPorts != nil {
out.IgnoreDestinationPorts = make([]uint16, 0, len(*policy.IgnoreDestinationPorts))
for _, port := range *policy.IgnoreDestinationPorts {
if port < 1 || port > 65535 {
return nil, fmt.Errorf("auto_standby.ignore_destination_ports must be between 1 and 65535")
}
out.IgnoreDestinationPorts = append(out.IgnoreDestinationPorts, uint16(port))
}
}

return out, nil
}

func toOAPIAutoStandbyPolicy(policy *autostandby.Policy) *oapi.AutoStandbyPolicy {
if policy == nil {
return nil
}

out := &oapi.AutoStandbyPolicy{
Enabled: lo.ToPtr(policy.Enabled),
}
if policy.IdleTimeout != "" {
out.IdleTimeout = lo.ToPtr(policy.IdleTimeout)
}
if len(policy.IgnoreSourceCIDRs) > 0 {
out.IgnoreSourceCidrs = lo.ToPtr(append([]string(nil), policy.IgnoreSourceCIDRs...))
}
if len(policy.IgnoreDestinationPorts) > 0 {
ports := make([]int, 0, len(policy.IgnoreDestinationPorts))
for _, port := range policy.IgnoreDestinationPorts {
ports = append(ports, int(port))
}
out.IgnoreDestinationPorts = &ports
}

return out
}
19 changes: 18 additions & 1 deletion cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
if request.Body.Cmd != nil {
cmd = *request.Body.Cmd
}
autoStandby, err := toDomainAutoStandbyPolicy(request.Body.AutoStandby)
if err != nil {
return oapi.CreateInstance400JSONResponse{
Code: "invalid_auto_standby",
Message: err.Error(),
}, nil
}

domainReq := instances.CreateInstanceRequest{
Name: request.Body.Name,
Expand All @@ -302,6 +309,7 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
Cmd: cmd,
SkipKernelHeaders: request.Body.SkipKernelHeaders != nil && *request.Body.SkipKernelHeaders,
SkipGuestAgent: request.Body.SkipGuestAgent != nil && *request.Body.SkipGuestAgent,
AutoStandby: autoStandby,
}
if request.Body.SnapshotPolicy != nil {
snapshotPolicy, err := toInstanceSnapshotPolicy(*request.Body.SnapshotPolicy)
Expand Down Expand Up @@ -924,9 +932,17 @@ func (s *ApiService) UpdateInstance(ctx context.Context, request oapi.UpdateInst
if request.Body.Env != nil {
env = *request.Body.Env
}
autoStandby, err := toDomainAutoStandbyPolicy(request.Body.AutoStandby)
if err != nil {
return oapi.UpdateInstance400JSONResponse{
Code: "invalid_auto_standby",
Message: err.Error(),
}, nil
}

result, err := s.InstanceManager.UpdateInstance(ctx, inst.Id, instances.UpdateInstanceRequest{
Env: env,
Env: env,
AutoStandby: autoStandby,
})
if err != nil {
switch {
Expand Down Expand Up @@ -1057,6 +1073,7 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance {
oapiPolicy := toOAPISnapshotPolicy(*inst.SnapshotPolicy)
oapiInst.SnapshotPolicy = &oapiPolicy
}
oapiInst.AutoStandby = toOAPIAutoStandbyPolicy(inst.AutoStandby)

// Convert volume attachments
if len(inst.Volumes) > 0 {
Expand Down
154 changes: 154 additions & 0 deletions cmd/api/api/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/c2h5oh/datasize"
"github.com/kernel/hypeman/lib/autostandby"
"github.com/kernel/hypeman/lib/hypervisor"
"github.com/kernel/hypeman/lib/instances"
mw "github.com/kernel/hypeman/lib/middleware"
Expand Down Expand Up @@ -276,6 +277,7 @@ func (m *captureUpdateManager) UpdateInstance(ctx context.Context, id string, re
Name: "updated-instance",
Image: "docker.io/library/alpine:latest",
Env: req.Env,
AutoStandby: req.AutoStandby,
CreatedAt: now,
HypervisorType: hypervisor.TypeCloudHypervisor,
},
Expand All @@ -297,6 +299,7 @@ func (m *captureCreateManager) CreateInstance(ctx context.Context, req instances
HotplugSize: req.HotplugSize,
OverlaySize: req.OverlaySize,
Vcpus: req.Vcpus,
AutoStandby: req.AutoStandby,
CreatedAt: now,
HypervisorType: hypervisor.TypeCloudHypervisor,
},
Expand Down Expand Up @@ -477,6 +480,49 @@ func TestCreateInstance_MapsNetworkEgressEnforcementMode(t *testing.T) {
assert.Equal(t, instances.EgressEnforcementModeHTTPHTTPSOnly, mockMgr.lastReq.NetworkEgress.EnforcementMode)
}

func TestCreateInstance_MapsAutoStandbyPolicy(t *testing.T) {
t.Parallel()
svc := newTestService(t)

origMgr := svc.InstanceManager
mockMgr := &captureCreateManager{Manager: origMgr}
svc.InstanceManager = mockMgr

enabled := true
idleTimeout := "5m"
ignoreSourceCidrs := []string{"10.0.0.0/8", "192.168.0.0/16"}
ignoreDestinationPorts := []int{22, 9000}

resp, err := svc.CreateInstance(ctx(), oapi.CreateInstanceRequestObject{
Body: &oapi.CreateInstanceRequest{
Name: "test-auto-standby",
Image: "docker.io/library/alpine:latest",
AutoStandby: &oapi.AutoStandbyPolicy{
Enabled: &enabled,
IdleTimeout: &idleTimeout,
IgnoreSourceCidrs: &ignoreSourceCidrs,
IgnoreDestinationPorts: &ignoreDestinationPorts,
},
},
})
require.NoError(t, err)

created, ok := resp.(oapi.CreateInstance201JSONResponse)
require.True(t, ok, "expected 201 response")
require.NotNil(t, mockMgr.lastReq)
require.NotNil(t, mockMgr.lastReq.AutoStandby)
assert.True(t, mockMgr.lastReq.AutoStandby.Enabled)
assert.Equal(t, "5m", mockMgr.lastReq.AutoStandby.IdleTimeout)
assert.Equal(t, []string{"10.0.0.0/8", "192.168.0.0/16"}, mockMgr.lastReq.AutoStandby.IgnoreSourceCIDRs)
assert.Equal(t, []uint16{22, 9000}, mockMgr.lastReq.AutoStandby.IgnoreDestinationPorts)

instance := oapi.Instance(created)
require.NotNil(t, instance.AutoStandby)
require.NotNil(t, instance.AutoStandby.Enabled)
assert.True(t, *instance.AutoStandby.Enabled)
assert.Equal(t, idleTimeout, *instance.AutoStandby.IdleTimeout)
}

func TestUpdateInstance_MapsEnvPatch(t *testing.T) {
t.Parallel()
svc := newTestService(t)
Expand Down Expand Up @@ -524,6 +570,114 @@ func TestUpdateInstance_MapsEnvPatch(t *testing.T) {
assert.Equal(t, "rotated-key-456", mockMgr.lastReq.Env["OUTBOUND_OPENAI_KEY"])
}

func TestUpdateInstance_MapsAutoStandbyPatch(t *testing.T) {
t.Parallel()
svc := newTestService(t)

origMgr := svc.InstanceManager
now := time.Now()
mockMgr := &captureUpdateManager{
Manager: origMgr,
result: &instances.Instance{
StoredMetadata: instances.StoredMetadata{
Id: "inst-update-auto-standby",
Name: "inst-update-auto-standby",
Image: "docker.io/library/alpine:latest",
CreatedAt: now,
HypervisorType: hypervisor.TypeCloudHypervisor,
AutoStandby: &autostandby.Policy{
Enabled: true,
IdleTimeout: "10m0s",
},
},
State: instances.StateStopped,
},
}
svc.InstanceManager = mockMgr

enabled := true
idleTimeout := "10m"
ignoreDestinationPorts := []int{22}
resolved := &instances.Instance{
StoredMetadata: instances.StoredMetadata{
Id: "inst-update-auto-standby",
Name: "inst-update-auto-standby",
Image: "docker.io/library/alpine:latest",
CreatedAt: now,
HypervisorType: hypervisor.TypeCloudHypervisor,
},
State: instances.StateStopped,
}

resp, err := svc.UpdateInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.UpdateInstanceRequestObject{
Id: resolved.Id,
Body: &oapi.UpdateInstanceRequest{
AutoStandby: &oapi.AutoStandbyPolicy{
Enabled: &enabled,
IdleTimeout: &idleTimeout,
IgnoreDestinationPorts: &ignoreDestinationPorts,
},
},
})
require.NoError(t, err)
updated, ok := resp.(oapi.UpdateInstance200JSONResponse)
require.True(t, ok, "expected 200 response")

require.NotNil(t, mockMgr.lastReq)
require.NotNil(t, mockMgr.lastReq.AutoStandby)
assert.Equal(t, resolved.Id, mockMgr.lastID)
assert.True(t, mockMgr.lastReq.AutoStandby.Enabled)
assert.Equal(t, "10m", mockMgr.lastReq.AutoStandby.IdleTimeout)
assert.Equal(t, []uint16{22}, mockMgr.lastReq.AutoStandby.IgnoreDestinationPorts)

instance := oapi.Instance(updated)
require.NotNil(t, instance.AutoStandby)
require.NotNil(t, instance.AutoStandby.Enabled)
assert.True(t, *instance.AutoStandby.Enabled)
}

func TestUpdateInstance_RejectsZeroAutoStandbyIgnoreDestinationPort(t *testing.T) {
t.Parallel()
svc := newTestService(t)

origMgr := svc.InstanceManager
now := time.Now()
mockMgr := &captureUpdateManager{Manager: origMgr}
svc.InstanceManager = mockMgr

resolved := &instances.Instance{
StoredMetadata: instances.StoredMetadata{
Id: "inst-update-auto-standby",
Name: "inst-update-auto-standby",
Image: "docker.io/library/alpine:latest",
CreatedAt: now,
HypervisorType: hypervisor.TypeCloudHypervisor,
},
State: instances.StateStopped,
}
enabled := true
idleTimeout := "10m"
ignoreDestinationPorts := []int{0}

resp, err := svc.UpdateInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.UpdateInstanceRequestObject{
Id: resolved.Id,
Body: &oapi.UpdateInstanceRequest{
AutoStandby: &oapi.AutoStandbyPolicy{
Enabled: &enabled,
IdleTimeout: &idleTimeout,
IgnoreDestinationPorts: &ignoreDestinationPorts,
},
},
})
require.NoError(t, err)

badReq, ok := resp.(oapi.UpdateInstance400JSONResponse)
require.True(t, ok, "expected 400 response")
assert.Equal(t, "invalid_auto_standby", badReq.Code)
assert.Contains(t, badReq.Message, "between 1 and 65535")
assert.Nil(t, mockMgr.lastReq)
}

func TestUpdateInstance_RequiresBody(t *testing.T) {
t.Parallel()
svc := newTestService(t)
Expand Down
60 changes: 60 additions & 0 deletions cmd/api/auto_standby_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//go:build linux

package main

import (
"context"
"log/slog"
"time"

"github.com/kernel/hypeman/lib/autostandby"
"github.com/kernel/hypeman/lib/instances"
"golang.org/x/sync/errgroup"
)

type autoStandbyInstanceStore struct {
manager instances.Manager
}

func (s autoStandbyInstanceStore) ListInstances(ctx context.Context) ([]autostandby.Instance, error) {
insts, err := s.manager.ListInstances(ctx, nil)
if err != nil {
return nil, err
}

out := make([]autostandby.Instance, 0, len(insts))
for _, inst := range insts {
out = append(out, autostandby.Instance{
ID: inst.Id,
Name: inst.Name,
State: string(inst.State),
NetworkEnabled: inst.NetworkEnabled,
IP: inst.IP,
HasVGPU: inst.GPUProfile != "" || inst.GPUMdevUUID != "",
AutoStandby: inst.AutoStandby,
})
}
return out, nil
}

func (s autoStandbyInstanceStore) StandbyInstance(ctx context.Context, id string) error {
_, err := s.manager.StandbyInstance(ctx, id, instances.StandbyInstanceRequest{})
return err
}

func startAutoStandbyController(grp *errgroup.Group, ctx context.Context, logger *slog.Logger, manager instances.Manager) bool {
if grp == nil || ctx == nil || logger == nil || manager == nil {
return false
}

controller := autostandby.NewController(
autoStandbyInstanceStore{manager: manager},
autostandby.NewConntrackSource(),
logger.With("controller", "auto_standby"),
5*time.Second,
)
grp.Go(func() error {
return controller.Run(ctx)
})
return true
}
15 changes: 15 additions & 0 deletions cmd/api/auto_standby_unsupported.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build !linux

package main

import (
"context"
"log/slog"

"github.com/kernel/hypeman/lib/instances"
"golang.org/x/sync/errgroup"
)

func startAutoStandbyController(*errgroup.Group, context.Context, *slog.Logger, instances.Manager) bool {
return false
}
3 changes: 3 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@ func run() error {
logger.Info("starting guest memory controller")
return app.GuestMemoryController.Start(gctx)
})
if startAutoStandbyController(grp, gctx, logger, app.InstanceManager) {
logger.Info("auto-standby controller enabled")
}

// Run the server
grp.Go(func() error {
Expand Down
Loading
Loading