mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
f3b753e3a8
A self-hosted boot converts free and empty plans to `self-hosted`, which has no limits. Nothing recorded which kind of instance owned a database, so one non-cloud boot of a Pad Cloud database (a missing PAD_MODE, a restore tested locally, a debug boot) made every free user unlimited for good: a later cloud boot fills only empty plans. - Every cloud-mode boot marks its database cloud-owned (platform_settings.instance_mode = cloud), sticky and before its backfill. - A database with no marker counts as cloud-owned when any user carries Pad Cloud billing state (a stripe plan source or a stripe customer id). - The non-cloud boot's conversion (store.BackfillSelfHostedPlans) checks ownership in its own transaction and converts nothing on a cloud-owned database. The boot logs an error and continues: a debug or restore boot is not an outage, and free users keep free-plan limits. - `pad db release-cloud [--force]`, on the server host, is the deliberate way to move a Cloud database to self-host. It refuses while billing state remains unless forced; a later cloud boot marks the database again. The plan step of `pad server start` moves into bootPlanBackfill so it can be tested as the server runs it. docs/backup.md documents the behaviour. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
161 lines
5.7 KiB
Go
161 lines
5.7 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// TASK-3551: which kind of instance owns this database.
|
|
//
|
|
// A non-cloud boot converts every free user to `self-hosted` (unlimited;
|
|
// BackfillSelfHostedPlans). Run once against a Pad Cloud database (a missing
|
|
// PAD_MODE, a restore tested locally, a debug boot), that conversion used to
|
|
// be permanent: a later cloud boot only fills `plan = ”`. A Cloud database now
|
|
// records that it is one, and the self-hosted conversion refuses it.
|
|
const settingInstanceMode = "instance_mode"
|
|
|
|
// Values of settingInstanceMode.
|
|
const (
|
|
// InstanceModeCloud is written by every cloud-server boot. Sticky: no
|
|
// non-cloud boot clears it.
|
|
InstanceModeCloud = "cloud"
|
|
// InstanceModeReleased is written only by `pad db release-cloud`: an
|
|
// operator deliberately taking a Cloud database to self-host. It lets the
|
|
// self-hosted conversion run even where the Stripe fingerprint remains. A
|
|
// later cloud boot writes InstanceModeCloud again.
|
|
InstanceModeReleased = "released"
|
|
)
|
|
|
|
// ErrCloudOwnedDatabase is returned by BackfillSelfHostedPlans when the
|
|
// database belongs to a Pad Cloud instance. Nothing was written.
|
|
var ErrCloudOwnedDatabase = errors.New("this database belongs to a Pad Cloud instance")
|
|
|
|
// ErrCloudFingerprintPresent is returned by ReleaseCloudOwnership without
|
|
// force while users still carry Stripe billing state.
|
|
var ErrCloudFingerprintPresent = errors.New("users in this database carry Pad Cloud billing state")
|
|
|
|
// CloudOwnership says whether the database belongs to a Pad Cloud instance,
|
|
// and why.
|
|
type CloudOwnership struct {
|
|
Owned bool
|
|
// Marker is the stored instance_mode ("" when none).
|
|
Marker string
|
|
// StripeUsers counts users carrying the Stripe fingerprint: a stripe
|
|
// plan_source or a stored stripe_customer_id. Only the Pad Cloud billing
|
|
// sidecar writes either, through admin routes that exist only in cloud
|
|
// mode, so a self-hosted database has none.
|
|
StripeUsers int64
|
|
}
|
|
|
|
// Reason renders why the database counts as cloud-owned, for a log line.
|
|
func (o CloudOwnership) Reason() string {
|
|
switch {
|
|
case !o.Owned:
|
|
return ""
|
|
case o.Marker == InstanceModeCloud:
|
|
return "instance_mode is cloud (written by a cloud-mode boot)"
|
|
default:
|
|
return fmt.Sprintf("%d user(s) carry Pad Cloud billing state (a Stripe plan source or customer id)", o.StripeUsers)
|
|
}
|
|
}
|
|
|
|
// MarkCloudOwned records that a cloud-mode server owns this database. Every
|
|
// cloud boot calls it before its own plan backfill. Idempotent.
|
|
func (s *Store) MarkCloudOwned() error {
|
|
if err := s.SetPlatformSetting(settingInstanceMode, InstanceModeCloud); err != nil {
|
|
return fmt.Errorf("mark database cloud-owned: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetCloudOwnership reads the ownership marker and the Stripe fingerprint.
|
|
func (s *Store) GetCloudOwnership() (CloudOwnership, error) {
|
|
return s.cloudOwnershipQ(s.db)
|
|
}
|
|
|
|
func (s *Store) cloudOwnershipQ(q Queryer) (CloudOwnership, error) {
|
|
var o CloudOwnership
|
|
marker, err := s.GetPlatformSettingQ(q, settingInstanceMode)
|
|
if err != nil {
|
|
return o, fmt.Errorf("read instance mode: %w", err)
|
|
}
|
|
o.Marker = marker
|
|
if err := q.QueryRow(s.q(`
|
|
SELECT COUNT(*) FROM users
|
|
WHERE plan_source = ? OR COALESCE(stripe_customer_id, '') <> ''`), PlanSourceStripe).Scan(&o.StripeUsers); err != nil {
|
|
return o, fmt.Errorf("count stripe users: %w", err)
|
|
}
|
|
switch marker {
|
|
case InstanceModeCloud:
|
|
o.Owned = true
|
|
case InstanceModeReleased:
|
|
o.Owned = false
|
|
default:
|
|
// No marker: a Cloud database that predates the marker is recognised
|
|
// by its billing state until its next cloud boot writes the marker.
|
|
o.Owned = o.StripeUsers > 0
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// BackfillSelfHostedPlans is the non-cloud boot's plan conversion: free and
|
|
// empty plans become `self-hosted` (unlimited). On a cloud-owned database it
|
|
// writes NOTHING and returns ErrCloudOwnedDatabase with the ownership, so
|
|
// those users keep free-plan limits, the safe direction, instead of being
|
|
// made unlimited for good. The read and the write share one transaction.
|
|
// Returns the number of users converted.
|
|
func (s *Store) BackfillSelfHostedPlans() (int64, CloudOwnership, error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return 0, CloudOwnership{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
o, err := s.cloudOwnershipQ(tx)
|
|
if err != nil {
|
|
return 0, o, err
|
|
}
|
|
if o.Owned {
|
|
return 0, o, ErrCloudOwnedDatabase
|
|
}
|
|
n, err := s.backfillSelfHostedQ(tx)
|
|
if err != nil {
|
|
return 0, o, fmt.Errorf("backfill self-hosted plans: %w", err)
|
|
}
|
|
return n, o, tx.Commit()
|
|
}
|
|
|
|
// backfillSelfHostedQ is the conversion itself, with no ownership check.
|
|
// Taking plan_source over as manual is deliberate (PLAN-3291 DR-6: the write
|
|
// that sets a plan owns it).
|
|
func (s *Store) backfillSelfHostedQ(ex interface {
|
|
Exec(query string, args ...any) (sql.Result, error)
|
|
}) (int64, error) {
|
|
res, err := ex.Exec(s.q(`UPDATE users SET plan = ?, plan_source = ?, updated_at = ? WHERE plan IN ('', 'free')`),
|
|
"self-hosted", PlanSourceManual, now())
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
// ReleaseCloudOwnership records that an operator is deliberately taking this
|
|
// database off Pad Cloud, so the next non-cloud boot converts free users to
|
|
// self-hosted. Without force it refuses while users carry Stripe billing
|
|
// state (ErrCloudFingerprintPresent): those are Pad Cloud customers. A later
|
|
// cloud boot marks the database cloud-owned again.
|
|
func (s *Store) ReleaseCloudOwnership(force bool) (CloudOwnership, error) {
|
|
o, err := s.GetCloudOwnership()
|
|
if err != nil {
|
|
return o, err
|
|
}
|
|
if o.StripeUsers > 0 && !force {
|
|
return o, ErrCloudFingerprintPresent
|
|
}
|
|
if err := s.SetPlatformSetting(settingInstanceMode, InstanceModeReleased); err != nil {
|
|
return o, fmt.Errorf("release cloud ownership: %w", err)
|
|
}
|
|
return o, nil
|
|
}
|