Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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
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
67 changes: 67 additions & 0 deletions internal/api/handlers/sync.go
View file Open in desktop
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
View file Open in desktop
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(&registry.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(&registry.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(&registry.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(&registry.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)
}
11 changes: 11 additions & 0 deletions internal/api/server.go
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions internal/api/sync_route_test.go
View file Open in desktop
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")
}
Loading

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /