Skip to content
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 5 additions & 3 deletions apps/web/content/docs/dev/updates/admin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ Each run fetches every distinct manifest once, even when several plugins share i

Staff with **Plugins → Check for plugin and core updates now** (`plugins.can_check_updates`) see a **Check for updates** button. It runs the same check as the cron job and refreshes the page when it finishes.

- Only one check runs at a time across all API instances. A second click while one runs says _A check is already running_.
- Only one check runs at a time across all API instances, and the daily check shares the same lease. A second click while one runs says _A check is already running_.
- A long check keeps its lease by renewing it every minute. If an API instance crashes mid-check, its lease runs out within five minutes and the next check takes over.
- If the server running a check can no longer confirm it holds the lease, the check stops and says _The check stopped before it finished_. Results stored before that are kept; press the button again a moment later.
- A manual check can run once every two minutes. A click inside that window says how many seconds to wait.
- The endpoint is `POST /api/@vitnode/core/admin/plugins/updates/check`; it answers `409` while a check runs and `429` during the cooldown.
- The endpoint is `POST /api/@vitnode/core/admin/plugins/updates/check`. It answers `409` while a check runs (`"status": "running"`) or when the check was interrupted (`"status": "interrupted"`), and `429` during the cooldown.

Viewing the results only needs **Plugins → View installed plugins and their versions** (`plugins.can_view`).
Viewing the results only needs **Plugins → View installed plugins and their versions** (`plugins.can_view`). The same permission shows the [update count, security alert and System updates widget](/docs/dev/updates/alerts) everywhere else in the AdminCP. How the lease keeps checks exclusive is explained in [How update checks share one lease](/docs/dev/updates/lease).

## Read the results

Expand Down
98 changes: 98 additions & 0 deletions apps/web/content/docs/dev/updates/alerts.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: Update alerts in the AdminCP
description: The update count next to System → Plugins, the security alert in the AdminCP header and the System updates dashboard widget - what each number means, how fresh it is and who sees it.
icon: BellRing
---

import { ImgDocs } from '@/components/fumadocs/img'

import updateAlerts from './admin-update-alerts.webp'

You do not have to open **System → Plugins** to notice an update. VitNode shows the results of the [update checks](/docs/dev/updates/admin) in three more places across the AdminCP: a count in the navigation, a security alert in the header and a dashboard widget. All three read the results already stored by the last check. Opening them never contacts a publisher and never starts a check.

<ImgDocs
src={updateAlerts}
alt="AdminCP dashboard: a red 2 security updates pill sits next to search in the header and is open, listing VitNode and Forum under Security fixes and six packages under Other updates with an Open Plugins link, while the System item in the sidebar shows a count of 8"
withoutBackground
/>

The versions and the security update in the screenshot are illustrative demo data.

## Who sees the alerts

Staff with **Plugins → View installed plugins and their versions** (`plugins.can_view`) see all three. Everyone else sees none of them, and their browser never asks for the data. Running a check still needs `plugins.can_check_updates` and stays a button on the Plugins page.

## The count next to System → Plugins

A number next to **System → Plugins** tells you how many packages have a newer release on their channel. Core counts as one package and every plugin counts once, even when its API and web app are registered separately. It is the same number of _Update available_ rows the Plugins page shows.

- The count only appears when it is above zero. While the data loads, or if it cannot be loaded, the menu looks exactly as before.
- Security updates do not add to the count. They have their own alert in the header.
- When the **System** group is folded, the count moves to the **System** item. In the collapsed icon sidebar a small dot marks the icon, and the count sits next to **Plugins** in the menu that opens.

## The security alert in the header

When a security update affects something you run, a red pill appears in the AdminCP header, next to search, on every page:

> **2 security updates**

On a phone it shrinks to the shield and the number. Each component counts once, however many security updates match it. The pill has no close button on purpose: it disappears on its own once no evaluated finding applies to what you run - after you upgrade and redeploy, or after a check finds the publisher changed the affected range. It sits in the header, so it never pushes the page down.

Click it to see what is waiting, in two groups:

- **Security fixes** - every package a security update affects, with the version that fixes it.
- **Other updates** - packages with a newer release and no security finding.

Each group lists up to four packages and then says how many more there are. A row, or **Open Plugins** at the bottom, takes you to System → Plugins, where each affected row names the security update and its fix. The list itself is only fetched when you open the pill, so the header stays as light as before.

