diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index ba5b448a..399aea20 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -798,6 +798,62 @@ func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInst return response, nil } +// UpdateInstance updates mutable properties of a running instance. +// Currently supports updating env vars referenced by credential policies for key rotation. +// Note: Resolution is handled by ResolveResource middleware +func (s *ApiService) UpdateInstance(ctx context.Context, request oapi.UpdateInstanceRequestObject) (oapi.UpdateInstanceResponseObject, error) { + inst := mw.GetResolvedInstance[instances.Instance](ctx) + if inst == nil { + return oapi.UpdateInstance500JSONResponse{ + Code: "internal_error", + Message: "resource not resolved", + }, nil + } + log := logger.FromContext(ctx) + + if request.Body == nil { + return oapi.UpdateInstance400JSONResponse{ + Code: "invalid_request", + Message: "request body is required", + }, nil + } + + env := make(map[string]string) + if request.Body.Env != nil { + env = *request.Body.Env + } + + result, err := s.InstanceManager.UpdateInstance(ctx, inst.Id, instances.UpdateInstanceRequest{ + Env: env, + }) + if err != nil { + switch { + case errors.Is(err, instances.ErrNotFound): + return oapi.UpdateInstance404JSONResponse{ + Code: "not_found", + Message: "instance not found", + }, nil + case errors.Is(err, instances.ErrInvalidState): + return oapi.UpdateInstance409JSONResponse{ + Code: "invalid_state", + Message: err.Error(), + }, nil + case errors.Is(err, instances.ErrInvalidRequest): + return oapi.UpdateInstance400JSONResponse{ + Code: "invalid_request", + Message: err.Error(), + }, nil + default: + log.ErrorContext(ctx, "failed to update instance", "error", err) + return oapi.UpdateInstance500JSONResponse{ + Code: "internal_error", + Message: "failed to update instance", + }, nil + } + } + return oapi.UpdateInstance200JSONResponse(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{ diff --git a/cmd/api/api/instances_test.go b/cmd/api/api/instances_test.go index ea883a08..d71d34cf 100644 --- a/cmd/api/api/instances_test.go +++ b/cmd/api/api/instances_test.go @@ -204,6 +204,14 @@ type captureForkManager struct { err error } +type captureUpdateManager struct { + instances.Manager + lastID string + lastReq *instances.UpdateInstanceRequest + result *instances.Instance + err error +} + func (m *captureForkManager) ForkInstance(ctx context.Context, id string, req instances.ForkInstanceRequest) (*instances.Instance, error) { reqCopy := req m.lastID = id @@ -214,6 +222,31 @@ func (m *captureForkManager) ForkInstance(ctx context.Context, id string, req in return m.result, nil } +func (m *captureUpdateManager) UpdateInstance(ctx context.Context, id string, req instances.UpdateInstanceRequest) (*instances.Instance, error) { + reqCopy := req + m.lastID = id + m.lastReq = &reqCopy + if m.err != nil { + return nil, m.err + } + if m.result != nil { + return m.result, nil + } + + now := time.Now() + return &instances.Instance{ + StoredMetadata: instances.StoredMetadata{ + Id: id, + Name: "updated-instance", + Image: "docker.io/library/alpine:latest", + Env: req.Env, + CreatedAt: now, + HypervisorType: hypervisor.TypeCloudHypervisor, + }, + State: instances.StateRunning, + }, nil +} + func (m *captureCreateManager) CreateInstance(ctx context.Context, req instances.CreateInstanceRequest) (*instances.Instance, error) { reqCopy := req m.lastReq = &reqCopy @@ -408,6 +441,114 @@ func TestCreateInstance_MapsNetworkEgressEnforcementMode(t *testing.T) { assert.Equal(t, instances.EgressEnforcementModeHTTPHTTPSOnly, mockMgr.lastReq.NetworkEgress.EnforcementMode) } +func TestUpdateInstance_MapsEnvPatch(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", + Name: "inst-update", + Image: "docker.io/library/alpine:latest", + Env: map[string]string{"OUTBOUND_OPENAI_KEY": "rotated-key-456"}, + CreatedAt: now, + HypervisorType: hypervisor.TypeCloudHypervisor, + }, + State: instances.StateRunning, + }, + } + svc.InstanceManager = mockMgr + + env := map[string]string{"OUTBOUND_OPENAI_KEY": "rotated-key-456"} + resolved := &instances.Instance{ + StoredMetadata: instances.StoredMetadata{ + Id: "inst-update", + Name: "inst-update", + Image: "docker.io/library/alpine:latest", + CreatedAt: now, + HypervisorType: hypervisor.TypeCloudHypervisor, + }, + State: instances.StateRunning, + } + + resp, err := svc.UpdateInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.UpdateInstanceRequestObject{ + Id: resolved.Id, + Body: &oapi.UpdateInstanceRequest{Env: &env}, + }) + require.NoError(t, err) + _, ok := resp.(oapi.UpdateInstance200JSONResponse) + require.True(t, ok, "expected 200 response") + + require.NotNil(t, mockMgr.lastReq) + assert.Equal(t, resolved.Id, mockMgr.lastID) + assert.Equal(t, "rotated-key-456", mockMgr.lastReq.Env["OUTBOUND_OPENAI_KEY"]) +} + +func TestUpdateInstance_RequiresBody(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + now := time.Now() + resolved := &instances.Instance{ + StoredMetadata: instances.StoredMetadata{ + Id: "inst-update", + Name: "inst-update", + Image: "docker.io/library/alpine:latest", + CreatedAt: now, + HypervisorType: hypervisor.TypeCloudHypervisor, + }, + State: instances.StateRunning, + } + + resp, err := svc.UpdateInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.UpdateInstanceRequestObject{ + Id: resolved.Id, + }) + require.NoError(t, err) + badReq, ok := resp.(oapi.UpdateInstance400JSONResponse) + require.True(t, ok, "expected 400 response") + assert.Equal(t, "invalid_request", badReq.Code) + assert.Contains(t, badReq.Message, "request body is required") +} + +func TestUpdateInstance_MapsInvalidRequestError(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + origMgr := svc.InstanceManager + mockMgr := &captureUpdateManager{ + Manager: origMgr, + err: fmt.Errorf("%w: env keys [UNRELATED_KEY] are not credential source env vars; allowed keys: [OUTBOUND_OPENAI_KEY]", instances.ErrInvalidRequest), + } + svc.InstanceManager = mockMgr + + now := time.Now() + resolved := &instances.Instance{ + StoredMetadata: instances.StoredMetadata{ + Id: "inst-update", + Name: "inst-update", + Image: "docker.io/library/alpine:latest", + CreatedAt: now, + HypervisorType: hypervisor.TypeCloudHypervisor, + }, + State: instances.StateRunning, + } + env := map[string]string{"UNRELATED_KEY": "value"} + + resp, err := svc.UpdateInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.UpdateInstanceRequestObject{ + Id: resolved.Id, + Body: &oapi.UpdateInstanceRequest{Env: &env}, + }) + require.NoError(t, err) + badReq, ok := resp.(oapi.UpdateInstance400JSONResponse) + require.True(t, ok, "expected 400 response") + assert.Equal(t, "invalid_request", badReq.Code) + assert.Contains(t, badReq.Message, "UNRELATED_KEY") +} + func TestForkInstance_Success(t *testing.T) { t.Parallel() svc := newTestService(t) diff --git a/lib/builds/manager_test.go b/lib/builds/manager_test.go index a3765d5f..5e4d16f5 100644 --- a/lib/builds/manager_test.go +++ b/lib/builds/manager_test.go @@ -135,6 +135,10 @@ func (m *mockInstanceManager) StartInstance(ctx context.Context, id string, req return nil, nil } +func (m *mockInstanceManager) UpdateInstance(ctx context.Context, id string, req instances.UpdateInstanceRequest) (*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 } diff --git a/lib/egressproxy/README.md b/lib/egressproxy/README.md index 42d77d42..0796ef4a 100644 --- a/lib/egressproxy/README.md +++ b/lib/egressproxy/README.md @@ -8,6 +8,16 @@ When enabled for an instance, hypeman does three things: 2. It injects proxy environment variables into the guest (`HTTP_PROXY` / `HTTPS_PROXY`) and installs the proxy CA certificate in the guest trust store. 3. It enforces policy on the host to prevent direct outbound TCP egress from the VM unless traffic is going to the bridge gateway (the proxy), depending on `network.egress.enforcement.mode`. +## How the feature works + +At a high level, the feature separates what the guest sees from what the host uses for outbound authentication: + +- The guest gets normal proxy configuration plus mock credential values. +- The host keeps the real credential values in instance metadata. +- The host-side proxy injects the real values into outbound HTTPS headers only when the configured policy matches. + +That means the VM can make authenticated outbound requests without ever receiving the real secret material directly. + ## Secret substitution flow - API callers provide real secret values in instance `env`. @@ -30,6 +40,25 @@ When enabled for an instance, hypeman does three things: This keeps real secrets out of the VM while still allowing authenticated egress requests. +## Credential rotation via instance update + +The feature also supports rotating real credential values without restarting the VM. + +- `PATCH /instances/{id}` accepts updates to env keys that are already referenced by existing credential `source.env` bindings. +- The request updates the host-side stored value for that credential source. +- If the instance is currently registered with the egress proxy, hypeman recompiles the proxy's header injection rules using the new real value. +- The guest-visible env value does not change: inside the VM, the credential still appears as `mock-`. +- New outbound HTTPS requests start using the rotated value after the update succeeds. + +Operationally, this is intended for key rotation, revocation/reissue flows, and similar secret lifecycle events where you want host-side outbound auth behavior to change immediately without a guest reboot. + +### Update safety behavior + +- The update path only accepts env keys already bound by the instance's credential policy. +- If proxy rule recompilation fails, the running instance keeps using the old value. +- If runtime rules are updated but metadata persistence fails, hypeman rolls the proxy rules back to the previous value before returning an error. +- Invalid or unreadable metadata is treated as an internal failure, not as a synthetic "instance not found" result. + ## Security behavior - Real secret values are persisted in the normal instance `env` metadata, which is already host-side state. @@ -42,3 +71,63 @@ This keeps real secrets out of the VM while still allowing authenticated egress - Header injection is applied to HTTP headers only (not request/response bodies). - Non-HTTP protocols or custom ports are not rewritten by the MITM layer. - Plain HTTP requests are not eligible for secret substitution. + +## Observability + +This feature exposes operator-facing logs, traces, and metrics for both the control plane and the proxy data plane. + +### Logs + +- Egress proxy logs use the `EGRESS` logging subsystem. +- Control-plane actions such as register, unregister, and rule update include `instance_id` so they can be correlated with per-instance logs. +- Upstream proxy failures are logged with low-cardinality fields such as `protocol` and whether header injection occurred (`injected=true/false`). +- When request trace context is available, logs include `trace_id` and `span_id`. + +In normal operation, the important things to watch for are: + +- repeated "failed to configure egress proxy" errors during create, start, or restore +- repeated "failed to update egress proxy rules" errors during credential rotation +- repeated upstream proxy failure warnings, especially after a credential rotation or policy rollout + +### Tracing + +The feature adds child spans for control-plane operations, including: + +- `MaybeRegisterEgressProxy` +- `EgressProxy.RegisterInstance` +- `EgressProxy.UpdateInstanceRules` +- `EgressProxy.UnregisterInstance` + +These spans include attributes such as: + +- `operation` +- `proxy_enabled` +- `enforcement_mode` +- `inject_rule_count` +- `result` + +This makes it possible to distinguish failures in proxy registration, runtime rule update, and teardown from the broader instance lifecycle span that triggered them. + +### Metrics + +Control-plane metrics: + +- `hypeman_egress_proxy_registrations_total{operation,result,enforcement_mode}` +- `hypeman_egress_proxy_rule_updates_total{result}` +- `hypeman_egress_proxy_registered_instances_total` +- `hypeman_egress_proxy_control_plane_duration_seconds{operation,result}` + +Data-plane metrics: + +- `hypeman_egress_proxy_requests_total{protocol,result,injected}` +- `hypeman_egress_proxy_upstream_duration_seconds{protocol,result}` +- `hypeman_egress_proxy_upstream_failures_total{protocol}` + +These labels are intentionally low-cardinality. In particular, destination host is not used as a metric label. + +### What operators should look for + +- A rise in `hypeman_egress_proxy_registrations_total{result="error"}` usually means create/start/restore flows are failing to attach egress mediation correctly. +- A rise in `hypeman_egress_proxy_rule_updates_total{result="error"}` means key rotation requests are being rejected or failing to apply. +- `hypeman_egress_proxy_registered_instances_total` should roughly match the number of running instances that currently have `network.egress.enabled=true`. +- A rise in `hypeman_egress_proxy_upstream_failures_total` or a latency increase in `hypeman_egress_proxy_upstream_duration_seconds` usually points to upstream reachability, TLS trust, or destination-side issues rather than guest boot problems. diff --git a/lib/egressproxy/metrics.go b/lib/egressproxy/metrics.go new file mode 100644 index 00000000..e03ed5bd --- /dev/null +++ b/lib/egressproxy/metrics.go @@ -0,0 +1,165 @@ +package egressproxy + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +var ( + controlPlaneDurationBuckets = []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 5} + upstreamDurationBuckets = []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60} +) + +type metrics struct { + registrations metric.Int64Counter + ruleUpdates metric.Int64Counter + registeredInstances metric.Int64ObservableGauge + controlPlaneDuration metric.Float64Histogram + requests metric.Int64Counter + upstreamDuration metric.Float64Histogram + upstreamFailures metric.Int64Counter +} + +func newMetrics(meter metric.Meter, svc *Service) (*metrics, error) { + registrations, err := meter.Int64Counter( + "hypeman_egress_proxy_registrations_total", + metric.WithDescription("Total number of egress proxy registration operations"), + ) + if err != nil { + return nil, err + } + + ruleUpdates, err := meter.Int64Counter( + "hypeman_egress_proxy_rule_updates_total", + metric.WithDescription("Total number of egress proxy rule update operations"), + ) + if err != nil { + return nil, err + } + + registeredInstances, err := meter.Int64ObservableGauge( + "hypeman_egress_proxy_registered_instances_total", + metric.WithDescription("Total number of instances currently registered with the egress proxy"), + ) + if err != nil { + return nil, err + } + + controlPlaneDuration, err := meter.Float64Histogram( + "hypeman_egress_proxy_control_plane_duration_seconds", + metric.WithDescription("Duration of egress proxy control plane operations"), + metric.WithUnit("s"), + metric.WithExplicitBucketBoundaries(controlPlaneDurationBuckets...), + ) + if err != nil { + return nil, err + } + + requests, err := meter.Int64Counter( + "hypeman_egress_proxy_requests_total", + metric.WithDescription("Total number of egress proxy request handling outcomes"), + ) + if err != nil { + return nil, err + } + + upstreamDuration, err := meter.Float64Histogram( + "hypeman_egress_proxy_upstream_duration_seconds", + metric.WithDescription("Duration of egress proxy upstream requests"), + metric.WithUnit("s"), + metric.WithExplicitBucketBoundaries(upstreamDurationBuckets...), + ) + if err != nil { + return nil, err + } + + upstreamFailures, err := meter.Int64Counter( + "hypeman_egress_proxy_upstream_failures_total", + metric.WithDescription("Total number of egress proxy upstream request failures"), + ) + if err != nil { + return nil, err + } + + if _, err := meter.RegisterCallback(func(ctx context.Context, o metric.Observer) error { + svc.mu.RLock() + count := int64(len(svc.sourceIPByInstance)) + svc.mu.RUnlock() + o.ObserveInt64(registeredInstances, count) + return nil + }, registeredInstances); err != nil { + return nil, err + } + + return &metrics{ + registrations: registrations, + ruleUpdates: ruleUpdates, + registeredInstances: registeredInstances, + controlPlaneDuration: controlPlaneDuration, + requests: requests, + upstreamDuration: upstreamDuration, + upstreamFailures: upstreamFailures, + }, nil +} + +func (m *metrics) recordRegistration(ctx context.Context, operation, result, enforcementMode string) { + if m == nil { + return + } + m.registrations.Add(ctx, 1, metric.WithAttributes( + attribute.String("operation", operation), + attribute.String("result", result), + attribute.String("enforcement_mode", enforcementMode), + )) +} + +func (m *metrics) recordRuleUpdate(ctx context.Context, result string) { + if m == nil { + return + } + m.ruleUpdates.Add(ctx, 1, metric.WithAttributes( + attribute.String("result", result), + )) +} + +func (m *metrics) recordControlPlaneDuration(ctx context.Context, operation, result string, seconds float64) { + if m == nil { + return + } + m.controlPlaneDuration.Record(ctx, seconds, metric.WithAttributes( + attribute.String("operation", operation), + attribute.String("result", result), + )) +} + +func (m *metrics) recordRequest(ctx context.Context, protocol, result string, injected bool) { + if m == nil { + return + } + m.requests.Add(ctx, 1, metric.WithAttributes( + attribute.String("protocol", protocol), + attribute.String("result", result), + attribute.Bool("injected", injected), + )) +} + +func (m *metrics) recordUpstreamDuration(ctx context.Context, protocol, result string, seconds float64) { + if m == nil { + return + } + m.upstreamDuration.Record(ctx, seconds, metric.WithAttributes( + attribute.String("protocol", protocol), + attribute.String("result", result), + )) +} + +func (m *metrics) recordUpstreamFailure(ctx context.Context, protocol string) { + if m == nil { + return + } + m.upstreamFailures.Add(ctx, 1, metric.WithAttributes( + attribute.String("protocol", protocol), + )) +} diff --git a/lib/egressproxy/service.go b/lib/egressproxy/service.go index 75cd2e7f..acd6172c 100644 --- a/lib/egressproxy/service.go +++ b/lib/egressproxy/service.go @@ -17,6 +17,11 @@ import ( "strings" "sync" "time" + + "github.com/kernel/hypeman/lib/logger" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) type sourcePolicy struct { @@ -54,6 +59,9 @@ type Service struct { policiesBySourceIP map[string]sourcePolicy sourceIPByInstance map[string]string + metrics *metrics + tracer trace.Tracer + log *slog.Logger } func NewService(dataDir string, listenPort int) (*Service, error) { @@ -87,7 +95,12 @@ func NewServiceWithOptions(dataDir string, listenPort int, opts ServiceOptions) }, } - return &Service{ + log := opts.Logger + if log == nil { + log = logger.NewSubsystemLogger(logger.SubsystemEgress, logger.NewConfig(), nil) + } + + svc := &Service{ dataDir: dataDir, listenPort: listenPort, transport: transport, @@ -98,7 +111,18 @@ func NewServiceWithOptions(dataDir string, listenPort int, opts ServiceOptions) certCacheLimit: defaultLeafCertCacheLimit, policiesBySourceIP: make(map[string]sourcePolicy), sourceIPByInstance: make(map[string]string), - }, nil + tracer: opts.Tracer, + log: log, + } + if opts.Meter != nil { + metrics, err := newMetrics(opts.Meter, svc) + if err != nil { + return nil, err + } + svc.metrics = metrics + } + + return svc, nil } func buildRootCAPool(opts ServiceOptions) (*x509.CertPool, error) { @@ -145,7 +169,7 @@ func (s *Service) EnsureStarted(ctx context.Context, gatewayIP string) error { go func() { if serveErr := s.server.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed { - slog.Error("egress proxy server exited", "error", serveErr) + s.log.ErrorContext(context.Background(), "egress proxy server exited", "error", serveErr) } }() @@ -165,11 +189,32 @@ func (s *Service) Shutdown(ctx context.Context) error { } func (s *Service) RegisterInstance(ctx context.Context, gatewayIP string, cfg InstanceConfig) (GuestConfig, error) { + start := time.Now() + log := s.loggerForContext(ctx) + result := "error" + var opErr error + enforcementMode := enforcementModeLabel(cfg.BlockAllTCPEgress) + ctx, span := s.startControlPlaneSpan(ctx, "EgressProxy.RegisterInstance", + attribute.String("operation", "register"), + attribute.String("enforcement_mode", enforcementMode), + attribute.Bool("proxy_enabled", true), + attribute.Int("inject_rule_count", len(cfg.HeaderInjectRules)), + ) + defer func() { + s.finishControlPlaneSpan(span, result, opErr) + s.metrics.recordRegistration(ctx, "register", result, enforcementMode) + s.metrics.recordControlPlaneDuration(ctx, "register", result, time.Since(start).Seconds()) + }() + if err := s.EnsureStarted(ctx, gatewayIP); err != nil { + log.WarnContext(ctx, "failed to ensure egress proxy service is started", "instance_id", cfg.InstanceID, "error", err) + opErr = err return GuestConfig{}, err } if err := applyEgressEnforcement(cfg.InstanceID, cfg.TAPDevice, gatewayIP, s.listenPort, cfg.BlockAllTCPEgress); err != nil { + log.WarnContext(ctx, "failed to apply egress proxy enforcement", "instance_id", cfg.InstanceID, "error", err) + opErr = err return GuestConfig{}, err } @@ -182,12 +227,16 @@ func (s *Service) RegisterInstance(ctx context.Context, gatewayIP string, cfg In injectRules, err := compileHeaderInjectRules(cfg.HeaderInjectRules) if err != nil { + log.WarnContext(ctx, "failed to compile egress proxy inject rules", "instance_id", cfg.InstanceID, "error", err) + opErr = err return GuestConfig{}, err } s.sourceIPByInstance[cfg.InstanceID] = cfg.SourceIP s.policiesBySourceIP[cfg.SourceIP] = sourcePolicy{headerInjectRules: injectRules} + result = "success" + log.DebugContext(ctx, "registered instance with egress proxy", "instance_id", cfg.InstanceID, "enforcement_mode", enforcementMode, "inject_rule_count", len(injectRules)) return GuestConfig{ Enabled: true, ProxyURL: s.proxyURLLocked(), @@ -217,7 +266,64 @@ func compileHeaderInjectRules(cfgRules []HeaderInjectRuleConfig) ([]headerInject return out, nil } -func (s *Service) UnregisterInstance(_ context.Context, instanceID string) { +// UpdateInstanceRules replaces the header inject rules for a registered instance. +// Returns an error if the instance is not currently registered. +func (s *Service) UpdateInstanceRules(ctx context.Context, instanceID string, rules []HeaderInjectRuleConfig) error { + start := time.Now() + log := s.loggerForContext(ctx) + result := "error" + var opErr error + ctx, span := s.startControlPlaneSpan(ctx, "EgressProxy.UpdateInstanceRules", + attribute.String("operation", "update"), + attribute.Bool("proxy_enabled", true), + attribute.Int("inject_rule_count", len(rules)), + ) + defer func() { + s.finishControlPlaneSpan(span, result, opErr) + s.metrics.recordRuleUpdate(ctx, result) + s.metrics.recordControlPlaneDuration(ctx, "update", result, time.Since(start).Seconds()) + }() + + s.mu.Lock() + defer s.mu.Unlock() + + sourceIP, ok := s.sourceIPByInstance[instanceID] + if !ok { + err := fmt.Errorf("instance %s is not registered with egress proxy", instanceID) + log.WarnContext(ctx, "failed to update egress proxy rules", "instance_id", instanceID, "error", err) + opErr = err + return err + } + + compiled, err := compileHeaderInjectRules(rules) + if err != nil { + err = fmt.Errorf("compile header inject rules: %w", err) + log.WarnContext(ctx, "failed to compile egress proxy rules", "instance_id", instanceID, "error", err) + opErr = err + return err + } + + s.policiesBySourceIP[sourceIP] = sourcePolicy{headerInjectRules: compiled} + result = "success" + log.DebugContext(ctx, "updated egress proxy rules", "instance_id", instanceID, "inject_rule_count", len(compiled)) + return nil +} + +func (s *Service) UnregisterInstance(ctx context.Context, instanceID string) error { + start := time.Now() + log := s.loggerForContext(ctx) + result := "error" + var opErr error + ctx, span := s.startControlPlaneSpan(ctx, "EgressProxy.UnregisterInstance", + attribute.String("operation", "unregister"), + attribute.Bool("proxy_enabled", true), + ) + defer func() { + s.finishControlPlaneSpan(span, result, opErr) + s.metrics.recordRegistration(ctx, "unregister", result, "unknown") + s.metrics.recordControlPlaneDuration(ctx, "unregister", result, time.Since(start).Seconds()) + }() + s.mu.Lock() sourceIP, ok := s.sourceIPByInstance[instanceID] if ok { @@ -226,7 +332,15 @@ func (s *Service) UnregisterInstance(_ context.Context, instanceID string) { } s.mu.Unlock() - _ = removeEgressEnforcement(instanceID) + if err := removeEgressEnforcement(instanceID); err != nil { + log.WarnContext(ctx, "failed to remove egress proxy enforcement", "instance_id", instanceID, "error", err) + opErr = err + return err + } + + result = "success" + log.DebugContext(ctx, "unregistered instance from egress proxy", "instance_id", instanceID) + return nil } func (s *Service) ProxyURL() string { @@ -252,6 +366,10 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (s *Service) handleHTTPProxyRequest(w http.ResponseWriter, r *http.Request, sourceIP string, insideTunnel bool) { + protocol := "http" + if insideTunnel { + protocol = "https" + } outReq := r.Clone(r.Context()) outReq.Header = cloneHeader(r.Header) removeHopByHopHeaders(outReq.Header) @@ -273,11 +391,16 @@ func (s *Service) handleHTTPProxyRequest(w http.ResponseWriter, r *http.Request, if destinationHost == "" { destinationHost = normalizeDestinationHost(outReq.Host) } - s.applyHeaderInjections(sourceIP, destinationHost, outReq.Header, false) + injected := s.applyHeaderInjections(sourceIP, destinationHost, outReq.Header, false) > 0 + start := time.Now() resp, err := s.transport.RoundTrip(outReq) if err != nil { - slog.Warn("egress proxy upstream request failed", "destination_host", destinationHost, "error", err) + log := s.loggerForContext(r.Context()) + log.WarnContext(r.Context(), "egress proxy upstream request failed", "protocol", protocol, "injected", injected, "error", err) + s.metrics.recordRequest(r.Context(), protocol, "upstream_error", injected) + s.metrics.recordUpstreamDuration(r.Context(), protocol, "upstream_error", time.Since(start).Seconds()) + s.metrics.recordUpstreamFailure(r.Context(), protocol) http.Error(w, "proxy upstream error", http.StatusBadGateway) return } @@ -291,6 +414,8 @@ func (s *Service) handleHTTPProxyRequest(w http.ResponseWriter, r *http.Request, } w.WriteHeader(resp.StatusCode) _, _ = io.Copy(w, resp.Body) + s.metrics.recordRequest(r.Context(), protocol, "success", injected) + s.metrics.recordUpstreamDuration(r.Context(), protocol, "success", time.Since(start).Seconds()) } func (s *Service) handleConnect(w http.ResponseWriter, r *http.Request, sourceIP string) { @@ -317,6 +442,9 @@ func (s *Service) handleConnect(w http.ResponseWriter, r *http.Request, sourceIP cert, err := s.getOrCreateLeafCert(targetHost) if err != nil { + log := s.loggerForContext(r.Context()) + log.WarnContext(r.Context(), "egress proxy CONNECT setup failed", "stage", "leaf_cert", "destination_host", targetHost, "error", err) + s.metrics.recordRequest(r.Context(), "https", "connect_cert_error", false) return } @@ -324,6 +452,9 @@ func (s *Service) handleConnect(w http.ResponseWriter, r *http.Request, sourceIP Certificates: []tls.Certificate{*cert}, }) if err := tlsConn.Handshake(); err != nil { + log := s.loggerForContext(r.Context()) + log.WarnContext(r.Context(), "egress proxy CONNECT setup failed", "stage", "client_tls_handshake", "destination_host", targetHost, "error", err) + s.metrics.recordRequest(r.Context(), "https", "connect_handshake_error", false) return } defer tlsConn.Close() @@ -349,10 +480,16 @@ func (s *Service) handleConnect(w http.ResponseWriter, r *http.Request, sourceIP req.RequestURI = "" req.Header = cloneHeader(req.Header) removeHopByHopHeaders(req.Header) - s.applyHeaderInjections(sourceIP, targetHost, req.Header, true) + injected := s.applyHeaderInjections(sourceIP, targetHost, req.Header, true) > 0 + start := time.Now() resp, err := s.transport.RoundTrip(req) if err != nil { + log := s.loggerForContext(r.Context()) + log.WarnContext(r.Context(), "egress proxy upstream request failed", "protocol", "https", "injected", injected, "error", err) + s.metrics.recordRequest(r.Context(), "https", "upstream_error", injected) + s.metrics.recordUpstreamDuration(r.Context(), "https", "upstream_error", time.Since(start).Seconds()) + s.metrics.recordUpstreamFailure(r.Context(), "https") _, _ = io.WriteString(tlsConn, "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") return } @@ -363,6 +500,8 @@ func (s *Service) handleConnect(w http.ResponseWriter, r *http.Request, sourceIP return } resp.Body.Close() + s.metrics.recordRequest(r.Context(), "https", "success", injected) + s.metrics.recordUpstreamDuration(r.Context(), "https", "success", time.Since(start).Seconds()) if req.Close || resp.Close { return @@ -398,15 +537,16 @@ func (s *Service) getOrCreateLeafCert(host string) (*tls.Certificate, error) { return cert, nil } -func (s *Service) applyHeaderInjections(sourceIP, destinationHost string, headers http.Header, isHTTPS bool) { +func (s *Service) applyHeaderInjections(sourceIP, destinationHost string, headers http.Header, isHTTPS bool) int { rules := s.resolveHeaderInjectRules(sourceIP, destinationHost, isHTTPS) if len(rules) == 0 { - return + return 0 } for _, rule := range rules { headers.Set(rule.headerName, rule.headerValue) } + return len(rules) } func (s *Service) resolveHeaderInjectRules(sourceIP, destinationHost string, isHTTPS bool) []headerInjectRule { @@ -474,3 +614,46 @@ func sourceIPFromRemoteAddr(remoteAddr string) string { } return host } + +func (s *Service) loggerForContext(ctx context.Context) *slog.Logger { + if ctx != nil { + log := logger.FromContext(ctx) + if log != nil && log != slog.Default() { + return log + } + } + if s.log != nil { + return s.log + } + return slog.Default() +} + +func (s *Service) startControlPlaneSpan(ctx context.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { + if s.tracer == nil { + return ctx, nil + } + ctx, span := s.tracer.Start(ctx, name) + if len(attrs) > 0 { + span.SetAttributes(attrs...) + } + return ctx, span +} + +func (s *Service) finishControlPlaneSpan(span trace.Span, result string, err error) { + if span == nil { + return + } + span.SetAttributes(attribute.String("result", result)) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + span.End() +} + +func enforcementModeLabel(blockAllTCPEgress bool) string { + if blockAllTCPEgress { + return "all" + } + return "http_https_only" +} diff --git a/lib/egressproxy/types.go b/lib/egressproxy/types.go index 29c2e838..3a2e59fa 100644 --- a/lib/egressproxy/types.go +++ b/lib/egressproxy/types.go @@ -1,6 +1,12 @@ package egressproxy -import "errors" +import ( + "errors" + "log/slog" + + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" +) const ( DefaultListenPort = 18080 @@ -29,6 +35,9 @@ type HeaderInjectRuleConfig struct { // ServiceOptions customizes service construction (primarily for tests). type ServiceOptions struct { AdditionalRootCAPEM []string + Logger *slog.Logger + Meter metric.Meter + Tracer trace.Tracer } // GuestConfig is injected into guest config.json when proxy mode is enabled. diff --git a/lib/instances/egress_proxy.go b/lib/instances/egress_proxy.go index 8800d72e..82a3c4e1 100644 --- a/lib/instances/egress_proxy.go +++ b/lib/instances/egress_proxy.go @@ -7,7 +7,11 @@ import ( "strings" "github.com/kernel/hypeman/lib/egressproxy" + "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/network" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) const mockSecretPrefix = "mock-" @@ -194,6 +198,36 @@ func buildEgressProxyInjectRules(egressPolicy *NetworkEgressPolicy, credentials return out } +func credentialSourceEnvNames(credentials map[string]CredentialPolicy) []string { + if len(credentials) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(credentials)) + names := make([]string, 0, len(credentials)) + for _, policy := range credentials { + name := strings.TrimSpace(policy.Source.Env) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + sort.Strings(names) + return names +} + +// getEgressProxyIfExists returns the egress proxy service if it has been created, +// or nil if no instance has registered with the proxy yet. +func (m *manager) getEgressProxyIfExists() *egressproxy.Service { + m.egressProxyMu.Lock() + defer m.egressProxyMu.Unlock() + return m.egressProxy +} + func (m *manager) getOrCreateEgressProxyService() (*egressproxy.Service, error) { m.egressProxyMu.Lock() defer m.egressProxyMu.Unlock() @@ -202,7 +236,18 @@ func (m *manager) getOrCreateEgressProxyService() (*egressproxy.Service, error) return m.egressProxy, nil } - svc, err := egressproxy.NewServiceWithOptions(m.paths.DataDir(), egressproxy.DefaultListenPort, m.egressProxyServiceOptions) + opts := m.egressProxyServiceOptions + if opts.Logger == nil { + opts.Logger = logger.NewSubsystemLogger(logger.SubsystemEgress, logger.NewConfig(), nil) + } + if opts.Meter == nil { + opts.Meter = m.meter + } + if opts.Tracer == nil { + opts.Tracer = m.tracer + } + + svc, err := egressproxy.NewServiceWithOptions(m.paths.DataDir(), egressproxy.DefaultListenPort, opts) if err != nil { return nil, err } @@ -218,8 +263,30 @@ func (m *manager) maybeRegisterEgressProxy(ctx context.Context, stored *StoredMe return nil, fmt.Errorf("network.egress requires network.enabled=true") } + rules := buildEgressProxyInjectRules(stored.NetworkEgress, stored.Credentials, stored.Env) + enforcementMode := string(stored.NetworkEgress.EnforcementMode) + if enforcementMode == "" { + enforcementMode = string(EgressEnforcementModeAll) + } + + var span trace.Span + if m.tracer != nil { + ctx, span = m.tracer.Start(ctx, "MaybeRegisterEgressProxy") + span.SetAttributes( + attribute.String("operation", "register"), + attribute.String("enforcement_mode", enforcementMode), + attribute.Bool("proxy_enabled", true), + attribute.Int("inject_rule_count", len(rules)), + ) + defer span.End() + } + svc, err := m.getOrCreateEgressProxyService() if err != nil { + if span != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } return nil, fmt.Errorf("create egress proxy service: %w", err) } @@ -228,9 +295,13 @@ func (m *manager) maybeRegisterEgressProxy(ctx context.Context, stored *StoredMe SourceIP: netConfig.IP, TAPDevice: netConfig.TAPDevice, BlockAllTCPEgress: stored.NetworkEgress.EnforcementMode != EgressEnforcementModeHTTPHTTPSOnly, - HeaderInjectRules: buildEgressProxyInjectRules(stored.NetworkEgress, stored.Credentials, stored.Env), + HeaderInjectRules: rules, }) if err != nil { + if span != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } return nil, fmt.Errorf("register instance with egress proxy: %w", err) } @@ -238,12 +309,13 @@ func (m *manager) maybeRegisterEgressProxy(ctx context.Context, stored *StoredMe } func (m *manager) unregisterEgressProxyInstance(ctx context.Context, instanceID string) { - _ = ctx m.egressProxyMu.Lock() svc := m.egressProxy m.egressProxyMu.Unlock() if svc == nil { return } - svc.UnregisterInstance(context.Background(), instanceID) + if err := svc.UnregisterInstance(ctx, instanceID); err != nil { + logger.FromContext(ctx).WarnContext(ctx, "failed to unregister instance from egress proxy", "instance_id", instanceID, "error", err) + } } diff --git a/lib/instances/egress_proxy_integration_test.go b/lib/instances/egress_proxy_integration_test.go index 96fdd86c..bee8793c 100644 --- a/lib/instances/egress_proxy_integration_test.go +++ b/lib/instances/egress_proxy_integration_test.go @@ -28,6 +28,11 @@ func TestEgressProxyRewritesHTTPSHeaders(t *testing.T) { manager, _ := setupTestManager(t) ctx := context.Background() + probeTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "proxy-ok") + })) + defer probeTarget.Close() + caPEM, cert := mustGenerateTLSChain(t, []string{"localhost"}) manager.egressProxyServiceOptions = egressproxy.ServiceOptions{ AdditionalRootCAPEM: []string{caPEM}, @@ -102,13 +107,45 @@ func TestEgressProxyRewritesHTTPSHeaders(t *testing.T) { } }) - require.NoError(t, waitForVMReady(ctx, inst.SocketPath, 10*time.Second)) - require.NoError(t, waitForLogMessage(ctx, manager, inst.Id, "[guest-agent] listening", 45*time.Second)) + _, err = waitForInstanceState(ctx, manager, inst.Id, StateRunning, 5*time.Second) + if err != nil { + logs, logErr := collectLogs(ctx, manager, inst.Id, 200) + if logErr != nil { + t.Logf("failed to collect logs after Running timeout: %v", logErr) + } else { + t.Logf("app logs after Running timeout:\n%s", logs) + } + current, getErr := manager.GetInstance(ctx, inst.Id) + if getErr != nil { + t.Logf("failed to get instance after Running timeout: %v", getErr) + } else { + t.Logf("instance after Running timeout: state=%s program_started_at=%v guest_agent_ready_at=%v boot_markers_hydrated=%v", current.State, current.ProgramStartedAt, current.GuestAgentReadyAt, current.BootMarkersHydrated) + } + } + require.NoError(t, err) - envOutput, envExitCode, err := execCommand(ctx, inst, "sh", "-lc", "printf '%s' \"$OUTBOUND_OPENAI_KEY\"") + envOutput, envExitCode, err := execCommand(ctx, inst, "sh", "-lc", "printf '%s\\n%s\\n%s' \"$OUTBOUND_OPENAI_KEY\" \"$HTTP_PROXY\" \"$HTTPS_PROXY\"") require.NoError(t, err) require.Equal(t, 0, envExitCode) - require.Equal(t, "mock-OUTBOUND_OPENAI_KEY", envOutput) + envLines := strings.Split(strings.TrimSpace(envOutput), "\n") + require.Len(t, envLines, 3, "unexpected env output: %q", envOutput) + require.Equal(t, "mock-OUTBOUND_OPENAI_KEY", envLines[0]) + + alloc, err := manager.networkManager.GetAllocation(ctx, inst.Id) + require.NoError(t, err) + proxyURL := fmt.Sprintf("http://%s:%d", alloc.Gateway, egressproxy.DefaultListenPort) + require.Equal(t, proxyURL, envLines[1]) + require.Equal(t, proxyURL, envLines[2]) + + probeCmd := fmt.Sprintf( + "NO_PROXY= no_proxy= curl -sS --proxy %s %s", + proxyURL, + probeTarget.URL, + ) + probeOutput, probeExitCode, err := execCommand(ctx, inst, "sh", "-lc", probeCmd) + require.NoError(t, err) + require.Equal(t, 0, probeExitCode, "curl output: %s", probeOutput) + require.Equal(t, "proxy-ok", strings.TrimSpace(probeOutput)) allowedCmd := fmt.Sprintf( "NO_PROXY= no_proxy= curl -k -sS https://%s:%s", @@ -128,6 +165,33 @@ func TestEgressProxyRewritesHTTPSHeaders(t *testing.T) { require.Equal(t, 0, blockedExitCode, "curl output: %s", blockedOutput) require.Equal(t, "", blockedOutput) + // === Key rotation: update credential env and verify new value is used === + t.Log("Updating egress proxy credential env for key rotation...") + updated, err := manager.UpdateInstance(ctx, inst.Id, UpdateInstanceRequest{ + Env: map[string]string{ + "OUTBOUND_OPENAI_KEY": "rotated-key-456", + }, + }) + require.NoError(t, err) + require.Equal(t, "rotated-key-456", updated.Env["OUTBOUND_OPENAI_KEY"]) + + // Guest-visible env should still show the mock value (secrets never reach guest) + envAfterUpdate, envExitCode2, err := execCommand(ctx, inst, "sh", "-lc", "printf '%s' \"$OUTBOUND_OPENAI_KEY\"") + require.NoError(t, err) + require.Equal(t, 0, envExitCode2) + require.Equal(t, "mock-OUTBOUND_OPENAI_KEY", envAfterUpdate) + + // Egress proxy should now inject the rotated key + rotatedCmd := fmt.Sprintf( + "NO_PROXY= no_proxy= curl -k -sS https://%s:%s", + targetHost, targetPort, + ) + rotatedOutput, rotatedExitCode, err := execCommand(ctx, inst, "sh", "-lc", rotatedCmd) + require.NoError(t, err) + require.Equal(t, 0, rotatedExitCode, "curl output: %s", rotatedOutput) + require.Contains(t, rotatedOutput, "Bearer rotated-key-456") + require.NotContains(t, rotatedOutput, "real-openai-key-123") + require.NoError(t, manager.DeleteInstance(ctx, inst.Id)) deleted = true } diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 8305a0c5..0f82f7c8 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -39,6 +39,7 @@ 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) + UpdateInstance(ctx context.Context, id string, req UpdateInstanceRequest) (*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) @@ -84,6 +85,8 @@ type manager struct { bootMarkerScans sync.Map // map[string]time.Time next allowed boot-marker rescan hostTopology *HostTopology // Cached host CPU topology metrics *Metrics + meter metric.Meter + tracer trace.Tracer now func() time.Time egressProxy *egressproxy.Service egressProxyServiceOptions egressproxy.ServiceOptions @@ -133,6 +136,8 @@ func NewManager(p *paths.Paths, imageManager images.Manager, systemManager syste vmStarters: vmStarters, defaultHypervisor: defaultHypervisor, now: time.Now, + meter: meter, + tracer: tracer, guestMemoryPolicy: policy, } @@ -313,6 +318,14 @@ func (m *manager) StartInstance(ctx context.Context, id string, req StartInstanc return m.startInstance(ctx, id, req) } +// UpdateInstance updates mutable properties of a running instance +func (m *manager) UpdateInstance(ctx context.Context, id string, req UpdateInstanceRequest) (*Instance, error) { + lock := m.getInstanceLock(id) + lock.Lock() + defer lock.Unlock() + return m.updateInstance(ctx, id, req) +} + // ListInstances returns instances, optionally filtered by the given criteria. // Pass nil to return all instances. func (m *manager) ListInstances(ctx context.Context, filter *ListInstancesFilter) ([]Instance, error) { diff --git a/lib/instances/test_prewarm_test.go b/lib/instances/test_prewarm_test.go index a0aa1a03..041c1b81 100644 --- a/lib/instances/test_prewarm_test.go +++ b/lib/instances/test_prewarm_test.go @@ -103,6 +103,7 @@ func prepareIntegrationTestDataDir(t *testing.T, tmpDir string) { linkSubdir(t, srcSystemDir, dstSystemDir, "kernel", true) linkSubdir(t, srcSystemDir, dstSystemDir, "initrd", true) linkSubdir(t, srcSystemDir, dstSystemDir, "binaries", runtime.GOOS == "linux") + linkSubdir(t, srcSystemDir, dstSystemDir, "oci-cache", runtime.GOOS == "linux") prewarmLogOnce.Do(func() { t.Logf("using prewarmed test cache dir=%s registry=%s", prewarmDir, os.Getenv(testRegistryEnv)) diff --git a/lib/instances/types.go b/lib/instances/types.go index 8dc48837..66c0841f 100644 --- a/lib/instances/types.go +++ b/lib/instances/types.go @@ -224,6 +224,13 @@ type StartInstanceRequest struct { Cmd []string // Override cmd (nil = keep previous/image default) } +// UpdateInstanceRequest is the domain request for updating a running instance. +// Currently supports updating env vars referenced by credential policies +// to enable secret/key rotation without instance restart. +type UpdateInstanceRequest struct { + Env map[string]string // Updated environment variables (merged with existing) +} + // ForkInstanceRequest is the domain request for forking an instance. type ForkInstanceRequest struct { Name string // Required: name for the new forked instance diff --git a/lib/instances/update.go b/lib/instances/update.go new file mode 100644 index 00000000..74ae3384 --- /dev/null +++ b/lib/instances/update.go @@ -0,0 +1,148 @@ +package instances + +import ( + "context" + "fmt" + "log/slog" + "sort" + + "github.com/kernel/hypeman/lib/egressproxy" + "github.com/kernel/hypeman/lib/logger" +) + +type updateInstanceRulesService interface { + UpdateInstanceRules(ctx context.Context, instanceID string, rules []egressproxy.HeaderInjectRuleConfig) error +} + +// updateInstance updates mutable properties of a running instance. +// Currently supports updating env vars referenced by credential policies, +// which causes the egress proxy header inject rules to be recomputed +// with the new secret values — enabling key rotation without restart. +func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInstanceRequest) (*Instance, error) { + log := logger.FromContext(ctx) + + // 1. Load and validate current state + meta, err := m.loadMetadata(id) + if err != nil { + log.ErrorContext(ctx, "failed to load instance metadata", "instance_id", id, "error", err) + return nil, err + } + + inst, err := m.getInstance(ctx, id) + if err != nil { + return nil, fmt.Errorf("get instance: %w", err) + } + + if inst.State != StateRunning && inst.State != StateInitializing { + return nil, fmt.Errorf("%w: instance must be running or initializing to update (current state: %s)", ErrInvalidState, inst.State) + } + + if err := validateUpdateInstanceRequest(meta, req); err != nil { + return nil, err + } + + prevEnv := cloneEnvMap(meta.Env) + nextEnv := cloneEnvMap(meta.Env) + if nextEnv == nil { + nextEnv = make(map[string]string) + } + for k, v := range req.Env { + nextEnv[k] = v + } + + if err := validateCredentialEnvBindings(meta.Credentials, nextEnv); err != nil { + return nil, err + } + + svc := m.getEgressProxyIfExists() + if svc == nil { + log.ErrorContext(ctx, "egress proxy service unavailable for credential update", "instance_id", id) + return nil, fmt.Errorf("egress proxy service unavailable") + } + + if err := applyUpdatedInstanceEnv(ctx, log, id, meta, prevEnv, nextEnv, m.saveMetadata, svc); err != nil { + return nil, err + } + + log.InfoContext(ctx, "instance updated", "instance_id", id) + + updated, err := m.getInstance(ctx, id) + if err != nil { + return nil, fmt.Errorf("get updated instance: %w", err) + } + return updated, nil +} + +func validateUpdateInstanceRequest(meta *metadata, req UpdateInstanceRequest) error { + if len(req.Env) == 0 { + return fmt.Errorf("%w: env must include at least one credential source env var", ErrInvalidRequest) + } + if meta == nil || len(meta.Credentials) == 0 || meta.NetworkEgress == nil || !meta.NetworkEgress.Enabled { + return fmt.Errorf("%w: instance has no credential-backed env vars to update", ErrInvalidRequest) + } + + allowedNames := credentialSourceEnvNames(meta.Credentials) + if len(allowedNames) == 0 { + return fmt.Errorf("%w: instance has no credential-backed env vars to update", ErrInvalidRequest) + } + allowedSet := make(map[string]struct{}, len(allowedNames)) + for _, name := range allowedNames { + allowedSet[name] = struct{}{} + } + + invalidKeys := make([]string, 0) + for key := range req.Env { + if _, ok := allowedSet[key]; ok { + continue + } + invalidKeys = append(invalidKeys, key) + } + if len(invalidKeys) > 0 { + sort.Strings(invalidKeys) + return fmt.Errorf("%w: env keys %v are not credential source env vars; allowed keys: %v", ErrInvalidRequest, invalidKeys, allowedNames) + } + + return nil +} + +func cloneEnvMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func applyUpdatedInstanceEnv(ctx context.Context, log *slog.Logger, instanceID string, meta *metadata, prevEnv map[string]string, nextEnv map[string]string, save func(*metadata) error, svc updateInstanceRulesService) error { + if log == nil { + log = logger.FromContext(ctx) + } + + if svc == nil { + return fmt.Errorf("egress proxy service unavailable") + } + + oldRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, prevEnv) + newRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, nextEnv) + + if err := svc.UpdateInstanceRules(ctx, instanceID, newRules); err != nil { + return fmt.Errorf("update egress proxy rules: %w", err) + } + log.DebugContext(ctx, "updated egress proxy header inject rules", "instance_id", instanceID) + + meta.Env = nextEnv + if err := save(meta); err != nil { + if rollbackErr := svc.UpdateInstanceRules(ctx, instanceID, oldRules); rollbackErr != nil { + meta.Env = prevEnv + return fmt.Errorf("save metadata: %w (failed to roll back egress proxy rules: %v)", err, rollbackErr) + } + meta.Env = prevEnv + log.WarnContext(ctx, "rolled back egress proxy header inject rules after metadata save failure", "instance_id", instanceID, "error", err) + return fmt.Errorf("save metadata: %w", err) + } + + return nil +} diff --git a/lib/instances/update_test.go b/lib/instances/update_test.go new file mode 100644 index 00000000..338483be --- /dev/null +++ b/lib/instances/update_test.go @@ -0,0 +1,172 @@ +package instances + +import ( + "context" + "errors" + "testing" + + "github.com/kernel/hypeman/lib/egressproxy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateUpdateInstanceRequest(t *testing.T) { + baseMeta := &metadata{ + StoredMetadata: StoredMetadata{ + NetworkEgress: &NetworkEgressPolicy{Enabled: true}, + Credentials: map[string]CredentialPolicy{ + "OUTBOUND_OPENAI_KEY": { + Source: CredentialSource{Env: "OUTBOUND_OPENAI_KEY"}, + }, + }, + }, + } + + t.Run("requires at least one env key", func(t *testing.T) { + err := validateUpdateInstanceRequest(baseMeta, UpdateInstanceRequest{}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRequest) + assert.Contains(t, err.Error(), "at least one credential source env var") + }) + + t.Run("rejects instances without credential backed envs", func(t *testing.T) { + err := validateUpdateInstanceRequest(&metadata{}, UpdateInstanceRequest{ + Env: map[string]string{"OUTBOUND_OPENAI_KEY": "rotated"}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRequest) + assert.Contains(t, err.Error(), "no credential-backed env vars") + }) + + t.Run("rejects unrelated env keys", func(t *testing.T) { + err := validateUpdateInstanceRequest(baseMeta, UpdateInstanceRequest{ + Env: map[string]string{"UNRELATED_KEY": "value"}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRequest) + assert.Contains(t, err.Error(), "UNRELATED_KEY") + assert.Contains(t, err.Error(), "OUTBOUND_OPENAI_KEY") + }) + + t.Run("allows credential source env keys", func(t *testing.T) { + err := validateUpdateInstanceRequest(baseMeta, UpdateInstanceRequest{ + Env: map[string]string{"OUTBOUND_OPENAI_KEY": "rotated"}, + }) + require.NoError(t, err) + }) +} + +type fakeUpdateInstanceRulesService struct { + calls [][]egressproxy.HeaderInjectRuleConfig + errs []error +} + +func (f *fakeUpdateInstanceRulesService) UpdateInstanceRules(_ context.Context, _ string, rules []egressproxy.HeaderInjectRuleConfig) error { + copied := make([]egressproxy.HeaderInjectRuleConfig, len(rules)) + copy(copied, rules) + f.calls = append(f.calls, copied) + + if len(f.errs) == 0 { + return nil + } + err := f.errs[0] + f.errs = f.errs[1:] + return err +} + +func TestApplyUpdatedInstanceEnvWithoutProxyService(t *testing.T) { + t.Parallel() + + meta := &metadata{ + StoredMetadata: StoredMetadata{ + Id: "inst-no-proxy", + Env: map[string]string{"OUTBOUND_OPENAI_KEY": "old"}, + }, + } + prevEnv := cloneEnvMap(meta.Env) + nextEnv := map[string]string{"OUTBOUND_OPENAI_KEY": "new"} + + err := applyUpdatedInstanceEnv(context.Background(), nil, meta.Id, meta, prevEnv, nextEnv, func(saved *metadata) error { + t.Fatalf("save should not be called when proxy service is unavailable") + return nil + }, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "egress proxy service unavailable") + assert.Equal(t, prevEnv, meta.Env) +} + +func TestApplyUpdatedInstanceEnvRollsBackRulesOnSaveFailure(t *testing.T) { + t.Parallel() + + meta := &metadata{ + StoredMetadata: StoredMetadata{ + Id: "inst-save-rollback", + NetworkEgress: &NetworkEgressPolicy{Enabled: true}, + Credentials: map[string]CredentialPolicy{ + "OUTBOUND_OPENAI_KEY": { + Source: CredentialSource{Env: "OUTBOUND_OPENAI_KEY"}, + Inject: []CredentialInjectRule{{ + As: CredentialInjectAs{ + Header: "Authorization", + Format: "Bearer ${value}", + }, + }}, + }, + }, + Env: map[string]string{"OUTBOUND_OPENAI_KEY": "old"}, + }, + } + prevEnv := cloneEnvMap(meta.Env) + nextEnv := map[string]string{"OUTBOUND_OPENAI_KEY": "new"} + svc := &fakeUpdateInstanceRulesService{} + saveErr := errors.New("disk full") + + err := applyUpdatedInstanceEnv(context.Background(), nil, meta.Id, meta, prevEnv, nextEnv, func(*metadata) error { + return saveErr + }, svc) + require.Error(t, err) + assert.ErrorContains(t, err, "save metadata") + assert.ErrorContains(t, err, saveErr.Error()) + assert.Equal(t, prevEnv, meta.Env) + require.Len(t, svc.calls, 2) + assert.Equal(t, buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, nextEnv), svc.calls[0]) + assert.Equal(t, buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, prevEnv), svc.calls[1]) +} + +func TestApplyUpdatedInstanceEnvReturnsRollbackFailure(t *testing.T) { + t.Parallel() + + meta := &metadata{ + StoredMetadata: StoredMetadata{ + Id: "inst-double-failure", + NetworkEgress: &NetworkEgressPolicy{Enabled: true}, + Credentials: map[string]CredentialPolicy{ + "OUTBOUND_OPENAI_KEY": { + Source: CredentialSource{Env: "OUTBOUND_OPENAI_KEY"}, + Inject: []CredentialInjectRule{{ + As: CredentialInjectAs{ + Header: "Authorization", + Format: "Bearer ${value}", + }, + }}, + }, + }, + Env: map[string]string{"OUTBOUND_OPENAI_KEY": "old"}, + }, + } + prevEnv := cloneEnvMap(meta.Env) + nextEnv := map[string]string{"OUTBOUND_OPENAI_KEY": "new"} + saveErr := errors.New("save failed") + rollbackErr := errors.New("rollback failed") + svc := &fakeUpdateInstanceRulesService{errs: []error{nil, rollbackErr}} + + err := applyUpdatedInstanceEnv(context.Background(), nil, meta.Id, meta, prevEnv, nextEnv, func(*metadata) error { + return saveErr + }, svc) + require.Error(t, err) + assert.ErrorContains(t, err, "save metadata") + assert.ErrorContains(t, err, saveErr.Error()) + assert.ErrorContains(t, err, rollbackErr.Error()) + assert.Equal(t, prevEnv, meta.Env) + require.Len(t, svc.calls, 2) +} diff --git a/lib/logger/logger.go b/lib/logger/logger.go index d539930b..42794431 100644 --- a/lib/logger/logger.go +++ b/lib/logger/logger.go @@ -19,6 +19,7 @@ const loggerKey contextKey = "logger" const ( SubsystemAPI = "API" SubsystemCaddy = "CADDY" + SubsystemEgress = "EGRESS" SubsystemImages = "IMAGES" SubsystemIngress = "INGRESS" SubsystemInstances = "INSTANCES" @@ -56,7 +57,7 @@ func NewConfig() Config { // Parse subsystem-specific levels subsystems := []string{ - SubsystemAPI, SubsystemCaddy, SubsystemImages, SubsystemIngress, + SubsystemAPI, SubsystemCaddy, SubsystemEgress, SubsystemImages, SubsystemIngress, SubsystemInstances, SubsystemNetwork, SubsystemVolumes, SubsystemVMM, SubsystemSystem, SubsystemExec, } diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 161067ad..cb38c56d 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -1039,6 +1039,14 @@ type SnapshotTargetState string // Tags User-defined key-value tags. type Tags map[string]string +// UpdateInstanceRequest defines model for UpdateInstanceRequest. +type UpdateInstanceRequest struct { + // Env Environment variables to update (merged with existing). + // Only keys referenced by the instance's existing credential `source.env` bindings + // are accepted. Use this to rotate real credential values without restarting the VM. + Env *map[string]string `json:"env,omitempty"` +} + // Volume defines model for Volume. type Volume struct { // Attachments List of current attachments (empty if not attached) @@ -1264,6 +1272,9 @@ type CreateIngressJSONRequestBody = CreateIngressRequest // CreateInstanceJSONRequestBody defines body for CreateInstance for application/json ContentType. type CreateInstanceJSONRequestBody = CreateInstanceRequest +// UpdateInstanceJSONRequestBody defines body for UpdateInstance for application/json ContentType. +type UpdateInstanceJSONRequestBody = UpdateInstanceRequest + // ForkInstanceJSONRequestBody defines body for ForkInstance for application/json ContentType. type ForkInstanceJSONRequestBody = ForkInstanceRequest @@ -1435,6 +1446,11 @@ type ClientInterface interface { // GetInstance request GetInstance(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) + // UpdateInstanceWithBody request with any body + UpdateInstanceWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + UpdateInstance(ctx context.Context, id string, body UpdateInstanceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ForkInstanceWithBody request with any body ForkInstanceWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1840,6 +1856,30 @@ func (c *Client) GetInstance(ctx context.Context, id string, reqEditors ...Reque return c.Client.Do(req) } +func (c *Client) UpdateInstanceWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateInstanceRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UpdateInstance(ctx context.Context, id string, body UpdateInstanceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateInstanceRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ForkInstanceWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewForkInstanceRequestWithBody(c.Server, id, contentType, body) if err != nil { @@ -3088,6 +3128,53 @@ func NewGetInstanceRequest(server string, id string) (*http.Request, error) { return req, nil } +// NewUpdateInstanceRequest calls the generic UpdateInstance builder with application/json body +func NewUpdateInstanceRequest(server string, id string, body UpdateInstanceJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewUpdateInstanceRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewUpdateInstanceRequestWithBody generates requests for UpdateInstance with any type of body +func NewUpdateInstanceRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/instances/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PATCH", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewForkInstanceRequest calls the generic ForkInstance builder with application/json body func NewForkInstanceRequest(server string, id string, body ForkInstanceJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -4277,6 +4364,11 @@ type ClientWithResponsesInterface interface { // GetInstanceWithResponse request GetInstanceWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetInstanceResponse, error) + // UpdateInstanceWithBodyWithResponse request with any body + UpdateInstanceWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateInstanceResponse, error) + + UpdateInstanceWithResponse(ctx context.Context, id string, body UpdateInstanceJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateInstanceResponse, error) + // ForkInstanceWithBodyWithResponse request with any body ForkInstanceWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ForkInstanceResponse, error) @@ -4916,6 +5008,32 @@ func (r GetInstanceResponse) StatusCode() int { return 0 } +type UpdateInstanceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Instance + JSON400 *Error + JSON404 *Error + JSON409 *Error + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r UpdateInstanceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UpdateInstanceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ForkInstanceResponse struct { Body []byte HTTPResponse *http.Response @@ -5701,6 +5819,23 @@ func (c *ClientWithResponses) GetInstanceWithResponse(ctx context.Context, id st return ParseGetInstanceResponse(rsp) } +// UpdateInstanceWithBodyWithResponse request with arbitrary body returning *UpdateInstanceResponse +func (c *ClientWithResponses) UpdateInstanceWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateInstanceResponse, error) { + rsp, err := c.UpdateInstanceWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateInstanceResponse(rsp) +} + +func (c *ClientWithResponses) UpdateInstanceWithResponse(ctx context.Context, id string, body UpdateInstanceJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateInstanceResponse, error) { + rsp, err := c.UpdateInstance(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateInstanceResponse(rsp) +} + // ForkInstanceWithBodyWithResponse request with arbitrary body returning *ForkInstanceResponse func (c *ClientWithResponses) ForkInstanceWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ForkInstanceResponse, error) { rsp, err := c.ForkInstanceWithBody(ctx, id, contentType, body, reqEditors...) @@ -6917,6 +7052,60 @@ func ParseGetInstanceResponse(rsp *http.Response) (*GetInstanceResponse, error) return response, nil } +// ParseUpdateInstanceResponse parses an HTTP response from a UpdateInstanceWithResponse call +func ParseUpdateInstanceResponse(rsp *http.Response) (*UpdateInstanceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UpdateInstanceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Instance + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseForkInstanceResponse parses an HTTP response from a ForkInstanceWithResponse call func ParseForkInstanceResponse(rsp *http.Response) (*ForkInstanceResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -7994,6 +8183,9 @@ type ServerInterface interface { // Get instance details // (GET /instances/{id}) GetInstance(w http.ResponseWriter, r *http.Request, id string) + // Update instance properties + // (PATCH /instances/{id}) + UpdateInstance(w http.ResponseWriter, r *http.Request, id string) // Fork an instance from stopped, standby, or running (with from_running=true) // (POST /instances/{id}/fork) ForkInstance(w http.ResponseWriter, r *http.Request, id string) @@ -8204,6 +8396,12 @@ func (_ Unimplemented) GetInstance(w http.ResponseWriter, r *http.Request, id st w.WriteHeader(http.StatusNotImplemented) } +// Update instance properties +// (PATCH /instances/{id}) +func (_ Unimplemented) UpdateInstance(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + // Fork an instance from stopped, standby, or running (with from_running=true) // (POST /instances/{id}/fork) func (_ Unimplemented) ForkInstance(w http.ResponseWriter, r *http.Request, id string) { @@ -9004,6 +9202,37 @@ func (siw *ServerInterfaceWrapper) GetInstance(w http.ResponseWriter, r *http.Re handler.ServeHTTP(w, r) } +// UpdateInstance operation middleware +func (siw *ServerInterfaceWrapper) UpdateInstance(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateInstance(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ForkInstance operation middleware func (siw *ServerInterfaceWrapper) ForkInstance(w http.ResponseWriter, r *http.Request) { @@ -9994,6 +10223,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/instances/{id}", wrapper.GetInstance) }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/instances/{id}", wrapper.UpdateInstance) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/instances/{id}/fork", wrapper.ForkInstance) }) @@ -10981,6 +11213,60 @@ func (response GetInstance500JSONResponse) VisitGetInstanceResponse(w http.Respo return json.NewEncoder(w).Encode(response) } +type UpdateInstanceRequestObject struct { + Id string `json:"id"` + Body *UpdateInstanceJSONRequestBody +} + +type UpdateInstanceResponseObject interface { + VisitUpdateInstanceResponse(w http.ResponseWriter) error +} + +type UpdateInstance200JSONResponse Instance + +func (response UpdateInstance200JSONResponse) VisitUpdateInstanceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateInstance400JSONResponse Error + +func (response UpdateInstance400JSONResponse) VisitUpdateInstanceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateInstance404JSONResponse Error + +func (response UpdateInstance404JSONResponse) VisitUpdateInstanceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateInstance409JSONResponse Error + +func (response UpdateInstance409JSONResponse) VisitUpdateInstanceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateInstance500JSONResponse Error + +func (response UpdateInstance500JSONResponse) VisitUpdateInstanceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type ForkInstanceRequestObject struct { Id string `json:"id"` Body *ForkInstanceJSONRequestBody @@ -12030,6 +12316,9 @@ type StrictServerInterface interface { // Get instance details // (GET /instances/{id}) GetInstance(ctx context.Context, request GetInstanceRequestObject) (GetInstanceResponseObject, error) + // Update instance properties + // (PATCH /instances/{id}) + UpdateInstance(ctx context.Context, request UpdateInstanceRequestObject) (UpdateInstanceResponseObject, error) // Fork an instance from stopped, standby, or running (with from_running=true) // (POST /instances/{id}/fork) ForkInstance(ctx context.Context, request ForkInstanceRequestObject) (ForkInstanceResponseObject, error) @@ -12747,6 +13036,39 @@ func (sh *strictHandler) GetInstance(w http.ResponseWriter, r *http.Request, id } } +// UpdateInstance operation middleware +func (sh *strictHandler) UpdateInstance(w http.ResponseWriter, r *http.Request, id string) { + var request UpdateInstanceRequestObject + + request.Id = id + + var body UpdateInstanceJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateInstance(ctx, request.(UpdateInstanceRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateInstance") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateInstanceResponseObject); ok { + if err := validResponse.VisitUpdateInstanceResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ForkInstance operation middleware func (sh *strictHandler) ForkInstance(w http.ResponseWriter, r *http.Request, id string) { var request ForkInstanceRequestObject @@ -13374,217 +13696,222 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "//O3dQn9MAEA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/scopes/scopes.go b/lib/scopes/scopes.go index 1ebbc49a..f1ae6087 100644 --- a/lib/scopes/scopes.go +++ b/lib/scopes/scopes.go @@ -239,6 +239,7 @@ var RouteScopes = map[string]Scope{ "GET /instances/{id}/stat": InstanceRead, "GET /instances/{id}/stats": InstanceRead, "POST /instances/{id}/stop": InstanceWrite, + "PATCH /instances/{id}": InstanceWrite, "DELETE /instances/{id}/volumes/{volumeId}": VolumeWrite, "POST /instances/{id}/volumes/{volumeId}": VolumeWrite, diff --git a/openapi.yaml b/openapi.yaml index 1f0b5e1a..a54bf367 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -203,6 +203,21 @@ components: $ref: "#/components/schemas/CreateInstanceRequestCredentialInject" minItems: 1 + UpdateInstanceRequest: + type: object + properties: + env: + type: object + minProperties: 1 + additionalProperties: + type: string + description: | + Environment variables to update (merged with existing). + Only keys referenced by the instance's existing credential `source.env` bindings + are accepted. Use this to rotate real credential values without restarting the VM. + example: + OUTBOUND_OPENAI_KEY: new-rotated-key-456 + CreateInstanceRequest: type: object required: [name, image] @@ -1718,7 +1733,60 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + patch: + summary: Update instance properties + description: | + Update mutable properties of a running instance. Currently supports updating + only the environment variables referenced by existing credential policies, + enabling secret/key rotation without instance restart. + operationId: updateInstance + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Instance ID or name + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateInstanceRequest" + responses: + 200: + description: Instance updated + content: + application/json: + schema: + $ref: "#/components/schemas/Instance" + 400: + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 404: + description: Instance not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 409: + description: Invalid state (instance must be running or initializing) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 500: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /instances/{id}/standby: post: summary: Put instance in standby (pause, snapshot, delete VMM) diff --git a/stainless.yaml b/stainless.yaml index 53f516fd..afeb7216 100644 --- a/stainless.yaml +++ b/stainless.yaml @@ -76,6 +76,7 @@ resources: list: get /instances create: post /instances get: get /instances/{id} + update: patch /instances/{id} delete: delete /instances/{id} standby: post /instances/{id}/standby restore: post /instances/{id}/restore