Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb32d367c8 | ||
|
|
a134f8443a | ||
|
|
ee1f9f3b32 | ||
|
|
5326639918 | ||
|
|
3bf80a117b | ||
|
|
189a8fa963 | ||
|
|
7b13944c24 | ||
|
|
e7384d334a | ||
|
|
be299845ca | ||
|
|
d83061786c | ||
|
|
b28a2263bb | ||
|
|
b6f45390c4 | ||
|
|
2e4bc687d4 | ||
|
|
d328d3aaca | ||
|
|
89cc524f3f | ||
|
|
f0f2600bed | ||
|
|
884fd189fb | ||
|
|
e08cc2f928 | ||
|
|
3ce963b0cd | ||
|
|
461a79277d | ||
|
|
d9f7fa6993 | ||
|
|
060fa64339 | ||
|
|
30d83c4c32 | ||
|
|
a918b1bdc1 | ||
|
|
66b1a041ba | ||
|
|
d55ed2b19a | ||
|
|
8135c8d781 | ||
|
|
577b060b8a | ||
|
|
1028a2e43a | ||
|
|
eba93a812e | ||
|
|
83b7256b60 | ||
|
|
9d17f539b5 | ||
|
|
28f746c7e2 | ||
|
|
9d218cb19f | ||
|
|
f9ef9c4929 | ||
|
|
049e005873 | ||
|
|
c440b59b93 | ||
|
|
3e4865884c | ||
|
|
270d55e6a6 | ||
|
|
7a0a1953f6 | ||
|
|
3e4ccc9720 | ||
|
|
e5947489e4 | ||
|
|
0a7a10aeed | ||
|
|
28b813ba64 | ||
|
|
68160dc681 | ||
|
|
32e7420d89 | ||
|
|
7e1d67dba4 | ||
|
|
da1dc90ac5 | ||
|
|
72c9492223 | ||
|
|
fa67d839cd | ||
|
|
1452928b75 | ||
|
|
bf10023f35 | ||
|
|
f4f41e400b | ||
|
|
3abbdc41d6 | ||
|
|
6ba54f690c | ||
|
|
a3c6b2a305 | ||
|
|
21a2d077d8 | ||
|
|
6263c7e16f | ||
|
|
161835802d | ||
|
|
6f998ff506 | ||
|
|
d192589790 | ||
|
|
21c2bb2646 | ||
|
|
9c0bbd13dd | ||
|
|
1c15961309 | ||
|
|
383b763a66 | ||
|
|
3e99a9df33 | ||
|
|
f1b6f90345 | ||
|
|
2a660697c5 | ||
|
|
0c08dda635 | ||
|
|
22b99ff895 | ||
|
|
2fab784ba7 | ||
|
|
83cdf92575 | ||
|
|
aa1c8e4aa1 | ||
|
|
ac61015cc0 | ||
|
|
a0fbf5b9ba | ||
|
|
ddf0814803 | ||
|
|
2bc15648b7 | ||
|
|
7f348b7b2b | ||
|
|
a71fd9a9f4 | ||
|
|
36848a519a | ||
|
|
ea6d0b969c | ||
|
|
4ba9983cf8 | ||
|
|
8caaa4540f | ||
|
|
42f0cb67cb | ||
|
|
1dfb3cc28c | ||
|
|
b51e87477e | ||
|
|
05c0ad43d2 | ||
|
|
4a07af049c | ||
|
|
057c193e26 | ||
|
|
38a731f269 | ||
|
|
06d69590fc | ||
|
|
6adee810dc |
@@ -9,10 +9,21 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-docker
|
||||
container: node:26
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# vantage-shared is a private module, so the Go builds below cannot
|
||||
# resolve it without a credential.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -57,10 +68,20 @@ jobs:
|
||||
msi:
|
||||
needs: build
|
||||
runs-on: windows-2022
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Same private-module credential as the build job, in the file
|
||||
# Windows Go looks for: _netrc in the profile directory, not .netrc.
|
||||
- name: Write the module fetch credential
|
||||
shell: pwsh
|
||||
run: |
|
||||
"machine gitea.hostxtra.co.uk`nlogin ${{ secrets.REGISTRY_USER }}`npassword ${{ secrets.RELEASE_TOKEN }}" |
|
||||
Out-File -Encoding ascii "$env:USERPROFILE\_netrc"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -76,6 +76,13 @@ jobs:
|
||||
fi
|
||||
echo "ok: reaper configured in cloud mode only"
|
||||
|
||||
- name: Render with backups enabled
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set backup.enabled=true \
|
||||
--set backup.image=gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest \
|
||||
--set backup.pvcName=vantage-backups > /dev/null
|
||||
|
||||
- name: Render against external Redis and MongoDB
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
@@ -140,10 +147,21 @@ jobs:
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set server.env.grpcHost=agents.example.com:443
|
||||
refuses "an ingress that leaves /api unrouted" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.enabled=false \
|
||||
--set ingress.api.enabled=false
|
||||
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
|
||||
refuses "backup enabled with no pvcName" \
|
||||
--set backup.enabled=true \
|
||||
--set backup.image=gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest
|
||||
refuses "backup enabled with no image" \
|
||||
--set backup.enabled=true \
|
||||
--set backup.pvcName=vantage-backups
|
||||
|
||||
- name: Read the chart version
|
||||
id: chart
|
||||
|
||||
@@ -71,15 +71,22 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
# The three Go images build from the repo root and COPY
|
||||
# shared/ plus their own directory, so shared/ rebuilds all
|
||||
# three. proto/ is in server's list as insurance: the
|
||||
# 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/|default_steps/|go\.work)'
|
||||
flag sitesvc '^(sitesvc/|shared/|go\.work)'
|
||||
flag admin '^(admin/|shared/|go\.work)'
|
||||
# shared/ is gone from this repository: it is the private
|
||||
# module gitea.hostxtra.co.uk/vantage/vantage-shared, pinned
|
||||
# per service in its own go.mod. A change over there reaches
|
||||
# a service when somebody bumps that pin, which is a commit
|
||||
# under the service's own directory and so already matches
|
||||
# below. There is no longer a directory whose change fans out
|
||||
# to three images, and no longer a way to ship a service
|
||||
# against a shared/ it was never built with.
|
||||
#
|
||||
# proto/ stays in server's list as insurance: the hand-written
|
||||
# pb now lives in vantage-shared, but a proto change made here
|
||||
# in the same push should not depend on the pin being bumped
|
||||
# in that same push to trigger a rebuild.
|
||||
flag server '^(server/|proto/|default_steps/|go\.work)'
|
||||
flag sitesvc '^(sitesvc/|go\.work)'
|
||||
flag admin '^(admin/|go\.work)'
|
||||
|
||||
# The three Next images and the docs site use their own
|
||||
# directory as the build context, so nothing outside it can
|
||||
@@ -89,6 +96,17 @@ jobs:
|
||||
flag adminsite '^adminsite/'
|
||||
flag docsite '^docsite/'
|
||||
|
||||
# vantage-shared is private, so every Go build below needs a
|
||||
# credential for it. A netrc is written once here rather than a
|
||||
# token being passed as a build arg, which would survive in the
|
||||
# builder layer's history.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_TOKEN }}" | \
|
||||
@@ -102,14 +120,23 @@ jobs:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: server/go.sum
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
|
||||
- name: Verify the OpenAPI document is current
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
run: |
|
||||
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
|
||||
cd server
|
||||
swag init --generalInfo cmd/main.go --dir ./,../shared \
|
||||
--output internal/api/docs --outputTypes json --v3.1
|
||||
go mod download gitea.hostxtra.co.uk/vantage/vantage-shared
|
||||
SHARED_DIR="$(go list -m -f '{{.Dir}}' gitea.hostxtra.co.uk/vantage/vantage-shared)"
|
||||
if [ -z "$SHARED_DIR" ] || [ ! -d "$SHARED_DIR" ]; then
|
||||
echo "vantage-shared source not in the module cache: '$SHARED_DIR'" >&2
|
||||
exit 1
|
||||
fi
|
||||
swag init --generalInfo cmd/main.go --dir "./,$SHARED_DIR" --output internal/api/docs --outputTypes json --v3.1
|
||||
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
|
||||
git diff --exit-code internal/api/docs/openapi.json
|
||||
|
||||
@@ -117,8 +144,8 @@ jobs:
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
|
||||
# Root context: server depends on the shared module.
|
||||
docker build -t "$IMAGE" -f server/Dockerfile .
|
||||
docker build --secret id=netrc,src="$HOME/.netrc" \
|
||||
-t "$IMAGE" -f server/Dockerfile .
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push web image
|
||||
@@ -147,16 +174,16 @@ jobs:
|
||||
if: steps.changed.outputs.sitesvc == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
|
||||
# Root context: sitesvc depends on the shared module.
|
||||
docker build -t "$IMAGE" -f sitesvc/Dockerfile .
|
||||
docker build --secret id=netrc,src="$HOME/.netrc" \
|
||||
-t "$IMAGE" -f sitesvc/Dockerfile sitesvc/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push admin image
|
||||
if: steps.changed.outputs.admin == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/admin:latest"
|
||||
# Root context: admin depends on the shared module.
|
||||
docker build -t "$IMAGE" -f admin/Dockerfile .
|
||||
docker build --secret id=netrc,src="$HOME/.netrc" \
|
||||
-t "$IMAGE" -f admin/Dockerfile admin/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push adminsite image
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: vantagectl Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "vantagectl/v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-docker
|
||||
container: node:26
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# vantage-shared is a private module, so the Go builds below cannot
|
||||
# resolve it without a credential.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: vantagectl/go.sum
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#vantagectl/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Test
|
||||
working-directory: vantagectl
|
||||
run: go test ./...
|
||||
|
||||
- name: Build
|
||||
working-directory: vantagectl
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
for target in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do
|
||||
goos="${target%/*}"
|
||||
goarch="${target#*/}"
|
||||
out="dist/vantagectl-${goos}-${goarch}"
|
||||
if [ "$goos" = "windows" ]; then out="${out}.exe"; fi
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o "$out" .
|
||||
done
|
||||
|
||||
- name: Checksums
|
||||
working-directory: vantagectl/dist
|
||||
run: sha256sum vantagectl-* > checksums.txt
|
||||
|
||||
- name: Create release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
files: |
|
||||
vantagectl/dist/vantagectl-linux-amd64
|
||||
vantagectl/dist/vantagectl-linux-arm64
|
||||
vantagectl/dist/vantagectl-darwin-arm64
|
||||
vantagectl/dist/vantagectl-windows-amd64.exe
|
||||
vantagectl/dist/checksums.txt
|
||||
|
||||
# The image is built here rather than in server-deploy.yml on every push to
|
||||
# main, because vantagectl is a released tool rather than a running service.
|
||||
# An operator restoring a database should be able to name the version they
|
||||
# ran; ":latest, rebuilt whenever main moved" cannot be named after the
|
||||
# fact. It is a separate job from the binaries because it needs a
|
||||
# docker-capable runner rather than a Go one, and it does not need the
|
||||
# binaries — the image builds from source in its own stage.
|
||||
image:
|
||||
runs-on: ubuntu-docker
|
||||
container: docker:dind
|
||||
steps:
|
||||
- name: Setup
|
||||
run: apk add --update nodejs npm git
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
# v0.1.0 for the binary stamp, 0.1.0 for the image tag: a
|
||||
# leading v is conventional on a git tag and unconventional on
|
||||
# a container tag.
|
||||
VERSION="${GITHUB_REF_NAME#vantagectl/}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "IMAGE_TAG=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
# The image build resolves the private vantage-shared module, so
|
||||
# it needs a credential of its own — this job does not share the
|
||||
# build job's filesystem.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_TOKEN }}" | \
|
||||
docker login ${{ vars.DOCKER_HOST }} \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push image
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
IMAGE_TAG: ${{ steps.version.outputs.IMAGE_TAG }}
|
||||
run: |
|
||||
REPO="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/vantagectl"
|
||||
# VERSION is passed through so `vantagectl --version` inside the
|
||||
# image reports the tag it was built from rather than "dev".
|
||||
# The context is vantagectl/ now that shared/ is an external
|
||||
# module rather than a sibling directory.
|
||||
docker build \
|
||||
--build-arg VERSION="${VERSION}" \
|
||||
--secret id=netrc,src="$HOME/.netrc" \
|
||||
-t "${REPO}:${IMAGE_TAG}" \
|
||||
-t "${REPO}:latest" \
|
||||
-f vantagectl/Dockerfile vantagectl/
|
||||
docker push "${REPO}:${IMAGE_TAG}"
|
||||
docker push "${REPO}:latest"
|
||||
@@ -91,7 +91,7 @@ vantage/
|
||||
│ └── models/ # accounts, instances, licences, plans
|
||||
├── adminsite/ # staff + customer console (vantage-hq)
|
||||
│ ├── app/(customer)/ # overview, instance, link, billing
|
||||
│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit
|
||||
│ ├── app/(staff)/staff/ # operations, accounts, licences, pricing, audit
|
||||
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
|
||||
│ └── lib/ # api client, session guards, formatters
|
||||
├── docsite/ # user documentation (Docusaurus, static)
|
||||
@@ -99,17 +99,47 @@ vantage/
|
||||
│ ├── 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
|
||||
│ ├── models/ # Instance, User, Settings
|
||||
│ └── cmd/lkctl/ # issue and inspect licences by hand
|
||||
├── proto/vantage/v1/vantage.proto
|
||||
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
|
||||
├── deploy/ # docker-compose.yml, agent.service
|
||||
└── .gitea/workflows/ # agent-release.yml, server-deploy.yml
|
||||
```
|
||||
|
||||
**`shared/` is not in this repository.** It is the private module
|
||||
`gitea.hostxtra.co.uk/vantage/vantage-shared`, and it holds `mail/` (the one
|
||||
email system: transport plus templates), `license/` (payload, sign, verify,
|
||||
trusted keys, plans), `models/` (Instance, User, Settings), `provision/`,
|
||||
`backup/`, `cryptobox/`, `indexes/`, `grpc/pb` + `grpc/codec`, and
|
||||
`cmd/lkctl/`. Five modules here depend on it — `server`, `agent`, `admin`,
|
||||
`sitesvc`, `vantagectl` — each pinning a version in its own `go.mod`. It was a
|
||||
directory in this repository until it was extracted with its history; the
|
||||
`replace ../shared` directives and the `./shared` entry in `go.work` are gone
|
||||
with it.
|
||||
|
||||
**A version pin is now the coupling, and that is the point.** While it was a
|
||||
directory, every service in a given commit built against exactly one `shared/`,
|
||||
and a change there rebuilt three images at once whether or not they were ready
|
||||
for it. Now a service moves when somebody bumps its pin, which is a commit under
|
||||
that service's own directory — so the existing per-directory rebuild triggers
|
||||
already cover it, and there is no longer any way to ship a service against a
|
||||
`shared/` it was never built against. The cost is the obvious one: a fix in
|
||||
`vantage-shared` is live nowhere until each consumer's pin is bumped, and
|
||||
nothing in this repository will remind you.
|
||||
|
||||
Every Go build now needs a credential for it — `GOPRIVATE=gitea.hostxtra.co.uk/*`
|
||||
plus a netrc. CI writes one per job from `REGISTRY_USER` + `RELEASE_TOKEN`
|
||||
(**that token needs read access to the `vantage` org, not only `mrhid6`**), and
|
||||
the four Go Dockerfiles take it as a **BuildKit secret** rather than a build
|
||||
arg, because an arg survives in the builder layer's history and this one is a
|
||||
Gitea token. Locally, either a netrc or
|
||||
`git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`.
|
||||
|
||||
**Three build contexts shrank as a result.** `admin`, `sitesvc` and `vantagectl`
|
||||
build from their own directory now; only `server` still builds from the
|
||||
repository root, and only because its runtime stage copies `default_steps/`.
|
||||
One side effect worth knowing: `admin/.dockerignore` was inert while the context
|
||||
was the root, and is live now.
|
||||
|
||||
---
|
||||
|
||||
## Subsystems
|
||||
@@ -315,6 +345,17 @@ a Service cannot address the one pod holding a console listener.
|
||||
|
||||
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`).
|
||||
|
||||
Windows update checking and applying go through the Windows Update COM API
|
||||
(`Microsoft.Update.Session`) rather than the PSWindowsUpdate module, which would
|
||||
need a PowerShell Gallery install on every host and fails on an air-gapped
|
||||
fleet. `CurrentVersion` is empty on Windows and `NewVersion` carries the KB
|
||||
article ID: a Windows update is not a version bump of a named package.
|
||||
|
||||
**The agent never reboots a host.** `ApplyUpdatesCmd` installs and stops there;
|
||||
`inventory.reboot_required` reports that one is owed, set on the static snapshot
|
||||
every 15 minutes. Linux fills it too, from `/var/run/reboot-required` or
|
||||
`dnf needs-restarting -r`.
|
||||
|
||||
### Package inventory and CVE findings
|
||||
|
||||
Agents report their installed packages hourly; the control plane matches them
|
||||
@@ -364,10 +405,24 @@ scheduler off entirely.
|
||||
|
||||
A **workload** is one Docker container or one systemd unit — one word for the
|
||||
page, the collection and the commands, rather than saying "container or
|
||||
service" in every identifier. Linux only, and **not gated by licence**: this
|
||||
reads as core fleet management, so v1 ships everywhere with no `HasFeature`
|
||||
check. If that changes the check belongs at `ReportWorkloads`, gating collection
|
||||
rather than display, exactly as sub-project A does.
|
||||
service" in every identifier.
|
||||
|
||||
On Windows a workload is a Docker container or a Windows **service**, reported
|
||||
under the same `unit` kind and the same `systemd_ok` / `systemd_error` fields —
|
||||
one wire shape, worded per platform in the UI, which is the only layer that
|
||||
knows the host's OS. The platform split lives entirely in the agent, as build
|
||||
tags (`systemd_linux.go` / `services_windows.go` and the matching `control_`
|
||||
and `logs_` pairs); the control plane is OS-blind and needed no changes.
|
||||
Windows collection runs PowerShell through `agent/internal/winexec`. Every
|
||||
script that reports data emits JSON that a build-tag-free parser reads, so
|
||||
those parsers are tested on Linux — the agent module has no Windows CI. The
|
||||
control verbs and `serviceDisplayName` emit no JSON and have no parser; they
|
||||
are exercised only by running the agent on Windows.
|
||||
|
||||
**Not gated by licence**: this reads as core fleet management, so v1 ships
|
||||
everywhere with no `HasFeature` check. If that changes the check belongs at
|
||||
`ReportWorkloads`, gating collection rather than display, exactly as sub-project
|
||||
A does.
|
||||
|
||||
Agents collect on a 60-second ticker and report through `ReportWorkloads` with
|
||||
the **offer-then-send** handshake the package report already uses. The offer is
|
||||
@@ -391,8 +446,9 @@ reads get `WorkloadLogsResult`. `CommandStream` republishes **every**
|
||||
no-op, so this costs nothing and avoids a second result path.
|
||||
|
||||
**The protected set is computed agent-side and enforced agent-side.**
|
||||
`vantage-agent.service`, plus the container ID read from `/proc/self/cgroup`
|
||||
should the agent ever run in a container. As with the console relay hardcoding
|
||||
`vantage-agent.service` on Linux, `VantageAgent` on Windows, plus the container
|
||||
ID read from `/proc/self/cgroup` should the agent ever run in a container. As
|
||||
with the console relay hardcoding
|
||||
`127.0.0.1`, the control plane may name a target but the agent decides what it
|
||||
will do to itself; a server-side denylist alone would be bypassed by the next
|
||||
dispatch path someone adds, and the failure is unrecoverable from the UI. The
|
||||
@@ -425,15 +481,231 @@ is in `ScopedCollections` (which `scopedCollectionsForPurge` derives from). Ther
|
||||
is no history: a workload list is state, not a record.
|
||||
|
||||
**`proto/vantage/v1/vantage.proto` is documentation, not a generator input.**
|
||||
Both `pb` packages are hand-written JSON-tagged structs over a custom codec, and
|
||||
there are two copies — `agent/internal/grpc/pb` and `server/internal/grpc/pb`.
|
||||
A message added to one must be added to the other and to the `.proto`, in the
|
||||
same commit.
|
||||
`shared/grpc/pb` is hand-written JSON-tagged structs over the custom codec in
|
||||
`shared/grpc/codec`, and it is **one** package shared by both sides — a message
|
||||
added to it must be added to the `.proto` in the same commit, but there is no
|
||||
longer a second Go copy to keep in step. There used to be two
|
||||
(`agent/internal/grpc/pb` and `server/internal/grpc/pb`) and they had already
|
||||
drifted: the agent's `UnimplementedVantageServer` was three methods stale and
|
||||
carried no `ReportWorkloads` at all. The agent links the server half as dead
|
||||
code, which the linker drops.
|
||||
|
||||
This makes `agent` the **fifth** consumer of `shared/`, and the wire contract now
|
||||
lives outside this repository entirely: a message added to `vantage-shared` is
|
||||
not a message either side has until its pin is bumped. What that buys is that
|
||||
the mismatch is a compile error rather than two copies that both compiled and
|
||||
disagreed on the wire. What it costs is ordering — a wire change needs a
|
||||
`vantage-shared` release, then a pin bump in `server/` (live at the next push to
|
||||
main) and a pin bump in `agent/` (live at the next `agent/v*` tag). The
|
||||
server-ahead-of-fleet gap existed before too; it is now explicit in two `go.mod`
|
||||
files instead of implicit in a shared directory.
|
||||
|
||||
### Status pages
|
||||
|
||||
Two collections: `status_pages` is the page itself — title, banner, published
|
||||
flag, and an ordered list of sections each holding entries that pair a
|
||||
`monitor_id` with a per-page display name. `status_incidents` holds both
|
||||
operator-authored incidents and maintenance windows, sharing one document
|
||||
shape because they share a timeline, an impact and a set of affected
|
||||
components; each carries an explicit `page_ids` rather than deriving it from
|
||||
`affected_monitors`, because adding a monitor to a page later must not
|
||||
retroactively republish that monitor's old incidents to a new audience.
|
||||
|
||||
**`services.assembleSnapshot` is the redaction boundary, and it is the only
|
||||
one.** It takes a `snapshotInput` built from already-fetched
|
||||
`models.Monitor`/`models.Rollup`/`models.Incident` documents and returns a
|
||||
`StatusSnapshot` built entirely from a parallel, deliberately smaller
|
||||
vocabulary (`PublicComponent`, `PublicIncident`, …) that has no field for a
|
||||
target URL, host, port, expected status, keyword, failure message,
|
||||
certificate expiry, latency, runner or notification channel — `models.Monitor`
|
||||
itself never reaches an anonymous caller, only the handful of fields
|
||||
`assembleSnapshot` chooses to copy out of it. Being a pure function of already-
|
||||
fetched data (no DB calls inside it) is what makes the boundary testable
|
||||
without a database, which is the only thing standing between an editor adding
|
||||
a field to `PublicComponent` and that field being a hostname.
|
||||
|
||||
**An incident may only name components the page already carries.**
|
||||
`services.checkAffectedOnPages` refuses an `affected_monitors` entry that no
|
||||
page in the incident's `page_ids` lists, and the editor offers only the saved
|
||||
page's components — labelled by their per-page display name, since that is the
|
||||
name the reader sees. Naming an arbitrary monitor would publish a machine the
|
||||
page deliberately does not, which is the same leak `assembleSnapshot`'s
|
||||
redaction boundary exists to prevent, reached from the authoring side instead
|
||||
of the read side. It is a separate pass rather than part of `validateIncident`
|
||||
because it reads the database and `validateIncident` is a pure function of the
|
||||
document. A component dropped from the page **after** an incident named it
|
||||
makes the next edit of that incident fail, deliberately: the editor renders the
|
||||
stale entry flagged and checked so it is one click from being dropped, and the
|
||||
alternative is a page quietly publishing a component it no longer has.
|
||||
|
||||
Monitor-detected outages are **derived at read time, never copied**: each
|
||||
snapshot assembly reads recent `incidents` for the page's monitors and folds
|
||||
them into the timeline alongside the authored ones. There is no second
|
||||
incidents table for automatic ones and no reconciliation between two records
|
||||
of the same outage. A maintenance window in progress **repaints how a day is
|
||||
drawn, never the uptime number** — `buildDays` computes each day's up/down
|
||||
state and the 90-day percentage from rollups first, and
|
||||
`applyMaintenanceRepaint` only overwrites today's display state afterward, so
|
||||
a component that stayed up throughout a maintenance window still shows as up
|
||||
in its history.
|
||||
|
||||
The public route, `GET /public/status/:pageId`, is mounted on the gin **root**,
|
||||
outside `/api`, on purpose: `/api` carries `auth.Middleware`, `RequireScopes`,
|
||||
`RateLimitTokens` and `RequireActiveLicense` by virtue of where it is mounted,
|
||||
and a public route living there would need four exemptions — each one a hole a
|
||||
later change to any of those four could widen back open. A missing page, an
|
||||
unpublished page, and a page on the wrong host all answer the same 404;
|
||||
inventing a distinct code for "exists but unpublished" would itself leak that
|
||||
the page exists. A lapsed licence or a tier lacking `status_pages` answers 200
|
||||
with `available:false` and a `reason`, never a 403 or a blank page — the
|
||||
reader is a member of the public who can do nothing about either condition and
|
||||
deserves an explanation, not a browser error.
|
||||
|
||||
**The instance is resolved from `X-Forwarded-Host`, not `Host`.** The public
|
||||
page is server-rendered by `web/`, and the SSR fetch cannot set `Host` at all:
|
||||
it is a forbidden header name and undici drops it silently, so the Go server
|
||||
saw `server:8080` and every status page 404'd on every deployment. `web/`
|
||||
forwards the visitor's host in `X-Forwarded-Host` (and their address in
|
||||
`X-Forwarded-For`, or the whole deployment shares one rate-limit bucket), and
|
||||
`publicStatusInstance` honours that header **only when `c.RemoteIP()` is in
|
||||
`TRUSTED_PROXIES`** — it selects a tenant, so an untrusted peer must not be
|
||||
able to name one. It uses `RemoteIP()` and not `ClientIP()` deliberately: the
|
||||
latter is reconstructed from the very headers being judged.
|
||||
|
||||
**A host naming no slug falls back to the sole instance on a non-cloud
|
||||
deployment.** `hostSlug` requires `<slug>.vantage.<tld>`; a self-hosted install
|
||||
at `vantage.acme.com` or an IP has no slug and would otherwise 404 forever. It
|
||||
has exactly one instance, resolved with the same count-then-read bootstrap
|
||||
uses, cached alongside the slug lookups. More than one instance is a 404, not a
|
||||
guess. A host that *does* name a slug which does not exist stays a 404 —
|
||||
falling back there would serve one tenant's page on another's address.
|
||||
|
||||
Assembled snapshots are cached in Redis for **30 seconds**, keyed per
|
||||
instance and page, and every authoring write (`UpdateStatusPage`,
|
||||
`DeleteStatusPage`, and every incident mutation) invalidates its page's entry
|
||||
immediately rather than waiting out the TTL — an operator posting an update
|
||||
mid-incident should not wonder for half a minute whether it saved. A cache
|
||||
miss, on Redis being down or on any read error, degrades to reassembly rather
|
||||
than an error: the status page has to survive the outage it exists to report.
|
||||
The public endpoint itself is rate limited to **120 requests per minute per
|
||||
client address**, answering 429 with `Retry-After`, on the same fixed-window
|
||||
pattern as `RateLimitTokens`.
|
||||
|
||||
**`TRUSTED_PROXIES` is load-bearing for that limiter, not cosmetic.** `main.go`
|
||||
always calls `gin.SetTrustedProxies` with it; left unset, gin trusts no proxy
|
||||
and `c.ClientIP()` falls back to the direct peer address — which, sat behind a
|
||||
real reverse proxy, is the proxy's own address for every visitor. The rate
|
||||
limiter then keys on one address for the whole fleet of readers, and the first
|
||||
burst of legitimate traffic during an incident is what trips it. Set it to the
|
||||
proxy's real address or CIDR, not merely a private range guess; the shipped
|
||||
compose file and Helm chart default it to the RFC1918 ranges, which is right
|
||||
for their own bundled reverse proxy but wrong the moment another one is
|
||||
inserted in front. The same setting also decides the address recorded in
|
||||
`audit_logs` and `console_sessions`.
|
||||
|
||||
### Agent self-update
|
||||
|
||||
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
|
||||
|
||||
### Backup and restore
|
||||
|
||||
`vantagectl` is a standalone Go module (`vantagectl/`), not a subcommand of
|
||||
`server`. It needs its own module rather than living inside `server`'s for the
|
||||
same reason `admin` and `sitesvc` already do: `server` imports the rest of
|
||||
`server`'s dependency graph, and `spf13/cobra` has no business in a process
|
||||
that also terminates gRPC streams and serves the REST API. More to the point,
|
||||
`vantagectl` has to run when the control plane **does not** — a backup or
|
||||
restore against a database with no server container alive at all — so it
|
||||
cannot be a mode of the binary whose crash is the reason you need it.
|
||||
|
||||
The actual logic lives in `shared/backup` (dump, restore, verify, manifest,
|
||||
fingerprint), not in `vantagectl/internal/cmd`, which holds only argument
|
||||
parsing and operator-facing output. That split is what lets `server` import
|
||||
`shared/backup` later — a scheduled in-process backup, say — without a second
|
||||
implementation to keep in sync. `shared/cryptobox` is the same move one layer
|
||||
down: it is now the **single** AES-256-GCM implementation, and
|
||||
`server/internal/services/crypto.go` delegates to it rather than keeping its
|
||||
own copy that `shared/backup` would otherwise have had to duplicate to decrypt
|
||||
a probe value during `verify`.
|
||||
|
||||
**The archive stores a SHA-256 fingerprint of `KEY_ENCRYPTION_KEY`, never the
|
||||
key.** `backup` refuses to run without the key set in the environment unless
|
||||
`--allow-no-key` is passed, because an archive with no fingerprint at all
|
||||
cannot later tell a restore that the wrong key is in hand — it can only find
|
||||
that out when the data comes back as noise. The fingerprint is what turns that
|
||||
failure into a refusal at `restore` time instead.
|
||||
|
||||
**Collections are enumerated live** — `shared/backup` lists what the database
|
||||
actually holds rather than reading `services.ScopedCollections`, the opposite
|
||||
choice from the one instance-deletion purge makes. Purge must never miss a
|
||||
tenant-scoped collection, so it keeps one hand-maintained registry; a backup
|
||||
must never miss **any** collection, tenant-scoped or not (`migrations`,
|
||||
`vulndb_meta`), so a static list is the wrong shape twice over — once for the
|
||||
collections it would still owe `instance_id` deletion but not a backup, and
|
||||
once for the two singleton collections that carry neither `instance_id` nor a
|
||||
release note.
|
||||
|
||||
**Restore refuses a non-empty target database and has no merge semantics.**
|
||||
There is no code path that upserts an archive's documents over existing ones:
|
||||
merging two control planes' data reconciles nothing about which SSH keys are
|
||||
still valid or which users still exist, and an upsert would resurrect a
|
||||
revoked key or a deleted member from the older side. `--force` drops each
|
||||
collection in the archive first, and is gated behind a second assurance:
|
||||
`--confirm-db NAME` matching the target exactly, which works everywhere, or —
|
||||
on a terminal only, and only when `--confirm-db` was not given — the target
|
||||
database's name typed back at a prompt. `--confirm-db` is accepted on a
|
||||
terminal too: it is the stronger of the two, because naming the target in the
|
||||
command itself means a copied command carries its intended target with it and
|
||||
cannot destroy a different one by accident. Without a terminal and without
|
||||
`--confirm-db`, `--force` is refused.
|
||||
|
||||
**`--force` drops only what the archive names.** Collections already in the
|
||||
target that the archive does not carry are left untouched and **named in a
|
||||
warning** — an archive taken with `--exclude workflow_log_lines` restored over
|
||||
a live database leaves the old lines joined to restored runs, which the
|
||||
operator must be told. Dropping them instead would delete data nobody asked to
|
||||
delete, and there is no way back from that.
|
||||
|
||||
**Index specifications are replayed verbatim, never reconstructed.**
|
||||
`dumpIndexes` stores each spec as extended JSON over the raw BSON the server
|
||||
reported, and `replayIndexes` hands it back to `createIndexes` through
|
||||
`RunCommand` with only `v` and `ns` stripped and `_id_` skipped. Rebuilding a
|
||||
`mongo.IndexModel` from a hand-picked set of options dropped
|
||||
`partialFilterExpression` — which this codebase relies on in
|
||||
`services/workflows.go` and `services/settings.go` — so a partial unique index
|
||||
came back as a full one, failed on duplicate keys, and aborted the restore
|
||||
mid-write. Reconstructing the key document from JSON also lost compound key
|
||||
order, which is significant.
|
||||
|
||||
**`backup.ciphertextFields` mirrors `server/internal/models` by hand.**
|
||||
`shared/` is a separate module — a separate *repository* now — and `models` is
|
||||
under `server/internal`, so `shared/backup` cannot import it; the map naming each collection's `*_enc`
|
||||
fields (`keys`, `secrets`, `auth_providers`, `console_sessions`) must change in
|
||||
the same commit as any of those bson tags, the same hazard as
|
||||
`web/lib/targets.ts` and `services.MaxWorkloadLogLines`. Wrong field names are
|
||||
silent: `verify`'s live probe simply finds no ciphertext and reports "this
|
||||
database stores no ciphertext yet", so the one gate that catches what a
|
||||
fingerprint cannot no-ops. `settings` is deliberately in neither that map nor
|
||||
`CiphertextCollections()` — its ESO read token is a SHA-256 hash, not
|
||||
ciphertext.
|
||||
|
||||
**A file-backed `backup` writes to `<name>.tar.gz.partial` and renames on
|
||||
success**, the same discipline the agent uses for `authorized_keys`. A failed
|
||||
dump must not leave a partial file named exactly like a good archive; `--out -`
|
||||
is untouched, since a broken pipe has no file to mislead anyone.
|
||||
|
||||
**`vantagectl/Dockerfile`'s runtime stage is `scratch`, and needs the same
|
||||
explicit `/tmp` as `server/Dockerfile`.** `restore` extracts an archive to a
|
||||
temporary directory before verifying its checksums, and a scratch image has no
|
||||
`/tmp` for `os.MkdirTemp` to find — the same failure mode `vulnsched` hits on
|
||||
`server`, but here it would break every restore rather than only vulnerability
|
||||
scanning.
|
||||
|
||||
**Nothing in this repository rebuilds when `vantage-shared` changes.** It is an
|
||||
external module, so a `shared/backup` fix reaches `vantagectl` when somebody
|
||||
bumps `vantagectl/go.mod` and cuts a `vantagectl/v*` tag, and reaches nothing
|
||||
else until its pin moves too — see the CI section below.
|
||||
|
||||
### API tokens and OpenAPI
|
||||
|
||||
A token is `vt_` plus 32 random bytes hex, shown once at creation and stored
|
||||
@@ -441,9 +713,9 @@ only as sha256 — the same shape as `servers.agent_token_hash` and the ESO read
|
||||
token, and for the same reason: nothing downstream ever needs the plaintext
|
||||
back. It belongs to the user who created it, and its role can never exceed
|
||||
theirs; see the `api_tokens` note under MongoDB Collections for how that stays
|
||||
true across a demotion rather than only at issuance. Scopes are eight
|
||||
true across a demotion rather than only at issuance. Scopes are nine
|
||||
resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
|
||||
`workloads`, `settings` — each split into `:read` and `:write`, with `:write`
|
||||
`workloads`, `status`, `settings` — each split into `:read` and `:write`, with `:write`
|
||||
satisfying a `:read` requirement on the same resource so a caller does not have
|
||||
to hold both. Any signed-in member may mint and revoke their **own** tokens —
|
||||
there is no `RequireRole` on `POST /tokens` or `DELETE /tokens/:id` — because
|
||||
@@ -722,6 +994,10 @@ workloads GET /workloads · GET /servers/:id/workloads
|
||||
POST /servers/:id/workloads/refresh
|
||||
POST /servers/:id/workloads/:wid/action (owner|admin)
|
||||
GET /servers/:id/workloads/:wid/logs (owner|admin)
|
||||
status-pages GET,POST /status-pages · GET,PUT,DELETE /status-pages/:pageId (owner|admin)
|
||||
GET,POST /status-pages/:pageId/incidents
|
||||
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
|
||||
POST /status-pages/:pageId/incidents/:incidentId/updates
|
||||
audit GET /audit
|
||||
agent GET /agent/latest-version
|
||||
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
|
||||
@@ -805,7 +1081,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
`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` · `server_workloads` · `api_tokens` · `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` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `migrations`
|
||||
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
|
||||
@@ -828,7 +1104,9 @@ Notes that are not obvious from the structs:
|
||||
|
||||
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.
|
||||
`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. A row carries a `scope`: `plan` rows name a `deployment` and `tier` and belong to that plan alone, `shared` rows leave both empty and are sold by every paid plan. **How many rows a component needs follows from how many Paddle products it is** — the base fee is a different product per plan, every add-on is one product at one price, so the catalogue is four base rows plus five shared rows, nine instead of twenty-four, and an add-on's price ID is typed once rather than four times. `models.CatalogueFor` is the seam: it returns a plan's base row plus every shared row, and **nothing may filter the catalogue by `deployment` and `tier` itself** or it sees a plan priced by its base fee alone. `adminsite/lib/catalogue.ts`'s `rowsForPlan` is the TypeScript half of that and must change in the same commit, the same shape of hazard as `web/lib/targets.ts`. `models.MigrateSharedCatalogue` runs at boot after `SeedCatalogue`, merges the old per-plan copies onto the shared row and deletes them; it **refuses rather than guesses** when the four copies disagree, because four rows meant to be one price and are not is a pricing decision somebody made and picking one silently moves a customer's bill. `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.
|
||||
|
||||
**No tier bundles a feature.** `console`, `oidc`, `vuln_scanning` and `status_pages` are each a per-customer priceable add-on: every plan row carries an empty `base_features`, and the grant comes from a `catalogue` row the customer buys. Adding a fifth feature therefore means one more shared `KindFeature` row in `SeedCatalogue`'s `seedRows` and one entry in `adminsite/lib/features.ts` — that map is what the customer's grant list, the staff configurator and the purchase form all enumerate, so a feature missing from it exists in the licence and is invisible in the portal. `SeedCatalogue` upserts on the row's natural key `(kind, deployment, tier, limit_key, feature_key)` — a shared row's empty deployment and tier are part of that key, not a wildcard — so a new row reaches an existing database on the next admin boot with no migration; `SeedPlans` is `$setOnInsert` on the whole document and would not, which is the other reason bundling into a tier is the harder path.
|
||||
|
||||
### Migrations
|
||||
|
||||
@@ -871,7 +1149,7 @@ tls: true
|
||||
|
||||
```
|
||||
1. SyncKeys(server_id, agent_token, agent_version)
|
||||
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
|
||||
2. Non-Linux hosts stop here — the key-management steps below are Linux-only; a Windows agent's other work (workflow steps, inventory, OS updates, workloads) runs from the goroutines started above, not from this loop
|
||||
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
|
||||
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
|
||||
```
|
||||
@@ -914,6 +1192,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
| `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 |
|
||||
| `TRUSTED_PROXIES` | no | comma-separated CIDRs or addresses gin trusts for `X-Forwarded-For`. Empty means trust none: `c.ClientIP()` falls back to the direct peer address, which behind a real reverse proxy is that proxy's own address for every visitor — the public status page's per-address rate limit then keys on one address for the whole fleet of readers. Also the address recorded in `audit_logs` and `console_sessions`. Compose and the Helm chart default it to the RFC1918 ranges, right for their own bundled proxy and wrong the moment another one is inserted in front |
|
||||
| `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 |
|
||||
@@ -945,7 +1224,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
**`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.
|
||||
**`ingress.api.enabled` routes `/api`, `/auth`, `/public`, `/install*` and `/update*` straight to the server, and it is not optional.** It defaults to **true** and the chart refuses to render with it off, because `web` proxies nothing: with those prefixes unrouted the UI loads and every request it makes 404s against Next. The value survives only for an installation whose own terminator sits in front of this ingress and routes them there instead. 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.
|
||||
|
||||
@@ -955,6 +1234,8 @@ TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) *
|
||||
|
||||
---
|
||||
|
||||
**Neither compose file ships a reverse proxy, and both now need one.** `web:3000` serves the UI only; a request to `/api` there is a Next 404. Route `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` to `server:8080` and everything else to `web:3000` — on vantage.hostxtra.co.uk that is the Nginx Proxy Manager already in front, and it is what a self-hosted install has to configure before the UI works at all.
|
||||
|
||||
`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.
|
||||
@@ -1004,6 +1285,27 @@ Tailwind in all three maps `var(--…)` references only, so **no component in an
|
||||
|
||||
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to site/'s 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it.
|
||||
|
||||
**Plans and the catalogue are one page, `/staff/pricing`.** They were two nav
|
||||
entries and the split asked staff to hold one half in their head while looking
|
||||
at the other: a tier's allowance is what the metered component charges above,
|
||||
and a base fee means nothing without the allowance it includes. The page is
|
||||
`PlansSection` then `CatalogueSection`, in the order the decision is made —
|
||||
what a tier grants, then what it costs. `next.config.ts` keeps permanent
|
||||
redirects from `/staff/plans` and `/staff/catalogue`, which are bookmarked in
|
||||
staff browsers. **The tier list is cards, not forms**: six plans with five
|
||||
number fields, a select, a checkbox and four feature toggles each was forty-odd
|
||||
controls on one screen, and the page could not be read for the thing it exists
|
||||
to answer. A card states what the tier grants and `Modal` — a native
|
||||
`<dialog>`, for the focus trap and Escape handling a hand-rolled overlay gets
|
||||
wrong — is where it is changed. Every feature key renders on every card, lit or
|
||||
unlit: no tier bundles one today, so the unlit row is the information.
|
||||
|
||||
**The catalogue's coverage ledger is not decoration.** A missing production
|
||||
price is invisible in a grid of text inputs — every cell looks like every other
|
||||
until twenty-six characters of each are read — and it is the one thing staff
|
||||
come to the page to check before a launch, so each component draws one filled
|
||||
or empty square per environment and term.
|
||||
|
||||
**The `adminsite/` shell.** `AppBar` is the single masthead — identity, nav, environment, account menu — and it belongs to the two authenticated layouts, never to `app/layout.tsx`, so `/login` and `/accept-invite` do not render navigation they cannot use. Nav active state is derived from `usePathname`; do not hardcode it. `PageHeader` gives every screen the same back link, title, actions and **record line** (the reference number in mono, click-to-copy) — the reference is what people paste into support tickets, so it has a fixed slot rather than a per-page treatment. `PageFrame` is the main-plus-320px-rail split; the rail carries only what is true account-wide, which is why there is no plan card in it — **tier, limits and expiry belong to a licence, and a licence belongs to one instance**, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan.
|
||||
|
||||
Customer nav is three destinations — Overview, People, Billing. Settings is in the account menu because it is your password, not a place, and appearance lives there too: `AccountMenu` is the only thing that sets `data-theme`, which the token blocks have always supported in both directions.
|
||||
@@ -1062,7 +1364,7 @@ GOOS=linux GOARCH=amd64 go build \
|
||||
|
||||
### `server-deploy.yml` — triggered on every push to `main`
|
||||
|
||||
Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`.
|
||||
Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`. **`vantagectl` is deliberately not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`.
|
||||
|
||||
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:
|
||||
|
||||
@@ -1073,14 +1375,33 @@ 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` · `docsite` | their own directory only |
|
||||
| Image | Rebuilds when |
|
||||
| ---------------------------- | -------------------------------- |
|
||||
| `server` | `server/`, `proto/`, `go.work` |
|
||||
| `admin` | `admin/`, `go.work` |
|
||||
| `sitesvc` | `sitesvc/`, `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.
|
||||
**No path in this table names `shared/` any more**, and no fan-out rule replaces
|
||||
it: `vantage-shared` is an external module pinned per service, so a service
|
||||
rebuilds when its own `go.mod` moves, which its own directory pattern already
|
||||
matches. What that removes is the failure where a `shared/` edit rebuilt three
|
||||
images and one of them was not ready; what it adds is that nothing here reminds
|
||||
you a pin is stale.
|
||||
|
||||
Every Go build in these workflows writes a netrc from `REGISTRY_USER` +
|
||||
`RELEASE_TOKEN` before it runs, and sets `GOPRIVATE=gitea.hostxtra.co.uk/*`.
|
||||
There are **five** such places, and each needs its own because jobs do not share
|
||||
a filesystem: `server-deploy.yml`'s single job, both jobs of
|
||||
`agent-release.yml` (the `msi` job is Windows, where Go reads `%USERPROFILE%\_netrc`,
|
||||
not `.netrc`) and both jobs of `vantagectl-release.yml`. The docker builds pass
|
||||
it on as `--secret id=netrc`, never a build arg. **`RELEASE_TOKEN` needs read
|
||||
access to the `vantage` org** on top of its existing scopes; without it every Go
|
||||
build fails at `go mod download` with a 404 on the module, which reads like a
|
||||
missing tag rather than a missing permission. 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 `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.
|
||||
|
||||
@@ -1109,11 +1430,11 @@ git push origin main # server + web deploy
|
||||
|
||||
| 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` |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart) and **read access to the `vantage` org**, which is where the private `vantage-shared` module lives — without that last one every Go build fails at `go mod download` with what looks like a missing tag. **This is the only token any workflow authenticates with** — `docker login`, the chart publish and the module netrc all 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. |
|
||||
| ~~`API_URL`~~ | — | **Gone.** `web` proxies nothing and holds no address for the control plane. `/api`, `/auth`, `/public`, `/install*` and `/update*` must be routed to `server:8080` by the reverse proxy in front of both; everything else goes to `web:3000`. One variable that could name the wrong host was one request path too many — pointed at the marketing site, `/public/status/…` answered a Next 404 indistinguishable from a status page that does not exist. |
|
||||
| `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. |
|
||||
@@ -1144,7 +1465,14 @@ git push origin main # server + web deploy
|
||||
- **guacd for console** — protocol handling is Guacamole's problem, not ours; we proxy the WebSocket and manage credentials.
|
||||
- **`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.
|
||||
- **Windows agents cover the fleet-management path** — register, heartbeat, run
|
||||
steps, report inventory, OS updates through the Windows Update COM API, and
|
||||
workloads (services plus containers, with control and logs). They still do no
|
||||
`authorized_keys` management, and no package inventory or CVE matching: a
|
||||
Windows agent never calls `ReportPackages`, so no `server_packages` document
|
||||
exists for it and it reports no package inventory at all — a different,
|
||||
earlier state than the `unsupported` a Linux distribution reaches when its
|
||||
family has no security feed.
|
||||
- **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.
|
||||
|
||||
|
||||
+16
-8
@@ -1,17 +1,25 @@
|
||||
# Context is the repository root; admin depends on the shared module.
|
||||
# Context is admin/ itself. It used to be the repository root, so that
|
||||
# shared/ could be copied in beside it; shared is now the private module
|
||||
# gitea.hostxtra.co.uk/vantage/vantage-shared, fetched like any other
|
||||
# dependency. The credential for it arrives as a BuildKit secret rather than a
|
||||
# build arg, which would be baked into this stage's layer history.
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY admin/go.mod admin/go.sum ./admin/
|
||||
RUN cd admin && go mod download
|
||||
ENV GOPRIVATE=gitea.hostxtra.co.uk/*
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY shared/ ./shared/
|
||||
COPY admin/ ./admin/
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
go mod download
|
||||
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
|
||||
COPY . .
|
||||
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
|
||||
|
||||
FROM alpine:3.20 AS runner
|
||||
|
||||
|
||||
+5
-1
@@ -22,7 +22,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
|
||||
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
|
||||
sharedmail "gitea.hostxtra.co.uk/vantage/vantage-shared/mail"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
@@ -86,6 +86,10 @@ func main() {
|
||||
idxCancel()
|
||||
log.Fatalf("seed catalogue: %v", err)
|
||||
}
|
||||
if err := models.MigrateSharedCatalogue(idxCtx); err != nil {
|
||||
idxCancel()
|
||||
log.Fatalf("migrate catalogue: %v", err)
|
||||
}
|
||||
if err := models.Backfill(idxCtx); err != nil {
|
||||
idxCancel()
|
||||
log.Fatalf("backfill: %v", err)
|
||||
|
||||
+1
-3
@@ -3,10 +3,10 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/admin
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
@@ -51,5 +51,3 @@ require (
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/provision"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/provision"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
"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/shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/provision"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -485,6 +485,7 @@ func staffListCatalogue(c *gin.Context) {
|
||||
func staffUpdateCatalogue(c *gin.Context) {
|
||||
var body struct {
|
||||
Kind string `json:"kind"`
|
||||
Scope string `json:"scope"`
|
||||
Deployment string `json:"deployment"`
|
||||
Tier string `json:"tier"`
|
||||
LimitKey string `json:"limit_key"`
|
||||
@@ -500,11 +501,19 @@ func staffUpdateCatalogue(c *gin.Context) {
|
||||
// mean a resolved self-hosted monthly price later, which the resolver treats
|
||||
// as a configuration error — better to refuse it at the point somebody
|
||||
// pastes it, while they are looking at the screen.
|
||||
//
|
||||
// A shared row is sold by both deployments, so both terms are legitimate on
|
||||
// it: the cloud checkout takes the monthly price and the self-hosted one
|
||||
// never asks for it. Only a plan row can name a term its own deployment
|
||||
// does not sell.
|
||||
for env, byTerm := range body.PriceIDs {
|
||||
for term, id := range byTerm {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if body.Scope == models.ScopeShared {
|
||||
continue
|
||||
}
|
||||
if !termSold(body.Deployment, term) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("%s does not sell %s (environment %s)",
|
||||
@@ -514,6 +523,8 @@ func staffUpdateCatalogue(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Addressed by its natural key, so the staff UI never holds a Mongo id. A
|
||||
// shared row's empty deployment and tier are part of that key.
|
||||
filter := bson.M{
|
||||
"kind": body.Kind,
|
||||
"deployment": body.Deployment,
|
||||
@@ -534,12 +545,21 @@ func staffUpdateCatalogue(c *gin.Context) {
|
||||
audit.Write(c.Request.Context(), models.AuditEntry{
|
||||
Actor: auth.Current(c).Email,
|
||||
Action: "catalogue.updated",
|
||||
Target: body.Deployment + "/" + body.Tier + "/" + body.Kind,
|
||||
Target: catalogueTarget(body.Scope, body.Deployment, body.Tier, body.Kind),
|
||||
Detail: body.LimitKey + body.FeatureKey,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
// catalogueTarget names an edited component in the audit log. A shared row has
|
||||
// no plan to name, so it says so rather than logging "//feature".
|
||||
func catalogueTarget(scope, deployment, tier, kind string) string {
|
||||
if scope == models.ScopeShared {
|
||||
return "shared/" + kind
|
||||
}
|
||||
return deployment + "/" + tier + "/" + kind
|
||||
}
|
||||
|
||||
func termSold(deployment, term string) bool {
|
||||
for _, t := range license.TermsFor(deployment) {
|
||||
if t == term {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
|
||||
sharedmail "gitea.hostxtra.co.uk/vantage/vantage-shared/mail"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"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"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
)
|
||||
|
||||
// deliver sends a freshly issued licence where it belongs. Cloud is injected;
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
)
|
||||
|
||||
// Item is one Paddle line item: a price and how many of it.
|
||||
@@ -29,8 +29,18 @@ func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg mod
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s/%s is priced by nothing",
|
||||
// A plan is identified by its base row, and shared add-on rows exist whether
|
||||
// or not any plan sells them — so "the catalogue returned something" is no
|
||||
// longer proof this plan is priced. Check for the base row itself.
|
||||
hasBase := false
|
||||
for _, r := range rows {
|
||||
if r.Kind == models.KindBase {
|
||||
hasBase = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasBase {
|
||||
return nil, fmt.Errorf("%w: %s/%s has no base row",
|
||||
ErrUnpriced, plan.Deployment, plan.Tier)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/provision"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"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/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// api, auth, billing and lifecycle.
|
||||
package mail
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
|
||||
import "gitea.hostxtra.co.uk/vantage/vantage-shared/mail"
|
||||
|
||||
// Default is admin's sender. Set once by main; read everywhere else.
|
||||
var Default mail.Sender
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
@@ -2,9 +2,11 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
@@ -19,6 +21,23 @@ const (
|
||||
KindFeature = "feature"
|
||||
)
|
||||
|
||||
// Component scopes.
|
||||
//
|
||||
// A component is priced by one Paddle product, and how many catalogue rows it
|
||||
// needs follows from how many products it is. The base fee is a different
|
||||
// product per plan, so it is a row per plan. Every add-on — the server limit and
|
||||
// all four features — is ONE product sold to every paid plan at one price, so it
|
||||
// is one row, and its price ID is typed once instead of four times.
|
||||
//
|
||||
// Scope is stored rather than inferred from Kind so the rule is data. Pricing a
|
||||
// future add-on per tier is then a scope on a row, not a rewrite of every reader.
|
||||
const (
|
||||
// ScopePlan rows carry a deployment and a tier and belong to that plan alone.
|
||||
ScopePlan = "plan"
|
||||
// ScopeShared rows leave deployment and tier empty and belong to every paid plan.
|
||||
ScopeShared = "shared"
|
||||
)
|
||||
|
||||
// LimitKeyServers is the only metered limit today.
|
||||
//
|
||||
// A limit_key is a field name in license.Limits, which is what lets a second
|
||||
@@ -27,19 +46,23 @@ const (
|
||||
// would be 1 in every row that will ever exist.
|
||||
const LimitKeyServers = "max_servers"
|
||||
|
||||
// CatalogueRow is one priceable component of one plan.
|
||||
// CatalogueRow is one priceable component.
|
||||
//
|
||||
// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty
|
||||
// PriceIDs means the component is free — a feature with no price is a toggle a
|
||||
// customer may take at no charge, and giving it a price later is a staff edit
|
||||
// rather than a migration or a deploy.
|
||||
type CatalogueRow struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Kind string `bson:"kind" json:"kind"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
|
||||
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Kind string `bson:"kind" json:"kind"`
|
||||
Scope string `bson:"scope" json:"scope"`
|
||||
// Deployment and Tier are empty on a shared row, and are what a plan row is
|
||||
// keyed by. Readers must go through CatalogueFor rather than filtering on
|
||||
// them, or a shared row is invisible to the plan that sells it.
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
|
||||
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
|
||||
// PriceIDs is environment -> term -> Paddle price ID, e.g.
|
||||
// {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}.
|
||||
//
|
||||
@@ -67,57 +90,174 @@ func (r CatalogueRow) Priced(env string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
|
||||
// server limit, and one row per feature key.
|
||||
// Shared reports whether this row is sold by every paid plan.
|
||||
func (r CatalogueRow) Shared() bool { return r.Scope == ScopeShared }
|
||||
|
||||
// naturalKey is how a row is addressed everywhere: by what it is, never by its
|
||||
// ObjectID. A shared row's deployment and tier are empty, and that emptiness is
|
||||
// part of the key rather than a wildcard.
|
||||
func (r CatalogueRow) naturalKey() bson.M {
|
||||
return bson.M{
|
||||
"kind": r.Kind,
|
||||
"deployment": r.Deployment,
|
||||
"tier": r.Tier,
|
||||
"limit_key": r.LimitKey,
|
||||
"feature_key": r.FeatureKey,
|
||||
}
|
||||
}
|
||||
|
||||
// seedRows is the catalogue as it should exist: four base rows, one per paid
|
||||
// plan, plus five shared add-on rows every paid plan sells.
|
||||
//
|
||||
// Nine rows, down from twenty-four. The count moves whenever shared/license
|
||||
// gains a feature, and this comment is how the next person knows the number was
|
||||
// chosen rather than drifted.
|
||||
//
|
||||
// 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
|
||||
// "fix" this by adding zero-priced Free rows.
|
||||
func seedRows() []CatalogueRow {
|
||||
rows := []CatalogueRow{}
|
||||
paid := []string{license.TierProfessional, license.TierEnterprise}
|
||||
for _, deployment := range license.Deployments() {
|
||||
for _, tier := range paid {
|
||||
rows = append(rows, CatalogueRow{
|
||||
Kind: KindBase, Scope: ScopePlan, Deployment: deployment, Tier: tier,
|
||||
})
|
||||
}
|
||||
}
|
||||
rows = append(rows, CatalogueRow{
|
||||
Kind: KindLimit, Scope: ScopeShared, LimitKey: LimitKeyServers,
|
||||
})
|
||||
for _, f := range []string{
|
||||
license.FeatureConsole,
|
||||
license.FeatureOIDC,
|
||||
license.FeatureVulnScanning,
|
||||
license.FeatureStatusPages,
|
||||
} {
|
||||
rows = append(rows, CatalogueRow{
|
||||
Kind: KindFeature, Scope: ScopeShared, FeatureKey: f,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the nine rows the four paid plans need.
|
||||
//
|
||||
// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted
|
||||
// in by staff and a redeploy must not blank them.
|
||||
func SeedCatalogue(ctx context.Context) error {
|
||||
paid := []string{license.TierProfessional, license.TierEnterprise}
|
||||
for _, deployment := range license.Deployments() {
|
||||
for _, tier := range paid {
|
||||
rows := []CatalogueRow{
|
||||
{Kind: KindBase, Deployment: deployment, Tier: tier},
|
||||
{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{
|
||||
"kind": r.Kind,
|
||||
"deployment": r.Deployment,
|
||||
"tier": r.Tier,
|
||||
"limit_key": r.LimitKey,
|
||||
"feature_key": r.FeatureKey,
|
||||
}
|
||||
if _, err := db.Admin("catalogue").UpdateOne(ctx, filter,
|
||||
bson.M{"$setOnInsert": bson.M{
|
||||
"kind": r.Kind,
|
||||
"deployment": r.Deployment,
|
||||
"tier": r.Tier,
|
||||
"limit_key": r.LimitKey,
|
||||
"feature_key": r.FeatureKey,
|
||||
"price_ids": map[string]map[string]string{},
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, r := range seedRows() {
|
||||
set := r.naturalKey()
|
||||
set["scope"] = r.Scope
|
||||
set["price_ids"] = map[string]map[string]string{}
|
||||
if _, err := db.Admin("catalogue").UpdateOne(ctx, r.naturalKey(),
|
||||
bson.M{"$setOnInsert": set},
|
||||
options.UpdateOne().SetUpsert(true)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CatalogueFor returns every component of one plan.
|
||||
// MigrateSharedCatalogue collapses the four per-plan copies of each add-on onto
|
||||
// the one shared row, and deletes the copies.
|
||||
//
|
||||
// It runs after SeedCatalogue, which has already created the shared rows empty,
|
||||
// and is idempotent: once the per-plan copies are gone there is nothing to move.
|
||||
//
|
||||
// It REFUSES rather than guesses when the copies disagree. Four rows that were
|
||||
// meant to be one price and are not is a real pricing decision somebody made,
|
||||
// and picking one of them silently would move a customer's bill.
|
||||
func MigrateSharedCatalogue(ctx context.Context) error {
|
||||
// Rows seeded before scope existed are all per-plan rows. Naming them so
|
||||
// keeps CatalogueFor's $or honest for the base rows that survive.
|
||||
if _, err := db.Admin("catalogue").UpdateMany(ctx,
|
||||
bson.M{"scope": bson.M{"$exists": false}},
|
||||
bson.M{"$set": bson.M{"scope": ScopePlan}}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, shared := range seedRows() {
|
||||
if !shared.Shared() {
|
||||
continue
|
||||
}
|
||||
cur, err := db.Admin("catalogue").Find(ctx, bson.M{
|
||||
"kind": shared.Kind,
|
||||
"limit_key": shared.LimitKey,
|
||||
"feature_key": shared.FeatureKey,
|
||||
"deployment": bson.M{"$ne": ""},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
old := []CatalogueRow{}
|
||||
if err := cur.All(ctx, &old); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(old) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var target CatalogueRow
|
||||
if err := db.Admin("catalogue").FindOne(ctx, shared.naturalKey()).Decode(&target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merged := target.PriceIDs
|
||||
if merged == nil {
|
||||
merged = map[string]map[string]string{}
|
||||
}
|
||||
for _, o := range old {
|
||||
for env, byTerm := range o.PriceIDs {
|
||||
for term, id := range byTerm {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if merged[env] == nil {
|
||||
merged[env] = map[string]string{}
|
||||
}
|
||||
if have := merged[env][term]; have != "" && have != id {
|
||||
return fmt.Errorf(
|
||||
"catalogue: %s%s was priced differently per plan (%s %s: %q and %q); "+
|
||||
"decide which price is the shared one and delete the others before upgrading",
|
||||
shared.LimitKey, shared.FeatureKey, env, term, have, id)
|
||||
}
|
||||
merged[env][term] = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Admin("catalogue").UpdateOne(ctx, shared.naturalKey(),
|
||||
bson.M{"$set": bson.M{"price_ids": merged}}); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]bson.ObjectID, 0, len(old))
|
||||
for _, o := range old {
|
||||
ids = append(ids, o.ID)
|
||||
}
|
||||
if _, err := db.Admin("catalogue").DeleteMany(ctx,
|
||||
bson.M{"_id": bson.M{"$in": ids}}); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("catalogue: merged %d per-plan rows into shared %s%s",
|
||||
len(old), shared.LimitKey, shared.FeatureKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CatalogueFor returns every component one plan sells: its own base row plus
|
||||
// every shared add-on.
|
||||
//
|
||||
// This is the seam the whole shared-row change rests on. Every reader that used
|
||||
// to filter the catalogue by deployment and tier must come through here instead,
|
||||
// or it sees a plan priced by nothing but its base fee.
|
||||
func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) {
|
||||
deployment, tier = license.NormaliseTier(deployment, tier)
|
||||
cur, err := db.Admin("catalogue").Find(ctx,
|
||||
bson.M{"deployment": deployment, "tier": tier})
|
||||
cur, err := db.Admin("catalogue").Find(ctx, bson.M{"$or": []bson.M{
|
||||
{"scope": ScopeShared},
|
||||
{"deployment": deployment, "tier": tier},
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { rowsForPlan, sharedRows } from "@/lib/catalogue";
|
||||
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";
|
||||
@@ -60,24 +61,24 @@ export function PurchaseForm() {
|
||||
const options = optionsQ.data;
|
||||
const accountId = account.data?.account.account_id ?? "";
|
||||
|
||||
// Distinct feature keys offered on this deployment, in a stable order.
|
||||
// Every feature a paid plan can be sold, in a stable order. Features are
|
||||
// shared rows now, so they no longer differ by deployment — the list is the
|
||||
// same on both, and reads from one place rather than four.
|
||||
const featureKeys = useMemo(() => {
|
||||
if (!options) return [] as string[];
|
||||
const keys = new Set<string>();
|
||||
for (const r of options.catalogue) {
|
||||
if (r.deployment === dep && r.kind === "feature" && r.feature_key) {
|
||||
keys.add(r.feature_key);
|
||||
}
|
||||
for (const r of sharedRows(options.catalogue)) {
|
||||
if (r.kind === "feature" && r.feature_key) keys.add(r.feature_key);
|
||||
}
|
||||
return [...keys];
|
||||
}, [options, dep]);
|
||||
}, [options]);
|
||||
|
||||
const activePlans = useMemo(() => (options?.plans ?? []).filter((p) => p.deployment === dep && p.active).sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)), [options, dep]);
|
||||
const plan = activePlans.find((p) => p.tier === choice.tier);
|
||||
const baseServers = plan?.base_limits.max_servers ?? 0;
|
||||
const unlimited = baseServers === -1;
|
||||
|
||||
const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]);
|
||||
const rows = useMemo(() => rowsForPlan(options?.catalogue ?? [], dep, choice.tier), [options, dep, choice.tier]);
|
||||
|
||||
// Real line items for the current configuration the same builder the
|
||||
// checkout uses, so the summary can never disagree with the overlay.
|
||||
@@ -239,7 +240,7 @@ export function PurchaseForm() {
|
||||
headline={p.tier === "free" ? "£0" : basePrices[p.tier]}
|
||||
cycleLabel={cycleShort(dep, choice.term)}
|
||||
featureKeys={featureKeys}
|
||||
catalogue={options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier)}
|
||||
catalogue={rowsForPlan(options.catalogue, dep, p.tier)}
|
||||
env={options.env}
|
||||
term={choice.term}
|
||||
onSelect={() =>
|
||||
@@ -252,7 +253,7 @@ export function PurchaseForm() {
|
||||
features: c.features.filter((k) => {
|
||||
const st = featureStateFor(
|
||||
p,
|
||||
options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier),
|
||||
rowsForPlan(options.catalogue, dep, p.tier),
|
||||
options.env,
|
||||
c.term,
|
||||
k,
|
||||
@@ -658,7 +659,7 @@ function Receipt({
|
||||
// Label each real line item from the catalogue, and price it from Paddle.
|
||||
const base = plan?.base_limits.max_servers ?? 0;
|
||||
const extra = base === -1 ? 0 : Math.max(0, choice.servers - base);
|
||||
const rows = options.catalogue.filter((r) => r.deployment === dep && r.tier === choice.tier);
|
||||
const rows = rowsForPlan(options.catalogue, dep, choice.tier);
|
||||
const idFor = (predicate: (r: CatalogueRow) => boolean) => {
|
||||
const row = rows.find(predicate);
|
||||
return row?.price_ids?.[options.env]?.[choice.term] ?? "";
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { PageFrame } from "@/components/PageFrame";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { api, type CatalogueRow, type Term } from "@/lib/api";
|
||||
|
||||
const ENVS = ["sandbox", "production"] as const;
|
||||
|
||||
/* Self-hosted sells annual only, so the monthly cell is not rendered for it
|
||||
* rather than rendered and rejected. The backend refuses one either way; this is
|
||||
* so nobody types into a field that cannot be saved. */
|
||||
function termsFor(deployment: string): Term[] {
|
||||
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
|
||||
}
|
||||
|
||||
function componentLabel(r: CatalogueRow): string {
|
||||
if (r.kind === "base") return "Base fee";
|
||||
if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`;
|
||||
return `Feature: ${r.feature_key}`;
|
||||
}
|
||||
|
||||
function rowKey(r: CatalogueRow): string {
|
||||
return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
|
||||
}
|
||||
|
||||
export default function CataloguePage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: rows = [], isLoading } = useQuery({
|
||||
queryKey: ["staff", "catalogue"],
|
||||
queryFn: api.staff.catalogue,
|
||||
});
|
||||
const [drafts, setDrafts] = useState<Record<string, CatalogueRow["price_ids"]>>({});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }),
|
||||
});
|
||||
|
||||
const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`)));
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Catalogue"
|
||||
back={{ href: "/staff", label: "Operations" }}
|
||||
subtitle="Every priceable component. This is the only place a Paddle price ID lives."
|
||||
/>
|
||||
<PageFrame
|
||||
aside={
|
||||
<aside className="space-y-3 text-[0.82rem] text-ink-2">
|
||||
<p>
|
||||
A component with no price ID is free. A feature with no price is a
|
||||
toggle a customer may take at no charge; giving it a price here is
|
||||
all it takes to start charging for it.
|
||||
</p>
|
||||
<p>
|
||||
Free is priced by nothing and has no rows. That absence is what
|
||||
keeps it outside Paddle.
|
||||
</p>
|
||||
<p>
|
||||
Changing a price affects the next checkout only. It cannot touch an
|
||||
issued licence.
|
||||
</p>
|
||||
</aside>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="text-[0.85rem] text-ink-3">Loading…</p>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{groups.map((g) => {
|
||||
const [deployment, tier] = g.split("/");
|
||||
const terms = termsFor(deployment);
|
||||
return (
|
||||
<Panel key={g} title={`${deployment === "cloud" ? "Cloud" : "Self-Hosted"} ${tier}`} meta={terms.join(" · ")} bodyless>
|
||||
<Table className="min-w-[42rem]">
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Component</TH>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<TH key={`${env}-${t}`}>
|
||||
{env} / {t}
|
||||
</TH>
|
||||
)),
|
||||
)}
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows
|
||||
.filter(
|
||||
(r) =>
|
||||
r.deployment === deployment &&
|
||||
r.tier === tier,
|
||||
)
|
||||
.map((r) => {
|
||||
const k = rowKey(r);
|
||||
const ids = drafts[k] ?? r.price_ids ?? {};
|
||||
const dirty =
|
||||
JSON.stringify(ids) !==
|
||||
JSON.stringify(r.price_ids ?? {});
|
||||
return (
|
||||
<TR key={k}>
|
||||
<TD className="text-ink">{componentLabel(r)}</TD>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<TD key={`${env}-${t}`}>
|
||||
<input
|
||||
value={
|
||||
ids[env]?.[t] ?? ""
|
||||
}
|
||||
placeholder="pri_…"
|
||||
onChange={(e) =>
|
||||
setDrafts({
|
||||
...drafts,
|
||||
[k]: {
|
||||
...ids,
|
||||
[env]: {
|
||||
...(ids[
|
||||
env
|
||||
] ?? {}),
|
||||
[t]: e
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-40 rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.78rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</TD>
|
||||
)),
|
||||
)}
|
||||
<TD numeric>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!dirty || save.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
...r,
|
||||
price_ids: ids,
|
||||
})
|
||||
}
|
||||
className="rounded border border-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,7 @@ const LINKS: NavLink[] = [
|
||||
{ href: "/staff", label: "Operations" },
|
||||
{ href: "/staff/accounts", label: "Accounts" },
|
||||
{ href: "/staff/licenses", label: "Licences" },
|
||||
{ href: "/staff/plans", label: "Plans" },
|
||||
{ href: "/staff/catalogue", label: "Catalogue" },
|
||||
{ href: "/staff/pricing", label: "Pricing" },
|
||||
{ href: "/staff/audit", label: "Audit" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Panel } from "@/components/Panel";
|
||||
|
||||
const SUPPORT_LEVELS = [
|
||||
{ value: "community", label: "Community" },
|
||||
{ value: "email_24_5", label: "Email, 24/5" },
|
||||
{ value: "email_call_24_7", label: "Email + call, 24/7" },
|
||||
] as const;
|
||||
|
||||
const LIMIT_FIELDS = [
|
||||
{ key: "max_servers", label: "Servers" },
|
||||
{ key: "max_monitors", label: "Monitors" },
|
||||
{ key: "max_secret_groups", label: "Secret groups" },
|
||||
{ key: "max_channels", label: "Channels" },
|
||||
{ key: "audit_retention_days", label: "Audit history (days)" },
|
||||
] as const;
|
||||
|
||||
/*
|
||||
* -1 is Unlimited everywhere in the licence payload, so the form takes it
|
||||
* literally rather than inventing a checkbox. A staff screen that hides the
|
||||
* 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 }) {
|
||||
const [draft, setDraft] = useState<Plan>(plan);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.base_limits[f.key]}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
base_limits: {
|
||||
...draft.base_limits,
|
||||
[f.key]: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<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>
|
||||
<select
|
||||
value={draft.support_level}
|
||||
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
|
||||
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
|
||||
>
|
||||
{SUPPORT_LEVELS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</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 })} />
|
||||
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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => onSave(draft)}
|
||||
className="justify-self-start rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink disabled:opacity-40"
|
||||
>
|
||||
{saving ? "Saving…" : "Save allowances"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlansPage() {
|
||||
const qc = useQueryClient();
|
||||
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
|
||||
const licenses = useQuery({
|
||||
queryKey: ["staff-licenses"],
|
||||
queryFn: () => api.staff.licenses(),
|
||||
});
|
||||
const [draft, setDraft] = useState<Plan | null>(null);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["plans"] });
|
||||
setDraft(null);
|
||||
setSaving(null);
|
||||
},
|
||||
onError: () => setSaving(null),
|
||||
});
|
||||
|
||||
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."
|
||||
/>
|
||||
|
||||
{draft && original && (
|
||||
<ConfirmPlanChange
|
||||
plan={original}
|
||||
next={draft}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
|
||||
onConfirm={() => {
|
||||
setSaving(`${draft.deployment}/${draft.tier}`);
|
||||
save.mutate(draft);
|
||||
}}
|
||||
onCancel={() => setDraft(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="grid gap-3">
|
||||
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<Panel
|
||||
key={`${p.deployment}/${p.tier}`}
|
||||
title={p.name}
|
||||
meta={`${p.deployment}/${p.tier}`}
|
||||
actions={!p.active ? <span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-warn">Not offered</span> : undefined}
|
||||
>
|
||||
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
|
||||
</Panel>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { SectionHeading } from "./SectionHeading";
|
||||
import { planRows, rowKey, sharedRows } from "@/lib/catalogue";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
import { api, type CatalogueRow, type Term } from "@/lib/api";
|
||||
|
||||
const ENVS = ["sandbox", "production"] as const;
|
||||
|
||||
/* A shared row is sold by both deployments, so it holds both terms: the cloud
|
||||
* checkout takes the monthly price and the self-hosted one never asks for it. A
|
||||
* plan row offers only the terms its own deployment sells — self-hosted is
|
||||
* annual only, and the field is not rendered rather than rendered and refused. */
|
||||
function termsFor(r: CatalogueRow): Term[] {
|
||||
if (r.scope === "shared") return ["monthly", "annual"];
|
||||
return r.deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
|
||||
}
|
||||
|
||||
function componentLabel(r: CatalogueRow): string {
|
||||
if (r.kind === "base") return `${r.tier === "enterprise" ? "Enterprise" : "Professional"} (${r.deployment === "cloud" ? "Cloud" : "Self-hosted"})`;
|
||||
if (r.kind === "limit") return "Additional server";
|
||||
return featureLabel(r.feature_key ?? "");
|
||||
}
|
||||
|
||||
function componentDetail(r: CatalogueRow): string {
|
||||
if (r.kind === "base") return "The plan's own fee, always quantity 1";
|
||||
if (r.kind === "limit") return `Raises ${r.limit_key} by one per unit`;
|
||||
return `feature · ${r.feature_key}`;
|
||||
}
|
||||
|
||||
/*
|
||||
* The coverage ledger: one square per environment and term, filled when that
|
||||
* cell holds a price ID.
|
||||
*
|
||||
* A missing production price is invisible in a grid of text inputs — every cell
|
||||
* looks like every other until you read twenty-six characters of each. This is
|
||||
* the one thing staff come to this page to check before a launch, so it reads
|
||||
* before the IDs do.
|
||||
*/
|
||||
function Coverage({ row, terms }: { row: CatalogueRow; terms: Term[] }) {
|
||||
const cells = ENVS.flatMap((env) => terms.map((t) => ({ env, t, filled: Boolean(row.price_ids?.[env]?.[t]) })));
|
||||
const filled = cells.filter((c) => c.filled).length;
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{cells.map((c) => (
|
||||
<span key={`${c.env}-${c.t}`} title={`${c.env} ${c.t}`} className={["block h-2.5 w-2.5 rounded-[1px] border", c.filled ? "border-valid bg-valid" : "border-rule bg-panel-2"].join(" ")} />
|
||||
))}
|
||||
<span className="ml-1.5 font-mono text-[0.62rem] tracking-[0.08em] text-ink-3">
|
||||
{filled}/{cells.length} priced
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentRow({ row, scopeLabel }: { row: CatalogueRow; scopeLabel: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState<CatalogueRow["price_ids"] | null>(null);
|
||||
const ids = draft ?? row.price_ids ?? {};
|
||||
const dirty = JSON.stringify(ids) !== JSON.stringify(row.price_ids ?? {});
|
||||
const terms = termsFor(row);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.staff.updateCatalogue({ ...row, price_ids: ids }),
|
||||
onSuccess: () => {
|
||||
setDraft(null);
|
||||
qc.invalidateQueries({ queryKey: ["staff", "catalogue"] });
|
||||
},
|
||||
});
|
||||
|
||||
const set = (env: string, term: Term, value: string) =>
|
||||
setDraft({ ...ids, [env]: { ...(ids[env] ?? {}), [term]: value } });
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 border-t border-rule-soft pt-3 first:border-0 first:pt-0 md:grid-cols-[minmax(0,17rem)_1fr]">
|
||||
<div className="grid content-start gap-1.5">
|
||||
<span className="text-[0.9rem] font-semibold">{componentLabel(row)}</span>
|
||||
<span className={["w-max rounded border px-1.5 py-px font-mono text-[0.6rem] uppercase tracking-[0.1em]", row.scope === "shared" ? "border-accent text-accent" : "border-rule text-ink-3"].join(" ")}>{scopeLabel}</span>
|
||||
<span className="text-[0.78rem] text-ink-3">{componentDetail(row)}</span>
|
||||
<Coverage row={{ ...row, price_ids: ids }} terms={terms} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{ENVS.map((env) => (
|
||||
<div key={env} className="grid content-start gap-1.5">
|
||||
<span className="flex items-center gap-2 font-mono text-[0.62rem] uppercase tracking-[0.12em] text-ink-3">
|
||||
{env}
|
||||
<span className="h-px flex-1 bg-rule-soft" />
|
||||
</span>
|
||||
{terms.map((t) => (
|
||||
<label key={t} className="grid gap-1">
|
||||
<span className="font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3">{t}</span>
|
||||
<input
|
||||
value={ids[env]?.[t] ?? ""}
|
||||
placeholder="pri_…"
|
||||
onChange={(e) => set(env, t, e.target.value)}
|
||||
className={["w-full rounded border bg-panel-2 px-2 py-1.5 font-mono text-[0.76rem] text-ink focus:border-accent focus:outline-none", ids[env]?.[t] ? "border-rule" : "border-dashed border-rule"].join(" ")}
|
||||
aria-label={`${componentLabel(row)} ${env} ${t} price ID`}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<button type="button" disabled={!dirty || save.isPending} onClick={() => save.mutate()} className="rounded border border-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent disabled:opacity-40">
|
||||
{save.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{save.error && <span className="text-[0.78rem] text-expired">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* The catalogue half of /staff/pricing: every priceable component, grouped by
|
||||
* what it is rather than by which plan sells it.
|
||||
*/
|
||||
export function CatalogueSection() {
|
||||
const { data: rows = [], isLoading } = useQuery({
|
||||
queryKey: ["staff", "catalogue"],
|
||||
queryFn: api.staff.catalogue,
|
||||
});
|
||||
|
||||
const shared = sharedRows(rows);
|
||||
const bases = planRows(rows);
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<SectionHeading
|
||||
title="Catalogue"
|
||||
note="Every priceable component, grouped by what it is rather than by which plan sells it. This is the only place a Paddle price ID lives."
|
||||
/>
|
||||
|
||||
<div className="grid gap-1.5 rounded border-l-2 border-accent bg-accent-wash px-3 py-2.5 text-[0.82rem] text-ink-2">
|
||||
<p>An add-on is one Paddle product sold to every paid plan, so its price is typed once. Only the base fee differs by plan, because only the base fee is a different product per plan.</p>
|
||||
<p>A component with no price ID is free — a feature with no price is a toggle a customer may take at no charge. Free is priced by nothing and has no rows at all, which is what keeps it outside Paddle. Changing a price affects the next checkout only; it cannot touch an issued licence.</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-[0.85rem] text-ink-3">Loading…</p>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
<Panel title="Add-ons" meta={`${shared.length} rows · every paid plan`}>
|
||||
{shared.map((r) => (
|
||||
<ComponentRow key={rowKey(r)} row={r} scopeLabel="All paid plans" />
|
||||
))}
|
||||
</Panel>
|
||||
<Panel title="Base fee" meta={`${bases.length} rows · one per plan`}>
|
||||
{bases.map((r) => (
|
||||
<ComponentRow key={rowKey(r)} row={r} scopeLabel="This plan only" />
|
||||
))}
|
||||
</Panel>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api, type Deployment, type Plan } from "@/lib/api";
|
||||
import { featureDesc, featureLabel, FEATURE_LABEL } from "@/lib/features";
|
||||
import { limitLabel } from "@/lib/format";
|
||||
import { Button, controlClass } from "@/components/Button";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { SectionHeading } from "./SectionHeading";
|
||||
import { Modal } from "@/components/Modal";
|
||||
|
||||
const SUPPORT_LEVELS = [
|
||||
{ value: "community", label: "Community" },
|
||||
{ value: "email_24_5", label: "Email, 24/5" },
|
||||
{ value: "email_call_24_7", label: "Email + call, 24/7" },
|
||||
] as const;
|
||||
|
||||
const LIMIT_FIELDS = [
|
||||
{ key: "max_servers", label: "Servers" },
|
||||
{ key: "max_monitors", label: "Monitors" },
|
||||
{ key: "max_secret_groups", label: "Secret groups" },
|
||||
{ key: "max_channels", label: "Channels" },
|
||||
{ key: "audit_retention_days", label: "Audit history (days)" },
|
||||
] as const;
|
||||
|
||||
const FEATURE_KEYS = Object.keys(FEATURE_LABEL);
|
||||
|
||||
const planKey = (p: Plan) => `${p.deployment}/${p.tier}`;
|
||||
|
||||
/*
|
||||
* The list is tiers, and a tier's settings are behind a button.
|
||||
*
|
||||
* Six plans with five number fields, a select, a checkbox and four toggles each
|
||||
* is forty-odd controls on one screen, and the page it made could not be read
|
||||
* for the thing it exists to answer: what does each tier give you. The card
|
||||
* answers that; the modal is where it is changed.
|
||||
*/
|
||||
function TierCard({ plan, onOpen }: { plan: Plan; onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={[
|
||||
"grid w-full gap-2.5 rounded border bg-panel p-3.5 text-left",
|
||||
"transition-[border-color,transform] duration-150 hover:-translate-y-px hover:border-accent",
|
||||
plan.active ? "border-rule" : "border-dashed border-rule opacity-75",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[1rem] font-semibold">{plan.name}</span>
|
||||
{!plan.active && <span className="rounded border border-warn px-1.5 py-px font-mono text-[0.6rem] uppercase tracking-[0.1em] text-warn">Not offered</span>}
|
||||
<span className="ml-auto font-mono text-[0.68rem] text-ink-3">{planKey(plan)}</span>
|
||||
</span>
|
||||
|
||||
<dl className="grid grid-cols-[1fr_auto] gap-x-3 gap-y-0.5 text-[0.82rem]">
|
||||
<dt className="text-ink-3">Servers</dt>
|
||||
<dd className="text-right tabular-nums">{limitLabel(plan.base_limits.max_servers)}</dd>
|
||||
<dt className="text-ink-3">Monitors</dt>
|
||||
<dd className="text-right tabular-nums">{limitLabel(plan.base_limits.max_monitors)}</dd>
|
||||
<dt className="text-ink-3">Audit history</dt>
|
||||
<dd className="text-right tabular-nums">{limitLabel(plan.base_limits.audit_retention_days)} days</dd>
|
||||
</dl>
|
||||
|
||||
{/* Every feature key, lit or unlit — an absent chip cannot be told
|
||||
* from a feature nobody has heard of, and no tier bundles one today,
|
||||
* so the unlit row IS the information. */}
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{FEATURE_KEYS.map((k) => {
|
||||
const on = plan.base_features.includes(k);
|
||||
return (
|
||||
<span key={k} className={["rounded border px-1.5 py-px font-mono text-[0.6rem] uppercase tracking-[0.06em]", on ? "border-valid text-valid" : "border-rule text-ink-3"].join(" ")}>
|
||||
{featureLabel(k)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
|
||||
<span className="justify-self-start rounded border border-accent px-2.5 py-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-accent">Open plan</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* -1 is Unlimited everywhere in the licence payload, so the form takes it
|
||||
* literally rather than inventing a checkbox. A staff screen that hides the
|
||||
* sentinel is a staff screen where nobody can tell whether a plan says
|
||||
* unlimited or nothing at all. */
|
||||
function PlanModal({ plan, onClose, onSave }: { plan: Plan; onClose: () => void; onSave: (next: Plan) => void }) {
|
||||
const [draft, setDraft] = useState<Plan>(plan);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
const toggleFeature = (key: string, on: boolean) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
base_features: on ? [...draft.base_features, key] : draft.base_features.filter((f) => f !== key),
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={plan.name}
|
||||
meta={planKey(plan)}
|
||||
footer={
|
||||
<>
|
||||
<p className="mr-auto max-w-md text-[0.78rem] text-ink-3">Applies to licences issued from now on. Issued licences snapshotted their plan and are unaffected.</p>
|
||||
<Button type="button" variant="line" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" disabled={!dirty} onClick={() => onSave(draft)}>
|
||||
Save plan
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<section className="grid gap-2">
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.14em] text-ink-3">Base limits</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<label key={f.key} className="grid gap-1">
|
||||
<span className="text-[0.78rem] text-ink-3">{f.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.base_limits[f.key]}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
base_limits: { ...draft.base_limits, [f.key]: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
className={controlClass("h-9 text-[0.84rem] tabular-nums")}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[0.78rem] text-ink-3">−1 is unlimited. A metered dimension starts here and the customer buys upward from it.</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2">
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.14em] text-ink-3">Base features</span>
|
||||
<div className="grid gap-1.5">
|
||||
{FEATURE_KEYS.map((k) => {
|
||||
const on = draft.base_features.includes(k);
|
||||
return (
|
||||
<label key={k} className="flex items-center gap-2.5 rounded border border-rule-soft bg-panel-2 px-2.5 py-2">
|
||||
<input type="checkbox" checked={on} onChange={(e) => toggleFeature(k, e.target.checked)} />
|
||||
<span>
|
||||
<span className="block text-[0.86rem]">{featureLabel(k)}</span>
|
||||
<span className="block text-[0.75rem] text-ink-3">{featureDesc(k)}</span>
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-3">{on ? "Included" : "Sold as add-on"}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[0.78rem] text-ink-3">No tier bundles a feature today. Including one here grants it with the plan and removes it from the customer's purchase form.</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2">
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.14em] text-ink-3">Availability</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[0.78rem] text-ink-3">Support level</span>
|
||||
<select value={draft.support_level} onChange={(e) => setDraft({ ...draft, support_level: e.target.value })} className={controlClass("h-9 text-[0.84rem]")}>
|
||||
{SUPPORT_LEVELS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 self-end pb-2 text-[0.86rem]">
|
||||
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
|
||||
Offered to customers
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* The plans half of /staff/pricing. It is a section rather than a page because
|
||||
* a tier's allowances and a tier's price are one decision made in one sitting,
|
||||
* and they were two screens with no view showing both.
|
||||
*/
|
||||
export function PlansSection() {
|
||||
const qc = useQueryClient();
|
||||
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
|
||||
const licenses = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });
|
||||
|
||||
/* Two pieces of state, not one: `editing` is the plan whose modal is open,
|
||||
* `confirming` is the edit awaiting the change summary. Collapsing them put
|
||||
* the confirmation behind the modal it was confirming. */
|
||||
const [editing, setEditing] = useState<Plan | null>(null);
|
||||
const [confirming, setConfirming] = useState<Plan | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["plans"] });
|
||||
setConfirming(null);
|
||||
},
|
||||
});
|
||||
|
||||
const original = plans.data?.find((p) => p.deployment === confirming?.deployment && p.tier === confirming?.tier);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<SectionHeading title="Plans" note="What each tier grants. Open a tier to change its base limits and features. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence." />
|
||||
|
||||
{confirming && original && (
|
||||
<ConfirmPlanChange
|
||||
plan={original}
|
||||
next={confirming}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === confirming.tier && l.deployment === confirming.deployment).length}
|
||||
onConfirm={() => save.mutate(confirming)}
|
||||
onCancel={() => setConfirming(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="grid gap-2.5">
|
||||
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-hosted"}</h2>
|
||||
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<TierCard key={planKey(p)} plan={p} onOpen={() => setEditing(p)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{editing && (
|
||||
<PlanModal
|
||||
key={planKey(editing)}
|
||||
plan={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={(next) => {
|
||||
setEditing(null);
|
||||
setConfirming(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* The heading that separates the two halves of /staff/pricing.
|
||||
*
|
||||
* It is not PageHeader: the page has one of those, and a second title-sized
|
||||
* heading under it would read as a second page. This is the same mono eyebrow
|
||||
* idiom the deployment groups use, one level up.
|
||||
*/
|
||||
export function SectionHeading({ title, note }: { title: string; note: string }) {
|
||||
return (
|
||||
<div className="grid gap-1 border-b border-rule pb-2">
|
||||
<h2 className="text-[1.05rem] font-bold tracking-[-0.01em]">{title}</h2>
|
||||
<p className="max-w-[68ch] text-[0.84rem] text-ink-3">{note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { CatalogueSection } from "./CatalogueSection";
|
||||
import { PlansSection } from "./PlansSection";
|
||||
|
||||
/*
|
||||
* Plans and catalogue on one page.
|
||||
*
|
||||
* They were two nav entries, and the split asked staff to hold one half in
|
||||
* their head while looking at the other: a tier's allowances decide what the
|
||||
* metered component charges for, and the base fee is meaningless without the
|
||||
* allowance it includes. One page, two sections, in the order the decision is
|
||||
* made — what a tier grants, then what it costs.
|
||||
*/
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="grid gap-7">
|
||||
<PageHeader title="Pricing" back={{ href: "/staff", label: "Operations" }} subtitle="What each tier grants, and what every priceable component costs." />
|
||||
<PlansSection />
|
||||
<CatalogueSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/*
|
||||
* A native <dialog>, not a div with a fixed overlay.
|
||||
*
|
||||
* showModal() gives focus trapping, inert background, Escape and the top layer
|
||||
* for free — all four are things a hand-rolled overlay gets wrong, and the third
|
||||
* is the one staff will actually reach for. The only wiring needed is keeping
|
||||
* React state and the element's open state in step, and routing every close —
|
||||
* Escape, backdrop, button — through one onClose.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
meta,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
meta?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (open && !el.open) el.showModal();
|
||||
if (!open && el.open) el.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
/* Clicking the backdrop hits the dialog element itself, never a
|
||||
* child — so this closes on backdrop and not on content. */
|
||||
onClick={(e) => {
|
||||
if (e.target === ref.current) onClose();
|
||||
}}
|
||||
className="w-[min(44rem,94vw)] rounded border border-rule bg-panel p-0 text-ink shadow-lg backdrop:bg-[rgba(4,12,24,0.55)]"
|
||||
>
|
||||
<header className="flex flex-wrap items-center gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<h2 className="text-[1.02rem] font-bold tracking-[-0.01em]">{title}</h2>
|
||||
{meta && <span className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">{meta}</span>}
|
||||
<button type="button" onClick={onClose} className="ml-auto rounded border border-rule px-2 py-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-2 hover:border-ink-3" aria-label="Close">
|
||||
Esc
|
||||
</button>
|
||||
</header>
|
||||
<div className="grid max-h-[68vh] gap-4 overflow-y-auto p-4">{children}</div>
|
||||
{footer && <footer className="flex flex-wrap items-center gap-3 border-t border-rule-soft bg-panel-2 px-4 py-3">{footer}</footer>}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
|
||||
import { rowsForPlan } from "@/lib/catalogue";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export interface PlanChoice {
|
||||
@@ -50,7 +51,7 @@ export default function PlanConfigurator({
|
||||
);
|
||||
const plan = available.find((p) => p.tier === value.tier);
|
||||
const rows = useMemo(
|
||||
() => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier),
|
||||
() => rowsForPlan(catalogue, deployment, value.tier),
|
||||
[catalogue, deployment, value.tier],
|
||||
);
|
||||
const featureRows = rows.filter((r) => r.kind === "feature");
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* customer-facing and should be shown verbatim).
|
||||
*/
|
||||
|
||||
/* catalogue.ts imports only types from here, so this is not a cycle. */
|
||||
import { rowsForPlan } from "@/lib/catalogue";
|
||||
|
||||
export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
export class NotConnected extends Error {
|
||||
@@ -176,6 +179,11 @@ export interface Plan {
|
||||
|
||||
export interface CatalogueRow {
|
||||
kind: "base" | "limit" | "feature";
|
||||
/* "plan" rows carry a deployment and tier and belong to that plan alone.
|
||||
* "shared" rows leave both empty and are sold by every paid plan, which is
|
||||
* why a price ID is typed once rather than four times. Read them through
|
||||
* rowsForPlan in lib/catalogue, never by filtering on deployment. */
|
||||
scope: "plan" | "shared";
|
||||
deployment: Deployment;
|
||||
tier: Tier;
|
||||
limit_key?: string;
|
||||
@@ -213,9 +221,7 @@ export function lineItemsFor(
|
||||
const env = opts.env;
|
||||
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
|
||||
if (!plan) return [];
|
||||
const rows = opts.catalogue.filter(
|
||||
(r) => r.deployment === deployment && r.tier === choice.tier,
|
||||
);
|
||||
const rows = rowsForPlan(opts.catalogue, deployment, choice.tier);
|
||||
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
|
||||
const base = plan.base_limits.max_servers;
|
||||
const items: { priceId: string; quantity: number }[] = [];
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CatalogueRow, Deployment, Tier } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* rowsForPlan is the TypeScript half of Go's models.CatalogueFor, and the two
|
||||
* must change together — the same shape of hazard as web/lib/targets.ts.
|
||||
*
|
||||
* A plan sells its own base row plus every shared add-on row. Shared rows leave
|
||||
* deployment and tier empty, so the filter this replaced — `r.deployment === dep
|
||||
* && r.tier === tier` — now returns a plan priced by its base fee and nothing
|
||||
* else. There were five copies of that filter; this is why it is a module.
|
||||
*/
|
||||
export function rowsForPlan(
|
||||
catalogue: CatalogueRow[],
|
||||
deployment: Deployment,
|
||||
tier: Tier,
|
||||
): CatalogueRow[] {
|
||||
return catalogue.filter(
|
||||
(r) => r.scope === "shared" || (r.deployment === deployment && r.tier === tier),
|
||||
);
|
||||
}
|
||||
|
||||
/* Every add-on a paid plan can be sold, in one list. The staff catalogue editor
|
||||
* shows these once; the purchase form reads them per plan through rowsForPlan. */
|
||||
export function sharedRows(catalogue: CatalogueRow[]): CatalogueRow[] {
|
||||
return catalogue.filter((r) => r.scope === "shared");
|
||||
}
|
||||
|
||||
/* The base fee rows, which are genuinely one per plan because each is its own
|
||||
* Paddle product at its own price. */
|
||||
export function planRows(catalogue: CatalogueRow[]): CatalogueRow[] {
|
||||
return catalogue.filter((r) => r.scope !== "shared");
|
||||
}
|
||||
|
||||
/* A stable identity for a row, used as a React key and as the draft key in the
|
||||
* staff editor. Mirrors the natural key the API addresses a row by. */
|
||||
export function rowKey(r: CatalogueRow): string {
|
||||
return [r.scope ?? "plan", r.deployment ?? "", r.tier ?? "", r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
|
||||
}
|
||||
@@ -10,12 +10,14 @@ export const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
vuln_scanning: "Vulnerability scanning",
|
||||
status_pages: "Status pages",
|
||||
};
|
||||
|
||||
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",
|
||||
status_pages: "Public status pages for your customers, built from your monitors",
|
||||
};
|
||||
|
||||
export function featureLabel(key: string): string {
|
||||
|
||||
@@ -8,6 +8,14 @@ import type { NextConfig } from "next";
|
||||
*/
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
/* Plans and catalogue became one page. Both old paths are bookmarked in
|
||||
* staff browsers, so they redirect rather than 404. */
|
||||
async redirects() {
|
||||
return [
|
||||
{ source: "/staff/plans", destination: "/staff/pricing", permanent: true },
|
||||
{ source: "/staff/catalogue", destination: "/staff/pricing", permanent: true },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
+4
-3
@@ -3,14 +3,15 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/agent
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.20.0
|
||||
golang.org/x/sys v0.47.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/text v0.15.0 // indirect
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
)
|
||||
|
||||
+8
-6
@@ -1,11 +1,13 @@
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
|
||||
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||
|
||||
@@ -18,6 +18,10 @@ const (
|
||||
TypeTCP = "tcp"
|
||||
TypeICMP = "icmp"
|
||||
TypeTLS = "tls"
|
||||
|
||||
// UserAgent identifies Vantage monitor traffic so a WAF rule can single it
|
||||
// out. Match on a prefix, not equality: the version moves.
|
||||
UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)"
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +88,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
type streamWriter struct {
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/codec"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
encoding.RegisterCodec(JSONCodec{})
|
||||
encoding.RegisterCodec(codec.JSONCodec{})
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package grpcclient
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type JSONCodec struct{}
|
||||
|
||||
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (JSONCodec) Name() string {
|
||||
return "proto"
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
Hostname string `json:"hostname"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
OsInfo string `json:"os_info"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Label string `json:"label"`
|
||||
PrivateKey string `json:"private_key,omitempty"`
|
||||
}
|
||||
|
||||
type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
NewVersion string `json:"new_version"`
|
||||
}
|
||||
|
||||
type ReportUpdatesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Updates []PackageUpdate `json:"updates"`
|
||||
}
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
|
||||
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
Load1 float64 `json:"load1,omitempty"`
|
||||
}
|
||||
type MemReport struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type PartitionReport struct {
|
||||
Device string `json:"device"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Fstype string `json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type InventoryReport struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
IncludeStatic bool `json:"include_static"`
|
||||
CPU *CPUReport `json:"cpu,omitempty"`
|
||||
Memory *MemReport `json:"memory,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
Partitions []PartitionReport `json:"partitions,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
|
||||
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
ExpectedStatus int `json:"expected_status,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
TLSWarnDays int `json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
IntervalSec int `json:"interval_sec"`
|
||||
Retries int `json:"retries"`
|
||||
}
|
||||
type SyncMonitorsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
type SyncMonitorsResponse struct {
|
||||
Monitors []MonitorSpec `json:"monitors,omitempty"`
|
||||
}
|
||||
type CheckResult struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Up bool `json:"up"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
|
||||
}
|
||||
type ReportChecksRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Results []CheckResult `json:"results,omitempty"`
|
||||
}
|
||||
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"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
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"`
|
||||
|
||||
RefreshWorkloads *RefreshWorkloadsCmd `json:"refresh_workloads,omitempty"`
|
||||
ControlWorkload *ControlWorkloadCmd `json:"control_workload,omitempty"`
|
||||
WorkloadLogs *WorkloadLogsCmd `json:"workload_logs,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 {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UpdateAgentCmd struct {
|
||||
Version string `json:"version"`
|
||||
GiteaBaseURL string `json:"gitea_base_url"`
|
||||
}
|
||||
|
||||
type GenerateKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
KeySize int `json:"key_size,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
|
||||
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
|
||||
type CommandResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type RunStepCmd struct {
|
||||
Interpreter string `json:"interpreter"`
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
|
||||
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
OutputEnv map[string]string `json:"output_env,omitempty"`
|
||||
}
|
||||
|
||||
type StepOutputChunk struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Seq uint64 `json:"seq"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
Recv() (*ServerCommand, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageCommandStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
m := new(ServerCommand)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
Recv() (*AgentMessage, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type keyManagerCommandStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
m := new(AgentMessage)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, 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{}
|
||||
|
||||
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
|
||||
type keyManagerClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
|
||||
return &keyManagerClient{cc}
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
|
||||
out := new(RegisterResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
|
||||
out := new(SyncResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
|
||||
out := new(UploadKeyResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
|
||||
out := new(ReportUpdatesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
|
||||
out := new(SyncMonitorsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
|
||||
out := new(ReportChecksResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Workload registry messages. Hand-written like the rest of this package: the
|
||||
// .proto is the contract, this file is the Go side of it, and the two must be
|
||||
// changed together.
|
||||
|
||||
// Workload is one container or one systemd unit.
|
||||
type Workload struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Restarts int32 `json:"restarts,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running
|
||||
Protected bool `json:"protected,omitempty"`
|
||||
}
|
||||
|
||||
// ReportWorkloadsRequest carries what a server is running.
|
||||
//
|
||||
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
|
||||
// with Workloads empty, and resends with the body only if NeedFull is set.
|
||||
type ReportWorkloadsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
DockerOk bool `json:"docker_ok"`
|
||||
DockerError string `json:"docker_error,omitempty"`
|
||||
SystemdOk bool `json:"systemd_ok"`
|
||||
SystemdError string `json:"systemd_error,omitempty"`
|
||||
Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call
|
||||
// Full marks the second call. It is not inferred from an empty Workloads
|
||||
// slice: a host running nothing sends an empty list as its full report.
|
||||
Full bool `json:"full,omitempty"`
|
||||
}
|
||||
|
||||
type ReportWorkloadsResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
|
||||
// immediately through ReportWorkloads, so there is exactly one writer for the
|
||||
// server_workloads collection rather than two arriving by different routes.
|
||||
type RefreshWorkloadsCmd struct{}
|
||||
|
||||
type ControlWorkloadCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Action string `json:"action"` // start | stop | restart
|
||||
}
|
||||
|
||||
type WorkloadLogsCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Tail int32 `json:"tail,omitempty"`
|
||||
}
|
||||
|
||||
type WorkloadLogsResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) {
|
||||
out := new(ReportWorkloadsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
// without it this file compiles on Linux too and collides with collect_linux.go.
|
||||
package inventory
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
import "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package inventory
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
import "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
|
||||
func Collect(includeStatic bool) *pb.InventoryReport {
|
||||
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/checker"
|
||||
"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/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
const syncInterval = 30 * time.Second
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
// collectPackagesFlag is written by the 30s key poll and read by the hourly
|
||||
|
||||
@@ -20,12 +20,12 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
agentexec "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/exec"
|
||||
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/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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
@@ -450,6 +450,16 @@ func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
r := inventory.Collect(static)
|
||||
r.ServerId = cfg.ServerID
|
||||
r.AgentToken = cfg.AgentToken
|
||||
// Static snapshots only — every 15 minutes, not every 30 seconds. On
|
||||
// Windows this spawns a PowerShell process, which is not something to
|
||||
// do twice a minute forever, and a host rebooted by hand clearing the
|
||||
// flag within a quarter of an hour is soon enough.
|
||||
//
|
||||
// Computed here rather than inside inventory.Collect so the inventory
|
||||
// package gains no dependency on updates.
|
||||
if static {
|
||||
r.RebootRequired = updates.RebootRequired()
|
||||
}
|
||||
if err := client.ReportInventory(r); err != nil {
|
||||
log.Printf("report inventory: %v", err)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ package agentsync
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
"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/workloads"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
// workloadInterval is the report cadence. Sixty seconds is affordable because
|
||||
@@ -18,10 +17,6 @@ const workloadInterval = 60 * time.Second
|
||||
|
||||
// runWorkloads reports what this host runs, on its own ticker.
|
||||
func runWorkloads(ctx context.Context, cfg *config.Config) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
|
||||
reportWorkloads(cfg)
|
||||
|
||||
ticker := time.NewTicker(workloadInterval)
|
||||
@@ -42,10 +37,6 @@ func runWorkloads(ctx context.Context, cfg *config.Config) {
|
||||
// This is the ONLY writer of the server_workloads collection. RefreshWorkloadsCmd
|
||||
// calls straight into here rather than answering with data of its own.
|
||||
func reportWorkloads(cfg *config.Config) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -1,238 +1,24 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PackageUpdate is one pending update. On Linux it is a package with a version
|
||||
// on each side. On Windows CurrentVersion is empty and NewVersion carries the
|
||||
// KB article ID: a Windows update is not a version bump of a named package,
|
||||
// and inventing a current version would put a wrong string in front of an
|
||||
// operator.
|
||||
type PackageUpdate struct {
|
||||
Name string
|
||||
CurrentVersion string
|
||||
NewVersion string
|
||||
}
|
||||
|
||||
func detectPM() string {
|
||||
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
|
||||
if _, err := exec.LookPath(pm); err == nil {
|
||||
if pm == "apt-get" {
|
||||
return "apt"
|
||||
}
|
||||
return pm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// CheckAvailable lists pending OS updates.
|
||||
func CheckAvailable() ([]PackageUpdate, error) { return checkAvailable() }
|
||||
|
||||
// ApplyAll installs every pending update. It never reboots: a control plane
|
||||
// silently restarting a production server is unrecoverable from the UI, so the
|
||||
// reboot stays a decision a person or a workflow makes. RebootRequired reports
|
||||
// when one is owed.
|
||||
func ApplyAll() error { return applyAll() }
|
||||
|
||||
|
||||
func CheckAvailable() ([]PackageUpdate, error) {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
return checkApt()
|
||||
case "dnf":
|
||||
return checkDnfYum("dnf")
|
||||
case "yum":
|
||||
return checkDnfYum("yum")
|
||||
case "pacman":
|
||||
return checkPacman()
|
||||
case "zypper":
|
||||
return checkZypper()
|
||||
case "apk":
|
||||
return checkApk()
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func ApplyAll() error {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
|
||||
case "dnf":
|
||||
return exec.Command("dnf", "upgrade", "-y").Run()
|
||||
case "yum":
|
||||
return exec.Command("yum", "upgrade", "-y").Run()
|
||||
case "pacman":
|
||||
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
|
||||
case "zypper":
|
||||
return exec.Command("zypper", "update", "-y").Run()
|
||||
case "apk":
|
||||
return exec.Command("apk", "upgrade").Run()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkApt() ([]PackageUpdate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
|
||||
|
||||
out, err := exec.Command("apt", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.Contains(line, "[upgradable from:") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.SplitN(parts[0], "/", 2)[0]
|
||||
newVer := parts[1]
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
|
||||
rest := line[idx+len("upgradable from: "):]
|
||||
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkDnfYum(pm string) ([]PackageUpdate, error) {
|
||||
cmd := exec.Command(pm, "check-update")
|
||||
out, err := cmd.Output()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
pastHeader := false
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !pastHeader {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pastHeader = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.SplitN(parts[0], ".", 2)[0]
|
||||
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkPacman() ([]PackageUpdate, error) {
|
||||
out, _ := exec.Command("pacman", "-Qu").Output()
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
parts := strings.Fields(scanner.Text())
|
||||
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkZypper() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("zypper", "list-updates").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{
|
||||
Name: strings.TrimSpace(parts[2]),
|
||||
CurrentVersion: strings.TrimSpace(parts[3]),
|
||||
NewVersion: strings.TrimSpace(parts[4]),
|
||||
})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkApk() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("apk", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.Contains(line, "[upgradable") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
pkgVer := parts[0]
|
||||
name := apkName(pkgVer)
|
||||
newVer := apkVersion(pkgVer)
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
|
||||
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
|
||||
rest = strings.TrimSuffix(rest, "]")
|
||||
oldVer = apkVersion(strings.TrimSpace(rest))
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func apkName(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var name []string
|
||||
for _, p := range parts {
|
||||
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
break
|
||||
}
|
||||
name = append(name, p)
|
||||
}
|
||||
return strings.Join(name, "-")
|
||||
}
|
||||
|
||||
func apkVersion(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var ver []string
|
||||
inVer := false
|
||||
for _, p := range parts {
|
||||
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
inVer = true
|
||||
}
|
||||
if inVer {
|
||||
ver = append(ver, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(ver, "-")
|
||||
}
|
||||
// RebootRequired reports whether this host is waiting on a restart.
|
||||
func RebootRequired() bool { return rebootRequired() }
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func detectPM() string {
|
||||
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
|
||||
if _, err := exec.LookPath(pm); err == nil {
|
||||
if pm == "apt-get" {
|
||||
return "apt"
|
||||
}
|
||||
return pm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
func checkAvailable() ([]PackageUpdate, error) {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
return checkApt()
|
||||
case "dnf":
|
||||
return checkDnfYum("dnf")
|
||||
case "yum":
|
||||
return checkDnfYum("yum")
|
||||
case "pacman":
|
||||
return checkPacman()
|
||||
case "zypper":
|
||||
return checkZypper()
|
||||
case "apk":
|
||||
return checkApk()
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func applyAll() error {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
|
||||
case "dnf":
|
||||
return exec.Command("dnf", "upgrade", "-y").Run()
|
||||
case "yum":
|
||||
return exec.Command("yum", "upgrade", "-y").Run()
|
||||
case "pacman":
|
||||
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
|
||||
case "zypper":
|
||||
return exec.Command("zypper", "update", "-y").Run()
|
||||
case "apk":
|
||||
return exec.Command("apk", "upgrade").Run()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// rebootRequired reads what the distributions themselves record. Debian and
|
||||
// Ubuntu drop a file; the RPM family answers through needs-restarting, whose
|
||||
// exit code is 1 when a reboot is owed and 0 when it is not.
|
||||
func rebootRequired() bool {
|
||||
if _, err := os.Stat("/var/run/reboot-required"); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := exec.LookPath("dnf"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "dnf", "needs-restarting", "-r").Run(); err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkApt() ([]PackageUpdate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
|
||||
|
||||
out, err := exec.Command("apt", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.Contains(line, "[upgradable from:") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.SplitN(parts[0], "/", 2)[0]
|
||||
newVer := parts[1]
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
|
||||
rest := line[idx+len("upgradable from: "):]
|
||||
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkDnfYum(pm string) ([]PackageUpdate, error) {
|
||||
cmd := exec.Command(pm, "check-update")
|
||||
out, err := cmd.Output()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
pastHeader := false
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !pastHeader {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pastHeader = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.SplitN(parts[0], ".", 2)[0]
|
||||
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkPacman() ([]PackageUpdate, error) {
|
||||
out, _ := exec.Command("pacman", "-Qu").Output()
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
parts := strings.Fields(scanner.Text())
|
||||
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkZypper() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("zypper", "list-updates").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{
|
||||
Name: strings.TrimSpace(parts[2]),
|
||||
CurrentVersion: strings.TrimSpace(parts[3]),
|
||||
NewVersion: strings.TrimSpace(parts[4]),
|
||||
})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkApk() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("apk", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.Contains(line, "[upgradable") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
pkgVer := parts[0]
|
||||
name := apkName(pkgVer)
|
||||
newVer := apkVersion(pkgVer)
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
|
||||
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
|
||||
rest = strings.TrimSuffix(rest, "]")
|
||||
oldVer = apkVersion(strings.TrimSpace(rest))
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func apkName(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var name []string
|
||||
for _, p := range parts {
|
||||
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
break
|
||||
}
|
||||
name = append(name, p)
|
||||
}
|
||||
return strings.Join(name, "-")
|
||||
}
|
||||
|
||||
func apkVersion(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var ver []string
|
||||
inVer := false
|
||||
for _, p := range parts {
|
||||
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
inVer = true
|
||||
}
|
||||
if inVer {
|
||||
ver = append(ver, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(ver, "-")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
// 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 updates_linux.go.
|
||||
package updates
|
||||
|
||||
func checkAvailable() ([]PackageUpdate, error) { return nil, nil }
|
||||
func applyAll() error { return nil }
|
||||
func rebootRequired() bool { return false }
|
||||
@@ -0,0 +1,117 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
const (
|
||||
// The first search after a boot contacts Microsoft Update (or WSUS) and is
|
||||
// routinely slow. Ten minutes is not generous, it is realistic.
|
||||
searchTimeout = 10 * time.Minute
|
||||
|
||||
// A patch-Tuesday cumulative genuinely takes this long to download and
|
||||
// install on a modest server.
|
||||
applyTimeout = 60 * time.Minute
|
||||
|
||||
rebootTimeout = 2 * time.Minute
|
||||
)
|
||||
|
||||
// The Windows Update COM API is used rather than the PSWindowsUpdate module: it
|
||||
// is present on every supported Windows, needs no PowerShell Gallery install,
|
||||
// and works unchanged against a WSUS server on an air-gapped fleet. The agent
|
||||
// runs as LocalSystem, which holds the rights it requires.
|
||||
const searchScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
|
||||
$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
|
||||
$rows = @()
|
||||
foreach ($u in $result.Updates) {
|
||||
$ids = @($u.KBArticleIDs)
|
||||
$kb = ''
|
||||
if ($ids.Count -gt 0) { $kb = [string]$ids[0] }
|
||||
$rows += [pscustomobject]@{ title = [string]$u.Title; kb = $kb }
|
||||
}
|
||||
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
const applyScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$session = New-Object -ComObject Microsoft.Update.Session
|
||||
$result = $session.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software' and IsHidden=0")
|
||||
|
||||
$batch = New-Object -ComObject Microsoft.Update.UpdateColl
|
||||
foreach ($u in $result.Updates) {
|
||||
if ($u.InstallationBehavior.CanRequestUserInput) { continue }
|
||||
if (-not $u.EulaAccepted) {
|
||||
try { $u.AcceptEula() } catch { continue }
|
||||
}
|
||||
$null = $batch.Add($u)
|
||||
}
|
||||
|
||||
if ($batch.Count -eq 0) { Write-Output 'nothing-to-install'; exit 0 }
|
||||
|
||||
$downloader = $session.CreateUpdateDownloader()
|
||||
$downloader.Updates = $batch
|
||||
$null = $downloader.Download()
|
||||
|
||||
$installer = $session.CreateUpdateInstaller()
|
||||
$installer.Updates = $batch
|
||||
$r = $installer.Install()
|
||||
|
||||
Write-Output ('resultcode=' + $r.ResultCode)
|
||||
# 2 = succeeded, 3 = succeeded with errors. Anything else failed, and this
|
||||
# process must exit non-zero so the agent logs a failure rather than an ack.
|
||||
if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 }
|
||||
exit 0
|
||||
`
|
||||
|
||||
const rebootScript = `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$si = New-Object -ComObject Microsoft.Update.SystemInfo
|
||||
if ($si.RebootRequired) { Write-Output 'true'; exit 0 }
|
||||
$keys = @(
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending',
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
|
||||
)
|
||||
foreach ($k in $keys) { if (Test-Path $k) { Write-Output 'true'; exit 0 } }
|
||||
$sm = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations
|
||||
if ($sm -and $sm.PendingFileRenameOperations) { Write-Output 'true'; exit 0 }
|
||||
Write-Output 'false'
|
||||
`
|
||||
|
||||
func checkAvailable() ([]PackageUpdate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), searchTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := winexec.Run(ctx, searchScript)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("windows update search: %w", err)
|
||||
}
|
||||
return parseUpdateSearch(out)
|
||||
}
|
||||
|
||||
func applyAll() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), applyTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := winexec.Run(ctx, applyScript); err != nil {
|
||||
return fmt.Errorf("windows update install: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rebootRequired() bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rebootTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := winexec.Run(ctx, rebootScript)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(out) == "true"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// winUpdate is one row of the Windows Update searcher's output, in the shape
|
||||
// searchScript emits it.
|
||||
type winUpdate struct {
|
||||
Title string `json:"title"`
|
||||
KB string `json:"kb"`
|
||||
}
|
||||
|
||||
// parseUpdateSearch reads the searcher's JSON.
|
||||
//
|
||||
// It carries no build tag on purpose: this is the half of the Windows update
|
||||
// path that can be tested on a development machine, and the agent module has no
|
||||
// Windows CI.
|
||||
func parseUpdateSearch(jsonText string) ([]PackageUpdate, error) {
|
||||
s := strings.TrimSpace(jsonText)
|
||||
if s == "" || s == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rows []winUpdate
|
||||
if err := json.Unmarshal([]byte(s), &rows); err != nil {
|
||||
// ConvertTo-Json renders a one-element array as a bare object.
|
||||
var one winUpdate
|
||||
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = []winUpdate{one}
|
||||
}
|
||||
|
||||
out := make([]PackageUpdate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
u := PackageUpdate{Name: r.Title}
|
||||
if kb := strings.TrimSpace(r.KB); kb != "" {
|
||||
if !strings.HasPrefix(strings.ToUpper(kb), "KB") {
|
||||
kb = "KB" + kb
|
||||
}
|
||||
u.NewVersion = kb
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package updates
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseUpdateSearchArray(t *testing.T) {
|
||||
in := `[{"title":"2026-08 Cumulative Update for Windows Server 2022","kb":"5034123"},
|
||||
{"title":"Windows Malicious Software Removal Tool","kb":"890830"}]`
|
||||
|
||||
got, err := parseUpdateSearch(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUpdateSearch: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d updates, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "2026-08 Cumulative Update for Windows Server 2022" {
|
||||
t.Errorf("Name = %q", got[0].Name)
|
||||
}
|
||||
if got[0].NewVersion != "KB5034123" {
|
||||
t.Errorf("NewVersion = %q, want KB5034123", got[0].NewVersion)
|
||||
}
|
||||
if got[0].CurrentVersion != "" {
|
||||
t.Errorf("CurrentVersion = %q, want empty", got[0].CurrentVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// PowerShell 5.1's ConvertTo-Json collapses a one-element array into a bare
|
||||
// object. A host with exactly one pending update is common, and a parser that
|
||||
// only accepts arrays reports it as zero.
|
||||
func TestParseUpdateSearchSingleObject(t *testing.T) {
|
||||
got, err := parseUpdateSearch(`{"title":"Security Intelligence Update","kb":"2267602"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUpdateSearch: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].NewVersion != "KB2267602" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpdateSearchNoKB(t *testing.T) {
|
||||
got, err := parseUpdateSearch(`[{"title":"Driver update for Contoso NIC","kb":""}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUpdateSearch: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].NewVersion != "" {
|
||||
t.Fatalf("got %+v, want one update with an empty NewVersion", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty result set is "nothing pending", not a parse failure.
|
||||
func TestParseUpdateSearchEmpty(t *testing.T) {
|
||||
for _, in := range []string{"", " \r\n", "[]", "null"} {
|
||||
got, err := parseUpdateSearch(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUpdateSearch(%q): %v", in, err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("parseUpdateSearch(%q) = %+v, want none", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A KB already carrying its prefix must not become KBKB5034123.
|
||||
func TestParseUpdateSearchPrefixedKB(t *testing.T) {
|
||||
got, _ := parseUpdateSearch(`[{"title":"x","kb":"KB5034123"}]`)
|
||||
if got[0].NewVersion != "KB5034123" {
|
||||
t.Fatalf("NewVersion = %q", got[0].NewVersion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package winexec runs PowerShell on Windows hosts.
|
||||
//
|
||||
// It exists because three subsystems — updates, workload collection and
|
||||
// workload logs — all need the same invocation, and because getting a
|
||||
// multi-line script past Go quoting, cmd.exe quoting and PowerShell's own
|
||||
// parser is a problem worth solving once.
|
||||
package winexec
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// EncodeCommand renders a script for powershell.exe -EncodedCommand: UTF-16LE,
|
||||
// no byte-order mark, base64. This is deliberately free of build tags so it is
|
||||
// tested on a Linux development machine like every other pure function here.
|
||||
func EncodeCommand(script string) string {
|
||||
units := utf16.Encode([]rune(script))
|
||||
b := make([]byte, 0, len(units)*2)
|
||||
for _, u := range units {
|
||||
b = append(b, byte(u), byte(u>>8))
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package winexec
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEncodeCommand(t *testing.T) {
|
||||
// "hi" as UTF-16LE is 68 00 69 00, which base64-encodes to aABpAA==.
|
||||
if got := EncodeCommand("hi"); got != "aABpAA==" {
|
||||
t.Fatalf("EncodeCommand(hi) = %q, want aABpAA==", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeCommandMultiline(t *testing.T) {
|
||||
// Only that it round-trips through the same encoding PowerShell expects:
|
||||
// every ASCII byte followed by a zero byte, no BOM.
|
||||
got := EncodeCommand("a\nb")
|
||||
want := "YQAKAGIA"
|
||||
if got != want {
|
||||
t.Fatalf("EncodeCommand = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package winexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Run executes a PowerShell script and returns its stdout.
|
||||
//
|
||||
// powershell.exe rather than pwsh: everything this agent runs through here
|
||||
// touches Windows Update COM or CIM, both of which are most reliable under
|
||||
// Windows PowerShell 5.1, and 5.1 is present on every supported Windows while
|
||||
// pwsh is an optional install.
|
||||
func Run(ctx context.Context, script string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "powershell.exe",
|
||||
"-NoProfile", "-NonInteractive", "-EncodedCommand", EncodeCommand(script))
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Checked before the ExitError/stderr branch: CommandContext kills the
|
||||
// process on timeout, and that kill can itself produce an ExitError
|
||||
// carrying stderr text, so a genuine timeout would otherwise surface
|
||||
// as that stderr instead of the "timed out" message callers match on.
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "", fmt.Errorf("powershell: timed out")
|
||||
}
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return "", fmt.Errorf("powershell: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -4,9 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -14,34 +11,12 @@ import (
|
||||
// ErrProtected is returned for a workload the agent will not act on.
|
||||
var ErrProtected = errors.New("workload is protected")
|
||||
|
||||
// AgentUnit is the systemd unit this agent runs as.
|
||||
const AgentUnit = "vantage-agent.service"
|
||||
|
||||
// controlTimeout bounds a stop that may never finish on its own. `docker stop`
|
||||
// waits on a container that may ignore SIGTERM, and `systemctl stop` on a unit
|
||||
// with a long TimeoutStopSec blocks for exactly as long as that says. A
|
||||
// waits on a container that may ignore SIGTERM, and both systemctl and
|
||||
// Stop-Service block for as long as the unit's own stop timeout says. A
|
||||
// timeout must return a real error rather than an ack implying success.
|
||||
const controlTimeout = 90 * time.Second
|
||||
|
||||
// ownContainerID is read once: the container this agent runs in, if any.
|
||||
var ownContainerID = detectOwnContainer()
|
||||
|
||||
var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`)
|
||||
|
||||
// detectOwnContainer returns this process's container ID, or "" on a host
|
||||
// install. The agent is normally a systemd service, so "" is the common case;
|
||||
// this exists so containerising it later cannot silently remove the guard.
|
||||
func detectOwnContainer() string {
|
||||
b, err := os.ReadFile("/proc/self/cgroup")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if m := cgroupContainerRe.FindString(string(b)); m != "" {
|
||||
return m
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isProtected reports whether the agent refuses to act on this workload.
|
||||
//
|
||||
// The refusal lives here, in the agent, and not in the control plane. As with
|
||||
@@ -50,7 +25,7 @@ func detectOwnContainer() string {
|
||||
// denylist alone would be bypassed by the next dispatch path someone adds.
|
||||
func isProtected(kind, id, name string) bool {
|
||||
if kind == "unit" {
|
||||
return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
|
||||
return isProtectedUnit(id, name)
|
||||
}
|
||||
if ownContainerID == "" {
|
||||
return false
|
||||
@@ -85,21 +60,5 @@ func Control(ctx context.Context, kind, id, action string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, controlTimeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", action, id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "systemctl", action, id)
|
||||
default:
|
||||
return fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
return controlPlatform(ctx, kind, id, action)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentUnit is the systemd unit this agent runs as.
|
||||
const AgentUnit = "vantage-agent.service"
|
||||
|
||||
// ownContainerID is read once: the container this agent runs in, if any.
|
||||
var ownContainerID = detectOwnContainer()
|
||||
|
||||
var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`)
|
||||
|
||||
// detectOwnContainer returns this process's container ID, or "" on a host
|
||||
// install. The agent is normally a systemd service, so "" is the common case;
|
||||
// this exists so containerising it later cannot silently remove the guard.
|
||||
func detectOwnContainer() string {
|
||||
b, err := os.ReadFile("/proc/self/cgroup")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if m := cgroupContainerRe.FindString(string(b)); m != "" {
|
||||
return m
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isProtectedUnit(id, name string) bool {
|
||||
return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
|
||||
}
|
||||
|
||||
func controlPlatform(ctx context.Context, kind, id, action string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", action, id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "systemctl", action, id)
|
||||
default:
|
||||
return fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
// AgentUnit is the service this agent runs as — the NSSM service name written
|
||||
// by installer/setup.ps1. Change one, change the other.
|
||||
const AgentUnit = "VantageAgent"
|
||||
|
||||
// A Windows agent is never itself in a container; the Linux build reads
|
||||
// /proc/self/cgroup, and there is no equivalent question to ask here.
|
||||
var ownContainerID = ""
|
||||
|
||||
// Windows service names are case-insensitive, so the comparison must be too.
|
||||
func isProtectedUnit(id, name string) bool {
|
||||
return strings.EqualFold(id, AgentUnit) || strings.EqualFold(name, AgentUnit)
|
||||
}
|
||||
|
||||
func controlPlatform(ctx context.Context, kind, id, action string) error {
|
||||
switch kind {
|
||||
case "container":
|
||||
// Docker behaves identically on Windows, so this path is shared in
|
||||
// spirit with the Linux one rather than routed through PowerShell.
|
||||
cmd := exec.CommandContext(ctx, "docker", action, id)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
|
||||
case "unit":
|
||||
// -Force is required: Stop-Service without it refuses outright when
|
||||
// another service depends on the target, and that refusal reads to an
|
||||
// operator as a silent no-op.
|
||||
//
|
||||
// sc.exe is avoided because it returns before the operation completes,
|
||||
// which turns a timeout into a false success.
|
||||
var verb string
|
||||
switch action {
|
||||
case "start":
|
||||
verb = "Start-Service"
|
||||
case "stop":
|
||||
verb = "Stop-Service"
|
||||
case "restart":
|
||||
verb = "Restart-Service"
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
|
||||
script := "$ErrorActionPreference='Stop'\n" + verb + " -Name " + psQuote(id)
|
||||
if action != "start" {
|
||||
script += " -Force"
|
||||
}
|
||||
|
||||
if _, err := winexec.Run(ctx, script); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %w", action, id, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,6 @@ package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -35,26 +32,12 @@ func Logs(ctx context.Context, kind, id string, tail int) (string, bool, error)
|
||||
ctx, cancel := context.WithTimeout(ctx, logTimeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", "logs",
|
||||
"--tail", strconv.Itoa(tail), "--timestamps", id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "journalctl", "-u", id,
|
||||
"-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso")
|
||||
default:
|
||||
return "", false, fmt.Errorf("unknown workload kind %q", kind)
|
||||
out, err := logsPlatform(ctx, kind, id, tail)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
// docker logs writes container stderr to our stderr, so both streams must
|
||||
// be captured or half the output silently disappears.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return "", false, fmt.Errorf("read logs for %s: %s", id, errText(err))
|
||||
}
|
||||
|
||||
text, truncated := capLog(string(out))
|
||||
text, truncated := capLog(out)
|
||||
return text, truncated, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", "logs",
|
||||
"--tail", strconv.Itoa(tail), "--timestamps", id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "journalctl", "-u", id,
|
||||
"-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso")
|
||||
default:
|
||||
return "", fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
|
||||
// docker logs writes container stderr to our stderr, so both streams must
|
||||
// be captured or half the output silently disappears.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return "", fmt.Errorf("read logs for %s: %s", id, errText(err))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd := exec.CommandContext(ctx, "docker", "logs",
|
||||
"--tail", strconv.Itoa(tail), "--timestamps", id)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return "", fmt.Errorf("read logs for %s: %s", id, errText(err))
|
||||
}
|
||||
return string(out), nil
|
||||
|
||||
case "unit":
|
||||
display := serviceDisplayName(ctx, id)
|
||||
|
||||
// Timestamps are formatted PowerShell-side rather than left to
|
||||
// ConvertTo-Json, whose DateTime rendering differs between PowerShell
|
||||
// versions — one of them emits /Date(1699...)/.
|
||||
//
|
||||
// $ErrorActionPreference = 'SilentlyContinue' because Get-WinEvent
|
||||
// treats "no events matched" as a terminating error, and a quiet
|
||||
// service is normal.
|
||||
names := psQuote(id)
|
||||
if display != "" && display != id {
|
||||
names += "," + psQuote(display)
|
||||
}
|
||||
names += "," + psQuote(scmProvider)
|
||||
|
||||
// ProviderName includes the host-wide Service Control Manager, so a
|
||||
// -MaxEvents cap of exactly tail would apply to the combined stream
|
||||
// before parseEvents narrows SCM rows down to this service — on a
|
||||
// host with busy service churn the target's own events could be
|
||||
// squeezed out of the window entirely. Over-fetch instead, hard-capped
|
||||
// so a pathological host cannot pull an unbounded batch across the
|
||||
// wire, and let parseEvents trim to the last tail lines after
|
||||
// filtering.
|
||||
fetch := tail * 5
|
||||
if fetch > 2500 {
|
||||
fetch = 2500
|
||||
}
|
||||
|
||||
script := `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$rows = Get-WinEvent -FilterHashtable @{LogName='System','Application'; ProviderName=@(` + names + `)} ` +
|
||||
`-MaxEvents ` + strconv.Itoa(fetch) + ` |
|
||||
ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
t = $_.TimeCreated.ToUniversalTime().ToString('o')
|
||||
l = [string]$_.LevelDisplayName
|
||||
p = [string]$_.ProviderName
|
||||
m = [string]$_.Message
|
||||
}
|
||||
}
|
||||
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
out, err := winexec.Run(ctx, script)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read events for %s: %w", id, err)
|
||||
}
|
||||
return parseEvents(out, id, display, tail)
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceDisplayName resolves a service's display name, which is what Service
|
||||
// Control Manager events name it by. An empty answer is fine — the filter then
|
||||
// matches on the service name alone.
|
||||
func serviceDisplayName(ctx context.Context, id string) string {
|
||||
out, err := winexec.Run(ctx,
|
||||
"$ErrorActionPreference='SilentlyContinue'\n"+
|
||||
"(Get-Service -Name "+psQuote(id)+").DisplayName")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return trimLine(out)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
const servicesTimeout = 60 * time.Second
|
||||
|
||||
const servicesScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$svcs = Get-CimInstance Win32_Service | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
Name = $_.Name
|
||||
DisplayName = $_.DisplayName
|
||||
State = $_.State
|
||||
StartMode = $_.StartMode
|
||||
PathName = $_.PathName
|
||||
ExitCode = $_.ExitCode
|
||||
}
|
||||
}
|
||||
ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
// collectUnits enumerates Windows services. The bool and string it returns are
|
||||
// the same SystemdOK / SystemdError pair the Linux collector fills: the wire
|
||||
// shape is shared, and the UI words it per platform.
|
||||
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, servicesTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := winexec.Run(ctx, servicesScript)
|
||||
if err != nil {
|
||||
return nil, false, "Win32_Service query failed: " + err.Error()
|
||||
}
|
||||
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
|
||||
wls, err := parseServices(out, systemRoot)
|
||||
if err != nil {
|
||||
return nil, false, "Win32_Service output could not be read: " + err.Error()
|
||||
}
|
||||
return wls, true, ""
|
||||
}
|
||||
@@ -14,10 +14,10 @@ const systemdTimeout = 30 * time.Second
|
||||
// ten anyone cares about.
|
||||
var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"}
|
||||
|
||||
// collectSystemd enumerates services in two passes, because "running or
|
||||
// collectUnits enumerates services in two passes, because "running or
|
||||
// failed" and "enabled but stopped" are different questions — and an enabled
|
||||
// unit that is not running is exactly the one worth seeing.
|
||||
func collectSystemd(ctx context.Context) ([]Workload, bool, string) {
|
||||
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return nil, false, ""
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
// The build constraint is load-bearing — see updates_other.go.
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ownContainerID = ""
|
||||
|
||||
func collectUnits(context.Context) ([]Workload, bool, string) { return nil, false, "" }
|
||||
func isProtectedUnit(string, string) bool { return false }
|
||||
|
||||
func controlPlatform(context.Context, string, string, string) error {
|
||||
return fmt.Errorf("workload control is not supported on this platform")
|
||||
}
|
||||
|
||||
func logsPlatform(context.Context, string, string, int) (string, error) {
|
||||
return "", fmt.Errorf("workload logs are not supported on this platform")
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// winService is one row of Get-CimInstance Win32_Service.
|
||||
//
|
||||
// Win32_Service rather than Get-Service: Get-Service exposes neither PathName
|
||||
// nor StartMode, and the filter below needs both.
|
||||
type winService struct {
|
||||
Name string `json:"Name"`
|
||||
DisplayName string `json:"DisplayName"`
|
||||
State string `json:"State"`
|
||||
StartMode string `json:"StartMode"`
|
||||
PathName string `json:"PathName"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
}
|
||||
|
||||
// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service
|
||||
// carrying it has not failed — it has not run since boot — and painting that
|
||||
// red would cry wolf on every host.
|
||||
const exitCodeNeverStarted = 1077
|
||||
|
||||
// servicePath extracts the executable from a Win32_Service PathName.
|
||||
//
|
||||
// A naive split on whitespace misfiles a substantial share of a real fleet:
|
||||
// `"C:\Program Files\X\x.exe" -service` is one path and one argument.
|
||||
func servicePath(pathName string) string {
|
||||
s := strings.TrimSpace(pathName)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if s[0] == '"' {
|
||||
if end := strings.IndexByte(s[1:], '"'); end >= 0 {
|
||||
return s[1 : 1+end]
|
||||
}
|
||||
// No closing quote: a malformed or truncated PathName. Fall back to
|
||||
// the unquoted handling below on the text after the opening quote,
|
||||
// so this yields a bare path rather than a path plus trailing
|
||||
// argument text.
|
||||
s = s[1:]
|
||||
}
|
||||
if i := exeBoundaryIndex(s); i >= 0 {
|
||||
return s[:i+len(".exe")]
|
||||
}
|
||||
if i := strings.IndexAny(s, " \t"); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// exeBoundaryIndex finds the first ".exe" (case-insensitive) in s that
|
||||
// actually ends the executable name — followed by end-of-string, whitespace,
|
||||
// or a double quote — rather than continuing into a longer segment such as
|
||||
// ".exec". It returns -1 when no such occurrence exists, so a path like
|
||||
// `C:\Program Files\Ad.exec\tool.com -flag` is not misparsed by matching the
|
||||
// ".exe" inside "Ad.exec" and silently dropping the real filename.
|
||||
func exeBoundaryIndex(s string) int {
|
||||
lower := strings.ToLower(s)
|
||||
from := 0
|
||||
for {
|
||||
rel := strings.Index(lower[from:], ".exe")
|
||||
if rel < 0 {
|
||||
return -1
|
||||
}
|
||||
idx := from + rel
|
||||
end := idx + len(".exe")
|
||||
if end == len(s) || s[end] == ' ' || s[end] == '\t' || s[end] == '"' {
|
||||
return idx
|
||||
}
|
||||
from = idx + 1
|
||||
}
|
||||
}
|
||||
|
||||
// parseServices turns the collector's JSON into workloads.
|
||||
//
|
||||
// systemRoot is a parameter rather than an environment read so this is testable
|
||||
// off Windows. The caller passes %SystemRoot%.
|
||||
//
|
||||
// The filter mirrors the systemd collector's intent: show what an operator
|
||||
// installed, and show what is meant to be up but is not. Services under
|
||||
// %SystemRoot%\System32 are the platform's own, and a typical host has well
|
||||
// over a hundred of them.
|
||||
func parseServices(jsonText, systemRoot string) ([]Workload, error) {
|
||||
s := strings.TrimSpace(jsonText)
|
||||
if s == "" || s == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rows []winService
|
||||
if err := json.Unmarshal([]byte(s), &rows); err != nil {
|
||||
var one winService
|
||||
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = []winService{one}
|
||||
}
|
||||
|
||||
sys32 := strings.ToLower(strings.TrimRight(systemRoot, `\`) + `\system32\`)
|
||||
|
||||
var wls []Workload
|
||||
for _, r := range rows {
|
||||
if p := strings.ToLower(servicePath(r.PathName)); p != "" && strings.HasPrefix(p, sys32) {
|
||||
continue
|
||||
}
|
||||
|
||||
running := strings.EqualFold(r.State, "Running")
|
||||
failed := !running && r.ExitCode != 0 && r.ExitCode != exitCodeNeverStarted
|
||||
auto := strings.HasPrefix(strings.ToLower(r.StartMode), "auto")
|
||||
if !running && !failed && !auto {
|
||||
continue
|
||||
}
|
||||
|
||||
// The wire shape is shared with the systemd collector — both report
|
||||
// under kind "unit" — so the state word has to be too, or the UI
|
||||
// (which colours and filters on it, and does so before it knows
|
||||
// which platform sent the row) needs two vocabularies for one kind.
|
||||
// running/stopped/failed become active/inactive/failed to match.
|
||||
state := "inactive"
|
||||
switch {
|
||||
case running:
|
||||
state = "active"
|
||||
case failed:
|
||||
state = "failed"
|
||||
}
|
||||
|
||||
name := r.DisplayName
|
||||
if name == "" {
|
||||
name = r.Name
|
||||
}
|
||||
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: r.Name,
|
||||
Name: name,
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
return wls, nil
|
||||
}
|
||||
|
||||
// psQuote renders a Go string as a PowerShell single-quoted literal. Single
|
||||
// quotes suppress every form of expansion, so the only character needing an
|
||||
// escape is the quote itself, which is doubled.
|
||||
func psQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// scmProvider is the provider every service's start and stop is logged under,
|
||||
// host-wide.
|
||||
const scmProvider = "Service Control Manager"
|
||||
|
||||
type winEvent struct {
|
||||
T string `json:"t"`
|
||||
L string `json:"l"`
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
}
|
||||
|
||||
// parseEvents renders Get-WinEvent output as text in the shape journalctl
|
||||
// --output=short-iso produces, so the log dialog needs no per-platform
|
||||
// rendering: "<timestamp> <level> <message>", oldest first.
|
||||
//
|
||||
// The caller over-fetches from Get-WinEvent because the ProviderName filter
|
||||
// includes the host-wide Service Control Manager, and a -MaxEvents cap
|
||||
// applied before SCM rows are narrowed down to this service would squeeze the
|
||||
// target's own events out of the window on a host with busy service churn.
|
||||
// tail is therefore applied here, AFTER filtering and AFTER the oldest-first
|
||||
// reversal, keeping the last tail lines — the most recent lines are the ones
|
||||
// worth keeping, matching capLog's front-trim reasoning in the shared
|
||||
// logs.go.
|
||||
func parseEvents(jsonText, serviceName, displayName string, tail int) (string, error) {
|
||||
s := strings.TrimSpace(jsonText)
|
||||
if s == "" || s == "null" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var rows []winEvent
|
||||
if err := json.Unmarshal([]byte(s), &rows); err != nil {
|
||||
var one winEvent
|
||||
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
|
||||
return "", err
|
||||
}
|
||||
rows = []winEvent{one}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, e := range rows {
|
||||
if strings.EqualFold(e.P, scmProvider) {
|
||||
if !strings.Contains(e.M, serviceName) &&
|
||||
(displayName == "" || !strings.Contains(e.M, displayName)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Collapse every newline form, not just "\r\n": a message containing a
|
||||
// bare "\n" would otherwise still break the one-line-per-event shape
|
||||
// this renders for the log dialog, and undercount the tail trim above.
|
||||
msg := strings.TrimSpace(strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(e.M))
|
||||
lines = append(lines, e.T+" "+e.L+" "+msg)
|
||||
}
|
||||
|
||||
// Get-WinEvent is newest-first. Reverse it.
|
||||
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
|
||||
lines[i], lines[j] = lines[j], lines[i]
|
||||
}
|
||||
|
||||
if tail > 0 && len(lines) > tail {
|
||||
lines = lines[len(lines)-tail:]
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
// trimLine reduces single-value PowerShell output to its first non-empty line.
|
||||
func trimLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if t := strings.TrimSpace(l); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServicePath(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{`"C:\Program Files\Contoso\svc.exe" -service`, `C:\Program Files\Contoso\svc.exe`},
|
||||
{`C:\WINDOWS\system32\svchost.exe -k netsvcs`, `C:\WINDOWS\system32\svchost.exe`},
|
||||
{`C:\Vantage\vantage-agent.exe`, `C:\Vantage\vantage-agent.exe`},
|
||||
{`"C:\no\args.exe"`, `C:\no\args.exe`},
|
||||
{``, ``},
|
||||
// ".exe" appearing inside an earlier segment ("Ad.exec") must not be
|
||||
// treated as the end of the executable — that would drop the real
|
||||
// filename and arguments.
|
||||
{`C:\Program Files\Ad.exec\tool.com -flag`, `C:\Program`},
|
||||
// An unterminated quote falls back to the unquoted handling on the
|
||||
// text after the opening quote, yielding a bare path rather than a
|
||||
// path plus trailing argument text.
|
||||
{`"C:\Program Files\Contoso\svc.exe -service`, `C:\Program Files\Contoso\svc.exe`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := servicePath(c.in); got != c.want {
|
||||
t.Errorf("servicePath(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServicesFilters(t *testing.T) {
|
||||
in := `[
|
||||
{"Name":"Contoso","DisplayName":"Contoso Broker","State":"Running","StartMode":"Auto","PathName":"\"C:\\Program Files\\Contoso\\svc.exe\" -service","ExitCode":0},
|
||||
{"Name":"Themes","DisplayName":"Themes","State":"Running","StartMode":"Auto","PathName":"C:\\WINDOWS\\system32\\svchost.exe -k netsvcs","ExitCode":0},
|
||||
{"Name":"Fabrikam","DisplayName":"Fabrikam Sync","State":"Stopped","StartMode":"Auto","PathName":"C:\\Fabrikam\\sync.exe","ExitCode":0},
|
||||
{"Name":"Northwind","DisplayName":"Northwind Poller","State":"Stopped","StartMode":"Manual","PathName":"C:\\Northwind\\poll.exe","ExitCode":0},
|
||||
{"Name":"Crashed","DisplayName":"Crashed Thing","State":"Stopped","StartMode":"Auto","PathName":"C:\\Crashed\\c.exe","ExitCode":1067}
|
||||
]`
|
||||
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
|
||||
byID := map[string]Workload{}
|
||||
for _, w := range got {
|
||||
byID[w.ID] = w
|
||||
}
|
||||
|
||||
// The OS's own svchost service is dropped; a manual, stopped, never-failed
|
||||
// service is nobody's business either.
|
||||
if _, ok := byID["Themes"]; ok {
|
||||
t.Error("Themes (under %SystemRoot%) should be filtered out")
|
||||
}
|
||||
if _, ok := byID["Northwind"]; ok {
|
||||
t.Error("stopped Manual service should be filtered out")
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d workloads, want 3: %+v", len(got), got)
|
||||
}
|
||||
|
||||
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "active" {
|
||||
t.Errorf("Contoso = %+v", w)
|
||||
}
|
||||
// Enabled but not running is exactly the row worth seeing.
|
||||
if byID["Fabrikam"].State != "inactive" {
|
||||
t.Errorf("Fabrikam state = %q, want inactive", byID["Fabrikam"].State)
|
||||
}
|
||||
// A non-zero exit code on a stopped service is a crash, not a clean stop.
|
||||
if byID["Crashed"].State != "failed" {
|
||||
t.Errorf("Crashed state = %q, want failed", byID["Crashed"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// 1077 means "no attempt to start since boot" — a clean stopped service, not a
|
||||
// failure, and reporting it red would cry wolf on every host.
|
||||
func TestParseServicesExitCode1077(t *testing.T) {
|
||||
in := `[{"Name":"Idle","DisplayName":"Idle","State":"Stopped","StartMode":"Auto","PathName":"C:\\Idle\\i.exe","ExitCode":1077}]`
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].State != "inactive" {
|
||||
t.Fatalf("got %+v, want one inactive workload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServicesSingleObjectAndEmpty(t *testing.T) {
|
||||
one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}`
|
||||
got, err := parseServices(one, `C:\WINDOWS`)
|
||||
if err != nil || len(got) != 1 || got[0].State != "active" {
|
||||
t.Fatalf("single object: got %+v, err %v", got, err)
|
||||
}
|
||||
|
||||
for _, in := range []string{"", "[]", "null"} {
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("parseServices(%q) = %+v, err %v", in, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPSQuote(t *testing.T) {
|
||||
if got := psQuote(`it's`); got != `'it''s'` {
|
||||
t.Fatalf("psQuote = %s", got)
|
||||
}
|
||||
if got := psQuote(`plain`); got != `'plain'` {
|
||||
t.Fatalf("psQuote = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventsFormatsAndOrders(t *testing.T) {
|
||||
// Get-WinEvent returns newest first; journalctl --output=short-iso returns
|
||||
// oldest first, and the log dialog and capLog's front-trim both assume the
|
||||
// most recent line is at the bottom.
|
||||
in := `[
|
||||
{"t":"2026-08-13T10:22:31.0000000Z","l":"Error","p":"Contoso","m":"broker died"},
|
||||
{"t":"2026-08-13T10:22:03.0000000Z","l":"Information","p":"Contoso","m":"broker starting"}
|
||||
]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
|
||||
want := "2026-08-13T10:22:03.0000000Z Information broker starting\n" +
|
||||
"2026-08-13T10:22:31.0000000Z Error broker died"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A message containing a bare "\n" (no carriage return) must still collapse to
|
||||
// one line, or it silently multiplies into several output lines and throws
|
||||
// off the tail trim's count.
|
||||
func TestParseEventsCollapsesBareLF(t *testing.T) {
|
||||
in := `[{"t":"2026-08-13T10:00:00Z","l":"Error","p":"Contoso","m":"broker died\nstack trace here"}]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
if strings.Count(got, "\n") != 0 {
|
||||
t.Fatalf("parseEvents did not collapse bare LF into one line: %q", got)
|
||||
}
|
||||
want := "2026-08-13T10:00:00Z Error broker died stack trace here"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Service Control Manager logs every service on the host under one provider, so
|
||||
// its rows must be filtered down to the target or the log is somebody else's.
|
||||
func TestParseEventsFiltersOtherServicesSCM(t *testing.T) {
|
||||
in := `[
|
||||
{"t":"2026-08-13T10:00:00Z","l":"Information","p":"Service Control Manager","m":"The Print Spooler service entered the running state."},
|
||||
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Service Control Manager","m":"The Contoso Broker service entered the running state."}
|
||||
]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
if strings.Contains(got, "Print Spooler") {
|
||||
t.Errorf("another service's SCM event leaked in:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Contoso Broker") {
|
||||
t.Errorf("the target's SCM event was dropped:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A service that has logged nothing is normal. An error there would read as a
|
||||
// broken feature.
|
||||
func TestParseEventsEmpty(t *testing.T) {
|
||||
for _, in := range []string{"", "[]", "null"} {
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil || got != "" {
|
||||
t.Fatalf("parseEvents(%q) = %q, err %v", in, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The over-fetch in logs_windows.go can return more events than the caller
|
||||
// asked for once SCM rows are filtered down to the target; parseEvents must
|
||||
// keep the most RECENT tail lines, not the oldest, matching capLog's
|
||||
// front-trim reasoning in the shared logs.go.
|
||||
func TestParseEventsTrimsToTailKeepingMostRecent(t *testing.T) {
|
||||
in := `[
|
||||
{"t":"2026-08-13T10:00:06Z","l":"Information","p":"Contoso","m":"event 6"},
|
||||
{"t":"2026-08-13T10:00:05Z","l":"Information","p":"Contoso","m":"event 5"},
|
||||
{"t":"2026-08-13T10:00:04Z","l":"Information","p":"Contoso","m":"event 4"},
|
||||
{"t":"2026-08-13T10:00:03Z","l":"Information","p":"Contoso","m":"event 3"},
|
||||
{"t":"2026-08-13T10:00:02Z","l":"Information","p":"Contoso","m":"event 2"},
|
||||
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Contoso","m":"event 1"}
|
||||
]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
|
||||
want := "2026-08-13T10:00:05Z Information event 5\n" +
|
||||
"2026-08-13T10:00:06Z Information event 6"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -19,15 +18,12 @@ type Result struct {
|
||||
SystemdError string
|
||||
}
|
||||
|
||||
// Collect enumerates every workload on this host. Linux only.
|
||||
// Collect enumerates every workload on this host: containers from Docker, and
|
||||
// units from systemd on Linux or the service control manager on Windows.
|
||||
func Collect(ctx context.Context) Result {
|
||||
if runtime.GOOS != "linux" {
|
||||
return Result{}
|
||||
}
|
||||
|
||||
var r Result
|
||||
containers, dockerOK, dockerErr := collectDocker(ctx)
|
||||
units, systemdOK, systemdErr := collectSystemd(ctx)
|
||||
units, systemdOK, systemdErr := collectUnits(ctx)
|
||||
|
||||
r.DockerOK, r.DockerError = dockerOK, dockerErr
|
||||
r.SystemdOK, r.SystemdError = systemdOK, systemdErr
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Current cloud instance process
|
||||
|
||||
The current processs for creating cloud instances is incorrect.
|
||||
|
||||
At the moment the cloud instance process is the following:
|
||||
|
||||
- Customer goes to `https://vantage.hostxtra.co.uk/start` then fills in the form.
|
||||
- Customer is then sent and email to verify
|
||||
- Customer clicks the link and the instance is created in the DB.
|
||||
- Customer can then access the instance.
|
||||
|
||||
As the `/start` process is auto creating a new instance this should default to the free tier instance.
|
||||
|
||||
The issue is that this doesn't create an `account` and `admin_instance` on the admin side.
|
||||
|
||||
The Cloud instance creation / account creation needs to be restructured.
|
||||
|
||||
for context when I say `hq` I mean `admin`
|
||||
|
||||
- customer goes to `https://vantage.hostxtra.co.uk/start` and fills in the form.
|
||||
- This is where the HQ account is created.
|
||||
- The customer is then sent and email to verify their email address.
|
||||
- The customer can then access the HQ customer portal.
|
||||
- The customer can then create a free new instance in the HQ portal.
|
||||
- The cloud instance is created in the DB.
|
||||
- The HQ `account` and `admin_instance` is created and populated in the DB.
|
||||
- A `Free` License is created and attached to the instance.
|
||||
- The customer is then sent an email letting them know the instance has been created and when the license expires.
|
||||
- The customer will need to renew the license after expiry, if they are on a Free license.
|
||||
- This is so that unused instances can be cleaned up if no renew after a length of time has passed.
|
||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: vantage
|
||||
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
|
||||
type: application
|
||||
version: 1.0.8
|
||||
version: 1.1.0
|
||||
appVersion: "1.0.8"
|
||||
|
||||
@@ -41,12 +41,9 @@ Ingress (Traefik):
|
||||
{{- 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 }}
|
||||
{{ join ", " .Values.ingress.api.paths }} go to the server; everything else to web.
|
||||
web proxies nothing, so those paths must be routed here or by a terminator
|
||||
in front of this ingress.
|
||||
{{- 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 . }}.
|
||||
@@ -70,3 +67,13 @@ 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 }}
|
||||
{{- if not .Values.backup.enabled }}
|
||||
|
||||
No backups are scheduled. Vantage encrypts SSH private keys, vault secrets and
|
||||
SSO client secrets with KEY_ENCRYPTION_KEY, and that key is not stored anywhere
|
||||
but your own configuration — a database restored without it is permanently
|
||||
unreadable.
|
||||
|
||||
Set backup.enabled, backup.image and backup.pvcName, and store
|
||||
KEY_ENCRYPTION_KEY somewhere that survives this cluster.
|
||||
{{- end }}
|
||||
|
||||
@@ -72,6 +72,8 @@ both read it.
|
||||
value: {{ .Values.server.env.proxyAdvertiseHost | quote }}
|
||||
- name: PROXY_LISTEN_HOST
|
||||
value: {{ .Values.server.env.proxyListenHost | quote }}
|
||||
- name: TRUSTED_PROXIES
|
||||
value: {{ .Values.server.env.trustedProxies | quote }}
|
||||
{{- if eq .Values.server.env.deploymentType "cloud" }}
|
||||
- name: VANTAGE_DEPLOYMENT
|
||||
value: "cloud"
|
||||
@@ -83,3 +85,18 @@ both read it.
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
vantage.backup.env renders the environment vantagectl needs.
|
||||
|
||||
It reads the SAME values the server does rather than taking its own, because a
|
||||
backup that connected to a different database, or stamped a fingerprint of a
|
||||
different key, than the deployment it is backing up would be worse than no
|
||||
backup: it would look like one.
|
||||
*/}}
|
||||
{{- define "vantage.backup.env" -}}
|
||||
- name: MONGO_URI
|
||||
value: {{ tpl .Values.server.env.mongoUri . | quote }}
|
||||
- name: KEY_ENCRYPTION_KEY
|
||||
value: {{ .Values.server.env.keyEncryptionKey | quote }}
|
||||
{{- end -}}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{{- if .Values.backup.enabled }}
|
||||
{{- if not .Values.backup.pvcName }}
|
||||
{{- fail "backup.enabled requires backup.pvcName: a backup needs somewhere durable to land, and the chart cannot guess where that is" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.backup.image }}
|
||||
{{- fail "backup.enabled requires backup.image: the vantagectl image to run" }}
|
||||
{{- end }}
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: {{ include "vantage.fullname" . }}-backup
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backup
|
||||
spec:
|
||||
schedule: {{ .Values.backup.schedule | quote }}
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit }}
|
||||
failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit }}
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 12 }}
|
||||
app.kubernetes.io/component: backup
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: vantagectl
|
||||
image: {{ .Values.backup.image | quote }}
|
||||
args:
|
||||
- backup
|
||||
- --out
|
||||
- /backups
|
||||
{{- with .Values.backup.exclude }}
|
||||
- --exclude
|
||||
- {{ join "," . | quote }}
|
||||
{{- end }}
|
||||
env:
|
||||
# Referenced, never redeclared. A backup job holding its own
|
||||
# copy of KEY_ENCRYPTION_KEY is a second place for it to be
|
||||
# wrong, and the fingerprint it stamps would then be a
|
||||
# fingerprint of the wrong key.
|
||||
{{- include "vantage.backup.env" . | nindent 16 }}
|
||||
volumeMounts:
|
||||
- name: backups
|
||||
mountPath: /backups
|
||||
resources:
|
||||
{{- toYaml .Values.backup.resources | nindent 16 }}
|
||||
volumes:
|
||||
- name: backups
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.backup.pvcName | quote }}
|
||||
{{- end }}
|
||||
@@ -2,16 +2,14 @@
|
||||
{{/*
|
||||
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).
|
||||
Browsers reach the web host, and the path decides what answers: /api, /auth,
|
||||
/public, /install* and /update* go to the server, everything else to `web`.
|
||||
|
||||
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.
|
||||
That split is not optional and ingress.api.enabled defaults to true. `web`
|
||||
proxies nothing — it holds no address for the server at all — so with these
|
||||
paths absent the UI loads and every request it makes 404s against Next. The
|
||||
setting remains a value only so an installation terminating in front of this
|
||||
ingress can route the prefixes itself; it must be routed somewhere.
|
||||
|
||||
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
|
||||
@@ -35,6 +33,9 @@ its HTTP port too.
|
||||
{{- if and .Values.ingress.api.enabled (not $apiPaths) }}
|
||||
{{- fail "ingress.api.enabled requires at least one path in ingress.api.paths" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.ingress.api.enabled }}
|
||||
{{- fail "ingress.api.enabled=false leaves /api, /auth and /public unrouted: web proxies nothing. Route those prefixes to the server at your own terminator, or leave this enabled." }}
|
||||
{{- end }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
|
||||
@@ -38,12 +38,10 @@ spec:
|
||||
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.
|
||||
# /healthz is served by this Next process. /api never reaches this
|
||||
# pod at all — the ingress routes it to the server — so there is no
|
||||
# backend address to configure and no probe here that could report
|
||||
# the backend's health by accident.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
|
||||
@@ -63,6 +63,7 @@ server:
|
||||
appRootLabel: vantage
|
||||
proxyAdvertiseHost: "{{ .Release.Name }}-server"
|
||||
proxyListenHost: "0.0.0.0"
|
||||
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
persistence:
|
||||
enabled: false
|
||||
size: 1Gi
|
||||
@@ -78,8 +79,6 @@ web:
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
env:
|
||||
apiUrl: "http://{{ .Release.Name }}-server:8080"
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
@@ -89,11 +88,14 @@ ingress:
|
||||
web:
|
||||
host: ""
|
||||
extraHosts: []
|
||||
# Not optional: web proxies nothing, so these prefixes reach the server
|
||||
# only through this ingress. Turning it off serves the UI with a dead API.
|
||||
api:
|
||||
enabled: false
|
||||
enabled: true
|
||||
paths:
|
||||
- /api/
|
||||
- /auth/
|
||||
- /public/
|
||||
- /update
|
||||
- /install
|
||||
- /update.ps1
|
||||
@@ -109,3 +111,25 @@ ingress:
|
||||
certResolver: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
# Scheduled backups.
|
||||
#
|
||||
# Off by default, deliberately. A backup with nowhere durable to land is a
|
||||
# false sense of safety, and the chart cannot know where that is — pvcName
|
||||
# must name a volume you have decided will outlive the cluster.
|
||||
#
|
||||
# There is no restore manifest here on purpose: a restore is an operator
|
||||
# decision with a confirmation attached, and must never be something a
|
||||
# `helm upgrade` can trigger. Run one as a `kubectl run` Job with
|
||||
# --confirm-db.
|
||||
backup:
|
||||
enabled: false
|
||||
schedule: "0 2 * * *"
|
||||
image: ""
|
||||
pvcName: ""
|
||||
# Collections to leave out. Recorded in each archive's manifest, so an
|
||||
# archive can never claim to be complete when it is not.
|
||||
exclude: []
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
resources: {}
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
PROXY_ADVERTISE_HOST: server
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -59,8 +60,10 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
API_URL: ${API_URL:-http://server:8080}
|
||||
# No API_URL: web proxies nothing. The reverse proxy in front of this
|
||||
# deployment must route /api, /auth, /public, /install*, /update* to
|
||||
# server:8080 and everything else to web:3000. Reaching web:3000
|
||||
# directly serves the UI and every API call 404s.
|
||||
depends_on:
|
||||
- server
|
||||
volumes:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,938 +0,0 @@
|
||||
# Instance Rename in Vantage HQ — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let an HQ customer (owner or admin) rename a cloud instance, which re-derives its slug and moves it to a new DNS host, with staff able to do the same without the cooldown.
|
||||
|
||||
**Architecture:** Slug derivation stays in `shared/provision`, beside the create path that already owns it. Admin reaches the control plane only through `cloudprov`, writing `instances` — a collection it already writes. Admin's own row (`admin_instances`) is updated second and carries the 24h cooldown timestamp, because the cooldown is admin's policy and the control plane has no opinion about it. The portal shows the new host and asks the customer to click through; it does not redirect.
|
||||
|
||||
**Note:** This repo has no automated test suite and the user has ruled out adding test files. Every task verifies by build, vet and (Task 8) manual exercise.
|
||||
|
||||
**Tech Stack:** Go 1.x (gin, mongo-driver v2), Next.js 16 App Router + TanStack Query + Tailwind 3 (`adminsite`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-12-instance-rename-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- A licence binds an instance **UUID**, not a slug. A rename must not issue a licence, call Paddle, or touch `licenses`, `subscriptions` or `entitlements`.
|
||||
- Admin's control-plane write boundary is unchanged: `cloudprov` writes `instances` and `users` only. Do not add a write to any other control-plane collection.
|
||||
- Customer rename is **cloud only**. Self-hosted is refused with the existing `selfHostedRefusal` constant and HTTP **400**, matching `members.go`.
|
||||
- Cooldown for customers is **24 hours**, tracked by `admin_instances.renamed_at`. Staff bypass it and must **not** write `renamed_at`.
|
||||
- No `-2` suffix loop on rename. A taken slug is a refusal (`ErrSlugTaken` → HTTP 409).
|
||||
- No component in `adminsite` may carry a hex colour; use the existing token classes (`text-ink-2`, `text-ink-3`, `border-rule`, `text-accent`, `text-expired`, `bg-panel-2`).
|
||||
- The host domain used for display is `vantage.hostxtra.co.uk`, already hardcoded in `adminsite/components/InstanceRecord.tsx` and the customer instance page.
|
||||
- Commit messages follow the repo's existing style: `feat: Sentence case summary` / `fix: …` / `docs: …`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Slug derivation and the control-plane rename
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/provision/instance.go`
|
||||
**Interfaces:**
|
||||
- Consumes: `BaseSlug(name string) (string, error)`, `ErrNameRejected` — both already in `shared/provision`.
|
||||
- Produces:
|
||||
- `provision.ErrSlugTaken` (`error`)
|
||||
- `provision.RenameSlug(name, currentSlug string) (string, error)`
|
||||
- `provision.RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error)`
|
||||
- `provision.RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error`
|
||||
|
||||
Behaviour `RenameSlug` must have, verified by reading rather than by test (this
|
||||
repo has no Go test suite and the user has ruled out adding one):
|
||||
|
||||
| Input name | Current slug | Result |
|
||||
|---|---|---|
|
||||
| `Acme Ltd` | `acme` | `acme-ltd` |
|
||||
| `ACME!` | `acme` | `acme` — still derives to the current slug, so not a move |
|
||||
| `Acme` | `acme-2` | `acme` — a creation-time collision suffix derives from no name, so moving off it is a real move |
|
||||
| `ab` | any | `ErrNameRejected` |
|
||||
| `Admin` | any | `ErrNameRejected` (reserved) |
|
||||
| `!!!` | any | `ErrNameRejected` |
|
||||
| 50 `a`s | any | truncated to `MaxSlugLength`, exactly as `BaseSlug` truncates on create |
|
||||
|
||||
- [ ] **Step 1: Write the implementation**
|
||||
|
||||
Append to `shared/provision/instance.go`:
|
||||
|
||||
```go
|
||||
// ErrSlugTaken means the slug a new name derives to already belongs to another
|
||||
// instance.
|
||||
//
|
||||
// Rename refuses rather than appending a counter the way creation does. Creation
|
||||
// appends because the customer is waiting on an instance and any free slug will
|
||||
// do; a rename is a request for one specific host, and silently landing them on
|
||||
// "acme-2" answers a question they did not ask.
|
||||
var ErrSlugTaken = errors.New("slug taken")
|
||||
|
||||
// RenameSlug derives the slug a rename to name would move an instance to, given
|
||||
// the slug it holds now.
|
||||
//
|
||||
// It returns the current slug unchanged when the name still derives to it, so a
|
||||
// cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move
|
||||
// and cannot collide with the instance's own slug.
|
||||
func RenameSlug(name, currentSlug string) (string, error) {
|
||||
base, err := BaseSlug(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
|
||||
}
|
||||
if base == currentSlug {
|
||||
return currentSlug, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// RenameInstance changes an instance's name and re-derives its slug from it.
|
||||
//
|
||||
// The count-then-update is racy on its own, and is safe for the same reason
|
||||
// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a
|
||||
// lost race surfaces as a duplicate-key error. Unlike creation there is nothing
|
||||
// to retry with — the caller asked for one specific name — so it becomes
|
||||
// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the
|
||||
// index.
|
||||
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) {
|
||||
var inst models.Instance
|
||||
if err := db.Collection("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slug, err := RenameSlug(name, inst.Slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if slug != inst.Slug {
|
||||
n, err := db.Collection("instances").CountDocuments(ctx, bson.M{
|
||||
"slug": slug,
|
||||
"instance_id": bson.M{"$ne": instanceID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Collection("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"name": name, "slug": slug}}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inst.Name = name
|
||||
inst.Slug = slug
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity writes an exact name and slug back, unwinding a rename
|
||||
// whose caller-side bookkeeping then failed.
|
||||
//
|
||||
// It derives nothing. The values being restored may include a creation-time
|
||||
// collision suffix that no name derives to, so re-running RenameInstance with the
|
||||
// old name would not reproduce them.
|
||||
func RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error {
|
||||
_, err := db.Collection("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"name": name, "slug": slug}})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and vet**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./shared/... && go vet ./shared/provision/`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add shared/provision/instance.go
|
||||
git commit -m "feat: Add instance rename to shared provisioning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Admin's row and the cloudprov wrappers
|
||||
|
||||
**Files:**
|
||||
- Modify: `admin/internal/models/models.go` (the `Instance` struct, ~line 129; constants block near `RenewWindow`, ~line 105)
|
||||
- Modify: `admin/internal/cloudprov/cloudprov.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `provision.RenameInstance`, `provision.RestoreInstanceIdentity` (Task 1).
|
||||
- Produces:
|
||||
- `models.RenameCooldown` (`time.Duration`)
|
||||
- `models.Instance.RenamedAt *time.Time` (bson `renamed_at`, json `renamed_at`)
|
||||
- `cloudprov.RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error)`
|
||||
- `cloudprov.RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error`
|
||||
|
||||
- [ ] **Step 1: Add the cooldown constant**
|
||||
|
||||
In `admin/internal/models/models.go`, directly beneath the `RenewWindow` block:
|
||||
|
||||
```go
|
||||
// RenameCooldown is how long a customer must wait between renames of one
|
||||
// instance.
|
||||
//
|
||||
// A rename moves the instance's DNS host and invalidates every saved link to it,
|
||||
// so this exists to make that a considered act rather than a slider. Staff are
|
||||
// not subject to it: a support conversation about a name is already a human
|
||||
// deciding.
|
||||
const RenameCooldown = 24 * time.Hour
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the field to `Instance`**
|
||||
|
||||
In the same file, inside the `Instance` struct, after `RelinkCount`:
|
||||
|
||||
```go
|
||||
// RenamedAt is when this instance last changed name, and backs the customer
|
||||
// rename cooldown. It is a pointer because absent means "never renamed"; a
|
||||
// zero time.Time would read as year 1 — an inert cooldown, but only by
|
||||
// accident. Staff renames deliberately leave it alone.
|
||||
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the cloudprov wrappers**
|
||||
|
||||
Append to `admin/internal/cloudprov/cloudprov.go`:
|
||||
|
||||
```go
|
||||
// RenameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// It writes `instances` and nothing else, so admin's control-plane write
|
||||
// boundary is unchanged. It issues no licence: a licence binds the instance
|
||||
// UUID, which a rename never touches.
|
||||
func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error) {
|
||||
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity puts an instance's previous name and slug back, for a
|
||||
// caller unwinding a rename whose admin-side write failed. Leaving the two
|
||||
// databases disagreeing would have HQ print a host that is not the host.
|
||||
func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error {
|
||||
return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./admin/... ./shared/...`
|
||||
Expected: clean build, no output.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/internal/models/models.go admin/internal/cloudprov/cloudprov.go
|
||||
git commit -m "feat: Add rename cooldown field and cloudprov rename"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Customer rename endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `admin/internal/api/customer.go` (add handler; `loginURLFor` at ~line 442 is already in this file)
|
||||
- Modify: `admin/internal/api/routes.go` (~line 77, beside the other `/instances/:id/*` customer routes)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ownedInstance(c, id) (*models.Instance, bool)`, `selfHostedRefusal` (`members.go`), `loginURLFor(slug) string`, `cloudprov.RenameInstance`, `cloudprov.RestoreInstanceIdentity`, `models.RenameCooldown`, `provision.ErrSlugTaken`, `provision.ErrNameRejected`.
|
||||
- Produces: `PUT /api/instances/:id/name` returning `{instance_id, name, slug, login_url}`.
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
Append to `admin/internal/api/customer.go`:
|
||||
|
||||
```go
|
||||
// renameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// The control plane is written FIRST, because instances.slug carries the unique
|
||||
// index and that index is what actually settles a race between two accounts
|
||||
// reaching for the same name. Admin's own row follows; if that write fails the
|
||||
// control plane is put back, because HQ printing a host that is not the host is
|
||||
// worse than a failed rename.
|
||||
//
|
||||
// No licence is issued and Paddle is not called: a licence binds the instance
|
||||
// UUID, and a rename does not change it.
|
||||
func renameInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
|
||||
return
|
||||
}
|
||||
if inst.Placeholder {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if inst.RenamedAt != nil {
|
||||
if until := inst.RenamedAt.Add(models.RenameCooldown); time.Now().UTC().Before(until) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
|
||||
"retry_after": until,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{
|
||||
"name": renamed.Name,
|
||||
"slug": renamed.Slug,
|
||||
"renamed_at": time.Now().UTC(),
|
||||
}}); err != nil {
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
|
||||
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: inst.Slug + " -> " + renamed.Slug, IP: c.ClientIP()})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instance_id": inst.InstanceID,
|
||||
"name": renamed.Name,
|
||||
"slug": renamed.Slug,
|
||||
// The same builder the licence emails use, rather than a second opinion
|
||||
// about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset.
|
||||
"login_url": loginURLFor(renamed.Slug),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Check the imports**
|
||||
|
||||
`customer.go` must import `errors`, `fmt`, `log`, `net/http`, `strings`, `time`, `audit`, `auth`, `cloudprov`, `db`, `models`, `license`, `provision`, `gin`, `bson`. Most are already there — add only what the compiler asks for. `provision` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision`; `license` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/license`.
|
||||
|
||||
- [ ] **Step 3: Mount the route**
|
||||
|
||||
In `admin/internal/api/routes.go`, in the `cust` group beside the other instance routes (after `cust.POST("/instances/:id/claim-free", …)`):
|
||||
|
||||
```go
|
||||
// Renaming moves the instance's DNS host, so it is owner-or-admin like
|
||||
// every other instance mutation. Cloud only; the handler refuses the rest.
|
||||
cust.PUT("/instances/:id/name",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
renameInstance)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/internal/api/customer.go admin/internal/api/routes.go
|
||||
git commit -m "feat: Add customer instance rename endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Staff rename endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `admin/internal/api/staff.go`
|
||||
- Modify: `admin/internal/api/routes.go` (the `staff` group, beside `staff.POST("/instances/:id/relink", …)`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything Task 3 consumes, plus `db.Admin`.
|
||||
- Produces: `PUT /api/staff/instances/:id/name` returning `{instance_id, name, slug}`.
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
Append to `admin/internal/api/staff.go`:
|
||||
|
||||
```go
|
||||
// staffRenameInstance renames any instance, with no cooldown.
|
||||
//
|
||||
// It does NOT write renamed_at: a staff rename must not start the customer's
|
||||
// 24h clock, or fixing a name for someone locks them out of fixing it further.
|
||||
//
|
||||
// On self-hosted it changes admin's label only. There is no control-plane row to
|
||||
// write — the install is the customer's — and no slug, because self-hosted has
|
||||
// no tenant subdomain.
|
||||
func staffRenameInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
set := bson.M{"name": name}
|
||||
slug := inst.Slug
|
||||
|
||||
if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
|
||||
renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
slug = renamed.Slug
|
||||
set["slug"] = renamed.Slug
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
|
||||
if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
|
||||
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
|
||||
Target: inst.InstanceID, Detail: inst.Slug + " -> " + slug, IP: c.ClientIP()})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
|
||||
}
|
||||
```
|
||||
|
||||
`staff.go` will need `errors`, `log`, `cloudprov` and `provision` added to its imports; `fmt`, `net/http`, `strings`, `time`, `audit`, `auth`, `db`, `models`, `license`, `bson` are already there.
|
||||
|
||||
- [ ] **Step 2: Mount the route**
|
||||
|
||||
In `routes.go`, in the `staff` group after `staff.POST("/instances/:id/relink", staffRelink)`:
|
||||
|
||||
```go
|
||||
staff.PUT("/instances/:id/name", staffRenameInstance)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/internal/api/staff.go admin/internal/api/routes.go
|
||||
git commit -m "feat: Add staff instance rename endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `adminsite` API client and slug preview
|
||||
|
||||
**Files:**
|
||||
- Create: `adminsite/lib/slug.ts`
|
||||
- Modify: `adminsite/lib/api.ts` (the `Instance` interface ~line 123; the `api` object's instance calls ~line 305; `api.staff` ~line 360)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PUT /api/instances/:id/name`, `PUT /api/staff/instances/:id/name` (Tasks 3 and 4).
|
||||
- Produces:
|
||||
- `INSTANCE_DOMAIN`, `slugify(name: string): string`, `slugError(name: string): string | undefined` from `@/lib/slug`
|
||||
- `RenameResult` interface, `api.renameInstance(id, name): Promise<RenameResult>`, `api.staff.renameInstance(id, name): Promise<RenameResult>`
|
||||
- `Instance.renamed_at?: string`
|
||||
|
||||
- [ ] **Step 1: Create the slug mirror**
|
||||
|
||||
Create `adminsite/lib/slug.ts`:
|
||||
|
||||
```ts
|
||||
/*
|
||||
* A TypeScript mirror of shared/provision's slug rules, used ONLY to preview the
|
||||
* host a rename would move an instance to while the customer types.
|
||||
*
|
||||
* It is a second implementation of Slugify, BaseSlug and ReservedSlugs, and it
|
||||
* must change in the same commit as the Go one — the same hazard as
|
||||
* web/lib/targets.ts. The preview is a courtesy; the server's 409 is the
|
||||
* boundary, and the two are allowed to disagree without anything breaking.
|
||||
*/
|
||||
|
||||
/** Mirrors provision.MinSlugLength / MaxSlugLength. */
|
||||
export const MIN_SLUG_LENGTH = 3;
|
||||
export const MAX_SLUG_LENGTH = 40;
|
||||
|
||||
/** Mirrors provision.ReservedSlugs. */
|
||||
const RESERVED = new Set([
|
||||
"www", "api", "app", "admin", "auth",
|
||||
"install", "static", "_next", "default",
|
||||
]);
|
||||
|
||||
/*
|
||||
* The tenant subdomain namespace. Also hardcoded in InstanceRecord.tsx and the
|
||||
* customer instance page; those predate this file and are left alone rather than
|
||||
* refactored under a rename change.
|
||||
*/
|
||||
export const INSTANCE_DOMAIN = "vantage.hostxtra.co.uk";
|
||||
|
||||
/** Mirrors provision.Slugify. */
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Mirrors provision.BaseSlug's truncation. */
|
||||
export function baseSlug(name: string): string {
|
||||
return slugify(name).slice(0, MAX_SLUG_LENGTH);
|
||||
}
|
||||
|
||||
/** The reason a name cannot become a slug, or undefined when it can. */
|
||||
export function slugError(name: string): string | undefined {
|
||||
const base = slugify(name);
|
||||
if (base.length < MIN_SLUG_LENGTH) {
|
||||
return `Needs at least ${MIN_SLUG_LENGTH} letters or digits.`;
|
||||
}
|
||||
if (RESERVED.has(base.slice(0, MAX_SLUG_LENGTH))) {
|
||||
return "That name is reserved.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The host an instance on this slug is reached at. */
|
||||
export function hostFor(slug: string): string {
|
||||
return `${slug}.${INSTANCE_DOMAIN}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the API client**
|
||||
|
||||
In `adminsite/lib/api.ts`, add `renamed_at` to `Instance` (after `relink_count`):
|
||||
|
||||
```ts
|
||||
renamed_at?: string;
|
||||
```
|
||||
|
||||
Add the response type beside the other interfaces:
|
||||
|
||||
```ts
|
||||
export interface RenameResult {
|
||||
instance_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
/** Empty when APP_LOGIN_URL is unset on the server. */
|
||||
login_url?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Add the call to the `api` object, after `renewInstance`:
|
||||
|
||||
```ts
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/instances/${id}/name`, { name }),
|
||||
```
|
||||
|
||||
And to `api.staff`, after `relink`:
|
||||
|
||||
```ts
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/staff/instances/${id}/name`, { name }),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `cd /go-projects/vantage/adminsite && npx tsc --noEmit`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add adminsite/lib/slug.ts adminsite/lib/api.ts
|
||||
git commit -m "feat: Add rename calls and slug preview to the HQ client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: The rename panel and the customer instance page
|
||||
|
||||
**Files:**
|
||||
- Create: `adminsite/components/RenamePanel.tsx`
|
||||
- Modify: `adminsite/app/(customer)/instances/[id]/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.renameInstance` / `api.staff.renameInstance`, `RenameResult` (Task 5); `slugError`, `baseSlug`, `hostFor` (Task 5); `Panel`, `Note` (`@/components/Panel`), `Button` (`@/components/Button`), `Field` (`@/components/Field`), `ApiError` (`@/lib/api`).
|
||||
- Produces: `RenamePanel({ currentName, currentSlug, onRename })` — a default-collapsed control; `onRename` is `(name: string) => Promise<RenameResult>`.
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
Create `adminsite/components/RenamePanel.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Field } from "./Field";
|
||||
import { Note } from "./Panel";
|
||||
import { ApiError, type RenameResult } from "@/lib/api";
|
||||
import { baseSlug, hostFor, slugError } from "@/lib/slug";
|
||||
|
||||
/*
|
||||
* The rename control, and only the control — the same shape as RelinkPanel: an
|
||||
* input that expands in place rather than a modal, because this app has no modal
|
||||
* and one action with one field does not need one.
|
||||
*
|
||||
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
|
||||
* disagree with the server; the 409 that comes back is the answer that counts.
|
||||
*/
|
||||
export function RenamePanel({
|
||||
currentName,
|
||||
currentSlug,
|
||||
onRename,
|
||||
}: {
|
||||
currentName: string;
|
||||
currentSlug: string;
|
||||
onRename: (name: string) => Promise<RenameResult>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(currentName);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState<RenameResult | undefined>();
|
||||
|
||||
const name = value.trim();
|
||||
const derived = baseSlug(name);
|
||||
const invalid = slugError(name);
|
||||
// A cosmetic edit that lands on the same slug is still a rename worth doing —
|
||||
// the name is what the customer reads. Only an empty or unchanged name is
|
||||
// nothing to submit.
|
||||
const unchanged = name === currentName.trim();
|
||||
|
||||
async function submit() {
|
||||
setError(undefined);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await onRename(name);
|
||||
setDone(res);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
const host = done.login_url || `https://${hostFor(done.slug)}`;
|
||||
return (
|
||||
<Note tone="warn">
|
||||
<span className="grid gap-2">
|
||||
<span>
|
||||
This instance is now <strong>{done.name}</strong>, at{" "}
|
||||
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and your
|
||||
sign-in does not follow it — you will need to sign in again there.
|
||||
</span>
|
||||
<a href={host} className="justify-self-start font-mono text-[0.78rem] text-accent underline">
|
||||
Open {hostFor(done.slug)} →
|
||||
</a>
|
||||
</span>
|
||||
</Note>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{open && (
|
||||
<Field
|
||||
label="Instance name"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error ?? (name ? invalid : undefined)}
|
||||
hint={
|
||||
name && !invalid ? (
|
||||
<>
|
||||
Moves to <span className="font-mono">{hostFor(derived)}</span>
|
||||
{derived === currentSlug && " — the address does not change"}
|
||||
</>
|
||||
) : (
|
||||
"Letters and digits; everything else becomes a hyphen."
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
|
||||
onClick={() => (open ? submit() : setOpen(true))}
|
||||
>
|
||||
{busy ? "Renaming…" : "Rename instance"}
|
||||
</Button>
|
||||
{open && (
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`Note` is `({ tone = "accent" | "warn" | "expired", children })` and renders a `<p>`, which is why the success state wraps its two lines in a `<span className="grid gap-2">` rather than block elements.
|
||||
|
||||
- [ ] **Step 2: Mount it on the customer instance page**
|
||||
|
||||
In `adminsite/app/(customer)/instances/[id]/page.tsx`:
|
||||
|
||||
Add the imports:
|
||||
|
||||
```tsx
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```tsx
|
||||
import { useSession } from "@/lib/session";
|
||||
```
|
||||
|
||||
Inside `InstancePage`, with the other hooks (hooks must precede the early returns already in this component):
|
||||
|
||||
```tsx
|
||||
// useSession is the app's one way to ask who the caller is — it shares the
|
||||
// ["me"] query, so this adds no request.
|
||||
const { session } = useSession();
|
||||
```
|
||||
|
||||
and after the `cloud` const:
|
||||
|
||||
```tsx
|
||||
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
|
||||
```
|
||||
|
||||
Then add the panel to `PageFrame`'s children, directly after the `MembersPanel` line:
|
||||
|
||||
```tsx
|
||||
{/*
|
||||
* Address rather than "Rename": the panel is about where this
|
||||
* instance lives, and the rename is how you change it. Cloud
|
||||
* only — a self-hosted install has no tenant subdomain for us to
|
||||
* move.
|
||||
*/}
|
||||
{cloud && mayRename && (
|
||||
<Panel title="Address" meta={host ?? undefined}>
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
|
||||
one, so saved links and bookmarks to it stop working.
|
||||
</p>
|
||||
<RenamePanel
|
||||
currentName={instance.name}
|
||||
currentSlug={instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.renameInstance(instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage/adminsite && npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add adminsite/components/RenamePanel.tsx "adminsite/app/(customer)/instances/[id]/page.tsx"
|
||||
git commit -m "feat: Let a customer rename a cloud instance from HQ"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Staff instance page rename
|
||||
|
||||
**Files:**
|
||||
- Modify: `adminsite/app/(staff)/staff/instances/[id]/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RenamePanel` (Task 6), `api.staff.renameInstance` (Task 5).
|
||||
- Produces: nothing later tasks depend on.
|
||||
|
||||
- [ ] **Step 1: Add the panel**
|
||||
|
||||
In `adminsite/app/(staff)/staff/instances/[id]/page.tsx`, add the imports:
|
||||
|
||||
```tsx
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
```
|
||||
|
||||
and, inside `StaffInstancePage`, add `const qc = useQueryClient();` at the top of the component if it is not already there (`useQueryClient` is already imported for `EntitlementSection`).
|
||||
|
||||
Add this panel after the "Licence history" panel:
|
||||
|
||||
```tsx
|
||||
{/*
|
||||
* Staff rename has no cooldown and does not start the customer's:
|
||||
* fixing a name on someone's behalf must not spend their next 24
|
||||
* hours.
|
||||
*/}
|
||||
<Panel title="Name" meta={data.instance.deployment === "cloud" ? "Moves the address" : "Label only"}>
|
||||
<RenamePanel
|
||||
currentName={data.instance.name}
|
||||
currentSlug={data.instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.staff.renameInstance(data.instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage/adminsite && npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add "adminsite/app/(staff)/staff/instances/[id]/page.tsx"
|
||||
git commit -m "feat: Let staff rename an instance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Documentation and end-to-end verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (the Admin REST API route list, and the `admin_instances` note under MongoDB Collections)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything above.
|
||||
- Produces: nothing.
|
||||
|
||||
- [ ] **Step 1: Update the Admin REST API route list**
|
||||
|
||||
In `CLAUDE.md`, in the customer-session block, after the `POST /instances/:id/claim-free` line:
|
||||
|
||||
```
|
||||
PUT /instances/:id/name # rename a cloud instance; moves its slug (owner|admin, 24h cooldown)
|
||||
```
|
||||
|
||||
and in the staff-session block, after `POST /instances/:id/issue · /instances/:id/relink`:
|
||||
|
||||
```
|
||||
PUT /instances/:id/name # rename any instance, no cooldown
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the design note**
|
||||
|
||||
In `CLAUDE.md`, under "Grants project, they do not federate" (admin's control-plane write boundary is described nearby), add a short paragraph:
|
||||
|
||||
```markdown
|
||||
**A rename moves the host, and the licence does not care.** `PUT
|
||||
/api/instances/:id/name` re-derives the slug from the new name through
|
||||
`provision.RenameSlug` — the same rules that named the instance at creation —
|
||||
and writes the control plane first, because `instances.slug`'s unique index is
|
||||
what settles a race between two accounts reaching for one name. A taken slug is
|
||||
a refusal, not an `acme-2`: creation appends a counter because any free slug
|
||||
will do, and a rename is a request for one specific host. A licence binds the
|
||||
instance UUID, so nothing is reissued and Paddle is not called. The old host
|
||||
keeps resolving for up to 60s (`instancehost.go`'s cache, which admin cannot
|
||||
reach into), and `km_session` is host-only, so the customer signs in again on
|
||||
the new address — the portal says so rather than redirecting them into a login
|
||||
screen with no explanation. The 24h cooldown lives on `admin_instances.renamed_at`
|
||||
because it is admin's policy; staff bypass it and must not write the field.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Full build**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /go-projects/vantage && go build ./... && go vet ./admin/... ./shared/... && (cd adminsite && npm run build)
|
||||
```
|
||||
Expected: all clean.
|
||||
|
||||
- [ ] **Step 4: Manual verification against a running stack**
|
||||
|
||||
Work through each and record the result:
|
||||
|
||||
1. Rename a cloud instance from `/instances/<id>` as an owner. Panel reports the new host.
|
||||
2. In Mongo: `db.instances.findOne({instance_id})` and `db.admin_instances.findOne({instance_id})` agree on `name` and `slug`; `admin_instances.renamed_at` is set.
|
||||
3. The new host serves a login page. The old host stops resolving to the instance within ~60 seconds.
|
||||
4. A second rename inside 24 hours answers `429` with the unlock time.
|
||||
5. Renaming onto a slug another instance holds answers `409` and changes neither database.
|
||||
6. `PUT /api/instances/:id/name` on a self-hosted instance answers `400` with the `selfHostedRefusal` message.
|
||||
7. `GET /api/staff/audit` shows `instance.renamed` with `old-slug -> new-slug`.
|
||||
8. Staff rename of the same instance succeeds immediately and leaves `renamed_at` unchanged.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: Document instance rename in HQ"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Refresh the knowledge graph**
|
||||
|
||||
```bash
|
||||
graphify update .
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user