refactor: stand up vantage-agent as its own repository
agent/ becomes the repository root, so the module is gitea.hostxtra.co.uk/vantage/vantage-agent, and installer/ comes with it. Releases move to this repository, and internal/sync's self-update URLs move with them. Agents built before this point have the old mrhid6/vantage path compiled in and will fail the push-button update; re-running the server-generated /update one-liner on each host moves them onto a build that knows the new address.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
name: Agent Release
|
||||
|
||||
# The tag stays "agent/v*" rather than becoming a bare "v*", even though this
|
||||
# repository is only the agent now. The tag is not a private detail: it is the
|
||||
# release the fleet downloads by, it is what UpdateAgentCmd carries, and the
|
||||
# control plane finds the newest one by grepping tag names for that exact
|
||||
# prefix. Renaming it would be a second breaking change stacked on the
|
||||
# repository move.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "agent/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: go.sum
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#agent/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-linux-amd64 ./cmd
|
||||
GOOS=linux GOARCH=arm64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-linux-arm64 ./cmd
|
||||
GOOS=windows GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-windows-amd64.exe ./cmd
|
||||
|
||||
- name: Checksums
|
||||
working-directory: dist
|
||||
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt
|
||||
|
||||
- name: Create release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
files: |
|
||||
dist/vantage-agent-linux-amd64
|
||||
dist/vantage-agent-linux-arm64
|
||||
dist/vantage-agent-windows-amd64.exe
|
||||
dist/checksums.txt
|
||||
|
||||
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:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$v = "${{ github.ref_name }}" -replace '^agent/v', ''
|
||||
"VERSION=$v" | Out-File -Append $env:GITHUB_OUTPUT
|
||||
# MSI ProductVersion must be numeric x.x.x.x
|
||||
"MSIVERSION=$v.0" | Out-File -Append $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Build agent exe
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
$env:GOOS = "windows"; $env:GOARCH = "amd64"
|
||||
go build -ldflags="-s -w -X main.Version=$env:VERSION" -o installer/vantage-agent-windows-amd64.exe ./cmd
|
||||
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.*
|
||||
|
||||
- name: Build MSI
|
||||
working-directory: installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools"
|
||||
wix build vantage-agent.wxs -d Version=${{ steps.version.outputs.MSIVERSION }} -o vantage-agent.msi
|
||||
(Get-FileHash vantage-agent.msi -Algorithm SHA256).Hash.ToLower() + " vantage-agent.msi" | Out-File -Encoding ascii checksums-msi.txt
|
||||
|
||||
- name: Attach MSI to release
|
||||
working-directory: installer
|
||||
shell: pwsh
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
$tag = [uri]::EscapeDataString("${{ github.ref_name }}")
|
||||
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||
# gitea-release-action can't find a slashed tag, so append via the API directly
|
||||
$rel = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$tag"
|
||||
foreach ($f in "vantage-agent.msi", "checksums-msi.txt") {
|
||||
$name = [uri]::EscapeDataString($f)
|
||||
Invoke-RestMethod -Headers $headers -Method Post -InFile $f `
|
||||
-ContentType "application/octet-stream" `
|
||||
-Uri "$api/releases/$($rel.id)/assets?name=$name"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
dist
|
||||
installer/vantage-agent-windows-amd64.exe
|
||||
installer/vantage-agent.msi
|
||||
installer/checksums-msi.txt
|
||||
.env
|
||||
@@ -0,0 +1,137 @@
|
||||
# Vantage agent (`vantage-agent`)
|
||||
|
||||
The lightweight Go agent installed on every managed server, and the Windows
|
||||
installer that packages it. Extracted from the `vantage` monorepo with its
|
||||
history; the agent is the repository root, so the module is
|
||||
`gitea.hostxtra.co.uk/vantage/vantage-agent`.
|
||||
|
||||
```
|
||||
vantage-agent/
|
||||
├── cmd/main.go # flags: -generate-key
|
||||
├── internal/
|
||||
│ ├── checker/ # monitor check execution
|
||||
│ ├── config/ # config.yaml load/save
|
||||
│ ├── exec/ # workflow step execution
|
||||
│ ├── grpc/ # client + the codec registration
|
||||
│ ├── inventory/ # CPU/mem/disk collection (linux/other/windows)
|
||||
│ ├── keys/ # authorized_keys read/diff/write
|
||||
│ ├── monitors/ # agent-run monitor loop
|
||||
│ ├── proxy/ # console relay, always from 127.0.0.1
|
||||
│ ├── sync/ # poll loop + command stream + self-update
|
||||
│ ├── updates/ # OS package update check/apply
|
||||
│ ├── winexec/ # PowerShell invocation on Windows
|
||||
│ └── workloads/ # containers and units/services
|
||||
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
|
||||
└── .gitea/workflows/agent-release.yml
|
||||
```
|
||||
|
||||
## Relationship to the other repositories
|
||||
|
||||
| Repository | Relationship |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `vantage-shared` | a private Go module. `grpc/pb` and `grpc/codec` are the wire contract; `proto/` there documents them |
|
||||
| `vantage` | the control plane. **No import in either direction** — the coupling is the gRPC wire, and it is entirely mediated by `vantage-shared` |
|
||||
|
||||
**`vantage-shared` is private**, so every Go build needs
|
||||
`GOPRIVATE=gitea.hostxtra.co.uk/*` plus a credential. CI writes a netrc from
|
||||
`REGISTRY_USER` + `RELEASE_TOKEN` (**that token needs read access to the
|
||||
`vantage` org**) — twice, because the `msi` job is Windows and Go looks for
|
||||
`_netrc` in the profile directory there, not `.netrc`. Locally, either a netrc
|
||||
or `git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`.
|
||||
|
||||
### A wire change is three steps, in order
|
||||
|
||||
`shared/grpc/pb` is hand-written and shared by both sides, so a new message is a
|
||||
compile error rather than a silent disagreement — but only once each side moves:
|
||||
|
||||
1. release `vantage-shared` (and change `proto/vantage/v1/vantage.proto` in the
|
||||
same commit as the Go types)
|
||||
2. bump the pin in `vantage`'s `server/go.mod` — live at the next push to main
|
||||
3. bump the pin here — live only at the next `agent/v*` tag
|
||||
|
||||
The control plane runs ahead of the fleet in between. That was true before the
|
||||
split too; it is now explicit in two `go.mod` files rather than implicit in a
|
||||
shared directory.
|
||||
|
||||
## Releases
|
||||
|
||||
Tags are `agent/v*`, **not** bare `v*`, even though this repository is only the
|
||||
agent. The tag is not a private detail: it is what the fleet downloads by, what
|
||||
`UpdateAgentCmd` carries, and what the control plane greps release tag names for
|
||||
when answering `GET /api/agent/latest-version`. Renaming it would be a second
|
||||
breaking change stacked on the repository move.
|
||||
|
||||
```bash
|
||||
git tag agent/v1.2.0 && git push origin agent/v1.2.0
|
||||
```
|
||||
|
||||
Builds `linux/amd64`, `linux/arm64` and `windows/amd64`, writes `checksums.txt`,
|
||||
creates the Gitea release. A second `msi` job on `windows-2022` builds the exe
|
||||
again, packages it with WiX and appends the MSI to the same release **through
|
||||
the API** — `gitea-release-action` cannot find a tag with a slash in it.
|
||||
|
||||
### The self-update path, and what the move broke
|
||||
|
||||
`internal/sync` downloads its own replacement from
|
||||
`<gitea>/vantage/vantage-agent/releases/download/<tag>/…`, verifying the
|
||||
SHA-256 from `checksums.txt` before swapping itself. That path is **compiled
|
||||
into the binary**.
|
||||
|
||||
**Every agent built before this move has the old path — `mrhid6/vantage` —
|
||||
baked in, and releases are no longer published there.** For those agents the
|
||||
push-button update in the UI will fail: the download 404s. They are not
|
||||
stranded, because `/update` and `/update.ps1` are generated by the control plane
|
||||
at request time and point wherever the current server says, so re-running the
|
||||
update one-liner on a host moves it onto a build that knows the new address.
|
||||
After that, self-update works again permanently.
|
||||
|
||||
This was a deliberate choice — the alternative was publishing releases to a
|
||||
repository that no longer holds the source — but it means **the fleet needs one
|
||||
pass of the update one-liner**, and the control plane must be redeployed with
|
||||
the new release paths *first*, or the one-liner points at the old repository
|
||||
too.
|
||||
|
||||
## What the agent will not do
|
||||
|
||||
- **It never reboots a host.** `ApplyUpdatesCmd` installs and stops there;
|
||||
`inventory.reboot_required` reports that one is owed.
|
||||
- **It decides what it will not touch.** The protected workload set is computed
|
||||
and enforced agent-side — `vantage-agent.service`, `VantageAgent` on Windows,
|
||||
and its own container ID from `/proc/self/cgroup`. The control plane may name
|
||||
a target; 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 console relay dials `127.0.0.1` only.** The host is hardcoded here, so
|
||||
the control plane can name a port and nothing else.
|
||||
- **No `authorized_keys` management on Windows**, and no package inventory: a
|
||||
Windows agent never calls `ReportPackages`, so no `server_packages` document
|
||||
exists for it at all — a different, earlier state than the `unsupported` a
|
||||
Linux distribution reaches when its family has no security feed.
|
||||
|
||||
## Platform split
|
||||
|
||||
Windows support is build tags, not runtime branches — `systemd_linux.go` /
|
||||
`services_windows.go` and the matching `control_` and `logs_` pairs. Windows
|
||||
collection runs PowerShell through `internal/winexec`, and **every script that
|
||||
reports data emits JSON that a build-tag-free parser reads**, so those parsers
|
||||
are tested on Linux. This module has no Windows CI: the control verbs and
|
||||
`serviceDisplayName` emit no JSON, have no parser, and are exercised only by
|
||||
running the agent on Windows.
|
||||
|
||||
Windows updates 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.
|
||||
|
||||
## Two constants that mirror the control plane
|
||||
|
||||
Neither can be shared — this is a separate module and the control plane's are
|
||||
under `internal/` — so both must change in step, by hand:
|
||||
|
||||
- the workload log cap, **500 lines and 256KB whichever binds first**, mirrored
|
||||
in the control plane's `services.MaxWorkloadLogLines`
|
||||
- `streamHealthyAfter` and the 70s command-stream watchdog, which pair with the
|
||||
control plane's 20s `PingCmd`. The watchdog arms only **after** a first ping
|
||||
has been seen, so an older server that sends none is treated as working rather
|
||||
than put into a reconnect loop.
|
||||
+2
-2
@@ -7,8 +7,8 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
agentsync "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/sync"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
|
||||
agentsync "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/sync"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module gitea.hostxtra.co.uk/mrhid6/vantage/agent
|
||||
module gitea.hostxtra.co.uk/vantage/vantage-agent
|
||||
|
||||
go 1.26
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/vantage/vantage-agent/internal/checker"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/packages"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
|
||||
+12
-12
@@ -17,14 +17,14 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/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-agent/internal/config"
|
||||
agentexec "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/exec"
|
||||
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/inventory"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/keys"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/monitors"
|
||||
agentproxy "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/proxy"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/updates"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
@@ -554,8 +554,8 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
u := cmd.UpdateAgent
|
||||
arch := runtime.GOARCH
|
||||
tag := "agent%2Fv" + u.Version
|
||||
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
|
||||
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
|
||||
binaryURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
|
||||
checksumURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
|
||||
|
||||
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
|
||||
|
||||
@@ -592,8 +592,8 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
|
||||
u := cmd.UpdateAgent
|
||||
tag := "agent%2Fv" + u.Version
|
||||
msiURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag)
|
||||
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag)
|
||||
msiURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag)
|
||||
checksumURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag)
|
||||
|
||||
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"log"
|
||||
"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/workloads"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/workloads"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec"
|
||||
)
|
||||
|
||||
// AgentUnit is the service this agent runs as — the NSSM service name written
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec"
|
||||
)
|
||||
|
||||
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec"
|
||||
)
|
||||
|
||||
const servicesTimeout = 60 * time.Second
|
||||
|
||||
Reference in New Issue
Block a user