Skip to content
Merged
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
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,9 @@ test-linux: ensure-ch-binaries ensure-caddy-binaries build-embedded
if [ -n "$(VERBOSE)" ]; then VERBOSE_FLAG="-v"; fi; \
if [ -n "$(TEST)" ]; then \
echo "Running specific test: $(TEST)"; \
sudo env "PATH=$$PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" go test -tags containers_image_openpgp -run=$(TEST) $$VERBOSE_FLAG -timeout=180s ./...; \
sudo env "PATH=$$PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" go test -tags containers_image_openpgp -run=$(TEST) $$VERBOSE_FLAG -timeout=300s ./...; \
else \
sudo env "PATH=$$PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" go test -tags containers_image_openpgp $$VERBOSE_FLAG -timeout=180s ./...; \
sudo env "PATH=$$PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" go test -tags containers_image_openpgp $$VERBOSE_FLAG -timeout=300s ./...; \
fi

# macOS tests (no sudo needed, adds e2fsprogs to PATH)
Expand All @@ -246,10 +246,10 @@ test-darwin: build-embedded
if [ -n "$(TEST)" ]; then \
echo "Running specific test: $(TEST)"; \
PATH="/opt/homebrew/opt/e2fsprogs/sbin:$(PATH)" \
go test -tags containers_image_openpgp -run=$(TEST) $$VERBOSE_FLAG -timeout=180s $$PKGS; \
go test -tags containers_image_openpgp -run=$(TEST) $$VERBOSE_FLAG -timeout=300s $$PKGS; \
else \
PATH="/opt/homebrew/opt/e2fsprogs/sbin:$(PATH)" \
go test -tags containers_image_openpgp $$VERBOSE_FLAG -timeout=180s $$PKGS; \
go test -tags containers_image_openpgp $$VERBOSE_FLAG -timeout=300s $$PKGS; \
fi

