package models import ( "database/sql/driver" "fmt" "time" "github.com/lib/pq" "gorm.io/datatypes" "gorm.io/gorm" "rocketgit.ru/rsmon/worker/app/models/concerns" ) // DeploymentKind is the rstuff-mirrored lifecycle label for a // deployment (production / production_prev / production_next / // internal / staging / old). See // docs/parity/rstuff-inventory.md §6.1 for the byte-stable numeric // mapping (which Postgres stores alphabetically, not numerically). type DeploymentKind string // Lifecycle labels for a Deployment row. See DeploymentKind for // the matching rstuff enum. The label set is closed; new values // require adding a Postgres enum value via app/models/migrate.go. const ( // DeploymentKindProduction is the customer-facing "live" deployment. DeploymentKindProduction DeploymentKind = "production" DeploymentKindProductionPrev DeploymentKind = "production_prev" DeploymentKindProductionNext DeploymentKind = "production_next" // DeploymentKindInternal is for ops/admin tooling (not customer-facing). DeploymentKindInternal DeploymentKind = "internal" DeploymentKindStaging DeploymentKind = "staging" DeploymentKindOld DeploymentKind = "old" ) // DeploymentMode is the host-side lifecycle label (kubernetes / // compose / dedicated / vds / user). `dedicated` covers a single // nginx vhost; `compose` covers a Docker Compose project. type DeploymentMode string // DeploymentMode values map onto rstuff's Deployment.mode enum. // `dedicated` covers a single nginx vhost; `compose` covers a // Docker Compose project; the others are reserved for future // v2 surfaces (Kubernetes, VDS, user-owned). const ( DeploymentModeKubernetes DeploymentMode = "kubernetes" DeploymentModeCompose DeploymentMode = "compose" DeploymentModeDedicated DeploymentMode = "dedicated" DeploymentModeVDS DeploymentMode = "vds" DeploymentModeUser DeploymentMode = "user" ) // DeploymentAction is the reconciliation state. Updated by the // deploymentd receiver on every POST and by the 90s reconcile sweep // (see app/models/deployment.go ReconcileMissing). type DeploymentAction string // DeploymentAction values. Pending/PendingMove/PendingDrop are // transient (operator or receiver-initiated); Deleted/Missing are // sticky until the deployment shows up again on a future POST. const ( DeploymentActionOk DeploymentAction = "ok" DeploymentActionPending DeploymentAction = "pending" DeploymentActionPendingMove DeploymentAction = "pending_move" DeploymentActionPendingDrop DeploymentAction = "pending_drop" DeploymentActionDeleted DeploymentAction = "deleted" DeploymentActionMissing DeploymentAction = "missing" ) // Deployment represents a single host-side binding: one nginx // vhost, one Docker Compose service, or one Kubernetes service. The // shape mirrors rstuff's `deployments` table. See // docs/plans/inventory-management.md §4 / §6.1. type Deployment struct { concerns.Model AccountID int64 `gorm:"type:bigint REFERENCES accounts(id);not null;index" json:"account_id"` Account *Account `json:"-"` ServerID *int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE SET NULL;index" json:"server_id,omitempty"` Server *Server `json:"-"` SiteID *int64 `gorm:"type:bigint REFERENCES sites(id) ON DELETE SET NULL;index" json:"site_id,omitempty"` Site *Site `json:"site,omitempty"` ExtID *string `gorm:"size:64" json:"ext_id,omitempty"` ServiceName *string `gorm:"size:120" json:"service_name,omitempty"` Kind DeploymentKind `gorm:"type:deployment_kind;not null;default:'production'" json:"kind"` Mode DeploymentMode `gorm:"type:deployment_mode;not null;default:'dedicated'" json:"mode"` Action DeploymentAction `gorm:"type:deployment_action;not null;default:'ok'" json:"action"` URL *string `gorm:"type:text" json:"url,omitempty"` SSHUser *string `gorm:"size:64" json:"ssh_user,omitempty"` RootPath *string `gorm:"type:text" json:"root_path,omitempty"` ConfigPath *string `gorm:"type:text" json:"config_path,omitempty"` IP *string `gorm:"type:inet" json:"ip,omitempty"` Listen pq.StringArray `gorm:"type:varchar(64)[];not null;default:'{}'" json:"listen"` ServerName pq.StringArray `gorm:"type:varchar(255)[];not null;default:'{}'" json:"server_name"` Auth bool `gorm:"not null;default:false" json:"auth"` IsProxied bool `gorm:"not null;default:false" json:"is_proxied"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"` Meta datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"meta"` concerns.Timestamped Audited } // TableName provides functionality. func (Deployment) TableName() string { return "deployments" } // Scan implements sql.Scanner so a Postgres enum value can land in // our typed string alias without a code-generation step. func (d *DeploymentKind) Scan(src any) error { if src == nil { *d = "" return nil } switch v := src.(type) { case string: *d = DeploymentKind(v) case []byte: *d = DeploymentKind(string(v)) default: return fmt.Errorf("deployment_kind: cannot scan %T", src) } return nil } // Value implements driver.Valuer for the inverse direction. func (d DeploymentKind) Value() (driver.Value, error) { if d == "" { return nil, nil } return string(d), nil } // Scan implements sql.Scanner for DeploymentMode. func (d *DeploymentMode) Scan(src any) error { if src == nil { *d = "" return nil } switch v := src.(type) { case string: *d = DeploymentMode(v) case []byte: *d = DeploymentMode(string(v)) default: return fmt.Errorf("deployment_mode: cannot scan %T", src) } return nil } // Value implements driver.Valuer for DeploymentMode. func (d DeploymentMode) Value() (driver.Value, error) { if d == "" { return nil, nil } return string(d), nil } // Scan implements sql.Scanner for DeploymentAction. func (d *DeploymentAction) Scan(src any) error { if src == nil { *d = "" return nil } switch v := src.(type) { case string: *d = DeploymentAction(v) case []byte: *d = DeploymentAction(string(v)) default: return fmt.Errorf("deployment_action: cannot scan %T", src) } return nil } // Value implements driver.Valuer for DeploymentAction. func (d DeploymentAction) Value() (driver.Value, error) { if d == "" { return nil, nil } return string(d), nil } // ReconcileMissingDeployments flips action='missing' on every Deployment for the // given server whose last_seen_at is older than cutoff. Called by // the deploymentd receiver after every successful upsert so the // "missing" badge appears within one POST cycle. // // Idempotent: re-running with the same cutoff is a no-op. func ReconcileMissingDeployments(serverID int64, mode DeploymentMode, cutoff time.Time) (int64, error) { res := DB().Model(&Deployment{}). Where("server_id = ? AND mode = ? AND action NOT IN ?", serverID, mode, []DeploymentAction{DeploymentActionDeleted, DeploymentActionMissing, DeploymentActionPendingDrop}). Where("last_seen_at IS NULL OR last_seen_at < ?", cutoff). Update("action", DeploymentActionMissing) return res.RowsAffected, res.Error } // UpsertNginxDeployment finds or creates a Deployment by // (server_id, config_path) for an nginx vhost. The caller fills in // the lifecycle fields (listen, server_name, etc.) after the upsert // returns. The return value is the row to mutate; the caller MUST // also touch last_seen_at and save. func UpsertNginxDeployment(tx *gorm.DB, accountID int64, serverID int64, configPath string) (*Deployment, error) { if tx == nil { tx = DB() } var d Deployment err := tx.Where("server_id = ? AND config_path = ?", serverID, configPath).First(&d).Error if err == nil { return &d, nil } if err != gorm.ErrRecordNotFound { return nil, err } d = Deployment{ AccountID: accountID, ServerID: &serverID, Kind: DeploymentKindProduction, Mode: DeploymentModeDedicated, Action: DeploymentActionOk, ConfigPath: &configPath, } if err := tx.Create(&d).Error; err != nil { return nil, err } return &d, nil } // RotateServerToken sets a new random token for a server and returns // the plaintext. Called by the operator-only // POST /api/v1/servers/:id/rotate-token endpoint. The plaintext is // returned exactly once — it is not stored anywhere recoverable. func RotateServerToken(tx *gorm.DB, serverID int64, newToken string) error { if tx == nil { tx = DB() } return tx.Model(&Server{}).Where("id = ?", serverID).Update("token", newToken).Error }