Files
mgmt/etcd/deployer/deployer.go
James Shubin a5842a41b2 etcd: Rewrite embed etcd implementation
This is a giant cleanup of the etcd code. The earlier version was
written when I was less experienced with golang.

This is still not perfect, and does contain some races, but at least
it's a decent base to start from. The automatic elastic clustering
should be considered an experimental feature. If you need a more
battle-tested cluster, then you should manage etcd manually and point
mgmt at your existing cluster.
2019-04-11 21:43:48 -04:00

215 lines
7.5 KiB
Go

// Mgmt
// Copyright (C) 2013-2019+ James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package deployer
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"github.com/purpleidea/mgmt/etcd/interfaces"
"github.com/purpleidea/mgmt/util/errwrap"
etcd "github.com/coreos/etcd/clientv3"
etcdutil "github.com/coreos/etcd/clientv3/clientv3util"
)
const (
deployPath = "deploy"
payloadPath = "payload"
hashPath = "hash"
)
// SimpleDeploy is a deploy struct that provides all of the needed deploy
// methods. It requires that you give it a Client interface so that it can
// perform its remote work. You must call Init before you use it, and Close when
// you are done.
type SimpleDeploy struct {
Client interfaces.Client
Debug bool
Logf func(format string, v ...interface{})
ns string // TODO: if we ever need to hardcode a base path
wg *sync.WaitGroup
}
// Init validates the deploy structure and prepares it for first use.
func (obj *SimpleDeploy) Init() error {
if obj.Client == nil {
return fmt.Errorf("the Client was not specified")
}
obj.wg = &sync.WaitGroup{}
return nil
}
// Close cleans up after using the deploy struct and waits for any ongoing
// watches to exit before it returns.
func (obj *SimpleDeploy) Close() error {
obj.wg.Wait()
return nil
}
// WatchDeploy returns a channel which spits out events on new deploy activity.
// It closes the channel when it's done, and spits out errors when something
// goes wrong. If it can't start up, it errors immediately. The returned channel
// is buffered, so that a quick succession of events will get discarded.
func (obj *SimpleDeploy) WatchDeploy(ctx context.Context) (chan error, error) {
// key structure is $NS/deploy/$id/payload = $data
path := fmt.Sprintf("%s/%s/", obj.ns, deployPath)
// FIXME: obj.wg.Add(1) && obj.wg.Done()
return obj.Client.Watcher(ctx, path, etcd.WithPrefix())
}
// GetDeploys gets all the available deploys.
func (obj *SimpleDeploy) GetDeploys(ctx context.Context) (map[uint64]string, error) {
// key structure is $NS/deploy/$id/payload = $data
path := fmt.Sprintf("%s/%s/", obj.ns, deployPath)
keyMap, err := obj.Client.Get(ctx, path, etcd.WithPrefix(), etcd.WithSort(etcd.SortByKey, etcd.SortAscend))
if err != nil {
return nil, errwrap.Wrapf(err, "could not get deploy")
}
result := make(map[uint64]string)
for key, val := range keyMap {
if !strings.HasPrefix(key, path) { // sanity check
continue
}
str := strings.Split(key[len(path):], "/")
if len(str) != 2 {
return nil, fmt.Errorf("unexpected chunk count of %d", len(str))
}
if s := str[1]; s != payloadPath {
continue // skip, maybe there are other future additions
}
var id uint64
var err error
x := str[0]
if id, err = strconv.ParseUint(x, 10, 64); err != nil {
return nil, fmt.Errorf("invalid id of `%s`", x)
}
// TODO: do some sort of filtering here?
//obj.Logf("GetDeploys(%s): Id => Data: %d => %s", key, id, val)
result[id] = val
}
return result, nil
}
// calculateMax is a helper function.
func calculateMax(deploys map[uint64]string) uint64 {
var max uint64
for i := range deploys {
if i > max {
max = i
}
}
return max
}
// GetDeploy returns the deploy with the specified id if it exists. If you input
// an id of 0, you'll get back an empty deploy without error. This is useful so
// that you can pass through this function easily.
// FIXME: implement this more efficiently so that it doesn't have to download *all* the old deploys from etcd!
func (obj *SimpleDeploy) GetDeploy(ctx context.Context, id uint64) (string, error) {
result, err := obj.GetDeploys(ctx)
if err != nil {
return "", err
}
// don't optimize this test to the top, because it's better to catch an
// etcd failure early if we can, rather than fail later when we deploy!
if id == 0 {
return "", nil // no results yet
}
str, exists := result[id]
if !exists {
return "", fmt.Errorf("can't find id `%d`", id)
}
return str, nil
}
// GetMaxDeployID returns the maximum deploy id. If none are found, this returns
// zero. You must increment the returned value by one when you add a deploy. If
// two or more clients race for this deploy id, then the loser is not committed,
// and must repeat this GetMaxDeployID process until it succeeds with a commit!
func (obj *SimpleDeploy) GetMaxDeployID(ctx context.Context) (uint64, error) {
// TODO: this was all implemented super inefficiently, fix up for perf!
deploys, err := obj.GetDeploys(ctx) // get previous deploys
if err != nil {
return 0, errwrap.Wrapf(err, "error getting previous deploys")
}
// find the latest id
max := calculateMax(deploys)
return max, nil // found! (or zero)
}
// AddDeploy adds a new deploy. It takes an id and ensures it's sequential. If
// hash is not empty, then it will check that the pHash matches what the
// previous hash was, and also adds this new hash along side the id. This is
// useful to make sure you get a linear chain of git patches, and to avoid two
// contributors pushing conflicting deploys. This isn't git specific, and so any
// arbitrary string hash can be used.
// FIXME: prune old deploys from the store when they aren't needed anymore...
func (obj *SimpleDeploy) AddDeploy(ctx context.Context, id uint64, hash, pHash string, data *string) error {
// key structure is $NS/deploy/$id/payload = $data
// key structure is $NS/deploy/$id/hash = $hash
path := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id, payloadPath)
tPath := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id, hashPath)
ifs := []etcd.Cmp{} // list matching the desired state
ops := []etcd.Op{} // list of ops in this transaction (then)
// we're append only, so ensure this unique deploy id doesn't exist
//ifs = append(ifs, etcd.Compare(etcd.Version(path), "=", 0)) // KeyMissing
ifs = append(ifs, etcdutil.KeyMissing(path))
// don't look for previous deploy if this is the first deploy ever
if id > 1 {
// we append sequentially, so ensure previous key *does* exist
prev := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id-1, payloadPath)
//ifs = append(ifs, etcd.Compare(etcd.Version(prev), ">", 0)) // KeyExists
ifs = append(ifs, etcdutil.KeyExists(prev))
if hash != "" && pHash != "" {
// does the previously stored hash match what we expect?
prevHash := fmt.Sprintf("%s/%s/%d/%s", obj.ns, deployPath, id-1, hashPath)
ifs = append(ifs, etcd.Compare(etcd.Value(prevHash), "=", pHash))
}
}
ops = append(ops, etcd.OpPut(path, *data))
if hash != "" {
ops = append(ops, etcd.OpPut(tPath, hash)) // store new hash as well
}
// it's important to do this in one transaction, and atomically, because
// this way, we only generate one watch event, and only when it's needed
result, err := obj.Client.Txn(ctx, ifs, ops, nil)
if err != nil {
return errwrap.Wrapf(err, "error creating deploy id %d", id)
}
if !result.Succeeded {
return fmt.Errorf("could not create deploy id %d", id)
}
return nil // success
}