mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-31 08:58:42 +00:00
* Adding Redis DB option This allows selecting something other than the default database of 0. * Add missing db argument to fix failing tests.
408 lines
12 KiB
Go
408 lines
12 KiB
Go
// Package redis handles our remote database layer.
|
|
package redis
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-redis/redis/v9"
|
|
"github.com/pkg/errors"
|
|
"github.com/runatlantis/atlantis/server/events/command"
|
|
"github.com/runatlantis/atlantis/server/events/models"
|
|
)
|
|
|
|
var ctx = context.Background()
|
|
|
|
// Redis is a database using Redis 6
|
|
type RedisDB struct { // nolint: revive
|
|
client *redis.Client
|
|
}
|
|
|
|
const (
|
|
pullKeySeparator = "::"
|
|
)
|
|
|
|
func New(hostname string, port int, password string, tlsEnabled bool, insecureSkipVerify bool, db int) (*RedisDB, error) {
|
|
var rdb *redis.Client
|
|
|
|
var tlsConfig *tls.Config
|
|
if tlsEnabled {
|
|
tlsConfig = &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
InsecureSkipVerify: insecureSkipVerify, //nolint:gosec // In some cases, users may want to use this at their own caution
|
|
}
|
|
}
|
|
|
|
rdb = redis.NewClient(&redis.Options{
|
|
Addr: fmt.Sprintf("%s:%d", hostname, port),
|
|
Password: password,
|
|
DB: db,
|
|
TLSConfig: tlsConfig,
|
|
})
|
|
|
|
// Check if connection is valid
|
|
err := rdb.Ping(ctx).Err()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, fmt.Sprintf("failed to connect to redis instance at %s:%d", hostname, port))
|
|
}
|
|
|
|
return &RedisDB{
|
|
client: rdb,
|
|
}, nil
|
|
}
|
|
|
|
// NewWithClient is used for testing.
|
|
func NewWithClient(client *redis.Client, bucket string, globalBucket string) (*RedisDB, error) {
|
|
return &RedisDB{
|
|
client: client,
|
|
}, nil
|
|
}
|
|
|
|
// TryLock attempts to create a new lock. If the lock is
|
|
// acquired, it will return true and the lock returned will be newLock.
|
|
// If the lock is not acquired, it will return false and the current
|
|
// lock that is preventing this lock from being acquired.
|
|
func (r *RedisDB) TryLock(newLock models.ProjectLock) (bool, models.ProjectLock, error) {
|
|
var currLock models.ProjectLock
|
|
key := r.lockKey(newLock.Project, newLock.Workspace)
|
|
newLockSerialized, _ := json.Marshal(newLock)
|
|
|
|
val, err := r.client.Get(ctx, key).Result()
|
|
// if there is no run at that key then we're free to create the lock
|
|
if err == redis.Nil {
|
|
err := r.client.Set(ctx, key, newLockSerialized, 0).Err()
|
|
if err != nil {
|
|
return false, currLock, errors.Wrap(err, "db transaction failed")
|
|
}
|
|
return true, newLock, nil
|
|
} else if err != nil {
|
|
// otherwise the lock fails, return to caller the run that's holding the lock
|
|
return false, currLock, errors.Wrap(err, "db transaction failed")
|
|
} else {
|
|
if err := json.Unmarshal([]byte(val), &currLock); err != nil {
|
|
return false, currLock, errors.Wrap(err, "failed to deserialize current lock")
|
|
}
|
|
return false, currLock, nil
|
|
}
|
|
}
|
|
|
|
// Unlock attempts to unlock the project and workspace.
|
|
// If there is no lock, then it will return a nil pointer.
|
|
// If there is a lock, then it will delete it, and then return a pointer
|
|
// to the deleted lock.
|
|
func (r *RedisDB) Unlock(project models.Project, workspace string) (*models.ProjectLock, error) {
|
|
var lock models.ProjectLock
|
|
key := r.lockKey(project, workspace)
|
|
|
|
val, err := r.client.Get(ctx, key).Result()
|
|
if err == redis.Nil {
|
|
return nil, nil
|
|
} else if err != nil {
|
|
return nil, errors.Wrap(err, "db transaction failed")
|
|
} else {
|
|
if err := json.Unmarshal([]byte(val), &lock); err != nil {
|
|
return nil, errors.Wrap(err, "failed to deserialize current lock")
|
|
}
|
|
r.client.Del(ctx, key)
|
|
return &lock, nil
|
|
}
|
|
}
|
|
|
|
// List lists all current locks.
|
|
func (r *RedisDB) List() ([]models.ProjectLock, error) {
|
|
var locks []models.ProjectLock
|
|
iter := r.client.Scan(ctx, 0, "pr*", 0).Iterator()
|
|
for iter.Next(ctx) {
|
|
var lock models.ProjectLock
|
|
val, err := r.client.Get(ctx, iter.Val()).Result()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "db transaction failed")
|
|
}
|
|
if err := json.Unmarshal([]byte(val), &lock); err != nil {
|
|
return locks, errors.Wrap(err, fmt.Sprintf("failed to deserialize lock at key '%s'", iter.Val()))
|
|
}
|
|
locks = append(locks, lock)
|
|
}
|
|
if err := iter.Err(); err != nil {
|
|
return locks, errors.Wrap(err, "db transaction failed")
|
|
}
|
|
|
|
return locks, nil
|
|
}
|
|
|
|
// GetLock returns a pointer to the lock for that project and workspace.
|
|
// If there is no lock, it returns a nil pointer.
|
|
func (r *RedisDB) GetLock(project models.Project, workspace string) (*models.ProjectLock, error) {
|
|
key := r.lockKey(project, workspace)
|
|
|
|
val, err := r.client.Get(ctx, key).Result()
|
|
if err == redis.Nil {
|
|
return nil, nil
|
|
} else if err != nil {
|
|
return nil, errors.Wrap(err, "db transaction failed")
|
|
} else {
|
|
var lock models.ProjectLock
|
|
if err := json.Unmarshal([]byte(val), &lock); err != nil {
|
|
return nil, errors.Wrapf(err, "deserializing lock at key %q", key)
|
|
}
|
|
// need to set it to Local after deserialization due to https://github.com/golang/go/issues/19486
|
|
lock.Time = lock.Time.Local()
|
|
return &lock, nil
|
|
}
|
|
}
|
|
|
|
// UnlockByPull deletes all locks associated with that pull request and returns them.
|
|
func (r *RedisDB) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) {
|
|
var locks []models.ProjectLock
|
|
|
|
iter := r.client.Scan(ctx, 0, fmt.Sprintf("pr/%s*", repoFullName), 0).Iterator()
|
|
for iter.Next(ctx) {
|
|
var lock models.ProjectLock
|
|
val, err := r.client.Get(ctx, iter.Val()).Result()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "db transaction failed")
|
|
}
|
|
if err := json.Unmarshal([]byte(val), &lock); err != nil {
|
|
return locks, errors.Wrap(err, fmt.Sprintf("failed to deserialize lock at key '%s'", iter.Val()))
|
|
}
|
|
if lock.Pull.Num == pullNum {
|
|
locks = append(locks, lock)
|
|
if _, err := r.Unlock(lock.Project, lock.Workspace); err != nil {
|
|
return locks, errors.Wrapf(err, "unlocking repo %s, path %s, workspace %s", lock.Project.RepoFullName, lock.Project.Path, lock.Workspace)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := iter.Err(); err != nil {
|
|
return locks, errors.Wrap(err, "db transaction failed")
|
|
}
|
|
|
|
return locks, nil
|
|
}
|
|
|
|
func (r *RedisDB) LockCommand(cmdName command.Name, lockTime time.Time) (*command.Lock, error) {
|
|
|
|
lock := command.Lock{
|
|
CommandName: cmdName,
|
|
LockMetadata: command.LockMetadata{
|
|
UnixTime: lockTime.Unix(),
|
|
},
|
|
}
|
|
|
|
cmdLockKey := r.commandLockKey(cmdName)
|
|
|
|
newLockSerialized, _ := json.Marshal(lock)
|
|
|
|
_, err := r.client.Get(ctx, cmdLockKey).Result()
|
|
if err == redis.Nil {
|
|
err = r.client.Set(ctx, cmdLockKey, newLockSerialized, 0).Err()
|
|
return &lock, errors.Wrap(err, "db transaction failed")
|
|
} else if err != nil {
|
|
return nil, errors.Wrap(err, "db transaction failed")
|
|
} else {
|
|
return nil, errors.New("db transaction failed: lock already exists")
|
|
}
|
|
}
|
|
|
|
func (r *RedisDB) UnlockCommand(cmdName command.Name) error {
|
|
cmdLockKey := r.commandLockKey(cmdName)
|
|
_, err := r.client.Get(ctx, cmdLockKey).Result()
|
|
if err == redis.Nil {
|
|
return errors.New("db transaction failed: no lock exists")
|
|
} else if err != nil {
|
|
return errors.Wrap(err, "db transaction failed")
|
|
} else {
|
|
return r.client.Del(ctx, cmdLockKey).Err()
|
|
}
|
|
}
|
|
|
|
func (r *RedisDB) CheckCommandLock(cmdName command.Name) (*command.Lock, error) {
|
|
cmdLock := command.Lock{}
|
|
|
|
cmdLockKey := r.commandLockKey(cmdName)
|
|
val, err := r.client.Get(ctx, cmdLockKey).Result()
|
|
if err == redis.Nil {
|
|
return nil, nil
|
|
} else if err != nil {
|
|
return nil, errors.Wrap(err, "db transaction failed")
|
|
} else {
|
|
if err := json.Unmarshal([]byte(val), &cmdLock); err != nil {
|
|
return nil, errors.Wrap(err, "failed to deserialize Lock")
|
|
}
|
|
return &cmdLock, err
|
|
}
|
|
}
|
|
|
|
// UpdatePullWithResults updates pull's status with the latest project results.
|
|
// It returns the new PullStatus object.
|
|
func (r *RedisDB) UpdateProjectStatus(pull models.PullRequest, workspace string, repoRelDir string, newStatus models.ProjectPlanStatus) error {
|
|
key, err := r.pullKey(pull)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
currStatusPtr, err := r.getPull(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if currStatusPtr == nil {
|
|
return nil
|
|
}
|
|
currStatus := *currStatusPtr
|
|
|
|
// Update the status.
|
|
for i := range currStatus.Projects {
|
|
// NOTE: We're using a reference here because we are
|
|
// in-place updating its Status field.
|
|
proj := &currStatus.Projects[i]
|
|
if proj.Workspace == workspace && proj.RepoRelDir == repoRelDir {
|
|
proj.Status = newStatus
|
|
break
|
|
}
|
|
}
|
|
|
|
err = r.writePull(key, currStatus)
|
|
return errors.Wrap(err, "db transaction failed")
|
|
}
|
|
|
|
func (r *RedisDB) GetPullStatus(pull models.PullRequest) (*models.PullStatus, error) {
|
|
key, err := r.pullKey(pull)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pullStatus, err := r.getPull(key)
|
|
|
|
return pullStatus, errors.Wrap(err, "db transaction failed")
|
|
}
|
|
|
|
func (r *RedisDB) DeletePullStatus(pull models.PullRequest) error {
|
|
key, err := r.pullKey(pull)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return errors.Wrap(r.deletePull(key), "db transaction failed")
|
|
}
|
|
|
|
func (r *RedisDB) UpdatePullWithResults(pull models.PullRequest, newResults []command.ProjectResult) (models.PullStatus, error) {
|
|
key, err := r.pullKey(pull)
|
|
if err != nil {
|
|
return models.PullStatus{}, err
|
|
}
|
|
|
|
var newStatus models.PullStatus
|
|
currStatus, err := r.getPull(key)
|
|
if err != nil {
|
|
return newStatus, errors.Wrap(err, "db transaction failed")
|
|
}
|
|
|
|
// If there is no pull OR if the pull we have is out of date, we
|
|
// just write a new pull.
|
|
if currStatus == nil || currStatus.Pull.HeadCommit != pull.HeadCommit {
|
|
var statuses []models.ProjectStatus
|
|
for _, res := range newResults {
|
|
statuses = append(statuses, r.projectResultToProject(res))
|
|
}
|
|
newStatus = models.PullStatus{
|
|
Pull: pull,
|
|
Projects: statuses,
|
|
}
|
|
} else {
|
|
// If there's an existing pull at the right commit then we have to
|
|
// merge our project results with the existing ones. We do a merge
|
|
// because it's possible a user is just applying a single project
|
|
// in this command and so we don't want to delete our data about
|
|
// other projects that aren't affected by this command.
|
|
newStatus = *currStatus
|
|
for _, res := range newResults {
|
|
// First, check if we should update any existing projects.
|
|
updatedExisting := false
|
|
for i := range newStatus.Projects {
|
|
// NOTE: We're using a reference here because we are
|
|
// in-place updating its Status field.
|
|
proj := &newStatus.Projects[i]
|
|
if res.Workspace == proj.Workspace &&
|
|
res.RepoRelDir == proj.RepoRelDir &&
|
|
res.ProjectName == proj.ProjectName {
|
|
|
|
proj.Status = res.PlanStatus()
|
|
updatedExisting = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !updatedExisting {
|
|
// If we didn't update an existing project, then we need to
|
|
// add this because it's a new one.
|
|
newStatus.Projects = append(newStatus.Projects, r.projectResultToProject(res))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now, we overwrite the key with our new status.
|
|
return newStatus, errors.Wrap(r.writePull(key, newStatus), "db transaction failed")
|
|
}
|
|
|
|
func (r *RedisDB) getPull(key string) (*models.PullStatus, error) {
|
|
val, err := r.client.Get(ctx, key).Result()
|
|
if err == redis.Nil {
|
|
return nil, nil
|
|
} else if err != nil {
|
|
return nil, errors.Wrap(err, "db transaction failed")
|
|
} else {
|
|
var p models.PullStatus
|
|
if err := json.Unmarshal([]byte(val), &p); err != nil {
|
|
return nil, errors.Wrapf(err, "deserializing pull at %q with contents %q", key, val)
|
|
}
|
|
return &p, nil
|
|
}
|
|
}
|
|
|
|
func (r *RedisDB) writePull(key string, pull models.PullStatus) error {
|
|
serialized, err := json.Marshal(pull)
|
|
if err != nil {
|
|
return errors.Wrap(err, "serializing")
|
|
}
|
|
err = r.client.Set(ctx, key, serialized, 0).Err()
|
|
return errors.Wrap(err, "DB Transaction failed")
|
|
}
|
|
|
|
func (r *RedisDB) deletePull(key string) error {
|
|
err := r.client.Del(ctx, key).Err()
|
|
return errors.Wrap(err, "DB Transaction failed")
|
|
}
|
|
|
|
func (r *RedisDB) lockKey(p models.Project, workspace string) string {
|
|
return fmt.Sprintf("pr/%s/%s/%s", p.RepoFullName, p.Path, workspace)
|
|
}
|
|
|
|
func (r *RedisDB) commandLockKey(cmdName command.Name) string {
|
|
return fmt.Sprintf("global/%s/lock", cmdName)
|
|
}
|
|
|
|
func (r *RedisDB) pullKey(pull models.PullRequest) (string, error) {
|
|
hostname := pull.BaseRepo.VCSHost.Hostname
|
|
if strings.Contains(hostname, pullKeySeparator) {
|
|
return "", fmt.Errorf("vcs hostname %q contains illegal string %q", hostname, pullKeySeparator)
|
|
}
|
|
repo := pull.BaseRepo.FullName
|
|
if strings.Contains(repo, pullKeySeparator) {
|
|
return "", fmt.Errorf("repo name %q contains illegal string %q", hostname, pullKeySeparator)
|
|
}
|
|
|
|
return fmt.Sprintf("%s::%s::%d", hostname, repo, pull.Num), nil
|
|
}
|
|
|
|
func (r *RedisDB) projectResultToProject(p command.ProjectResult) models.ProjectStatus {
|
|
return models.ProjectStatus{
|
|
Workspace: p.Workspace,
|
|
RepoRelDir: p.RepoRelDir,
|
|
ProjectName: p.ProjectName,
|
|
Status: p.PlanStatus(),
|
|
}
|
|
}
|