Files
pad/internal/store/instance_mode.go
T
xarmian f3b753e3a8 fix(server): a non-cloud boot no longer makes a Pad Cloud database's free users unlimited (TASK-3551) (#2028)
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
2026-10-10 17:26:08 -04:00

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
}