-
Notifications
You must be signed in to change notification settings - Fork 1
feat(api): add manual sync endpoint for on-demand refresh #1319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jonnii
merged 1 commit into
main
from
jonnii/20260623030002/add-manual-sync-endpoint-for-on-demand-refresh
Jun 24, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
67 changes: 67 additions & 0 deletions
internal/api/handlers/sync.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
100 changes: 100 additions & 0 deletions
internal/api/handlers/sync_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
46 changes: 46 additions & 0 deletions
internal/api/sync_route_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.