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
50 changes: 50 additions & 0 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,56 @@ func (s *ApiService) StartInstance(ctx context.Context, request oapi.StartInstan
return oapi.StartInstance200JSONResponse(instanceToOAPI(*result)), nil
}

// UpdateInstanceEnv updates environment variables on a running instance and refreshes
// egress proxy rules. Enables credential rotation without instance restart.
func (s *ApiService) UpdateInstanceEnv(ctx context.Context, request oapi.UpdateInstanceEnvRequestObject) (oapi.UpdateInstanceEnvResponseObject, error) {
inst := mw.GetResolvedInstance[instances.Instance](ctx)
if inst == nil {
return oapi.UpdateInstanceEnv500JSONResponse{
Code: "internal_error",
Message: "resource not resolved",
}, nil
}
log := logger.FromContext(ctx)

if request.Body == nil || request.Body.Env == nil {
return oapi.UpdateInstanceEnv400JSONResponse{
Code: "invalid_request",
Message: "request body with env is required",
}, nil
}

result, err := s.InstanceManager.UpdateInstanceEnv(ctx, inst.Id, instances.UpdateInstanceEnvRequest{
Env: request.Body.Env,
})
if err != nil {
switch {
case errors.Is(err, instances.ErrInvalidState):
return oapi.UpdateInstanceEnv409JSONResponse{
Code: "invalid_state",
Message: err.Error(),
}, nil
case errors.Is(err, instances.ErrInvalidRequest):
return oapi.UpdateInstanceEnv400JSONResponse{
Code: "invalid_request",
Message: err.Error(),
}, nil
case errors.Is(err, instances.ErrNotFound):
return oapi.UpdateInstanceEnv404JSONResponse{
Code: "not_found",
Message: "instance not found",
}, nil
default:
log.ErrorContext(ctx, "failed to update instance env", "error", err)
return oapi.UpdateInstanceEnv500JSONResponse{
Code: "internal_error",
Message: "failed to update instance env",
}, nil
}
}
return oapi.UpdateInstanceEnv200JSONResponse(instanceToOAPI(*result)), nil
}

