feat(admin): cloudprov, the instance provisioning write path
Admin's second and final write path into the control plane. It creates instances and users and nothing else; inject still owns exactly three licence fields and is untouched. The owner's password hash is copied from the HQ account, not shared. The two diverge on the next password change, which is accepted: propagating a hash across two databases is worse than two passwords that started equal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// Package cloudprov provisions cloud instances in the control plane.
|
||||
//
|
||||
// This is admin's second and final write path into the control-plane database,
|
||||
// alongside inject. It writes `instances` and `users` and nothing else. A third
|
||||
// write target, or a write to any other collection from here, is a design change
|
||||
// and not a refactor — see the spec's "Admin's control-plane write boundary".
|
||||
//
|
||||
// Every function here is called from a customer request, so each one leaves the
|
||||
// control plane in a consistent state or not at all: the caller unwinds in
|
||||
// reverse order on failure, and RollbackInstance refuses to delete an instance
|
||||
// that has users.
|
||||
package cloudprov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// CreateInstance creates a control-plane instance and its owner.
|
||||
//
|
||||
// The owner's password hash is COPIED from the HQ account rather than shared.
|
||||
// Changing the password on either side does not propagate, and they diverge from
|
||||
// that moment — accepted deliberately, because propagating a hash across two
|
||||
// services' databases is a worse problem than two passwords that started equal.
|
||||
//
|
||||
// On owner-insert failure the instance is rolled back, so a failed provision
|
||||
// never leaves a slug permanently occupied by an instance nobody owns.
|
||||
func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
|
||||
inst, err := provision.CreateInstance(ctx, db.ControlDB(), name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
|
||||
ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
|
||||
if err != nil {
|
||||
if rbErr := provision.RollbackInstance(ctx, db.ControlDB(), inst.InstanceID); rbErr != nil {
|
||||
return nil, fmt.Errorf("create owner: %w (and rollback failed: %v)", err, rbErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// hq_user_id is what phase 3 uses to find every row projected from one HQ
|
||||
// user when its password changes. Set at creation so the owner is not a
|
||||
// special case later.
|
||||
if _, err := db.Control("users").UpdateOne(ctx,
|
||||
bson.M{"user_id": u.UserID},
|
||||
bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
|
||||
return nil, fmt.Errorf("set hq_user_id: %w", err)
|
||||
}
|
||||
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes one control-plane user. Used only to unwind a failed
|
||||
// provision.
|
||||
func DeleteUser(ctx context.Context, instanceID, userID string) error {
|
||||
_, err := db.Control("users").DeleteOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID})
|
||||
return err
|
||||
}
|
||||
|
||||
// RollbackInstance deletes an instance that has no users.
|
||||
func RollbackInstance(ctx context.Context, instanceID string) error {
|
||||
return provision.RollbackInstance(ctx, db.ControlDB(), instanceID)
|
||||
}
|
||||
|
||||
// OwnerUserID returns the control-plane user_id of an instance's owner, so a
|
||||
// caller can unwind a partial provision without re-deriving it.
|
||||
func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
|
||||
var u sharedmodels.User
|
||||
err := db.Control("users").FindOne(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"role": sharedmodels.RoleOwner,
|
||||
}).Decode(&u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.UserID, nil
|
||||
}
|
||||
Reference in New Issue
Block a user