From 2f3ff1a6d0313521329a2f173ae1d2f4eca64a34 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:38:06 +0000 Subject: [PATCH 01/11] feat: support updating egress proxy secret envs on running instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables key rotation for egress proxy credentials without instance restart. Updates are propagated to the running guest via the host-side egress proxy service — the proxy recompiles header inject rules with the new secret values in-place, so the guest never needs to be restarted or signaled. Changes: - Add UpdateInstanceRules() to egressproxy.Service for live rule updates - Add UpdateInstance() to instances.Manager with UpdateInstanceRequest type - Add PATCH /instances/{id} API endpoint (OpenAPI + handler + codegen) - Extend egress proxy integration test with key rotation verification - Update mock in lib/builds to satisfy Manager interface Co-Authored-By: Claude Opus 4.6 --- cmd/api/api/instances.go | 56 ++ lib/builds/manager_test.go | 4 + lib/egressproxy/service.go | 20 + lib/instances/egress_proxy.go | 8 + .../egress_proxy_integration_test.go | 27 + lib/instances/manager.go | 9 + lib/instances/types.go | 7 + lib/instances/update.go | 72 ++ lib/oapi/oapi.go | 747 +++++++++++++----- openapi.yaml | 68 +- 10 files changed, 806 insertions(+), 212 deletions(-) create mode 100644 lib/instances/update.go 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/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/service.go b/lib/egressproxy/service.go index 75cd2e7f..2d6be127 100644 --- a/lib/egressproxy/service.go +++ b/lib/egressproxy/service.go @@ -217,6 +217,26 @@ func compileHeaderInjectRules(cfgRules []HeaderInjectRuleConfig) ([]headerInject return out, nil } +// UpdateInstanceRules replaces the header inject rules for a registered instance. +// Returns an error if the instance is not currently registered. +func (s *Service) UpdateInstanceRules(instanceID string, rules []HeaderInjectRuleConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + + sourceIP, ok := s.sourceIPByInstance[instanceID] + if !ok { + return fmt.Errorf("instance %s is not registered with egress proxy", instanceID) + } + + compiled, err := compileHeaderInjectRules(rules) + if err != nil { + return fmt.Errorf("compile header inject rules: %w", err) + } + + s.policiesBySourceIP[sourceIP] = sourcePolicy{headerInjectRules: compiled} + return nil +} + func (s *Service) UnregisterInstance(_ context.Context, instanceID string) { s.mu.Lock() sourceIP, ok := s.sourceIPByInstance[instanceID] diff --git a/lib/instances/egress_proxy.go b/lib/instances/egress_proxy.go index 8800d72e..05012034 100644 --- a/lib/instances/egress_proxy.go +++ b/lib/instances/egress_proxy.go @@ -194,6 +194,14 @@ func buildEgressProxyInjectRules(egressPolicy *NetworkEgressPolicy, credentials return out } +// 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() diff --git a/lib/instances/egress_proxy_integration_test.go b/lib/instances/egress_proxy_integration_test.go index 96fdd86c..7a47170a 100644 --- a/lib/instances/egress_proxy_integration_test.go +++ b/lib/instances/egress_proxy_integration_test.go @@ -128,6 +128,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..6ef5f3a1 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) @@ -313,6 +314,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/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..c6584f27 --- /dev/null +++ b/lib/instances/update.go @@ -0,0 +1,72 @@ +package instances + +import ( + "context" + "fmt" + + "github.com/kernel/hypeman/lib/logger" +) + +// 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 { + return nil, ErrNotFound + } + + 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) + } + + // 2. Merge new env vars into existing env + if len(req.Env) > 0 { + if meta.Env == nil { + meta.Env = make(map[string]string) + } + for k, v := range req.Env { + meta.Env[k] = v + } + } + + // 3. If credentials are configured, validate bindings and update egress proxy rules + if len(meta.Credentials) > 0 && meta.NetworkEgress != nil && meta.NetworkEgress.Enabled { + if err := validateCredentialEnvBindings(meta.Credentials, meta.Env); err != nil { + return nil, err + } + + newRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, meta.Env) + + svc := m.getEgressProxyIfExists() + if svc != nil { + if err := svc.UpdateInstanceRules(id, newRules); err != nil { + return nil, fmt.Errorf("update egress proxy rules: %w", err) + } + log.InfoContext(ctx, "updated egress proxy header inject rules", "instance_id", id) + } + } + + // 4. Persist updated metadata + if err := m.saveMetadata(meta); err != nil { + return nil, fmt.Errorf("save metadata: %w", err) + } + + log.InfoContext(ctx, "instance updated", "instance_id", id) + + // 5. Return updated instance + updated, err := m.getInstance(ctx, id) + if err != nil { + return nil, fmt.Errorf("get updated instance: %w", err) + } + return updated, nil +} diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 161067ad..50c96357 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -1039,6 +1039,13 @@ 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). + // For credential rotation, update the env vars referenced by credential policies. + 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 +1271,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 +1445,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 +1855,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 +3127,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 +4363,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 +5007,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 +5818,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 +7051,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 +8182,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 +8395,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 +9201,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 +10222,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 +11212,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 +12315,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 +13035,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 +13695,221 @@ 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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", + "AP//XWLyuIQwAQA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/openapi.yaml b/openapi.yaml index 1f0b5e1a..9f09dfba 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -203,6 +203,19 @@ components: $ref: "#/components/schemas/CreateInstanceRequestCredentialInject" minItems: 1 + UpdateInstanceRequest: + type: object + properties: + env: + type: object + additionalProperties: + type: string + description: | + Environment variables to update (merged with existing). + For credential rotation, update the env vars referenced by credential policies. + example: + OUTBOUND_OPENAI_KEY: new-rotated-key-456 + CreateInstanceRequest: type: object required: [name, image] @@ -1718,7 +1731,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 + environment variables referenced by 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) + 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) From 1cc7734668906c5196434e32f7c9d068736d302b Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:42:43 +0000 Subject: [PATCH 02/11] fix: add scope mapping for PATCH /instances/{id} route Co-Authored-By: Claude Opus 4.6 --- lib/scopes/scopes.go | 1 + 1 file changed, 1 insertion(+) 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, From 3de185dcb0e4864391ed14e04c279c14766e246c Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 20 Mar 2026 10:21:04 -0400 Subject: [PATCH 03/11] Tighten instance secret rotation updates --- cmd/api/api/instances_test.go | 141 +++++++++++ lib/instances/egress_proxy.go | 22 ++ lib/instances/update.go | 98 ++++++-- lib/instances/update_test.go | 60 +++++ lib/oapi/oapi.go | 434 +++++++++++++++++----------------- openapi.yaml | 8 +- stainless.yaml | 1 + 7 files changed, 521 insertions(+), 243 deletions(-) create mode 100644 lib/instances/update_test.go 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/instances/egress_proxy.go b/lib/instances/egress_proxy.go index 05012034..aa69a804 100644 --- a/lib/instances/egress_proxy.go +++ b/lib/instances/egress_proxy.go @@ -194,6 +194,28 @@ 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 { diff --git a/lib/instances/update.go b/lib/instances/update.go index c6584f27..e16681ab 100644 --- a/lib/instances/update.go +++ b/lib/instances/update.go @@ -3,6 +3,7 @@ package instances import ( "context" "fmt" + "sort" "github.com/kernel/hypeman/lib/logger" ) @@ -29,44 +30,93 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta return nil, fmt.Errorf("%w: instance must be running or initializing to update (current state: %s)", ErrInvalidState, inst.State) } - // 2. Merge new env vars into existing env - if len(req.Env) > 0 { - if meta.Env == nil { - meta.Env = make(map[string]string) - } - for k, v := range req.Env { - meta.Env[k] = v - } + if err := validateUpdateInstanceRequest(meta, req); err != nil { + return nil, err } - // 3. If credentials are configured, validate bindings and update egress proxy rules - if len(meta.Credentials) > 0 && meta.NetworkEgress != nil && meta.NetworkEgress.Enabled { - if err := validateCredentialEnvBindings(meta.Credentials, meta.Env); err != nil { - return nil, err - } + prevEnv := cloneEnvMap(meta.Env) + if meta.Env == nil { + meta.Env = make(map[string]string) + } + for k, v := range req.Env { + meta.Env[k] = v + } + + if err := validateCredentialEnvBindings(meta.Credentials, meta.Env); err != nil { + return nil, err + } + svc := m.getEgressProxyIfExists() + if svc != nil { + oldRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, prevEnv) newRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, meta.Env) - svc := m.getEgressProxyIfExists() - if svc != nil { - if err := svc.UpdateInstanceRules(id, newRules); err != nil { - return nil, fmt.Errorf("update egress proxy rules: %w", err) - } - log.InfoContext(ctx, "updated egress proxy header inject rules", "instance_id", id) + if err := svc.UpdateInstanceRules(id, newRules); err != nil { + return nil, fmt.Errorf("update egress proxy rules: %w", err) } - } + log.InfoContext(ctx, "updated egress proxy header inject rules", "instance_id", id) - // 4. Persist updated metadata - if err := m.saveMetadata(meta); err != nil { - return nil, fmt.Errorf("save metadata: %w", err) + if err := m.saveMetadata(meta); err != nil { + if rollbackErr := svc.UpdateInstanceRules(id, oldRules); rollbackErr != nil { + return nil, fmt.Errorf("save metadata: %w (failed to roll back egress proxy rules: %v)", err, rollbackErr) + } + log.WarnContext(ctx, "rolled back egress proxy header inject rules after metadata save failure", "instance_id", id, "error", err) + return nil, fmt.Errorf("save metadata: %w", err) + } + } else { + if err := m.saveMetadata(meta); err != nil { + return nil, fmt.Errorf("save metadata: %w", err) + } } log.InfoContext(ctx, "instance updated", "instance_id", id) - // 5. Return updated instance 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 +} diff --git a/lib/instances/update_test.go b/lib/instances/update_test.go new file mode 100644 index 00000000..e9014d28 --- /dev/null +++ b/lib/instances/update_test.go @@ -0,0 +1,60 @@ +package instances + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateUpdateInstanceRequest(t *testing.T) { + t.Parallel() + + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + err := validateUpdateInstanceRequest(baseMeta, UpdateInstanceRequest{ + Env: map[string]string{"OUTBOUND_OPENAI_KEY": "rotated"}, + }) + require.NoError(t, err) + }) +} diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 50c96357..cb38c56d 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -1042,7 +1042,8 @@ type Tags map[string]string // UpdateInstanceRequest defines model for UpdateInstanceRequest. type UpdateInstanceRequest struct { // Env Environment variables to update (merged with existing). - // For credential rotation, update the env vars referenced by credential policies. + // 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"` } @@ -13695,221 +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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", - "AP//XWLyuIQwAQA=", + "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", + "//O3dQn9MAEA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/openapi.yaml b/openapi.yaml index 9f09dfba..aa652c8d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -208,11 +208,13 @@ components: properties: env: type: object + minProperties: 1 additionalProperties: type: string description: | Environment variables to update (merged with existing). - For credential rotation, update the env vars referenced by credential policies. + 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 @@ -1735,8 +1737,8 @@ paths: summary: Update instance properties description: | Update mutable properties of a running instance. Currently supports updating - environment variables referenced by credential policies, enabling secret/key - rotation without instance restart. + only the environment variables referenced by existing credential policies, + enabling secret/key rotation without instance restart. operationId: updateInstance security: - bearerAuth: [] 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 From cc2427b0896d78f69d89de23022b196270af2729 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 20 Mar 2026 10:28:12 -0400 Subject: [PATCH 04/11] Reduce update test parallelism --- lib/instances/update_test.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/instances/update_test.go b/lib/instances/update_test.go index e9014d28..a26a5987 100644 --- a/lib/instances/update_test.go +++ b/lib/instances/update_test.go @@ -8,8 +8,6 @@ import ( ) func TestValidateUpdateInstanceRequest(t *testing.T) { - t.Parallel() - baseMeta := &metadata{ StoredMetadata: StoredMetadata{ NetworkEgress: &NetworkEgressPolicy{Enabled: true}, @@ -22,7 +20,6 @@ func TestValidateUpdateInstanceRequest(t *testing.T) { } t.Run("requires at least one env key", func(t *testing.T) { - t.Parallel() err := validateUpdateInstanceRequest(baseMeta, UpdateInstanceRequest{}) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidRequest) @@ -30,7 +27,6 @@ func TestValidateUpdateInstanceRequest(t *testing.T) { }) t.Run("rejects instances without credential backed envs", func(t *testing.T) { - t.Parallel() err := validateUpdateInstanceRequest(&metadata{}, UpdateInstanceRequest{ Env: map[string]string{"OUTBOUND_OPENAI_KEY": "rotated"}, }) @@ -40,7 +36,6 @@ func TestValidateUpdateInstanceRequest(t *testing.T) { }) t.Run("rejects unrelated env keys", func(t *testing.T) { - t.Parallel() err := validateUpdateInstanceRequest(baseMeta, UpdateInstanceRequest{ Env: map[string]string{"UNRELATED_KEY": "value"}, }) @@ -51,7 +46,6 @@ func TestValidateUpdateInstanceRequest(t *testing.T) { }) t.Run("allows credential source env keys", func(t *testing.T) { - t.Parallel() err := validateUpdateInstanceRequest(baseMeta, UpdateInstanceRequest{ Env: map[string]string{"OUTBOUND_OPENAI_KEY": "rotated"}, }) From 63c6c249a9df6ef89082bbabb6d06fcc3946c57d Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 20 Mar 2026 10:43:44 -0400 Subject: [PATCH 05/11] Instrument egress proxy monitoring --- lib/egressproxy/metrics.go | 158 +++++++++++++++++++++++++++++ lib/egressproxy/service.go | 183 +++++++++++++++++++++++++++++++--- lib/egressproxy/types.go | 11 +- lib/instances/egress_proxy.go | 50 +++++++++- lib/instances/manager.go | 4 + lib/instances/update.go | 6 +- lib/logger/logger.go | 3 +- 7 files changed, 393 insertions(+), 22 deletions(-) create mode 100644 lib/egressproxy/metrics.go diff --git a/lib/egressproxy/metrics.go b/lib/egressproxy/metrics.go new file mode 100644 index 00000000..9660efcc --- /dev/null +++ b/lib/egressproxy/metrics.go @@ -0,0 +1,158 @@ +package egressproxy + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +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"), + ) + if err != nil { + return nil, err + } + + requests, err := meter.Int64Counter( + "hypeman_egress_proxy_requests_total", + metric.WithDescription("Total number of egress proxy upstream request attempts"), + ) + 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"), + ) + 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 2d6be127..add736bf 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(), @@ -219,25 +268,62 @@ func compileHeaderInjectRules(cfgRules []HeaderInjectRuleConfig) ([]headerInject // UpdateInstanceRules replaces the header inject rules for a registered instance. // Returns an error if the instance is not currently registered. -func (s *Service) UpdateInstanceRules(instanceID string, rules []HeaderInjectRuleConfig) error { +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 { - return fmt.Errorf("instance %s is not registered with egress proxy", instanceID) + 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 { - return fmt.Errorf("compile header inject rules: %w", err) + 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(_ context.Context, instanceID string) { +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 { @@ -246,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 { @@ -272,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) @@ -293,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 } @@ -311,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) { @@ -369,10 +474,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 } @@ -383,6 +494,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 @@ -418,15 +531,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 { @@ -494,3 +608,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 aa69a804..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-" @@ -232,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 } @@ -248,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) } @@ -258,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) } @@ -268,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/manager.go b/lib/instances/manager.go index 6ef5f3a1..0f82f7c8 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -85,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 @@ -134,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, } diff --git a/lib/instances/update.go b/lib/instances/update.go index e16681ab..8cd400b8 100644 --- a/lib/instances/update.go +++ b/lib/instances/update.go @@ -51,13 +51,13 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta oldRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, prevEnv) newRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, meta.Env) - if err := svc.UpdateInstanceRules(id, newRules); err != nil { + if err := svc.UpdateInstanceRules(ctx, id, newRules); err != nil { return nil, fmt.Errorf("update egress proxy rules: %w", err) } - log.InfoContext(ctx, "updated egress proxy header inject rules", "instance_id", id) + log.DebugContext(ctx, "updated egress proxy header inject rules", "instance_id", id) if err := m.saveMetadata(meta); err != nil { - if rollbackErr := svc.UpdateInstanceRules(id, oldRules); rollbackErr != nil { + if rollbackErr := svc.UpdateInstanceRules(ctx, id, oldRules); rollbackErr != nil { return nil, fmt.Errorf("save metadata: %w (failed to roll back egress proxy rules: %v)", err, rollbackErr) } log.WarnContext(ctx, "rolled back egress proxy header inject rules after metadata save failure", "instance_id", id, "error", err) 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, } From f409273cb5f7ddfacea4902b095ab3eb8d62b4a1 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 20 Mar 2026 10:49:17 -0400 Subject: [PATCH 06/11] Fix update review findings --- lib/instances/update.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/instances/update.go b/lib/instances/update.go index 8cd400b8..18660310 100644 --- a/lib/instances/update.go +++ b/lib/instances/update.go @@ -18,7 +18,8 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta // 1. Load and validate current state meta, err := m.loadMetadata(id) if err != nil { - return nil, ErrNotFound + log.ErrorContext(ctx, "failed to load instance metadata", "instance_id", id, "error", err) + return nil, err } inst, err := m.getInstance(ctx, id) @@ -35,36 +36,41 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta } prevEnv := cloneEnvMap(meta.Env) - if meta.Env == nil { - meta.Env = make(map[string]string) + nextEnv := cloneEnvMap(meta.Env) + if nextEnv == nil { + nextEnv = make(map[string]string) } for k, v := range req.Env { - meta.Env[k] = v + nextEnv[k] = v } - if err := validateCredentialEnvBindings(meta.Credentials, meta.Env); err != nil { + if err := validateCredentialEnvBindings(meta.Credentials, nextEnv); err != nil { return nil, err } svc := m.getEgressProxyIfExists() if svc != nil { oldRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, prevEnv) - newRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, meta.Env) + newRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, nextEnv) if err := svc.UpdateInstanceRules(ctx, id, newRules); err != nil { return nil, fmt.Errorf("update egress proxy rules: %w", err) } log.DebugContext(ctx, "updated egress proxy header inject rules", "instance_id", id) + meta.Env = nextEnv if err := m.saveMetadata(meta); err != nil { if rollbackErr := svc.UpdateInstanceRules(ctx, id, oldRules); rollbackErr != nil { return nil, 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", id, "error", err) return nil, fmt.Errorf("save metadata: %w", err) } } else { + meta.Env = nextEnv if err := m.saveMetadata(meta); err != nil { + meta.Env = prevEnv return nil, fmt.Errorf("save metadata: %w", err) } } From b94fe04a1d1563b5297c0b16ee7b68a2531bd160 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 20 Mar 2026 10:58:51 -0400 Subject: [PATCH 07/11] Document egress proxy operations --- lib/egressproxy/README.md | 89 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) 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. From 21c75a29ef182d70cdfdfb4ee2e8ad9902a0667a Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 20 Mar 2026 18:02:51 -0400 Subject: [PATCH 08/11] Improve test --- .../egress_proxy_integration_test.go | 45 +++++++++++++++++-- lib/instances/test_prewarm_test.go | 1 + 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/lib/instances/egress_proxy_integration_test.go b/lib/instances/egress_proxy_integration_test.go index 7a47170a..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", 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)) From 9dc7e0794d7383a4db14ad8ac573ef8b11399fff Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 20 Mar 2026 18:07:39 -0400 Subject: [PATCH 09/11] Address review feedback on egress proxy observability --- lib/egressproxy/metrics.go | 9 ++- lib/egressproxy/service.go | 6 ++ lib/instances/update.go | 69 ++++++++++++-------- lib/instances/update_test.go | 120 +++++++++++++++++++++++++++++++++++ openapi.yaml | 2 +- 5 files changed, 179 insertions(+), 27 deletions(-) diff --git a/lib/egressproxy/metrics.go b/lib/egressproxy/metrics.go index 9660efcc..e03ed5bd 100644 --- a/lib/egressproxy/metrics.go +++ b/lib/egressproxy/metrics.go @@ -7,6 +7,11 @@ import ( "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 @@ -46,6 +51,7 @@ func newMetrics(meter metric.Meter, svc *Service) (*metrics, error) { "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 @@ -53,7 +59,7 @@ func newMetrics(meter metric.Meter, svc *Service) (*metrics, error) { requests, err := meter.Int64Counter( "hypeman_egress_proxy_requests_total", - metric.WithDescription("Total number of egress proxy upstream request attempts"), + metric.WithDescription("Total number of egress proxy request handling outcomes"), ) if err != nil { return nil, err @@ -63,6 +69,7 @@ func newMetrics(meter metric.Meter, svc *Service) (*metrics, error) { "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 diff --git a/lib/egressproxy/service.go b/lib/egressproxy/service.go index add736bf..acd6172c 100644 --- a/lib/egressproxy/service.go +++ b/lib/egressproxy/service.go @@ -442,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 } @@ -449,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() diff --git a/lib/instances/update.go b/lib/instances/update.go index 18660310..dd2db230 100644 --- a/lib/instances/update.go +++ b/lib/instances/update.go @@ -3,11 +3,17 @@ 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 @@ -48,31 +54,8 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta return nil, err } - svc := m.getEgressProxyIfExists() - if svc != nil { - oldRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, prevEnv) - newRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, nextEnv) - - if err := svc.UpdateInstanceRules(ctx, id, newRules); err != nil { - return nil, fmt.Errorf("update egress proxy rules: %w", err) - } - log.DebugContext(ctx, "updated egress proxy header inject rules", "instance_id", id) - - meta.Env = nextEnv - if err := m.saveMetadata(meta); err != nil { - if rollbackErr := svc.UpdateInstanceRules(ctx, id, oldRules); rollbackErr != nil { - return nil, 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", id, "error", err) - return nil, fmt.Errorf("save metadata: %w", err) - } - } else { - meta.Env = nextEnv - if err := m.saveMetadata(meta); err != nil { - meta.Env = prevEnv - return nil, fmt.Errorf("save metadata: %w", err) - } + if err := applyUpdatedInstanceEnv(ctx, log, id, meta, prevEnv, nextEnv, m.saveMetadata, m.getEgressProxyIfExists()); err != nil { + return nil, err } log.InfoContext(ctx, "instance updated", "instance_id", id) @@ -126,3 +109,39 @@ func cloneEnvMap(in map[string]string) map[string]string { } 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 { + meta.Env = nextEnv + if err := save(meta); err != nil { + meta.Env = prevEnv + return fmt.Errorf("save metadata: %w", err) + } + return nil + } + + 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 index a26a5987..34b64ad7 100644 --- a/lib/instances/update_test.go +++ b/lib/instances/update_test.go @@ -1,8 +1,11 @@ package instances import ( + "context" + "errors" "testing" + "github.com/kernel/hypeman/lib/egressproxy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -52,3 +55,120 @@ func TestValidateUpdateInstanceRequest(t *testing.T) { 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"} + + saveCalls := 0 + err := applyUpdatedInstanceEnv(context.Background(), nil, meta.Id, meta, prevEnv, nextEnv, func(saved *metadata) error { + saveCalls++ + assert.Equal(t, nextEnv, saved.Env) + return nil + }, nil) + require.NoError(t, err) + assert.Equal(t, 1, saveCalls) + assert.Equal(t, nextEnv, 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/openapi.yaml b/openapi.yaml index aa652c8d..a54bf367 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1775,7 +1775,7 @@ paths: schema: $ref: "#/components/schemas/Error" 409: - description: Invalid state (instance must be running) + description: Invalid state (instance must be running or initializing) content: application/json: schema: From ae7b94a3741b7622701052f0f171ec6e0770e503 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 20 Mar 2026 18:17:20 -0400 Subject: [PATCH 10/11] Handle nil egress proxy update service --- lib/instances/update.go | 9 ++++++++- lib/instances/update_test.go | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/instances/update.go b/lib/instances/update.go index dd2db230..fe18bfbc 100644 --- a/lib/instances/update.go +++ b/lib/instances/update.go @@ -54,7 +54,7 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta return nil, err } - if err := applyUpdatedInstanceEnv(ctx, log, id, meta, prevEnv, nextEnv, m.saveMetadata, m.getEgressProxyIfExists()); err != nil { + if err := applyUpdatedInstanceEnv(ctx, log, id, meta, prevEnv, nextEnv, m.saveMetadata, updateInstanceRulesServiceOrNil(m.getEgressProxyIfExists())); err != nil { return nil, err } @@ -110,6 +110,13 @@ func cloneEnvMap(in map[string]string) map[string]string { return out } +func updateInstanceRulesServiceOrNil(svc *egressproxy.Service) updateInstanceRulesService { + if svc == nil { + return nil + } + return svc +} + 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) diff --git a/lib/instances/update_test.go b/lib/instances/update_test.go index 34b64ad7..7fec917c 100644 --- a/lib/instances/update_test.go +++ b/lib/instances/update_test.go @@ -56,6 +56,16 @@ func TestValidateUpdateInstanceRequest(t *testing.T) { }) } +func TestUpdateInstanceRulesServiceOrNil(t *testing.T) { + t.Parallel() + + var svc *egressproxy.Service + assert.Nil(t, updateInstanceRulesServiceOrNil(svc)) + + typedSvc := updateInstanceRulesService(&fakeUpdateInstanceRulesService{}) + require.NotNil(t, typedSvc) +} + type fakeUpdateInstanceRulesService struct { calls [][]egressproxy.HeaderInjectRuleConfig errs []error From 5bdd7a625fb817f21a49dbb2935c261b47cbcb20 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Fri, 20 Mar 2026 18:22:39 -0400 Subject: [PATCH 11/11] Fail update when egress proxy service is missing --- lib/instances/update.go | 22 ++++++++-------------- lib/instances/update_test.go | 20 ++++---------------- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/lib/instances/update.go b/lib/instances/update.go index fe18bfbc..74ae3384 100644 --- a/lib/instances/update.go +++ b/lib/instances/update.go @@ -54,7 +54,13 @@ func (m *manager) updateInstance(ctx context.Context, id string, req UpdateInsta return nil, err } - if err := applyUpdatedInstanceEnv(ctx, log, id, meta, prevEnv, nextEnv, m.saveMetadata, updateInstanceRulesServiceOrNil(m.getEgressProxyIfExists())); err != nil { + 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 } @@ -110,25 +116,13 @@ func cloneEnvMap(in map[string]string) map[string]string { return out } -func updateInstanceRulesServiceOrNil(svc *egressproxy.Service) updateInstanceRulesService { - if svc == nil { - return nil - } - return svc -} - 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 { - meta.Env = nextEnv - if err := save(meta); err != nil { - meta.Env = prevEnv - return fmt.Errorf("save metadata: %w", err) - } - return nil + return fmt.Errorf("egress proxy service unavailable") } oldRules := buildEgressProxyInjectRules(meta.NetworkEgress, meta.Credentials, prevEnv) diff --git a/lib/instances/update_test.go b/lib/instances/update_test.go index 7fec917c..338483be 100644 --- a/lib/instances/update_test.go +++ b/lib/instances/update_test.go @@ -56,16 +56,6 @@ func TestValidateUpdateInstanceRequest(t *testing.T) { }) } -func TestUpdateInstanceRulesServiceOrNil(t *testing.T) { - t.Parallel() - - var svc *egressproxy.Service - assert.Nil(t, updateInstanceRulesServiceOrNil(svc)) - - typedSvc := updateInstanceRulesService(&fakeUpdateInstanceRulesService{}) - require.NotNil(t, typedSvc) -} - type fakeUpdateInstanceRulesService struct { calls [][]egressproxy.HeaderInjectRuleConfig errs []error @@ -96,15 +86,13 @@ func TestApplyUpdatedInstanceEnvWithoutProxyService(t *testing.T) { prevEnv := cloneEnvMap(meta.Env) nextEnv := map[string]string{"OUTBOUND_OPENAI_KEY": "new"} - saveCalls := 0 err := applyUpdatedInstanceEnv(context.Background(), nil, meta.Id, meta, prevEnv, nextEnv, func(saved *metadata) error { - saveCalls++ - assert.Equal(t, nextEnv, saved.Env) + t.Fatalf("save should not be called when proxy service is unavailable") return nil }, nil) - require.NoError(t, err) - assert.Equal(t, 1, saveCalls) - assert.Equal(t, nextEnv, meta.Env) + require.Error(t, err) + assert.ErrorContains(t, err, "egress proxy service unavailable") + assert.Equal(t, prevEnv, meta.Env) } func TestApplyUpdatedInstanceEnvRollsBackRulesOnSaveFailure(t *testing.T) {