A publisher that cannot be reached never clears a warning. VitNode keeps the last known findings and adds _Based on the last known results_ under the list, so an outage at the publisher never looks like a fix.

## The System updates widget

The **System updates** widget is on the dashboard by default for staff who can view plugins. You can remove it or add it back from **Edit layout**, like any other widget. It reads like a small ledger, one row per fact:

| Row | Meaning |
| -------------- | --------------------------------------------------------------------------------------------------------- |
| Status | The one-line verdict - see below. |
| VitNode | The core version you run and, after the arrow, the newer core release on your channel when one is known. |
| Plugin updates | Plugins with a newer release on their channel. Core is not included here. |
| Security | How many packages, core included, a security update affects - or _None_. |
| Last checked | How fresh the numbers are. |

Before anything was checked, the numbers show a dash and _Unknown_ instead of `0`.

### Status

| Status | What it means |
| ------------------------ | ------------------------------------------------------------------------------------------------- |
| Security fix needed | At least one package you run is affected by a security update. |
| Updates available | A newer release exists for core or a plugin, and nothing is affected by a security update. |
| Up to date | Every installed package was checked and none has an update or a security warning. |
| 2 packages not confirmed | Nothing is outdated, but some packages could not be confirmed, for example they declare no update source. The Plugins page says why. |
| Checking... | A check is running. The widget, the count and the pill refresh every 15 seconds until it finishes. |
| Not checked yet | No check has run. The daily check runs overnight, or press **Check for updates** on the Plugins page. |
| No update sources | No installed package declares where its updates are published. |
| Checks turned off | The API config sets `updates.enabled` to `false`. |

### Last checked

| Value | What it means |
| -------------- | ----------------------------------------------------------------------------------------------------------------------- |
| 3 hours ago | The oldest stored result is that recent, so every package was checked at least that recently. |
| Failed 5 minutes ago | The latest check could not read a source. The numbers include the last known results for it. |
| Out of date | You redeployed, upgraded core or changed channel. Results are recomputed from the stored manifests until the next check. |
| Never | Nothing has been checked yet. |

The time is the **oldest** successful result, not the newest. One package checked a minute ago does not prove the others were.

## When update checks are turned off

With `updates: { enabled: false }` in the API config, nothing is checked. The navigation count and the security pill stay hidden, and the widget only says that checks are turned off. Nothing stored earlier is shown as current.

## How fresh the alerts are

The alerts share one request per browser tab. It is refreshed when it is older than a minute and you come back to the tab, after **Check for updates** finishes, and every 15 seconds while a check is running. There is no other polling.

## Check the result

Press **Check for updates** on **System → Plugins**. When it finishes, the count next to **Plugins**, the security pill and the widget update together without reloading the page.
4 changes: 3 additions & 1 deletion apps/web/content/docs/dev/updates/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: How VitNode tells administrators about new versions and security up
icon: RefreshCw
---

VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security update. The results appear in **AdminCP → System → Plugins**.
VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security update. The results appear in **AdminCP → System → Plugins**, as a count next to that menu item, as a security alert in the AdminCP header and in the **System updates** dashboard widget.

Each package says where its **update manifest** lives - a public JSON file on GitHub Raw or any other HTTPS host. VitNode reads those files from the server. It does not ask npm, and it does not install, download or change anything.

Expand All @@ -13,6 +13,8 @@ Each package says where its **update manifest** lives - a public JSON file on Gi
| You want to... | Read |
| ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
| See updates and security warnings, run a check, choose a channel | [Check for updates in the AdminCP](/docs/dev/updates/admin) |
| Understand the update count, security alert and dashboard widget | [Update alerts in the AdminCP](/docs/dev/updates/alerts) |
| Learn how checks stay exclusive across API instances | [How update checks share one lease](/docs/dev/updates/lease) |
| Make your own plugin report updates | [Publish updates for your plugin](/docs/dev/updates/plugin-authors) |
| Look up every field of the JSON format | [Update manifest reference](/docs/dev/updates/manifest) |
| Warn installations about a vulnerability | [Publish a security update](/docs/dev/updates/security-updates) |
Expand Down
61 changes: 61 additions & 0 deletions apps/web/content/docs/dev/updates/lease.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
title: How update checks share one lease
description: How VitNode keeps the daily and manual update checks exclusive across API instances with a renewable database lease, cancels a check that loses it, and fences every write so a stale owner cannot overwrite newer results.
icon: Lock
---

