Compare commits
3
Commits
c2635ed51a
...
3d59836d0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d59836d0c | ||
|
|
d9184312aa | ||
|
|
b9802e6b04 |
@@ -124,6 +124,39 @@ A library of reusable **steps** (bash or PowerShell scripts with declared inputs
|
||||
|
||||
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
|
||||
|
||||
### Scheduled workflows
|
||||
|
||||
A workflow may carry `schedule{enabled, cron, tz}` — standard **5-field** cron
|
||||
and an IANA zone name, both validated at save time. `next_run_at` is
|
||||
**persisted on the document, not held in memory**: a leader handover between
|
||||
computing an occurrence and firing it would otherwise lose it or fire it twice,
|
||||
the same argument that put `workflow_log_seq` in MongoDB.
|
||||
|
||||
`server/internal/workflowsched` ticks every 30s inside the **existing**
|
||||
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched` and the sweepers —
|
||||
one role, one lock. **The atomic claim, not the lock, is what prevents a double
|
||||
fire**: the `UpdateOne` matches on the document *and* its current `next_run_at`
|
||||
while setting the recomputed one, so a second process reaching the same workflow
|
||||
matches nothing and does nothing. The lock only makes it cheap.
|
||||
|
||||
`workflowsched` **must not import `services`** — `services` already imports it
|
||||
for `SetSchedule`'s call to `NextOccurrence`, and Go has no cycles.
|
||||
`TriggerWorkflow` and `LogEvent` are therefore injected as `workflowsched.Deps`
|
||||
from `main.go`. Firing goes through the same `TriggerWorkflow` a person uses,
|
||||
with `"schedule"` as the actor, so there is no second dispatch path and the run
|
||||
detail page needed no changes.
|
||||
|
||||
`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile`
|
||||
builds on Alpine, which ships no zone database, so without it
|
||||
`time.LoadLocation("Europe/London")` fails and every schedule silently falls
|
||||
back to UTC — an hour wrong for half the year, in the direction nobody notices
|
||||
until a maintenance window lands in business hours. It works on a developer
|
||||
machine either way, which is exactly why it gets forgotten.
|
||||
|
||||
Skips are recorded and surfaced, not just logged: past the 1h grace window is
|
||||
`missed`, an active run is `already_running`, and a schedule that no longer
|
||||
parses is disabled rather than left spinning the loop every 30 seconds forever.
|
||||
|
||||
### Server tags and workflow targeting
|
||||
|
||||
A server carries `tags map[string]string` — lowercase `[a-z0-9_-]`, key ≤32,
|
||||
@@ -10,67 +10,54 @@ HQ portal.
|
||||
|
||||
## What a licence is
|
||||
|
||||
A signed file. It carries the instance UUID it belongs to, the tier, the server
|
||||
A signed file. It carries the instance ID it belongs to, the tier, the server
|
||||
allowance, feature toggles and an expiry. The control plane verifies the
|
||||
signature locally checking a licence never contacts HQ, and a running instance
|
||||
does not need HQ to be reachable.
|
||||
signature locally.
|
||||
|
||||
Signing happens in exactly one place, in HQ. The control plane can only verify.
|
||||
A running instance does not need HQ to be reachable.
|
||||
|
||||
## 1. Find your instance UUID
|
||||
:::info One Free per account, per deployment.
|
||||
The limit is enforced per account **and** deployment, so a Free cloud instance does not stop you claiming Free on a self-hosted install.
|
||||
:::
|
||||
|
||||
In the control plane, go to **Settings → Licence**. The instance UUID is shown
|
||||
there. It is the identity your licence binds to.
|
||||
## 1. Find your instance ID
|
||||
|
||||
## 2. Link the install to your HQ account
|
||||
In the control plane, go to **Settings → Licence**. The instance ID is shown there.
|
||||
|
||||
## 2. Create a free license
|
||||
|
||||
1. Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk). If you have no
|
||||
account, see [Accounts and signup](../hq/accounts-and-signup.md).
|
||||
2. Choose **Link an instance**.
|
||||
3. Paste the instance UUID and give it a name you will recognise.
|
||||
2. Click on the **Buy A Plan** button.
|
||||
3. Click on **Self Hosted** then click on the **Free** plan, then finally Paste the instance ID and give it a name you will recognise.
|
||||
|
||||
Linking claims the UUID for your account. A UUID already linked elsewhere is
|
||||
refused with a conflict rather than silently moved.
|
||||
You will then see the new instance on the **Overview** page.
|
||||
|
||||
## 3. Claim Free
|
||||
## 3. Downloading the free license
|
||||
|
||||
With the instance linked, choose **Claim Free** on it. HQ issues a Free licence
|
||||
bound to that UUID and hands it back.
|
||||
With the instance created go to the **Overview** page and expand the new instance.
|
||||
|
||||
:::info One Free per account, per deployment
|
||||
The limit is enforced per account **and** deployment, so a Free cloud instance
|
||||
does not stop you claiming Free on a self-hosted install. Both the friendly
|
||||
pre-check and the issuer apply the same rule deliberately, because a
|
||||
pre-check stricter than the issuer would refuse something that would actually
|
||||
have worked.
|
||||
:::
|
||||
Click on the **View Instance Settings** button. You can then click on the **Download License** or the **Copy to clipboard** button.
|
||||
|
||||
## 4. Install the licence
|
||||
|
||||
Download the licence from HQ and paste it in the control plane at
|
||||
**Settings → Licence**.
|
||||
|
||||
The instance validates the signature, checks the UUID matches its own, and
|
||||
The instance validates the signature, checks the ID matches its own, and
|
||||
starts reporting the tier, allowance and expiry.
|
||||
|
||||
:::warning Cloud instances cannot paste a licence
|
||||
On a cloud instance `POST /license` answers `409 cloud_managed`, and the UI
|
||||
hides the form entirely. A cloud licence is written directly by HQ. This is not
|
||||
a restriction the injection path has to work around it writes to the database,
|
||||
not through the endpoint.
|
||||
:::info Cloud instances do **not** require installing the license as this is done automatically.
|
||||
:::
|
||||
|
||||
## Renewing
|
||||
|
||||
Free licences are renewable from HQ within a renewal window near expiry;
|
||||
outside that window the renew call refuses. See [Free tier](../hq/free-tier.md).
|
||||
|
||||
Pasting a licence keeps working while the current one is expired that endpoint
|
||||
is exempt from the licence check, because it is the way out of degraded mode.
|
||||
outside that window you cannot renew early. See [Free tier](../hq/free-tier.md).
|
||||
|
||||
## Moving the install to new hardware
|
||||
|
||||
Rebuilding produces a new instance UUID, and a licence binds to a UUID. Use
|
||||
Rebuilding produces a new instance ID, and a licence binds to a ID. Use
|
||||
**Relink** in HQ to move the licence across. The number of relinks per term is
|
||||
capped; the portal shows how many you have left.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ title: First login
|
||||
sidebar_label: First login
|
||||
---
|
||||
|
||||
A fresh install has no users and no organisation. The first visit creates both.
|
||||
A fresh install has no users and no instance. The first visit creates both.
|
||||
|
||||
## 1. Bootstrap
|
||||
|
||||
@@ -13,48 +13,49 @@ Open the control plane in a browser. Because no user exists, you land on
|
||||
|
||||
Fill in:
|
||||
|
||||
| Field | Notes |
|
||||
| ----------------- | ----------------------------------------------------------------- |
|
||||
| Organisation name | Display name. Shown throughout the UI |
|
||||
| Slug | Lowercase, used in the hostname on cloud. Some names are reserved |
|
||||
| Your name | |
|
||||
| Email | Becomes your sign-in identity |
|
||||
| Password | Stored bcrypt-hashed |
|
||||
| Field | Notes |
|
||||
| ------------- | ------------------------------------- |
|
||||
| Instance name | Display name. Shown throughout the UI |
|
||||
| Email | Becomes your sign-in identity |
|
||||
| Password | Stored bcrypt-hashed |
|
||||
|
||||
Submitting creates the organisation and its **owner** you.
|
||||
Submitting creates the instance and its **owner** you.
|
||||
|
||||
:::warning Bootstrap works exactly once
|
||||
The endpoint is open only while the database has no users. As soon as the first
|
||||
one exists, `/setup` redirects to the login page and the bootstrap endpoint
|
||||
refuses. There is no second chance to create the first owner, so record the
|
||||
credentials before you close the tab.
|
||||
one exists, There is no second chance to create the first owner, so record the
|
||||
credentials before you continue.
|
||||
:::
|
||||
|
||||
## 2. Sign in
|
||||
## 2. Copy the Instance ID
|
||||
|
||||
You are taken to `/login`. Sign in with the email and password you just set.
|
||||
Once you have finished setup you will see the successfully created page.
|
||||
|
||||
Sessions are an opaque 32-byte token in the `km_session` cookie, with the body
|
||||
held in Redis for 24 hours. Restarting Redis signs everyone out and loses
|
||||
nothing else.
|
||||
This will show the Instance ID. You will need this ID when creating a license in the HQ.
|
||||
|
||||
## 3. Look around
|
||||
## 3. Sign in
|
||||
|
||||
You land on the fleet dashboard, which is empty. The sidebar is the whole
|
||||
Click the continue to sign in button on the successful setup page.
|
||||
|
||||
You will be taken to `/login`. Sign in with the email and password you just set.
|
||||
|
||||
## 4. Look around
|
||||
|
||||
You land on the servers dashboard, which is empty. The sidebar is the whole
|
||||
product:
|
||||
|
||||
| Section | What it does |
|
||||
| --------- | ----------------------------------------- |
|
||||
| Servers | The fleet enrol, inspect, console, update |
|
||||
| Keys | SSH public keys and their assignments |
|
||||
| Workflows | Compose and run scripted work |
|
||||
| Steps | The reusable step library |
|
||||
| Monitors | HTTP, TCP, ICMP and TLS checks |
|
||||
| Secrets | The encrypted vault |
|
||||
| Audit | Every mutating action |
|
||||
| Settings | Members, SSO, alerts, retention, licence |
|
||||
| Section | What it does |
|
||||
| --------- | ------------------------------------------ |
|
||||
| Servers | The server enrol, inspect, console, update |
|
||||
| Keys | SSH public keys and their assignments |
|
||||
| Workflows | Compose and run scripted work |
|
||||
| Steps | The reusable step library |
|
||||
| Monitors | HTTP, TCP, ICMP and TLS checks |
|
||||
| Secrets | The encrypted vault |
|
||||
| Audit | Every mutating action |
|
||||
| Settings | Members, SSO, alerts, retention, licence |
|
||||
|
||||
## 4. Add the rest of your team
|
||||
## 5. Add the rest of your team
|
||||
|
||||
Go to **Settings → Access**. Add members with a role:
|
||||
|
||||
@@ -66,12 +67,6 @@ Go to **Settings → Access**. Add members with a role:
|
||||
|
||||
Settings and organisation management require `owner` or `admin`.
|
||||
|
||||
If you would rather not manage passwords, configure single sign-on instead:
|
||||
see [Settings](../vantage/settings.md#single-sign-on). You can add more than
|
||||
one identity provider; each gets its own button on the login page, and no
|
||||
buttons appear at all until at least one provider is configured. Client
|
||||
secrets are stored encrypted.
|
||||
If you would rather not manage passwords, configure single sign-on instead: see [Settings](../vantage/settings.md#single-sign-on).
|
||||
|
||||
## Next
|
||||
|
||||
[Add your first server](./first-server.md).
|
||||
You can add more than one identity provider; each gets its own button on the login page, and no buttons appear at all until at least one provider is configured.
|
||||
|
||||
@@ -4,41 +4,39 @@ title: Add your first server
|
||||
sidebar_label: Add your first server
|
||||
---
|
||||
|
||||
Enrolling a machine means running one command on it. The control plane issues a
|
||||
Enrolling a server means running one command on it. The control plane issues a
|
||||
short-lived token, the install script fetches the agent and writes a config, and
|
||||
the machine registers itself.
|
||||
|
||||
## 1. Create the enrolment
|
||||
|
||||
In the UI, go to **Servers → Add server**. That calls `POST /api/servers/new`,
|
||||
which generates a server ID and a pre-registration token and hands back a ready
|
||||
one-liner.
|
||||
In the UI, go to **Servers → Add server** Then click the **Generate Install Command** button.
|
||||
This generates a server ID and a pre-registration token
|
||||
|
||||
:::warning The token is single-use and lives one hour
|
||||
It is the only credential in the flow, and it is spent the moment the agent
|
||||
calls `Register`. If you paste it somewhere and come back tomorrow, create a new
|
||||
enrolment instead nothing is lost by doing so.
|
||||
It is the only credential in the flow, and it is spent the moment the agent registers.
|
||||
:::
|
||||
|
||||
## 2. Run the one-liner
|
||||
|
||||
### Linux
|
||||
|
||||
Run the generated install script as root.
|
||||
|
||||
Here is an example of the install script:
|
||||
|
||||
```bash
|
||||
curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" | bash
|
||||
```
|
||||
|
||||
Run it as root. The script:
|
||||
What the script does:
|
||||
|
||||
1. Detects architecture `x86_64` and `aarch64` only; anything else exits.
|
||||
2. Asks the Gitea API for the newest `agent/v*` release.
|
||||
3. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**,
|
||||
aborting on a mismatch.
|
||||
4. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
|
||||
5. Writes `/etc/vantage/config.yaml` (directory `0700`, file `0600`) containing
|
||||
the server ID, the pre-registration token and the gRPC host.
|
||||
6. Writes `/etc/systemd/system/vantage-agent.service` with `Restart=always`, and
|
||||
runs `systemctl enable --now vantage-agent`.
|
||||
2. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**, aborting on a mismatch.
|
||||
3. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
|
||||
4. Writes the config file at `/etc/vantage/config.yaml`
|
||||
1. This contains the server ID, the pre-registration token and the gRPC host.
|
||||
5. Writes the systemd service file `/etc/systemd/system/vantage-agent.service` and starts the agent.
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -46,42 +44,30 @@ Run it as root. The script:
|
||||
irm "https://vantage.example.com/install.ps1?server_id=<id>&token=<token>" | iex
|
||||
```
|
||||
|
||||
Run from an elevated PowerShell. The agent is registered as a service through
|
||||
NSSM, with the config at `%ProgramData%\vantage\config.yaml`. There is also an
|
||||
MSI built by CI if you would rather deploy that.
|
||||
Run from an elevated PowerShell.
|
||||
|
||||
:::info Windows agents are second-class on purpose
|
||||
They register, heartbeat, run workflow steps and report inventory. They do
|
||||
**not** manage `authorized_keys` the key subsystem is Linux-only, and a
|
||||
Windows agent stops after the heartbeat portion of the poll.
|
||||
What the script does:
|
||||
|
||||
1. Creates the config at `%ProgramData%\vantage\config.yaml`.
|
||||
1. This contains the server ID, the pre-registration token and the gRPC host.
|
||||
2. Downloads the agent MSI from Gitea.
|
||||
3. Installs the MSI and creates the Windows service.
|
||||
4. Starts the agent.
|
||||
|
||||
:::info Windows agents do **not** manage `authorized_keys` as this is a Linux-only function.
|
||||
:::
|
||||
|
||||
## 3. Watch it come up
|
||||
|
||||
The server appears immediately as `pending`. Within one poll interval 30
|
||||
seconds it flips to `active`.
|
||||
The server appears immediately as `pending`. Within one poll interval, 30 seconds it becomes `active`.
|
||||
|
||||
On the machine:
|
||||
Check the systemd logs using the following commands:
|
||||
|
||||
```bash
|
||||
systemctl status vantage-agent
|
||||
journalctl -u vantage-agent -f
|
||||
```
|
||||
|
||||
What happens on that first run:
|
||||
|
||||
```
|
||||
1. Load /etc/vantage/config.yaml
|
||||
2. pre_reg_token present → register → save agent_token, clear pre_reg_token
|
||||
3. Reconnect with the permanent token
|
||||
4. Start: command stream · hourly update check · inventory · monitors
|
||||
5. Enter the key poll loop
|
||||
```
|
||||
|
||||
After registration the config no longer contains the pre-registration token; it
|
||||
contains a permanent agent token instead. The control plane stores only the
|
||||
SHA-256 of that token, never the token itself.
|
||||
|
||||
## 4. Confirm it works
|
||||
|
||||
Open the server's detail page. Within a minute or two you should see:
|
||||
@@ -104,7 +90,7 @@ Open the server's detail page. Within a minute or two you should see:
|
||||
A server is marked `offline` when its last-seen time passes the threshold; that
|
||||
sweep runs every two minutes, so allow for it before concluding anything.
|
||||
|
||||
## Next
|
||||
## Next Steps
|
||||
|
||||
- [Assign an SSH key](../vantage/ssh-keys.md)
|
||||
- [Run a workflow](../vantage/workflows.md)
|
||||
|
||||
@@ -4,8 +4,7 @@ title: Accounts and signup
|
||||
sidebar_label: Accounts and signup
|
||||
---
|
||||
|
||||
Vantage HQ, at `vantage-hq.hostxtra.co.uk`, is where you manage the **account**
|
||||
behind your instances: your team, your instances, their licences and billing.
|
||||
[Vantage HQ](https://vantage-hq.hostxtra.co.uk) is where you manage the **account**, your team, your instances, their licences and billing.
|
||||
|
||||
## An account is a team, not a person
|
||||
|
||||
@@ -31,26 +30,15 @@ Signup is **account-first**. Creating an account creates the account and you;
|
||||
it does not create a Vantage instance. Nothing exists in any control plane until
|
||||
you later create or link one.
|
||||
|
||||
1. Go to the signup form.
|
||||
1. Go to the [signup form](https://vantage.hostxtra.co.uk/start).
|
||||
2. Enter your name, email and a password.
|
||||
3. Check your email and click the verification link.
|
||||
|
||||
:::info Verify before you can sign in
|
||||
An unverified account gets a distinct "check your email" message rather than a
|
||||
generic authentication failure the address is already known to be yours, so
|
||||
there is nothing to protect by being vague.
|
||||
:::
|
||||
|
||||
Verification links are valid for **24 hours**. The token is 32 random bytes and
|
||||
only its SHA-256 hash is stored, so a leaked database yields no working links.
|
||||
|
||||
If the verification email cannot be sent, the signup is rolled back rather than
|
||||
left stranded retry rather than assuming a half-created account is in the way.
|
||||
Verification links are valid for **24 hours**.
|
||||
|
||||
## Signing in
|
||||
|
||||
Email and password. The session is a cookie, separate from the control plane's:
|
||||
signing in to HQ does not sign you in to an instance, and vice versa.
|
||||
Use the Email and password used in the signup form to login to the HQ, signing in to HQ does not sign you in to an instance, and vice versa.
|
||||
|
||||
## What comes next
|
||||
|
||||
@@ -65,14 +53,6 @@ signing in to HQ does not sign you in to an instance, and vice versa.
|
||||
|
||||
Three destinations: **Overview**, **People**, **Billing**.
|
||||
|
||||
Settings lives in the account menu rather than the nav, because it is your
|
||||
password rather than a place. The appearance toggle is there too.
|
||||
|
||||
Overview lists your instances. Each is one record, closed to a row and open to
|
||||
its licence contents, members and actions. It opens by default when it is your
|
||||
only instance or when it needs attention, and your manual choice is remembered.
|
||||
|
||||
There is deliberately no "your plan" card in the sidebar: tier, limits and
|
||||
expiry belong to a **licence**, and a licence belongs to one instance. An
|
||||
account with a Free cloud instance and a Professional self-hosted one has no
|
||||
single plan to show.
|
||||
- Overview lists your instances.
|
||||
- People shows all the account members and their roles.
|
||||
- Billing show the current subscriptions and subscription management.
|
||||
|
||||
+17
-50
@@ -8,56 +8,29 @@ Paid plans are billed through **Paddle**, which is the merchant of record. Your
|
||||
invoice, your card details and your tax handling are all Paddle's; HQ holds a
|
||||
customer reference and nothing sensitive.
|
||||
|
||||
Billing is **owner-only**.
|
||||
:::warning
|
||||
The Billing page requires the **owner-only** account role.
|
||||
:::
|
||||
|
||||
## Buying
|
||||
## Buying A Plan
|
||||
|
||||
Buying a plan license can be found in Vantage HQ by clicking on the **Buy a Plan** button on the **Overview** page.
|
||||
|
||||
### Cloud
|
||||
|
||||
Open the instance, change its configuration to what you want, and check out.
|
||||
Checkout runs in the browser.
|
||||
On the **Buy A Plan** page you will need to select the **Deployment** to **Cloud** then chose your **Billing** cycle (Monthly or Annually).
|
||||
|
||||
Then select your desired **Plan** and configure the features.
|
||||
|
||||
Finally specify the **Instance Name** and click the **Continue to payment** button.
|
||||
|
||||
### Self-hosted
|
||||
|
||||
**Buy self-hosted**, then bind the purchase to your install's UUID. See
|
||||
[Self-hosted instances](./self-hosted-instances.md).
|
||||
On the **Buy A Plan** page you will need to select the **Deployment** to **Self-Hosted** then chose your **Billing** cycle (Monthly or Annually).
|
||||
|
||||
## What you are buying
|
||||
Then select your desired **Plan** and configure the features.
|
||||
|
||||
A subscription's line items are the configuration: the plan base, the metered
|
||||
server count above the base, and any per-instance features. Changing the
|
||||
configuration changes the line items.
|
||||
|
||||
## Changing configuration
|
||||
|
||||
**Instance → Configuration**, adjust servers or features, and save.
|
||||
|
||||
- **Increases** take effect when the payment confirms.
|
||||
- **Reductions** are scheduled for the end of the term. The portal shows the
|
||||
date and the new value.
|
||||
|
||||
## The customer portal
|
||||
|
||||
**Billing → Manage** mints a Paddle customer-portal session where you can
|
||||
update your payment method, see invoices and cancel.
|
||||
|
||||
## How a licence follows a payment
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["Checkout / change"] --> P["Paddle"]
|
||||
P -->|signed webhook| H["HQ"]
|
||||
H --> G["Entitlement: desired → granted"]
|
||||
G --> L["Licence signed from granted"]
|
||||
```
|
||||
|
||||
The webhook is the **only** issuing path for paid plans. It is signature
|
||||
verified, processed exactly once, and resolved from the subscription's _current_
|
||||
line items so a webhook that arrives out of order still produces the right
|
||||
answer rather than replaying a stale state.
|
||||
|
||||
A licence is signed from **granted** only. A checkout you abandon changes
|
||||
nothing.
|
||||
Finally specify the **Instance Name** and click the **Continue to payment** button.
|
||||
|
||||
## Cancelling and failed payments
|
||||
|
||||
@@ -66,18 +39,12 @@ Your licence runs to its grace-padded expiry and then lapses normally. There is
|
||||
no mid-term cut-off.
|
||||
|
||||
For a cloud Free instance, lapsing eventually leads to deletion see
|
||||
[Free tier](./free-tier.md). Paid instances are not reaped.
|
||||
[Free tier](./free-tier.md). Paid instances are not deleted.
|
||||
|
||||
## Renewals
|
||||
|
||||
At renewal the subscription bills again and the licence is reissued for the new
|
||||
term. It is also the only moment a scheduled **reduction** takes effect.
|
||||
|
||||
Self-hosted customers: download and paste the reissued licence. Cloud customers:
|
||||
nothing to do.
|
||||
|
||||
## Free is not in Paddle at all
|
||||
|
||||
Free has no subscription, no £0 line item and no Paddle record. It has its own
|
||||
renewal, in the portal. An account only acquires a Paddle customer reference
|
||||
with its first paid purchase.
|
||||
- Self-hosted customers: download and paste the reissued licence.
|
||||
- Cloud customers: the license is automatically linked to the instance.
|
||||
|
||||
@@ -25,11 +25,6 @@ features on a paid plan.
|
||||
The limit is enforced per account **and** deployment. A Free cloud instance does
|
||||
not prevent a Free self-hosted one they are separate slots.
|
||||
|
||||
## Free is outside Paddle
|
||||
|
||||
There is no subscription, no £0 line item and no invoice. Your account acquires
|
||||
a Paddle customer reference only with its first paid purchase.
|
||||
|
||||
## Renewing
|
||||
|
||||
Free licences have a term and must be renewed from the portal.
|
||||
|
||||
@@ -30,9 +30,9 @@ entitlement.
|
||||
|
||||
Two are per-instance toggles rather than tier bundles:
|
||||
|
||||
| Feature | What it enables |
|
||||
| --------- | ------------------------------------------------------------------------- |
|
||||
| `console` | The [browser console](../vantage/browser-console.md) |
|
||||
| Feature | What it enables |
|
||||
| --------- | -------------------------------------------------------------------- |
|
||||
| `console` | The [browser console](../vantage/browser-console.md) |
|
||||
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on) |
|
||||
|
||||
No tier includes them by default; you enable them on the instances that need
|
||||
@@ -88,8 +88,3 @@ or let it be written for you (cloud).
|
||||
When you exceed your server allowance, enrolling another one is refused. The
|
||||
existing fleet is unaffected. Raise the allowance in the portal, or remove a
|
||||
server you are not using.
|
||||
|
||||
## Legacy tiers
|
||||
|
||||
An older `self_hosted` tier is mapped forward to self-hosted Professional
|
||||
wherever it appears. Nothing needs doing about it.
|
||||
|
||||
@@ -10,7 +10,6 @@ themselves on command.
|
||||
## Checking the current version
|
||||
|
||||
Each server's detail page shows the version it reported at its last sync.
|
||||
`GET /api/agent/latest-version` reports the newest release available.
|
||||
|
||||
## Updating from the UI
|
||||
|
||||
@@ -21,8 +20,6 @@ version. The agent then:
|
||||
2. Verifies the SHA-256 against `checksums.txt`.
|
||||
3. Stops itself, replaces the binary in place, and starts again.
|
||||
|
||||
`Restart=always` on the systemd unit is what makes the last step work.
|
||||
|
||||
The server briefly goes `offline` and comes back within a poll interval or two.
|
||||
|
||||
## Updating from the machine
|
||||
|
||||
@@ -52,16 +52,9 @@ cp /opt/vantage/.env /secure-location/vantage.env
|
||||
|
||||
Treat it as a credential in its own right it holds the encryption key.
|
||||
|
||||
## Run logs
|
||||
|
||||
Workflow run logs live in the `./data` bind mount, not in the database. They are
|
||||
swept on the retention schedule anyway, so most people do not back them up. If
|
||||
you keep them for compliance, set retention to `0` (forever) and include the
|
||||
directory.
|
||||
|
||||
## What a restore gives you
|
||||
|
||||
Everything: fleet, keys, assignments, workflows and their history, monitors and
|
||||
Everything: server, keys, assignments, workflows and their history, monitors and
|
||||
incidents, secrets, settings and the audit log.
|
||||
|
||||
What it does **not** do is reconcile the world. After a restore:
|
||||
|
||||
@@ -37,31 +37,9 @@ tls: true
|
||||
|
||||
:::danger This file is the credential
|
||||
`agent_token` is plaintext here and nowhere else the control plane holds only
|
||||
its SHA-256. Anyone who can read this file can act as this agent. That is why
|
||||
it is `0600` and the directory is `0700`.
|
||||
its SHA-256. Anyone who can read this file can act as this agent.
|
||||
:::
|
||||
|
||||
## Startup sequence
|
||||
|
||||
```
|
||||
1. Load the config
|
||||
2. pre_reg_token present → register → save agent_token,
|
||||
clear pre_reg_token, reconnect
|
||||
3. Start: command stream · hourly update check · inventory · monitors
|
||||
4. Enter the key poll loop
|
||||
```
|
||||
|
||||
## The poll loop
|
||||
|
||||
```
|
||||
1. Ask the control plane for the desired key state, reporting the
|
||||
agent version
|
||||
2. Non-Linux hosts stop here Windows agents register and heartbeat only
|
||||
3. Diff the desired keys against /root/.ssh/authorized_keys;
|
||||
unchanged → write nothing
|
||||
4. Changed → write a temp file, rename it over the real one, chmod 0600
|
||||
```
|
||||
|
||||
## Service management
|
||||
|
||||
### Linux
|
||||
@@ -77,21 +55,13 @@ journalctl -u vantage-agent -f
|
||||
|
||||
### Windows
|
||||
|
||||
A service registered through NSSM, or installed by the MSI that CI builds.
|
||||
A service registered through NSSM, or installed by the MSI.
|
||||
|
||||
```powershell
|
||||
Get-Service vantage-agent
|
||||
Restart-Service vantage-agent
|
||||
```
|
||||
|
||||
## Command-line flags
|
||||
|
||||
```
|
||||
vantage-agent -generate-key
|
||||
```
|
||||
|
||||
Generates a keypair locally. Normal operation takes no flags.
|
||||
|
||||
## Moving an agent to a new control plane
|
||||
|
||||
Change `server_url`, clear `agent_token`, set a fresh `pre_reg_token` from a new
|
||||
|
||||
@@ -147,6 +147,57 @@ A run shows the script that actually executed, not the current library version.
|
||||
|
||||
Targets run **in parallel**; steps within one server run **in order**.
|
||||
|
||||
## Schedules
|
||||
|
||||
A workflow can carry a schedule, and Vantage will start it the same way a person
|
||||
would — the same dispatch, the same snapshot, the same run page. A scheduled run
|
||||
is an ordinary run with `schedule` recorded as who triggered it.
|
||||
|
||||
Open a workflow, choose **Edit**, and tick **Run on a schedule**. The expression
|
||||
is standard five-field cron:
|
||||
|
||||
```
|
||||
minute hour day-of-month month day-of-week
|
||||
```
|
||||
|
||||
The presets write cron underneath, so you can start from one and adjust:
|
||||
|
||||
| Preset | Cron |
|
||||
| ------------------- | ----------- |
|
||||
| Hourly | `0 * * * *` |
|
||||
| Nightly, 02:00 | `0 2 * * *` |
|
||||
| Weekly, Sun 02:00 | `0 2 * * 0` |
|
||||
| Monthly, 1st 02:00 | `0 2 1 * *` |
|
||||
|
||||
There is no seconds field and no `@daily`-style shorthand. The next three
|
||||
occurrences are shown as you type, and they are computed by the server rather
|
||||
than the browser, so what you see is exactly what will fire.
|
||||
|
||||
### Timezones
|
||||
|
||||
A schedule stores an IANA timezone by name — `Europe/London`, not an offset.
|
||||
That is what makes a 02:00 job stay at 02:00 across a daylight-saving change
|
||||
instead of drifting an hour for half the year. An unknown zone is refused when
|
||||
you save it, not at 2am.
|
||||
|
||||
### Overlaps are skipped, not queued
|
||||
|
||||
If a run of the same workflow is still going when the next occurrence comes
|
||||
round, the occurrence is **skipped** and the reason recorded. It is not queued
|
||||
behind the running one. A patch workflow that takes longer than its interval
|
||||
should fall behind visibly rather than pile up.
|
||||
|
||||
### Missed occurrences
|
||||
|
||||
If the control plane was not running when an occurrence was due, it still fires
|
||||
when the control plane comes back — but only within **one hour** of the due
|
||||
time. Anything older is recorded as missed and dropped. A job missed by ten
|
||||
minutes during an upgrade should still run; one missed by two days should not
|
||||
suddenly fire at lunchtime.
|
||||
|
||||
Either kind of skip is shown on the workflow's schedule panel, with the time it
|
||||
was due and why it did not run.
|
||||
|
||||
## Watching a run
|
||||
|
||||
Step stdout and stderr stream back as chunks, are appended to a log file on the
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
/*
|
||||
* A two-pane picker: everything available on the left, everything chosen on the
|
||||
* right, moved across with the four buttons between them.
|
||||
*
|
||||
* The panes are divs rather than <select multiple>. A native multi-select draws
|
||||
* its selected rows with the platform's own highlight colour, which cannot be
|
||||
* restyled reliably across browsers — on a dark ground it renders as a pale
|
||||
* band that belongs to no palette. Rebuilding the widget is the only way to
|
||||
* keep it inside the token system.
|
||||
*/
|
||||
|
||||
export interface DualItem {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
function Pane({
|
||||
items,
|
||||
marked,
|
||||
onToggle,
|
||||
onCommit,
|
||||
empty,
|
||||
}: {
|
||||
items: DualItem[];
|
||||
marked: string[];
|
||||
onToggle: (id: string, additive: boolean) => void;
|
||||
onCommit: (id: string) => void;
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-56 overflow-y-auto rounded-lg border border-border bg-surface-2" role="listbox" aria-multiselectable>
|
||||
{items.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-text-tertiary">{empty}</p>
|
||||
) : (
|
||||
items.map((it) => {
|
||||
const on = marked.includes(it.id);
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
onClick={(e) => onToggle(it.id, e.ctrlKey || e.metaKey || e.shiftKey)}
|
||||
onDoubleClick={() => onCommit(it.id)}
|
||||
className={`flex w-full items-baseline gap-2 px-3 py-1.5 text-left text-sm transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent ${
|
||||
on ? "bg-accent text-accent-ink" : "text-text-primary hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{it.label}</span>
|
||||
{it.hint && <span className={`truncate font-mono text-[11px] ${on ? "opacity-70" : "text-text-tertiary"}`}>{it.hint}</span>}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveButton({ children, onClick, disabled, label }: { children: React.ReactNode; onClick: () => void; disabled: boolean; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-border disabled:hover:text-text-secondary"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DualListBox({
|
||||
items,
|
||||
selected,
|
||||
onChange,
|
||||
availableLabel = "Available",
|
||||
selectedLabel = "Selected",
|
||||
emptyAvailable = "Nothing left to add.",
|
||||
emptySelected = "Nothing selected.",
|
||||
}: {
|
||||
items: DualItem[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
availableLabel?: string;
|
||||
selectedLabel?: string;
|
||||
emptyAvailable?: string;
|
||||
emptySelected?: string;
|
||||
}) {
|
||||
// Which rows are highlighted in each pane, not which are chosen. Highlight
|
||||
// is transient and per-pane; membership is the `selected` prop.
|
||||
const [markedLeft, setMarkedLeft] = useState<string[]>([]);
|
||||
const [markedRight, setMarkedRight] = useState<string[]>([]);
|
||||
|
||||
const byLabel = (a: DualItem, b: DualItem) => a.label.localeCompare(b.label);
|
||||
const available = useMemo(() => items.filter((i) => !selected.includes(i.id)).sort(byLabel), [items, selected]);
|
||||
// Ordered by the same rule as the left pane rather than by the order things
|
||||
// were clicked, so a workflow's targets read the same way every time.
|
||||
const chosen = useMemo(() => items.filter((i) => selected.includes(i.id)).sort(byLabel), [items, selected]);
|
||||
|
||||
const mark = (setter: typeof setMarkedLeft) => (id: string, additive: boolean) =>
|
||||
setter((m) => (additive ? (m.includes(id) ? m.filter((x) => x !== id) : [...m, id]) : m.length === 1 && m[0] === id ? [] : [id]));
|
||||
|
||||
const add = (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
onChange([...selected, ...ids.filter((id) => !selected.includes(id))]);
|
||||
setMarkedLeft([]);
|
||||
};
|
||||
|
||||
const remove = (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
onChange(selected.filter((id) => !ids.includes(id)));
|
||||
setMarkedRight([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-3">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] uppercase tracking-wide text-text-tertiary">{availableLabel}</p>
|
||||
<Pane items={available} marked={markedLeft} onToggle={mark(setMarkedLeft)} onCommit={(id) => add([id])} empty={emptyAvailable} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-6">
|
||||
<MoveButton label={`Add all to ${selectedLabel}`} disabled={available.length === 0} onClick={() => add(available.map((i) => i.id))}>
|
||||
»
|
||||
</MoveButton>
|
||||
<MoveButton label={`Add to ${selectedLabel}`} disabled={markedLeft.length === 0} onClick={() => add(markedLeft)}>
|
||||
›
|
||||
</MoveButton>
|
||||
<MoveButton label={`Remove from ${selectedLabel}`} disabled={markedRight.length === 0} onClick={() => remove(markedRight)}>
|
||||
‹
|
||||
</MoveButton>
|
||||
<MoveButton label={`Remove all from ${selectedLabel}`} disabled={chosen.length === 0} onClick={() => remove(chosen.map((i) => i.id))}>
|
||||
«
|
||||
</MoveButton>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] uppercase tracking-wide text-text-tertiary">
|
||||
{selectedLabel} · {chosen.length}
|
||||
</p>
|
||||
<Pane items={chosen} marked={markedRight} onToggle={mark(setMarkedRight)} onCommit={(id) => remove([id])} empty={emptySelected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import { ScheduleCard } from "./ScheduleCard";
|
||||
import { DualListBox } from "./DualListBox";
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
@@ -24,8 +25,6 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -54,7 +53,7 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit workflow">
|
||||
<Modal open={open} onClose={onClose} title="Edit workflow" wide>
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<div>
|
||||
@@ -63,23 +62,26 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers?.map((s) => {
|
||||
const on = targets.includes(s.server_id);
|
||||
return (
|
||||
<label
|
||||
key={s.server_id}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}
|
||||
>
|
||||
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
|
||||
{s.hostname}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
|
||||
</div>
|
||||
{servers && servers.length === 0 ? (
|
||||
<p className="text-xs text-text-secondary">No servers registered.</p>
|
||||
) : (
|
||||
<DualListBox
|
||||
items={(servers ?? []).map((s) => ({ id: s.server_id, label: s.hostname, hint: s.status === "active" ? undefined : s.status }))}
|
||||
selected={targets}
|
||||
onChange={setTargets}
|
||||
selectedLabel="Targets"
|
||||
emptyAvailable="Every server is a target."
|
||||
emptySelected="No servers targeted."
|
||||
/>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-text-tertiary">Click to highlight, ctrl-click for several, double-click to move. Tag selectors are set on the workflow page.</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{/* The schedule saves through its own endpoint, so it sits above
|
||||
the footer rather than under it — the footer's Save covers the
|
||||
name and targets only, and the two are labelled accordingly. */}
|
||||
<ScheduleCard workflow={workflow} />
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border-soft pt-4">
|
||||
<Button variant="danger" onClick={del} loading={busy}>
|
||||
Delete workflow
|
||||
</Button>
|
||||
@@ -88,11 +90,10 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>
|
||||
Save
|
||||
Save workflow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScheduleCard workflow={workflow} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,10 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
|
||||
const [tz, setTz] = useState(workflow.schedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: preview } = useQuery({
|
||||
// isError, not !preview: an in-flight query and a rejected expression both
|
||||
// leave data undefined, so keying the invalid message off the data alone
|
||||
// flashes "not valid" at every keystroke on a perfectly good cron string.
|
||||
const { data: preview, isError: previewFailed } = useQuery({
|
||||
queryKey: ["schedule-preview", workflow.workflow_id, cron, tz],
|
||||
queryFn: () => api.previewSchedule(workflow.workflow_id, cron, tz),
|
||||
retry: false,
|
||||
@@ -44,13 +47,16 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface">
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
|
||||
<h2 className="text-[15px] font-semibold text-text-primary">Schedule</h2>
|
||||
// No panel chrome: this renders inside a Modal that already supplies the
|
||||
// border, the background and a title bar, and nesting a second card in
|
||||
// one produced a box inside a box.
|
||||
<div className="border-t border-border-soft pt-4">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">Schedule</h3>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">{enabled ? "Active" : "Off"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="mt-0.5 h-4 w-4 accent-accent" />
|
||||
<span>
|
||||
@@ -103,14 +109,16 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
|
||||
|
||||
<div className="rounded-lg bg-well px-4 py-3">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Next three runs</p>
|
||||
{preview ? (
|
||||
{previewFailed ? (
|
||||
<p className="mt-1.5 font-mono text-[11.5px] text-danger">That expression is not valid.</p>
|
||||
) : preview ? (
|
||||
<ul className="mt-1.5 flex flex-col gap-0.5 font-mono text-[11.5px] text-text-secondary">
|
||||
{preview.occurrences.map((o) => (
|
||||
<li key={o}>{new Date(o).toLocaleString()}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1.5 font-mono text-[11.5px] text-danger">That expression is not valid.</p>
|
||||
<p className="mt-1.5 font-mono text-[11.5px] text-text-tertiary">Working it out…</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -128,7 +136,9 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div>
|
||||
<Button variant="primary" loading={isPending} onClick={() => save()}>
|
||||
{/* Gated on the preview: the server has already rejected this
|
||||
expression once, and submitting it only earns the same 400. */}
|
||||
<Button variant="primary" loading={isPending} disabled={previewFailed} onClick={() => save()}>
|
||||
Save schedule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user