Compare commits
133
Commits
agent/v1.2.2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1769fc886 | ||
|
|
6dced22499 | ||
|
|
5cee53dc5f | ||
|
|
81248bb159 | ||
|
|
6354d54de8 | ||
|
|
da6d64f95c | ||
|
|
9ba3d4a61f | ||
|
|
eee236a072 | ||
|
|
9df89e2db4 | ||
|
|
f60c509b47 | ||
|
|
84dfcfeac7 | ||
|
|
5dda3b5c4a | ||
|
|
db64320bd8 | ||
|
|
583f60771c | ||
|
|
a92c3190c2 | ||
|
|
3a6d24fe0e | ||
|
|
c277ecff44 | ||
|
|
bd690c94c3 | ||
|
|
a22fdf197e | ||
|
|
bd24b03cac | ||
|
|
3afc4ab012 | ||
|
|
d1ac3e98ce | ||
|
|
5bba54f3e5 | ||
|
|
fe7bc300e2 | ||
|
|
00c03c365d | ||
|
|
dc8dd3dd58 | ||
|
|
85a8865892 | ||
|
|
50a9ac5fdc | ||
|
|
3388d2f895 | ||
|
|
3a77fc2abd | ||
|
|
3d59836d0c | ||
|
|
d9184312aa | ||
|
|
b9802e6b04 | ||
|
|
c2635ed51a | ||
|
|
b21ac05547 | ||
|
|
484b620867 | ||
|
|
439bc2ed7d | ||
|
|
a1e6986a64 | ||
|
|
d0e1cc4ad6 | ||
|
|
b877024365 | ||
|
|
2de7ac116b | ||
|
|
fa1fd14ed1 | ||
|
|
d1b3cd2f74 | ||
|
|
e00a0da5d9 | ||
|
|
fef0b7c7a1 | ||
|
|
efd29dc259 | ||
|
|
13cd41d202 | ||
|
|
3530ce6cb7 | ||
|
|
09522c2566 | ||
|
|
80f0afb28b | ||
|
|
287bd9657b | ||
|
|
b5f684c4fe | ||
|
|
1f08e90009 | ||
|
|
6881d92d0a | ||
|
|
5e016c6584 | ||
|
|
537b8758ff | ||
|
|
c03360333b | ||
|
|
fa7c5d341d | ||
|
|
b6fc8c3f77 | ||
|
|
37f2c1457e | ||
|
|
3a626922a5 | ||
|
|
dde47de145 | ||
|
|
f3b9f6f286 | ||
|
|
f1c3f67864 | ||
|
|
8f5873afca | ||
|
|
e22faebfcd | ||
|
|
e2b01b62a5 | ||
|
|
0858693d57 | ||
|
|
45f7c0c393 | ||
|
|
c56bfb7270 | ||
|
|
eb45072031 | ||
|
|
1e2132c1a1 | ||
|
|
19ef773690 | ||
|
|
c5aae0614a | ||
|
|
17d97aaf52 | ||
|
|
1fb9bd827f | ||
|
|
8699dc5b7e | ||
|
|
71240f183c | ||
|
|
01e8b0ba44 | ||
|
|
2aa4784518 | ||
|
|
f611cae438 | ||
|
|
1eb98ef962 | ||
|
|
6f86496f10 | ||
|
|
57a9b18102 | ||
|
|
36995fa62b | ||
|
|
9121fc461f | ||
|
|
fc56bae5f9 | ||
|
|
ac75b3ef76 | ||
|
|
e6fe463216 | ||
|
|
8528f14ed7 | ||
|
|
df1d9658f5 | ||
|
|
9f9b384481 | ||
|
|
165114471f | ||
|
|
de78688093 | ||
|
|
bbf9f72fd3 | ||
|
|
978b665aa6 | ||
|
|
1fe608f531 | ||
|
|
1e1546cb60 | ||
|
|
119d8694d1 | ||
|
|
8d43c689f5 | ||
|
|
05f10ed3c9 | ||
|
|
c0bec3737b | ||
|
|
59d147fe4d | ||
|
|
9e38a01e3d | ||
|
|
20a302f84a | ||
|
|
ba2e263d00 | ||
|
|
a000703199 | ||
|
|
8fcda63742 | ||
|
|
3363ac9dad | ||
|
|
a7e338b171 | ||
|
|
bc79daab48 | ||
|
|
d3d8dba3ff | ||
|
|
6d047e25ab | ||
|
|
ed4c39650c | ||
|
|
7b8fa4a8a0 | ||
|
|
8a02c35ec9 | ||
|
|
487de34a50 | ||
|
|
0424547dd4 | ||
|
|
5be9ddb2e5 | ||
|
|
bc6c7cdb5a | ||
|
|
f46fb7fc0e | ||
|
|
d9945882e5 | ||
|
|
9db16522e3 | ||
|
|
6070972f9a | ||
|
|
31e0000306 | ||
|
|
26567766a9 | ||
|
|
97dc6feaca | ||
|
|
f10e61cde4 | ||
|
|
febe48a974 | ||
|
|
766dcabfde | ||
|
|
c9dab99271 | ||
|
|
60a4ed9aab | ||
|
|
f56fc2e54d |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Grep",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Python314/Scripts/graphify.EXE hook-guard search"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Python314/Scripts/graphify.EXE hook-guard read"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
graphify-out/graph.json merge=graphify
|
||||
@@ -0,0 +1,221 @@
|
||||
name: Chart Release
|
||||
|
||||
on:
|
||||
# Every push that touches the chart is validated. Publishing is separate and
|
||||
# deliberate: a chart version is immutable in the registry once pushed, so
|
||||
# it must come from a tag someone chose, not from whatever landed on main.
|
||||
# No `paths` filter on push, deliberately. A paths filter applies to tag
|
||||
# pushes too, so tagging a commit that happened not to touch the chart
|
||||
# would skip the publish entirely — a release that silently does nothing.
|
||||
# Validation is seconds of helm rendering; running it on every push to main
|
||||
# is cheaper than that failure mode.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "chart/v*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "deploy/chart/**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CHART_DIR: deploy/chart/vantage
|
||||
HELM_VERSION: v3.16.3
|
||||
|
||||
jobs:
|
||||
chart:
|
||||
runs-on: ubuntu-docker
|
||||
container: alpine:3.21
|
||||
steps:
|
||||
# git for actions/checkout, curl for both the Helm download and the
|
||||
# registry upload, tar because the Helm tarball is not self-extracting.
|
||||
- name: Setup
|
||||
run: apk add --no-cache bash curl git tar nodejs npm
|
||||
|
||||
- name: Install Helm
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" \
|
||||
| tar -xz -C /tmp linux-amd64/helm
|
||||
mv /tmp/linux-amd64/helm /usr/local/bin/helm
|
||||
helm version --short
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint
|
||||
run: helm lint "$CHART_DIR"
|
||||
|
||||
# Rendering is the real test. `helm lint` accepts a chart whose
|
||||
# templates fail to execute, and every guard in this chart is a
|
||||
# template `fail` that only fires during rendering.
|
||||
- name: Render default values
|
||||
run: helm template test "$CHART_DIR" > /dev/null
|
||||
|
||||
- name: Render a multi-replica install
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set server.replicaCount=3 \
|
||||
--set web.replicaCount=3 > /dev/null
|
||||
|
||||
# The reaper deletes whole instances, so "does this env appear only
|
||||
# in cloud mode" is worth asserting rather than eyeballing.
|
||||
- name: Check the reaper is cloud-only
|
||||
run: |
|
||||
set -eu
|
||||
if helm template test "$CHART_DIR" | grep -q FREE_INSTANCE_REAP_AFTER; then
|
||||
echo "FREE_INSTANCE_REAP_AFTER is set on a self-hosted render"
|
||||
exit 1
|
||||
fi
|
||||
if ! helm template test "$CHART_DIR" \
|
||||
--set server.env.deploymentType=cloud \
|
||||
| grep -q FREE_INSTANCE_REAP_AFTER; then
|
||||
echo "FREE_INSTANCE_REAP_AFTER is missing from a cloud render"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: reaper configured in cloud mode only"
|
||||
|
||||
- name: Render against external Redis and MongoDB
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set redis.enabled=false \
|
||||
--set redis.addr=redis.example.com:6379 \
|
||||
--set mongo.enabled=false \
|
||||
--set server.env.mongoUri=mongodb://mongo.example.com:27017/vantage > /dev/null
|
||||
|
||||
- name: Render with the Traefik ingress
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.host=agents.example.com \
|
||||
--set ingress.tls.certResolver=letsencrypt \
|
||||
--set server.env.grpcHost=agents.example.com:443 > /dev/null
|
||||
|
||||
# The shape the cloud deployment actually uses: a wildcard tenant
|
||||
# namespace, /api and /auth routed at the edge, and no apex — that
|
||||
# belongs to the marketing site, which this chart does not deploy.
|
||||
- name: Render a wildcard host with edge-routed API paths
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set ingress.enabled=true \
|
||||
--set 'ingress.web.host=*.vantage.example.com' \
|
||||
--set ingress.api.enabled=true \
|
||||
--set ingress.grpc.host=agents.example.com \
|
||||
--set server.env.grpcHost=agents.example.com:443 \
|
||||
--set ingress.tls.secretName=vantage-tls \
|
||||
--set ingress.tls.grpcSecretName=agents-tls > /dev/null
|
||||
|
||||
# The guards are load-bearing, so their absence is a regression the
|
||||
# same way a broken render is. Each of these must fail.
|
||||
- name: Check the guards still refuse bad values
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
refuses() {
|
||||
desc="$1"; shift
|
||||
if helm template test "$CHART_DIR" "$@" > /dev/null 2>&1; then
|
||||
echo "GUARD MISSING: $desc was accepted"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: refused $desc"
|
||||
}
|
||||
|
||||
refuses "mongo disabled with an in-chart URI" \
|
||||
--set mongo.enabled=false
|
||||
refuses "redis disabled with no external address" \
|
||||
--set redis.enabled=false
|
||||
refuses "multiple replicas on a ReadWriteOnce volume" \
|
||||
--set server.replicaCount=2 --set server.persistence.enabled=true
|
||||
refuses "ingress with no web host" \
|
||||
--set ingress.enabled=true
|
||||
refuses "edge-routed API with an empty path list" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.enabled=false \
|
||||
--set ingress.api.enabled=true \
|
||||
--set 'ingress.api.paths=null'
|
||||
refuses "gRPC ingress with no host" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set server.env.grpcHost=agents.example.com:443
|
||||
refuses "gRPC ingress while grpcHost is still in-cluster" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.host=agents.example.com
|
||||
|
||||
- name: Read the chart version
|
||||
id: chart
|
||||
run: |
|
||||
set -eu
|
||||
VERSION="$(grep '^version:' "$CHART_DIR/Chart.yaml" | awk '{print $2}')"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "chart version is $VERSION"
|
||||
|
||||
# Chart.yaml is the source of truth for the version; the tag only
|
||||
# says "publish this one". A mismatch is a mistake worth stopping
|
||||
# for — the alternative is stamping the tag over Chart.yaml, which
|
||||
# leaves the repository disagreeing with what was published.
|
||||
- name: Check the tag matches Chart.yaml
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
run: |
|
||||
set -eu
|
||||
TAG_VERSION="${GITHUB_REF_NAME#chart/v}"
|
||||
CHART_VERSION="${{ steps.chart.outputs.version }}"
|
||||
if [ "$TAG_VERSION" != "$CHART_VERSION" ]; then
|
||||
echo "tag chart/v$TAG_VERSION does not match Chart.yaml version $CHART_VERSION"
|
||||
echo "bump version: in $CHART_DIR/Chart.yaml, or retag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
helm package "$CHART_DIR" --destination dist
|
||||
ls -l dist
|
||||
|
||||
- name: Publish to the Gitea chart registry
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
env:
|
||||
# github.server_url is this Gitea instance, so the registry
|
||||
# host needs no variable of its own and cannot drift from it.
|
||||
REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm/api/charts
|
||||
# The same pair server-deploy.yml uses for `docker login`.
|
||||
# RELEASE_TOKEN, not REGISTRY_PASSWORD: the latter is named in
|
||||
# the docs but set by no workflow, and an unset secret becomes
|
||||
# an empty password, which Gitea reports as "Failed to
|
||||
# authenticate user" rather than as a missing credential.
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
CHART_VERSION: ${{ steps.chart.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
PKG="dist/vantage-${CHART_VERSION}.tgz"
|
||||
test -f "$PKG"
|
||||
|
||||
# Checked explicitly, because the failure it prevents is a
|
||||
# 401 that looks like a permissions problem on the token that
|
||||
# was never sent.
|
||||
if [ -z "${REGISTRY_USER}" ] || [ -z "${REGISTRY_TOKEN}" ]; then
|
||||
echo "REGISTRY_USER or RELEASE_TOKEN is not set on this repository."
|
||||
echo "RELEASE_TOKEN needs the write:package scope to publish a chart."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "publishing to ${REGISTRY} as ${REGISTRY_USER}"
|
||||
|
||||
# --fail-with-body so an HTTP error is a failed step with the
|
||||
# server's explanation, rather than a green run that published
|
||||
# nothing. A repeated version is rejected by the registry;
|
||||
# that is the intended behaviour, not something to retry past.
|
||||
curl --fail-with-body -sS \
|
||||
--user "${REGISTRY_USER}:${REGISTRY_TOKEN}" \
|
||||
-X POST \
|
||||
--upload-file "$PKG" \
|
||||
"$REGISTRY"
|
||||
|
||||
echo "published vantage ${CHART_VERSION}"
|
||||
echo " helm repo add vantage ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm"
|
||||
echo " helm install vantage vantage/vantage --version ${CHART_VERSION}"
|
||||
@@ -77,15 +77,17 @@ jobs:
|
||||
# generated pb is committed under server/, but a proto change
|
||||
# that someone regenerates in the same push should not depend
|
||||
# on that ordering.
|
||||
flag server '^(server/|shared/|proto/|go\.work)'
|
||||
flag server '^(server/|shared/|proto/|default_steps/|go\.work)'
|
||||
flag sitesvc '^(sitesvc/|shared/|go\.work)'
|
||||
flag admin '^(admin/|shared/|go\.work)'
|
||||
|
||||
# The three Next images use their own directory as the build
|
||||
# context, so nothing outside it can affect them.
|
||||
# The three Next images and the docs site use their own
|
||||
# directory as the build context, so nothing outside it can
|
||||
# affect them.
|
||||
flag web '^web/'
|
||||
flag site '^site/'
|
||||
flag adminsite '^adminsite/'
|
||||
flag docsite '^docsite/'
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
@@ -106,7 +108,6 @@ jobs:
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f web/Dockerfile web/
|
||||
@@ -149,6 +150,18 @@ jobs:
|
||||
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
|
||||
--build-arg NEXT_PUBLIC_SITE_URL="${{ vars.SITE_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f adminsite/Dockerfile adminsite/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push docsite image
|
||||
if: steps.changed.outputs.docsite == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/docsite:latest"
|
||||
# DOCS_BASE_URL must match the proxy location that routes to
|
||||
# this container and the directory the image serves from.
|
||||
docker build \
|
||||
-t "$IMAGE" \
|
||||
-f docsite/Dockerfile docsite/
|
||||
docker push "$IMAGE"
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@ dist
|
||||
build
|
||||
.env
|
||||
.env.bck
|
||||
.env.live
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
@@ -11,4 +12,6 @@ installer/*.msi
|
||||
installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
graphify-out
|
||||
docker-compose.live.yml
|
||||
+388
-71
@@ -94,6 +94,11 @@ vantage/
|
||||
│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit
|
||||
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
|
||||
│ └── lib/ # api client, session guards, formatters
|
||||
├── docsite/ # user documentation (Docusaurus, static)
|
||||
│ ├── docs/ # getting-started, vantage, hq, reference, operations
|
||||
│ ├── src/css/custom.css # site/'s tokens, copied, mapped onto --ifm-*
|
||||
│ ├── sidebars.ts # authored by hand, not autogenerated
|
||||
│ └── nginx.conf # serves the build under /docs
|
||||
├── shared/ # imported by server, sitesvc and admin
|
||||
│ ├── mail/ # the one email system: transport + tmpl templates
|
||||
│ ├── license/ # payload, sign, verify, trusted keys, plans
|
||||
@@ -115,9 +120,89 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire
|
||||
|
||||
### Workflows
|
||||
|
||||
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
|
||||
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to MongoDB (`workflow_log_lines`, one document per line); the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
|
||||
|
||||
Default steps are seeded per org at boot (`SeedDefaultSteps`). Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
|
||||
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`
|
||||
runs on `scratch`, 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,
|
||||
value ≤64, 20 per server, `sys:` reserved. **There is no `tags` collection**: a
|
||||
tag is a property of a server, not an entity, so `KnownTags` aggregates over
|
||||
`servers` rather than reading a registry that would need reference counting to
|
||||
know when a tag stopped existing. `PUT /api/servers/:id/tags` replaces the whole
|
||||
map — last-write-wins over a small map beats merge semantics between two people
|
||||
editing one server. The index is `{instance_id: 1, "tags.$**": 1}`, wildcard
|
||||
because the queried key is chosen by the user at request time and cannot be named
|
||||
in advance; `EnsureServerIndexes` warns rather than being fatal, since a missing
|
||||
index degrades tag filtering to a scan of a small collection and is no reason to
|
||||
refuse to serve the fleet list.
|
||||
|
||||
`services.ResolveTargets` is the **single** answer to which servers a workflow
|
||||
touches — the run path and validation both go through it, so the readout and the
|
||||
dispatch cannot disagree. It is the distinct union of `target_server_ids` and
|
||||
`target_tags` (AND across keys), ordered by the fleet rather than by the
|
||||
arguments, so two runs naming the same servers differently are still comparable
|
||||
line by line. **An empty selector matches nothing** on purpose: "matches
|
||||
everything" turns a cleared field in the designer into a fleet-wide run. Both
|
||||
empty is `ErrNoTargets` (400), not a success over zero servers. Offline servers
|
||||
are **not** filtered out — the dispatcher already answers 503 per server, and a
|
||||
patch run that silently omits an unreachable machine is worse than one that
|
||||
visibly fails on it.
|
||||
|
||||
**Both halves of the selector are edited in `EditWorkflowModal`** — the named
|
||||
servers in a `DualListBox`, the tag rows directly beneath it — and saved
|
||||
together by one `updateWorkflow`. The designer's Targets panel is **read-only**:
|
||||
it reports the count and the tags and links to Edit. Splitting the two halves
|
||||
across two screens meant a workflow's reach was decided in two places with no
|
||||
one view showing both.
|
||||
|
||||
`web/lib/targets.ts` **duplicates the match logic in TypeScript** to draw the
|
||||
resolved count without a round trip, since the browser already holds the fleet.
|
||||
It is a second implementation of `UnionTargets` / `MatchesTags` and must change
|
||||
in the same commit as the Go one — the same shape of hazard as the mirrored
|
||||
token blocks. It is a shared module rather than inline in a component because
|
||||
the logic had already been written twice, and the second copy — the workflows
|
||||
list — counted `target_server_ids` alone, so a **tag-only workflow reported zero
|
||||
targets** while running fine.
|
||||
|
||||
The server picker is a hand-built two-pane list, not `<select multiple>`: a
|
||||
native multi-select paints its selected rows with the platform highlight colour,
|
||||
which cannot be restyled across browsers and lands outside the token palette on
|
||||
a dark ground.
|
||||
|
||||
### Monitors
|
||||
|
||||
@@ -133,12 +218,148 @@ Key/value pairs grouped by name, encrypted at rest with AES-256-GCM. Consumed tw
|
||||
|
||||
### Browser console
|
||||
|
||||
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel` upgrades to a WebSocket and proxies to **guacd** (Apache Guacamole daemon) using `github.com/wwt/guac`. SSH connections authenticate with a stored private key; RDP/VNC credentials are encrypted, single-use, and consumed when the tunnel opens.
|
||||
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel`
|
||||
upgrades to a WebSocket and proxies to **guacd** using `github.com/wwt/guac`.
|
||||
|
||||
guacd never dials the managed server. The server binds a single-use ephemeral
|
||||
listener, pushes `OpenProxyCmd` down the agent's command stream, and the agent
|
||||
opens a `ProxyStream` and relays the connection from its own **`127.0.0.1`** —
|
||||
the host is hardcoded agent-side, so the control plane can name only a port.
|
||||
This is what makes the console work on Vantage Cloud, where the customer's
|
||||
server is behind NAT on a private address. It also means the console now
|
||||
**requires a live agent** on every deployment: `consoleConnect` answers 409
|
||||
`agent_offline` rather than hanging.
|
||||
|
||||
**guacd's Service is headless on purpose.** The server resolves `GUACD_ADDR` to
|
||||
build the allow-list of sources permitted to claim a relay listener; a ClusterIP
|
||||
resolves to the Service's virtual address while guacd connects from its *pod*
|
||||
IP, so every relay connection is rejected and every session dies with
|
||||
`waiting for guacd: i/o timeout`. Compose is immune — there the name resolves to
|
||||
the address that connects.
|
||||
|
||||
SSH connections authenticate with a stored private key; RDP/VNC credentials are
|
||||
encrypted, single-use, and consumed when the tunnel opens. None of them reach
|
||||
the agent — the session is negotiated end-to-end between guacd and the target
|
||||
daemon, so the agent relays bytes it cannot read.
|
||||
|
||||
### Running more than one server replica
|
||||
|
||||
An agent's `CommandStream` terminates on exactly **one** server process. Every
|
||||
piece of coordination below exists because of that single fact: with several
|
||||
replicas, the process asked to do something to an agent is almost never the
|
||||
process holding that agent's stream.
|
||||
|
||||
`server/internal/bus` is the Redis message bus that closes the gap. It adds no
|
||||
infrastructure — Redis was already required for sessions — and it is **not
|
||||
optional on a single-replica deployment**: dispatch takes the bus path always,
|
||||
so the code running in production is the code running everywhere, rather than a
|
||||
rare cross-pod branch that only fails under load.
|
||||
|
||||
| Concern | How it crosses replicas |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Which pod owns an agent | `vantage:agent:<server_id>` holds the owner's node ID with a 30s TTL, renewed every 10s. `Dispatcher.IsConnected` is an `EXISTS` on it |
|
||||
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** — a command whose owner died must fail loudly (503) rather than queue. The envelope carries `node`, the presence holder resolved at publish time, and a pod ignores envelopes addressed elsewhere: the channel is a fan-out, and during a reconnect a half-open stream's pod is still subscribed. Unaddressed, it could ack first and queue the command onto a dead stream — the operator told it worked, the agent never seeing it. Presence renewal is owner-only (`RenewPresence`) for the same reason: a blind `SET` let the stale pod steal the key back every 10s |
|
||||
| Step results | the owner pod publishes to `vantage:res:<command_id>`; the pod driving the run subscribes **before** dispatching, or a fast agent answers into a channel nobody has joined |
|
||||
| Step output | never crosses. The dispatch envelope carries the secret mask list, so the owner pod masks and writes lines itself — unmasked bytes stay off the bus |
|
||||
| Console relay | **not routed to the owner pod at all.** A `ProxyStream` is its own HTTP/2 request and an L7 proxy balances requests, not connections, so it does not follow the command stream — the listener therefore cannot be bound in advance. Whichever pod receives the stream binds it and announces **its own** address on `vantage:proxyaddr:<proxy_id>`; `vantage:proxypending:<proxy_id>` (30s, consumed atomically) is what authorises the claim, and the failure reason comes back on `vantage:proxyend:<proxy_id>` |
|
||||
| Background jobs | `bus.RunAsLeader` — one Redis lock named `housekeeping` |
|
||||
|
||||
**Workflow logs are in MongoDB** (`workflow_log_lines`, one document per line,
|
||||
with a `workflow_log_seq` counter document per run/server). Two pods write the
|
||||
same log concurrently — the run's pod emits markers, the agent's pod emits
|
||||
output — so ordering only means anything if both draw sequence numbers from the
|
||||
same counter. `StepRun.log_offset` is that sequence number now, not a byte
|
||||
offset. Writes are batched (128 lines or 250ms) and capped: 8 KB per line,
|
||||
200k lines per server-run, after which one final `[vantage] log truncated`
|
||||
marker is written and the rest is dropped. Without that cap a `yes` in a step
|
||||
is a database incident. **Nothing writes to `/data` any more**, which is why
|
||||
`server.persistence` now defaults to off and `VANTAGE_WORKFLOW_LOG_DIR` is gone.
|
||||
|
||||
**Shutdown order is load-bearing.** `main` traps SIGTERM, stops gRPC
|
||||
(`GracefulStop`, 10s cap) and only then drains HTTP. Each `CommandStream`
|
||||
handler releases its agent's presence claim on return, so a killed process
|
||||
leaves `vantage:agent:<server_id>` behind for the rest of its 30s TTL — during
|
||||
which other replicas dispatch to a pod that has exited and the caller sees
|
||||
`agent offline` for a perfectly healthy agent. Draining HTTP first would hold
|
||||
those claims for the length of the drain, which is why gRPC goes first. The
|
||||
chart's `server.terminationGracePeriodSeconds` (30s) must stay above the
|
||||
10s + 10s the stop sequence needs, or the kubelet SIGKILLs mid-shutdown and the
|
||||
handling buys nothing.
|
||||
|
||||
The agent side of the same failure: `runCommandStream` resets its backoff only
|
||||
after a stream that survived `streamHealthyAfter`. `connectAndHandleStream`
|
||||
returns an error on *every* stream end, healthy ones included, so without that
|
||||
reset the backoff only ever climbed — an agent pinned itself at the ceiling
|
||||
after a handful of ordinary deploys and stayed there. The ceiling is 30s, not
|
||||
minutes, because while the stream is down the agent still polls `SyncKeys` and
|
||||
still reads as `active` in the fleet list while answering no commands at all.
|
||||
|
||||
**The leader lock is not an optimisation.** N replicas each running the monitor
|
||||
scheduler means each check fires N times, each incident notification reaches the
|
||||
customer N times, and each hourly rollup is written N times; N reapers race to
|
||||
purge the same Free instance. `monitorsched`, `StartReaper`, `StartLogSweeper`,
|
||||
`StartAuditSweeper` and the offline sweep therefore all run inside one
|
||||
`RunAsLeader("housekeeping", …)` — one role, one lock. Each takes a context
|
||||
cancelled the instant leadership is lost, and must return when it is.
|
||||
|
||||
Redis rather than a Kubernetes `Lease` so Compose takes the identical path: one
|
||||
implementation to reason about, not two.
|
||||
|
||||
Two deployment requirements come with `replicaCount > 1`: every replica must
|
||||
share **one** Redis (a per-pod Redis partitions the bus and every agent looks
|
||||
offline to two thirds of the fleet), and `POD_IP` must be set — the chart does
|
||||
it from the downward API — because `PROXY_ADVERTISE_HOST` names the Service, and
|
||||
a Service cannot address the one pod holding a console listener.
|
||||
|
||||
### Inventory and OS updates
|
||||
|
||||
Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
|
||||
|
||||
### Package inventory and CVE findings
|
||||
|
||||
Agents report their installed packages hourly; the control plane matches them
|
||||
against distribution security feeds and raises findings that link to the
|
||||
existing `ApplyUpdatesCmd` patching path. Gated by the `vuln_scanning` licence
|
||||
feature, **checked at collection rather than display** — an ungated instance
|
||||
stores no inventory, and storage is the expensive half.
|
||||
|
||||
**Matching uses distribution feeds, never NVD version ranges.** Distributions
|
||||
backport security fixes without changing the upstream version: Ubuntu's
|
||||
`openssl 3.0.2-0ubuntu1.15` is patched against CVE-2023-0286 while NVD still
|
||||
calls 3.0.2 vulnerable. Matching on NVD would report a fully patched fleet as
|
||||
critical, and once the first report is mostly wrong nobody reads the second.
|
||||
`trivy-db` is those feeds pre-merged; `server/internal/vulndb` pulls it as an
|
||||
OCI artifact to an ephemeral directory. Version comparison is bought from
|
||||
`go-deb-version`/`go-rpm-version`/`go-apk-version` because dpkg epochs, `~`
|
||||
sorting before the empty string, and `rpmvercmp` are each a silent false
|
||||
negative waiting to happen.
|
||||
|
||||
**Only the leader matches.** `ReportPackages` upserts the list and sets
|
||||
`scan_pending`; it does not scan. `vulnsched` runs inside the existing
|
||||
`bus.RunAsLeader("housekeeping", …)` and does the matching, because otherwise
|
||||
every replica needs the ~50MB database resident and a database refresh has N
|
||||
replicas rescanning the same fleet and sending N digests. The tick is also the
|
||||
digest's batch boundary, which is what makes "one message, not five hundred"
|
||||
structural rather than a debounce someone maintains.
|
||||
|
||||
Findings are **never deleted when a package is patched** — the state moves to
|
||||
`fixed`, so "what did we remediate last quarter" stays answerable. Acceptance
|
||||
requires a reason and an expiry, and reopens automatically: permanent dismissal
|
||||
is where risk goes to be forgotten. An unsupported distribution reports
|
||||
`status: unsupported`, never "0 findings"; claiming clean when the truth is
|
||||
unknown is the same lie as a silently stale database, which is why
|
||||
`vulndb_meta.pulled_at` is on screen rather than only in a log.
|
||||
|
||||
**`server/Dockerfile`'s runtime stage is `scratch`, so it carries an explicitly
|
||||
copied `/tmp`.** The scheduler unpacks the database to a temporary directory,
|
||||
and a scratch image has none — the failure is `vulnsched: temp dir: stat /tmp:
|
||||
no such file or directory`, logged once at boot while every other subsystem
|
||||
runs normally, so the only symptom is a fleet that never reports a finding.
|
||||
|
||||
Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for
|
||||
air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and
|
||||
scheduler off entirely.
|
||||
|
||||
### Agent self-update
|
||||
|
||||
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
|
||||
@@ -147,19 +368,25 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat
|
||||
|
||||
`site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
|
||||
|
||||
`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately *outside* `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play.
|
||||
`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately _outside_ `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play.
|
||||
|
||||
**`ADMIN_ORIGIN` must list every browser origin that calls admin — currently two**: `https://vantage-hq.hostxtra.co.uk` for the console, and `https://vantage.hostxtra.co.uk` because the marketing site's `/start` form posts account signups to admin directly. It is comma-separated. A missing origin does not produce a 403: `cors()` simply omits the `Access-Control-Allow-Origin` header and still answers the preflight `204`, so the browser blocks the request and **admin logs nothing at all**. Symptom is a CORS preflight failure on an endpoint that works fine under curl.
|
||||
|
||||
`sitesvc/` (port `8082`) now owns only the contact flow:
|
||||
|
||||
| Form | Endpoint | Effect |
|
||||
| ------- | --------------------- | ----------------------------------------------------------------------- |
|
||||
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
|
||||
| Form | Endpoint | Effect |
|
||||
| ------- | ------------------- | ----------------------------------------------------------------------- |
|
||||
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
|
||||
|
||||
Account signup lives in `admin` instead (`POST /auth/signup`, `GET /auth/verify?token=…`) — see Signup and verification below.
|
||||
|
||||
`site`, `sitesvc` and `admin` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
|
||||
`site`, `sitesvc`, `admin` and `docsite` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
|
||||
|
||||
### Documentation site
|
||||
|
||||
`docsite/` is the user-facing documentation — Docusaurus 3 in docs-only mode (`routeBasePath: "/"`, no blog), one version tracking `main`, search indexed at build time by `@easyops-cn/docusaurus-search-local` so nothing external is keyed or called. It documents the **product**, not the codebase: this file remains the contributor's map, and the two are allowed to differ in altitude but not in fact. Five sections — Getting started, Vantage, Vantage HQ, Reference, Operations — with `sidebars.ts` authored by hand so ordering is a decision rather than a filename accident.
|
||||
|
||||
Unlike the three Next apps it builds to static files, so its runtime stage is `nginx:alpine-slim` rather than Node, and it listens on `80`. See the compose note below for the `/docs` prefix, which is the one thing about it that is easy to get wrong.
|
||||
|
||||
```bash
|
||||
# self-hosted install — no marketing site, no sitesvc
|
||||
@@ -234,7 +461,7 @@ instance authenticates it exactly as it authenticates anyone else, with **no
|
||||
runtime dependency on admin**. Revoking deletes that row — the control plane has
|
||||
no disabled state, and a row that exists is a row that can sign in.
|
||||
|
||||
`instance_members` in admin's database is only admin's *index* of those
|
||||
`instance_members` in admin's database is only admin's _index_ of those
|
||||
projections; the control-plane row is the access. That is why a failed
|
||||
`instance_members` insert unwinds the projection, and why the boot backfill can
|
||||
rebuild the index from the control plane but never the other way round.
|
||||
@@ -260,13 +487,14 @@ password-change endpoint at all, so there is no competing writer for the hash.
|
||||
|
||||
- **Bootstrap** — first run has no users. `GET /auth/bootstrap-status` drives `/setup`, `POST /auth/bootstrap` creates the first org plus its owner.
|
||||
- **Local auth** — email + password (bcrypt), `POST /auth/login`.
|
||||
- **OIDC** — configured _per org_ (`org_oidc`), issuer + client ID + encrypted client secret. `/auth/oidc/start` → `/auth/oidc/callback`.
|
||||
- **Auth providers** — configured _per instance_ in `auth_providers`, any number of them, each named and independently enabled. Issuer, client ID and an encrypted client secret per provider. `/auth/oidc/:providerId/start` → `/auth/oidc/:providerId/callback`. Presets (Entra, Google, Okta, GitHub) are a Go table in `server/internal/auth/presets.go` and expand to a real issuer on save, so nothing downstream knows a preset existed. GitHub is OAuth2 rather than OIDC and takes its own branch, requiring an address that is both primary **and** verified — an unverified address is not proof of control.
|
||||
- **Local login** — `settings.local_login_enabled`, a `*bool` because absent must mean enabled; a plain bool would disable password sign-in fleet-wide at upgrade. `services.CheckLockout` refuses any change leaving neither local login nor an enabled provider, and is enforced in the service layer so the settings path and the provider path cannot disagree.
|
||||
- **Sessions** — opaque 32-byte hex ID in the `km_session` cookie, session body stored in Redis with a 24h TTL.
|
||||
- **Roles** — `owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin.
|
||||
- **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
|
||||
|
||||
Unique indexes are a **security property**, not an optimisation. `users` is
|
||||
unique on `(instance_id, email)` — one address is one user *within* an instance,
|
||||
unique on `(instance_id, email)` — one address is one user _within_ an instance,
|
||||
and the same address may hold a user in several instances, because an account's
|
||||
people are projected into each instance they are granted. This is sufficient only
|
||||
because **every lookup by email is scoped by instance**; there is deliberately no
|
||||
@@ -293,7 +521,18 @@ service Vantage {
|
||||
|
||||
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
|
||||
|
||||
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`.
|
||||
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`.
|
||||
|
||||
**`PingCmd` is a liveness beat, and it is not redundant with gRPC keepalive.**
|
||||
The server sends one every 20s on an otherwise idle command stream; the agent
|
||||
treats 70s of silence as a dead stream and reconnects. Keepalive cannot do this
|
||||
job behind an L7 proxy: the agent's HTTP/2 connection terminates at the proxy,
|
||||
which answers pings on its own behalf, so a control-plane pod that dies leaves
|
||||
the agent blocked in `Recv` on a stream that never delivers another message and
|
||||
never errors — commands dispatched into it are silently lost while `SyncKeys`
|
||||
keeps succeeding and the fleet list still shows the server `active`. The agent's
|
||||
watchdog arms only **after** it has seen a first ping, so an older server that
|
||||
sends none is treated as working rather than put into a reconnect loop.
|
||||
|
||||
Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions live in `proto/vantage/v1/vantage.proto`.
|
||||
|
||||
@@ -304,11 +543,13 @@ Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions
|
||||
Unauthenticated:
|
||||
|
||||
```
|
||||
GET /healthz /readyz # liveness / readiness probes
|
||||
GET /install /install.ps1 # dynamic agent install scripts
|
||||
GET /update /update.ps1
|
||||
GET /auth/bootstrap-status
|
||||
POST /auth/bootstrap /auth/login /auth/logout
|
||||
GET /auth/me /auth/oidc/start /auth/oidc/callback
|
||||
GET /auth/me
|
||||
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} — no issuer, client ID or secret
|
||||
GET /api/secrets/:group/values # bearer token (ESO)
|
||||
```
|
||||
|
||||
@@ -331,12 +572,19 @@ channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:i
|
||||
secrets GET,POST /secrets · GET,PUT,DELETE /secrets/:group
|
||||
POST /secrets/:group/reveal · DELETE /secrets/:group/:key
|
||||
console POST /console/connect · GET /console/tunnel (websocket)
|
||||
vulns GET /vulnerabilities · GET /vulnerabilities/summary
|
||||
POST /vulnerabilities/rescan (owner|admin)
|
||||
POST,DELETE /vulnerabilities/:id/accept (owner|admin)
|
||||
GET /servers/:id/vulnerabilities · GET /servers/:id/packages
|
||||
GET /packages/search?name=
|
||||
GET,POST /vuln-rules · PUT,DELETE /vuln-rules/:id (owner|admin)
|
||||
audit GET /audit
|
||||
agent GET /agent/latest-version
|
||||
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
|
||||
licence GET /license · POST /license (POST: self-hosted only)
|
||||
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
|
||||
GET,PUT /org/oidc (owner|admin)
|
||||
providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id
|
||||
POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin)
|
||||
```
|
||||
|
||||
`GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy.
|
||||
@@ -406,11 +654,11 @@ GET /health/injection · /health/billing
|
||||
|
||||
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
|
||||
|
||||
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's *current* line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
|
||||
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `migrations`
|
||||
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
|
||||
@@ -422,9 +670,15 @@ Notes that are not obvious from the structs:
|
||||
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
|
||||
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
|
||||
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
|
||||
- `auth_providers.provider_id` is a short random identifier, not the Mongo `_id`: it appears in the callback URL a customer pastes into their IdP, and an `_id` there would publish a database key. `callback_notice` marks a provider migrated from the old single-provider shape, whose redirect URI therefore changed.
|
||||
- `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped.
|
||||
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
|
||||
- `server_packages` holds a server's whole package set in **one** document, not one per package. The hash already established that something changed, so a report is a single atomic upsert with no delta logic to get wrong; ~2000 packages is ~150KB, well inside the 16MB limit. `scan_pending` lives on the document rather than in memory so a leader handover cannot lose it.
|
||||
- `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists — a real state, never "not vulnerable".
|
||||
- `vulndb_meta` is a singleton and deliberately carries **no** `instance_id`: the vulnerability database is a property of the deployment, not a tenant. Same reasoning as `migrations`, and the reason it is absent from `services.ScopedCollections`.
|
||||
- **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance.
|
||||
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
|
||||
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
|
||||
|
||||
@@ -435,6 +689,7 @@ Admin's own database is separate and holds `accounts` · `admin_instances` · `l
|
||||
- `0001_default_org_backfill`
|
||||
- `0002_settings_org_backfill` (must run before 0003 — 0003 can create a `default` org, which pushes 0002 into its ambiguous multi-org branch)
|
||||
- `0003_missed_org_scopes`
|
||||
- `0005_auth_providers` — copies each `instance_oidc` document into `auth_providers`, ciphertext verbatim rather than decrypted and re-encrypted, so it does not need `KEY_ENCRYPTION_KEY` and cannot strand an instance's SSO configuration that has none set.
|
||||
|
||||
Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failure; `EnsureSecretIndexes` and `EnsureWorkflowIndexes` only warn.
|
||||
|
||||
@@ -498,34 +753,65 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
## Environment Variables (server)
|
||||
|
||||
| Name | Required | Notes |
|
||||
| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
|
||||
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
|
||||
| `MONGO_DB` | no | default `vantage` |
|
||||
| `REDIS_ADDR` | no | default `localhost:6379` |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
|
||||
| `GITEA_HOST` | yes | used to build install scripts and agent download URLs |
|
||||
| `GUACD_ADDR` | no | default `guacd:4822` |
|
||||
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
|
||||
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
|
||||
| Name | Required | Notes |
|
||||
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
|
||||
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
|
||||
| `MONGO_DB` | no | default `vantage` |
|
||||
| `REDIS_USERNAME` | no | Redis 6+ ACL user. Leave empty for a legacy `requirepass` instance — go-redis then sends AUTH with one argument instead of two |
|
||||
| `REDIS_PASSWORD` | no | empty for an unauthenticated Redis |
|
||||
| `REDIS_ADDR` | no | default `localhost:6379` |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
|
||||
| `GUACD_ADDR` | no | default `guacd:4822` |
|
||||
| `PROXY_ADVERTISE_HOST` | no | default `server`; the hostname guacd resolves the control plane by, handed to guacd as the relay's address. Wrong here and every console session fails at connect |
|
||||
| `PROXY_LISTEN_HOST` | no | default `0.0.0.0`; the interface the ephemeral relay listener binds |
|
||||
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
|
||||
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them |
|
||||
| `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job |
|
||||
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
|
||||
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for an air-gapped install, or to avoid the anonymous ghcr rate limit |
|
||||
| `VANTAGE_VULNDB_DISABLED` | no | `true` disables the vulnerability database puller and scan loop entirely. Findings already written are still served, and still shown as stale |
|
||||
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
|
||||
|
||||
**sitesvc** (`deploy/docker-compose.site.yml` only):
|
||||
|
||||
| Name | Required | Notes |
|
||||
| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Name | Required | Notes |
|
||||
| --------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MONGO_URI` | yes | **must point at the control plane's database.** sitesvc no longer provisions orgs itself, but it still refuses to start (`RequireMigratedDatabase`) against a database that has not run migration `0004` (the `orgs` → `instances` rename), and it (re)declares the shared `users.email` / `instances.slug` indexes at boot. The database name is read from the URI path; a URI without one is refused rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
|
||||
| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping |
|
||||
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
|
||||
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
|
||||
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
|
||||
| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request |
|
||||
| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
|
||||
| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping |
|
||||
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
|
||||
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
|
||||
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
|
||||
| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request |
|
||||
| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
|
||||
|
||||
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds four more — `site` (3003), `sitesvc` (8082), `admin` (8083) and `adminsite` (3004) — and is only used on vantage.hostxtra.co.uk.
|
||||
### Ingress (Helm, Traefik)
|
||||
|
||||
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`; the base compose hardcodes `redis:6379` for `server`, so those variables reach admin only.
|
||||
`ingress.enabled` publishes **two** hostnames, because the two audiences arrive over different protocols:
|
||||
|
||||
| Values | Route |
|
||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `ingress.web.host` (+ `web.extraHosts`) | browsers → `web:3000` |
|
||||
| `ingress.api.paths` (when `api.enabled`) | `/api`, `/auth` → `<release>-server:8080`, bypassing the Next proxy |
|
||||
| `ingress.grpc.host` | agents → a dedicated `<release>-server-grpc` Service on 9090, annotated `serversscheme: h2c` |
|
||||
|
||||
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site (`site/`, in `docker-compose.site.yml`), which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
|
||||
|
||||
**`ingress.api.enabled` routes `/api` and `/auth` straight to the server.** Both arrangements work — without it `web` proxies those prefixes onward itself (`web/next.config.ts`) — but edge routing is one hop shorter and matches what the Nginx Proxy Manager in front of the Docker deployment already does, so leaving it off makes the request path a different shape on Kubernetes than in production. It stays **off by default** because it only helps where the server is reachable on the same host and certificate as `web`; turning it on blindly moves the whole API onto a route that may not be provisioned. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
|
||||
|
||||
**The gRPC route needs its own Service.** The server terminates no TLS; it speaks plain h2c and always has, with TLS terminated by whatever sits in front. Traefik will not use h2c to a backend unless the *Service* says so, and that annotation applies to every port on the Service — so annotating the shared two-port `<release>-server` would force h2c on its HTTP port too.
|
||||
|
||||
**`server.env.grpcHost` is not derived from `ingress.grpc.host`, and the chart refuses to render if they disagree.** Agents dial whatever `grpcHost` says, and it is baked into every install one-liner; left pointing at the in-cluster Service while agents arrive through the ingress, every install succeeds and every agent then fails to connect, with nothing in the control plane explaining why. Guessing at the port (443? 9090?) would be worse than stopping.
|
||||
|
||||
TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) **or** `certResolver` (Traefik ACME). Setting neither while `tls.enabled` produces a TLS router with no certificate, so Traefik serves its self-signed default — which looks valid and is trusted by nothing. NOTES.txt warns on install rather than the chart failing, since it is a real if unusual choice behind another terminator.
|
||||
|
||||
---
|
||||
|
||||
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds five more — `site` (3003), `sitesvc` (8082), `admin` (8083), `adminsite` (3004) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk.
|
||||
|
||||
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
|
||||
|
||||
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`. `server` now reads the same three, so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it, so in Docker those credentials remain admin's alone.
|
||||
|
||||
---
|
||||
|
||||
@@ -547,17 +833,20 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`.
|
||||
|
||||
All three apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
|
||||
All four apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
|
||||
|
||||
| App | Ground | Accent | Themes |
|
||||
| --- | --- | --- | --- |
|
||||
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
|
||||
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
|
||||
| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
|
||||
| App | Ground | Accent | Themes |
|
||||
| ------------ | -------------------------- | -------------------------------- | --------------------------- |
|
||||
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
|
||||
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
|
||||
| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
|
||||
| `docsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
|
||||
|
||||
`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all three files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules.
|
||||
`adminsite/app/globals.css` and `docsite/src/css/custom.css` hold `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all four files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules.
|
||||
|
||||
There is a **fourth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the three web apps carries a hex.
|
||||
`docsite/` is the one place the tokens are not consumed through Tailwind: everything below its token block maps Docusaurus's `--ifm-*` variables onto them. Docusaurus already stamps `data-theme` on `<html>`, which is the selector `site/`'s dark block keys on, so the built-in toggle needed no wiring. The rule holds all the same — no rule in that file outside the token blocks carries a hex. The one concession is `docsite/static/img/favicon.svg`, which must, for the same reason the email layout must: a browser tab cannot read a token.
|
||||
|
||||
There is a **fifth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the four web apps carries a hex.
|
||||
|
||||
Tailwind in all three maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side.
|
||||
|
||||
@@ -614,7 +903,7 @@ GOOS=linux GOARCH=amd64 go build \
|
||||
|
||||
### `server-deploy.yml` — triggered on every push to `main`
|
||||
|
||||
Builds and pushes six images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin` and `adminsite`.
|
||||
Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`.
|
||||
|
||||
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host:
|
||||
|
||||
@@ -625,43 +914,60 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
|
||||
|
||||
**Each image only rebuilds when its own inputs changed.** A `git diff` against `github.event.before` decides, which is why the checkout uses `fetch-depth: 0` — the default shallow clone has one commit and nothing to diff — and why `git` is installed in the `docker:dind` container. The mapping follows the build contexts exactly:
|
||||
|
||||
| Image | Rebuilds when |
|
||||
| --- | --- |
|
||||
| `server` | `server/`, `shared/`, `proto/`, `go.work` |
|
||||
| `admin` | `admin/`, `shared/`, `go.work` |
|
||||
| `sitesvc` | `sitesvc/`, `shared/`, `go.work` |
|
||||
| `web` · `site` · `adminsite` | their own directory only |
|
||||
| Image | Rebuilds when |
|
||||
| ---------------------------- | ----------------------------------------- |
|
||||
| `server` | `server/`, `shared/`, `proto/`, `go.work` |
|
||||
| `admin` | `admin/`, `shared/`, `go.work` |
|
||||
| `sitesvc` | `sitesvc/`, `shared/`, `go.work` |
|
||||
| `web` · `site` · `adminsite` · `docsite` | their own directory only |
|
||||
|
||||
`shared/` fans out to all three Go images because each of their Dockerfiles copies `shared/` from a root context — **if a fourth service ever imports `shared/`, add it to that list or it will ship stale**. A change to the workflow file rebuilds everything, since a build arg is baked into the image. So does anything that leaves no trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a force-push whose old head is gone.
|
||||
|
||||
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `API_URL`, `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
|
||||
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
|
||||
|
||||
### `chart-release.yml` — validates on every chart change, publishes on `chart/v*` tags
|
||||
|
||||
Two jobs' worth of work in one, split by trigger. Any push or PR touching `deploy/chart/` lints the chart and renders it four ways: defaults, a multi-replica install, external Redis and MongoDB, and a set of values that **must be refused**. That last one is the point — every safety rail in this chart is a template `fail`, and `helm lint` happily accepts a chart whose templates never execute, so only rendering proves they still fire.
|
||||
|
||||
Publishing runs only on a `chart/v*` tag, to the Gitea Helm registry at `/api/packages/<owner>/helm/api/charts`. **`Chart.yaml` is the source of truth for the version**; the tag only selects which one to publish, and a tag that disagrees with `Chart.yaml` fails rather than stamping over it — the alternative leaves the repository disagreeing with what shipped. A version already in the registry is rejected by Gitea, which is intended: published chart versions are immutable.
|
||||
|
||||
The registry host comes from `github.server_url`, so it cannot drift from the instance the workflow is running on. It authenticates with `REGISTRY_USER` + **`RELEASE_TOKEN`** — the pair `server-deploy.yml` actually uses for `docker login`. `REGISTRY_PASSWORD` is listed in the secrets table below but set by no workflow; passing an unset secret yields an empty password and Gitea answers `401 Failed to authenticate user`, which reads like a scope problem on a token that was never sent. The publish step therefore checks both are non-empty before it calls curl. `RELEASE_TOKEN` needs `write:package` in addition to `write:release`.
|
||||
|
||||
```bash
|
||||
helm repo add vantage https://gitea.hostxtra.co.uk/api/packages/mrhid6/helm
|
||||
helm install vantage vantage/vantage --version 0.1.0
|
||||
```
|
||||
|
||||
### Tagging
|
||||
|
||||
```bash
|
||||
git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release
|
||||
git tag chart/v0.1.0 && git push origin chart/v0.1.0 # helm chart package
|
||||
git push origin main # server + web deploy
|
||||
```
|
||||
|
||||
### Secrets / variables
|
||||
|
||||
| Name | Type | Value |
|
||||
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
|
||||
| `REGISTRY_USER` | Secret | Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
|
||||
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
|
||||
| `DOCKER_HOST` | Variable | registry host used for image tags |
|
||||
| `API_URL` | Variable | baked into the `web` image at build time |
|
||||
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
|
||||
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
|
||||
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
|
||||
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
|
||||
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
|
||||
| `PADDLE_CLIENT_TOKEN`| Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. |
|
||||
| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. |
|
||||
| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. |
|
||||
| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. |
|
||||
| Name | Type | Value |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart). **This is the only token any workflow authenticates with** — `docker login` and the chart publish both pair it with `REGISTRY_USER` |
|
||||
| `REGISTRY_USER` | Secret | Gitea username. Must own `RELEASE_TOKEN`, or basic auth is rejected |
|
||||
| ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` |
|
||||
| `DOCKER_HOST` | Variable | registry host used for image tags |
|
||||
| `API_URL` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. |
|
||||
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
|
||||
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
|
||||
| `SITE_URL` | Variable | browser URL of the marketing site, baked into `adminsite` so `/login` can point at `/start`. **Signup has no page in `adminsite` at all** — one signup form, on `site/`. Empty renders no link rather than one that 404s. |
|
||||
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
|
||||
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
|
||||
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
|
||||
| `PADDLE_CLIENT_TOKEN` | Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. |
|
||||
| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. |
|
||||
| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. |
|
||||
| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. |
|
||||
| `DOCS_URL` | Variable | site `url` baked into `docsite`; `https://vantage.hostxtra.co.uk`. Empty falls back to that default rather than breaking the build. |
|
||||
| `DOCS_BASE_URL` | Variable | `/docs/`. Must match the NPM location and the directory the image serves from — all three, or the HTML loads and every asset 404s. |
|
||||
| `APP_URL` | Variable | control-plane link in `docsite`'s navbar. |
|
||||
|
||||
---
|
||||
|
||||
@@ -680,4 +986,15 @@ git push origin main # server + web deploy
|
||||
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
|
||||
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
|
||||
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
|
||||
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
|
||||
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
@@ -245,19 +246,23 @@ func claimPlaceholderLink(c *gin.Context) {
|
||||
placeholderID := inst.InstanceID
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": placeholderID},
|
||||
bson.M{"$set": bson.M{
|
||||
"instance_id": body.InstanceID,
|
||||
"status": models.StatusActive,
|
||||
"placeholder": false,
|
||||
}}); err != nil {
|
||||
bson.M{
|
||||
"$set": bson.M{
|
||||
"instance_id": body.InstanceID,
|
||||
"status": models.StatusActive,
|
||||
"placeholder": false,
|
||||
},
|
||||
"$addToSet": bson.M{"previous_instance_ids": placeholderID},
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Re-point the subscription from the placeholder id to the real UUID so
|
||||
// billing.IssueForInstance (and every later webhook) finds it.
|
||||
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
|
||||
bson.M{"instance_id": placeholderID},
|
||||
bson.M{"$set": bson.M{"instance_id": body.InstanceID}}); err != nil {
|
||||
|
||||
// Re-point the subscription rows from the placeholder id to the real UUID so
|
||||
// billing.IssueForInstance finds it, and rewrite Paddle's own copy of
|
||||
// custom_data — written at checkout, it still names the placeholder, and every
|
||||
// later event on this subscription is decoded from it.
|
||||
if err := licensing.RepointSubscriptions(ctx, placeholderID, body.InstanceID, inst.AccountID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
@@ -67,9 +69,21 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
|
||||
}
|
||||
|
||||
// Resolve BEFORE recording. A self-hosted subscription's custom_data is
|
||||
// written at checkout and names the placeholder; the claim rewrote the
|
||||
// instance's identity to the install's real UUID and patched Paddle, but that
|
||||
// patch is best-effort and any event already in flight still carries the old
|
||||
// id. Writing it straight through would revert the linked subscription row and
|
||||
// then fail to find the instance, wedging every renewal.
|
||||
instanceID, inst, err := resolveInstance(ctx, d.CustomData.InstanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscription %s names unknown instance %s: %w",
|
||||
d.ID, d.CustomData.InstanceID, err)
|
||||
}
|
||||
|
||||
sub := models.Subscription{
|
||||
AccountID: d.CustomData.AccountID,
|
||||
InstanceID: d.CustomData.InstanceID,
|
||||
InstanceID: instanceID,
|
||||
PaddleSubscriptionID: d.ID,
|
||||
Tier: match.Tier,
|
||||
Term: match.Term,
|
||||
@@ -88,13 +102,6 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
|
||||
return fmt.Errorf("subscription %s names unknown instance %s: %w",
|
||||
d.ID, d.CustomData.InstanceID, err)
|
||||
}
|
||||
|
||||
// Placeholders are the payment-first path: the instance does not exist until
|
||||
// this confirmed-payment event. A cloud placeholder is provisioned here and
|
||||
// then issued (first term). A self-hosted placeholder has no UUID to bind to
|
||||
@@ -116,6 +123,27 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
return promoteAndIssue(ctx, &inst, match, reason)
|
||||
}
|
||||
|
||||
// resolveInstance finds the instance a webhook's custom_data names, following the
|
||||
// identity trail when the id is one a placeholder claim or a relink has since
|
||||
// replaced. It returns the instance's CURRENT id, which is the only id anything
|
||||
// else should be written against.
|
||||
func resolveInstance(ctx context.Context, customDataID string) (string, models.Instance, error) {
|
||||
var inst models.Instance
|
||||
err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": customDataID}).Decode(&inst)
|
||||
if err == nil {
|
||||
return inst.InstanceID, inst, nil
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return "", inst, err
|
||||
}
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"previous_instance_ids": customDataID}).Decode(&inst); err != nil {
|
||||
return "", inst, err
|
||||
}
|
||||
return inst.InstanceID, inst, nil
|
||||
}
|
||||
|
||||
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
|
||||
// licence from granted. This is the only promotion path other than the staff
|
||||
// grant, and it exists because a webhook is a confirmed payment.
|
||||
@@ -280,9 +308,10 @@ func billingEmailFor(ctx context.Context, accountID string) string {
|
||||
|
||||
// instanceNameFor is a best-effort display name for an email subject.
|
||||
func instanceNameFor(ctx context.Context, instanceID string) string {
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" {
|
||||
// Alias-aware: a cancellation can name a placeholder id, and "your instance"
|
||||
// in place of the name the customer chose reads like the wrong email.
|
||||
_, inst, err := resolveInstance(ctx, instanceID)
|
||||
if err != nil || inst.Name == "" {
|
||||
return "your instance"
|
||||
}
|
||||
return inst.Name
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -57,6 +59,48 @@ func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*mod
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
// RepointSubscriptions follows an instance identity rewrite: it moves every
|
||||
// subscription row from the old id to the new one, then rewrites Paddle's copy
|
||||
// of custom_data so future webhooks decode to the new id.
|
||||
//
|
||||
// The local rewrite is returned as an error — issuance reads the subscription
|
||||
// back, so a half-moved row is worth failing on. The Paddle patch only logs: the
|
||||
// customer must not be blocked from linking or relinking by an outbound API
|
||||
// failure, and the caller has already recorded the old id in
|
||||
// previous_instance_ids, which is what makes the webhook path correct whether or
|
||||
// not the patch lands.
|
||||
func RepointSubscriptions(ctx context.Context, oldID, newID, accountID string) error {
|
||||
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
|
||||
bson.M{"instance_id": oldID},
|
||||
bson.M{"$set": bson.M{"instance_id": newID}}); err != nil {
|
||||
return fmt.Errorf("repoint %s -> %s: %w", oldID, newID, err)
|
||||
}
|
||||
|
||||
cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"instance_id": newID})
|
||||
if err != nil {
|
||||
log.Printf("repoint %s -> %s: read subscriptions: %v", oldID, newID, err)
|
||||
return nil
|
||||
}
|
||||
var subs []models.Subscription
|
||||
if err := cur.All(ctx, &subs); err != nil {
|
||||
log.Printf("repoint %s -> %s: decode subscriptions: %v", oldID, newID, err)
|
||||
return nil
|
||||
}
|
||||
for _, s := range subs {
|
||||
if s.PaddleSubscriptionID == "" {
|
||||
continue
|
||||
}
|
||||
// Paddle replaces the whole custom_data object on a PATCH, so account_id
|
||||
// is sent alongside rather than dropped.
|
||||
if err := paddle.Get().UpdateSubscriptionCustomData(ctx, s.PaddleSubscriptionID,
|
||||
map[string]string{"account_id": accountID, "instance_id": newID}); err != nil {
|
||||
log.Printf("repoint %s -> %s: patch custom_data on %s: %v",
|
||||
oldID, newID, s.PaddleSubscriptionID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Relink moves a licence to a rebuilt server's new UUID.
|
||||
//
|
||||
// The replacement covers the REMAINING term, not a fresh one — relinking is not
|
||||
@@ -96,13 +140,25 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": oldID},
|
||||
bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil {
|
||||
bson.M{
|
||||
"$set": bson.M{"instance_id": newID},
|
||||
"$inc": bson.M{"relink_count": 1},
|
||||
"$addToSet": bson.M{"previous_instance_ids": oldID},
|
||||
}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
return nil, fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
|
||||
// A relink rewrites the instance's identity exactly as a placeholder claim
|
||||
// does, so the same two things have to follow it: the subscription rows that
|
||||
// named the old id, and Paddle's own copy of custom_data. Without this a
|
||||
// renewal after a relink cannot find its instance and the term never extends.
|
||||
if err := RepointSubscriptions(ctx, oldID, newID, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actor := accountID
|
||||
if staff {
|
||||
actor = "staff"
|
||||
|
||||
@@ -77,6 +77,16 @@ func Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
if lic.ExpiresAt.Add(reapAfter).Before(now) {
|
||||
if _, err := db.Admin("admin_instances").DeleteOne(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
|
||||
log.Printf("lifecycle: delete instance %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
if _, err := db.Admin("licenses").DeleteMany(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
|
||||
log.Printf("lifecycle: delete licenses for instance %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
|
||||
if due == "" {
|
||||
continue
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before
|
||||
@@ -210,6 +212,73 @@ func Backfill(ctx context.Context) error {
|
||||
if err := backfillEntitlements(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pass 6: instances whose identity was rewritten before previous_instance_ids
|
||||
// existed carry no trail, and Paddle's custom_data still names the id they
|
||||
// were rewritten FROM — so their next webhook resolves to nothing. Both
|
||||
// rewrites wrote an audit entry naming the old id, which is the only surviving
|
||||
// record of it, so reconstruct the trail from those.
|
||||
if err := backfillInstanceIDHistory(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfillInstanceIDHistory rebuilds previous_instance_ids from the audit entries
|
||||
// the two identity rewrites leave behind: a placeholder claim
|
||||
// ("instance.placeholder_linked", detail "from placeholder <id>") and a relink
|
||||
// ("instance.relinked", detail "was <id>").
|
||||
//
|
||||
// $addToSet is what makes it idempotent, and it also means a chain of relinks
|
||||
// accumulates rather than the last one winning. Entries are walked NEWEST first,
|
||||
// matching on the current id or an already-recovered one: an instance relinked
|
||||
// A→B→C answers to neither A nor B by the time this runs, so the C entry has to
|
||||
// record B before the B entry has anything to attach A to.
|
||||
func backfillInstanceIDHistory(ctx context.Context) error {
|
||||
prefixes := map[string]string{
|
||||
"instance.placeholder_linked": "from placeholder ",
|
||||
"instance.relinked": "was ",
|
||||
}
|
||||
actions := make(bson.A, 0, len(prefixes))
|
||||
for action := range prefixes {
|
||||
actions = append(actions, action)
|
||||
}
|
||||
|
||||
cur, err := db.Admin("admin_audit").Find(ctx,
|
||||
bson.M{"action": bson.M{"$in": actions}},
|
||||
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var entries []AuditEntry
|
||||
if err := cur.All(ctx, &entries); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recorded := 0
|
||||
for _, e := range entries {
|
||||
prefix := prefixes[e.Action]
|
||||
if e.Target == "" || !strings.HasPrefix(e.Detail, prefix) {
|
||||
continue
|
||||
}
|
||||
oldID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix))
|
||||
if oldID == "" || oldID == e.Target {
|
||||
continue
|
||||
}
|
||||
res, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"$or": bson.A{
|
||||
bson.M{"instance_id": e.Target},
|
||||
bson.M{"previous_instance_ids": e.Target},
|
||||
}},
|
||||
bson.M{"$addToSet": bson.M{"previous_instance_ids": oldID}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recorded += int(res.ModifiedCount)
|
||||
}
|
||||
if recorded > 0 {
|
||||
log.Printf("backfill: recovered %d instance id rewrites from the audit log", recorded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ func (r CatalogueRow) Priced(env string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
|
||||
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
|
||||
// server limit, and one row per feature key.
|
||||
//
|
||||
// The two Free plans get no rows at all, and that absence is what keeps Free
|
||||
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
|
||||
@@ -84,6 +85,7 @@ func SeedCatalogue(ctx context.Context) error {
|
||||
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
|
||||
}
|
||||
for _, r := range rows {
|
||||
filter := bson.M{
|
||||
|
||||
@@ -147,6 +147,14 @@ type Instance struct {
|
||||
// checkout has something to attach custom_data to, before the customer has
|
||||
// pasted their install's real UUID. Cleared when the instance is linked.
|
||||
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
|
||||
// PreviousInstanceIDs is every id this row has carried before its current one.
|
||||
// A self-hosted row's identity is rewritten twice over its life — once when a
|
||||
// paid placeholder is claimed, and again on each relink to a rebuilt server —
|
||||
// and Paddle keeps its own copy of custom_data written at checkout. That copy
|
||||
// is patched on each rewrite, but the patch is best-effort and any event
|
||||
// already in flight still names an old id, so this is what lets a webhook
|
||||
// resolve to the right instance instead of erroring as unknown.
|
||||
PreviousInstanceIDs []string `bson:"previous_instance_ids,omitempty" json:"-"`
|
||||
// PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
|
||||
// remembered so the confirmed-payment webhook can provision the instance with
|
||||
// them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
|
||||
|
||||
@@ -25,6 +25,10 @@ type Client interface {
|
||||
// immediately by Paddle. This is the one outbound mutation, used when a
|
||||
// customer changes their server count or features on an existing plan.
|
||||
UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
|
||||
// UpdateSubscriptionCustomData replaces a subscription's custom_data. Used
|
||||
// when a self-hosted placeholder is claimed: the checkout attached the
|
||||
// placeholder id, and every later webhook must name the real install UUID.
|
||||
UpdateSubscriptionCustomData(ctx context.Context, paddleSubscriptionID string, data map[string]string) error
|
||||
// PortalSession returns a customer-portal URL for managing billing.
|
||||
PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
|
||||
// Env is "sandbox" or "production", the same value catalogue price lookups
|
||||
|
||||
@@ -99,6 +99,17 @@ func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// UpdateSubscriptionCustomData patches custom_data only. Paddle replaces the
|
||||
// whole object, so callers pass every key they want to keep.
|
||||
func (c *httpClient) UpdateSubscriptionCustomData(ctx context.Context, subID string, data map[string]string) error {
|
||||
if subID == "" {
|
||||
return fmt.Errorf("paddle: empty subscription id")
|
||||
}
|
||||
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, struct {
|
||||
CustomData map[string]string `json:"custom_data"`
|
||||
}{CustomData: data}, nil)
|
||||
}
|
||||
|
||||
func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) {
|
||||
if customerID == "" {
|
||||
return "", fmt.Errorf("paddle: empty customer id")
|
||||
|
||||
@@ -26,6 +26,11 @@ ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
|
||||
ARG NEXT_PUBLIC_PADDLE_ENV=sandbox
|
||||
ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV
|
||||
|
||||
# Marketing site origin. Signup lives there (/start), not here; empty renders no
|
||||
# link at all rather than one that 404s.
|
||||
ARG NEXT_PUBLIC_SITE_URL=
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:26-alpine AS runner
|
||||
|
||||
@@ -20,19 +20,14 @@ export default function BillingPage() {
|
||||
// Each subscription names the instance it pays for, because tier and term
|
||||
// are per-licence rather than per-account. Resolving the name here is the
|
||||
// difference between "professional · annual" and knowing which install that is.
|
||||
const nameFor = (instanceId?: string) =>
|
||||
account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
|
||||
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Billing"
|
||||
subtitle="One subscription per instance — each carries its own tier and term."
|
||||
record={
|
||||
account.data
|
||||
? [{ key: "Billing", value: account.data.account.billing_email }]
|
||||
: undefined
|
||||
}
|
||||
subtitle="One subscription per instance each carries its own tier and term."
|
||||
record={account.data ? [{ key: "Billing", value: account.data.account.billing_email }] : undefined}
|
||||
/>
|
||||
|
||||
<PageFrame
|
||||
@@ -51,18 +46,10 @@ export default function BillingPage() {
|
||||
/>
|
||||
</RailCard>
|
||||
<RailCard title="Need a change?">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Change a card, download an invoice or cancel from the billing
|
||||
portal. It covers every subscription on this account.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-2">Change a card, download an invoice or cancel from the billing portal. It covers every subscription on this account.</p>
|
||||
<ManageBillingButton />
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Anything else, email support.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@hostxtra.co.uk"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<p className="text-[0.82rem] text-ink-2">Anything else, email support.</p>
|
||||
<a href="mailto:support@hostxtra.co.uk" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
support@hostxtra.co.uk
|
||||
</a>
|
||||
</RailCard>
|
||||
@@ -70,10 +57,7 @@ export default function BillingPage() {
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
You have no subscriptions. Cloud instances and self-hosted licences are
|
||||
both bought from the pricing page.
|
||||
</p>
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">You have no subscriptions. Cloud instances and self-hosted licences are both bought from the pricing page.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
@@ -88,21 +72,12 @@ export default function BillingPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr
|
||||
key={s.subscription_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{nameFor(s.instance_id) ?? (
|
||||
<span className="text-ink-3">Not linked yet</span>
|
||||
)}
|
||||
</td>
|
||||
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
|
||||
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{s.term}</td>
|
||||
<td className="px-4 py-3">{s.status}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(s.current_period_end)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">{formatDate(s.current_period_end)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { StatePill } from "@/components/StatePill";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export default function InstancePage() {
|
||||
const id = String(useParams().id);
|
||||
@@ -104,7 +105,11 @@ export default function InstancePage() {
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
value: lic.features.join(", ") || "none",
|
||||
// Labelled, not raw keys: this is
|
||||
// the customer's own licence, and
|
||||
// "vuln_scanning" is not a name
|
||||
// anyone bought.
|
||||
value: lic.features.map(featureLabel).join(", ") || "none",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function LinkPage() {
|
||||
const qc = useQueryClient();
|
||||
// This page only claims a PAID placeholder's real install UUID. Self-hosted
|
||||
// Free is created on the purchase page, so with no placeholder to claim there
|
||||
// is nothing to do here — send them there.
|
||||
// is nothing to do here send them there.
|
||||
const claimId = useSearchParams().get("claim") ?? undefined;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,8 +6,8 @@ import { RequireKind } from "@/lib/session";
|
||||
import { AppBar, type NavLink } from "@/components/AppBar";
|
||||
|
||||
/*
|
||||
* Three destinations, not five. Settings moved into the account menu — it is
|
||||
* your password, not a place — and Instances went with it, because Overview
|
||||
* Three destinations, not five. Settings moved into the account menu it is
|
||||
* your password, not a place and Instances went with it, because Overview
|
||||
* already lists them and a second door to the same room is just a second thing
|
||||
* to keep in sync. Linking an install is an action, so it is a button on
|
||||
* Overview rather than a permanent nav entry.
|
||||
@@ -22,11 +22,7 @@ function AccountName() {
|
||||
// Shares the ["account"] key with Overview, so this costs no extra request.
|
||||
const { data } = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
if (!data) return null;
|
||||
return (
|
||||
<span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">
|
||||
{data.account.name}
|
||||
</span>
|
||||
);
|
||||
return <span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">{data.account.name}</span>;
|
||||
}
|
||||
|
||||
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -35,14 +35,12 @@ export default function OverviewPage() {
|
||||
const live = data.instances.filter((i) => i.status !== "deleted");
|
||||
|
||||
// Work the customer has to do, gathered across every instance. This is the
|
||||
// only account-level view of it — each record only knows about itself.
|
||||
// only account-level view of it each record only knows about itself.
|
||||
const attention = live.flatMap((i) => {
|
||||
const lic = byInstance.get(i.instance_id);
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
if (state === "none")
|
||||
return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
|
||||
if (state === "expired")
|
||||
return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
|
||||
if (state === "none") return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
|
||||
if (state === "expired") return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
|
||||
if (state === "warn")
|
||||
return [
|
||||
{
|
||||
@@ -57,40 +55,26 @@ export default function OverviewPage() {
|
||||
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
|
||||
|
||||
const subtitle =
|
||||
live.length === 0
|
||||
? "Nothing here yet."
|
||||
: `${live.length} ${live.length === 1 ? "instance" : "instances"}${
|
||||
attention.length ? ` · ${attention.length} needing attention` : " · all licensed"
|
||||
}`;
|
||||
live.length === 0 ? "Nothing here yet." : `${live.length} ${live.length === 1 ? "instance" : "instances"}${attention.length ? ` · ${attention.length} needing attention` : " · all licensed"}`;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
live.length > 0 ? (
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
actions={live.length > 0 ? <LinkButton href="/purchase">Buy a plan</LinkButton> : undefined}
|
||||
record={[
|
||||
{ key: "Account", value: data.account.account_id, copy: true },
|
||||
{ key: "Billing", value: data.account.billing_email },
|
||||
]}
|
||||
status={
|
||||
attention.length === 0 && live.length > 0 ? (
|
||||
<StatePill state="valid" />
|
||||
) : undefined
|
||||
}
|
||||
status={attention.length === 0 && live.length > 0 ? <StatePill state="valid" /> : undefined}
|
||||
/>
|
||||
|
||||
{live.length === 0 ? (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">No instances yet</h2>
|
||||
<p className="text-ink-2">
|
||||
Create a free cloud instance and we host it, with your licence applied
|
||||
automatically. Or run Vantage on your own server and get its licence — free
|
||||
or paid — from the purchase page.
|
||||
Create a free cloud instance and we host it, with your licence applied automatically. Or run Vantage on your own server and get its licence free or paid from the purchase page.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
@@ -104,16 +88,9 @@ export default function OverviewPage() {
|
||||
<RailCard title="Needs you" count={attention.length}>
|
||||
<ul className="grid gap-2">
|
||||
{attention.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<li key={a.id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
|
||||
<span>{a.text}</span>
|
||||
{a.note && (
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">
|
||||
{a.note}
|
||||
</span>
|
||||
)}
|
||||
{a.note && <span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">{a.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -123,27 +100,18 @@ export default function OverviewPage() {
|
||||
<RailCard title="Your team" count={people.data?.length}>
|
||||
<ul className="grid gap-2">
|
||||
{(people.data ?? []).slice(0, 5).map((p) => (
|
||||
<li
|
||||
key={p.user_id}
|
||||
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<li key={p.user_id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
|
||||
<span className="truncate">{p.email}</span>
|
||||
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
{p.account_role}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">{p.account_role}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{pending > 0 && (
|
||||
<p className="border-t border-rule-soft pt-2 text-[0.78rem] text-warn">
|
||||
{pending} {pending === 1 ? "invitation" : "invitations"} not
|
||||
accepted yet
|
||||
{pending} {pending === 1 ? "invitation" : "invitations"} not accepted yet
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href="/users"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/users" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Manage people
|
||||
</Link>
|
||||
</RailCard>
|
||||
@@ -151,7 +119,7 @@ export default function OverviewPage() {
|
||||
{/*
|
||||
* Account-level facts only. Tier, limits and renewal date
|
||||
* belong to a licence, and a licence belongs to one
|
||||
* instance — an account holding a Free cloud instance and
|
||||
* instance an account holding a Free cloud instance and
|
||||
* a Professional self-hosted one has no single plan.
|
||||
*/}
|
||||
<RailCard title="Account">
|
||||
@@ -165,23 +133,14 @@ export default function OverviewPage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/billing" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Billing history
|
||||
</Link>
|
||||
</RailCard>
|
||||
|
||||
<RailCard title="Running Vantage yourself?">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Get a licence for your own install — free or paid — from the
|
||||
purchase page. It keeps its own users.
|
||||
</p>
|
||||
<Link
|
||||
href="/purchase"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<p className="text-[0.82rem] text-ink-2">Get a licence for your own install free or paid from the purchase page. It keeps its own users.</p>
|
||||
<Link href="/purchase" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Get a licence
|
||||
</Link>
|
||||
</RailCard>
|
||||
@@ -199,12 +158,7 @@ export default function OverviewPage() {
|
||||
// Open when it is the only one, or when it is the
|
||||
// first thing that needs a decision. A saved toggle
|
||||
// beats this from then on.
|
||||
defaultOpen={
|
||||
live.length === 1 ||
|
||||
(state !== "valid" &&
|
||||
attention[0]?.id === i.instance_id) ||
|
||||
(attention.length === 0 && n === 0)
|
||||
}
|
||||
defaultOpen={live.length === 1 || (state !== "valid" && attention[0]?.id === i.instance_id) || (attention.length === 0 && n === 0)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,25 +6,16 @@ import Link from "next/link";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
|
||||
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
|
||||
import { featureDesc, featureLabel } from "@/lib/features";
|
||||
|
||||
/* Tiers in the order a customer reads them, cheapest first. */
|
||||
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/* Human labels for feature keys. The catalogue names them by key; this is the
|
||||
* one place the customer-facing wording lives. */
|
||||
const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
};
|
||||
const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
};
|
||||
function featureLabel(key: string) {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
/* Feature wording lives in lib/features.ts, shared with the staff
|
||||
* configurator. It was duplicated here and there, and the two copies had
|
||||
* already drifted. */
|
||||
|
||||
interface Choice {
|
||||
tier: Tier;
|
||||
@@ -88,7 +79,7 @@ export function PurchaseForm() {
|
||||
|
||||
const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]);
|
||||
|
||||
// Real line items for the current configuration — the same builder the
|
||||
// Real line items for the current configuration the same builder the
|
||||
// checkout uses, so the summary can never disagree with the overlay.
|
||||
const items = useMemo(() => (options ? lineItemsFor(options, choice, dep) : []), [options, choice, dep]);
|
||||
|
||||
@@ -294,7 +285,7 @@ export function PurchaseForm() {
|
||||
{featureKeys.map((key) => {
|
||||
const st = featureStateFor(plan, rows, options.env, choice.term, key);
|
||||
return (
|
||||
<Row key={key} title={featureLabel(key)} desc={FEATURE_DESC[key] ?? ""} dim={st === "absent"}>
|
||||
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
|
||||
{st === "included" ? (
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
|
||||
) : st === "absent" ? (
|
||||
@@ -321,8 +312,7 @@ export function PurchaseForm() {
|
||||
<Block n={3} label="Your install">
|
||||
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
Install Vantage on your own server first, then paste the instance ID it
|
||||
reports. We register it and issue your Free licence — nothing to pay.
|
||||
Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence nothing to pay.
|
||||
</p>
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
|
||||
@@ -332,10 +322,7 @@ export function PurchaseForm() {
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
|
||||
/>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
Find this on your install’s Settings → Licence page, or the setup
|
||||
screen just after first sign-in.
|
||||
</span>
|
||||
<span className="text-[0.72rem] text-ink-3">Find this on your install’s Settings → Licence page, or the setup screen just after first sign-in.</span>
|
||||
</label>
|
||||
</div>
|
||||
</Block>
|
||||
@@ -423,7 +410,7 @@ export function PurchaseForm() {
|
||||
)}
|
||||
{pending?.deployment === "cloud" && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears — no further steps.</p>
|
||||
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears no further steps.</p>
|
||||
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
|
||||
Go to your instance
|
||||
</Link>
|
||||
@@ -554,7 +541,7 @@ function TierCard({
|
||||
return (
|
||||
<FeatureLine key={key} on={st !== "absent"}>
|
||||
{featureLabel(key)}
|
||||
{st === "included" ? " — included" : st === "addon" ? " — add-on" : " — not available"}
|
||||
{st === "included" ? " included" : st === "addon" ? " add-on" : " not available"}
|
||||
</FeatureLine>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,9 +25,7 @@ export default function AccountDetailPage() {
|
||||
record={[
|
||||
{ key: "Account", value: data.account.account_id, copy: true },
|
||||
{ key: "Status", value: data.account.status },
|
||||
...(data.account.paddle_customer_id
|
||||
? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }]
|
||||
: []),
|
||||
...(data.account.paddle_customer_id ? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -35,10 +33,7 @@ export default function AccountDetailPage() {
|
||||
<ul className="grid gap-2">
|
||||
{data.instances.map((i) => (
|
||||
<li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
|
||||
<Link
|
||||
href={`/staff/instances/${i.instance_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="text-accent underline">
|
||||
{i.name || i.instance_id}
|
||||
</Link>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
@@ -71,19 +66,10 @@ export default function AccountDetailPage() {
|
||||
{data.users.map((u) => (
|
||||
<li key={u.user_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="font-mono text-[0.82rem]">{u.email}</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{u.verified_at
|
||||
? `verified ${formatDate(u.verified_at)}`
|
||||
: "not verified"}
|
||||
</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">{u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.users.length === 0 && (
|
||||
<li className="text-ink-3">
|
||||
None — this is a cloud account, so its people sign in with their
|
||||
control-plane details.
|
||||
</li>
|
||||
)}
|
||||
{data.users.length === 0 && <li className="text-ink-3">None this is a cloud account, so its people sign in with their control-plane details.</li>}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
@@ -94,9 +80,7 @@ export default function AccountDetailPage() {
|
||||
<span>
|
||||
{e.action} · {e.actor}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">{formatDate(e.created_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.audit.length === 0 && <li className="text-ink-3">Nothing yet.</li>}
|
||||
|
||||
@@ -6,11 +6,7 @@ import { ApiError, api, type Tier } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export function IssuePanel({
|
||||
instanceId,
|
||||
}: {
|
||||
instanceId: string;
|
||||
}) {
|
||||
export function IssuePanel({ instanceId }: { instanceId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [tier, setTier] = useState<Tier>("professional");
|
||||
const [term, setTerm] = useState("annual");
|
||||
@@ -35,28 +31,16 @@ export function IssuePanel({
|
||||
<section className="grid gap-4 border-t border-rule-soft pt-5">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Tier
|
||||
</span>
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Tier</span>
|
||||
<select value={tier} onChange={(e) => setTier(e.target.value as Tier)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Term
|
||||
</span>
|
||||
<select
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Term</span>
|
||||
<select value={term} onChange={(e) => setTerm(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
|
||||
<option value="annual">Annual</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
@@ -67,18 +51,8 @@ export function IssuePanel({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field
|
||||
label="Relink to instance ID"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() => relink.mutate()}
|
||||
disabled={!newId.trim()}
|
||||
>
|
||||
<Field label="Relink to instance ID" value={newId} onChange={(e) => setNewId(e.target.value)} hint="Staff relinks are not capped the customer cap exists to put you in the loop." />
|
||||
<Button type="button" variant="line" onClick={() => relink.mutate()} disabled={!newId.trim()}>
|
||||
Relink
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { IssuePanel } from "./IssuePanel";
|
||||
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
current: { label: "Control plane holds the current licence", tone: "text-valid" },
|
||||
stale: {
|
||||
label: "Control plane holds an older blob — the reconciler will repair it",
|
||||
label: "Control plane holds an older blob the reconciler will repair it",
|
||||
tone: "text-warn",
|
||||
},
|
||||
missing: { label: "No matching instance in the control plane", tone: "text-expired" },
|
||||
@@ -44,30 +44,19 @@ export default function StaffInstancePage() {
|
||||
title={data.instance.name || data.instance.instance_id}
|
||||
subtitle={
|
||||
<>
|
||||
<Link
|
||||
href={`/staff/accounts/${data.account.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Link href={`/staff/accounts/${data.account.account_id}`} className="text-accent underline">
|
||||
{data.account.name || data.account.account_id}
|
||||
</Link>
|
||||
<span className="text-ink-3">
|
||||
{" "}
|
||||
· {data.instance.deployment} · {data.instance.status}
|
||||
{data.instance.relink_count > 0 &&
|
||||
` · ${data.instance.relink_count} relinks this term`}
|
||||
{data.instance.relink_count > 0 && ` · ${data.instance.relink_count} relinks this term`}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
record={[
|
||||
{ key: "Instance", value: data.instance.instance_id, copy: true },
|
||||
...(data.instance.slug
|
||||
? [{ key: "Slug", value: data.instance.slug }]
|
||||
: []),
|
||||
]}
|
||||
record={[{ key: "Instance", value: data.instance.instance_id, copy: true }, ...(data.instance.slug ? [{ key: "Slug", value: data.instance.slug }] : [])]}
|
||||
/>
|
||||
{data.injection.applicable && inj && (
|
||||
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
|
||||
)}
|
||||
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
@@ -76,10 +65,7 @@ export default function StaffInstancePage() {
|
||||
<IssuePanel instanceId={data.instance.instance_id} />
|
||||
</section>
|
||||
|
||||
<EntitlementSection
|
||||
instanceId={data.instance.instance_id}
|
||||
deployment={data.instance.deployment}
|
||||
/>
|
||||
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,8 +100,7 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (grant: boolean) =>
|
||||
api.staff.setEntitlement(instanceId, { ...choice, grant }),
|
||||
mutationFn: (grant: boolean) => api.staff.setEntitlement(instanceId, { ...choice, grant }),
|
||||
onSuccess: () => {
|
||||
setDraft(null);
|
||||
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
|
||||
@@ -127,38 +112,21 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
<header className="mb-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
|
||||
<p className="text-[0.78rem] text-ink-3">
|
||||
What this instance is allowed. A licence is signed from{" "}
|
||||
<em>granted</em>, never from <em>desired</em>.
|
||||
What this instance is allowed. A licence is signed from <em>granted</em>, never from <em>desired</em>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{ent && data?.pending && (
|
||||
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
|
||||
Pending change — currently granted {ent.granted.servers} servers,
|
||||
configured for {ent.desired.servers}
|
||||
{ent.scheduled_change_at
|
||||
? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}`
|
||||
: ""}
|
||||
.
|
||||
Pending change currently granted {ent.granted.servers} servers, configured for {ent.desired.servers}
|
||||
{ent.scheduled_change_at ? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PlanConfigurator
|
||||
deployment={deployment}
|
||||
value={choice}
|
||||
plans={plans}
|
||||
catalogue={catalogue}
|
||||
onChange={setDraft}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
<PlanConfigurator deployment={deployment} value={choice} plans={plans} catalogue={catalogue} onChange={setDraft} disabled={save.isPending} />
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate(false)}
|
||||
className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40"
|
||||
>
|
||||
<button type="button" disabled={save.isPending} onClick={() => save.mutate(false)} className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40">
|
||||
Save as configured
|
||||
</button>
|
||||
<button
|
||||
@@ -170,15 +138,8 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
Save and grant
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[0.72rem] text-ink-3">
|
||||
Granting takes effect on the next licence issued. It does not issue
|
||||
one.
|
||||
</p>
|
||||
{save.error && (
|
||||
<p className="mt-2 text-[0.82rem] text-expired">
|
||||
{String((save.error as Error).message)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-[0.72rem] text-ink-3">Granting takes effect on the next licence issued. It does not issue one.</p>
|
||||
{save.error && <p className="mt-2 text-[0.82rem] text-expired">{String((save.error as Error).message)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ export default function StaffDashboard() {
|
||||
|
||||
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
const stale = (unlinked.data ?? []).filter(
|
||||
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
|
||||
);
|
||||
const stale = (unlinked.data ?? []).filter((i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48);
|
||||
|
||||
const failed = injection.data?.count ?? 0;
|
||||
const instances = allInstances.data ?? [];
|
||||
@@ -46,11 +44,7 @@ export default function StaffDashboard() {
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Operations"
|
||||
subtitle={
|
||||
failed > 0
|
||||
? "Injection failures come first — those instances are paying for a licence they have not received."
|
||||
: "Nothing failing. Queues below are routine chasing."
|
||||
}
|
||||
subtitle={failed > 0 ? "Injection failures come first those instances are paying for a licence they have not received." : "Nothing failing. Queues below are routine chasing."}
|
||||
actions={
|
||||
<LinkButton variant="line" href="/staff/accounts">
|
||||
Find an account
|
||||
@@ -68,9 +62,7 @@ export default function StaffDashboard() {
|
||||
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/instances/${i.instance_id}`,
|
||||
meta: i.inject_failed_at
|
||||
? new Date(i.inject_failed_at).toISOString().slice(11, 16)
|
||||
: "",
|
||||
meta: i.inject_failed_at ? new Date(i.inject_failed_at).toISOString().slice(11, 16) : "",
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
@@ -117,8 +109,7 @@ export default function StaffDashboard() {
|
||||
},
|
||||
{
|
||||
label: "Self-hosted",
|
||||
value: instances.filter((i) => i.deployment === "self_hosted")
|
||||
.length,
|
||||
value: instances.filter((i) => i.deployment === "self_hosted").length,
|
||||
},
|
||||
{
|
||||
label: "Awaiting link",
|
||||
@@ -126,10 +117,7 @@ export default function StaffDashboard() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/staff/licenses"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/staff/licenses" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
All licences
|
||||
</Link>
|
||||
</RailCard>
|
||||
@@ -139,14 +127,9 @@ export default function StaffDashboard() {
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[0.95rem]">Recent activity</h2>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
Every licence issued, relinked or reaped, newest first.
|
||||
</p>
|
||||
<p className="text-[0.8rem] text-ink-2">Every licence issued, relinked or reaped, newest first.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/audit"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/staff/audit" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Full audit
|
||||
</Link>
|
||||
</header>
|
||||
@@ -163,9 +146,7 @@ export default function StaffDashboard() {
|
||||
<tbody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<tr key={n} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">
|
||||
{new Date(e.created_at).toISOString().slice(11, 16)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</td>
|
||||
<td className="px-4 py-2.5">{e.action}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
|
||||
|
||||
@@ -26,15 +26,7 @@ const LIMIT_FIELDS = [
|
||||
* sentinel is a staff screen where nobody can tell whether a plan says
|
||||
* unlimited or nothing at all.
|
||||
*/
|
||||
function AllowanceForm({
|
||||
plan,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
plan: Plan;
|
||||
onSave: (next: Plan) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) {
|
||||
const [draft, setDraft] = useState<Plan>(plan);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
@@ -43,9 +35,7 @@ function AllowanceForm({
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">
|
||||
{f.label}
|
||||
</span>
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.base_limits[f.key]}
|
||||
@@ -60,20 +50,14 @@ function AllowanceForm({
|
||||
}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
/>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">
|
||||
−1 is unlimited
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">−1 is unlimited</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">
|
||||
Support level
|
||||
</span>
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">Support level</span>
|
||||
<select
|
||||
value={draft.support_level}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, support_level: e.target.value })
|
||||
}
|
||||
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
>
|
||||
{SUPPORT_LEVELS.map((s) => (
|
||||
@@ -86,25 +70,13 @@ function AllowanceForm({
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.active}
|
||||
onChange={(e) => setDraft({ ...draft, active: e.target.checked })}
|
||||
/>
|
||||
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
|
||||
Offered to customers
|
||||
</label>
|
||||
|
||||
<p className="text-[0.78rem] text-ink-3">
|
||||
Changes apply to licences issued from now on. Existing licences
|
||||
snapshotted their plan and are unaffected.
|
||||
</p>
|
||||
<p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => onSave(draft)}
|
||||
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
<button type="button" disabled={!dirty || saving} onClick={() => onSave(draft)} className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40">
|
||||
{saving ? "Saving…" : "Save allowances"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -131,26 +103,20 @@ export default function PlansPage() {
|
||||
onError: () => setSaving(null),
|
||||
});
|
||||
|
||||
const original = plans.data?.find(
|
||||
(p) => p.deployment === draft?.deployment && p.tier === draft?.tier,
|
||||
);
|
||||
const original = plans.data?.find((p) => p.deployment === draft?.deployment && p.tier === draft?.tier);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Plans"
|
||||
subtitle="The authoritative tier table — six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
subtitle="The authoritative tier table six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
/>
|
||||
|
||||
{draft && original && (
|
||||
<ConfirmPlanChange
|
||||
plan={original}
|
||||
next={draft}
|
||||
issuedCount={
|
||||
(licenses.data ?? []).filter(
|
||||
(l) => l.tier === draft.tier && l.deployment === draft.deployment,
|
||||
).length
|
||||
}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
|
||||
onConfirm={() => {
|
||||
setSaving(`${draft.deployment}/${draft.tier}`);
|
||||
save.mutate(draft);
|
||||
@@ -161,29 +127,18 @@ export default function PlansPage() {
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="space-y-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}
|
||||
</h2>
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<article
|
||||
key={`${p.deployment}/${p.tier}`}
|
||||
className="rounded-lg border border-rule bg-panel p-4"
|
||||
>
|
||||
<article key={`${p.deployment}/${p.tier}`} className="rounded-lg border border-rule bg-panel p-4">
|
||||
<header className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">
|
||||
{p.name}
|
||||
</h3>
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">{p.name}</h3>
|
||||
<span className="font-mono text-[0.75rem] text-ink-3">
|
||||
{p.deployment}/{p.tier}
|
||||
</span>
|
||||
</header>
|
||||
<AllowanceForm
|
||||
plan={p}
|
||||
saving={saving === `${p.deployment}/${p.tier}`}
|
||||
onSave={(next: Plan) => setDraft(next)}
|
||||
/>
|
||||
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
|
||||
|
||||
/*
|
||||
* The masthead deliberately does NOT live here. It belongs to the authenticated
|
||||
* layouts, so /login, /signup, /verify and /accept-invite stop rendering a bar
|
||||
* layouts, so /login, /verify and /accept-invite stop rendering a bar
|
||||
* whose navigation and account menu they cannot use.
|
||||
*/
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -44,46 +45,75 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<h1 className="text-3xl">Sign in</h1>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={staff}
|
||||
onChange={(e) => setStaff(e.target.checked)}
|
||||
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
|
||||
<div className="mb-7 flex flex-col items-center gap-2 text-center">
|
||||
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
HQ
|
||||
</span>
|
||||
</span>
|
||||
<h1 className="text-[1.16rem]">Sign in</h1>
|
||||
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Licences · instances · billing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
I work at Vantage
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" disabled={busy}>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={staff}
|
||||
onChange={(e) => setStaff(e.target.checked)}
|
||||
className="accent-[var(--accent)]"
|
||||
/>
|
||||
I work at Vantage
|
||||
</label>
|
||||
<Button type="submit" disabled={busy} className="w-full justify-center">
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
<Link href="/signup" className="text-[0.82rem] text-accent underline">
|
||||
Create an account for a self-hosted licence
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
{SITE_URL && (
|
||||
<>
|
||||
<div className="my-5 h-px bg-rule-soft" />
|
||||
|
||||
{/* Signup lives on the marketing site's /start, not here. */}
|
||||
<p className="text-center text-[0.82rem] text-ink-3">
|
||||
No account?{" "}
|
||||
<a href={`${SITE_URL}/start`} className="text-accent underline">
|
||||
Create one
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function Main({ children }: { children: React.ReactNode }) {
|
||||
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
|
||||
const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setState("busy");
|
||||
setError(null);
|
||||
try {
|
||||
await api.signup(form);
|
||||
setState("sent");
|
||||
} catch (err) {
|
||||
setState("idle");
|
||||
setError(
|
||||
err instanceof NotConnected
|
||||
? "The licensing service is not reachable from this page."
|
||||
: err instanceof ApiError
|
||||
? err.message
|
||||
: "Could not create the account. Try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
{state === "sent" ? (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">Check your email</h1>
|
||||
<p className="text-ink-2">
|
||||
We sent a link to {form.email}. Open it to finish setting up your account —
|
||||
it expires in 24 hours. Nothing is created until you do.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-3xl">Create an account</h1>
|
||||
<p className="mt-2 max-w-xl text-ink-2">
|
||||
For self-hosted licences. If you run on our cloud, sign in with the same
|
||||
details you use for your Vantage instance.
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Organisation"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
{/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
className="absolute left-[-9999px] h-0 w-0"
|
||||
/>
|
||||
<Button type="submit" disabled={state === "busy"}>
|
||||
{state === "busy" ? "Creating…" : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type NavLink = { href: string; label: string };
|
||||
* which environment you are in.
|
||||
*
|
||||
* It replaces a brand bar and a separate nav strip. The nav's active state is
|
||||
* derived from the pathname rather than hardcoded — the previous customer nav
|
||||
* derived from the pathname rather than hardcoded the previous customer nav
|
||||
* marked Overview as current on every page, including the ones that weren't it.
|
||||
*
|
||||
* Staff sit on --panel-2 with a chip where the account name goes. web/ is locked
|
||||
@@ -21,24 +21,14 @@ export type NavLink = { href: string; label: string };
|
||||
* that distinction from each other too, and one shade plus one chip buys it
|
||||
* without a second palette.
|
||||
*/
|
||||
export function AppBar({
|
||||
links,
|
||||
context,
|
||||
staff = false,
|
||||
}: {
|
||||
links: NavLink[];
|
||||
context?: React.ReactNode;
|
||||
staff?: boolean;
|
||||
}) {
|
||||
export function AppBar({ links, context, staff = false }: { links: NavLink[]; context?: React.ReactNode; staff?: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isCurrent = (href: string) =>
|
||||
// The section root matches only exactly; deeper routes match by prefix,
|
||||
// so /staff/accounts/:id still lights Accounts while /staff/accounts
|
||||
// does not light Operations.
|
||||
href === "/" || href === "/staff"
|
||||
? pathname === href
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
href === "/" || href === "/staff" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
|
||||
@@ -46,9 +36,7 @@ export function AppBar({
|
||||
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-3 py-2.5">
|
||||
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
HQ
|
||||
</span>
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
|
||||
</span>
|
||||
{context && (
|
||||
<>
|
||||
@@ -70,9 +58,7 @@ export function AppBar({
|
||||
href={l.href}
|
||||
aria-current={on ? "page" : undefined}
|
||||
className={`relative inline-flex shrink-0 items-center px-3 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.08em] md:py-0 ${
|
||||
on
|
||||
? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']"
|
||||
: "text-ink-3 hover:text-ink-2"
|
||||
on ? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']" : "text-ink-3 hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
|
||||
@@ -12,9 +12,7 @@ export function buttonClass(variant: Variant = "solid", disabled = false, classN
|
||||
return clsx(
|
||||
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
|
||||
"transition-[filter,border-color] duration-150 hover:brightness-110",
|
||||
variant === "solid"
|
||||
? "border-accent bg-accent text-accent-ink"
|
||||
: "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
|
||||
className,
|
||||
);
|
||||
@@ -28,22 +26,10 @@ export function Button({ variant = "solid", className, ...rest }: Props) {
|
||||
|
||||
/*
|
||||
* A link that looks like a button. It exists so a navigation action never has to
|
||||
* be an <a> wrapped around a <button> — invalid markup, and it gives screen
|
||||
* be an <a> wrapped around a <button> invalid markup, and it gives screen
|
||||
* readers two nested controls where the page means one.
|
||||
*/
|
||||
export function LinkButton({
|
||||
href,
|
||||
variant = "solid",
|
||||
external,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
variant?: Variant;
|
||||
external?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function LinkButton({ href, variant = "solid", external, className, children }: { href: string; variant?: Variant; external?: boolean; className?: string; children: React.ReactNode }) {
|
||||
const cls = buttonClass(variant, false, className);
|
||||
return external ? (
|
||||
<a href={href} className={cls}>
|
||||
|
||||
@@ -5,30 +5,12 @@ import { Button } from "./Button";
|
||||
/*
|
||||
* Editing a plan changes what every future customer gets, so the confirmation
|
||||
* names each field rather than asking "are you sure". Existing licences
|
||||
* snapshotted their plan at issue time and are genuinely unaffected — saying so
|
||||
* snapshotted their plan at issue time and are genuinely unaffected saying so
|
||||
* is what stops a well-meaning edit being followed by a panicked reissue.
|
||||
*/
|
||||
export function ConfirmPlanChange({
|
||||
plan,
|
||||
next,
|
||||
issuedCount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
plan: Plan;
|
||||
next: Plan;
|
||||
issuedCount: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
export function ConfirmPlanChange({ plan, next, issuedCount, onConfirm, onCancel }: { plan: Plan; next: Plan; issuedCount: number; onConfirm: () => void; onCancel: () => void }) {
|
||||
const rows: { field: string; was: string; now: string }[] = [];
|
||||
const fields = [
|
||||
"max_servers",
|
||||
"max_monitors",
|
||||
"max_secret_groups",
|
||||
"max_channels",
|
||||
"audit_retention_days",
|
||||
] as const;
|
||||
const fields = ["max_servers", "max_monitors", "max_secret_groups", "max_channels", "audit_retention_days"] as const;
|
||||
for (const f of fields) {
|
||||
if (plan.base_limits[f] !== next.base_limits[f])
|
||||
rows.push({
|
||||
@@ -63,10 +45,7 @@ export function ConfirmPlanChange({
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing would change.</li>}
|
||||
</ul>
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
This applies to licences issued from now on. The {issuedCount} licences already
|
||||
issued keep what they were signed with until each is reissued.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-3">This applies to licences issued from now on. The {issuedCount} licences already issued keep what they were signed with until each is reissued.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Change plan
|
||||
|
||||
@@ -21,7 +21,7 @@ const KEY = (id: string) => `vantage-hq-record-open:${id}`;
|
||||
/*
|
||||
* One instance, open or closed.
|
||||
*
|
||||
* Closed it is a row — name, tier, host, term bar, state. Open it adds what the
|
||||
* Closed it is a row name, tier, host, term bar, state. Open it adds what the
|
||||
* licence includes, who can sign in, and the actions. Deliberately ONE component
|
||||
* rather than a card and a detail panel: two components meant a single-instance
|
||||
* account got a third of a row of summary with its substance a click away, and
|
||||
|
||||
@@ -13,16 +13,12 @@ const REASON: Record<License["reason"], string> = {
|
||||
/*
|
||||
* Licences are append-only: a renewal supersedes its predecessor rather than
|
||||
* replacing it. So this is a ledger, not a table. Superseded rows stay visible
|
||||
* and are overprinted the way a cancelled instrument is — hiding them would
|
||||
* and are overprinted the way a cancelled instrument is hiding them would
|
||||
* destroy the only record of why an instance stopped working on a given date.
|
||||
*/
|
||||
export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
if (licenses.length === 0) {
|
||||
return (
|
||||
<p className="text-ink-2">
|
||||
No licence has ever been issued for this instance, so it is read-only.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-ink-2">No licence has ever been issued for this instance, so it is read-only.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -30,22 +26,9 @@ export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
{licenses.map((l) => {
|
||||
const dead = Boolean(l.superseded_by);
|
||||
return (
|
||||
<li
|
||||
key={l.license_id}
|
||||
className={clsx(
|
||||
"grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]",
|
||||
dead && "text-ink-3",
|
||||
)}
|
||||
>
|
||||
<li key={l.license_id} className={clsx("grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]", dead && "text-ink-3")}>
|
||||
<div className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
<b
|
||||
className={clsx(
|
||||
"block text-[0.82rem] font-semibold",
|
||||
dead ? "text-ink-3" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{formatDate(l.issued_at)}
|
||||
</b>
|
||||
<b className={clsx("block text-[0.82rem] font-semibold", dead ? "text-ink-3" : "text-ink")}>{formatDate(l.issued_at)}</b>
|
||||
{formatStamp(l.issued_at)}
|
||||
</div>
|
||||
<div className="grid justify-items-start gap-1.5">
|
||||
@@ -56,20 +39,14 @@ export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
)}
|
||||
<p className="flex flex-wrap items-center gap-2 font-semibold">
|
||||
{l.tier.replace("_", " ")}
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">
|
||||
{REASON[l.reason]}
|
||||
</span>
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">{REASON[l.reason]}</span>
|
||||
</p>
|
||||
<p className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} ·{" "}
|
||||
{limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} · {limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.superseded_by && (
|
||||
<>
|
||||
{" "}
|
||||
· replaced by{" "}
|
||||
<span className="text-accent underline">
|
||||
{l.superseded_by.slice(0, 8)}
|
||||
</span>
|
||||
· replaced by <span className="text-accent underline">{l.superseded_by.slice(0, 8)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -4,19 +4,11 @@ import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
/*
|
||||
* A licence blob is signed public data, not a secret — it is useless on any
|
||||
* A licence blob is signed public data, not a secret it is useless on any
|
||||
* instance other than the one it names. So it is safe to show inline, and
|
||||
* showing it is what stops a blocked download from blocking a paying customer.
|
||||
*/
|
||||
export function LicenceDelivery({
|
||||
instanceId,
|
||||
blob,
|
||||
downloadUrl,
|
||||
}: {
|
||||
instanceId: string;
|
||||
blob: string;
|
||||
downloadUrl: string;
|
||||
}) {
|
||||
export function LicenceDelivery({ instanceId, blob, downloadUrl }: { instanceId: string; blob: string; downloadUrl: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
@@ -27,13 +19,11 @@ export function LicenceDelivery({
|
||||
|
||||
const steps = [
|
||||
<>
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">Settings → Licence</code> on your
|
||||
install.
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">Settings → Licence</code> on your install.
|
||||
</>,
|
||||
<>Paste the licence into the box and save.</>,
|
||||
<>
|
||||
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight
|
||||
away — no restart.
|
||||
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight away no restart.
|
||||
</>,
|
||||
];
|
||||
|
||||
@@ -52,18 +42,11 @@ export function LicenceDelivery({
|
||||
{copied ? "Copied" : "Copy to clipboard"}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
|
||||
{blob}
|
||||
</pre>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">{blob}</pre>
|
||||
<ol className="grid gap-2">
|
||||
{steps.map((body, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">
|
||||
{i + 1}
|
||||
</span>
|
||||
<li key={i} className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2">
|
||||
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">{i + 1}</span>
|
||||
<span>{body}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from "@/components/Button";
|
||||
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
|
||||
|
||||
/*
|
||||
* Absent entirely for self-hosted instances — the backend refuses those, and a
|
||||
* Absent entirely for self-hosted instances the backend refuses those, and a
|
||||
* panel that renders controls the server will reject is a panel that lies.
|
||||
*/
|
||||
export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
@@ -26,8 +26,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
|
||||
const fail = (e: unknown) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
|
||||
const grant = useMutation({
|
||||
mutationFn: () => api.grantMember(instanceId, selected, role),
|
||||
@@ -39,8 +38,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
onError: fail,
|
||||
});
|
||||
const changeRole = useMutation({
|
||||
mutationFn: (v: { uid: string; role: InstanceRole }) =>
|
||||
api.setMemberRole(instanceId, v.uid, v.role),
|
||||
mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
@@ -54,29 +52,21 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
|
||||
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
|
||||
const candidates = (people.data ?? []).filter(
|
||||
(p) => !granted.has(p.user_id) && p.verified_at,
|
||||
);
|
||||
const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
|
||||
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-xl">Who can sign in</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Each person here has a real user inside this instance and signs in with their
|
||||
Vantage HQ password.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
|
||||
|
||||
<ul className="grid gap-2">
|
||||
{(members.data ?? []).map((m) => (
|
||||
<li
|
||||
key={m.member_id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
|
||||
>
|
||||
<li key={m.member_id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2">
|
||||
<span>{m.email}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{canManage ? (
|
||||
@@ -104,8 +94,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${m.email} from this instance?`))
|
||||
revoke.mutate(m.customer_user_id);
|
||||
if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -114,9 +103,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{members.data?.length === 0 && (
|
||||
<li className="text-ink-2">Nobody has been added yet.</li>
|
||||
)}
|
||||
{members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
|
||||
</ul>
|
||||
|
||||
{canManage && (
|
||||
@@ -129,14 +116,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Add someone
|
||||
</span>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Add someone</span>
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
<option value="">Choose a person…</option>
|
||||
{candidates.map((p) => (
|
||||
<option key={p.user_id} value={p.user_id}>
|
||||
@@ -146,14 +127,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Role here
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as InstanceRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Role here</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
@@ -169,8 +144,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
|
||||
{canManage && pending > 0 && (
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
|
||||
yet and cannot be added until they do.
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* The deployment failure this repo makes most often, made legible. It names the
|
||||
* variable, the value baked in, and both reasons it fails — unreachable from
|
||||
* variable, the value baked in, and both reasons it fails unreachable from
|
||||
* the browser, or missing from admin's ADMIN_ORIGIN.
|
||||
*/
|
||||
export function NotConnectedPanel({ url }: { url: string }) {
|
||||
@@ -9,19 +9,16 @@ export function NotConnectedPanel({ url }: { url: string }) {
|
||||
<h2 className="text-xl text-expired">Not connected to the licensing service</h2>
|
||||
{url ? (
|
||||
<p className="text-ink-2">
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> ={" "}
|
||||
<code className="text-ink">{url}</code>, which did not respond.
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> = <code className="text-ink">{url}</code>, which did not respond.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-ink-2">
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was
|
||||
built, so there is nowhere to send requests.
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was built, so there is nowhere to send requests.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
The value is baked in when the image is built and has to be reachable from your
|
||||
browser, not just from the server. It also has to appear in the licensing
|
||||
service’s <code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
The value is baked in when the image is built and has to be reachable from your browser, not just from the server. It also has to appear in the licensing service’s{" "}
|
||||
<code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,16 +7,10 @@
|
||||
* many instances exist, so the page has a floor.
|
||||
*
|
||||
* It collapses below lg in source order, which puts the main column first on a
|
||||
* phone. Nothing is hidden at any width — if content only fits on a desktop it
|
||||
* phone. Nothing is hidden at any width if content only fits on a desktop it
|
||||
* does not belong in the rail.
|
||||
*/
|
||||
export function PageFrame({
|
||||
children,
|
||||
aside,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
aside?: React.ReactNode;
|
||||
}) {
|
||||
export function PageFrame({ children, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
|
||||
if (!aside) return <div className="grid gap-5">{children}</div>;
|
||||
|
||||
return (
|
||||
@@ -28,24 +22,12 @@ export function PageFrame({
|
||||
}
|
||||
|
||||
/** One card in the rail. Title is a label, not a heading you read for pleasure. */
|
||||
export function RailCard({
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number | string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function RailCard({ title, count, children }: { title: string; count?: number | string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="grid gap-2.5 rounded border border-rule bg-panel p-3.5">
|
||||
<header className="flex items-baseline justify-between gap-2.5">
|
||||
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">
|
||||
{title}
|
||||
</h2>
|
||||
{count !== undefined && (
|
||||
<b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>
|
||||
)}
|
||||
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">{title}</h2>
|
||||
{count !== undefined && <b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
@@ -59,9 +41,7 @@ export function RailFacts({ rows }: { rows: { label: string; value: React.ReactN
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className="flex justify-between gap-2.5 text-[0.82rem]">
|
||||
<dt className="text-ink-3">{r.label}</dt>
|
||||
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">
|
||||
{r.value}
|
||||
</dd>
|
||||
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
|
||||
/*
|
||||
* One record-line entry. `copy` marks the value as worth lifting to the
|
||||
* clipboard — an instance UUID or a licence ID, the strings people paste into
|
||||
* clipboard an instance UUID or a licence ID, the strings people paste into
|
||||
* support tickets.
|
||||
*/
|
||||
export type RecordField = { key: string; value: string; copy?: boolean };
|
||||
@@ -60,10 +60,7 @@ export function PageHeader({
|
||||
return (
|
||||
<header className="grid gap-3">
|
||||
{back && (
|
||||
<Link
|
||||
href={back.href}
|
||||
className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent"
|
||||
>
|
||||
<Link href={back.href} className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent">
|
||||
← {back.label}
|
||||
</Link>
|
||||
)}
|
||||
@@ -80,12 +77,8 @@ export function PageHeader({
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
|
||||
{record?.map((f) => (
|
||||
<span key={f.key} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
|
||||
{f.key}
|
||||
</span>
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
{f.value}
|
||||
</span>
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
|
||||
{f.copy && <CopyButton value={f.value} />}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export interface PlanChoice {
|
||||
tier: Tier;
|
||||
@@ -159,7 +160,7 @@ export default function PlanConfigurator({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
|
||||
<span>{featureLabel(key)}</span>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
{priced ? "paid add-on" : "included"}
|
||||
</span>
|
||||
|
||||
@@ -6,18 +6,7 @@ import { Field } from "./Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function RelinkPanel({
|
||||
used,
|
||||
max,
|
||||
onRelink,
|
||||
error,
|
||||
}: {
|
||||
instanceId: string;
|
||||
used: number;
|
||||
max: number;
|
||||
onRelink: (newId: string) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
export function RelinkPanel({ used, max, onRelink, error }: { instanceId: string; used: number; max: number; onRelink: (newId: string) => void; error?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const remaining = Math.max(0, max - used);
|
||||
@@ -26,32 +15,14 @@ export function RelinkPanel({
|
||||
return (
|
||||
<section className="grid gap-3 border-t border-rule-soft pt-5">
|
||||
<h2 className="text-xl">Moved to a new server?</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Relinking issues a replacement licence for the new install, covering the rest of
|
||||
your current term.
|
||||
</p>
|
||||
{open && !exhausted && (
|
||||
<Field
|
||||
label="New instance ID"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error}
|
||||
hint="From Settings → Licence on the new install."
|
||||
/>
|
||||
)}
|
||||
<p className="text-[0.82rem] text-ink-2">Relinking issues a replacement licence for the new install, covering the rest of your current term.</p>
|
||||
{open && !exhausted && <Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
|
||||
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
|
||||
>
|
||||
<Button type="button" variant="line" disabled={exhausted || (open && !UUID_RE.test(value.trim()))} onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}>
|
||||
Relink to a new install
|
||||
</Button>
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
{exhausted
|
||||
? "You have used every relink for this term — contact support and we will sort it out."
|
||||
: `${remaining} of ${max} relinks left this term`}
|
||||
{exhausted ? "You have used every relink for this term contact support and we will sort it out." : `${remaining} of ${max} relinks left this term`}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -296,8 +296,6 @@ export const api = {
|
||||
staffLogin: (email: string, password: string) =>
|
||||
post<Session>("/auth/staff/login", { email, password }),
|
||||
logout: () => post<{ ok: boolean }>("/auth/logout"),
|
||||
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
|
||||
post<{ pending: boolean }>("/auth/signup", payload),
|
||||
verify: (token: string) =>
|
||||
req<{ verified: boolean; needs_password?: boolean }>(
|
||||
`/auth/verify?token=${encodeURIComponent(token)}`,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Human wording for licence feature keys.
|
||||
*
|
||||
* One place, because there were two and they disagreed: the staff configurator
|
||||
* rendered every key that was not "console" as "Single sign-on", so adding a
|
||||
* third feature silently mislabelled the checkbox that grants it. A map with a
|
||||
* fallback degrades to the raw key, which is ugly but never wrong.
|
||||
*
|
||||
* Keys must match shared/license/license.go. */
|
||||
export const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
vuln_scanning: "Vulnerability scanning",
|
||||
};
|
||||
|
||||
export const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
vuln_scanning: "Package inventory matched against distribution security advisories",
|
||||
};
|
||||
|
||||
export function featureLabel(key: string): string {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
|
||||
export function featureDesc(key: string): string {
|
||||
return FEATURE_DESC[key] ?? "";
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
|
||||
// Starts at "system" on both server and first client render so hydration
|
||||
// matches; the real value lands in the effect below. The inline script in
|
||||
// app/layout.tsx has already painted the correct colours by then, so there
|
||||
// is no flash — only this control's own highlight settles a tick late.
|
||||
// is no flash only this control's own highlight settles a tick late.
|
||||
const [pref, setPref] = useState<ThemePref>("system");
|
||||
|
||||
useEffect(() => setPref(readTheme()), []);
|
||||
@@ -51,6 +51,4 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
|
||||
* Runs before first paint, so a dark-preferring user never sees a white flash.
|
||||
* Inlined as a string because it has to execute ahead of React.
|
||||
*/
|
||||
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
|
||||
KEY,
|
||||
)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
|
||||
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(KEY)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
|
||||
|
||||
+1
-1
@@ -3,13 +3,13 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/agent
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.20.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigDirByOS(t *testing.T) {
|
||||
d := ConfigDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
if !strings.Contains(strings.ToLower(d), "programdata") {
|
||||
t.Fatalf("windows config dir = %q, want ProgramData path", d)
|
||||
}
|
||||
} else {
|
||||
if d != "/etc/vantage" {
|
||||
t.Fatalf("unix config dir = %q, want /etc/vantage", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,11 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str
|
||||
return resp.AgentToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
|
||||
// SyncKeys returns the whole response rather than just the keys: the poll now
|
||||
// also carries CollectPackages, and a second RPC purely to learn one boolean
|
||||
// would be a message every 30 seconds for a value that changes at most when a
|
||||
// licence does.
|
||||
func (c *Client) SyncKeys(serverID, agentToken, version string) (*pb.SyncResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -92,7 +96,21 @@ func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.PublicKeys, nil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ReportPackages sends a package report and returns whether the server wants
|
||||
// the full list. Given a longer deadline than the other unary calls because the
|
||||
// full body is ~150KB on a slow link.
|
||||
func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.ReportPackages(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.NeedFull, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
|
||||
@@ -151,3 +169,7 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
return c.client.CommandStream(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) ProxyStream(ctx context.Context) (pb.Vantage_ProxyStreamClient, error) {
|
||||
return c.client.ProxyStream(ctx)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,45 @@ type SyncRequest struct {
|
||||
|
||||
type SyncResponse struct {
|
||||
PublicKeys []string `json:"public_keys"`
|
||||
// CollectPackages tells the agent whether this instance's licence grants
|
||||
// vulnerability scanning. Absent decodes as false, which is the safe
|
||||
// direction: an older server leaves agents collecting nothing.
|
||||
CollectPackages bool `json:"collect_packages,omitempty"`
|
||||
}
|
||||
|
||||
type OSRelease struct {
|
||||
Family string `json:"family"`
|
||||
// VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed
|
||||
// versions for the same CVE, so a scan without it is guesswork.
|
||||
VersionId string `json:"version_id"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
}
|
||||
|
||||
type InstalledPackage struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Epoch int32 `json:"epoch,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
// SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory
|
||||
// against "openssl" covers libssl3, openssl and libssl-dev.
|
||||
SourceName string `json:"source_name,omitempty"`
|
||||
}
|
||||
|
||||
// ReportPackagesRequest carries a server's installed package set.
|
||||
//
|
||||
// The agent calls twice at most: first with Packages empty, offering only the
|
||||
// hash. If the server already holds it, NeedFull is false and the ~150KB body
|
||||
// is never sent.
|
||||
type ReportPackagesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
Os OSRelease `json:"os"`
|
||||
Packages []InstalledPackage `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
type ReportPackagesResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
type UploadKeyRequest struct {
|
||||
@@ -131,6 +170,32 @@ type ReportChecksResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type OpenProxyCmd struct {
|
||||
ProxyId string `json:"proxy_id"`
|
||||
Port uint32 `json:"port"`
|
||||
}
|
||||
|
||||
type ProxyOpen struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
ProxyId string `json:"proxy_id"`
|
||||
}
|
||||
|
||||
type ProxyClose struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyClientMsg struct {
|
||||
Open *ProxyOpen `json:"open,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyServerMsg struct {
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
@@ -139,8 +204,15 @@ type ServerCommand struct {
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
|
||||
Ping *PingCmd `json:"ping,omitempty"`
|
||||
}
|
||||
|
||||
// PingCmd is a server-originated liveness beat. It carries nothing and expects
|
||||
// no reply: its arrival is the entire message. See the .proto for why gRPC
|
||||
// keepalive is not sufficient on its own.
|
||||
type PingCmd struct{}
|
||||
|
||||
|
||||
|
||||
type CleanupWorkspaceCmd struct {
|
||||
@@ -254,15 +326,62 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamServer interface {
|
||||
Send(*ProxyServerMsg) error
|
||||
Recv() (*ProxyClientMsg, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) {
|
||||
m := new(ProxyClientMsg)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamClient interface {
|
||||
Send(*ProxyClientMsg) error
|
||||
Recv() (*ProxyServerMsg, error)
|
||||
CloseSend() error
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) {
|
||||
m := new(ProxyServerMsg)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
|
||||
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error)
|
||||
}
|
||||
|
||||
type UnimplementedVantageServer struct{}
|
||||
@@ -317,6 +436,14 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) {
|
||||
out := new(ReportPackagesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
|
||||
out := new(InventoryReportResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
|
||||
@@ -349,3 +476,12 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
|
||||
}
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "ProxyStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/ProxyStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageProxyStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
@@ -144,11 +144,14 @@ func partitions() []pb.PartitionReport {
|
||||
if syscall.Statfs(fields[1], &st) != nil {
|
||||
continue
|
||||
}
|
||||
total := st.Blocks * uint64(st.Bsize)
|
||||
free := st.Bavail * uint64(st.Bsize)
|
||||
bsize := uint64(st.Bsize)
|
||||
total := st.Blocks * bsize
|
||||
// Bfree, not Bavail: the difference is the root-reserved 5% on ext4,
|
||||
// which is not used space. df counts it the same way.
|
||||
used := (st.Blocks - st.Bfree) * bsize
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
|
||||
TotalBytes: total, UsedBytes: total - free,
|
||||
TotalBytes: total, UsedBytes: used,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//go:build !linux
|
||||
//go:build !linux && !windows
|
||||
|
||||
// Inventory collection is Linux-only. This no-op stands in everywhere else.
|
||||
// Inventory collection has Linux and Windows implementations. This no-op stands
|
||||
// in everywhere else.
|
||||
//
|
||||
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
|
||||
// without it this file compiles on Linux too and collides with collect_linux.go.
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
|
||||
// x/sys/windows exposes neither of these two, so they are bound by hand.
|
||||
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
r.CPU.UsagePct = cpuUsage()
|
||||
// Windows has no load average. Left at zero; the UI already treats it as
|
||||
// optional because it is omitempty on the wire.
|
||||
|
||||
m := memoryStatus()
|
||||
if m.TotalPhys > m.AvailPhys {
|
||||
r.Memory.UsedBytes = m.TotalPhys - m.AvailPhys
|
||||
}
|
||||
// TotalPageFile is the commit limit — physical memory plus the pagefile —
|
||||
// so the pagefile alone is the difference.
|
||||
swapTotal := sub(m.TotalPageFile, m.TotalPhys)
|
||||
swapUsed := sub(sub(m.TotalPageFile, m.AvailPageFile), sub(m.TotalPhys, m.AvailPhys))
|
||||
if swapUsed > swapTotal {
|
||||
swapUsed = swapTotal
|
||||
}
|
||||
r.SwapUsed = swapUsed
|
||||
|
||||
if includeStatic {
|
||||
r.Memory.TotalBytes = m.TotalPhys
|
||||
r.SwapTotal = swapTotal
|
||||
r.CPU.Model, r.CPU.Cores = cpuStatic()
|
||||
r.Kernel = kernel()
|
||||
r.Partitions = partitions()
|
||||
}
|
||||
}
|
||||
|
||||
func sub(a, b uint64) uint64 {
|
||||
if a > b {
|
||||
return a - b
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type memoryStatusEx struct {
|
||||
Length uint32
|
||||
MemoryLoad uint32
|
||||
TotalPhys uint64
|
||||
AvailPhys uint64
|
||||
TotalPageFile uint64
|
||||
AvailPageFile uint64
|
||||
TotalVirtual uint64
|
||||
AvailVirtual uint64
|
||||
AvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
func memoryStatus() memoryStatusEx {
|
||||
var m memoryStatusEx
|
||||
m.Length = uint32(unsafe.Sizeof(m))
|
||||
r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m)))
|
||||
if r == 0 {
|
||||
return memoryStatusEx{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func systemTimes() (idle, total uint64, ok bool) {
|
||||
var idleFT, kernelFT, userFT windows.Filetime
|
||||
r, _, _ := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idleFT)),
|
||||
uintptr(unsafe.Pointer(&kernelFT)),
|
||||
uintptr(unsafe.Pointer(&userFT)),
|
||||
)
|
||||
if r == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
ft := func(f windows.Filetime) uint64 {
|
||||
return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime)
|
||||
}
|
||||
// Kernel time already includes idle time, so kernel+user is the whole.
|
||||
return ft(idleFT), ft(kernelFT) + ft(userFT), true
|
||||
}
|
||||
|
||||
func cpuUsage() float64 {
|
||||
i1, t1, ok := systemTimes()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
i2, t2, ok := systemTimes()
|
||||
if !ok || t2 <= t1 {
|
||||
return 0
|
||||
}
|
||||
return (1 - float64(i2-i1)/float64(t2-t1)) * 100
|
||||
}
|
||||
|
||||
func cpuStatic() (model string, cores int) {
|
||||
cores = runtime.NumCPU()
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
||||
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer k.Close()
|
||||
if s, _, err := k.GetStringValue("ProcessorNameString"); err == nil {
|
||||
model = s
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func kernel() string {
|
||||
v := windows.RtlGetVersion()
|
||||
return fmt.Sprintf("%d.%d.%d", v.MajorVersion, v.MinorVersion, v.BuildNumber)
|
||||
}
|
||||
|
||||
func partitions() []pb.PartitionReport {
|
||||
buf := make([]uint16, 256)
|
||||
n, err := windows.GetLogicalDriveStrings(uint32(len(buf)), &buf[0])
|
||||
if err != nil || n == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []pb.PartitionReport
|
||||
for _, root := range splitNullStrings(buf[:n]) {
|
||||
rootPtr, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Fixed disks only: network shares can hang, and removable drives
|
||||
// would appear and vanish between snapshots.
|
||||
if windows.GetDriveType(rootPtr) != windows.DRIVE_FIXED {
|
||||
continue
|
||||
}
|
||||
|
||||
var free, total, totalFree uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(rootPtr, &free, &total, &totalFree); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fsBuf := make([]uint16, 32)
|
||||
var fstype string
|
||||
if err := windows.GetVolumeInformation(rootPtr, nil, 0, nil, nil, nil, &fsBuf[0], uint32(len(fsBuf))); err == nil {
|
||||
fstype = windows.UTF16ToString(fsBuf)
|
||||
}
|
||||
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: root,
|
||||
Mountpoint: root,
|
||||
Fstype: fstype,
|
||||
TotalBytes: total,
|
||||
UsedBytes: total - totalFree,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitNullStrings splits the NUL-separated, double-NUL-terminated block that
|
||||
// GetLogicalDriveStrings writes.
|
||||
func splitNullStrings(b []uint16) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
for i, c := range b {
|
||||
if c != 0 {
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
out = append(out, windows.UTF16ToString(b[start:i]))
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OSRelease identifies the distribution well enough to select an advisory
|
||||
// feed. VersionID is not optional: Ubuntu 22.04 and 24.04 publish different
|
||||
// fixed versions for the same CVE.
|
||||
type OSRelease struct {
|
||||
Family string
|
||||
VersionID string
|
||||
Arch string
|
||||
}
|
||||
|
||||
// ParseOSRelease reads the os-release format: KEY=value, one per line, with
|
||||
// values optionally quoted, and # comments.
|
||||
//
|
||||
// The quote stripping handles both ID=ubuntu and ID="rocky", which real
|
||||
// distributions both emit.
|
||||
func ParseOSRelease(r io.Reader) (OSRelease, error) {
|
||||
out := OSRelease{Arch: runtime.GOARCH}
|
||||
sc := bufio.NewScanner(r)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "ID":
|
||||
out.Family = strings.ToLower(val)
|
||||
case "VERSION_ID":
|
||||
out.VersionID = val
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return OSRelease{}, err
|
||||
}
|
||||
if out.Family == "" {
|
||||
return OSRelease{}, errors.New("os-release has no ID")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DetectOS reads /etc/os-release.
|
||||
func DetectOS() (OSRelease, error) {
|
||||
f, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return OSRelease{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
return ParseOSRelease(f)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
const collectTimeout = 2 * time.Minute
|
||||
|
||||
// Collect enumerates installed packages. Linux only: Windows agents are
|
||||
// second-class by design, and vulnerability scanning there needs a different
|
||||
// source, a different collector and a different matcher, all out of scope.
|
||||
//
|
||||
// The format strings below are raw string literals on purpose. The "\t" and
|
||||
// "\n" reach dpkg-query and rpm as two characters each, and those tools do the
|
||||
// interpreting themselves — Go must not consume the escapes first.
|
||||
func Collect() (OSRelease, []Package, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
osrel, err := DetectOS()
|
||||
if err != nil {
|
||||
return OSRelease{}, nil, fmt.Errorf("detect os: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), collectTimeout)
|
||||
defer cancel()
|
||||
|
||||
switch {
|
||||
case have("dpkg-query"):
|
||||
out, err := run(ctx, "dpkg-query", "-W", "-f",
|
||||
`${Package}\t${Version}\t${Architecture}\t${source:Package}\n`)
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
return osrel, ParseDpkg(out), nil
|
||||
|
||||
case have("rpm"):
|
||||
out, err := run(ctx, "rpm", "-qa", "--qf",
|
||||
`%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n`)
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
return osrel, ParseRPM(out), nil
|
||||
|
||||
case have("apk"):
|
||||
out, err := run(ctx, "apk", "info", "-v")
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
return osrel, ParseAPK(out), nil
|
||||
|
||||
default:
|
||||
return osrel, nil, fmt.Errorf("no supported package manager found")
|
||||
}
|
||||
}
|
||||
|
||||
func have(bin string) bool {
|
||||
_, err := exec.LookPath(bin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, name string, args ...string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, name, args...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Package is one installed package as the distribution reports it. Version is
|
||||
// the distribution's own version string, verbatim — never normalised, because
|
||||
// the advisory feeds are keyed on exactly this form.
|
||||
type Package struct {
|
||||
Name string
|
||||
Version string
|
||||
Epoch int
|
||||
Arch string
|
||||
SourceName string
|
||||
}
|
||||
|
||||
// ParseDpkg reads tab-separated output of
|
||||
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\n'
|
||||
//
|
||||
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
|
||||
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
|
||||
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
|
||||
// alone finds one of the three.
|
||||
func ParseDpkg(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
p := Package{Name: f[0], Version: f[1], Arch: f[2]}
|
||||
if len(f) > 3 && f[3] != "" {
|
||||
p.SourceName = f[3]
|
||||
} else {
|
||||
p.SourceName = p.Name
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// ParseRPM reads tab-separated output of
|
||||
// rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n'
|
||||
func ParseRPM(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
epoch := 0
|
||||
// rpm prints "(none)" rather than omitting the field when a package has
|
||||
// no epoch. That must become 0, not fail the line.
|
||||
if f[1] != "" && f[1] != "(none)" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
epoch = n
|
||||
}
|
||||
}
|
||||
p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]}
|
||||
if len(f) > 4 {
|
||||
p.SourceName = srcRPMName(f[4])
|
||||
}
|
||||
if p.SourceName == "" {
|
||||
p.SourceName = p.Name
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping
|
||||
// the trailing ".src.rpm" and then the version and release segments, which are
|
||||
// the last two hyphen-separated fields.
|
||||
func srcRPMName(s string) string {
|
||||
s = strings.TrimSuffix(s, ".src.rpm")
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) <= 2 {
|
||||
return s
|
||||
}
|
||||
return strings.Join(parts[:len(parts)-2], "-")
|
||||
}
|
||||
|
||||
// ParseAPK reads "apk info -v" output: one "name-version-rREV" per line.
|
||||
// Alpine has no separate source package, so SourceName mirrors Name.
|
||||
func ParseAPK(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
name, version := splitAPK(line)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// splitAPK finds the version boundary from the RIGHT. The version is always the
|
||||
// last two hyphen-separated fields ("<version>-r<rev>"), which is reliable
|
||||
// where scanning from the left is not: package names legitimately contain
|
||||
// digits and underscores, so "musl" in "musl-1.2.4_git20230717-r4" cannot be
|
||||
// found by looking for the first digit.
|
||||
func splitAPK(s string) (name, version string) {
|
||||
last := strings.LastIndex(s, "-")
|
||||
if last <= 0 {
|
||||
return "", ""
|
||||
}
|
||||
prev := strings.LastIndex(s[:last], "-")
|
||||
if prev <= 0 {
|
||||
return "", ""
|
||||
}
|
||||
return s[:prev], s[prev+1:]
|
||||
}
|
||||
|
||||
// Hash fingerprints a package set so an unchanged set never has to be sent.
|
||||
//
|
||||
// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable,
|
||||
// and an ordering-sensitive hash would resend the full ~150KB list every hour
|
||||
// for no reason — a cost visible only as traffic.
|
||||
func Hash(pkgs []Package) string {
|
||||
lines := make([]string, 0, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch)
|
||||
}
|
||||
sort.Strings(lines)
|
||||
h := sha256.New()
|
||||
for _, l := range lines {
|
||||
h.Write([]byte(l))
|
||||
h.Write([]byte("\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package agentproxy relays a single TCP connection between a local service and
|
||||
// the control plane, so a control plane that cannot route to this host's network
|
||||
// can still open a console session.
|
||||
//
|
||||
// The dial host is hardcoded to loopback. The control plane supplies only a
|
||||
// port, and nothing in this package can be made to dial anywhere else.
|
||||
package agentproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
loopbackHost = "127.0.0.1"
|
||||
chunkSize = 32 * 1024
|
||||
dialTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Stream is the agent's half of a ProxyStream.
|
||||
type Stream interface {
|
||||
Send(*pb.ProxyClientMsg) error
|
||||
Recv() (*pb.ProxyServerMsg, error)
|
||||
CloseSend() error
|
||||
}
|
||||
|
||||
// Open dials the local port, announces itself on the stream, and relays until
|
||||
// either side ends. A refused dial is reported as an explicit close so the
|
||||
// operator sees a reason rather than a hang.
|
||||
func Open(stream Stream, serverID, agentToken, proxyID string, port uint32) error {
|
||||
conn, dialErr := net.DialTimeout("tcp",
|
||||
net.JoinHostPort(loopbackHost, strconv.Itoa(int(port))), dialTimeout)
|
||||
|
||||
if err := stream.Send(&pb.ProxyClientMsg{Open: &pb.ProxyOpen{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
ProxyId: proxyID,
|
||||
}}); err != nil {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return fmt.Errorf("send open: %w", err)
|
||||
}
|
||||
|
||||
if dialErr != nil {
|
||||
_ = stream.Send(&pb.ProxyClientMsg{Close: &pb.ProxyClose{
|
||||
Reason: "dial_refused: " + dialErr.Error(),
|
||||
}})
|
||||
_ = stream.CloseSend()
|
||||
return fmt.Errorf("dial 127.0.0.1:%d: %w", port, dialErr)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
return relay(conn, stream)
|
||||
}
|
||||
|
||||
func relay(conn net.Conn, stream Stream) error {
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
// local service -> control plane
|
||||
go func() {
|
||||
buf := make([]byte, chunkSize)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
if sendErr := stream.Send(&pb.ProxyClientMsg{Data: chunk}); sendErr != nil {
|
||||
errCh <- sendErr
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// control plane -> local service
|
||||
go func() {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if msg.Close != nil {
|
||||
errCh <- fmt.Errorf("server closed relay: %s", msg.Close.Reason)
|
||||
return
|
||||
}
|
||||
if len(msg.Data) > 0 {
|
||||
if _, err := conn.Write(msg.Data); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := <-errCh
|
||||
_ = conn.Close()
|
||||
_ = stream.CloseSend()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
|
||||
)
|
||||
|
||||
// collectPackagesFlag is written by the 30s key poll and read by the hourly
|
||||
// package loop — two different goroutines, hence the atomic.
|
||||
//
|
||||
// It defaults to false, so an agent that has not yet completed a poll, or is
|
||||
// talking to a server too old to send the field, collects nothing. Off is the
|
||||
// safe default: collecting without a licence costs the customer storage they
|
||||
// are not paying for.
|
||||
var collectPackagesFlag atomic.Bool
|
||||
|
||||
// firstPoll closes once a SyncKeys response has set the flag above.
|
||||
//
|
||||
// Without it the boot-time package report loses a race it can only lose: the
|
||||
// hourly loop starts before the first poll, reads a flag that is still false by
|
||||
// construction, and skips — so a freshly installed agent reports no packages for
|
||||
// an hour and the server shows nothing to scan.
|
||||
// How long the boot package report waits for that first poll. Two poll
|
||||
// intervals plus slack: long enough to cover one failed attempt, short enough
|
||||
// that a dead control plane does not hold the OS-update report hostage.
|
||||
const firstPollWait = 90 * time.Second
|
||||
|
||||
var (
|
||||
firstPoll = make(chan struct{})
|
||||
firstPollOnce sync.Once
|
||||
)
|
||||
|
||||
func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }
|
||||
|
||||
// waitFirstPoll blocks until the flag is known, or gives up. The wait is
|
||||
// bounded because this loop also reports OS updates, which do not depend on the
|
||||
// flag at all — a control plane that cannot be polled must not silence those too.
|
||||
func waitFirstPoll(ctx context.Context, limit time.Duration) {
|
||||
t := time.NewTimer(limit)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-firstPoll:
|
||||
case <-t.C:
|
||||
log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit)
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
|
||||
|
||||
// reportPackages offers a hash of the installed package set and sends the full
|
||||
// list only if the server does not already hold it.
|
||||
//
|
||||
// It runs on the same hourly cadence as the update check because a package set
|
||||
// changes on roughly the same schedule, and reusing that loop means one timer
|
||||
// rather than two.
|
||||
func reportPackages(client *grpcclient.Client, cfg *config.Config) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
if !collectPackagesEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
osrel, pkgs, err := packages.Collect()
|
||||
if err != nil {
|
||||
log.Printf("package collection error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
pbOS := pb.OSRelease{
|
||||
Family: osrel.Family,
|
||||
VersionId: osrel.VersionID,
|
||||
Arch: osrel.Arch,
|
||||
}
|
||||
hash := packages.Hash(pkgs)
|
||||
|
||||
// The offer: hash only, no body. On an unchanged host this is the whole
|
||||
// exchange, which is the point of the handshake.
|
||||
needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Hash: hash,
|
||||
Os: pbOS,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ReportPackages offer error: %v", err)
|
||||
return
|
||||
}
|
||||
if !needFull {
|
||||
return
|
||||
}
|
||||
|
||||
pbPkgs := make([]pb.InstalledPackage, len(pkgs))
|
||||
for i, p := range pkgs {
|
||||
pbPkgs[i] = pb.InstalledPackage{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Epoch: int32(p.Epoch),
|
||||
Arch: p.Arch,
|
||||
SourceName: p.SourceName,
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := client.ReportPackages(&pb.ReportPackagesRequest{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Hash: hash,
|
||||
Os: pbOS,
|
||||
Packages: pbPkgs,
|
||||
}); err != nil {
|
||||
log.Printf("ReportPackages full error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d installed packages", len(pkgs))
|
||||
}
|
||||
+245
-21
@@ -24,6 +24,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/inventory"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors"
|
||||
agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
|
||||
)
|
||||
|
||||
@@ -91,11 +92,19 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
}
|
||||
|
||||
func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
|
||||
resp, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SyncKeys: %w", err)
|
||||
}
|
||||
|
||||
// Stored atomically: the hourly package loop reads this from another
|
||||
// goroutine. Absent on the wire decodes as false, so an older server leaves
|
||||
// collection off rather than on.
|
||||
collectPackagesFlag.Store(resp.CollectPackages)
|
||||
markFirstPoll()
|
||||
|
||||
desired := resp.PublicKeys
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil
|
||||
}
|
||||
@@ -117,9 +126,36 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// How long a command stream must survive before it counts as having worked.
|
||||
// Past this, the next drop is treated as a fresh incident rather than as the
|
||||
// continuation of a run of failures.
|
||||
const streamHealthyAfter = time.Minute
|
||||
|
||||
// Stream staleness. The server beats every 20s, so 70s tolerates three missed
|
||||
// beats before the stream is written off — high enough that a slow network or a
|
||||
// briefly busy server does not cost a reconnect, low enough that an agent is
|
||||
// not uncommandable for minutes after a control-plane restart.
|
||||
const (
|
||||
streamStaleAfter = 70 * time.Second
|
||||
streamStaleCheck = 10 * time.Second
|
||||
|
||||
// How often a healthy stream reports itself. Also the interval at which an
|
||||
// agent talking to a control plane too old to send heartbeats says so —
|
||||
// that agent is running without a watchdog, and the journal should not be
|
||||
// silent about it.
|
||||
pingSummaryInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
func runCommandStream(ctx context.Context, cfg *config.Config) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 2 * time.Minute
|
||||
|
||||
// Two minutes was the old ceiling, and it was reached far too easily. The
|
||||
// command stream is what makes this agent controllable at all: while it is
|
||||
// down, workflows and console sessions fail as "agent offline" even though
|
||||
// SyncKeys keeps polling happily and the fleet list still shows the server
|
||||
// active. A shorter ceiling costs a few reconnect attempts; the old one cost
|
||||
// two minutes of an agent that looks fine and answers nothing.
|
||||
const maxBackoff = 30 * time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -128,22 +164,45 @@ func runCommandStream(ctx context.Context, cfg *config.Config) {
|
||||
default:
|
||||
}
|
||||
|
||||
if err := connectAndHandleStream(ctx, cfg); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("command stream error: %v, reconnecting in %s", err, backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
}
|
||||
} else {
|
||||
started := time.Now()
|
||||
err := connectAndHandleStream(ctx, cfg)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// A stream that stayed up is evidence the control plane is reachable,
|
||||
// whatever ended it. Without this the backoff only ever climbed:
|
||||
// connectAndHandleStream returns an error on *every* stream end,
|
||||
// including a healthy one dropped by a routine deploy, so an agent
|
||||
// pinned itself at the ceiling after a handful of ordinary restarts and
|
||||
// stayed there for the rest of its life.
|
||||
if time.Since(started) >= streamHealthyAfter {
|
||||
backoff = time.Second
|
||||
}
|
||||
|
||||
// The uptime is in the line because it is what distinguishes a stream
|
||||
// that never worked from one that ran for hours and was dropped by a
|
||||
// deploy — and it is the same measure that decides whether the backoff
|
||||
// resets, so a reader can see why the delay is what it is.
|
||||
up := time.Since(started).Truncate(time.Second)
|
||||
if err != nil {
|
||||
log.Printf("command stream error after %s: %v, reconnecting in %s", up, err, backoff)
|
||||
} else {
|
||||
log.Printf("command stream closed after %s, reconnecting in %s", up, backoff)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +213,13 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stream, err := client.CommandStream(ctx)
|
||||
// Cancelling this context is what unblocks Recv when the stream has gone
|
||||
// quiet. Without it the watchdog below would have no way to interrupt a
|
||||
// read that is never going to return.
|
||||
streamCtx, abandon := context.WithCancel(ctx)
|
||||
defer abandon()
|
||||
|
||||
stream, err := client.CommandStream(streamCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open stream: %w", err)
|
||||
}
|
||||
@@ -167,7 +232,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
return fmt.Errorf("send auth: %w", err)
|
||||
}
|
||||
|
||||
log.Println("command stream connected")
|
||||
log.Printf("command stream connected to %s", cfg.ServerURL)
|
||||
|
||||
var sendMu sync.Mutex
|
||||
send := func(msg *pb.AgentMessage) error {
|
||||
@@ -176,11 +241,93 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
return stream.Send(msg)
|
||||
}
|
||||
|
||||
// Stream liveness, tracked here rather than left to gRPC keepalive.
|
||||
//
|
||||
// Keepalive operates on the transport, and behind an L7 proxy the transport
|
||||
// ends at the proxy: it answers pings whether or not the server behind it
|
||||
// is still running. A control-plane pod that dies therefore leaves this
|
||||
// agent blocked in Recv on a stream that will never deliver another message
|
||||
// and never error, while the control plane dispatches commands into it and
|
||||
// the operator watches nothing happen.
|
||||
//
|
||||
// The watchdog only arms once a ping has actually been seen. A server too
|
||||
// old to send them must not be treated as dead — that would put the agent
|
||||
// in a reconnect loop against a control plane that is working perfectly.
|
||||
var (
|
||||
lastMu sync.Mutex
|
||||
lastRecv = time.Now()
|
||||
pinged bool
|
||||
beats int
|
||||
)
|
||||
markRecv := func(isPing bool) {
|
||||
lastMu.Lock()
|
||||
lastRecv = time.Now()
|
||||
if isPing {
|
||||
beats++
|
||||
// Logged once per stream, because it is the moment the agent starts
|
||||
// holding the control plane to account: before this the watchdog is
|
||||
// disarmed and a dead stream would go unnoticed indefinitely.
|
||||
if !pinged {
|
||||
pinged = true
|
||||
log.Printf("command stream heartbeat detected, watchdog armed (%s threshold)", streamStaleAfter)
|
||||
}
|
||||
}
|
||||
lastMu.Unlock()
|
||||
}
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(streamStaleCheck)
|
||||
defer t.Stop()
|
||||
|
||||
// Reported periodically rather than per beat: at one every 20s the
|
||||
// journal would be nothing else. The count is what makes a partial
|
||||
// failure visible — beats arriving but fewer than expected is a
|
||||
// different problem from beats stopping altogether.
|
||||
summary := time.NewTicker(pingSummaryInterval)
|
||||
defer summary.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-streamCtx.Done():
|
||||
return
|
||||
case <-summary.C:
|
||||
lastMu.Lock()
|
||||
n, armed := beats, pinged
|
||||
beats = 0
|
||||
lastMu.Unlock()
|
||||
if armed {
|
||||
log.Printf("command stream healthy, %d heartbeats in the last %s", n, pingSummaryInterval)
|
||||
} else {
|
||||
log.Printf("command stream up but sending no heartbeats; "+
|
||||
"control plane predates them, watchdog stays disarmed (last message %s ago)",
|
||||
time.Since(lastRecv).Truncate(time.Second))
|
||||
}
|
||||
case <-t.C:
|
||||
lastMu.Lock()
|
||||
idle, armed := time.Since(lastRecv), pinged
|
||||
lastMu.Unlock()
|
||||
if armed && idle > streamStaleAfter {
|
||||
log.Printf("command stream silent for %s (threshold %s), assuming it is dead and reconnecting",
|
||||
idle.Truncate(time.Second), streamStaleAfter)
|
||||
abandon()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
cmd, err := stream.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("recv: %w", err)
|
||||
}
|
||||
markRecv(cmd.Ping != nil)
|
||||
|
||||
// Pings carry nothing and are not acknowledged; being received is their
|
||||
// whole purpose.
|
||||
if cmd.Ping != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if cmd.GenerateKey != nil {
|
||||
go handleGenerateKey(cfg, cmd)
|
||||
@@ -197,6 +344,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.CleanupWorkspace != nil {
|
||||
go handleCleanupWorkspace(cmd)
|
||||
}
|
||||
if cmd.OpenProxy != nil {
|
||||
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
|
||||
}
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
@@ -253,8 +403,17 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d available OS updates", len(pkgs))
|
||||
|
||||
// Same hourly cadence, same connection. A package set changes on
|
||||
// roughly the schedule available updates do, so this needs no timer of
|
||||
// its own.
|
||||
reportPackages(client, cfg)
|
||||
}
|
||||
|
||||
// The boot round only: after this the flag has long been set, and every
|
||||
// later tick is an hour past a poll that runs every 30s.
|
||||
waitFirstPoll(ctx, firstPollWait)
|
||||
|
||||
doCheck()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -326,6 +485,29 @@ func handleCleanupWorkspace(cmd *pb.ServerCommand) {
|
||||
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
|
||||
}
|
||||
|
||||
// handleOpenProxy relays one console connection. It uses its own gRPC
|
||||
// connection so console traffic never shares a stream with commands, key sync
|
||||
// or workflow output.
|
||||
func handleOpenProxy(ctx context.Context, cfg *config.Config, cmd *pb.OpenProxyCmd) {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("proxy %s: dial control plane: %v", cmd.ProxyId, err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stream, err := client.ProxyStream(ctx)
|
||||
if err != nil {
|
||||
log.Printf("proxy %s: open stream: %v", cmd.ProxyId, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("proxy %s: relaying 127.0.0.1:%d", cmd.ProxyId, cmd.Port)
|
||||
if err := agentproxy.Open(stream, cfg.ServerID, cfg.AgentToken, cmd.ProxyId, cmd.Port); err != nil {
|
||||
log.Printf("proxy %s: %v", cmd.ProxyId, err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
@@ -412,13 +594,55 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
|
||||
}
|
||||
|
||||
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
|
||||
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
|
||||
|
||||
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
|
||||
if err := up.Start(); err != nil {
|
||||
// The MSI stops the vantage-agent service as part of the upgrade. Anything
|
||||
// descended from this process is killed with it, so msiexec must not be a
|
||||
// child: run it from a scheduled task, which is parented to the Task
|
||||
// Scheduler service instead.
|
||||
if err := launchDetachedUpdate(msiPath, logPath, cmd.CommandId); err != nil {
|
||||
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
log.Printf("scheduled msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
|
||||
}
|
||||
|
||||
const updateTaskName = "VantageAgentUpdate"
|
||||
|
||||
func launchDetachedUpdate(msiPath, logPath, commandID string) error {
|
||||
scriptPath := filepath.Join(os.TempDir(), "vantage-agent-update.cmd")
|
||||
script := fmt.Sprintf("@echo off\r\n"+
|
||||
"timeout /t 5 /nobreak >nul\r\n"+
|
||||
"msiexec /i \"%s\" /qn /norestart /l*v \"%s\"\r\n"+
|
||||
"schtasks /delete /tn %s /f >nul 2>&1\r\n"+
|
||||
"del /f /q \"%s\" >nul 2>&1\r\n"+
|
||||
"(goto) 2>nul & del /f /q \"%%~f0\"\r\n",
|
||||
msiPath, logPath, updateTaskName, msiPath)
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
|
||||
return fmt.Errorf("write update script: %w", err)
|
||||
}
|
||||
|
||||
// Stale task from a previous attempt would make /create fail even with /f
|
||||
// if it is still running, so tear it down first and ignore the result.
|
||||
exec.Command("schtasks", "/end", "/tn", updateTaskName).Run()
|
||||
exec.Command("schtasks", "/delete", "/tn", updateTaskName, "/f").Run()
|
||||
|
||||
create := exec.Command("schtasks", "/create",
|
||||
"/tn", updateTaskName,
|
||||
"/tr", `"`+scriptPath+`"`,
|
||||
"/sc", "once",
|
||||
// Already in the past: the task never fires on its own, only via /run.
|
||||
"/st", "00:00",
|
||||
"/ru", "SYSTEM",
|
||||
"/rl", "HIGHEST",
|
||||
"/f")
|
||||
if out, err := create.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("schtasks create: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
if out, err := exec.Command("schtasks", "/run", "/tn", updateTaskName).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("schtasks run: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Apply Package Updates",
|
||||
"description": "Apply all pending OS package updates. Supports apt, dnf, yum, zypper, apk and pacman.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nif command -v apt-get >/dev/null 2>&1; then\n export DEBIAN_FRONTEND=noninteractive\n apt-get update -qq && apt-get -y -qq upgrade\nelif command -v dnf >/dev/null 2>&1; then\n dnf -y upgrade\nelif command -v yum >/dev/null 2>&1; then\n yum -y update\nelif command -v zypper >/dev/null 2>&1; then\n zypper --non-interactive update\nelif command -v apk >/dev/null 2>&1; then\n apk update && apk upgrade\nelif command -v pacman >/dev/null 2>&1; then\n pacman -Syu --noconfirm\nelse\n echo \"no supported package manager found\"\n exit 1\nfi\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"package update failed\"\n exit 1\nfi\necho \"packages up to date\"\n# Debian and Ubuntu drop this file when a new kernel or libc needs a restart.\n# Reported rather than acted on: rebooting a fleet is a decision, not a detail.\nif [ -f /var/run/reboot-required ]; then\n echo \"REBOOT_REQUIRED=true\" >> $WORKFLOW_ENV\n echo \"a reboot is required to finish applying updates\"\nelse\n echo \"REBOOT_REQUIRED=false\" >> $WORKFLOW_ENV\nfi",
|
||||
"declared_outputs": [
|
||||
"REBOOT_REQUIRED"
|
||||
],
|
||||
"declared_inputs": [],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Check Port Is Listening",
|
||||
"description": "Fail unless something is listening on a TCP port.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nhost=\"${host:-127.0.0.1}\"\nif command -v nc >/dev/null 2>&1; then\n nc -z -w 5 \"$host\" \"$port\" >/dev/null 2>&1\n ok=$?\nelse\n # bash builds /dev/tcp in, so this needs nothing installed.\n timeout 5 bash -c \"cat < /dev/null > /dev/tcp/$host/$port\" >/dev/null 2>&1\n ok=$?\nfi\nif [ $ok -ne 0 ]; then\n echo \"PORT_OPEN=false\" >> $WORKFLOW_ENV\n echo \"nothing listening on $host:$port\"\n exit 1\nfi\necho \"PORT_OPEN=true\" >> $WORKFLOW_ENV\necho \"$host:$port is open\"",
|
||||
"declared_outputs": [
|
||||
"PORT_OPEN"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "host",
|
||||
"default": "127.0.0.1",
|
||||
"description": "host to test"
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"default": "",
|
||||
"description": "TCP port to test"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Copy File/Directory",
|
||||
"description": "Copy a file or directory, preserving mode, ownership and timestamps.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nif [ ! -e \"$source\" ]; then\n echo \"source $source does not exist\"\n exit 1\nfi\ncp -a \"$source\" \"$destination\" || { echo \"failed to copy $source to $destination\"; exit 1; }\necho \"copied $source to $destination\"\necho \"DEST_PATH=$destination\" >> $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"DEST_PATH"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"default": "",
|
||||
"description": "path to copy from"
|
||||
},
|
||||
{
|
||||
"name": "destination",
|
||||
"default": "",
|
||||
"description": "path to copy to"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Create Directory",
|
||||
"description": "Create a directory, including any missing parents.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nmkdir -p \"$path\" || { echo \"failed to create $path\"; exit 1; }\nif [ -n \"${mode:-}\" ]; then\n chmod \"$mode\" \"$path\" || { echo \"failed to set mode $mode on $path\"; exit 1; }\nfi\necho \"created $path\"\necho \"DIR_PATH=$path\" >> $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"DIR_PATH"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "",
|
||||
"description": "directory to create"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"default": "",
|
||||
"description": "optional octal mode, e.g. 0750"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Delete File/Directory",
|
||||
"description": "Delete a path. Refuses the root filesystem and an empty value.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\n# A step that runs as root on every server in a selector has to refuse the\n# one input that would wipe the fleet. An unset variable expands to empty,\n# so the empty case is the accident this actually guards against.\ncase \"$path\" in\n \"\"|\"/\"|\"/.\"|\"/..\")\n echo \"refusing to delete '$path'\"\n exit 1\n ;;\nesac\nif [ ! -e \"$path\" ]; then\n echo \"$path does not exist, nothing to do\"\n exit 0\nfi\nrm -rf \"$path\" || { echo \"failed to delete $path\"; exit 1; }\necho \"deleted $path\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "",
|
||||
"description": "path to delete"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Disk Usage Report",
|
||||
"description": "Report usage for a mount point and fail past a threshold.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nmount=\"${mountPoint:-/}\"\nlimit=\"${maxPercent:-90}\"\ndf -h \"$mount\"\nused=$(df --output=pcent \"$mount\" | tail -1 | tr -dc \"0-9\")\navail=$(df -h --output=avail \"$mount\" | tail -1 | tr -d \" \")\necho \"DISK_USED_PERCENT=$used\" >> $WORKFLOW_ENV\necho \"DISK_AVAILABLE=$avail\" >> $WORKFLOW_ENV\nif [ \"$used\" -ge \"$limit\" ]; then\n echo \"$mount is ${used}% full, at or over the ${limit}% limit\"\n exit 1\nfi\necho \"$mount is ${used}% full, ${avail} available\"",
|
||||
"declared_outputs": [
|
||||
"DISK_USED_PERCENT",
|
||||
"DISK_AVAILABLE"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "mountPoint",
|
||||
"default": "/",
|
||||
"description": "mount point to measure"
|
||||
},
|
||||
{
|
||||
"name": "maxPercent",
|
||||
"default": "90",
|
||||
"description": "fail at or above this percentage"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Docker Compose Pull and Up",
|
||||
"description": "Pull the latest images for a compose project and recreate its containers.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\ncd \"$projectDir\" || { echo \"no such directory: $projectDir\"; exit 1; }\nif docker compose version >/dev/null 2>&1; then\n dc=\"docker compose\"\nelif command -v docker-compose >/dev/null 2>&1; then\n dc=\"docker-compose\"\nelse\n echo \"docker compose is not installed\"\n exit 1\nfi\n$dc pull || { echo \"pull failed\"; exit 1; }\n$dc up -d --remove-orphans || { echo \"up failed\"; exit 1; }\n$dc ps",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "projectDir",
|
||||
"default": "",
|
||||
"description": "directory holding docker-compose.yml"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Download File (Linux)",
|
||||
"description": "Download a file over HTTP to a local path",
|
||||
"interpreter": "bash",
|
||||
"script": "out=$(mktemp -p ./)\necho \"Downloading file from $url\"\nwget -q $url -O $out\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to download file from url\"\n exit 1\nfi\necho \"FILE_PATH=$out\" \u003e\u003e $WORKFLOW_ENV",
|
||||
"declared_outputs": ["FILE_PATH"],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"default": "",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Enable Linux Service",
|
||||
"description": "Enable a systemd unit so it starts on boot.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\necho \"enabling service $serviceName\"\nsystemctl enable \"$serviceName\" || { echo \"failed to enable $serviceName\"; exit 1; }\necho \"$serviceName enabled\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "systemd unit to enable"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Extract Archive",
|
||||
"description": "Extract a tar, tar.gz, tar.bz2, tar.xz or zip archive into a directory.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\ndest=\"${destination:-.}\"\nif [ ! -f \"$archive\" ]; then\n echo \"archive $archive does not exist\"\n exit 1\nfi\nmkdir -p \"$dest\"\ncase \"$archive\" in\n *.tar.gz|*.tgz) tar -xzf \"$archive\" -C \"$dest\" ;;\n *.tar.bz2|*.tbz2) tar -xjf \"$archive\" -C \"$dest\" ;;\n *.tar.xz|*.txz) tar -xJf \"$archive\" -C \"$dest\" ;;\n *.tar) tar -xf \"$archive\" -C \"$dest\" ;;\n *.zip)\n command -v unzip >/dev/null 2>&1 || { echo \"unzip is not installed\"; exit 1; }\n unzip -oq \"$archive\" -d \"$dest\"\n ;;\n *)\n echo \"unsupported archive type: $archive\"\n exit 1\n ;;\nesac\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to extract $archive\"\n exit 1\nfi\necho \"extracted $archive into $dest\"\necho \"EXTRACT_DIR=$dest\" >> $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"EXTRACT_DIR"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "archive",
|
||||
"default": "",
|
||||
"description": "archive file to extract"
|
||||
},
|
||||
{
|
||||
"name": "destination",
|
||||
"default": ".",
|
||||
"description": "directory to extract into"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Get Host Name",
|
||||
"description": "Gets the agents hostname",
|
||||
"interpreter": "bash",
|
||||
"script": "HOSTNAME=$(hostname)\necho $HOSTNAME\necho \"HOSTNAME=$HOSTNAME\" \u003e\u003e $WORKFLOW_ENV",
|
||||
"declared_outputs": ["HOSTNAME"],
|
||||
"declared_inputs": [],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "HTTP Health Check",
|
||||
"description": "Request a URL and fail unless it answers with the expected status.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nexpected=\"${expectedStatus:-200}\"\nattempts=\"${retries:-3}\"\ndelay=\"${retryDelay:-5}\"\nstatus=\"\"\ni=1\n# Retries live in the script rather than in on_failure: a service coming up\n# after a restart wants a few seconds, not a whole step re-dispatched.\nwhile [ \"$i\" -le \"$attempts\" ]; do\n status=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"$url\" || echo \"000\")\n echo \"attempt $i: $url returned $status\"\n if [ \"$status\" = \"$expected\" ]; then\n break\n fi\n i=$(( i + 1 ))\n if [ \"$i\" -le \"$attempts\" ]; then sleep \"$delay\"; fi\ndone\necho \"HTTP_STATUS=$status\" >> $WORKFLOW_ENV\nif [ \"$status\" != \"$expected\" ]; then\n echo \"$url returned $status, expected $expected\"\n exit 1\nfi\necho \"$url is healthy\"",
|
||||
"declared_outputs": [
|
||||
"HTTP_STATUS"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"default": "",
|
||||
"description": "URL to request"
|
||||
},
|
||||
{
|
||||
"name": "expectedStatus",
|
||||
"default": "200",
|
||||
"description": "HTTP status that counts as healthy"
|
||||
},
|
||||
{
|
||||
"name": "retries",
|
||||
"default": "3",
|
||||
"description": "how many attempts before failing"
|
||||
},
|
||||
{
|
||||
"name": "retryDelay",
|
||||
"default": "5",
|
||||
"description": "seconds between attempts"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "List Directory",
|
||||
"description": "Lists the files in the specified path",
|
||||
"interpreter": "bash",
|
||||
"script": "if [ ! -e $path ]; then\n echo \"file or directory doesn't exist: $path\"\n exit 1\nfi\nls -l $path",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "./",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Memory Usage Report",
|
||||
"description": "Report memory usage as a percentage of total.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nfree -h\ntotal=$(free -m | awk \"/^Mem:/ {print \\$2}\")\nused=$(free -m | awk \"/^Mem:/ {print \\$3}\")\npct=$(( used * 100 / total ))\necho \"MEM_USED_PERCENT=$pct\" >> $WORKFLOW_ENV\necho \"MEM_USED_MB=$used\" >> $WORKFLOW_ENV\necho \"memory ${pct}% used (${used}MB of ${total}MB)\"",
|
||||
"declared_outputs": [
|
||||
"MEM_USED_PERCENT",
|
||||
"MEM_USED_MB"
|
||||
],
|
||||
"declared_inputs": [],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Reboot Server",
|
||||
"description": "Schedule a reboot a minute out, so the step reports success before the machine goes down.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\ndelay=\"${delayMinutes:-1}\"\n# Scheduled rather than immediate on purpose: `shutdown -r now` kills the\n# agent before it can report, and the run records a failure on a server\n# that did exactly what it was told.\necho \"rebooting in $delay minute(s)\"\nshutdown -r \"+$delay\" \"Reboot requested by Vantage\" || { echo \"failed to schedule a reboot\"; exit 1; }",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "delayMinutes",
|
||||
"default": "1",
|
||||
"description": "minutes to wait before rebooting"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Restart Linux Service",
|
||||
"description": "Restart a systemd unit and fail if it does not come back up.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\necho \"restarting service $serviceName\"\nsystemctl restart \"$serviceName\" || { echo \"failed to restart $serviceName\"; exit 1; }\nsystemctl is-active --quiet \"$serviceName\" || {\n echo \"$serviceName did not come back up\"\n systemctl status \"$serviceName\" --no-pager --lines=20 || true\n exit 1\n}\necho \"$serviceName is active\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "systemd unit to restart, e.g. nginx"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Linux Service Status",
|
||||
"description": "Report whether a systemd unit is active and enabled. Does not fail on a stopped unit.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nstate=$(systemctl is-active \"$serviceName\" 2>/dev/null || true)\nenabled=$(systemctl is-enabled \"$serviceName\" 2>/dev/null || true)\necho \"$serviceName: state=$state enabled=$enabled\"\necho \"SERVICE_STATE=$state\" >> $WORKFLOW_ENV\necho \"SERVICE_ENABLED=$enabled\" >> $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"SERVICE_STATE",
|
||||
"SERVICE_ENABLED"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "systemd unit to inspect"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Set Permissions and Ownership",
|
||||
"description": "Set the mode and optionally the owner of a path.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nif [ ! -e \"$path\" ]; then\n echo \"$path does not exist\"\n exit 1\nfi\nrecurse=\"\"\nif [ \"${recursive:-false}\" = \"true\" ]; then\n recurse=\"-R\"\nfi\nif [ -n \"${mode:-}\" ]; then\n chmod $recurse \"$mode\" \"$path\" || { echo \"failed to set mode\"; exit 1; }\n echo \"set mode $mode on $path\"\nfi\nif [ -n \"${owner:-}\" ]; then\n chown $recurse \"$owner\" \"$path\" || { echo \"failed to set owner\"; exit 1; }\n echo \"set owner $owner on $path\"\nfi",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "",
|
||||
"description": "path to change"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"default": "",
|
||||
"description": "octal mode, e.g. 0640"
|
||||
},
|
||||
{
|
||||
"name": "owner",
|
||||
"default": "",
|
||||
"description": "owner, e.g. root:root"
|
||||
},
|
||||
{
|
||||
"name": "recursive",
|
||||
"default": "false",
|
||||
"description": "true to apply recursively"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Start Linux Service",
|
||||
"description": "Start a linux systemd service",
|
||||
"interpreter": "bash",
|
||||
"script": "echo \"starting service $serviceName\"\nsystemctl start $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to start service\"\n exit 1\nfi",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Stop Linux Service",
|
||||
"description": "Stops a linux systemd service",
|
||||
"interpreter": "bash",
|
||||
"script": "echo \"stopping service $serviceName\"\nsystemctl stop $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to stop service\"\n exit 1\nfi",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Tail Log File",
|
||||
"description": "Print the last N lines of a file, for reading a log after a deployment step.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nlines=\"${lines:-50}\"\nif [ ! -f \"$path\" ]; then\n echo \"$path does not exist\"\n exit 1\nfi\necho \"last $lines lines of $path:\"\ntail -n \"$lines\" \"$path\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "",
|
||||
"description": "log file to read"
|
||||
},
|
||||
{
|
||||
"name": "lines",
|
||||
"default": "50",
|
||||
"description": "how many lines to print"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "TLS Certificate Expiry",
|
||||
"description": "Report days remaining on a TLS certificate and fail under a threshold.",
|
||||
"interpreter": "bash",
|
||||
"script": "set -u\nport=\"${port:-443}\"\nmin=\"${minDays:-14}\"\ncommand -v openssl >/dev/null 2>&1 || { echo \"openssl is not installed\"; exit 1; }\n# -servername sends SNI, without which a shared host returns the wrong\n# certificate and the expiry reported here belongs to someone else.\nend=$(echo | openssl s_client -servername \"$host\" -connect \"$host:$port\" 2>/dev/null \\\n | openssl x509 -noout -enddate | cut -d= -f2)\nif [ -z \"$end\" ]; then\n echo \"could not read a certificate from $host:$port\"\n exit 1\nfi\nendEpoch=$(date -d \"$end\" +%s)\nnowEpoch=$(date +%s)\ndays=$(( (endEpoch - nowEpoch) / 86400 ))\necho \"CERT_DAYS_REMAINING=$days\" >> $WORKFLOW_ENV\necho \"CERT_EXPIRES=$end\" >> $WORKFLOW_ENV\necho \"$host:$port expires in $days days ($end)\"\nif [ \"$days\" -lt \"$min\" ]; then\n echo \"fewer than $min days remaining\"\n exit 1\nfi",
|
||||
"declared_outputs": [
|
||||
"CERT_DAYS_REMAINING",
|
||||
"CERT_EXPIRES"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "host",
|
||||
"default": "",
|
||||
"description": "hostname to check"
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"default": "443",
|
||||
"description": "TLS port"
|
||||
},
|
||||
{
|
||||
"name": "minDays",
|
||||
"default": "14",
|
||||
"description": "fail below this many days remaining"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Download File (Windows)",
|
||||
"description": "Download a file over HTTP to a local path.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$url = $env:url\n$dest = if ($env:destination) { $env:destination } else { Join-Path $env:TEMP ([System.IO.Path]::GetFileName($url)) }\nWrite-Output \"downloading $url\"\ntry {\n # -UseBasicParsing keeps this working on Server Core, where the IE\n # engine Invoke-WebRequest otherwise reaches for is not installed.\n Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing\n} catch {\n Write-Output \"failed to download: $_\"\n exit 1\n}\nWrite-Output \"saved to $dest\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"FILE_PATH=$dest\"",
|
||||
"declared_outputs": [
|
||||
"FILE_PATH"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"default": "",
|
||||
"description": "URL to download"
|
||||
},
|
||||
{
|
||||
"name": "destination",
|
||||
"default": "",
|
||||
"description": "where to save it; defaults to a file in TEMP"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Reboot Windows Server",
|
||||
"description": "Schedule a reboot a minute out, so the step reports success before the machine goes down.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$delay = if ($env:delaySeconds) { [int]$env:delaySeconds } else { 60 }\nWrite-Output \"rebooting in $delay second(s)\"\n& shutdown.exe /r /t $delay /c \"Reboot requested by Vantage\"\nif ($LASTEXITCODE -ne 0) {\n Write-Output \"failed to schedule a reboot\"\n exit 1\n}",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "delaySeconds",
|
||||
"default": "60",
|
||||
"description": "seconds to wait before rebooting"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Restart Windows Service",
|
||||
"description": "Restart a Windows service and fail if it does not come back up.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$name = $env:serviceName\nWrite-Output \"restarting service $name\"\ntry {\n Restart-Service -Name $name -Force\n} catch {\n Write-Output \"failed to restart ${name}: $_\"\n exit 1\n}\n$svc = Get-Service -Name $name\nif ($svc.Status -ne \"Running\") {\n Write-Output \"$name is $($svc.Status), not Running\"\n exit 1\n}\nWrite-Output \"$name is running\"",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "Windows service name to restart"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Windows Disk Report",
|
||||
"description": "Report free space on a drive and fail past a usage threshold.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$letter = if ($env:driveLetter) { $env:driveLetter } else { \"C\" }\n$limit = if ($env:maxPercent) { [int]$env:maxPercent } else { 90 }\n$d = Get-PSDrive -Name $letter -ErrorAction SilentlyContinue\nif ($null -eq $d) {\n Write-Output \"drive $letter not found\"\n exit 1\n}\n$total = $d.Used + $d.Free\n$pct = [math]::Round(($d.Used / $total) * 100)\n$freeGb = [math]::Round($d.Free / 1GB, 1)\nWrite-Output \"${letter}: is $pct% full, $freeGb GB free\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"DISK_USED_PERCENT=$pct\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"DISK_FREE_GB=$freeGb\"\nif ($pct -ge $limit) {\n Write-Output \"at or over the $limit% limit\"\n exit 1\n}",
|
||||
"declared_outputs": [
|
||||
"DISK_USED_PERCENT",
|
||||
"DISK_FREE_GB"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "driveLetter",
|
||||
"default": "C",
|
||||
"description": "drive letter, without a colon"
|
||||
},
|
||||
{
|
||||
"name": "maxPercent",
|
||||
"default": "90",
|
||||
"description": "fail at or above this percentage"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Windows Service Status",
|
||||
"description": "Report a Windows service's status and start type. Does not fail on a stopped service.",
|
||||
"interpreter": "powershell",
|
||||
"script": "$ErrorActionPreference = \"Stop\"\n$name = $env:serviceName\n$svc = Get-Service -Name $name -ErrorAction SilentlyContinue\nif ($null -eq $svc) {\n Write-Output \"$name is not installed\"\n Add-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_STATE=missing\"\n Add-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_START_TYPE=none\"\n exit 0\n}\nWrite-Output \"${name}: $($svc.Status), start type $($svc.StartType)\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_STATE=$($svc.Status)\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_START_TYPE=$($svc.StartType)\"",
|
||||
"declared_outputs": [
|
||||
"SERVICE_STATE",
|
||||
"SERVICE_START_TYPE"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": "Windows service name to inspect"
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[Unit]
|
||||
Description=Vantage Agent
|
||||
Documentation=https://github.com/your-org/vantage
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=vantage-agent
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=false
|
||||
ProtectHome=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: vantage
|
||||
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
|
||||
type: application
|
||||
version: 1.0.7
|
||||
appVersion: "1.0.7"
|
||||
@@ -0,0 +1,72 @@
|
||||
Vantage has been deployed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}".
|
||||
|
||||
Services created:
|
||||
{{- if .Values.redis.enabled }}
|
||||
- {{ .Release.Name }}-redis (ClusterIP {{ .Values.redis.port }})
|
||||
{{- else }}
|
||||
- Redis: not deployed, using external {{ .Values.redis.addr }}
|
||||
{{- end }}
|
||||
{{- if .Values.mongo.enabled }}
|
||||
- {{ .Release.Name }}-mongo (ClusterIP {{ .Values.mongo.port }})
|
||||
{{- else }}
|
||||
- MongoDB: not deployed, using the external server.env.mongoUri
|
||||
{{- end }}
|
||||
- {{ .Release.Name }}-guacd ({{ .Values.guacd.service.type }} {{ .Values.guacd.service.port }})
|
||||
- {{ .Release.Name }}-server ({{ .Values.server.service.type }} http:{{ .Values.server.service.httpPort }} grpc:{{ .Values.server.service.grpcPort }})
|
||||
- {{ .Release.Name }}-web ({{ .Values.web.service.type }} {{ .Values.web.service.port }})
|
||||
|
||||
Scaling (server.replicaCount / web.replicaCount):
|
||||
- Both scale. Pin the image tags first — replicas on different builds serve
|
||||
mismatched web asset hashes, and mixed server versions share one bus.
|
||||
- server replicas route agent commands, step results and console relays to
|
||||
each other over Redis, so every replica must use the SAME Redis. Workflow
|
||||
logs are in MongoDB, not on a volume.
|
||||
- Background work (monitor scheduler, Free reaper, log and audit retention,
|
||||
the offline sweep) runs on one replica at a time under a Redis leader lock.
|
||||
- server.persistence must be off to scale past one replica on a ReadWriteOnce
|
||||
volume. Nothing writes to it any more.
|
||||
{{- if gt (int .Values.server.replicaCount) 1 }}
|
||||
- Console relays are reached by pod IP; guacd must be able to dial pod IPs
|
||||
directly (it can, inside the cluster network).
|
||||
{{- end }}
|
||||
{{- if .Values.server.migrationJob.enabled }}
|
||||
- Migrations run in the {{ .Release.Name }}-migrate Job before each upgrade;
|
||||
the pods skip them. Its logs are kept: kubectl logs job/{{ .Release.Name }}-migrate
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
Ingress (Traefik):
|
||||
- Browsers: https://{{ .Values.ingress.web.host }}
|
||||
{{- range .Values.ingress.web.extraHosts }}
|
||||
https://{{ . }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.api.enabled }}
|
||||
{{ join ", " .Values.ingress.api.paths }} go straight to the server; everything else to web.
|
||||
{{- else }}
|
||||
Everything goes to web, which proxies /api and /auth onward. Set
|
||||
ingress.api.enabled=true to route them at the edge instead.
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.grpc.enabled }}
|
||||
- Agents: {{ .Values.ingress.grpc.host }} (gRPC, h2c behind TLS)
|
||||
Agents dial server.env.grpcHost, currently {{ tpl .Values.server.env.grpcHost . }}.
|
||||
Point DNS for both hostnames at the Traefik load balancer.
|
||||
{{- if not .Values.ingress.tls.enabled }}
|
||||
- WARNING: ingress.tls.enabled is false. Agent tokens and session cookies
|
||||
would cross the network in clear.
|
||||
{{- else if and (not .Values.ingress.tls.certResolver) (not .Values.ingress.tls.secretName) }}
|
||||
- WARNING: TLS is on but neither ingress.tls.secretName nor
|
||||
ingress.tls.certResolver is set, so Traefik will serve its self-signed
|
||||
default certificate, which no agent and no browser will trust.
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
By default the server/web/guacd services are ClusterIP only (no host port publishing,
|
||||
unlike the original docker-compose file). To expose them externally, set
|
||||
server.service.type / web.service.type / guacd.service.type to NodePort or LoadBalancer,
|
||||
or add an Ingress on top of the -web and -server services.
|
||||
|
||||
Quick access via port-forward, e.g.:
|
||||
kubectl port-forward svc/{{ .Release.Name }}-web {{ .Values.web.service.port }}:{{ .Values.web.service.port }}
|
||||
kubectl port-forward svc/{{ .Release.Name }}-server {{ .Values.server.service.httpPort }}:{{ .Values.server.service.httpPort }}
|
||||
@@ -0,0 +1,85 @@
|
||||
{{/*
|
||||
Common name helpers
|
||||
*/}}
|
||||
{{- define "vantage.fullname" -}}
|
||||
{{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "vantage.labels" -}}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
vantage.server.env renders the server container's environment.
|
||||
|
||||
It lives here because two workloads need it identically: the Deployment and the
|
||||
pre-upgrade migration Job. A Job that connected to a different database than the
|
||||
pods it migrates for would be worse than no Job at all, so there is one copy and
|
||||
both read it.
|
||||
*/}}
|
||||
{{- define "vantage.server.env" -}}
|
||||
- name: MONGO_URI
|
||||
{{- $mongoUri := tpl .Values.server.env.mongoUri . }}
|
||||
{{- if and (not .Values.mongo.enabled) (contains (printf "%s-mongo" .Release.Name) $mongoUri) }}
|
||||
{{- fail "mongo.enabled is false, so server.env.mongoUri must point at an external MongoDB rather than the in-chart one" }}
|
||||
{{- end }}
|
||||
value: {{ $mongoUri | quote }}
|
||||
- name: REDIS_ADDR
|
||||
{{- if .Values.redis.enabled }}
|
||||
value: "{{ .Release.Name }}-redis:{{ .Values.redis.port }}"
|
||||
{{- else }}
|
||||
{{- if not .Values.redis.addr }}
|
||||
{{- fail "redis.enabled is false, so redis.addr must be set to an external Redis host:port" }}
|
||||
{{- end }}
|
||||
value: {{ .Values.redis.addr | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.redis.auth.existingSecret }}
|
||||
- name: REDIS_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.redis.auth.existingSecret }}
|
||||
key: {{ .Values.redis.auth.usernameKey }}
|
||||
optional: true
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.redis.auth.existingSecret }}
|
||||
key: {{ .Values.redis.auth.passwordKey }}
|
||||
{{- else }}
|
||||
{{- if .Values.redis.auth.username }}
|
||||
- name: REDIS_USERNAME
|
||||
value: {{ .Values.redis.auth.username | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.redis.auth.password }}
|
||||
- name: REDIS_PASSWORD
|
||||
value: {{ .Values.redis.auth.password | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
- name: GRPC_HOST
|
||||
value: {{ .Values.server.env.grpcHost | quote }}
|
||||
- name: GRPC_PORT
|
||||
value: {{ .Values.server.service.grpcPort | quote }}
|
||||
- name: HTTP_PORT
|
||||
value: {{ .Values.server.service.httpPort | quote }}
|
||||
- name: KEY_ENCRYPTION_KEY
|
||||
value: {{ .Values.server.env.keyEncryptionKey | quote }}
|
||||
- name: GUACD_ADDR
|
||||
value: "{{ .Release.Name }}-guacd:{{ .Values.guacd.service.port }}"
|
||||
- name: APP_ROOT_LABEL
|
||||
value: {{ .Values.server.env.appRootLabel | quote }}
|
||||
- name: PROXY_ADVERTISE_HOST
|
||||
value: {{ .Values.server.env.proxyAdvertiseHost | quote }}
|
||||
- name: PROXY_LISTEN_HOST
|
||||
value: {{ .Values.server.env.proxyListenHost | quote }}
|
||||
{{- if eq .Values.server.env.deploymentType "cloud" }}
|
||||
- name: VANTAGE_DEPLOYMENT
|
||||
value: "cloud"
|
||||
- name: FREE_INSTANCE_REAP_AFTER
|
||||
value: {{ .Values.server.env.freeInstanceReapAfter | quote }}
|
||||
{{- end }}
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,47 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-guacd
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: guacd
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: guacd
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: guacd
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: guacd
|
||||
image: "{{ .Values.guacd.image.repository }}:{{ .Values.guacd.image.tag }}"
|
||||
ports:
|
||||
- containerPort: 4822
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-guacd
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: guacd
|
||||
spec:
|
||||
type: {{ .Values.guacd.service.type }}
|
||||
{{- if eq .Values.guacd.service.type "ClusterIP" }}
|
||||
clusterIP: None
|
||||
{{- end }}
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: guacd
|
||||
ports:
|
||||
- port: {{ .Values.guacd.service.port }}
|
||||
targetPort: 4822
|
||||
@@ -0,0 +1,173 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{/*
|
||||
Two hostnames, because the two audiences arrive over different protocols.
|
||||
|
||||
Browsers reach the web host. What answers there depends on the path: with
|
||||
ingress.api.enabled, /api and /auth go straight to the server and everything
|
||||
else goes to `web`. Without it, everything goes to `web`, which proxies those
|
||||
prefixes onward itself (web/next.config.ts).
|
||||
|
||||
Both work. Routing at the edge is one hop shorter and is what the Nginx Proxy
|
||||
Manager deployment in front of the Docker install already does, so leaving it
|
||||
off changes the shape of the request path between the two deployments. It is
|
||||
still off by default, because turning it on where `web` is the only thing with
|
||||
a public certificate would strand /api behind a route nobody can reach.
|
||||
|
||||
The web host is normally a wildcard — `*.vantage.example.com` — because that is
|
||||
the per-tenant instance namespace; APP_ROOT_LABEL resolves the instance from the
|
||||
label. Kubernetes wildcard hosts match exactly one label, so this does not match
|
||||
the apex, and on the deployment this chart was written for it must not: the apex
|
||||
is the marketing site, a separate application (see site/ and
|
||||
docker-compose.site.yml). extraHosts exists for a genuine second name, not for
|
||||
reclaiming the apex.
|
||||
|
||||
Agents reach the server's gRPC port, which is plain h2c — the server holds no
|
||||
certificates of its own, TLS has always been terminated by whatever sits in
|
||||
front. Traefik will not speak h2c to a backend unless told to, and it is told
|
||||
per Service, which is why the gRPC route gets a Service of its own below rather
|
||||
than reusing the two-port one. Annotating the shared Service would force h2c on
|
||||
its HTTP port too.
|
||||
*/}}
|
||||
{{- $tls := .Values.ingress.tls }}
|
||||
{{- $webHost := required "ingress.enabled requires ingress.web.host" .Values.ingress.web.host }}
|
||||
{{- $hosts := prepend .Values.ingress.web.extraHosts $webHost }}
|
||||
{{- $apiPaths := .Values.ingress.api.paths }}
|
||||
{{- if and .Values.ingress.api.enabled (not $apiPaths) }}
|
||||
{{- fail "ingress.api.enabled requires at least one path in ingress.api.paths" }}
|
||||
{{- end }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: {{ .Values.ingress.entrypoint | quote }}
|
||||
{{- if and $tls.enabled $tls.certResolver }}
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.tls.certresolver: {{ $tls.certResolver | quote }}
|
||||
{{- else if $tls.enabled }}
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if and $tls.enabled $tls.secretName }}
|
||||
tls:
|
||||
- hosts:
|
||||
{{- range $hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ $tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range $host := $hosts }}
|
||||
- host: {{ $host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- /*
|
||||
The API paths come first and, more importantly, are longer. Traefik
|
||||
derives router priority from rule length, so Host(x) &&
|
||||
PathPrefix(/api) outranks Host(x) && PathPrefix(/) without anyone
|
||||
having to assign priorities by hand. Order within the list is for
|
||||
the reader; the length is what decides.
|
||||
*/}}
|
||||
{{- if $.Values.ingress.api.enabled }}
|
||||
{{- range $apiPaths }}
|
||||
- path: {{ . | quote }}
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ $.Release.Name }}-server
|
||||
port:
|
||||
number: {{ $.Values.server.service.httpPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ $.Release.Name }}-web
|
||||
port:
|
||||
number: {{ $.Values.web.service.port }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.grpc.enabled }}
|
||||
{{- $grpcHost := required "ingress.grpc.enabled requires ingress.grpc.host" .Values.ingress.grpc.host }}
|
||||
{{/*
|
||||
GRPC_HOST is what an agent is told to dial, and it is baked into every install
|
||||
one-liner. Left pointing at the in-cluster Service while agents are expected to
|
||||
arrive through the ingress, every install would succeed and every agent would
|
||||
fail to connect — with nothing in the control plane saying why.
|
||||
*/}}
|
||||
{{- $grpcEnv := tpl .Values.server.env.grpcHost . }}
|
||||
{{- if contains (printf "%s-server" .Release.Name) $grpcEnv }}
|
||||
{{- fail (printf "ingress.grpc.enabled routes agents through %s, but server.env.grpcHost is still the in-cluster address %q. Agents dial the value of grpcHost, so set it to the public gRPC address (for example %q)." $grpcHost $grpcEnv (printf "%s:443" $grpcHost)) }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server-grpc
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
annotations:
|
||||
# The server speaks h2c: it terminates no TLS itself. Without this Traefik
|
||||
# dials the backend as HTTP/1.1 and every agent handshake fails.
|
||||
traefik.ingress.kubernetes.io/service.serversscheme: h2c
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
ports:
|
||||
- name: grpc
|
||||
port: {{ .Values.server.service.grpcPort }}
|
||||
targetPort: {{ .Values.server.service.grpcPort }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-grpc
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: {{ .Values.ingress.entrypoint | quote }}
|
||||
{{- if and $tls.enabled $tls.certResolver }}
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.tls.certresolver: {{ $tls.certResolver | quote }}
|
||||
{{- else if $tls.enabled }}
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.grpc.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if and $tls.enabled $tls.grpcSecretName }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ $grpcHost | quote }}
|
||||
secretName: {{ $tls.grpcSecretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ $grpcHost | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-server-grpc
|
||||
port:
|
||||
number: {{ .Values.server.service.grpcPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,68 @@
|
||||
{{- if .Values.server.migrationJob.enabled }}
|
||||
{{/*
|
||||
Schema setup, lifted out of the serving pods.
|
||||
|
||||
Every server process used to run migrations, index builders and default-step
|
||||
seeding at boot. With one replica that is fine. With two it is not: 0004 renames
|
||||
the orgs collection to instances, and a sibling reading it mid-rename is a
|
||||
corruption, not a retry.
|
||||
|
||||
A Helm hook Job runs it once, before any pod of the new version starts. The
|
||||
Deployment then sets VANTAGE_SKIP_MIGRATIONS, which is what makes the Job's
|
||||
existence load-bearing rather than decorative — if you disable the Job, the
|
||||
pods go back to migrating themselves and you must go back to one replica.
|
||||
|
||||
hook-weight orders this after the dependency waits; before-hook-creation deletes
|
||||
the previous Job so a repeat upgrade is not blocked by an immutable object. The
|
||||
Job is deliberately NOT deleted on success: its logs are the record of what the
|
||||
upgrade did to the database.
|
||||
*/}}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-migrate
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: migrate
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "0"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation
|
||||
spec:
|
||||
backoffLimit: {{ .Values.server.migrationJob.backoffLimit }}
|
||||
# A migration that has not finished in this long is stuck, and a stuck
|
||||
# migration should fail the upgrade rather than hold it open forever.
|
||||
activeDeadlineSeconds: {{ .Values.server.migrationJob.activeDeadlineSeconds }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: migrate
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.mongo.enabled }}
|
||||
# Only Mongo. The Job never opens Redis, and waiting on a Redis this
|
||||
# chart may not even deploy would block an upgrade for no reason.
|
||||
initContainers:
|
||||
- name: wait-for-mongo
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-mongo {{ .Values.mongo.port }}; do
|
||||
echo "waiting for mongo..."; sleep 2;
|
||||
done
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migrate
|
||||
image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag }}"
|
||||
env:
|
||||
{{- include "vantage.server.env" . | nindent 12 }}
|
||||
- name: VANTAGE_MIGRATE_ONLY
|
||||
value: "true"
|
||||
{{- end }}
|
||||
@@ -0,0 +1,92 @@
|
||||
{{- if .Values.mongo.enabled }}
|
||||
{{- if .Values.mongo.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mongo-data
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.mongo.persistence.accessMode }}
|
||||
{{- if .Values.mongo.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.mongo.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.mongo.persistence.size }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mongo
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mongo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: mongo
|
||||
image: "{{ .Values.mongo.image.repository }}:{{ .Values.mongo.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.mongo.port }}
|
||||
volumeMounts:
|
||||
- name: mongo-data
|
||||
mountPath: /data/db
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
volumes:
|
||||
- name: mongo-data
|
||||
{{- if .Values.mongo.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-mongo-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mongo
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mongo
|
||||
ports:
|
||||
- port: {{ .Values.mongo.port }}
|
||||
targetPort: {{ .Values.mongo.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,92 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
{{- if .Values.redis.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-redis-data
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.redis.persistence.accessMode }}
|
||||
{{- if .Values.redis.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.redis.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.redis.persistence.size }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-redis
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: redis
|
||||
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.redis.port }}
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
volumes:
|
||||
- name: redis-data
|
||||
{{- if .Values.redis.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-redis-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-redis
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
ports:
|
||||
- port: {{ .Values.redis.port }}
|
||||
targetPort: {{ .Values.redis.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,181 @@
|
||||
{{- if and .Values.server.persistence.enabled (not .Values.server.persistence.useHostPath) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server-data
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.server.persistence.accessMode }}
|
||||
{{- if .Values.server.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.server.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.server.persistence.size }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
{{- $replicas := int .Values.server.replicaCount }}
|
||||
replicas: {{ $replicas }}
|
||||
{{- if and .Values.server.persistence.enabled (eq .Values.server.persistence.accessMode "ReadWriteOnce") }}
|
||||
# A ReadWriteOnce volume cannot be mounted by a second pod at all, and cannot
|
||||
# be handed to a new pod while the old one still holds it. Persistence is off
|
||||
# by default now that nothing writes to it; if it is on, replicas are capped
|
||||
# at one and updates go through Recreate.
|
||||
{{- if gt $replicas 1 }}
|
||||
{{- fail "server.persistence.enabled with a ReadWriteOnce volume cannot be combined with server.replicaCount > 1. Nothing in the server writes to that volume any more (workflow logs live in MongoDB); set server.persistence.enabled=false, or use a ReadWriteMany accessMode if you are keeping it for another reason." }}
|
||||
{{- end }}
|
||||
strategy:
|
||||
type: Recreate
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
# The server stops gRPC before draining HTTP, so that every CommandStream
|
||||
# handler returns and releases its agent's presence claim. A claim left
|
||||
# behind outlives the pod for its 30s TTL, and during that window other
|
||||
# replicas dispatch commands to a process that has exited — surfacing to
|
||||
# the operator as "agent offline" on an agent that is perfectly healthy.
|
||||
# 10s for gRPC plus 10s for the HTTP drain, with headroom.
|
||||
terminationGracePeriodSeconds: {{ .Values.server.terminationGracePeriodSeconds }}
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.redis.enabled .Values.mongo.enabled }}
|
||||
# Wait for the dependencies this chart deploys to be reachable,
|
||||
# approximating compose's `depends_on: condition: service_healthy`. An
|
||||
# external Redis or Mongo is assumed to be up already — waiting on one
|
||||
# would only turn someone else's outage into a stuck pod.
|
||||
initContainers:
|
||||
{{- if .Values.redis.enabled }}
|
||||
- name: wait-for-redis
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-redis {{ .Values.redis.port }}; do
|
||||
echo "waiting for redis..."; sleep 2;
|
||||
done
|
||||
{{- end }}
|
||||
{{- if .Values.mongo.enabled }}
|
||||
- name: wait-for-mongo
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-mongo {{ .Values.mongo.port }}; do
|
||||
echo "waiting for mongo..."; sleep 2;
|
||||
done
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: server
|
||||
image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.server.service.httpPort }}
|
||||
- containerPort: {{ .Values.server.service.grpcPort }}
|
||||
env:
|
||||
{{- include "vantage.server.env" . | nindent 12 }}
|
||||
{{- if .Values.server.migrationJob.enabled }}
|
||||
# Schema setup ran in the pre-upgrade Job. Pods that repeated it
|
||||
# would race each other, and the rename migration is not a race
|
||||
# that tolerates a loser.
|
||||
- name: VANTAGE_SKIP_MIGRATIONS
|
||||
value: "true"
|
||||
{{- end }}
|
||||
# Liveness never touches Mongo or Redis: restarting every pod cannot
|
||||
# fix a database outage, and each restart drops every agent command
|
||||
# stream and console session it was carrying. Readiness does check
|
||||
# both, so a pod that cannot serve leaves the Service and stays up.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
periodSeconds: 5
|
||||
# Generous: without the migration Job this pod runs every migration
|
||||
# before it listens, and the rename has a ten-minute budget.
|
||||
failureThreshold: 150
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
{{- if .Values.server.persistence.enabled }}
|
||||
# Nothing in the server writes here any more — workflow logs moved to
|
||||
# MongoDB so that every replica can read and write them. The mount
|
||||
# remains only so an operator upgrading from a file-log release can
|
||||
# still reach the old files before turning persistence off.
|
||||
volumeMounts:
|
||||
- name: server-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: server-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-server-data
|
||||
{{- end }}
|
||||
---
|
||||
{{- if gt (int .Values.server.replicaCount) 1 }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
# Agents reconnect on their own, but a drain that took every replica at once
|
||||
# would disconnect every agent in the fleet simultaneously and stall every
|
||||
# workflow run in flight.
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
type: {{ .Values.server.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
targetPort: {{ .Values.server.service.httpPort }}
|
||||
- name: grpc
|
||||
port: {{ .Values.server.service.grpcPort }}
|
||||
targetPort: {{ .Values.server.service.grpcPort }}
|
||||
@@ -0,0 +1,80 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
# web holds no per-process state: sessions live in Redis and every request is
|
||||
# proxied to the server. It is the one component here that scales freely.
|
||||
replicas: {{ .Values.web.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
initContainers:
|
||||
- name: wait-for-server
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-server {{ .Values.server.service.httpPort }}; do
|
||||
echo "waiting for server..."; sleep 2;
|
||||
done
|
||||
containers:
|
||||
- name: web
|
||||
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.web.service.port }}
|
||||
env:
|
||||
- name: API_URL
|
||||
value: {{ tpl .Values.web.env.apiUrl . | quote }}
|
||||
# /healthz is served by this Next process; /api is rewritten to the
|
||||
# server, so a probe there would report the backend's health and keep
|
||||
# passing while this pod was wedged.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.web.service.port }}
|
||||
periodSeconds: 3
|
||||
failureThreshold: 20
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.web.service.port }}
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.web.service.port }}
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
type: {{ .Values.web.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
ports:
|
||||
- port: {{ .Values.web.service.port }}
|
||||
targetPort: {{ .Values.web.service.port }}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Default values for the vantage chart.
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
addr: ""
|
||||
image:
|
||||
repository: redis
|
||||
tag: "8"
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
port: 6379
|
||||
auth:
|
||||
username: ""
|
||||
password: ""
|
||||
existingSecret: ""
|
||||
usernameKey: username
|
||||
passwordKey: password
|
||||
|
||||
mongo:
|
||||
enabled: true
|
||||
image:
|
||||
repository: mongo
|
||||
tag: "7"
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
port: 27017
|
||||
|
||||
guacd:
|
||||
image:
|
||||
repository: docker.io/guacamole/guacd
|
||||
tag: "1.6.0"
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 4822
|
||||
|
||||
server:
|
||||
replicaCount: 1
|
||||
# Must exceed the server's own stop sequence (10s gRPC GracefulStop + 10s
|
||||
# HTTP drain) or the kubelet SIGKILLs mid-shutdown, which is exactly the
|
||||
# abrupt exit that leaves agent presence claims stranded in Redis.
|
||||
terminationGracePeriodSeconds: 30
|
||||
migrationJob:
|
||||
enabled: true
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 900
|
||||
image:
|
||||
repository: gitea.hostxtra.co.uk/mrhid6/vantage/server
|
||||
tag: latest
|
||||
service:
|
||||
type: ClusterIP
|
||||
httpPort: 8080
|
||||
grpcPort: 9090
|
||||
env:
|
||||
mongoUri: "mongodb://{{ .Release.Name }}-mongo:27017/vantage"
|
||||
grpcHost: "{{ .Release.Name }}-server:9090"
|
||||
keyEncryptionKey: ""
|
||||
appRootLabel: vantage
|
||||
proxyAdvertiseHost: "{{ .Release.Name }}-server"
|
||||
proxyListenHost: "0.0.0.0"
|
||||
persistence:
|
||||
enabled: false
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
hostPath: /data
|
||||
|
||||
web:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: gitea.hostxtra.co.uk/mrhid6/vantage/web
|
||||
tag: latest
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
env:
|
||||
apiUrl: "http://{{ .Release.Name }}-server:8080"
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: traefik
|
||||
entrypoint: websecure
|
||||
annotations: {}
|
||||
web:
|
||||
host: ""
|
||||
extraHosts: []
|
||||
api:
|
||||
enabled: false
|
||||
paths:
|
||||
- /api
|
||||
- /auth
|
||||
- /update
|
||||
- /install
|
||||
- /update.ps1
|
||||
- /install.ps1
|
||||
grpc:
|
||||
enabled: true
|
||||
host: ""
|
||||
annotations: {}
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: ""
|
||||
grpcSecretName: ""
|
||||
certResolver: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
@@ -5,10 +5,7 @@
|
||||
|
||||
# host:port agents dial for gRPC. No default; boot fails without it.
|
||||
# Must be reachable from managed servers. Use the public host, port 9090.
|
||||
GRPC_HOST=192.168.1.250:9090
|
||||
|
||||
# Gitea host used to build agent install scripts and download URLs.
|
||||
GITEA_HOST=gitea.hostxtra.co.uk
|
||||
GRPC_HOST=vantage.yourdomain.com:9090
|
||||
|
||||
# 64-char hex (32 bytes) for AES-256-GCM. Required for private keys,
|
||||
# secrets, OIDC secrets, RDP/VNC credentials.
|
||||
@@ -21,4 +18,3 @@ KEY_ENCRYPTION_KEY=
|
||||
MONGO_URI=mongodb://mongo:27017/vantage
|
||||
|
||||
# Where workflow run logs are written inside the server container.
|
||||
# VANTAGE_WORKFLOW_LOG_DIR=/data/workflow-logs
|
||||
@@ -55,4 +55,13 @@ services:
|
||||
- 3004:3000
|
||||
depends_on:
|
||||
- admin
|
||||
# Static docs, served by nginx at vantage.hostxtra.co.uk/docs through its own
|
||||
# proxy location. That location must sort ABOVE the catch-all forwarding to
|
||||
# site:3003, or Next serves its own 404 for /docs. The container serves from
|
||||
# /usr/share/nginx/html/docs because the proxy forwards the full path.
|
||||
docsite:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/docsite:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3005:80
|
||||
networks: {}
|
||||
@@ -41,14 +41,12 @@ services:
|
||||
environment:
|
||||
MONGO_URI: ${MONGO_URI:-mongodb://mongo:27017/vantage}
|
||||
REDIS_ADDR: redis:6379
|
||||
GITEA_HOST: ${GITEA_HOST}
|
||||
GRPC_HOST: ${GRPC_HOST}
|
||||
GRPC_PORT: "9090"
|
||||
HTTP_PORT: "8080"
|
||||
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
|
||||
VANTAGE_WORKFLOW_LOG_DIR: ${VANTAGE_WORKFLOW_LOG_DIR:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
APP_ROOT_LABEL: vantage
|
||||
PROXY_ADVERTISE_HOST: server
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -61,6 +59,8 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
API_URL: ${API_URL:-http://server:8080}
|
||||
depends_on:
|
||||
- server
|
||||
volumes:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user