Every update check - the daily cron job and the **Check for updates** button - runs through one function, `runLockedUpdateCheck` in `@vitnode/core`. It takes a database lease first, so only one check runs at a time across all API instances. This page explains how that lease is kept, what happens when it is lost, and why an old owner can never write after a new one took over.

## The lease lifecycle

The lease is the single row `check` in `core_update_lock`. A check always goes through these steps, in order:

1. **Acquire** the lease for 5 minutes. A manual check must also be past the two-minute cooldown, stamped in the same transaction.
2. **Start the heartbeat**, which renews the lease about once a minute.
3. **Run the check.**
4. **Stop the heartbeat** and wait for a renewal that is still on its way.
5. **Release** the lease, but only if it still belongs to this run.

The heartbeat never overlaps itself: the next renewal is scheduled only after the previous one answered. A renewal only succeeds for the current owner while its lease has not expired, so an owner that let its lease run out cannot bring it back.

All expiry decisions use the database clock (`clock_timestamp()` in UTC), never the API server's. Two instances with skewed clocks still agree on who holds the lease.

## Recovery after a crash

An instance that dies mid-check stops renewing. Its lease runs out at most five minutes after its last renewal, and the next daily run or manual check takes it over. The crashed owner cannot release, renew or write after that, even if it comes back to life.

## Cancellation after the lease is lost

The heartbeat aborts the check's `AbortSignal` with an `UpdateCheckLeaseLostError` when ownership cannot be confirmed:

- a renewal answers that the lease is gone,
- a renewal fails, for example because the database is unreachable,
- or no renewal confirmed the lease before it could have expired.

The signal reaches `fetchUpdateSource`. Requests in flight are aborted, queued sources never start, and nothing more is evaluated or stored. A cancelled request is not a publisher failure: the source keeps its last valid document and no error is recorded for it. Every started request is awaited before the check returns, and the heartbeat timers are cleared.

`runLockedUpdateCheck` then returns `{ status: "lease_lost" }`. The cron job logs a warning, and `POST /api/@vitnode/core/admin/plugins/updates/check` answers `409` with `"status": "interrupted"`.

## Fenced writes

Renewing alone is not enough. A process paused by a long garbage collection or a slow network can wake up after its lease expired and another instance took over. Every write a check makes therefore goes through `withUpdateCheckLease`:

```ts title="packages/vitnode/src/api/lib/updates/check.ts"
await withUpdateCheckLease(db, lease.owner, async tx => {
// write the source document and the results computed from it
});
```

It opens a short transaction, takes `pg_advisory_xact_lock` on the lease name, locks the lease row with `SELECT ... FOR UPDATE`, and checks that the owner matches and the lease has not expired by the database clock. Only then does it run the writes, and it commits while still holding both locks. Otherwise it throws `UpdateCheckLeaseLostError` and writes nothing.

Acquire, renew and release take the same two locks. So a new owner cannot take the lease between an old owner's ownership check and its commit: the takeover waits for that transaction, and the old owner's next write sees the new owner and is refused. This covers the source documents and HTTP validators, the results, recorded failures and the clean-up of removed plugins.

The row lock also holds off an instance still running the previous release during a rolling deploy, since its lease statement locks the same row.

<Callout type="warn" title="Never hold the fence around a network request">
`withUpdateCheckLease` holds the lease lock until the callback returns. Fetch first, then write. A check writes one transaction per source - the source and its results commit together - and one more for the clean-up.
</Callout>

## The summary behind the AdminCP alerts

`GET /api/@vitnode/core/admin/plugins/updates/summary` feeds the [navigation count, security alert and dashboard widget](/docs/dev/updates/alerts). It needs `plugins.can_view` and reads stored results only - the same `loadUpdatesOverview` the catalog uses, condensed by `summarizeUpdates`. Core and each plugin count as one component. `oldestSuccessAt` is the oldest successful result among packages with an update source, and `null` while any of them has none. The web app reads it through one shared query, `useAdminUpdateSummary`.
5 changes: 4 additions & 1 deletion apps/web/content/docs/dev/updates/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
"pages": [
"index",
"admin",
"alerts",
"---Publish updates---",
"plugin-authors",
"manifest",
"security-updates",
"official-releases"
"official-releases",
"---How it works---",
"lease"
]
}
Loading
Loading