docs(plan): drop backfill, add staff instance attach
Server Deploy / deploy (push) Failing after 37s

Existing cloud instances get licensed by hand through the admin UI instead of
an automated backfill. That needs POST /api/staff/instances, which nothing else
provided — without it there is no way to attach an existing cloud instance to
an account.
This commit is contained in:
2026-07-24 16:08:29 +01:00
parent 8ed3bec511
commit c4f1684304
+112 -157
View File
@@ -30,7 +30,9 @@
## Context this plan inherits
Plan 2 shipped without grandfathering — the migration that would have licensed existing cloud instances was removed at the user's direction. **Every existing cloud instance is therefore read-only right now.** Task 12's backfill is not a tidy-up; it is what restores those instances to working order, and it must run before admin is considered live.
Plan 2 shipped without grandfathering — the migration that would have licensed existing cloud instances was removed at the user's direction. **Every existing cloud instance is therefore read-only right now**, and stays that way until someone issues it a licence.
There is deliberately no automated backfill. Those instances get licensed by hand through the admin UI once spec 4 lands, using `POST /api/staff/instances` to attach each one to an account followed by `POST /api/staff/instances/:id/issue`. Task 11 Step 5 walks that exact flow, so the path is proven by the time the UI needs it.
---
@@ -42,7 +44,7 @@ Plan 2 shipped without grandfathering — the migration that would have licensed
|---|---|
| `admin/go.mod`, `admin/Dockerfile`, `admin/.dockerignore` | module and image, built from the repo root like `server/` |
| `admin/cmd/main.go` | boot: config, two Mongo connections, Redis, indexes, plan seed, reconcile loop, HTTP |
| `admin/cmd/adminctl/main.go` | staff user creation and licence backfill; no HTTP surface for either |
| `admin/cmd/adminctl/main.go` | staff user creation; deliberately has no HTTP surface |
| `admin/internal/config/config.go` | env parsing, fail-fast validation |
| `admin/internal/db/db.go` | `Admin()` and `Control()` collections, connect, index creation |
| `admin/internal/models/models.go` | `Account`, `Instance`, `License`, `Subscription`, `Plan`, `StaffUser`, `CustomerUser`, `AuditEntry` |
@@ -429,7 +431,7 @@ MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```
Expected: no output. (`adminctl` does not exist yet — the Dockerfile is not built until Task 12.)
Expected: no output. (`adminctl` does not exist yet — the Dockerfile is not built until Task 11.)
- [ ] **Step 8: Commit**
@@ -1568,10 +1570,9 @@ Create `admin/cmd/adminctl/main.go`:
```go
// Command adminctl performs the operations that deliberately have no HTTP
// surface: creating staff users, and backfilling licences issued by hand.
// surface.
//
// adminctl staff-add --email=you@example.com --name="You" --password=...
// adminctl backfill --from=blobs.json
//
// There is no staff signup endpoint. A licensing authority that can be joined
// over the internet is not one.
@@ -1595,7 +1596,7 @@ import (
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add | backfill")
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
os.Exit(2)
}
@@ -1612,10 +1613,8 @@ func main() {
switch os.Args[1] {
case "staff-add":
staffAdd(ctx, os.Args[2:])
case "backfill":
backfill(ctx, cfg, os.Args[2:])
default:
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add | backfill")
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
os.Exit(2)
}
}
@@ -1649,15 +1648,6 @@ func staffAdd(ctx context.Context, args []string) {
fmt.Printf("created staff user %s\n", u.Email)
}
// backfill is implemented in Task 12.
func backfill(ctx context.Context, cfg config.Config, args []string) {
_ = ctx
_ = cfg
_ = args
_ = bson.M{}
fatal("backfill is implemented in Task 12")
}
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
@@ -2452,6 +2442,7 @@ func Routes(cfg config.Config) http.Handler {
staff.POST("/accounts", staffCreateAccount)
staff.GET("/accounts/:id", staffGetAccount)
staff.GET("/instances", staffListInstances)
staff.POST("/instances", staffCreateInstance)
staff.POST("/instances/:id/issue", staffIssue)
staff.POST("/instances/:id/relink", staffRelink)
staff.GET("/licenses", staffListLicenses)
@@ -2531,11 +2522,15 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -2642,6 +2637,70 @@ func staffListInstances(c *gin.Context) {
c.JSON(http.StatusOK, instances)
}
// staffCreateInstance attaches an instance to an account.
//
// For cloud, this ADOPTS an instance that already exists in the control plane —
// the control-plane row is the source of truth for its name and slug, and this
// refuses if no such instance exists, because an admin row pointing at nothing
// would issue licences nobody can use.
//
// For self-hosted it does the same job as the customer-facing link endpoint, so
// staff can link on a customer's behalf during support.
//
// This is how existing cloud instances get licensed: adopt, then issue.
func staffCreateInstance(c *gin.Context) {
var body struct {
InstanceID string `json:"instance_id"`
AccountID string `json:"account_id"`
Deployment string `json:"deployment"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" || body.AccountID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id and account_id are required"})
return
}
ctx := c.Request.Context()
if n, err := db.Admin("accounts").CountDocuments(ctx, bson.M{"account_id": body.AccountID}); err != nil || n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no such account"})
return
}
inst := models.Instance{
InstanceID: body.InstanceID,
AccountID: body.AccountID,
Name: body.Name,
Deployment: body.Deployment,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if body.Deployment == license.DeploymentCloud {
var remote sharedmodels.Instance
if err := db.Control("instances").FindOne(ctx,
bson.M{"instance_id": body.InstanceID}).Decode(&remote); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such cloud instance in the control plane"})
return
}
inst.Name = remote.Name
inst.Slug = remote.Slug
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
if mongo.IsDuplicateKeyError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "that instance is already attached to an account"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.attached", AccountID: body.AccountID, Target: body.InstanceID})
c.JSON(http.StatusCreated, inst)
}
func staffIssue(c *gin.Context) {
var body struct {
Tier string `json:"tier"`
@@ -2881,131 +2940,7 @@ git commit -m "feat(admin): compose service and image build"
---
### Task 11: Backfill
Existing cloud instances are read-only right now — plan 2 shipped without grandfathering. This is what fixes them, and it must run before admin is considered live.
**Files:**
- Modify: `admin/cmd/adminctl/main.go`
**Interfaces:**
- Consumes: `licensing.Issue`, `inject.Cloud`, `db.Control`
- Produces: `adminctl backfill`
- [ ] **Step 1: Replace the backfill stub**
In `admin/cmd/adminctl/main.go`, replace the stub with:
```go
// backfill creates an account, an admin instance row and a licence for every
// cloud instance in the control plane that does not already have one, then
// injects it.
//
// This is not a tidy-up. Plan 2 shipped without grandfathering, so every
// existing cloud instance is read-only until this runs.
//
// It is idempotent: instances that already have an admin row are skipped.
func backfill(ctx context.Context, cfg config.Config, args []string) {
fs := flag.NewFlagSet("backfill", flag.ExitOnError)
tier := fs.String("tier", "professional", "tier to issue")
term := fs.String("term", "annual", "monthly or annual")
apply := fs.Bool("apply", false, "actually write; without it, only report")
fs.Parse(args)
licensing.SetSigningKey(cfg.SigningKey)
cur, err := db.Control("instances").Find(ctx, bson.M{})
if err != nil {
fatal("list control instances: %v", err)
}
var remote []sharedmodels.Instance
if err := cur.All(ctx, &remote); err != nil {
fatal("decode control instances: %v", err)
}
for _, r := range remote {
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"instance_id": r.InstanceID})
if err != nil {
fatal("check instance %s: %v", r.InstanceID, err)
}
if n > 0 {
fmt.Printf("skip %s (%s) — already known\n", r.InstanceID, r.Slug)
continue
}
if !*apply {
fmt.Printf("would %s (%s) — create account + %s licence\n", r.InstanceID, r.Slug, *tier)
continue
}
acct := models.Account{
AccountID: uuid.NewString(),
Name: r.Name,
BillingEmail: "",
Status: models.AccountActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil {
fatal("create account for %s: %v", r.InstanceID, err)
}
inst := models.Instance{
InstanceID: r.InstanceID,
AccountID: acct.AccountID,
Name: r.Name,
Slug: r.Slug,
Deployment: license.DeploymentCloud,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
fatal("create instance row for %s: %v", r.InstanceID, err)
}
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: r.InstanceID,
Tier: *tier,
Term: *term,
Reason: models.ReasonManual,
IssuedBy: "backfill",
})
if err != nil {
fatal("issue for %s: %v", r.InstanceID, err)
}
if err := inject.Cloud(ctx, lic); err != nil {
fatal("inject for %s: %v", r.InstanceID, err)
}
fmt.Printf("done %s (%s) — %s licence %s\n", r.InstanceID, r.Slug, lic.Tier, lic.LicenseID)
}
if !*apply {
fmt.Println("\ndry run — nothing written. Re-run with --apply.")
}
}
```
Add to that file's imports: `"github.com/mrhid6/vantage/admin/internal/inject"`, `"github.com/mrhid6/vantage/admin/internal/licensing"`, `"github.com/mrhid6/vantage/shared/license"`, `sharedmodels "github.com/mrhid6/vantage/shared/models"`.
The default is a **dry run**. A tool that writes to production the first time someone types its name is a tool that gets typed by accident.
- [ ] **Step 2: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add admin/
git commit -m "feat(admin): backfill existing cloud instances"
```
---
### Task 12: Full verification
### Task 11: Full verification
Everything in containers. With no test suite this is the only evidence.
@@ -3073,26 +3008,46 @@ docker run --rm -e ADMIN_MONGO_URI=mongodb://x/y -e CONTROL_MONGO_URI=mongodb://
Expected: exits non-zero with `missing required environment: LICENSE_SIGNING_KEY`.
- [ ] **Step 5: Backfill the cloud instance**
- [ ] **Step 5: Adopt the cloud instance and issue through the staff API**
This is the flow the admin UI will drive when you licence your existing cloud
instances by hand.
```bash
docker exec vadmin adminctl backfill # dry run
docker exec vadmin adminctl backfill --apply
docker exec vadmin adminctl staff-add --email=staff@example.com --name=Staff --password=correcthorsebattery
curl -s -X POST localhost:8083/auth/staff/login -H 'Content-Type: application/json' \
-d '{"email":"staff@example.com","password":"correcthorsebattery"}' -c /tmp/s.txt
ACCOUNT=$(curl -s -X POST localhost:8083/api/staff/accounts -b /tmp/s.txt \
-H 'Content-Type: application/json' \
-d '{"name":"Admin Test","billing_email":"owner@example.com"}' \
| grep -o '"account_id":"[^"]*"' | cut -d'"' -f4)
INSTANCE=<instance_id from step 3>
curl -s -X POST localhost:8083/api/staff/instances -b /tmp/s.txt \
-H 'Content-Type: application/json' \
-d "{\"instance_id\":\"$INSTANCE\",\"account_id\":\"$ACCOUNT\",\"deployment\":\"cloud\"}"
curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \
-H 'Content-Type: application/json' -d '{"tier":"professional","term":"annual"}'
sleep 3
curl -s -X POST localhost:8080/auth/login -H 'Content-Type: application/json' \
-d '{"email":"owner@example.com","password":"hunter2hunter2"}' -c /tmp/a.txt
curl -s localhost:8080/api/license -b /tmp/a.txt
```
Expected: the dry run reports `would`, the apply reports `done`, and the control plane reports `"state":"valid"`, `"tier":"professional"` **within 60 seconds, with no restart**. This is the whole system working end to end.
Expected: adopting the instance returns 201, issuing returns 201, and the control
plane reports `"state":"valid"`, `"tier":"professional"` **within 60 seconds,
with no restart**. This is the whole system working end to end.
- [ ] **Step 6: Confirm the Free rule and the deployment check**
Reusing the staff session and `$INSTANCE` from step 5:
```bash
docker exec vadmin adminctl staff-add --email=staff@example.com --name=Staff --password=correcthorsebattery
curl -s -X POST localhost:8083/auth/staff/login -H 'Content-Type: application/json' \
-d '{"email":"staff@example.com","password":"correcthorsebattery"}' -c /tmp/s.txt
INSTANCE=<instance_id from step 3>
curl -s -X POST localhost:8083/api/staff/instances/$INSTANCE/issue -b /tmp/s.txt \
-H 'Content-Type: application/json' -d '{"tier":"self_hosted","term":"annual"}'
```
@@ -3189,19 +3144,19 @@ git commit -m "chore: verify the admin backend end to end" --allow-empty
1. Deploy admin with `LICENSE_SIGNING_KEY` set, alongside the existing site stack.
2. `adminctl staff-add` for each staff member. There is no signup.
3. `adminctl backfill` dry run, review the list, then `--apply`. **Existing cloud instances are read-only until this runs.**
4. Confirm every instance reports `valid` before announcing anything.
5. Spec 4 (the site) and spec 5 (Paddle) can then proceed in parallel.
3. Spec 4 (the site) and spec 5 (Paddle) can then proceed in parallel.
4. Once the UI exists, licence the existing cloud instances by hand: attach each to an account, then issue. **They stay read-only until that is done**, so it is the first thing the UI is used for, not the last.
## Risks
| Risk | Mitigation |
|---|---|
| Admin becomes a runtime dependency | Task 12 Step 10 verifies instances work with admin stopped |
| Admin becomes a runtime dependency | Task 11 Step 10 verifies instances work with admin stopped |
| Signing key exposure | One service, one variable, one compose file; never in `server`; rotation path from plan 1 |
| Cloud password now unlocks billing | Owner-only, rate-limited, every attempt audited; state it in the release notes |
| Injection silently fails | `inject_failed_at` plus the 15-minute reconciler plus `/api/staff/health/injection` |
| Admin writes outside its remit | One package with the write path, Task 4 Step 4 greps it, Task 12 Step 11 verifies it |
| Admin writes outside its remit | One package with the write path, Task 4 Step 4 greps it, Task 11 Step 11 verifies it |
| Self-hosted UUID squatted | Unique index on `admin_instances.instance_id`, non-disclosing error |
| A customer route forgets to scope | `ownedInstance` helper, audited by hand in Task 9 Step 3 |
| Backfill run by accident | Dry run is the default; `--apply` is required to write |
| Existing cloud instances stay read-only longer than intended | Licensing them is the first job the admin UI is used for; Task 11 Step 5 proves the flow before the UI exists |
| An admin instance row points at no real cloud instance | `staffCreateInstance` refuses a cloud instance the control plane does not have |