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:
@@ -30,6 +30,7 @@ func main() {
|
||||
}
|
||||
|
||||
licensing.SetSigningKey(cfg.SigningKey)
|
||||
api.SetAppLoginURL(cfg.AppLoginURL)
|
||||
|
||||
mail.Init(mail.Config{
|
||||
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -53,3 +54,22 @@ func SendLicense(to, instanceName, blob string) error {
|
||||
"Paste it into Settings → Licence on your Vantage install:\n\n%s\n",
|
||||
instanceName, blob))
|
||||
}
|
||||
|
||||
// SendInstanceReady tells a customer their cloud instance exists, where it is,
|
||||
// and when its licence runs out.
|
||||
//
|
||||
// The expiry is stated here rather than only in a later reminder: a Free licence
|
||||
// that quietly expires in a month is a surprise, and the first email is the one
|
||||
// people keep.
|
||||
func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error {
|
||||
body := fmt.Sprintf("%s is ready.\n\n", instanceName)
|
||||
if loginURL != "" {
|
||||
body += "Sign in here:\n\n" + loginURL + "\n\n"
|
||||
}
|
||||
body += fmt.Sprintf(
|
||||
"Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+
|
||||
"Sign in with the same email address and password you use for your Vantage account. "+
|
||||
"Changing one does not change the other.\n",
|
||||
expires.Format("2 January 2006"))
|
||||
return send(to, instanceName+" is ready", body)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ const (
|
||||
StatusActive = "active"
|
||||
StatusLapsed = "lapsed"
|
||||
StatusCancelled = "cancelled"
|
||||
|
||||
// StatusDeleted marks an instance the control plane has reaped. The row is
|
||||
// kept because the licence history references it and support questions
|
||||
// outlive the instance.
|
||||
StatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
// Account statuses.
|
||||
@@ -75,7 +80,12 @@ type Instance struct {
|
||||
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
|
||||
RelinkCount int `bson:"relink_count" json:"relink_count"`
|
||||
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
// NoticesSent holds the lifecycle notice keys already emailed for the
|
||||
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
|
||||
// clears it, so the next term starts the sequence again. It is what stops a
|
||||
// restart re-sending a notice.
|
||||
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// License is append-only. A renewal writes a new row and sets SupersededBy on
|
||||
|
||||
Reference in New Issue
Block a user