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(server): default sync interval to 5m and document evented refresh #1320

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
26 changes: 20 additions & 6 deletions apps/server/main.go
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
}
Expand Down
34 changes: 34 additions & 0 deletions apps/server/sync_interval_test.go
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
81 changes: 73 additions & 8 deletions docs/deploy.md
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ with every authenticated user.
| `STACKIT_REPOS_ROOT` | Base directory under which per-repo checkouts live (`<root>/<owner>/<name>`). 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. |
Expand All @@ -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 (`<root>/<owner>/<name>`). 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`. |
Expand Down Expand Up @@ -219,21 +220,85 @@ 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
fetched. A `-cwd` dev repo is the operator's own working tree and is left
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://<your-host>/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
Expand Down
Loading

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