# Generate JWT token for testing
Expand Down
12 changes: 6 additions & 6 deletions cmd/api/api/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,17 @@ func TestExecWithDebianMinimal(t *testing.T) {
// Create Debian 12 slim image (minimal, no iproute2)
createAndWaitForImage(t, svc, "docker.io/library/debian:12-slim", 60*time.Second)

// Create instance (network disabled in test environment)
// Create instance with a long-running command so the VM stays alive for exec.
// Debian's default CMD is "bash" which exits immediately (no stdin),
// and init shuts down the VM after the entrypoint exits.
t.Log("Creating Debian instance...")
networkEnabled := false
cmdOverride := []string{"sleep", "infinity"}
instResp, err := svc.CreateInstance(ctx(), oapi.CreateInstanceRequestObject{
Body: &oapi.CreateInstanceRequest{
Name: "debian-exec-test",
Image: "docker.io/library/debian:12-slim",
Cmd: &cmdOverride,
Network: &struct {
BandwidthDownload *string `json:"bandwidth_download,omitempty"`
BandwidthUpload *string `json:"bandwidth_upload,omitempty"`
Expand Down Expand Up @@ -238,11 +242,7 @@ func TestExecWithDebianMinimal(t *testing.T) {
}
}

// Verify the app exited but VM is still usable (key behavior this test validates)
logs = collectTestLogs(t, svc, inst.Id, 200)
assert.Contains(t, logs, "[exec] app exited with code", "App should have exited")

// Test exec commands work even though the main app (bash) has exited
// Test exec commands work while the app is running
dialer2, err := hypervisor.NewVsockDialer(actualInst.HypervisorType, actualInst.VsockSocket, actualInst.VsockCID)
require.NoError(t, err)

Expand Down
30 changes: 29 additions & 1 deletion cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,16 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
}
}

// Parse command overrides (like docker run --entrypoint / docker run <image> <command>)
var entrypoint []string
if request.Body.Entrypoint != nil {
entrypoint = *request.Body.Entrypoint
}
var cmd []string
if request.Body.Cmd != nil {
cmd = *request.Body.Cmd
}

domainReq := instances.CreateInstanceRequest{
Name: request.Body.Name,
Image: request.Body.Image,
Expand All @@ -236,6 +246,8 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
Volumes: volumes,
Hypervisor: hvType,
GPU: gpuConfig,
Entrypoint: entrypoint,
Cmd: cmd,
SkipKernelHeaders: request.Body.SkipKernelHeaders != nil && *request.Body.SkipKernelHeaders,
SkipGuestAgent: request.Body.SkipGuestAgent != nil && *request.Body.SkipGuestAgent,
}
Expand Down Expand Up @@ -467,7 +479,18 @@ func (s *ApiService) StartInstance(ctx context.Context, request oapi.StartInstan
}
log := logger.FromContext(ctx)

result, err := s.InstanceManager.StartInstance(ctx, inst.Id)
// Parse optional command overrides from request body
var startReq instances.StartInstanceRequest
if request.Body != nil {
if request.Body.Entrypoint != nil {
startReq.Entrypoint = *request.Body.Entrypoint
}
if request.Body.Cmd != nil {
startReq.Cmd = *request.Body.Cmd
}
}

result, err := s.InstanceManager.StartInstance(ctx, inst.Id, startReq)
if err != nil {
switch {
case errors.Is(err, instances.ErrInvalidState):
Expand Down Expand Up @@ -732,10 +755,15 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance {
CreatedAt: inst.CreatedAt,
StartedAt: inst.StartedAt,
StoppedAt: inst.StoppedAt,
ExitCode: inst.ExitCode,
HasSnapshot: lo.ToPtr(inst.HasSnapshot),
Hypervisor: &hvType,
}

if inst.ExitMessage != "" {
oapiInst.ExitMessage = lo.ToPtr(inst.ExitMessage)
}

if len(inst.Env) > 0 {
oapiInst.Env = &inst.Env
}
Expand Down
2 changes: 1 addition & 1 deletion lib/builds/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (m *mockInstanceManager) StopInstance(ctx context.Context, id string) (*ins
return nil, instances.ErrNotFound
}

func (m *mockInstanceManager) StartInstance(ctx context.Context, id string) (*instances.Instance, error) {
func (m *mockInstanceManager) StartInstance(ctx context.Context, id string, req instances.StartInstanceRequest) (*instances.Instance, error) {
return nil, nil
}

Expand Down
20 changes: 20 additions & 0 deletions lib/guest/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -658,3 +658,23 @@ func CopyFromInstance(ctx context.Context, dialer hypervisor.VsockDialer, opts C
}
return nil
}

// ShutdownInstance sends a shutdown signal to the guest VM's init process (PID 1).
// The guest-agent forwards the signal to init, which forwards it to the entrypoint.
// sig is the signal number to send (0 = SIGTERM default).
func ShutdownInstance(ctx context.Context, dialer hypervisor.VsockDialer, sig int32) error {
grpcConn, err := GetOrCreateConn(ctx, dialer)
if err != nil {
return fmt.Errorf("get grpc connection: %w", err)
}

client := NewGuestServiceClient(grpcConn)
_, err = client.Shutdown(ctx, &ShutdownRequest{
Signal: sig,
})
if err != nil {
return fmt.Errorf("shutdown RPC: %w", err)
}

return nil
}
114 changes: 102 additions & 12 deletions lib/guest/guest.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions lib/guest/guest.proto
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ service GuestService {

// StatPath returns information about a path in the guest filesystem
rpc StatPath(StatPathRequest) returns (StatPathResponse);

// Shutdown requests graceful VM shutdown by signaling init (PID 1)
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
}

// ExecRequest represents messages from client to server
Expand Down Expand Up @@ -143,3 +146,11 @@ message StatPathResponse {
int64 size = 7; // File size
string error = 8; // Error message if stat failed (e.g., permission denied)
}

// ShutdownRequest requests graceful VM shutdown
message ShutdownRequest {
int32 signal = 1; // Signal to send to init (PID 1), 0 = SIGTERM (default)
}

// ShutdownResponse acknowledges the shutdown request
message ShutdownResponse {}
Loading