// logsStreamResponse implements oapi.GetInstanceLogsResponseObject with proper SSE flushing
type logsStreamResponse struct {
logChan <-chan string
Expand Down
4 changes: 4 additions & 0 deletions lib/builds/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ func (m *mockInstanceManager) StartInstance(ctx context.Context, id string, req
return nil, nil
}

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

func (m *mockInstanceManager) StreamInstanceLogs(ctx context.Context, id string, tail int, follow bool, source instances.LogSource) (<-chan string, error) {
return nil, nil
}
Expand Down
16 changes: 16 additions & 0 deletions lib/instances/egress_proxy_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,22 @@ func TestEgressProxyRewritesHTTPSHeaders(t *testing.T) {
require.Equal(t, 0, blockedExitCode, "curl output: %s", blockedOutput)
require.Equal(t, "", blockedOutput)

// --- Secret rotation: update credential env and verify proxy injects new value ---
rotatedInst, err := manager.UpdateInstanceEnv(ctx, inst.Id, UpdateInstanceEnvRequest{
Env: map[string]string{
"OUTBOUND_OPENAI_KEY": "rotated-openai-key-456",
},
})
require.NoError(t, err)
require.Equal(t, StateRunning, rotatedInst.State)

// Verify the proxy now injects the rotated credential
rotatedOutput, rotatedExitCode, err := execCommand(ctx, inst, "sh", "-lc", allowedCmd)
require.NoError(t, err)
require.Equal(t, 0, rotatedExitCode, "curl output: %s", rotatedOutput)
require.Contains(t, rotatedOutput, "Bearer rotated-openai-key-456")
require.NotContains(t, rotatedOutput, "real-openai-key-123")

require.NoError(t, manager.DeleteInstance(ctx, inst.Id))
deleted = true
}
Expand Down
3 changes: 3 additions & 0 deletions lib/instances/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ type Manager interface {
RestoreSnapshot(ctx context.Context, id string, snapshotID string, req RestoreSnapshotRequest) (*Instance, error)
StopInstance(ctx context.Context, id string) (*Instance, error)
StartInstance(ctx context.Context, id string, req StartInstanceRequest) (*Instance, error)
// UpdateInstanceEnv merges new env vars into a running instance and re-registers
// egress proxy rules so that rotated credentials take effect immediately.
UpdateInstanceEnv(ctx context.Context, id string, req UpdateInstanceEnvRequest) (*Instance, error)
StreamInstanceLogs(ctx context.Context, id string, tail int, follow bool, source LogSource) (<-chan string, error)
RotateLogs(ctx context.Context, maxBytes int64, maxFiles int) error
AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error)
Expand Down
5 changes: 5 additions & 0 deletions lib/instances/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@ type ForkSnapshotRequest struct {
TargetHypervisor hypervisor.Type // Optional, allowed only for Stopped snapshots
}

// UpdateInstanceEnvRequest is the domain request for updating env vars on a running instance.
type UpdateInstanceEnvRequest struct {
Env map[string]string // Env vars to merge into the existing env
}

// AttachVolumeRequest is the domain request for attaching a volume (used for API compatibility)
type AttachVolumeRequest struct {
MountPath string
Expand Down
86 changes: 86 additions & 0 deletions lib/instances/update_env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package instances

import (
"context"
"fmt"

"github.com/kernel/hypeman/lib/network"
)

// UpdateInstanceEnv updates environment variables on a running instance and
// re-registers egress proxy header-injection rules with the new values.
// This enables credential rotation without restarting the VM.
func (m *manager) UpdateInstanceEnv(ctx context.Context, id string, req UpdateInstanceEnvRequest) (*Instance, error) {
lock := m.getInstanceLock(id)
lock.Lock()
defer lock.Unlock()
return m.updateInstanceEnv(ctx, id, req)
}

func (m *manager) updateInstanceEnv(ctx context.Context, id string, req UpdateInstanceEnvRequest) (*Instance, error) {
inst, err := m.getInstance(ctx, id)
if err != nil {
return nil, ErrNotFound
}

if inst.State != StateRunning && inst.State != StateInitializing {
return nil, fmt.Errorf("%w: instance must be running (current state: %s)", ErrInvalidState, inst.State)
}

if inst.NetworkEgress == nil || !inst.NetworkEgress.Enabled {
return nil, fmt.Errorf("%w: instance does not have egress proxy enabled", ErrInvalidRequest)
}

if len(inst.Credentials) == 0 {
return nil, fmt.Errorf("%w: instance has no credential policies configured", ErrInvalidRequest)
}

// Load persisted metadata so we can update and save it
meta, err := m.loadMetadata(id)
if err != nil {
return nil, fmt.Errorf("load metadata: %w", err)
}

if meta.Env == nil {
meta.Env = make(map[string]string)
}
for k, v := range req.Env {
meta.Env[k] = v
}

// Validate that all credential bindings are still satisfied
if err := validateCredentialEnvBindings(meta.Credentials, meta.Env); err != nil {
return nil, err
}

// Get the network allocation for this running instance so we can
// re-register the proxy with the correct source IP / TAP / gateway.
alloc, err := m.networkManager.GetAllocation(ctx, id)
if err != nil {
return nil, fmt.Errorf("get network allocation: %w", err)
}
if alloc == nil {
return nil, fmt.Errorf("no network allocation found for running instance")
}
netConfig := &network.NetworkConfig{
IP: alloc.IP,
MAC: alloc.MAC,
Gateway: alloc.Gateway,
Netmask: alloc.Netmask,
DNS: alloc.DNS,
TAPDevice: alloc.TAPDevice,
}

// Re-register egress proxy with updated inject rules (atomically swaps old rules).
if _, err := m.maybeRegisterEgressProxy(ctx, &meta.StoredMetadata, netConfig); err != nil {
return nil, fmt.Errorf("re-register egress proxy: %w", err)
}

// Persist updated env to metadata.json
if err := m.saveMetadata(meta); err != nil {
return nil, fmt.Errorf("save metadata: %w", err)
}

// Return fresh instance state
return m.getInstance(ctx, id)
}
Loading
Loading