feat(admin): POST /api/instances creates a Free cloud instance

Provisions the control-plane instance and its owner, records the
admin_instances row, issues and injects a Free licence, and emails the
customer where it is and when it expires.

Licence issuance and email cannot fail the request. The instance exists
and the customer can sign in; rolling back something they can already see
would be worse than shipping it unlicensed for staff to fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 13:16:12 +01:00
co-authored by Claude Opus 5
parent 983655d2a1
commit 1836237f82
5 changed files with 169 additions and 1 deletions
+136
View File
@@ -3,16 +3,23 @@ package api
import (
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/auth"
"github.com/mrhid6/vantage/admin/internal/cloudprov"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/inject"
"github.com/mrhid6/vantage/admin/internal/licensing"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/shared/license"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -187,6 +194,135 @@ func listSubscriptions(c *gin.Context) {
c.JSON(http.StatusOK, subs)
}
// createInstance provisions a Free cloud instance for the calling account.
//
// The ordering matters and each step unwinds the previous one. Licence issuance
// and email are deliberately NOT allowed to fail the request: the instance
// exists and the customer can sign in, they see the licence banner, and staff
// can issue by hand. Rolling back an instance the customer can already see would
// be worse than shipping it unlicensed.
func createInstance(c *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
name := strings.TrimSpace(body.Name)
ctx := c.Request.Context()
s := auth.Current(c)
// Pre-check the Free rule so we never create an instance we then cannot
// licence. licensing.Issue enforces it too; this is the friendly refusal.
n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
"account_id": s.AccountID,
"tier": license.TierFree,
"status": bson.M{"$ne": models.StatusCancelled},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not check your account"})
return
}
if n > 0 {
c.JSON(http.StatusConflict, gin.H{
"error": "this account already has a Free instance"})
return
}
var cu models.CustomerUser
if err := db.Admin("customer_users").FindOne(ctx,
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
return
}
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
if err != nil {
if errors.Is(err, provision.ErrEmailTaken) {
// users.email is unique per instance, so this means the address
// already owns a user in an instance we are not creating — a legacy
// cloud tenant. Staff have to attach that one by hand.
c.JSON(http.StatusConflict, gin.H{
"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
return
}
if errors.Is(err, provision.ErrNameRejected) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
return
}
rec := models.Instance{
InstanceID: inst.InstanceID,
AccountID: s.AccountID,
Name: inst.Name,
Slug: inst.Slug,
Deployment: license.DeploymentCloud,
Status: models.StatusActive,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
// Unwind in reverse: the owner first, because RollbackInstance refuses
// an instance that still has users.
if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
}
if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
log.Printf("createInstance: rollback of %s failed: %v", inst.InstanceID, e)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
// Past this point nothing fails the request.
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: license.TierFree,
Term: "monthly",
Reason: models.ReasonNew,
IssuedBy: "self-serve",
})
if err != nil {
log.Printf("ISSUE FAILED for new instance %s: %v", inst.InstanceID, err)
c.JSON(http.StatusCreated, rec)
return
}
inject.Deliver(ctx, lic)
if mail.Enabled() {
if err := mail.SendInstanceReady(s.Email, inst.Name,
loginURLFor(inst.Slug), lic.ExpiresAt); err != nil {
log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err)
}
}
rec.Tier = lic.Tier
rec.CurrentLicense = lic.LicenseID
c.JSON(http.StatusCreated, rec)
}
// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template
// yields an empty string, and the email simply omits the link.
func loginURLFor(slug string) string {
if appLoginURL == "" {
return ""
}
return strings.ReplaceAll(appLoginURL, "{slug}", url.PathEscape(slug))
}
// appLoginURL is set once at boot from config.
var appLoginURL string
// SetAppLoginURL is called from main.
func SetAppLoginURL(v string) { appLoginURL = v }
// deliver sends a freshly issued licence where it needs to go. Cloud instances
// are injected; self-hosted customers are emailed and can download.
//
+1
View File
@@ -41,6 +41,7 @@ func Routes(cfg config.Config) http.Handler {
cust.Use(auth.RequireCustomer())
{
cust.GET("/account", getAccount)
cust.POST("/instances", createInstance)
cust.POST("/instances/link", linkInstance)
cust.POST("/instances/:id/relink", relinkInstance)
cust.GET("/instances/:id/license", getInstanceLicense)