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
42 changes: 42 additions & 0 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,48 @@ func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInst
return response, nil
}

// UpdateInstanceEnv replaces the instance's env vars and updates egress proxy rules.
// The id parameter can be an instance ID, name, or ID prefix.
// Note: Resolution is handled by ResolveResource middleware.
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)

result, err := s.InstanceManager.UpdateInstanceEnv(ctx, inst.Id, request.Body.Env)
if err != nil {
switch {
case errors.Is(err, instances.ErrNotFound):
return oapi.UpdateInstanceEnv404JSONResponse{
Code: "not_found",
Message: "instance not found",
}, nil
case errors.Is(err, instances.ErrInvalidRequest):
return oapi.UpdateInstanceEnv400JSONResponse{
Code: "invalid_request",
Message: err.Error(),
}, nil
case errors.Is(err, instances.ErrInvalidState):
return oapi.UpdateInstanceEnv409JSONResponse{
Code: "invalid_state",
Message: err.Error(),
}, 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
}

// AttachVolume attaches a volume to an instance (not yet implemented)
func (s *ApiService) AttachVolume(ctx context.Context, request oapi.AttachVolumeRequestObject) (oapi.AttachVolumeResponseObject, error) {
return oapi.AttachVolume500JSONResponse{
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 @@ -167,6 +167,10 @@ func (m *mockInstanceManager) GetVsockDialer(ctx context.Context, instanceID str
return nil, nil
}

func (m *mockInstanceManager) UpdateInstanceEnv(ctx context.Context, id string, env map[string]string) (*instances.Instance, error) {
return nil, nil
}

// mockVolumeManager implements volumes.Manager for testing
type mockVolumeManager struct {
volumes map[string]*volumes.Volume
Expand Down
21 changes: 21 additions & 0 deletions lib/egressproxy/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,27 @@ func compileHeaderInjectRules(cfgRules []HeaderInjectRuleConfig) ([]headerInject
return out, nil
}

// UpdateInjectRules atomically replaces the header-inject rules for a
// registered instance. Unlike RegisterInstance it does NOT re-apply iptables
// enforcement, which makes it suitable for credential rotation on a running VM.
// Returns ErrInstanceNotRegistered if the instance has not been registered.
func (s *Service) UpdateInjectRules(instanceID string, rules []HeaderInjectRuleConfig) error {
compiled, err := compileHeaderInjectRules(rules)
if err != nil {
return err
}

s.mu.Lock()
defer s.mu.Unlock()

sourceIP, ok := s.sourceIPByInstance[instanceID]
if !ok {
return ErrInstanceNotRegistered
}
s.policiesBySourceIP[sourceIP] = sourcePolicy{headerInjectRules: compiled}
return nil
}

func (s *Service) UnregisterInstance(_ context.Context, instanceID string) {
s.mu.Lock()
sourceIP, ok := s.sourceIPByInstance[instanceID]
Expand Down
35 changes: 35 additions & 0 deletions lib/egressproxy/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,38 @@ func TestHandleHTTPProxyRequest_DoesNotLeakUpstreamErrorDetails(t *testing.T) {
require.NotContains(t, body, "dial failed")
require.False(t, strings.Contains(body, "internal network detail"))
}

func TestUpdateInjectRules(t *testing.T) {
t.Parallel()

matchers, err := compileDomainMatchers([]string{"api.openai.com"})
require.NoError(t, err)

svc := &Service{
sourceIPByInstance: map[string]string{
"inst-1": "10.0.0.2",
},
policiesBySourceIP: map[string]sourcePolicy{
"10.0.0.2": {
headerInjectRules: []headerInjectRule{
{headerName: "Authorization", headerValue: "Bearer old-key", domainMatchers: matchers},
},
},
},
}

// Update with a new key value.
err = svc.UpdateInjectRules("inst-1", []HeaderInjectRuleConfig{
{HeaderName: "Authorization", HeaderValue: "Bearer rotated-key", AllowedDomains: []string{"api.openai.com"}},
})
require.NoError(t, err)

// Verify the policy was swapped.
hdr := http.Header{}
svc.applyHeaderInjections("10.0.0.2", "api.openai.com", hdr, true)
require.Equal(t, "Bearer rotated-key", hdr.Get("Authorization"))

// Unregistered instance returns an error.
err = svc.UpdateInjectRules("no-such-instance", nil)
require.ErrorIs(t, err, ErrInstanceNotRegistered)
}
3 changes: 2 additions & 1 deletion lib/egressproxy/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const (
)

var (
ErrGatewayMismatch = errors.New("egress proxy already initialized with different gateway")
ErrGatewayMismatch = errors.New("egress proxy already initialized with different gateway")
ErrInstanceNotRegistered = errors.New("instance not registered with egress proxy")
)

// InstanceConfig defines per-instance proxy behavior.
Expand Down
19 changes: 19 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,25 @@ func TestEgressProxyRewritesHTTPSHeaders(t *testing.T) {
require.Equal(t, 0, blockedExitCode, "curl output: %s", blockedOutput)
require.Equal(t, "", blockedOutput)

// --- Key rotation: update env with a new secret value ---
rotatedInst, err := manager.UpdateInstanceEnv(ctx, inst.Id, map[string]string{
"OUTBOUND_OPENAI_KEY": "rotated-key-456",
})
require.NoError(t, err)
require.Equal(t, "rotated-key-456", rotatedInst.Env["OUTBOUND_OPENAI_KEY"])

// The proxy should now inject the rotated key.
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-key-456")

// The guest env must NOT change (still sees mock value).
envAfter, envAfterCode, err := execCommand(ctx, inst, "sh", "-lc", "printf '%s' \"$OUTBOUND_OPENAI_KEY\"")
require.NoError(t, err)
require.Equal(t, 0, envAfterCode)
require.Equal(t, "mock-OUTBOUND_OPENAI_KEY", envAfter)

require.NoError(t, manager.DeleteInstance(ctx, inst.Id))
deleted = true
}
Expand Down
44 changes: 44 additions & 0 deletions lib/instances/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type Manager interface {
StartInstance(ctx context.Context, id string, req StartInstanceRequest) (*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
UpdateInstanceEnv(ctx context.Context, id string, env map[string]string) (*Instance, error)
AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error)
DetachVolume(ctx context.Context, id string, volumeId string) (*Instance, error)
// ListInstanceAllocations returns resource allocations for all instances.
Expand Down Expand Up @@ -441,6 +442,49 @@ func (m *manager) RotateLogs(ctx context.Context, maxBytes int64, maxFiles int)
return lastErr
}

// UpdateInstanceEnv replaces the instance's env vars, re-validates credential
// bindings, persists the change, and atomically updates the egress proxy inject
// rules so rotated secrets take effect without a restart.
func (m *manager) UpdateInstanceEnv(ctx context.Context, id string, env map[string]string) (*Instance, error) {
lock := m.getInstanceLock(id)
lock.Lock()
defer lock.Unlock()
return m.updateInstanceEnv(ctx, id, env)
}

func (m *manager) updateInstanceEnv(ctx context.Context, id string, env map[string]string) (*Instance, error) {
meta, err := m.loadMetadata(id)
if err != nil {
return nil, err
}

// Validate credential bindings against the new env.
if len(meta.Credentials) > 0 {
if err := validateCredentialEnvBindings(meta.Credentials, env); err != nil {
return nil, err
}
}

meta.Env = env
if err := m.saveMetadata(meta); err != nil {
return nil, fmt.Errorf("persist env update: %w", err)
}

// If the egress proxy is active, update inject rules in-place.
if meta.NetworkEgress != nil && meta.NetworkEgress.Enabled && len(meta.Credentials) > 0 {
svc, err := m.getOrCreateEgressProxyService()
if err != nil {
return nil, fmt.Errorf("get egress proxy service: %w", err)
}
rules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, meta.Env)
if err := svc.UpdateInjectRules(id, rules); err != nil {
return nil, fmt.Errorf("update egress proxy inject rules: %w", err)
}
}

return m.getInstance(ctx, id)
}

// AttachVolume attaches a volume to an instance (not yet implemented)
func (m *manager) AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error) {
return nil, fmt.Errorf("attach volume not yet implemented")
Expand Down
Loading
Loading