From 0a8ec1d7c0964059ab23c64304409e3d3baf33ac Mon Sep 17 00:00:00 2001 From: jonnii Date: 2026年6月22日 23:04:05 -0400 Subject: [PATCH] feat(server): default sync interval to 5m and document evented refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync loop was opt-in: STACKIT_SYNC_INTERVAL unset meant 0, which disabled it entirely, so a server with no explicit interval never pulled remote changes. With webhooks now able to drive immediate refreshes, the interval becomes the backstop — so it should exist by default. - envSyncInterval defaults to 5m when STACKIT_SYNC_INTERVAL is unset; "0" still explicitly disables, and an unparseable value falls back to the default rather than silently disabling the loop. - docs/deploy.md: explain the three refresh triggers (interval, webhook, manual), document webhook setup (payload URL, secret, push event), the manual-sync endpoint, and how local servers stay current without webhooks (fsnotify watcher + manual sync). Calls out that GitHub sends no push event for refs/stackit/metadata/*, so the interval loop must stay on as a backstop. --- apps/server/main.go | 26 +++++++--- apps/server/sync_interval_test.go | 34 +++++++++++++ docs/deploy.md | 81 ++++++++++++++++++++++++++++--- 3 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 apps/server/sync_interval_test.go diff --git a/apps/server/main.go b/apps/server/main.go index 2a8abd73..34de68ac 100644 --- a/apps/server/main.go +++ b/apps/server/main.go @@ -77,7 +77,7 @@ func run() error { shutdownGrace = flag.Duration("shutdown-timeout", 10*time.Second, "Graceful shutdown timeout") authDisabled = flag.Bool("auth-disabled", false, "Disable GitHub OAuth gate. Refused when the server binds a non-loopback interface (e.g. STACKIT_ENV=production) unless -read-only is set.") readOnly = flag.Bool("read-only", envBool("STACKIT_READ_ONLY"), "Serve in read-only mode: the submit endpoint is disabled so the repo can be exposed publicly without write access. Also set via STACKIT_READ_ONLY.") - syncInterval = flag.Duration("sync-interval", envDuration("STACKIT_SYNC_INTERVAL"), "How often to mirror-fetch managed repos from their remotes so served state stays current. 0 disables the loop. Also set via STACKIT_SYNC_INTERVAL (e.g. 60s).") + syncInterval = flag.Duration("sync-interval", envSyncInterval(), "How often to mirror-fetch managed repos from their remotes so served state stays current. Defaults to 5m (the webhook backstop); 0 disables the loop. Also set via STACKIT_SYNC_INTERVAL (e.g. 60s).") ) flag.Parse() @@ -293,12 +293,26 @@ func envBool(name string) bool { return err == nil && v } -// envDuration parses the named environment variable as a Go duration (e.g. -// "60s"). Unset or unparseable values yield 0, which disables the sync loop. -func envDuration(name string) time.Duration { - d, err := time.ParseDuration(strings.TrimSpace(os.Getenv(name))) +// defaultSyncInterval is the background mirror-fetch cadence used when +// STACKIT_SYNC_INTERVAL is unset. It exists so the webhook backstop is present +// out of the box: even if a delivery is missed (or a push only touched stackit +// metadata refs, which GitHub sends no push event for), the loop catches up +// within this window. Operators tune it down for fresher state or set 0 to +// disable polling and rely solely on webhooks. +const defaultSyncInterval = 5 * time.Minute + +// envSyncInterval resolves the sync-loop interval default: the value of +// STACKIT_SYNC_INTERVAL when set ("0" explicitly disables the loop), otherwise +// defaultSyncInterval. An unparseable value falls back to the default rather +// than silently disabling the loop. +func envSyncInterval() time.Duration { + raw := strings.TrimSpace(os.Getenv("STACKIT_SYNC_INTERVAL")) + if raw == "" { + return defaultSyncInterval + } + d, err := time.ParseDuration(raw) if err != nil { - return 0 + return defaultSyncInterval } return d } diff --git a/apps/server/sync_interval_test.go b/apps/server/sync_interval_test.go new file mode 100644 index 00000000..763dc2bc --- /dev/null +++ b/apps/server/sync_interval_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestEnvSyncInterval(t *testing.T) { + tests := []struct { + name string + set bool + val string + want time.Duration + }{ + {name: "unset defaults to backstop", set: false, want: defaultSyncInterval}, + {name: "explicit value is honored", set: true, val: "60s", want: time.Minute}, + {name: "zero explicitly disables", set: true, val: "0", want: 0}, + {name: "unparseable falls back to default", set: true, val: "garbage", want: defaultSyncInterval}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.set { + t.Setenv("STACKIT_SYNC_INTERVAL", tt.val) + } else { + // t.Setenv with an empty value still marks it set; to test the + // unset path, clear it for this subtest. + t.Setenv("STACKIT_SYNC_INTERVAL", "") + } + require.Equal(t, tt.want, envSyncInterval()) + }) + } +} diff --git a/docs/deploy.md b/docs/deploy.md index 8ee563ba..6f45cb8f 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -62,7 +62,8 @@ with every authenticated user. | `STACKIT_REPOS_ROOT` | Base directory under which per-repo checkouts live (`//`). Required for DB-backed serving and onboarding. Equivalent to `-repos-root`. | | `STACKIT_GITHUB_APP_ID` | GitHub App ID; enables installation-token auth for onboarding clones and background syncs. See [GitHub App & background sync](#github-app--background-sync). | | `STACKIT_GITHUB_APP_PRIVATE_KEY` / `_FILE` | GitHub App private key (PEM contents, or a path in the `_FILE` variant). | -| `STACKIT_SYNC_INTERVAL` | How often to mirror-fetch managed repos (e.g. `60s`); `0`/unset disables the sync loop. Equivalent to `-sync-interval`. | +| `STACKIT_GITHUB_WEBHOOK_SECRET` | Shared secret GitHub signs webhook deliveries with. Set it to enable the [webhook receiver](#evented-refresh-webhooks) for immediate, push-driven refreshes; unset leaves the endpoint disabled (404). | +| `STACKIT_SYNC_INTERVAL` | How often to mirror-fetch managed repos (e.g. `60s`); defaults to `5m`, `0` disables the sync loop. Equivalent to `-sync-interval`. See [GitHub App & background sync](#github-app--background-sync). | | `STACKIT_BASE_URL` | The canonical https:// URL the server is reachable at. Required when auth is enabled (used to build the OAuth callback URL). | | `STACKIT_GITHUB_CLIENT_ID` | GitHub OAuth App client ID. | | `STACKIT_GITHUB_CLIENT_SECRET` | GitHub OAuth App client secret. | @@ -78,7 +79,7 @@ The most useful flags: |------|---------|---------| | `-database-url` | _(empty)_ | PostgreSQL connection string. Enables DB-backed multi-repo serving and runtime [onboarding](#repository-onboarding). Also settable via `STACKIT_DATABASE_URL`. | | `-repos-root` | _(empty)_ | Base directory for per-repo checkouts (`//`). Required with `-database-url` and for onboarding. Also settable via `STACKIT_REPOS_ROOT`. | -| `-sync-interval` | `0` | How often to mirror-fetch managed repos so served state stays current; `0` disables. Also settable via `STACKIT_SYNC_INTERVAL`. See [GitHub App & background sync](#github-app--background-sync). | +| `-sync-interval` | `5m` | How often to mirror-fetch managed repos so served state stays current; `0` disables. Also settable via `STACKIT_SYNC_INTERVAL`. See [GitHub App & background sync](#github-app--background-sync). | | `-cwd` | _(empty)_ | Single-repo shortcut: serve the repo discovered from this path as `default`. Ignored when `-database-url` is set. | | `-port` | `8080` | Listen port; overrides `$PORT`. | | `-bind` | `127.0.0.1` (or `0.0.0.0` when `STACKIT_ENV=production`) | Interface to bind on. Pass `-bind 0.0.0.0` explicitly to expose the server without setting `STACKIT_ENV=production`. Binding a non-loopback interface requires auth or `-read-only`. | @@ -219,12 +220,29 @@ server-side), so the server can fetch with no user present. | `STACKIT_GITHUB_APP_PRIVATE_KEY` | The App private key, PEM contents. | | `STACKIT_GITHUB_APP_PRIVATE_KEY_FILE` | Path to the PEM file, used when `_PRIVATE_KEY` is empty. | +### How a refresh happens + +Whatever the trigger, a refresh is the same unit of work: rebuild the repo's +engine from its current git refs and broadcast an SSE `refresh` so connected +clients refetch. Three things trigger it: + +1. **The interval loop** — a periodic mirror-fetch of every managed checkout + (below). The reliable backstop. +2. **Webhooks** — an immediate, push-driven refresh of a single repo + ([below](#evented-refresh-webhooks)). The low-latency path. +3. **Manual sync** — `POST /api/v1/repos/{repoID}/sync`, an on-demand refresh + (below). The fallback for local servers and for forcing a pull. + +The interval loop is the floor: webhooks and manual sync make refreshes faster +or on-demand, but the loop guarantees the server converges even if a delivery is +missed. + ### The sync loop -Set **`-sync-interval`** (or `STACKIT_SYNC_INTERVAL`, e.g. `60s`; `0` disables) -to keep served repos current. On each tick the server mirror-fetches every -managed checkout — force-updating local branch heads and stackit metadata from -the remote and pruning deleted refs — then rebuilds and pushes a refresh to +Set **`-sync-interval`** (or `STACKIT_SYNC_INTERVAL`, e.g. `60s`); it defaults to +`5m` and `0` disables it. On each tick the server mirror-fetches every managed +checkout — force-updating local branch heads and stackit metadata from the +remote and pruning deleted refs — then rebuilds and pushes a refresh to connected clients. Newly onboarded repos join the loop automatically. - Only **managed** checkouts (DB-backed / onboarded under the repos root) are @@ -232,8 +250,55 @@ connected clients. Newly onboarded repos join the loop automatically. alone. - Private repos need the GitHub App (above) for the fetch. Without an App the loop still runs but refreshes **public repos only**. -- A recommended interval is `60s` or higher; very short intervals hammer the - remote and add little. +- The default `5m` is a backstop. Pair it with webhooks for fresher state rather + than dropping the interval to a few seconds — short intervals hammer the + remote and add little once webhooks are in play. + +### Evented refresh (webhooks) + +Webhooks make a managed repo refresh **immediately** when someone pushes, +instead of waiting for the next tick. Set **`STACKIT_GITHUB_WEBHOOK_SECRET`** and +point a GitHub webhook at the server: + +1. In the GitHub App (or the repo/org), add a webhook: + - **Payload URL**: `https:///api/v1/webhooks/github` + - **Content type**: `application/json` + - **Secret**: the same value as `STACKIT_GITHUB_WEBHOOK_SECRET` + - **Events**: subscribe to **Pushes** only. +2. Deliveries are authenticated solely by their `X-Hub-Signature-256` HMAC. The + endpoint **fails closed**: with no secret set it returns `404`, so it is never + an open refresh trigger. It is unaffected by read-only mode (a refresh is a + read-side operation). +3. On a verified push the server resolves the repo, mirror-fetches it, and + refreshes — acking GitHub immediately and doing the fetch in the background. + A burst of pushes for one repo coalesces into a single fetch. + +> **Keep the interval loop on as a backstop.** Webhook delivery isn't +> guaranteed (the server may be down when GitHub delivers, and GitHub gives up +> after retries). Crucially, GitHub sends a push event only for `refs/heads/*` +> and `refs/tags/*` — **not** for stackit's `refs/stackit/metadata/*` refs. A +> normal branch push fires a webhook and the mirror-fetch picks up metadata in +> the same pass, but a metadata-only change (e.g. from `describe`) is invisible +> to webhooks and is caught only by the interval loop. So run **both**: webhooks +> for latency, the loop for correctness. + +### Manual sync + +`POST /api/v1/repos/{repoID}/sync` forces a refresh of one repo on demand. It is +session-gated like submit (and refused in read-only mode), so it is never an +anonymous trigger. For a managed mirror it mirror-fetches then rebuilds; for a +local `-cwd` working repo it only re-reads on-disk refs (it never mirror-fetches +a working tree, which would detach its HEAD). + +### Running locally + +Webhooks are a server-mode feature — GitHub can't reach a `localhost` server, so +there's nothing to configure locally, and the endpoint stays disabled. A local +server pointed at a `-cwd` working repo stays current a different way: a +filesystem watcher on the repo's `.git` refs already refreshes on every local +action (commit, branch switch, `git fetch`, `stackit sync`). To pull and reflect +remote changes on demand, run `stackit sync` (or `git fetch`) in the repo — the +watcher fires — or call the manual-sync endpoint above. > Note: GitHub App token minting is exercised by the `ghinstallation` library; > stackit's tests cover the surrounding logic with fakes. Verify end-to-end

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