633 lines
21 KiB
Go
633 lines
21 KiB
Go
package hub
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
_ "embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.ipao.vip/rogee/creator-hub/internal/taskstate"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
//go:embed migrations/002_hub.sql
|
|
var migration002 string
|
|
|
|
//go:embed migrations/003_unified_accounts.sql
|
|
var migration003 string
|
|
|
|
//go:embed migrations/004_environment_actions.sql
|
|
var migration004 string
|
|
|
|
//go:embed migrations/005_sanitize_legacy_proxy.sql
|
|
var migration005 string
|
|
|
|
//go:embed migrations/006_runtime_cleanup.sql
|
|
var migration006 string
|
|
|
|
//go:embed migrations/007_runtime_binding_version.sql
|
|
var migration007 string
|
|
|
|
//go:embed migrations/008_runtime_cleanup_generation.sql
|
|
var migration008 string
|
|
|
|
//go:embed migrations/009_runtime_cleanup_compatibility.sql
|
|
var migration009 string
|
|
|
|
//go:embed migrations/010_runtime_network_generation.sql
|
|
var migration010 string
|
|
|
|
//go:embed migrations/011_task_recovery.sql
|
|
var migration011 string
|
|
|
|
//go:embed migrations/012_task_recovery_compatibility.sql
|
|
var migration012 string
|
|
|
|
//go:embed migrations/013_account_creation.sql
|
|
var migration013 string
|
|
|
|
//go:embed migrations/014_account_creation_compatibility.sql
|
|
var migration014 string
|
|
|
|
//go:embed migrations/015_gateway_rename_cascade.sql
|
|
var migration015 string
|
|
|
|
//go:embed migrations/016_network_exit_plain_credentials.sql
|
|
var migration016 string
|
|
|
|
var (
|
|
ErrConflict = errors.New("resource conflicts with existing state")
|
|
ErrInvalid = errors.New("invalid hub input")
|
|
ErrNotFound = errors.New("resource not found")
|
|
ErrReconcileRequired = errors.New("runtime cleanup generation is unknown; manual reconciliation required")
|
|
)
|
|
|
|
func ValidImageVersion(version string) bool { return imageVersionPattern.MatchString(version) }
|
|
|
|
func ValidNetworkExitID(id string) bool { return exitIDPattern.MatchString(id) }
|
|
|
|
var (
|
|
aliasPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
|
gatewayNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
|
tokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$`)
|
|
imageVersionPattern = regexp.MustCompile(`^[0-9][A-Za-z0-9.+~-]{0,63}$`)
|
|
imageRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$`)
|
|
)
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
lockAdmission chan struct{}
|
|
notify taskstate.Notifier
|
|
}
|
|
|
|
// Gateway 是平台注册的 docker-gateway 实例;Token 由平台生成,明文存储供页面复制(开发阶段约定)。
|
|
type Gateway struct {
|
|
Name string `json:"name"`
|
|
Endpoint string `json:"endpoint"`
|
|
Token string `json:"token"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Image 是页面维护的可用浏览器镜像版本。
|
|
type Image struct {
|
|
Version string `json:"version"`
|
|
ImageRef string `json:"image_ref"`
|
|
Note string `json:"note"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Env 是一个浏览器环境;Fingerprint 为全量启动参数,创建后随请求整体下发网关。
|
|
type Env struct {
|
|
Alias string `json:"alias"`
|
|
Name string `json:"name"`
|
|
Gateway string `json:"gateway"`
|
|
ImageVersion string `json:"image_version"`
|
|
Fingerprint Fingerprint `json:"fingerprint"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
|
db, err := sql.Open("pgx", databaseURL)
|
|
if err != nil {
|
|
return nil, errors.New("open hub database")
|
|
}
|
|
db.SetMaxOpenConns(10)
|
|
db.SetMaxIdleConns(2)
|
|
db.SetConnMaxIdleTime(5 * time.Minute)
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, errors.New("connect to hub database")
|
|
}
|
|
// LockResources keeps one connection until the lifecycle operation finishes.
|
|
// Admit at most half the pool so those operations can still open nested DB calls.
|
|
store := &Store{db: db, lockAdmission: make(chan struct{}, 5)}
|
|
if err := store.migrate(ctx); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
|
|
func (s *Store) SetTaskNotifier(notify taskstate.Notifier) { s.notify = notify }
|
|
|
|
func (s *Store) notifyTransitions(transitions []taskstate.Transition) {
|
|
if s.notify == nil {
|
|
return
|
|
}
|
|
for _, transition := range transitions {
|
|
s.notify(transition)
|
|
}
|
|
}
|
|
|
|
// LockResources serializes lifecycle state across control-plane replicas. The
|
|
// transaction carries no data changes; rolling it back only releases the locks.
|
|
func (s *Store) LockResources(ctx context.Context, aliases, exitIDs, imageVersions []string) (func(), error) {
|
|
for _, alias := range aliases {
|
|
if !aliasPattern.MatchString(alias) {
|
|
return nil, ErrInvalid
|
|
}
|
|
}
|
|
for _, id := range exitIDs {
|
|
if !exitIDPattern.MatchString(id) {
|
|
return nil, ErrInvalid
|
|
}
|
|
}
|
|
for _, version := range imageVersions {
|
|
if !imageVersionPattern.MatchString(version) {
|
|
return nil, ErrInvalid
|
|
}
|
|
}
|
|
if len(aliases)+len(exitIDs)+len(imageVersions) == 0 {
|
|
return func() {}, nil
|
|
}
|
|
select {
|
|
case s.lockAdmission <- struct{}{}:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
<-s.lockAdmission
|
|
return nil, errors.New("begin resource lock")
|
|
}
|
|
resources := []struct {
|
|
namespace int
|
|
keys []string
|
|
}{{1542738013, aliases}, {1542738015, exitIDs}, {1542738014, imageVersions}}
|
|
for _, resource := range resources {
|
|
keys := append([]string(nil), resource.keys...)
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1, hashtext($2))`, resource.namespace, key); err != nil {
|
|
_ = tx.Rollback()
|
|
<-s.lockAdmission
|
|
return nil, errors.New("lock resource")
|
|
}
|
|
}
|
|
}
|
|
return func() {
|
|
_ = tx.Rollback()
|
|
<-s.lockAdmission
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) migrate(ctx context.Context) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return errors.New("begin hub schema migration")
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1542738012)`); err != nil {
|
|
return errors.New("lock hub schema migration")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migration (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
|
return errors.New("create schema migration table")
|
|
}
|
|
for _, migration := range []struct {
|
|
version int
|
|
sql string
|
|
}{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}, {16, migration016}} {
|
|
var applied bool
|
|
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil {
|
|
return errors.New("read hub schema migration state")
|
|
}
|
|
if applied {
|
|
continue
|
|
}
|
|
if _, err := tx.ExecContext(ctx, migration.sql); err != nil {
|
|
return fmt.Errorf("apply hub schema migration %d: %w", migration.version, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migration (version) VALUES ($1)`, migration.version); err != nil {
|
|
return fmt.Errorf("record hub schema migration %d", migration.version)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return errors.New("commit hub schema migration")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) CreateGateway(ctx context.Context, name, endpoint, token string) (Gateway, error) {
|
|
name, endpoint, token = strings.TrimSpace(name), strings.TrimSpace(endpoint), strings.TrimSpace(token)
|
|
if !gatewayNamePattern.MatchString(name) || !validHTTPURL(endpoint) {
|
|
return Gateway{}, ErrInvalid
|
|
}
|
|
// 未显式提供 token 时由平台生成;提供则与网关进程的 GATEWAY_TOKEN 保持一致。
|
|
if token != "" {
|
|
if !tokenPattern.MatchString(token) {
|
|
return Gateway{}, ErrInvalid
|
|
}
|
|
} else {
|
|
token = newToken()
|
|
}
|
|
gateway := Gateway{Name: name, Endpoint: endpoint, Token: token}
|
|
err := s.db.QueryRowContext(ctx, `
|
|
INSERT INTO gateway (name, endpoint, token) VALUES ($1, $2, $3)
|
|
RETURNING created_at, updated_at`, name, endpoint, gateway.Token).
|
|
Scan(&gateway.CreatedAt, &gateway.UpdatedAt)
|
|
if err != nil {
|
|
return Gateway{}, publicDatabaseError(err)
|
|
}
|
|
return gateway, nil
|
|
}
|
|
|
|
// UpdateGateway 修改网关名称、Endpoint 和令牌。名称变更由数据库外键 ON UPDATE CASCADE
|
|
// 原子同步 browser_env 引用;空令牌表示保留当前令牌,避免只改地址时意外轮换凭证。
|
|
func (s *Store) UpdateGateway(ctx context.Context, currentName, name, endpoint, token string) (Gateway, error) {
|
|
currentName, name = strings.TrimSpace(currentName), strings.TrimSpace(name)
|
|
endpoint, token = strings.TrimSpace(endpoint), strings.TrimSpace(token)
|
|
if !gatewayNamePattern.MatchString(currentName) || !gatewayNamePattern.MatchString(name) || !validHTTPURL(endpoint) ||
|
|
(token != "" && !tokenPattern.MatchString(token)) {
|
|
return Gateway{}, ErrInvalid
|
|
}
|
|
var gateway Gateway
|
|
err := s.db.QueryRowContext(ctx, `
|
|
UPDATE gateway SET name = $1, endpoint = $2,
|
|
token = CASE WHEN $3 = '' THEN token ELSE $3 END, updated_at = now()
|
|
WHERE name = $4
|
|
RETURNING name, endpoint, token, created_at, updated_at`, name, endpoint, token, currentName).
|
|
Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt)
|
|
if err != nil {
|
|
return Gateway{}, rowError(err)
|
|
}
|
|
return gateway, nil
|
|
}
|
|
|
|
func (s *Store) ListGateways(ctx context.Context) ([]Gateway, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT name, endpoint, token, created_at, updated_at FROM gateway ORDER BY created_at, name`)
|
|
if err != nil {
|
|
return nil, errors.New("read gateways")
|
|
}
|
|
defer rows.Close()
|
|
gateways := []Gateway{}
|
|
for rows.Next() {
|
|
var gateway Gateway
|
|
if err := rows.Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt); err != nil {
|
|
return nil, errors.New("decode gateway")
|
|
}
|
|
gateways = append(gateways, gateway)
|
|
}
|
|
return gateways, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetGateway(ctx context.Context, name string) (Gateway, error) {
|
|
var gateway Gateway
|
|
if !gatewayNamePattern.MatchString(name) {
|
|
return gateway, ErrInvalid
|
|
}
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT name, endpoint, token, created_at, updated_at FROM gateway WHERE name = $1`, name).
|
|
Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt)
|
|
if err != nil {
|
|
return Gateway{}, rowError(err)
|
|
}
|
|
return gateway, nil
|
|
}
|
|
|
|
func (s *Store) DeleteGateway(ctx context.Context, name string) error {
|
|
if !gatewayNamePattern.MatchString(name) {
|
|
return ErrInvalid
|
|
}
|
|
var deleted string
|
|
if err := s.db.QueryRowContext(ctx, `DELETE FROM gateway WHERE name = $1 RETURNING name`, name).Scan(&deleted); err != nil {
|
|
return rowError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) CreateImage(ctx context.Context, image Image) error {
|
|
image.Version = strings.TrimSpace(image.Version)
|
|
image.ImageRef = strings.TrimSpace(image.ImageRef)
|
|
image.Note = strings.TrimSpace(image.Note)
|
|
if !imageVersionPattern.MatchString(image.Version) || !imageRefPattern.MatchString(image.ImageRef) ||
|
|
utf8.RuneCountInString(image.Note) > 200 {
|
|
return ErrInvalid
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO browser_image (version, image_ref, note, enabled) VALUES ($1, $2, $3, $4)`,
|
|
image.Version, image.ImageRef, image.Note, image.Enabled)
|
|
return publicDatabaseError(err)
|
|
}
|
|
|
|
func (s *Store) UpdateImage(ctx context.Context, image Image) error {
|
|
image.ImageRef = strings.TrimSpace(image.ImageRef)
|
|
image.Note = strings.TrimSpace(image.Note)
|
|
if !imageVersionPattern.MatchString(image.Version) || !imageRefPattern.MatchString(image.ImageRef) ||
|
|
utf8.RuneCountInString(image.Note) > 200 {
|
|
return ErrInvalid
|
|
}
|
|
var updated string
|
|
err := s.db.QueryRowContext(ctx, `
|
|
UPDATE browser_image SET image_ref = $2, note = $3, enabled = $4, updated_at = now()
|
|
WHERE version = $1 RETURNING version`, image.Version, image.ImageRef, image.Note, image.Enabled).
|
|
Scan(&updated)
|
|
return rowError(err)
|
|
}
|
|
|
|
func (s *Store) ListImages(ctx context.Context, enabledOnly bool) ([]Image, error) {
|
|
query := `SELECT version, image_ref, note, enabled, created_at, updated_at FROM browser_image`
|
|
if enabledOnly {
|
|
query += ` WHERE enabled`
|
|
}
|
|
query += ` ORDER BY created_at DESC, version`
|
|
rows, err := s.db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, errors.New("read browser images")
|
|
}
|
|
defer rows.Close()
|
|
images := []Image{}
|
|
for rows.Next() {
|
|
var image Image
|
|
if err := rows.Scan(&image.Version, &image.ImageRef, &image.Note, &image.Enabled, &image.CreatedAt, &image.UpdatedAt); err != nil {
|
|
return nil, errors.New("decode browser image")
|
|
}
|
|
images = append(images, image)
|
|
}
|
|
return images, rows.Err()
|
|
}
|
|
|
|
func (s *Store) DeleteImage(ctx context.Context, version string) error {
|
|
if !imageVersionPattern.MatchString(version) {
|
|
return ErrInvalid
|
|
}
|
|
var deleted string
|
|
if err := s.db.QueryRowContext(ctx, `DELETE FROM browser_image WHERE version = $1 RETURNING version`, version).Scan(&deleted); err != nil {
|
|
return rowError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ImageRef 返回可用(存在且启用)版本的镜像引用;缺失或禁用均视为不可用。
|
|
func (s *Store) ImageRef(ctx context.Context, version string) (string, error) {
|
|
if !imageVersionPattern.MatchString(version) {
|
|
return "", ErrInvalid
|
|
}
|
|
var ref string
|
|
if err := s.db.QueryRowContext(ctx, `SELECT image_ref FROM browser_image WHERE version = $1 AND enabled`, version).Scan(&ref); err != nil {
|
|
return "", rowError(err)
|
|
}
|
|
return ref, nil
|
|
}
|
|
|
|
func (s *Store) CreateEnv(ctx context.Context, env Env) error {
|
|
env.Alias = strings.TrimSpace(env.Alias)
|
|
env.Name = strings.TrimSpace(env.Name)
|
|
if !aliasPattern.MatchString(env.Alias) || !validDisplayName(env.Name) ||
|
|
!gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) ||
|
|
env.Fingerprint.ProxyServer != "" {
|
|
return ErrInvalid
|
|
}
|
|
if err := env.Fingerprint.Validate(); err != nil {
|
|
return fmt.Errorf("%w: %s", ErrInvalid, err)
|
|
}
|
|
encoded, err := json.Marshal(env.Fingerprint)
|
|
if err != nil {
|
|
return ErrInvalid
|
|
}
|
|
var created string
|
|
err = s.db.QueryRowContext(ctx, `
|
|
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
|
|
SELECT $1, $2, $3, version, $5 FROM browser_image WHERE version = $4 AND enabled
|
|
RETURNING alias`, env.Alias, env.Name, env.Gateway, env.ImageVersion, encoded).Scan(&created)
|
|
return rowError(err)
|
|
}
|
|
|
|
func (s *Store) ListEnvs(ctx context.Context) ([]Env, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT alias, name, gateway_name, image_version, fingerprint, created_at
|
|
FROM browser_env ORDER BY created_at, alias`)
|
|
if err != nil {
|
|
return nil, errors.New("read browser envs")
|
|
}
|
|
defer rows.Close()
|
|
envs := []Env{}
|
|
for rows.Next() {
|
|
env, err := scanEnv(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
envs = append(envs, env)
|
|
}
|
|
return envs, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) {
|
|
if !aliasPattern.MatchString(alias) {
|
|
return Env{}, ErrInvalid
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT alias, name, gateway_name, image_version, fingerprint, created_at
|
|
FROM browser_env WHERE alias = $1`, alias)
|
|
if err != nil {
|
|
return Env{}, errors.New("read browser env")
|
|
}
|
|
defer rows.Close()
|
|
if !rows.Next() {
|
|
return Env{}, ErrNotFound
|
|
}
|
|
env, err := scanEnv(rows)
|
|
if err != nil {
|
|
return Env{}, err
|
|
}
|
|
return env, rows.Err()
|
|
}
|
|
|
|
// UpgradeEnv 将环境切换到指定可用镜像版本;参数与卷不变,容器重建由控制面编排网关完成。
|
|
func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error {
|
|
if !aliasPattern.MatchString(alias) || !imageVersionPattern.MatchString(version) {
|
|
return ErrInvalid
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return errors.New("begin environment upgrade")
|
|
}
|
|
defer tx.Rollback()
|
|
var cleanupPending bool
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT runtime_cleanup_pending FROM environment_binding
|
|
WHERE browser_env_alias = $1 FOR UPDATE`, alias).Scan(&cleanupPending); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return publicDatabaseError(err)
|
|
}
|
|
if cleanupPending {
|
|
return ErrConflict
|
|
}
|
|
var updated string
|
|
if err := tx.QueryRowContext(ctx, `
|
|
UPDATE browser_env SET image_version = $2, version = version + 1
|
|
WHERE alias = $1 AND EXISTS (SELECT 1 FROM browser_image WHERE version = $2 AND enabled)
|
|
RETURNING alias`, alias, version).Scan(&updated); err != nil {
|
|
return rowError(err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE environment_binding SET version = version + 1, updated_at = now()
|
|
WHERE browser_env_alias = $1`, alias); err != nil {
|
|
return errors.New("version upgraded environment binding")
|
|
}
|
|
rows, err := tx.QueryContext(ctx, `
|
|
WITH changed AS (
|
|
UPDATE social_account account
|
|
SET status = 'paused', paused_at = now(), version = account.version + 1, updated_at = now()
|
|
FROM environment_binding binding
|
|
WHERE binding.browser_env_alias = $1 AND binding.account_id = account.id
|
|
RETURNING account.id
|
|
), held AS (
|
|
UPDATE operation_task task SET
|
|
state = CASE task.state WHEN 'executing' THEN 'needs_confirmation' ELSE 'policy_hold' END,
|
|
hold_reason = CASE task.state WHEN 'executing' THEN 'task_result_uncertain' ELSE 'binding_version_changed' END,
|
|
verification_result = NULL, verified_at = NULL, verified_by = NULL,
|
|
lease_owner = NULL, lease_until = NULL, updated_at = now()
|
|
FROM changed WHERE task.account_id = changed.id AND task.state IN ('queued', 'executing')
|
|
RETURNING task.current_attempt_id, task.state, task.hold_reason, task.account_id, task.id
|
|
), finished AS (
|
|
UPDATE execution_attempt attempt SET finished_at = now(), outcome = 'uncertain'
|
|
FROM held WHERE held.state = 'needs_confirmation' AND attempt.id = held.current_attempt_id
|
|
AND attempt.finished_at IS NULL
|
|
RETURNING attempt.id
|
|
)
|
|
SELECT state, hold_reason, account_id, id FROM held`, alias)
|
|
if err != nil {
|
|
return errors.New("invalidate upgraded environment account")
|
|
}
|
|
transitions, err := scanTransitions(rows, "read upgraded environment tasks")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.commitTransitions(tx, transitions)
|
|
}
|
|
|
|
func (s *Store) DeleteEnv(ctx context.Context, alias string) error {
|
|
if !aliasPattern.MatchString(alias) {
|
|
return ErrInvalid
|
|
}
|
|
var deleted string
|
|
if err := s.db.QueryRowContext(ctx, `DELETE FROM browser_env WHERE alias = $1 RETURNING alias`, alias).Scan(&deleted); err != nil {
|
|
return rowError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanEnv(rows *sql.Rows) (Env, error) {
|
|
var env Env
|
|
var encoded []byte
|
|
if err := rows.Scan(&env.Alias, &env.Name, &env.Gateway, &env.ImageVersion, &encoded, &env.CreatedAt); err != nil {
|
|
return Env{}, errors.New("decode browser env")
|
|
}
|
|
if len(encoded) > 0 {
|
|
if err := json.Unmarshal(encoded, &env.Fingerprint); err != nil {
|
|
return Env{}, errors.New("decode env fingerprint")
|
|
}
|
|
}
|
|
env.Fingerprint.ProxyServer = ""
|
|
env.Fingerprint.DisableNonProxiedUDP = false
|
|
return env, nil
|
|
}
|
|
|
|
func validDisplayName(name string) bool {
|
|
if name == "" || utf8.RuneCountInString(name) > 64 {
|
|
return false
|
|
}
|
|
for _, r := range name {
|
|
if r < 0x20 || r == 0x7f {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validHTTPURL(raw string) bool {
|
|
parsed, err := url.Parse(raw)
|
|
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
|
|
}
|
|
|
|
func newToken() string {
|
|
var value [24]byte
|
|
_, _ = rand.Read(value[:])
|
|
return hex.EncodeToString(value[:])
|
|
}
|
|
|
|
func commitHub(tx *sql.Tx) error {
|
|
if err := tx.Commit(); err != nil {
|
|
return errors.New("commit hub transaction")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) commitTransitions(tx *sql.Tx, transitions []taskstate.Transition) error {
|
|
if err := commitHub(tx); err != nil {
|
|
return err
|
|
}
|
|
s.notifyTransitions(transitions)
|
|
return nil
|
|
}
|
|
|
|
func scanTransitions(rows *sql.Rows, failure string) ([]taskstate.Transition, error) {
|
|
defer rows.Close()
|
|
var transitions []taskstate.Transition
|
|
for rows.Next() {
|
|
var transition taskstate.Transition
|
|
if err := rows.Scan(&transition.State, &transition.ReasonCode, &transition.AccountID, &transition.TaskID); err != nil {
|
|
return nil, errors.New(failure)
|
|
}
|
|
transitions = append(transitions, transition)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, errors.New(failure)
|
|
}
|
|
return transitions, nil
|
|
}
|
|
|
|
func rowError(err error) error {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
return publicDatabaseError(err)
|
|
}
|
|
|
|
func publicDatabaseError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var postgresError *pgconn.PgError
|
|
if errors.As(err, &postgresError) && (postgresError.Code == "23505" || postgresError.Code == "23503" || postgresError.Code == "23514") {
|
|
return ErrConflict
|
|
}
|
|
return errors.New("hub persistence operation failed")
|
|
}
|