refactor: migrate browser gateway to native xvfb
This commit is contained in:
@@ -834,7 +834,7 @@ func contains(values []string, want string) bool {
|
||||
}
|
||||
func (s *Store) scanAccountTx(ctx context.Context, tx *sql.Tx, id string, result *AccountProfile) error {
|
||||
var checkedAt sql.NullTime
|
||||
if err := tx.QueryRowContext(ctx, accountProfileQuery(), id).Scan(&result.ID, &result.Name, &result.Platform, &result.PlatformAccountKey, &result.AuthorizationStatus, &result.RuntimeStatus, &result.LoginUsername, &result.PasswordConfigured, &result.RealNameStatus, &result.RealName, &result.IdentityNumber, &result.Note, &result.BusinessStatus, &result.BigAccount, &result.ReplyRequirements, &result.LoginStatus, &result.LoginReason, &checkedAt, &result.CooldownSeconds, &result.UpdatedAt); err != nil {
|
||||
if err := tx.QueryRowContext(ctx, accountProfileQuery(), id).Scan(&result.ID, &result.Name, &result.Platform, &result.PlatformAccountKey, &result.AuthorizationKind, &result.AuthorizationStatus, &result.RuntimeStatus, &result.LoginUsername, &result.PasswordConfigured, &result.RealNameStatus, &result.RealName, &result.IdentityNumber, &result.Note, &result.BusinessStatus, &result.BigAccount, &result.ReplyRequirements, &result.LoginStatus, &result.LoginReason, &checkedAt, &result.CooldownSeconds, &result.UpdatedAt); err != nil {
|
||||
return rowError(err)
|
||||
}
|
||||
result.LoginCheckedAt = nullableTime(checkedAt)
|
||||
|
||||
@@ -67,9 +67,9 @@ var migration032 string
|
||||
//go:embed migrations/033_competitor_tags.sql
|
||||
var migration033 string
|
||||
|
||||
// schema_migration is shared by phase-a, hub, and creator. Hub already used
|
||||
// version 33, so version 34 repairs creator competitor tags when creator
|
||||
// migration 33 was skipped.
|
||||
// Creator migrations use their own history table. Phase-a and hub retain a
|
||||
// shared schema_migration table for their schemas, but their numeric versions
|
||||
// overlap with the creator migration files and must not suppress one another.
|
||||
//
|
||||
//go:embed migrations/034_competitor_tags_repair.sql
|
||||
var migration034 string
|
||||
@@ -147,7 +147,7 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1542738017)`); err != nil {
|
||||
return errors.New("lock creator 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 {
|
||||
if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS creator_schema_migration (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
return errors.New("create creator schema migration table")
|
||||
}
|
||||
migrations := []struct {
|
||||
@@ -176,7 +176,7 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
}
|
||||
for _, migration := range migrations {
|
||||
var applied bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil {
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil {
|
||||
return errors.New("read creator schema migration state")
|
||||
}
|
||||
if applied {
|
||||
@@ -185,7 +185,7 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
if _, err := tx.ExecContext(ctx, migration.sql); err != nil {
|
||||
return fmt.Errorf("apply creator schema migration %d: %w", migration.version, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migration (version) VALUES ($1)`, migration.version); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO creator_schema_migration (version) VALUES ($1)`, migration.version); err != nil {
|
||||
return fmt.Errorf("record creator schema migration %d: %w", migration.version, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,10 +327,26 @@ func parseIdentity(body []byte) (identityEnvelope, bool) {
|
||||
return identity, true
|
||||
}
|
||||
|
||||
type douyinBool bool
|
||||
|
||||
func (value *douyinBool) UnmarshalJSON(data []byte) error {
|
||||
var boolean bool
|
||||
if err := json.Unmarshal(data, &boolean); err == nil {
|
||||
*value = douyinBool(boolean)
|
||||
return nil
|
||||
}
|
||||
var numeric int
|
||||
if err := json.Unmarshal(data, &numeric); err == nil && (numeric == 0 || numeric == 1) {
|
||||
*value = douyinBool(numeric == 1)
|
||||
return nil
|
||||
}
|
||||
return errors.New("douyin boolean must be true, false, 0, or 1")
|
||||
}
|
||||
|
||||
type worksEnvelope struct {
|
||||
StatusCode *int `json:"status_code"`
|
||||
HasMore *bool `json:"has_more"`
|
||||
MaxCursor *int64 `json:"max_cursor"`
|
||||
StatusCode *int `json:"status_code"`
|
||||
HasMore *douyinBool `json:"has_more"`
|
||||
MaxCursor *int64 `json:"max_cursor"`
|
||||
Works []struct {
|
||||
ID string `json:"aweme_id"`
|
||||
Description string `json:"desc"`
|
||||
@@ -365,7 +381,7 @@ func parseWorksPage(body []byte) ([]Work, bool, *int64, bool) {
|
||||
DiggCount: *candidate.Statistics.DiggCount, CommentCount: *candidate.Statistics.CommentCount,
|
||||
ShareCount: *candidate.Statistics.ShareCount, PlayCount: *candidate.Statistics.PlayCount})
|
||||
}
|
||||
return works, *envelope.HasMore, envelope.MaxCursor, true
|
||||
return works, bool(*envelope.HasMore), envelope.MaxCursor, true
|
||||
}
|
||||
|
||||
func parseWorks(body []byte) ([]Work, bool, bool) {
|
||||
|
||||
@@ -258,7 +258,7 @@ func parseCreatorCommentsPage(body []byte) (creator.CommentPage, error) {
|
||||
}
|
||||
items = append(items, creator.CommentInput{Platform: creator.PlatformDouyin, CommentKey: item.ID, AuthorUID: item.UserUID, AuthorName: item.UserName, Content: item.Text, PublishedAt: published, CommentType: "top_level"})
|
||||
}
|
||||
page := creator.CommentPage{Items: items, HasMore: *envelope.HasMore}
|
||||
page := creator.CommentPage{Items: items, HasMore: bool(*envelope.HasMore)}
|
||||
if envelope.Cursor != nil {
|
||||
page.NextCursor = strconv.FormatInt(*envelope.Cursor, 10)
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func parseCreatorWorksPage(body []byte) ([]creatorWorkPageItem, bool, *int64, bo
|
||||
return nil, false, nil, false
|
||||
}
|
||||
var envelope worksEnvelope
|
||||
if json.Unmarshal(body, &envelope) != nil || envelope.StatusCode == nil || *envelope.StatusCode != 0 || envelope.HasMore == nil || envelope.Works == nil || len(envelope.Works) > 20 || envelope.MaxCursor != nil && *envelope.MaxCursor < 0 || *envelope.HasMore && envelope.MaxCursor == nil {
|
||||
if json.Unmarshal(body, &envelope) != nil || envelope.StatusCode == nil || *envelope.StatusCode != 0 || envelope.HasMore == nil || envelope.Works == nil || len(envelope.Works) > 20 || envelope.MaxCursor != nil && *envelope.MaxCursor < 0 || bool(*envelope.HasMore) && envelope.MaxCursor == nil {
|
||||
return nil, false, nil, false
|
||||
}
|
||||
items := make([]creatorWorkPageItem, 0, len(envelope.Works))
|
||||
@@ -312,13 +312,13 @@ func parseCreatorWorksPage(body []byte) ([]creatorWorkPageItem, bool, *int64, bo
|
||||
}
|
||||
items = append(items, creatorWorkPageItem{ID: item.ID, Description: item.Description, CreatedAt: createdAt, CreatedAtInvalid: createdAtInvalid, DiggCount: likes, CommentCount: comments, ShareCount: shares})
|
||||
}
|
||||
return items, *envelope.HasMore, envelope.MaxCursor, true
|
||||
return items, bool(*envelope.HasMore), envelope.MaxCursor, true
|
||||
}
|
||||
|
||||
type commentEnvelope struct {
|
||||
StatusCode *int `json:"status_code"`
|
||||
HasMore *bool `json:"has_more"`
|
||||
Cursor *int64 `json:"cursor"`
|
||||
StatusCode *int `json:"status_code"`
|
||||
HasMore *douyinBool `json:"has_more"`
|
||||
Cursor *int64 `json:"cursor"`
|
||||
Comments []struct {
|
||||
ID string `json:"cid"`
|
||||
Text string `json:"text"`
|
||||
|
||||
@@ -92,6 +92,14 @@ func TestParseCreatorWorksPageMarksInvalidTimestamp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCreatorWorksPageAcceptsNumericHasMore(t *testing.T) {
|
||||
body := []byte(`{"status_code":0,"has_more":0,"aweme_list":[]}`)
|
||||
works, hasMore, cursor, ok := parseCreatorWorksPage(body)
|
||||
if !ok || hasMore || cursor != nil || len(works) != 0 {
|
||||
t.Fatalf("numeric has_more was not normalized: ok=%v hasMore=%v cursor=%v works=%d", ok, hasMore, cursor, len(works))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCreatorWorksPageKeepsPartialMetadata(t *testing.T) {
|
||||
body := []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"123","desc":"partial","statistics":{"digg_count":7}}]}`)
|
||||
works, hasMore, cursor, ok := parseCreatorWorksPage(body)
|
||||
|
||||
+177
-20
@@ -16,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
var exitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
|
||||
var nodeIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
|
||||
type NetworkExit struct {
|
||||
ID string `json:"id"`
|
||||
@@ -62,6 +63,16 @@ type EnvironmentContext struct {
|
||||
RuntimeInstanceID string `json:"runtime_instance_id,omitempty"`
|
||||
RuntimeID string `json:"runtime_id,omitempty"`
|
||||
RuntimeNetworkID string `json:"runtime_network_id,omitempty"`
|
||||
RuntimeNodeID string `json:"runtime_node_id,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeUseLease struct {
|
||||
Token string `json:"token"`
|
||||
RuntimeInstanceID string `json:"runtime_instance_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Purpose string `json:"purpose"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
LeaseUntil time.Time `json:"lease_until"`
|
||||
}
|
||||
|
||||
type EnvironmentAction struct {
|
||||
@@ -72,8 +83,8 @@ type EnvironmentAction struct {
|
||||
NetworkExitID string
|
||||
RuntimeInstanceID string
|
||||
BindingVersion int64
|
||||
OldImageVersion string
|
||||
NewImageVersion string
|
||||
OldBrowserVersion string
|
||||
NewBrowserVersion string
|
||||
Outcome string
|
||||
ReasonCode string
|
||||
}
|
||||
@@ -430,7 +441,7 @@ func invalidateAccountsForExit(ctx context.Context, tx *sql.Tx, exitID string) (
|
||||
func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID string) (EnvironmentContext, bool, error) {
|
||||
env.Alias, env.Name = strings.TrimSpace(env.Alias), strings.TrimSpace(env.Name)
|
||||
if !aliasPattern.MatchString(env.Alias) || !validDisplayName(env.Name) || !aliasPattern.MatchString(accountID) ||
|
||||
(exitID != "" && !exitIDPattern.MatchString(exitID)) || !gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) ||
|
||||
(exitID != "" && !exitIDPattern.MatchString(exitID)) || !gatewayNamePattern.MatchString(env.Gateway) || !browserVersionPattern.MatchString(env.BrowserVersion) ||
|
||||
env.Fingerprint.ProxyServer != "" {
|
||||
return EnvironmentContext{}, false, ErrInvalid
|
||||
}
|
||||
@@ -454,7 +465,7 @@ func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID s
|
||||
return EnvironmentContext{}, false, errors.New("commit existing environment lookup")
|
||||
}
|
||||
context, err := s.GetEnvironmentContext(ctx, env.Alias)
|
||||
if err != nil || context.Name != env.Name || context.Gateway != env.Gateway || context.ImageVersion != env.ImageVersion || context.Fingerprint != env.Fingerprint {
|
||||
if err != nil || context.Name != env.Name || context.Gateway != env.Gateway || context.BrowserVersion != env.BrowserVersion || context.Fingerprint != env.Fingerprint {
|
||||
return EnvironmentContext{}, false, ErrConflict
|
||||
}
|
||||
return context, false, nil
|
||||
@@ -464,13 +475,13 @@ func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID s
|
||||
}
|
||||
var created string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
|
||||
INSERT INTO browser_env (alias, name, gateway_name, browser_version, fingerprint)
|
||||
SELECT $1, $2, $3, image.version, $5
|
||||
FROM browser_image image, social_account account
|
||||
FROM browser_version image, social_account account
|
||||
WHERE image.version = $4 AND image.enabled AND account.id = $6 AND account.status = 'paused'
|
||||
AND account.authorization_status = 'authorized'
|
||||
AND ($7 = '' OR EXISTS (SELECT 1 FROM network_exit WHERE id = $7 AND health_status = 'healthy'))
|
||||
RETURNING alias`, env.Alias, env.Name, env.Gateway, env.ImageVersion, encoded, accountID, exitID).Scan(&created); err != nil {
|
||||
RETURNING alias`, env.Alias, env.Name, env.Gateway, env.BrowserVersion, encoded, accountID, exitID).Scan(&created); err != nil {
|
||||
return EnvironmentContext{}, false, rowError(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
@@ -504,7 +515,7 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro
|
||||
var runtimeInstanceID, runtimeID, runtimeNetworkID, cleanupInstanceID, cleanupRuntimeID, cleanupNetworkID sql.NullString
|
||||
var cleanupBindingVersion sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT environment.alias, environment.name, environment.gateway_name, environment.image_version,
|
||||
SELECT environment.alias, environment.name, environment.gateway_name, environment.browser_version,
|
||||
environment.fingerprint, environment.created_at, binding.account_id, account.status, account.authorization_status,
|
||||
binding.id, binding.version,
|
||||
binding.runtime_cleanup_pending, binding.runtime_cleanup_binding_version,
|
||||
@@ -515,21 +526,21 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro
|
||||
COALESCE(network.health_status, 'unchecked'), COALESCE(network.last_check_reason, ''),
|
||||
COALESCE(network.version, 0), network.last_checked_at,
|
||||
COALESCE(network.created_at, to_timestamp(0)), COALESCE(network.updated_at, to_timestamp(0)),
|
||||
runtime.id, runtime.runtime_id, runtime.network_id
|
||||
runtime.id, runtime.runtime_id, runtime.network_id, COALESCE(runtime.node_id, '')
|
||||
FROM browser_env environment
|
||||
JOIN environment_binding binding ON binding.browser_env_alias = environment.alias
|
||||
JOIN social_account account ON account.id = binding.account_id
|
||||
LEFT JOIN network_exit network ON network.id = binding.network_exit_id
|
||||
LEFT JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL
|
||||
WHERE environment.alias = $1`, alias).
|
||||
Scan(&result.Alias, &result.Name, &result.Gateway, &result.ImageVersion, &encoded, &result.CreatedAt,
|
||||
Scan(&result.Alias, &result.Name, &result.Gateway, &result.BrowserVersion, &encoded, &result.CreatedAt,
|
||||
&result.AccountID, &result.AccountStatus, &result.AuthorizationStatus,
|
||||
&result.BindingID, &result.BindingVersion, &result.RuntimeCleanupPending, &cleanupBindingVersion,
|
||||
&cleanupInstanceID, &cleanupRuntimeID, &cleanupNetworkID,
|
||||
&result.Exit.ID, &result.Exit.Protocol, &result.Exit.Host, &result.Exit.Port,
|
||||
&expectedIP, &result.Exit.ExpectedRegion, &observedIP, &result.Exit.ObservedRegion,
|
||||
&result.Exit.HealthStatus, &result.Exit.LastCheckReason, &result.Exit.Version, &checked, &result.Exit.CreatedAt, &result.Exit.UpdatedAt,
|
||||
&runtimeInstanceID, &runtimeID, &runtimeNetworkID)
|
||||
&runtimeInstanceID, &runtimeID, &runtimeNetworkID, &result.RuntimeNodeID)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, rowError(err)
|
||||
}
|
||||
@@ -566,6 +577,143 @@ func (s *Store) GetEnvironmentContextForAccount(ctx context.Context, accountID s
|
||||
return s.GetEnvironmentContext(ctx, alias)
|
||||
}
|
||||
|
||||
const runtimeUseLeaseDuration = time.Minute
|
||||
|
||||
func validRuntimeUse(purpose, ownerID, taskID string) bool {
|
||||
if purpose != "task" && purpose != "listener" || ownerID == "" || len(ownerID) > 128 || !exitIDPattern.MatchString(ownerID) {
|
||||
return false
|
||||
}
|
||||
return taskID == "" || exitIDPattern.MatchString(taskID)
|
||||
}
|
||||
|
||||
// AcquireRuntimeUse reserves a short-lived task or long-lived listener right on
|
||||
// an already running browser. It deliberately does not own the browser runtime
|
||||
// itself; stopping that runtime revokes active use leases and callers observe
|
||||
// the loss on their next renewal.
|
||||
func (s *Store) AcquireRuntimeUse(ctx context.Context, alias, purpose, ownerID, taskID string) (RuntimeUseLease, error) {
|
||||
if !aliasPattern.MatchString(alias) || !validRuntimeUse(purpose, ownerID, taskID) {
|
||||
return RuntimeUseLease{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return RuntimeUseLease{}, errors.New("begin runtime use acquisition")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var runtimeInstanceID string
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT runtime.id
|
||||
FROM environment_binding binding
|
||||
JOIN runtime_instance runtime ON runtime.binding_id = binding.id
|
||||
JOIN social_account account ON account.id = binding.account_id
|
||||
WHERE binding.browser_env_alias = $1 AND runtime.released_at IS NULL
|
||||
AND runtime.lease_until > now() AND NOT binding.runtime_cleanup_pending
|
||||
AND account.status = 'active' AND account.authorization_status = 'authorized'
|
||||
FOR UPDATE OF binding, runtime`, alias).Scan(&runtimeInstanceID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RuntimeUseLease{}, ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return RuntimeUseLease{}, publicDatabaseError(err)
|
||||
}
|
||||
token := "runtime-use-" + newHubID()
|
||||
var lease RuntimeUseLease
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO runtime_use_lease (token, runtime_instance_id, owner_id, purpose, task_id, lease_until)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, ''), now() + $6::interval)
|
||||
RETURNING token, runtime_instance_id, owner_id, purpose, COALESCE(task_id, ''), lease_until`,
|
||||
token, runtimeInstanceID, ownerID, purpose, taskID, runtimeUseLeaseDuration.String()).Scan(
|
||||
&lease.Token, &lease.RuntimeInstanceID, &lease.OwnerID, &lease.Purpose, &lease.TaskID, &lease.LeaseUntil)
|
||||
if err != nil {
|
||||
return RuntimeUseLease{}, publicDatabaseError(err)
|
||||
}
|
||||
if err := commitHub(tx); err != nil {
|
||||
return RuntimeUseLease{}, err
|
||||
}
|
||||
return lease, nil
|
||||
}
|
||||
|
||||
func (s *Store) RenewRuntimeUse(ctx context.Context, token string) (RuntimeUseLease, error) {
|
||||
if !exitIDPattern.MatchString(token) {
|
||||
return RuntimeUseLease{}, ErrInvalid
|
||||
}
|
||||
var lease RuntimeUseLease
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
UPDATE runtime_use_lease use_lease
|
||||
SET lease_until = now() + interval '1 minute'
|
||||
FROM runtime_instance runtime
|
||||
WHERE use_lease.token = $1 AND use_lease.released_at IS NULL
|
||||
AND use_lease.lease_until > now() AND runtime.id = use_lease.runtime_instance_id
|
||||
AND runtime.released_at IS NULL AND runtime.lease_until > now()
|
||||
RETURNING use_lease.token, use_lease.runtime_instance_id, use_lease.owner_id,
|
||||
use_lease.purpose, COALESCE(use_lease.task_id, ''), use_lease.lease_until`, token).Scan(
|
||||
&lease.Token, &lease.RuntimeInstanceID, &lease.OwnerID, &lease.Purpose, &lease.TaskID, &lease.LeaseUntil)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RuntimeUseLease{}, ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return RuntimeUseLease{}, publicDatabaseError(err)
|
||||
}
|
||||
return lease, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReleaseRuntimeUse(ctx context.Context, token string) error {
|
||||
if !exitIDPattern.MatchString(token) {
|
||||
return ErrInvalid
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE runtime_use_lease SET released_at = now()
|
||||
WHERE token = $1 AND released_at IS NULL`, token)
|
||||
if err != nil {
|
||||
return publicDatabaseError(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 1 {
|
||||
return nil
|
||||
}
|
||||
var exists bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM runtime_use_lease WHERE token = $1)`, token).Scan(&exists); err != nil {
|
||||
return publicDatabaseError(err)
|
||||
}
|
||||
if !exists {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetRuntimeNode(ctx context.Context, alias, runtimeID, nodeID string) error {
|
||||
if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || !nodeIDPattern.MatchString(nodeID) {
|
||||
return ErrInvalid
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE runtime_instance runtime
|
||||
SET node_id = $3
|
||||
FROM environment_binding binding
|
||||
WHERE binding.browser_env_alias = $1 AND runtime.id = $2 AND runtime.binding_id = binding.id
|
||||
AND runtime.released_at IS NULL`, alias, runtimeID, nodeID)
|
||||
if err != nil {
|
||||
return publicDatabaseError(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseRuntimeUseLeases(ctx context.Context, tx *sql.Tx, runtimeInstanceID string) error {
|
||||
if runtimeInstanceID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `UPDATE runtime_use_lease SET released_at = now() WHERE runtime_instance_id = $1 AND released_at IS NULL`, runtimeInstanceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func releaseExpiredRuntime(ctx context.Context, tx *sql.Tx, bindingID string) error {
|
||||
var accountID, alias, runtimeInstanceID string
|
||||
var exitID sql.NullString
|
||||
@@ -584,6 +732,9 @@ func releaseExpiredRuntime(ctx context.Context, tx *sql.Tx, bindingID string) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseRuntimeUseLeases(ctx, tx, runtimeInstanceID); err != nil {
|
||||
return errors.New("release runtime use leases")
|
||||
}
|
||||
return appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID.String, runtimeInstanceID, bindingVersion)
|
||||
}
|
||||
|
||||
@@ -670,8 +821,8 @@ func (s *Store) RebindEnvironment(ctx context.Context, alias, exitID, runtimeID
|
||||
if runtimeID != "" {
|
||||
runtimeInstanceID := "runtime-" + newHubID()
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, network_id, lease_until)
|
||||
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), now() + interval '1 minute')`,
|
||||
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, network_id, owner_id, purpose, lease_token, node_id, lease_until)
|
||||
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), 'control-plane', 'session', 'runtime-lease-' || $1, '', now() + interval '1 minute')`,
|
||||
runtimeInstanceID, accountID, bindingID, expectedBindingVersion+1, runtimeID, networkID); err != nil {
|
||||
return EnvironmentContext{}, publicDatabaseError(err)
|
||||
}
|
||||
@@ -732,15 +883,15 @@ func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bi
|
||||
if existingInstanceID == "" {
|
||||
existingInstanceID = "runtime-" + newHubID()
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, network_id, lease_until)
|
||||
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), now() + interval '1 minute')`,
|
||||
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, network_id, owner_id, purpose, lease_token, node_id, lease_until)
|
||||
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), 'control-plane', 'session', 'runtime-lease-' || $1, '', now() + interval '1 minute')`,
|
||||
existingInstanceID, accountID, bindingID, bindingVersion, runtimeID, networkID); err != nil {
|
||||
return EnvironmentContext{}, publicDatabaseError(err)
|
||||
}
|
||||
if err := appendRuntimeAudit(ctx, tx, "runtime_bound", accountID, alias, exitID, existingInstanceID, bindingVersion); err != nil {
|
||||
return EnvironmentContext{}, err
|
||||
}
|
||||
} else if _, err := tx.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() + interval '1 minute' WHERE id = $1`, existingInstanceID); err != nil {
|
||||
} else if _, err := tx.ExecContext(ctx, `UPDATE runtime_instance SET owner_id = 'control-plane', purpose = 'session', lease_until = now() + interval '1 minute' WHERE id = $1`, existingInstanceID); err != nil {
|
||||
return EnvironmentContext{}, errors.New("renew environment runtime")
|
||||
}
|
||||
if err := commitHub(tx); err != nil {
|
||||
@@ -780,6 +931,9 @@ func (s *Store) ReleaseRuntime(ctx context.Context, environment EnvironmentConte
|
||||
if err != nil {
|
||||
return errors.New("release environment runtime")
|
||||
}
|
||||
if err := releaseRuntimeUseLeases(ctx, tx, runtimeInstanceID); err != nil {
|
||||
return errors.New("release runtime use leases")
|
||||
}
|
||||
if err := appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID.String, runtimeInstanceID, bindingVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -855,6 +1009,9 @@ func (s *Store) SetRuntimeCleanupPending(ctx context.Context, environment Enviro
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := releaseRuntimeUseLeases(ctx, tx, runtimeInstanceID); err != nil {
|
||||
return errors.New("release runtime use leases")
|
||||
}
|
||||
if err := appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID.String, runtimeInstanceID, environment.BindingVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -895,20 +1052,20 @@ func appendRuntimeAudit(ctx context.Context, tx *sql.Tx, eventType, accountID, a
|
||||
func (s *Store) AppendEnvironmentAction(ctx context.Context, eventType string, action EnvironmentAction) error {
|
||||
if (eventType != "environment_action_requested" && eventType != "environment_action_finished") ||
|
||||
!exitIDPattern.MatchString(action.OperationID) || action.Action == "" || action.ReasonCode == "" ||
|
||||
(action.OldImageVersion != "" && !imageVersionPattern.MatchString(action.OldImageVersion)) ||
|
||||
(action.NewImageVersion != "" && !imageVersionPattern.MatchString(action.NewImageVersion)) ||
|
||||
(action.OldBrowserVersion != "" && !browserVersionPattern.MatchString(action.OldBrowserVersion)) ||
|
||||
(action.NewBrowserVersion != "" && !browserVersionPattern.MatchString(action.NewBrowserVersion)) ||
|
||||
(eventType == "environment_action_finished" && action.Outcome != "succeeded" && action.Outcome != "failed" && action.Outcome != "unknown") {
|
||||
return ErrInvalid
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_event
|
||||
(event_type, account_id, browser_env_alias, network_exit_id, runtime_instance_id,
|
||||
binding_version, actor, reason_code, operation_id, action, outcome, old_image_version, new_image_version)
|
||||
binding_version, actor, reason_code, operation_id, action, outcome, old_browser_version, new_browser_version)
|
||||
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''),
|
||||
NULLIF($6, 0), 'local-user', $7, $8, $9, NULLIF($10, ''), NULLIF($11, ''), NULLIF($12, ''))`,
|
||||
eventType, action.AccountID, action.BrowserEnvAlias, action.NetworkExitID, action.RuntimeInstanceID,
|
||||
action.BindingVersion, action.ReasonCode, action.OperationID, action.Action, action.Outcome,
|
||||
action.OldImageVersion, action.NewImageVersion)
|
||||
action.OldBrowserVersion, action.NewBrowserVersion)
|
||||
if err != nil {
|
||||
return errors.New("append environment action")
|
||||
}
|
||||
|
||||
@@ -33,8 +33,10 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 16`, 16)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 4)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 17`, 17)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'browser_version', 'network_exit', 'environment_binding')`, 5)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'browser_image'`, 0)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'browser_version' AND column_name = 'browser_path'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name IN ('name', 'tags')`, 2)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name = 'runtime_cleanup_pending'`, 1)
|
||||
@@ -43,7 +45,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
|
||||
store = openFullyMigratedHub(t, ctx, testURL)
|
||||
store.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 16`, 16)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 17`, 17)
|
||||
})
|
||||
|
||||
t.Run("legacy migration 013 without account secrets is repaired forward", func(t *testing.T) {
|
||||
@@ -152,8 +154,8 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
VALUES ('upgrade', 'credential-upgrade', 'mock', 'upgrade', 'owned', 'authorized', 'paused');
|
||||
INSERT INTO gateway (name, endpoint, token)
|
||||
VALUES ('upgrade-gateway', 'http://127.0.0.1:8081', 'upgrade-gateway-token');
|
||||
INSERT INTO browser_image (version, image_ref) VALUES ('1', 'example/browser:1');
|
||||
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
|
||||
INSERT INTO browser_version (version, browser_path) VALUES ('1', '/opt/creatorhub/browsers/1');
|
||||
INSERT INTO browser_env (alias, name, gateway_name, browser_version, fingerprint)
|
||||
VALUES ('upgrade', 'Upgrade', 'upgrade-gateway', '1', '{"seed":1}');
|
||||
INSERT INTO environment_binding (id, account_id, browser_env_alias)
|
||||
VALUES ('upgrade', 'upgrade', 'upgrade');
|
||||
@@ -233,7 +235,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
('legacy-task', 'legacy-task-key', 'mapped', 1, 'legacy-draft', 1, 'legacy-confirmation', 1, 'queued'),
|
||||
('legacy-unknown-task', 'legacy-unknown-task-key', 'mapped', 1, 'legacy-draft', 1, 'legacy-confirmation', 1, 'needs_confirmation');
|
||||
INSERT INTO gateway (name, endpoint, token) VALUES ('legacy-gateway', 'http://127.0.0.1:8081', 'legacy-gateway-token');
|
||||
INSERT INTO browser_image (version, image_ref) VALUES ('1', 'example/browser:1');
|
||||
INSERT INTO browser_image (version, image_ref) VALUES ('1', '/opt/creatorhub/browsers/1');
|
||||
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) VALUES
|
||||
('mapped', 'Mapped', 'legacy-gateway', '1', '{"seed":1,"proxy_server":"http://legacy:secret@proxy.example:8080","disable_non_proxied_udp":true}'),
|
||||
('orphan-env', 'Orphan', 'legacy-gateway', '1', '{"seed":2}');
|
||||
@@ -318,7 +320,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
t.Fatal("one binding must not have two active runtime instances")
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO browser_image (version, image_ref) VALUES ('2', 'example/browser:2');
|
||||
INSERT INTO browser_version (version, browser_path) VALUES ('2', '/opt/creatorhub/browsers/2');
|
||||
UPDATE social_account SET status = 'active' WHERE id = 'mapped';
|
||||
INSERT INTO content_draft (id, account_id, version, content) VALUES ('upgrade-draft', 'mapped', 1, 'test');
|
||||
INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version)
|
||||
@@ -344,7 +346,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM browser_env WHERE alias = 'mapped' AND version = 2 AND image_version = '2'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM browser_env WHERE alias = 'mapped' AND version = 2 AND browser_version = '2'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE id = 'mapped' AND version = 2`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM social_account WHERE id = 'mapped' AND version = 2 AND status = 'paused'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task WHERE id = 'upgrade-task' AND state = 'policy_hold' AND hold_reason = 'binding_version_changed'`, 1)
|
||||
|
||||
@@ -41,7 +41,7 @@ ALTER TABLE browser_env
|
||||
ALTER TABLE browser_image
|
||||
DROP CONSTRAINT IF EXISTS browser_image_image_ref_check,
|
||||
ADD CONSTRAINT browser_image_image_ref_check
|
||||
CHECK (length(image_ref) <= 301 AND image_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:/@-]*$');
|
||||
CHECK (length(image_ref) <= 301 AND (image_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:/@-]*$' OR image_ref ~ '^/[A-Za-z0-9._+~/-]+$'));
|
||||
|
||||
CREATE TABLE network_exit (
|
||||
id text PRIMARY KEY CHECK (id ~ '^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$'),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Replace Docker image terminology with the pre-installed native browser contract.
|
||||
ALTER TABLE browser_image RENAME TO browser_version;
|
||||
ALTER TABLE browser_version RENAME COLUMN image_ref TO browser_path;
|
||||
ALTER TABLE browser_env RENAME COLUMN image_version TO browser_version;
|
||||
ALTER TABLE audit_event RENAME COLUMN old_image_version TO old_browser_version;
|
||||
ALTER TABLE audit_event RENAME COLUMN new_image_version TO new_browser_version;
|
||||
|
||||
ALTER TABLE browser_version DROP CONSTRAINT browser_image_image_ref_check;
|
||||
ALTER TABLE browser_version
|
||||
ADD CONSTRAINT browser_version_browser_path_check
|
||||
CHECK (browser_path ~ '^/[A-Za-z0-9._+~/-]+$');
|
||||
ALTER TABLE browser_env
|
||||
RENAME CONSTRAINT browser_env_image_version_fkey TO browser_env_browser_version_fkey;
|
||||
+76
-69
@@ -66,6 +66,9 @@ var migration015 string
|
||||
//go:embed migrations/016_network_exit_plain_credentials.sql
|
||||
var migration016 string
|
||||
|
||||
//go:embed migrations/017_native_browser_versions.sql
|
||||
var migration017 string
|
||||
|
||||
//go:embed migrations/033_unique_fingerprint_seed.sql
|
||||
var migration033 string
|
||||
|
||||
@@ -76,16 +79,16 @@ var (
|
||||
ErrReconcileRequired = errors.New("runtime cleanup generation is unknown; manual reconciliation required")
|
||||
)
|
||||
|
||||
func ValidImageVersion(version string) bool { return imageVersionPattern.MatchString(version) }
|
||||
func ValidBrowserVersion(version string) bool { return browserVersionPattern.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}$`)
|
||||
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}$`)
|
||||
browserVersionPattern = regexp.MustCompile(`^[0-9][A-Za-z0-9.+~-]{0,63}$`)
|
||||
browserPathPattern = regexp.MustCompile(`^/[A-Za-z0-9._+~/-]+$`)
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
@@ -94,7 +97,7 @@ type Store struct {
|
||||
notify taskstate.Notifier
|
||||
}
|
||||
|
||||
// Gateway 是平台注册的 docker-gateway 实例;Token 由平台生成,明文存储供页面复制(开发阶段约定)。
|
||||
// Gateway 是平台注册的 native browser gateway 节点;Token 由平台生成,明文存储供页面复制(开发阶段约定)。
|
||||
type Gateway struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
@@ -103,24 +106,24 @@ type Gateway struct {
|
||||
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"`
|
||||
// BrowserVersion 是页面维护的可用预安装浏览器版本。
|
||||
type BrowserVersion struct {
|
||||
Version string `json:"version"`
|
||||
BrowserPath string `json:"browser_path"`
|
||||
Note string `json:"note"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Env 是一个浏览器环境;Fingerprint 为全量启动参数,创建后随请求整体下发网关。
|
||||
// Env 是一个浏览器环境;Fingerprint 为全量启动参数,创建后随请求整体下发 gateway。
|
||||
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"`
|
||||
Alias string `json:"alias"`
|
||||
Name string `json:"name"`
|
||||
Gateway string `json:"gateway"`
|
||||
BrowserVersion string `json:"browser_version"`
|
||||
Fingerprint Fingerprint `json:"fingerprint"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
@@ -162,7 +165,7 @@ func (s *Store) notifyTransitions(transitions []taskstate.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) {
|
||||
func (s *Store) LockResources(ctx context.Context, aliases, exitIDs, browserVersions []string) (func(), error) {
|
||||
for _, alias := range aliases {
|
||||
if !aliasPattern.MatchString(alias) {
|
||||
return nil, ErrInvalid
|
||||
@@ -173,12 +176,12 @@ func (s *Store) LockResources(ctx context.Context, aliases, exitIDs, imageVersio
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
}
|
||||
for _, version := range imageVersions {
|
||||
if !imageVersionPattern.MatchString(version) {
|
||||
for _, version := range browserVersions {
|
||||
if !browserVersionPattern.MatchString(version) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
}
|
||||
if len(aliases)+len(exitIDs)+len(imageVersions) == 0 {
|
||||
if len(aliases)+len(exitIDs)+len(browserVersions) == 0 {
|
||||
return func() {}, nil
|
||||
}
|
||||
select {
|
||||
@@ -194,7 +197,7 @@ func (s *Store) LockResources(ctx context.Context, aliases, exitIDs, imageVersio
|
||||
resources := []struct {
|
||||
namespace int
|
||||
keys []string
|
||||
}{{1542738013, aliases}, {1542738015, exitIDs}, {1542738014, imageVersions}}
|
||||
}{{1542738013, aliases}, {1542738015, exitIDs}, {1542738014, browserVersions}}
|
||||
for _, resource := range resources {
|
||||
keys := append([]string(nil), resource.keys...)
|
||||
sort.Strings(keys)
|
||||
@@ -227,7 +230,7 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
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}, {33, migration033}} {
|
||||
}{{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}, {17, migration017}, {33, migration033}} {
|
||||
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")
|
||||
@@ -337,75 +340,75 @@ func (s *Store) DeleteGateway(ctx context.Context, name string) error {
|
||||
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 {
|
||||
func (s *Store) CreateBrowserVersion(ctx context.Context, version BrowserVersion) error {
|
||||
version.Version = strings.TrimSpace(version.Version)
|
||||
version.BrowserPath = strings.TrimSpace(version.BrowserPath)
|
||||
version.Note = strings.TrimSpace(version.Note)
|
||||
if !browserVersionPattern.MatchString(version.Version) || !validBrowserPath(version.BrowserPath) ||
|
||||
utf8.RuneCountInString(version.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)
|
||||
INSERT INTO browser_version (version, browser_path, note, enabled) VALUES ($1, $2, $3, $4)`,
|
||||
version.Version, version.BrowserPath, version.Note, version.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 {
|
||||
func (s *Store) UpdateBrowserVersion(ctx context.Context, version BrowserVersion) error {
|
||||
version.BrowserPath = strings.TrimSpace(version.BrowserPath)
|
||||
version.Note = strings.TrimSpace(version.Note)
|
||||
if !browserVersionPattern.MatchString(version.Version) || !validBrowserPath(version.BrowserPath) ||
|
||||
utf8.RuneCountInString(version.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).
|
||||
UPDATE browser_version SET browser_path = $2, note = $3, enabled = $4, updated_at = now()
|
||||
WHERE version = $1 RETURNING version`, version.Version, version.BrowserPath, version.Note, version.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`
|
||||
func (s *Store) ListBrowserVersions(ctx context.Context, enabledOnly bool) ([]BrowserVersion, error) {
|
||||
query := `SELECT version, browser_path, note, enabled, created_at, updated_at FROM browser_version`
|
||||
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")
|
||||
return nil, errors.New("read browser versions")
|
||||
}
|
||||
defer rows.Close()
|
||||
images := []Image{}
|
||||
versions := []BrowserVersion{}
|
||||
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")
|
||||
var version BrowserVersion
|
||||
if err := rows.Scan(&version.Version, &version.BrowserPath, &version.Note, &version.Enabled, &version.CreatedAt, &version.UpdatedAt); err != nil {
|
||||
return nil, errors.New("decode browser version")
|
||||
}
|
||||
images = append(images, image)
|
||||
versions = append(versions, version)
|
||||
}
|
||||
return images, rows.Err()
|
||||
return versions, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteImage(ctx context.Context, version string) error {
|
||||
if !imageVersionPattern.MatchString(version) {
|
||||
func (s *Store) DeleteBrowserVersion(ctx context.Context, version string) error {
|
||||
if !browserVersionPattern.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 {
|
||||
if err := s.db.QueryRowContext(ctx, `DELETE FROM browser_version 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) {
|
||||
// BrowserPath 返回可用(存在且启用)版本的宿主机浏览器路径;缺失或禁用均视为不可用。
|
||||
func (s *Store) BrowserPath(ctx context.Context, version string) (string, error) {
|
||||
if !browserVersionPattern.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 {
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT browser_path FROM browser_version WHERE version = $1 AND enabled`, version).Scan(&ref); err != nil {
|
||||
return "", rowError(err)
|
||||
}
|
||||
return ref, nil
|
||||
@@ -415,7 +418,7 @@ 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) ||
|
||||
!gatewayNamePattern.MatchString(env.Gateway) || !browserVersionPattern.MatchString(env.BrowserVersion) ||
|
||||
env.Fingerprint.ProxyServer != "" {
|
||||
return ErrInvalid
|
||||
}
|
||||
@@ -428,15 +431,15 @@ func (s *Store) CreateEnv(ctx context.Context, env Env) error {
|
||||
}
|
||||
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)
|
||||
INSERT INTO browser_env (alias, name, gateway_name, browser_version, fingerprint)
|
||||
SELECT $1, $2, $3, version, $5 FROM browser_version WHERE version = $4 AND enabled
|
||||
RETURNING alias`, env.Alias, env.Name, env.Gateway, env.BrowserVersion, 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
|
||||
SELECT alias, name, gateway_name, browser_version, fingerprint, created_at
|
||||
FROM browser_env ORDER BY created_at, alias`)
|
||||
if err != nil {
|
||||
return nil, errors.New("read browser envs")
|
||||
@@ -458,7 +461,7 @@ func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) {
|
||||
return Env{}, ErrInvalid
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT alias, name, gateway_name, image_version, fingerprint, created_at
|
||||
SELECT alias, name, gateway_name, browser_version, fingerprint, created_at
|
||||
FROM browser_env WHERE alias = $1`, alias)
|
||||
if err != nil {
|
||||
return Env{}, errors.New("read browser env")
|
||||
@@ -476,7 +479,7 @@ func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) {
|
||||
|
||||
// UpgradeEnv 将环境切换到指定可用镜像版本;参数与卷不变,容器重建由控制面编排网关完成。
|
||||
func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error {
|
||||
if !aliasPattern.MatchString(alias) || !imageVersionPattern.MatchString(version) {
|
||||
if !aliasPattern.MatchString(alias) || !browserVersionPattern.MatchString(version) {
|
||||
return ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
@@ -495,8 +498,8 @@ func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error {
|
||||
}
|
||||
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)
|
||||
UPDATE browser_env SET browser_version = $2, version = version + 1
|
||||
WHERE alias = $1 AND EXISTS (SELECT 1 FROM browser_version WHERE version = $2 AND enabled)
|
||||
RETURNING alias`, alias, version).Scan(&updated); err != nil {
|
||||
return rowError(err)
|
||||
}
|
||||
@@ -581,7 +584,7 @@ func (s *Store) DeleteAccountEnvironment(ctx context.Context, accountID string)
|
||||
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 {
|
||||
if err := rows.Scan(&env.Alias, &env.Name, &env.Gateway, &env.BrowserVersion, &encoded, &env.CreatedAt); err != nil {
|
||||
return Env{}, errors.New("decode browser env")
|
||||
}
|
||||
if len(encoded) > 0 {
|
||||
@@ -594,6 +597,10 @@ func scanEnv(rows *sql.Rows) (Env, error) {
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func validBrowserPath(path string) bool {
|
||||
return len(path) >= 2 && len(path) <= 4096 && browserPathPattern.MatchString(path)
|
||||
}
|
||||
|
||||
func validDisplayName(name string) bool {
|
||||
if name == "" || utf8.RuneCountInString(name) > 64 {
|
||||
return false
|
||||
|
||||
+120
-39
@@ -258,22 +258,22 @@ func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) {
|
||||
t.Fatalf("expected invalid gateway update %s, got %v", test.name, err)
|
||||
}
|
||||
}
|
||||
if err := store.CreateImage(ctx, Image{Version: "v1", ImageRef: "registry/img:1"}); !errors.Is(err, ErrInvalid) {
|
||||
if err := store.CreateBrowserVersion(ctx, BrowserVersion{Version: "v1", BrowserPath: "registry/img:1"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid image version, got %v", err)
|
||||
}
|
||||
if err := store.CreateImage(ctx, Image{Version: "148.0.0.1", ImageRef: "has space"}); !errors.Is(err, ErrInvalid) {
|
||||
if err := store.CreateBrowserVersion(ctx, BrowserVersion{Version: "148.0.0.1", BrowserPath: "has space"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid image ref, got %v", err)
|
||||
}
|
||||
if err := store.CreateImage(ctx, Image{Version: "148.0.0.1", ImageRef: "registry/img:1", Note: strings.Repeat("长", 201)}); !errors.Is(err, ErrInvalid) {
|
||||
if err := store.CreateBrowserVersion(ctx, BrowserVersion{Version: "148.0.0.1", BrowserPath: "registry/img:1", Note: strings.Repeat("长", 201)}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected overlong note to be rejected, got %v", err)
|
||||
}
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "UP", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrInvalid) {
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "UP", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid alias, got %v", err)
|
||||
}
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: strings.Repeat("名", 65), Gateway: "gw-1", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrInvalid) {
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: strings.Repeat("名", 65), Gateway: "gw-1", BrowserVersion: "148", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected overlong name, got %v", err)
|
||||
}
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 0}}); !errors.Is(err, ErrInvalid) {
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: Fingerprint{Seed: 0}}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid fingerprint, got %v", err)
|
||||
}
|
||||
for name, exit := range map[string]NetworkExit{
|
||||
@@ -289,7 +289,7 @@ func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 1, ProxyServer: "socks5://proxy.example:1080"}}); !errors.Is(err, ErrInvalid) {
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", BrowserVersion: "148", Fingerprint: Fingerprint{Seed: 1, ProxyServer: "socks5://proxy.example:1080"}}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("stored fingerprint proxy must be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -302,13 +302,13 @@ func TestFingerprintSeedIsGloballyUnique(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL))
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if _, err := store.db.ExecContext(ctx, `TRUNCATE environment_binding, browser_env, browser_image, social_account, credential_reference, gateway CASCADE`); err != nil {
|
||||
if _, err := store.db.ExecContext(ctx, `TRUNCATE environment_binding, browser_env, browser_version, social_account, credential_reference, gateway CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.CreateGateway(ctx, "gw-seed", "http://127.0.0.1:8081", "unit-test-gateway-token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateImage(ctx, Image{Version: "148", ImageRef: "example/browser:148", Enabled: true}); err != nil {
|
||||
if err := store.CreateBrowserVersion(ctx, BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
@@ -318,7 +318,7 @@ func TestFingerprintSeedIsGloballyUnique(t *testing.T) {
|
||||
('seed-account-b', 'credential-seed-b', 'mock', 'seed-account-b', 'owned', 'authorized')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := Env{Alias: "seed-environment-a", Name: "Seed A", Gateway: "gw-seed", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 77}}
|
||||
env := Env{Alias: "seed-environment-a", Name: "Seed A", Gateway: "gw-seed", BrowserVersion: "148", Fingerprint: Fingerprint{Seed: 77}}
|
||||
if _, created, err := store.CreateBoundEnv(ctx, env, "seed-account-a", ""); err != nil || !created {
|
||||
t.Fatalf("create first seeded environment: created=%v err=%v", created, err)
|
||||
}
|
||||
@@ -337,7 +337,7 @@ func TestHubWorkflow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openFullyMigratedHub(t, ctx, databaseURL)
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if _, err := store.db.ExecContext(ctx, `TRUNCATE browser_env, browser_image, gateway CASCADE`); err != nil {
|
||||
if _, err := store.db.ExecContext(ctx, `TRUNCATE browser_env, browser_version, gateway CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -356,18 +356,18 @@ func TestHubWorkflow(t *testing.T) {
|
||||
t.Fatalf("expected duplicate gateway conflict, got %v", err)
|
||||
}
|
||||
|
||||
if err := store.CreateImage(ctx, Image{Version: "148.0.7778.215", ImageRef: "git.ipao.vip/rogee/fingerprint-chromium:148", Note: "主力版本", Enabled: true}); err != nil {
|
||||
if err := store.CreateBrowserVersion(ctx, BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148", Note: "主力版本", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateImage(ctx, Image{Version: "144.0.7559.132", ImageRef: "git.ipao.vip/rogee/fingerprint-chromium:144", Enabled: false}); err != nil {
|
||||
if err := store.CreateBrowserVersion(ctx, BrowserVersion{Version: "144.0.7559.132", BrowserPath: "/opt/creatorhub/browsers/144", Enabled: false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateImage(ctx, Image{Version: "148.0.7778.215", ImageRef: "git.ipao.vip/rogee/fingerprint-chromium:148b"}); !errors.Is(err, ErrConflict) {
|
||||
if err := store.CreateBrowserVersion(ctx, BrowserVersion{Version: "148.0.7778.215", BrowserPath: "/opt/creatorhub/browsers/148b"}); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected duplicate version conflict, got %v", err)
|
||||
}
|
||||
|
||||
env := Env{
|
||||
Alias: "shop-01", Name: "店铺一号", Gateway: "gw-main", ImageVersion: "148.0.7778.215",
|
||||
Alias: "shop-01", Name: "店铺一号", Gateway: "gw-main", BrowserVersion: "148.0.7778.215",
|
||||
Fingerprint: Fingerprint{Seed: 1000, Timezone: "Asia/Shanghai", Lang: "zh-CN"},
|
||||
}
|
||||
if err := store.CreateEnv(ctx, env); err != nil {
|
||||
@@ -376,7 +376,7 @@ func TestHubWorkflow(t *testing.T) {
|
||||
if err := store.CreateEnv(ctx, env); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected duplicate alias conflict, got %v", err)
|
||||
}
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "shop-02", Name: "店铺二号", Gateway: "missing", ImageVersion: "148.0.7778.215", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrConflict) {
|
||||
if err := store.CreateEnv(ctx, Env{Alias: "shop-02", Name: "店铺二号", Gateway: "missing", BrowserVersion: "148.0.7778.215", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected unknown gateway conflict, got %v", err)
|
||||
}
|
||||
|
||||
@@ -408,16 +408,16 @@ func TestHubWorkflow(t *testing.T) {
|
||||
t.Fatalf("expected missing env, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.ImageRef(ctx, "144.0.7559.132"); !errors.Is(err, ErrNotFound) {
|
||||
if _, err := store.BrowserPath(ctx, "144.0.7559.132"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("disabled version must not resolve, got %v", err)
|
||||
}
|
||||
if err := store.UpdateImage(ctx, Image{Version: "144.0.7559.132", ImageRef: "git.ipao.vip/rogee/fingerprint-chromium:144", Enabled: true}); err != nil {
|
||||
if err := store.UpdateBrowserVersion(ctx, BrowserVersion{Version: "144.0.7559.132", BrowserPath: "/opt/creatorhub/browsers/144", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ref, err := store.ImageRef(ctx, "144.0.7559.132"); err != nil || !strings.HasSuffix(ref, ":144") {
|
||||
if ref, err := store.BrowserPath(ctx, "144.0.7559.132"); err != nil || !strings.HasSuffix(ref, "/144") {
|
||||
t.Fatalf("enabled version must resolve: %v %q", err, ref)
|
||||
}
|
||||
if _, err := store.ImageRef(ctx, "999"); !errors.Is(err, ErrNotFound) {
|
||||
if _, err := store.BrowserPath(ctx, "999"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected missing version, got %v", err)
|
||||
}
|
||||
|
||||
@@ -425,14 +425,14 @@ func TestHubWorkflow(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
upgraded, err := store.GetEnv(ctx, "shop-01")
|
||||
if err != nil || upgraded.ImageVersion != "144.0.7559.132" || upgraded.Fingerprint.Seed != 1000 {
|
||||
if err != nil || upgraded.BrowserVersion != "144.0.7559.132" || upgraded.Fingerprint.Seed != 1000 {
|
||||
t.Fatalf("upgrade must only change image version: %#v %v", upgraded, err)
|
||||
}
|
||||
if err := store.UpgradeEnv(ctx, "ghost", "144.0.7559.132"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected missing env on upgrade, got %v", err)
|
||||
}
|
||||
|
||||
if err := store.DeleteImage(ctx, "144.0.7559.132"); !errors.Is(err, ErrConflict) {
|
||||
if err := store.DeleteBrowserVersion(ctx, "144.0.7559.132"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("referenced version must not be deletable, got %v", err)
|
||||
}
|
||||
if err := store.DeleteGateway(ctx, "gw-main"); !errors.Is(err, ErrConflict) {
|
||||
@@ -444,7 +444,7 @@ func TestHubWorkflow(t *testing.T) {
|
||||
if err := store.DeleteEnv(ctx, "shop-01"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected missing env on double delete, got %v", err)
|
||||
}
|
||||
if err := store.DeleteImage(ctx, "144.0.7559.132"); err != nil {
|
||||
if err := store.DeleteBrowserVersion(ctx, "144.0.7559.132"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.DeleteGateway(ctx, "gw-main"); err != nil {
|
||||
@@ -455,6 +455,87 @@ func TestHubWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeUseLeaseLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
|
||||
}
|
||||
ctx := context.Background()
|
||||
store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL))
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if _, err := store.db.ExecContext(ctx, `TRUNCATE runtime_use_lease, runtime_instance, environment_binding, browser_env,
|
||||
browser_version, gateway, social_account, credential_reference CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO credential_reference (id, provider, reference_key)
|
||||
VALUES ('lease-credential', 'os_keyring', 'creatorhub/lease-account');
|
||||
INSERT INTO social_account
|
||||
(id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status)
|
||||
VALUES ('lease-account', 'lease-credential', 'mock', 'lease-account', 'owned', 'authorized')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.CreateGateway(ctx, "lease-gateway", "http://127.0.0.1:8081", "lease-gateway-token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateBrowserVersion(ctx, BrowserVersion{Version: "148-lease", BrowserPath: "/opt/creatorhub/browsers/lease", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment, created, err := store.CreateBoundEnv(ctx, Env{
|
||||
Alias: "lease-env", Name: "Lease environment", Gateway: "lease-gateway", BrowserVersion: "148-lease",
|
||||
Fingerprint: Fingerprint{Seed: 1},
|
||||
}, "lease-account", "")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("create lease environment: %#v created=%v err=%v", environment, created, err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'lease-account'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := store.ActivateRuntime(ctx, environment.Alias, "runtime-lease-generation", environment.BindingVersion, "", "native-lease-network")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lease, err := store.AcquireRuntimeUse(ctx, environment.Alias, "task", "lease-owner", "")
|
||||
if err != nil || lease.RuntimeInstanceID != active.RuntimeInstanceID || lease.Purpose != "task" {
|
||||
t.Fatalf("acquire runtime-use lease: %#v err=%v", lease, err)
|
||||
}
|
||||
if renewed, renewErr := store.RenewRuntimeUse(ctx, lease.Token); renewErr != nil || !renewed.LeaseUntil.After(lease.LeaseUntil) {
|
||||
t.Fatalf("renew runtime-use lease: %#v err=%v", renewed, renewErr)
|
||||
}
|
||||
if err := store.ReleaseRuntimeUse(ctx, lease.Token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.ReleaseRuntimeUse(ctx, lease.Token); err != nil {
|
||||
t.Fatalf("releasing an already released lease must be idempotent: %v", err)
|
||||
}
|
||||
listener, err := store.AcquireRuntimeUse(ctx, environment.Alias, "listener", "listener-owner", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `UPDATE runtime_use_lease SET lease_until = now() - interval '1 second' WHERE token = $1`, listener.Token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.RenewRuntimeUse(ctx, listener.Token); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expired runtime-use lease renewed: %v", err)
|
||||
}
|
||||
if err := store.ReleaseRuntime(ctx, active); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var released, runtimeReleased bool
|
||||
if err := store.db.QueryRowContext(ctx, `SELECT released_at IS NOT NULL FROM runtime_use_lease WHERE token = $1`, listener.Token).Scan(&released); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.db.QueryRowContext(ctx, `SELECT released_at IS NOT NULL FROM runtime_instance WHERE id = $1`, active.RuntimeInstanceID).Scan(&runtimeReleased); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !released || !runtimeReleased {
|
||||
t.Fatalf("runtime cleanup did not revoke use leases: lease_released=%v runtime_released=%v", released, runtimeReleased)
|
||||
}
|
||||
if _, err := store.RenewRuntimeUse(ctx, listener.Token); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("runtime cleanup left an old lease renewable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
||||
if databaseURL == "" {
|
||||
@@ -466,7 +547,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
var notifications []taskstate.Transition
|
||||
store.SetTaskNotifier(func(transition taskstate.Transition) { notifications = append(notifications, transition) })
|
||||
if _, err := store.db.ExecContext(ctx, `TRUNCATE audit_event, runtime_instance, environment_binding, network_exit,
|
||||
social_account, credential_reference, browser_env, browser_image, gateway CASCADE`); err != nil {
|
||||
social_account, credential_reference, browser_env, browser_version, gateway CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
@@ -480,7 +561,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
if _, err := store.CreateGateway(ctx, "gw-main", "http://127.0.0.1:8081", "unit-test-gateway-token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateImage(ctx, Image{Version: "148", ImageRef: "example/browser:148", Enabled: true}); err != nil {
|
||||
if err := store.CreateBrowserVersion(ctx, BrowserVersion{Version: "148", BrowserPath: "/opt/creatorhub/browsers/148", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -510,7 +591,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
t.Fatalf("matching identity must make the exit healthy: %#v reason=%s err=%v", exit, reason, err)
|
||||
}
|
||||
|
||||
env := Env{Alias: "environment-a", Name: "环境 A", Gateway: "gw-main", ImageVersion: "148", Fingerprint: Fingerprint{Seed: 1}}
|
||||
env := Env{Alias: "environment-a", Name: "环境 A", Gateway: "gw-main", BrowserVersion: "148", Fingerprint: Fingerprint{Seed: 1}}
|
||||
bound, created, err := store.CreateBoundEnv(ctx, env, "account-a", exit.ID)
|
||||
if err != nil || !created || bound.AccountID != "account-a" || bound.Exit.ID != exit.ID {
|
||||
t.Fatalf("create stable binding: %#v created=%v err=%v", bound, created, err)
|
||||
@@ -522,7 +603,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'account-a'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion, bound.Exit.ID, "network-a")
|
||||
active, err := store.ActivateRuntime(ctx, env.Alias, "runtime-a", bound.BindingVersion, bound.Exit.ID, "network-a")
|
||||
if err != nil || active.RuntimeInstanceID == "" {
|
||||
t.Fatalf("activate runtime: %#v err=%v", active, err)
|
||||
}
|
||||
@@ -544,7 +625,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
INSERT INTO social_account
|
||||
(id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status)
|
||||
VALUES ('account-b', 'credential-account-b', 'mock', 'account-b', 'owned', 'authorized');
|
||||
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
|
||||
INSERT INTO browser_env (alias, name, gateway_name, browser_version, fingerprint)
|
||||
VALUES ('environment-b', '环境 B', 'gw-main', '148', '{"seed":2}');
|
||||
INSERT INTO environment_binding (id, account_id, browser_env_alias)
|
||||
VALUES ('binding-b', 'account-b', 'environment-b')`); err != nil {
|
||||
@@ -554,13 +635,13 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
if err != nil || legacyRebound.Exit.ID != second.ID {
|
||||
t.Fatalf("legacy binding without an exit must support explicit rebind: %#v err=%v", legacyRebound, err)
|
||||
}
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion+1, second.ID, "network-a"); !errors.Is(err, ErrConflict) {
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "runtime-a", bound.BindingVersion+1, second.ID, "network-a"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale binding metadata must not activate a runtime: %v", err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() + interval '10 seconds' WHERE id = $1`, active.RuntimeInstanceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "container-a", bound.BindingVersion, bound.Exit.ID, "network-a"); err != nil {
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "runtime-a", bound.BindingVersion, bound.Exit.ID, "network-a"); err != nil {
|
||||
t.Fatalf("runtime heartbeat failed: %v", err)
|
||||
}
|
||||
var renewed bool
|
||||
@@ -588,14 +669,14 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'account-a'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expiredBeforeActivation, err := store.ActivateRuntime(ctx, env.Alias, "expired-container", rebound.BindingVersion, rebound.Exit.ID, "network-expired")
|
||||
expiredBeforeActivation, err := store.ActivateRuntime(ctx, env.Alias, "expired-runtime", rebound.BindingVersion, rebound.Exit.ID, "network-expired")
|
||||
if err != nil {
|
||||
t.Fatalf("activate runtime to expire: %v", err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() - interval '1 second' WHERE id = $1`, expiredBeforeActivation.RuntimeInstanceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "same-exit-container", rebound.BindingVersion, rebound.Exit.ID, "network-same-exit"); err != nil {
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "same-exit-runtime", rebound.BindingVersion, rebound.Exit.ID, "network-same-exit"); err != nil {
|
||||
t.Fatalf("replace expired runtime before same-exit rebind: %v", err)
|
||||
}
|
||||
assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE event_type = 'runtime_released'
|
||||
@@ -619,15 +700,15 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
AND reason_code = 'runtime_released' AND account_id = 'account-a' AND browser_env_alias = 'environment-a'
|
||||
AND network_exit_id = $1 AND runtime_instance_id = $2 AND binding_version = $3 AND details = '{}'::jsonb`,
|
||||
1, active.Exit.ID, active.RuntimeInstanceID, active.BindingVersion)
|
||||
rebound, err = store.RebindEnvironment(ctx, env.Alias, second.ID, "rebound-container", rebound.BindingVersion)
|
||||
if err != nil || rebound.BindingVersion != 3 || rebound.RuntimeID != "rebound-container" {
|
||||
rebound, err = store.RebindEnvironment(ctx, env.Alias, second.ID, "rebound-runtime", rebound.BindingVersion)
|
||||
if err != nil || rebound.BindingVersion != 3 || rebound.RuntimeID != "rebound-runtime" {
|
||||
t.Fatalf("same-exit rebind must atomically CAS the binding and runtime: %#v err=%v", rebound, err)
|
||||
}
|
||||
if err := store.ReleaseRuntime(ctx, active); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale generation release must conflict: %v", err)
|
||||
}
|
||||
current, err := store.GetEnvironmentContext(ctx, env.Alias)
|
||||
if err != nil || current.RuntimeInstanceID != rebound.RuntimeInstanceID || current.RuntimeID != "rebound-container" {
|
||||
if err != nil || current.RuntimeInstanceID != rebound.RuntimeInstanceID || current.RuntimeID != "rebound-runtime" {
|
||||
t.Fatalf("stale release changed the current runtime: %#v err=%v", current, err)
|
||||
}
|
||||
cleanup := current
|
||||
@@ -641,7 +722,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
AND browser_env_alias = 'environment-a' AND network_exit_id = $1 AND runtime_instance_id = $2 AND binding_version = $3`,
|
||||
1, current.Exit.ID, current.RuntimeInstanceID, current.BindingVersion)
|
||||
wrongCleanup := cleanup
|
||||
wrongCleanup.RuntimeCleanupRuntimeID = "other-container"
|
||||
wrongCleanup.RuntimeCleanupRuntimeID = "other-runtime"
|
||||
if err := store.SetRuntimeCleanupPending(ctx, wrongCleanup, false); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("wrong cleanup generation cleared pending state: %v", err)
|
||||
}
|
||||
@@ -649,7 +730,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
if err != nil || !pending.RuntimeCleanupPending || pending.RuntimeCleanupRuntimeID != current.RuntimeID {
|
||||
t.Fatalf("cleanup generation was not persisted: %#v err=%v", pending, err)
|
||||
}
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "candidate-container", current.BindingVersion, current.Exit.ID, "network-candidate"); !errors.Is(err, ErrConflict) {
|
||||
if _, err := store.ActivateRuntime(ctx, env.Alias, "candidate-runtime", current.BindingVersion, current.Exit.ID, "network-candidate"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("paused account activated a stale request: %v", err)
|
||||
}
|
||||
if err := store.SetRuntimeCleanupPending(ctx, pending, false); err != nil {
|
||||
@@ -675,7 +756,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
}
|
||||
activation := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := store.ActivateRuntime(ctx, env.Alias, "racing-container", newGeneration.BindingVersion, newGeneration.Exit.ID, "network-racing")
|
||||
_, err := store.ActivateRuntime(ctx, env.Alias, "racing-runtime", newGeneration.BindingVersion, newGeneration.Exit.ID, "network-racing")
|
||||
activation <- err
|
||||
}()
|
||||
select {
|
||||
@@ -712,7 +793,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE operation_id = '`+action.OperationID+`'`, 2)
|
||||
invalidAction := action
|
||||
invalidAction.OperationID = NewOperationID()
|
||||
invalidAction.NewImageVersion = "http://operator:secret@proxy.example"
|
||||
invalidAction.NewBrowserVersion = "http://operator:secret@proxy.example"
|
||||
if err := store.AppendEnvironmentAction(ctx, "environment_action_requested", invalidAction); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("invalid image version must not reach audit persistence: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
ALTER TABLE runtime_instance
|
||||
ADD COLUMN IF NOT EXISTS owner_id text NOT NULL DEFAULT 'control-plane',
|
||||
ADD COLUMN IF NOT EXISTS purpose text NOT NULL DEFAULT 'session',
|
||||
ADD COLUMN IF NOT EXISTS lease_token text NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS node_id text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE runtime_instance
|
||||
DROP CONSTRAINT IF EXISTS runtime_instance_purpose_check;
|
||||
ALTER TABLE runtime_instance
|
||||
ADD CONSTRAINT runtime_instance_purpose_check
|
||||
CHECK (purpose IN ('session', 'task', 'listener'));
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS runtime_instance_active_lease_token_idx
|
||||
ON runtime_instance (lease_token)
|
||||
WHERE released_at IS NULL AND lease_token <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runtime_use_lease (
|
||||
token text PRIMARY KEY,
|
||||
runtime_instance_id text NOT NULL REFERENCES runtime_instance(id),
|
||||
owner_id text NOT NULL,
|
||||
purpose text NOT NULL CHECK (purpose IN ('task', 'listener')),
|
||||
task_id text REFERENCES operation_task(id),
|
||||
lease_until timestamptz NOT NULL,
|
||||
acquired_at timestamptz NOT NULL DEFAULT now(),
|
||||
released_at timestamptz,
|
||||
CHECK (length(token) BETWEEN 16 AND 128),
|
||||
CHECK (length(owner_id) BETWEEN 1 AND 128)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS runtime_use_lease_active_runtime_idx
|
||||
ON runtime_use_lease (runtime_instance_id, lease_until)
|
||||
WHERE released_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS runtime_use_lease_active_task_idx
|
||||
ON runtime_use_lease (task_id)
|
||||
WHERE released_at IS NULL AND task_id IS NOT NULL;
|
||||
+22
-13
@@ -26,6 +26,9 @@ import (
|
||||
//go:embed migrations/001_phase_a.sql
|
||||
var migration001 string
|
||||
|
||||
//go:embed migrations/036_runtime_use_leases.sql
|
||||
var migration036 string
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("resource conflicts with existing state")
|
||||
ErrInvalid = errors.New("invalid phase A input")
|
||||
@@ -188,8 +191,8 @@ type AuditEvent struct {
|
||||
OperationID string `json:"operation_id,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
OldImageVersion string `json:"old_image_version,omitempty"`
|
||||
NewImageVersion string `json:"new_image_version,omitempty"`
|
||||
OldBrowserVersion string `json:"old_browser_version,omitempty"`
|
||||
NewBrowserVersion string `json:"new_browser_version,omitempty"`
|
||||
Details json.RawMessage `json:"details"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -254,16 +257,22 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
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")
|
||||
}
|
||||
var applied bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = 1)`).Scan(&applied); err != nil {
|
||||
return errors.New("read schema migration state")
|
||||
}
|
||||
if !applied {
|
||||
if _, err := tx.ExecContext(ctx, migration001); err != nil {
|
||||
return fmt.Errorf("apply schema migration 1: %w", err)
|
||||
for _, migration := range []struct {
|
||||
version int
|
||||
sql string
|
||||
}{{1, migration001}, {36, migration036}} {
|
||||
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 schema migration state")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migration (version) VALUES (1)`); err != nil {
|
||||
return errors.New("record schema migration 1")
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, migration.sql); err != nil {
|
||||
return fmt.Errorf("apply 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 schema migration %d", migration.version)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -1824,7 +1833,7 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) (AuditPage, e
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, event_type, account_id, confirmation_id, confirmation_version, attempt_id, task_id,
|
||||
browser_env_alias, network_exit_id, runtime_instance_id, binding_version, actor, reason_code,
|
||||
operation_id, action, outcome, old_image_version, new_image_version,
|
||||
operation_id, action, outcome, old_browser_version, new_browser_version,
|
||||
details, created_at
|
||||
FROM audit_event
|
||||
WHERE ($1 = '' OR account_id = $1) AND ($2 = '' OR task_id = $2) AND ($3 = '' OR attempt_id = $3)
|
||||
@@ -1854,7 +1863,7 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) (AuditPage, e
|
||||
event.RuntimeInstanceID, event.BindingVersion = runtimeInstanceID.String, bindingVersion.Int64
|
||||
event.Actor, event.ReasonCode = actor.String, reasonCode.String
|
||||
event.OperationID, event.Action, event.Outcome = operationID.String, action.String, outcome.String
|
||||
event.OldImageVersion, event.NewImageVersion = oldImage.String, newImage.String
|
||||
event.OldBrowserVersion, event.NewBrowserVersion = oldImage.String, newImage.String
|
||||
event.Details = safeDetails(event.Details)
|
||||
events = append(events, event)
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ func TestPhaseAOfflineWorkflow(t *testing.T) {
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
TRUNCATE audit_event, execution_attempt, operation_task, confirmation, content_draft,
|
||||
runtime_instance, environment_binding, network_exit, social_account, credential_reference,
|
||||
browser_env, browser_image, gateway RESTART IDENTITY CASCADE`); err != nil {
|
||||
browser_env, browser_version, gateway RESTART IDENTITY CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -215,11 +215,11 @@ func TestPhaseAOfflineWorkflow(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO browser_image (version, image_ref) VALUES ('1', 'example/browser:1')`); err != nil {
|
||||
INSERT INTO browser_version (version, browser_path) VALUES ('1', '/opt/creatorhub/browsers/1')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) VALUES
|
||||
INSERT INTO browser_env (alias, name, gateway_name, browser_version, fingerprint) VALUES
|
||||
('account-a', 'Account A', 'test-gateway', '1', '{"seed":1}'),
|
||||
('account-b', 'Account B', 'test-gateway', '1', '{"seed":2}');
|
||||
INSERT INTO network_exit (id, protocol, host, port, health_status)
|
||||
@@ -407,7 +407,7 @@ func TestPhaseAOfflineWorkflow(t *testing.T) {
|
||||
// A held task is never revived in place: only a newly confirmed task with a new idempotency key may run.
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until)
|
||||
VALUES ('runtime-recovered', 'gate-runtime-missing', 'binding-runtime-missing', 1, 'container-recovered', now() + interval '1 minute')`); err != nil {
|
||||
VALUES ('runtime-recovered', 'gate-runtime-missing', 'binding-runtime-missing', 1, 'runtime-recovered', now() + interval '1 minute')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if execution, err := store.ExecuteMock(ctx, "worker-old-held", "succeeded"); err != nil || execution.WasClaimed {
|
||||
@@ -754,7 +754,7 @@ func TestCreateAccountWithoutCookiesSkipsCredentialStore(t *testing.T) {
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
TRUNCATE audit_event, execution_attempt, operation_task, confirmation, content_draft,
|
||||
runtime_instance, environment_binding, network_exit, social_account, credential_reference,
|
||||
browser_env, browser_image, gateway RESTART IDENTITY CASCADE`); err != nil {
|
||||
browser_env, browser_version, gateway RESTART IDENTITY CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credentials := &testCredentialBridge{values: map[string]string{}}
|
||||
@@ -785,7 +785,7 @@ func TestAccountCredentialCommitResult(t *testing.T) {
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
TRUNCATE audit_event, execution_attempt, operation_task, confirmation, content_draft,
|
||||
runtime_instance, environment_binding, network_exit, social_account, credential_reference,
|
||||
browser_env, browser_image, gateway RESTART IDENTITY CASCADE`); err != nil {
|
||||
browser_env, browser_version, gateway RESTART IDENTITY CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credentials := &testCredentialBridge{values: map[string]string{}}
|
||||
@@ -886,7 +886,7 @@ func seedGateTask(t *testing.T, store *Store, suffix, accountStatus, authorizati
|
||||
}
|
||||
if binding {
|
||||
exitID := "exit-gate-" + suffix
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO browser_env (alias, name, gateway_name, browser_version, fingerprint)
|
||||
VALUES ($1, $1, 'test-gateway', '1', '{"seed":3}')`, accountID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -907,7 +907,7 @@ func seedGateTask(t *testing.T, store *Store, suffix, accountStatus, authorizati
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until)
|
||||
VALUES ($1, $2, $3, 1, $4, now() + $5::interval)`, "runtime-gate-"+suffix, accountID,
|
||||
"binding-"+suffix, "container-gate-"+suffix, interval); err != nil {
|
||||
"binding-"+suffix, "runtime-gate-"+suffix, interval); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -958,7 +958,8 @@ func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) {
|
||||
}{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}, {4, "004_environment_actions.sql"}, {5, "005_sanitize_legacy_proxy.sql"},
|
||||
{6, "006_runtime_cleanup.sql"}, {7, "007_runtime_binding_version.sql"}, {8, "008_runtime_cleanup_generation.sql"},
|
||||
{9, "009_runtime_cleanup_compatibility.sql"}, {10, "010_runtime_network_generation.sql"}, {11, "011_task_recovery.sql"},
|
||||
{12, "012_task_recovery_compatibility.sql"}, {13, "013_account_creation.sql"}, {14, "014_account_creation_compatibility.sql"}} {
|
||||
{12, "012_task_recovery_compatibility.sql"}, {13, "013_account_creation.sql"}, {14, "014_account_creation_compatibility.sql"},
|
||||
{15, "015_gateway_rename_cascade.sql"}, {16, "016_network_exit_plain_credentials.sql"}, {17, "017_native_browser_versions.sql"}} {
|
||||
var applied bool
|
||||
if err := store.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migrationFile.version).Scan(&applied); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Reference in New Issue
Block a user