Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions cmd/api/handlers/log_sinks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
17 changes: 12 additions & 5 deletions cmd/tls/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/api/log-sinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ export function deleteLogSink(id: number): Promise<void> {
return apiFetch<void>(`/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<LogSink> {
return apiFetch<LogSink>(`/api/v1/log-sinks/${id}/revert`, { method: 'POST' });
}

/** Body for POST /api/v1/log-sinks/clone. */
export interface LogSinkCloneRequest {
source_environment_id: number;
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/features/log-sinks/LogSinksPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const mockTypes = vi.fn<() => Promise<LogSinkTypeSpec[]>>();
const mockCreate = vi.fn();
const mockUpdate = vi.fn();
const mockDelete = vi.fn();
const mockRevert = vi.fn<(id: number) => Promise<LogSink>>();
const mockClone = vi.fn();
const mockApply = vi.fn();
const mockGetSink = vi.fn<(id: number, reveal?: boolean) => Promise<LogSink>>();
Expand All @@ -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),
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/features/log-sinks/LogSinksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createLogSink,
updateLogSink,
deleteLogSink,
revertLogSink,
cloneLogSinks,
applyLogSinks,
getLogSink,
Expand Down Expand Up @@ -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: () => {
Expand Down Expand Up @@ -573,6 +586,21 @@ export function LogSinksPage() {
>
Edit
</button>
{s.source === 'db' && (
<button
type="button"
disabled={revertMutation.isPending}
onClick={() => {
if (confirm(`Revert "${s.name}" to service config? The config will be re-synced from the YAML/flags on the next Apply.`)) {
revertMutation.mutate(s.id);
}
}}
title="Reset this sink back to the service configuration values. Takes effect on the next Apply."
className="px-2 py-1 text-xs font-medium rounded text-[color:var(--text-2)] hover:text-[color:var(--text-1)] hover:bg-[color:var(--bg-2)] transition-colors disabled:opacity-50"
>
Revert
</button>
)}
<button
type="button"
disabled={deleteMutation.isPending}
Expand Down
26 changes: 26 additions & 0 deletions pkg/logsinks/logsinks.go
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,32 @@ func (m *LogSinksManager) Delete(id uint) error {
return nil
}

// RevertToService flips a sink row's Source from "db" back to "service",
// so the next Seed (at TLS boot or during hot-reload) re-syncs the config
// from the current service configuration (flags, env vars, or YAML). The
// config itself is NOT changed here — the sync happens in the TLS
// process during the reload, which has access to the resolved service
// parameters. 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.
//
// If the row's Source is already "service" (or the legacy "yaml"), this
// is a no-op. Rows that do not exist return ErrSinkNotFound.
func (m *LogSinksManager) RevertToService(id uint) error {
row, err := m.Get(id)
if err != nil {
return err
}
if row.Source != SourceDB {
return nil
}
if err := m.DB.Model(&row).Update("source", SourceService).Error; err != nil {
return fmt.Errorf("revert log sink %d: %w", id, err)
}
log.Debug().Uint("sink_id", id).Str("name", row.Name).Msg("Reverted sink to service config")
return nil
}

// ListByEnvironment returns all sinks for one environment (EnvironmentID
// == envID), ordered by Order then CreatedAt.
func (m *LogSinksManager) ListByEnvironment(envID uint) ([]LogSink, error) {
Expand Down
31 changes: 31 additions & 0 deletions pkg/logsinks/logsinks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,37 @@ func TestDelete(t *testing.T) {
}
}

func TestRevertToService(t *testing.T) {
m := newTestManager(t)
// Create a sink (Source defaults to "db").
row, err := m.Create("s", config.LoggingNone, true, 0, `{}`, 0, "")
if err != nil {
t.Fatalf("create: %v", err)
}
if row.Source != SourceDB {
t.Fatalf("new sink source: got %q want %q", row.Source, SourceDB)
}

// Revert flips Source back to "service".
if err := m.RevertToService(row.ID); err != nil {
t.Fatalf("revert: %v", err)
}
got, _ := m.Get(row.ID)
if got.Source != SourceService {
t.Fatalf("after revert: source=%q want %q", got.Source, SourceService)
}

// Reverting again is a no-op.
if err := m.RevertToService(row.ID); err != nil {
t.Fatalf("revert again: %v", err)
}

// Reverting a non-existent row returns ErrSinkNotFound.
if err := m.RevertToService(99999); !errors.Is(err, ErrSinkNotFound) {
t.Fatalf("revert missing: got %v want ErrSinkNotFound", err)
}
}

func TestListByEnvironmentAndEffectiveFor(t *testing.T) {
m := newTestManager(t)
if _, err := m.Create("g-none", config.LoggingNone, true, 0, `{}`, 0, ""); err != nil {
Expand Down
Loading