docs(plan): drop backfill, add staff instance attach
Server Deploy / deploy (push) Failing after 37s
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:
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user