From 1d6ae10d3adb08e431e31d36189b302f36aa49f8 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:26:48 +0200 Subject: [PATCH] Revert log sinks to service configuration --- cmd/api/handlers/log_sinks.go | 55 +++++++++++++++++++ cmd/api/main.go | 3 + cmd/tls/main.go | 17 ++++-- frontend/src/api/log-sinks.ts | 5 ++ .../features/log-sinks/LogSinksPage.test.tsx | 23 ++++++++ .../src/features/log-sinks/LogSinksPage.tsx | 28 ++++++++++ pkg/logsinks/logsinks.go | 26 +++++++++ pkg/logsinks/logsinks_test.go | 31 +++++++++++ 8 files changed, 183 insertions(+), 5 deletions(-) diff --git a/cmd/api/handlers/log_sinks.go b/cmd/api/handlers/log_sinks.go index 69acc07f..422280b8 100644 --- a/cmd/api/handlers/log_sinks.go +++ b/cmd/api/handlers/log_sinks.go @@ -374,6 +374,61 @@ func (h *HandlersApi) LogSinksDeleteHandler(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusNoContent) } +// LogSinksRevertHandler — POST /api/v1/log-sinks/{id}/revert +// +// Flips a sink row's Source from "db" back to "service" so the next +// hot-reload re-syncs the config from the current service configuration +// (flags, env vars, or YAML). The operator clicks "Revert", then +// "Apply" to trigger the reload — the re-seed overwrites the config +// with the service-config values before the exporters are rebuilt. +// +// @Summary Revert sink to service config +// @Description Flips a sink's source back to "service" so the next reload re-syncs the config from the service configuration. No config change happens here — the sync runs in osctrl-tls during the reload. +// @Tags log-sinks +// @Produce json +// @Param id path int true "Sink ID" +// @Success 200 {object} logSinkDTO +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/log-sinks/{id}/revert [post] +func (h *HandlersApi) LogSinksRevertHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, false) + } + user, ok := h.requireLogSinksAdmin(w, r) + if !ok { + return + } + if h.LogSinks == nil { + apiErrorResponse(w, "log sinks not initialized", http.StatusInternalServerError, nil) + return + } + id, err := parseLogSinkID(r) + if err != nil { + apiErrorResponse(w, "invalid sink id", http.StatusBadRequest, err) + return + } + if err := h.LogSinks.RevertToService(id); err != nil { + if errors.Is(err, logsinks.ErrSinkNotFound) { + apiErrorResponse(w, "log sink not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error reverting log sink", http.StatusInternalServerError, err) + return + } + row, err := h.LogSinks.Get(id) + if err != nil { + apiErrorResponse(w, "error getting reverted log sink", http.StatusInternalServerError, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("reverted log sink %q to service config", row.Name), strings.Split(r.RemoteAddr, ":")[0]) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, toLogSinkDTO(row, false)) +} + // LogSinksCloneHandler — POST /api/v1/log-sinks/clone // // @Summary Clone sinks from one environment to another diff --git a/cmd/api/main.go b/cmd/api/main.go index 721bd65b..2e19a202 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1054,6 +1054,9 @@ func osctrlAPIService() { muxAPI.Handle( "DELETE "+_apiPath(apiLogSinksPath)+"/{id}", handlerAuthCheck(http.HandlerFunc(handlersApi.LogSinksDeleteHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiLogSinksPath)+"/{id}/revert", + handlerAuthCheck(http.HandlerFunc(handlersApi.LogSinksRevertHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiLogSinksPath)+"/clone", handlerAuthCheck(http.HandlerFunc(handlersApi.LogSinksCloneHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) diff --git a/cmd/tls/main.go b/cmd/tls/main.go index 80447fd9..e052c85a 100644 --- a/cmd/tls/main.go +++ b/cmd/tls/main.go @@ -552,11 +552,18 @@ func watchServiceCommands(ctx context.Context, mgr *servicecommands.Manager, cfg auditLog.SettingsAction(cmd.RequestedBy, fmt.Sprintf("tls persist-config command %s consumed", cmd.CommandID), "local") log.Info().Str("command", cmd.CommandID).Msg("Persisted TLS config to file") case servicecommands.ActionReloadLogSinks: - // Hot-reload log sinks without restarting. Rebuild the - // per-environment exporter map from the DB and atomically - // swap it in. The old exporters are closed by - // ReplaceExporters; in-flight logs to them may be dropped - // during the swap. + // Hot-reload log sinks without restarting. Re-seed from the + // service configuration first so reverted rows (Source flipped + // back to "service" by the API) get their config synced from + // the current flags/env/YAML, then rebuild the per-environment + // exporter map and atomically swap it in. The old exporters + // are closed by ReplaceExporters; in-flight logs to them may + // be dropped during the swap. + if err := sinksMgr.Seed(flagParams, settings.NoEnvironmentID); err != nil { + log.Err(err).Str("command", cmd.CommandID).Msg("error re-seeding log sinks during reload") + auditLog.SettingsAction(cmd.RequestedBy, fmt.Sprintf("tls reload-log-sinks command %s failed: %v", cmd.CommandID, err), "local") + continue + } newExporters, err := sinksMgr.BuildExportersForEnvironments(settingsMgr) if err != nil { log.Err(err).Str("command", cmd.CommandID).Msg("error rebuilding log sink exporters") diff --git a/frontend/src/api/log-sinks.ts b/frontend/src/api/log-sinks.ts index 7702ed16..823c626d 100644 --- a/frontend/src/api/log-sinks.ts +++ b/frontend/src/api/log-sinks.ts @@ -122,6 +122,11 @@ export function deleteLogSink(id: number): Promise { return apiFetch(`/api/v1/log-sinks/${id}`, { method: 'DELETE' }); } +/** POST /api/v1/log-sinks/{id}/revert — flip source back to "service" so the next reload re-syncs from the service config. */ +export function revertLogSink(id: number): Promise { + return apiFetch(`/api/v1/log-sinks/${id}/revert`, { method: 'POST' }); +} + /** Body for POST /api/v1/log-sinks/clone. */ export interface LogSinkCloneRequest { source_environment_id: number; diff --git a/frontend/src/features/log-sinks/LogSinksPage.test.tsx b/frontend/src/features/log-sinks/LogSinksPage.test.tsx index 2a004f49..b7e8add4 100644 --- a/frontend/src/features/log-sinks/LogSinksPage.test.tsx +++ b/frontend/src/features/log-sinks/LogSinksPage.test.tsx @@ -19,6 +19,7 @@ const mockTypes = vi.fn<() => Promise>(); const mockCreate = vi.fn(); const mockUpdate = vi.fn(); const mockDelete = vi.fn(); +const mockRevert = vi.fn<(id: number) => Promise>(); const mockClone = vi.fn(); const mockApply = vi.fn(); const mockGetSink = vi.fn<(id: number, reveal?: boolean) => Promise>(); @@ -30,6 +31,7 @@ vi.mock('$/api/log-sinks', () => ({ createLogSink: (...args: unknown[]) => mockCreate(...args), updateLogSink: (...args: unknown[]) => mockUpdate(...args), deleteLogSink: (id: number) => mockDelete(id), + revertLogSink: (id: number) => mockRevert(id), cloneLogSinks: (...args: unknown[]) => mockClone(...args), applyLogSinks: () => mockApply(), getLogSink: (id: number, reveal?: boolean) => mockGetSink(id, reveal), @@ -172,6 +174,27 @@ describe('LogSinksPage', () => { expect(screen.getByText('second')).toBeInTheDocument(); }); + it('shows a Revert button for edited sinks and calls revertLogSink', async () => { + mockRevert.mockResolvedValue(makeSink({ id: 1, name: 'edited', source: 'service' })); + mockList.mockResolvedValue([makeSink({ id: 1, name: 'edited', source: 'db' })]); + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderPage(); + await screen.findByText('Revert'); + const revertBtn = screen.getByText('Revert'); + expect(revertBtn).toBeInTheDocument(); + await user.click(revertBtn); + await waitFor(() => expect(mockRevert).toHaveBeenCalledWith(1)); + vi.mocked(window.confirm).mockRestore(); + }); + + it('does not show a Revert button for seed sinks', async () => { + mockList.mockResolvedValue([makeSink({ id: 1, name: 'seeded', source: 'service' })]); + renderPage(); + await waitFor(() => expect(screen.getByText('seeded')).toBeInTheDocument()); + expect(screen.queryByText('Revert')).not.toBeInTheDocument(); + }); + it('opens the type picker, picks splunk, and creates a sink with typed config', async () => { mockCreate.mockResolvedValue(makeSink({ id: 9, name: 'new' })); const user = userEvent.setup(); diff --git a/frontend/src/features/log-sinks/LogSinksPage.tsx b/frontend/src/features/log-sinks/LogSinksPage.tsx index 2ac1480c..beb1b78f 100644 --- a/frontend/src/features/log-sinks/LogSinksPage.tsx +++ b/frontend/src/features/log-sinks/LogSinksPage.tsx @@ -8,6 +8,7 @@ import { createLogSink, updateLogSink, deleteLogSink, + revertLogSink, cloneLogSinks, applyLogSinks, getLogSink, @@ -199,6 +200,18 @@ export function LogSinksPage() { }, }); + const revertMutation = useMutation({ + mutationFn: (id: number) => revertLogSink(id), + onSuccess: () => invalidate(), + onError: (e) => { + if (e instanceof AuthError) { + void navigate({ to: '/login' }); + return; + } + setApplyErr(e instanceof Error ? e.message : 'Revert failed'); + }, + }); + const cloneMutation = useMutation({ mutationFn: cloneLogSinks, onSuccess: () => { @@ -573,6 +586,21 @@ export function LogSinksPage() { > Edit + {s.source === 'db' && ( + + )}