diff --git a/sandbox-api/docs/docs.go b/sandbox-api/docs/docs.go index fad76e9..59ad208 100644 --- a/sandbox-api/docs/docs.go +++ b/sandbox-api/docs/docs.go @@ -272,6 +272,38 @@ const docTemplate = `{ } } }, + "/environment/reload": { + "post": { + "description": "Re-reads /bl/metadata and applies its environment to the sandbox-api process, so this process and every process started afterwards see the current values. Called by the guest init after an environment update; safe to call manually.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Reload environment from guest metadata", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ReloadResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, "/filesystem-content-search/{path}": { "get": { "description": "Searches for text content inside files using ripgrep. Returns matching lines with context.", @@ -2758,6 +2790,20 @@ const docTemplate = `{ } } }, + "handler.ReloadResponse": { + "type": "object", + "properties": { + "applied": { + "type": "integer" + }, + "generation": { + "type": "integer" + }, + "removed": { + "type": "integer" + } + } + }, "process.UpgradeState": { "type": "string", "enum": [ diff --git a/sandbox-api/docs/openapi.yml b/sandbox-api/docs/openapi.yml index 1891664..f6c4b9a 100644 --- a/sandbox-api/docs/openapi.yml +++ b/sandbox-api/docs/openapi.yml @@ -249,6 +249,31 @@ paths: summary: Detach a drive from a local path tags: - drive + /environment/reload: + post: + description: Re-reads /bl/metadata and applies its environment to the sandbox-api process, so this process and every process started afterwards see the current values. Called by the guest init after an environment update; safe to call manually. + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/handler.ReloadResponse" + "404": + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + summary: Reload environment from guest metadata + tags: + - system "/filesystem-content-search/{path}": get: description: Searches for text content inside files using ripgrep. Returns matching lines with context. @@ -2181,6 +2206,15 @@ components: uploadedAt: type: string type: object + handler.ReloadResponse: + properties: + applied: + type: integer + generation: + type: integer + removed: + type: integer + type: object process.UpgradeState: enum: - idle diff --git a/sandbox-api/src/api/router.go b/sandbox-api/src/api/router.go index 5bae8a6..4f991e7 100644 --- a/sandbox-api/src/api/router.go +++ b/sandbox-api/src/api/router.go @@ -188,6 +188,11 @@ func SetupRouter(disableRequestLogging bool, enableProcessingTime bool) *gin.Eng logrus.Info("Terminal endpoint disabled via DISABLE_TERMINAL environment variable") } + // Environment routes: the guest init calls this after applying a new + // metadata generation so the process env follows without a restart. + environmentHandler := handler.NewEnvironmentHandler() + r.POST("/environment/reload", environmentHandler.HandleReload) + // System routes r.POST("/upgrade", systemHandler.HandleUpgrade) r.HEAD("/upgrade", head) diff --git a/sandbox-api/src/handler/environment.go b/sandbox-api/src/handler/environment.go new file mode 100644 index 0000000..1e446db --- /dev/null +++ b/sandbox-api/src/handler/environment.go @@ -0,0 +1,131 @@ +package handler + +import ( + "net/http" + "os" + "sync" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// defaultMetadataPath is the guest metadata document served by the host +// (vmm-manager) through the initrd's FUSE mount. It always reflects the +// current generation: on an environment update the host persists the new set, +// the initrd refetches the document, then pings POST /environment/reload so +// this process adopts it. Absent on VMs booted from an initrd that predates +// the metadata protocol. Overridable through BL_METADATA_PATH. +const defaultMetadataPath = "/bl/metadata" + +func metadataPath() string { + if path := os.Getenv("BL_METADATA_PATH"); path != "" { + return path + } + return defaultMetadataPath +} + +// metadataDocument is the subset of the guest metadata document this handler +// reads. The environment carried is the host's complete set, so a variable the +// host no longer has must be unset here too. +type metadataDocument struct { + Generation int64 `json:"generation"` + Environment map[string]string `json:"environment"` +} + +// EnvironmentHandler applies environment updates to the sandbox-api process +// itself. A live process's environment cannot be changed from outside, so the +// initrd notifies this endpoint after applying a new metadata generation; the +// values are set with os.Setenv, which also makes every process spawned +// afterwards (process API, terminals, restarts) inherit them. +type EnvironmentHandler struct { + *BaseHandler + + // path is resolved once at construction so a metadata document carrying + // BL_METADATA_PATH cannot redirect subsequent reloads. + path string + + mu sync.Mutex + // Keys applied from the metadata document. The document carries the host's + // complete environment set — including the variables the guest booted with, + // which the initrd applied from this same document — so a key a later + // generation no longer carries is unset. Keys never carried by a document + // are never touched. + applied map[string]struct{} +} + +// NewEnvironmentHandler creates a new environment handler. +func NewEnvironmentHandler() *EnvironmentHandler { + return &EnvironmentHandler{ + BaseHandler: NewBaseHandler(), + path: metadataPath(), + applied: map[string]struct{}{}, + } +} + +// ReloadResponse is the response body for the environment reload endpoint. +type ReloadResponse struct { + Generation int64 `json:"generation"` + Applied int `json:"applied"` + Removed int `json:"removed"` +} + +// HandleReload reloads the environment from the guest metadata document +// @Summary Reload environment from guest metadata +// @Description Re-reads /bl/metadata and applies its environment to the sandbox-api process, so this process and every process started afterwards see the current values. Called by the guest init after an environment update; safe to call manually. +// @Tags system +// @Produce json +// @Success 200 {object} ReloadResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /environment/reload [post] +func (h *EnvironmentHandler) HandleReload(c *gin.Context) { + raw, err := os.ReadFile(h.path) + if err != nil { + if os.IsNotExist(err) { + h.SendError(c, http.StatusNotFound, err) + return + } + h.SendError(c, http.StatusInternalServerError, err) + return + } + + var doc metadataDocument + if err := json.Unmarshal(raw, &doc); err != nil { + h.SendError(c, http.StatusInternalServerError, err) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + removed := 0 + for key := range h.applied { + if _, ok := doc.Environment[key]; !ok { + if err := os.Unsetenv(key); err == nil { + removed++ + } + delete(h.applied, key) + } + } + applied := 0 + for key, value := range doc.Environment { + if err := os.Setenv(key, value); err != nil { + logrus.WithError(err).WithField("key", key).Warn("Failed to set environment variable") + continue + } + h.applied[key] = struct{}{} + applied++ + } + + logrus.WithFields(logrus.Fields{ + "generation": doc.Generation, + "applied": applied, + "removed": removed, + }).Info("Environment reloaded from guest metadata") + + c.JSON(http.StatusOK, ReloadResponse{ + Generation: doc.Generation, + Applied: applied, + Removed: removed, + }) +} diff --git a/sandbox-api/src/handler/environment_test.go b/sandbox-api/src/handler/environment_test.go new file mode 100644 index 0000000..9e76dad --- /dev/null +++ b/sandbox-api/src/handler/environment_test.go @@ -0,0 +1,97 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +func performReload(t *testing.T, h *EnvironmentHandler) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/environment/reload", nil) + h.HandleReload(c) + return w +} + +func TestHandleReloadAppliesAndRemoves(t *testing.T) { + doc := filepath.Join(t.TempDir(), "metadata") + t.Setenv("BL_METADATA_PATH", doc) + + h := NewEnvironmentHandler() + t.Cleanup(func() { + os.Unsetenv("RELOAD_TEST_A") + os.Unsetenv("RELOAD_TEST_B") + }) + + if err := os.WriteFile(doc, []byte(`{"generation":1,"environment":{"RELOAD_TEST_A":"1","RELOAD_TEST_B":"2"}}`), 0o600); err != nil { + t.Fatal(err) + } + if w := performReload(t, h); w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if os.Getenv("RELOAD_TEST_A") != "1" || os.Getenv("RELOAD_TEST_B") != "2" { + t.Fatalf("environment not applied") + } + + // A variable the next generation no longer carries is unset; one it still + // carries is updated. + if err := os.WriteFile(doc, []byte(`{"generation":2,"environment":{"RELOAD_TEST_A":"updated"}}`), 0o600); err != nil { + t.Fatal(err) + } + if w := performReload(t, h); w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if os.Getenv("RELOAD_TEST_A") != "updated" { + t.Fatalf("environment not updated") + } + if _, ok := os.LookupEnv("RELOAD_TEST_B"); ok { + t.Fatalf("removed variable still set") + } +} + +func TestHandleReloadRemovesBootTimeVariable(t *testing.T) { + doc := filepath.Join(t.TempDir(), "metadata") + t.Setenv("BL_METADATA_PATH", doc) + // A host-injected variable present in the process since boot: the initrd + // applied it from the same metadata document before exec. + t.Setenv("RELOAD_TEST_BOOT", "from-host") + + h := NewEnvironmentHandler() + + if err := os.WriteFile(doc, []byte(`{"generation":1,"environment":{"RELOAD_TEST_BOOT":"from-host"}}`), 0o600); err != nil { + t.Fatal(err) + } + if w := performReload(t, h); w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if os.Getenv("RELOAD_TEST_BOOT") != "from-host" { + t.Fatalf("environment not applied") + } + + // The document is the host's complete set: dropping the key removes the + // variable, it does not resurface the boot-time value. + if err := os.WriteFile(doc, []byte(`{"generation":2,"environment":{}}`), 0o600); err != nil { + t.Fatal(err) + } + if w := performReload(t, h); w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got, ok := os.LookupEnv("RELOAD_TEST_BOOT"); ok { + t.Fatalf("removed variable still set, got %q", got) + } +} + +func TestHandleReloadWithoutMetadata(t *testing.T) { + t.Setenv("BL_METADATA_PATH", filepath.Join(t.TempDir(), "missing")) + + if w := performReload(t, NewEnvironmentHandler()); w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +}