diff --git a/internal/api/handlers/sync.go b/internal/api/handlers/sync.go new file mode 100644 index 00000000..0a87043b --- /dev/null +++ b/internal/api/handlers/sync.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "context" + "log/slog" + "net/http" + + "github.com/getstackit/stackit/internal/api/registry" +) + +// ManagedSyncer mirror-fetches and refreshes a managed repo by its GitHub +// coordinates, synchronously. The concrete implementation is *reposync.Syncer. +// The manual-sync handler uses the synchronous form (not the coalescer) so it +// can report success or failure back to the caller. +type ManagedSyncer interface { + SyncRepo(ctx context.Context, owner, name string) error +} + +// SyncHandler serves POST /api/v1/repos/{repoID}/sync: force an immediate +// refresh of one repo on demand. It is the manual complement to the webhook and +// interval refreshes, and the primary way to pull remote changes on a local +// server that GitHub cannot reach with a webhook. +// +// The refresh path depends on whether the repo is a server-managed mirror: +// - Managed mirror: mirror-fetch from the remote, then rebuild. The checkout +// runs detached, so fetching every branch is safe. +// - Local -cwd working repo: only re-read the on-disk refs and rebuild. It is +// never mirror-fetched, because that detaches HEAD and would corrupt the +// operator's working tree (see safety-invariants.md). Pulling the remote on +// a working repo is the human's job (git fetch / stackit sync); this just +// reflects whatever is on disk right now. +type SyncHandler struct { + reg *registry.Registry + syncer ManagedSyncer +} + +// NewSyncHandler wires a manual-sync handler over the registry and syncer. +func NewSyncHandler(reg *registry.Registry, syncer ManagedSyncer) *SyncHandler { + return &SyncHandler{reg: reg, syncer: syncer} +} + +func (h *SyncHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + entry, ok := resolveRepo(h.reg, w, r) + if !ok { + return + } + + if entry.Managed { + if err := h.syncer.SyncRepo(r.Context(), entry.Owner, entry.Name); err != nil { + slog.Warn("manual sync failed", "repo", entry.ID, "error", err) + http.Error(w, "sync failed", http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + + // Unmanaged (local) repo: re-read on-disk refs without fetching, so the + // operator's working tree HEAD is never touched. + entry.Refresh() + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handlers/sync_test.go b/internal/api/handlers/sync_test.go new file mode 100644 index 00000000..888ad515 --- /dev/null +++ b/internal/api/handlers/sync_test.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getstackit/stackit/internal/api/registry" +) + +type fakeManagedSyncer struct { + calls []string + err error +} + +func (f *fakeManagedSyncer) SyncRepo(_ context.Context, owner, name string) error { + f.calls = append(f.calls, owner+"/"+name) + return f.err +} + +func syncRequest(repoID string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/"+repoID+"/sync", nil) + req.SetPathValue("repoID", repoID) + return req +} + +func TestSyncHandler_ManagedRepoMirrorFetches(t *testing.T) { + t.Parallel() + reg := registry.New() + require.NoError(t, reg.Add(®istry.RepoEntry{ID: "m", Managed: true, Owner: "octo", Name: "widget"})) + + sy := &fakeManagedSyncer{} + h := NewSyncHandler(reg, sy) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, syncRequest("m")) + + require.Equal(t, http.StatusNoContent, rr.Code) + require.Equal(t, []string{"octo/widget"}, sy.calls) +} + +func TestSyncHandler_ManagedRepoFetchErrorReturns502(t *testing.T) { + t.Parallel() + reg := registry.New() + require.NoError(t, reg.Add(®istry.RepoEntry{ID: "m", Managed: true, Owner: "octo", Name: "widget"})) + + sy := &fakeManagedSyncer{err: errors.New("fetch boom")} + h := NewSyncHandler(reg, sy) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, syncRequest("m")) + + require.Equal(t, http.StatusBadGateway, rr.Code) +} + +func TestSyncHandler_LocalRepoRefreshesWithoutFetch(t *testing.T) { + t.Parallel() + reg := registry.New() + // Unmanaged repo with no engine: Refresh is a no-op, but the key assertion + // is that the managed mirror-fetch path is never taken for a working repo + // (which would detach its HEAD). + require.NoError(t, reg.Add(®istry.RepoEntry{ID: "default", Managed: false})) + + sy := &fakeManagedSyncer{} + h := NewSyncHandler(reg, sy) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, syncRequest("default")) + + require.Equal(t, http.StatusNoContent, rr.Code) + require.Empty(t, sy.calls, "a local working repo must never be mirror-fetched") +} + +func TestSyncHandler_UnknownRepo404(t *testing.T) { + t.Parallel() + h := NewSyncHandler(registry.New(), &fakeManagedSyncer{}) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, syncRequest("nope")) + + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestSyncHandler_RejectsNonPost(t *testing.T) { + t.Parallel() + reg := registry.New() + require.NoError(t, reg.Add(®istry.RepoEntry{ID: "m", Managed: true, Owner: "o", Name: "n"})) + h := NewSyncHandler(reg, &fakeManagedSyncer{}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/m/sync", nil) + req.SetPathValue("repoID", "m") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + require.Equal(t, http.StatusMethodNotAllowed, rr.Code) +} diff --git a/internal/api/server.go b/internal/api/server.go index 494d0f4b..df459308 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -251,6 +251,16 @@ func (s *Server) buildHandler() (http.Handler, error) { onboardHandler = readOnlyWriteHandler() } + // Manual sync forces a refresh of one repo on demand. It is privileged (it + // drives a git fetch on a managed mirror), so it is session-gated like + // submit and refused on a public read-only server — the webhook and interval + // loop keep public servers fresh without an anonymous trigger. On a local + // auth-disabled server it stays reachable as the on-demand pull. + var syncHandler http.Handler = handlers.NewSyncHandler(reg, s.syncer) + if s.config.ReadOnly { + syncHandler = readOnlyWriteHandler() + } + for _, prefix := range prefixes { // Unscoped index of available repos. apiMux.Handle("GET "+prefix+"/repos", reposListHandler) @@ -263,6 +273,7 @@ func (s *Server) buildHandler() (http.Handler, error) { apiMux.Handle("GET "+prefix+"/repos/{repoID}/stacks", stacksHandler) apiMux.Handle("GET "+prefix+"/repos/{repoID}/stacks/{name...}", stacksHandler) apiMux.Handle("POST "+prefix+"/repos/{repoID}/stacks/{rootBranch}/submit", submitHandler) + apiMux.Handle("POST "+prefix+"/repos/{repoID}/sync", syncHandler) apiMux.Handle("GET "+prefix+"/repos/{repoID}/branches", branchesHandler) apiMux.Handle("GET "+prefix+"/repos/{repoID}/branches/{name...}", branchesHandler) apiMux.Handle("GET "+prefix+"/repos/{repoID}/branch-diff", branchDiffHandler) diff --git a/internal/api/sync_route_test.go b/internal/api/sync_route_test.go new file mode 100644 index 00000000..ba188ceb --- /dev/null +++ b/internal/api/sync_route_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getstackit/stackit/internal/api/auth" +) + +// syncRouteRequest builds a POST to the manual-sync route with the CSRF header +// set so it clears RequireCSRFHeader and reaches the routed handler. +func syncRouteRequest() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/default/sync", nil) + req.Header.Set(auth.CSRFHeader, "1") + return req +} + +func TestReadOnlyModeRefusesManualSync(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, true) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, syncRouteRequest()) + + // Read-only public servers must not expose an anonymous fetch trigger; the + // route is replaced with the write-refusal handler, like submit/onboard. + require.Equal(t, http.StatusMethodNotAllowed, rr.Code) + require.JSONEq(t, `{"error":"server is read-only"}`, rr.Body.String()) +} + +func TestReadWriteModeKeepsManualSyncRoute(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, false) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, syncRouteRequest()) + + // In normal mode the route is live and reaches the real handler. Against an + // empty registry it resolves to an unknown repo (404), not the read-only + // refusal. + require.Equal(t, http.StatusNotFound, rr.Code) + require.NotContains(t, rr.Body.String(), "server is read-only") +}

AltStyle によって変換されたページ (->オリジナル) /