Files
teleport/lib/backend/dynamo/dynamodbbk.go
T

928 lines
28 KiB
Go

/*
Copyright 2015-2020 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package dynamo
import (
"bytes"
"context"
"net/http"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/applicationautoscaling"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"
"github.com/aws/aws-sdk-go/service/dynamodbstreams"
"github.com/aws/aws-sdk-go/service/dynamodbstreams/dynamodbstreamsiface"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
log "github.com/sirupsen/logrus"
"github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/defaults"
dynamometrics "github.com/gravitational/teleport/lib/observability/metrics/dynamo"
)
// Config structure represents DynamoDB configuration as appears in `storage` section
// of Teleport YAML
type Config struct {
// Region is where DynamoDB Table will be used to store k/v
Region string `json:"region,omitempty"`
// AWS AccessKey used to authenticate DynamoDB queries (prefer IAM role instead of hardcoded value)
AccessKey string `json:"access_key,omitempty"`
// AWS SecretKey used to authenticate DynamoDB queries (prefer IAM role instead of hardcoded value)
SecretKey string `json:"secret_key,omitempty"`
// TableName where to store K/V in DynamoDB
TableName string `json:"table_name,omitempty"`
// ReadCapacityUnits is Dynamodb read capacity units
ReadCapacityUnits int64 `json:"read_capacity_units"`
// WriteCapacityUnits is Dynamodb write capacity units
WriteCapacityUnits int64 `json:"write_capacity_units"`
// BufferSize is a default buffer size
// used to pull events
BufferSize int `json:"buffer_size,omitempty"`
// PollStreamPeriod is a polling period for event stream
PollStreamPeriod time.Duration `json:"poll_stream_period,omitempty"`
// RetryPeriod is a period between dynamo backend retries on failures
RetryPeriod time.Duration `json:"retry_period"`
// EnableContinuousBackups is used to enables PITR (Point-In-Time Recovery).
EnableContinuousBackups bool `json:"continuous_backups,omitempty"`
// EnableAutoScaling is used to enable auto scaling policy.
EnableAutoScaling bool `json:"auto_scaling,omitempty"`
// ReadMaxCapacity is the maximum provisioned read capacity. Required to be
// set if auto scaling is enabled.
ReadMaxCapacity int64 `json:"read_max_capacity,omitempty"`
// ReadMinCapacity is the minimum provisioned read capacity. Required to be
// set if auto scaling is enabled.
ReadMinCapacity int64 `json:"read_min_capacity,omitempty"`
// ReadTargetValue is the ratio of consumed read capacity to provisioned
// capacity. Required to be set if auto scaling is enabled.
ReadTargetValue float64 `json:"read_target_value,omitempty"`
// WriteMaxCapacity is the maximum provisioned write capacity. Required to
// be set if auto scaling is enabled.
WriteMaxCapacity int64 `json:"write_max_capacity,omitempty"`
// WriteMinCapacity is the minimum provisioned write capacity. Required to
// be set if auto scaling is enabled.
WriteMinCapacity int64 `json:"write_min_capacity,omitempty"`
// WriteTargetValue is the ratio of consumed write capacity to provisioned
// capacity. Required to be set if auto scaling is enabled.
WriteTargetValue float64 `json:"write_target_value,omitempty"`
}
// CheckAndSetDefaults is a helper returns an error if the supplied configuration
// is not enough to connect to DynamoDB
func (cfg *Config) CheckAndSetDefaults() error {
// Table name is required.
if cfg.TableName == "" {
return trace.BadParameter("DynamoDB: table_name is not specified")
}
if cfg.ReadCapacityUnits == 0 {
cfg.ReadCapacityUnits = DefaultReadCapacityUnits
}
if cfg.WriteCapacityUnits == 0 {
cfg.WriteCapacityUnits = DefaultWriteCapacityUnits
}
if cfg.BufferSize == 0 {
cfg.BufferSize = backend.DefaultBufferCapacity
}
if cfg.PollStreamPeriod == 0 {
cfg.PollStreamPeriod = backend.DefaultPollStreamPeriod
}
if cfg.RetryPeriod == 0 {
cfg.RetryPeriod = defaults.HighResPollingPeriod
}
return nil
}
// Backend is a DynamoDB-backed key value backend implementation.
type Backend struct {
*log.Entry
Config
svc dynamodbiface.DynamoDBAPI
streams dynamodbstreamsiface.DynamoDBStreamsAPI
clock clockwork.Clock
buf *backend.CircularBuffer
// closedFlag is set to indicate that the database is closed
closedFlag int32
// session holds the AWS client.
session *session.Session
}
type record struct {
HashKey string
FullPath string
Value []byte
Timestamp int64
Expires *int64 `json:"Expires,omitempty"`
ID int64
}
type keyLookup struct {
HashKey string
FullPath string
}
const (
// hashKey is actually the name of the partition. This backend
// places all objects in the same DynamoDB partition
hashKey = "teleport"
// obsolete schema key. if a table contains "Key" column it means
// such table needs to be migrated
oldPathAttr = "Key"
// BackendName is the name of this backend
BackendName = "dynamodb"
// ttlKey is a key used for TTL specification
ttlKey = "Expires"
// DefaultReadCapacityUnits specifies default value for read capacity units
DefaultReadCapacityUnits = 10
// DefaultWriteCapacityUnits specifies default value for write capacity units
DefaultWriteCapacityUnits = 10
// fullPathKey is a name of the full path key
fullPathKey = "FullPath"
// hashKeyKey is a name of the hash key
hashKeyKey = "HashKey"
// keyPrefix is a prefix that is added to every dynamodb key
// for backwards compatibility
keyPrefix = "teleport"
)
// GetName is a part of backend API and it returns DynamoDB backend type
// as it appears in `storage/type` section of Teleport YAML
func GetName() string {
return BackendName
}
// keep this here to test interface conformance
var _ backend.Backend = &Backend{}
// New returns new instance of DynamoDB backend.
// It's an implementation of backend API's NewFunc
func New(ctx context.Context, params backend.Params) (*Backend, error) {
l := log.WithFields(log.Fields{trace.Component: BackendName})
var cfg *Config
err := utils.ObjectToStruct(params, &cfg)
if err != nil {
return nil, trace.BadParameter("DynamoDB configuration is invalid: %v", err)
}
defer l.Debug("AWS session is created.")
if err := cfg.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
l.Infof("Initializing backend. Table: %q, poll streams every %v.", cfg.TableName, cfg.PollStreamPeriod)
buf := backend.NewCircularBuffer(
backend.BufferCapacity(cfg.BufferSize),
)
b := &Backend{
Entry: l,
Config: *cfg,
clock: clockwork.NewRealClock(),
buf: buf,
}
// create an AWS session using default SDK behavior, i.e. it will interpret
// the environment and ~/.aws directory just like an AWS CLI tool would:
b.session, err = session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
})
if err != nil {
return nil, trace.Wrap(err)
}
// override the default environment (region + credentials) with the values
// from the YAML file:
if cfg.Region != "" {
b.session.Config.Region = aws.String(cfg.Region)
}
if cfg.AccessKey != "" || cfg.SecretKey != "" {
creds := credentials.NewStaticCredentials(cfg.AccessKey, cfg.SecretKey, "")
b.session.Config.Credentials = creds
}
// Increase the size of the connection pool. This substantially improves the
// performance of Teleport under load as it reduces the number of TLS
// handshakes performed.
httpClient := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: defaults.HTTPMaxIdleConns,
MaxIdleConnsPerHost: defaults.HTTPMaxIdleConnsPerHost,
},
}
b.session.Config.HTTPClient = httpClient
// create DynamoDB service:
svc, err := dynamometrics.NewAPIMetrics(dynamometrics.Backend, dynamodb.New(b.session))
if err != nil {
return nil, trace.Wrap(err)
}
b.svc = svc
streams, err := dynamometrics.NewStreamsMetricsAPI(dynamometrics.Backend, dynamodbstreams.New(b.session))
if err != nil {
return nil, trace.Wrap(err)
}
b.streams = streams
// check if the table exists?
ts, err := b.getTableStatus(ctx, b.TableName)
if err != nil {
return nil, trace.Wrap(err)
}
switch ts {
case tableStatusOK:
break
case tableStatusMissing:
err = b.createTable(ctx, b.TableName, fullPathKey)
case tableStatusNeedsMigration:
return nil, trace.BadParameter("unsupported schema")
}
if err != nil {
return nil, trace.Wrap(err)
}
// Enable TTL on table.
err = TurnOnTimeToLive(ctx, b.svc, b.TableName, ttlKey)
if err != nil {
return nil, trace.Wrap(err)
}
// Turn on DynamoDB streams, needed to implement events.
err = TurnOnStreams(ctx, b.svc, b.TableName)
if err != nil {
return nil, trace.Wrap(err)
}
// Enable continuous backups if requested.
if b.Config.EnableContinuousBackups {
if err := SetContinuousBackups(ctx, b.svc, b.TableName); err != nil {
return nil, trace.Wrap(err)
}
}
// Enable auto scaling if requested.
if b.Config.EnableAutoScaling {
if err := SetAutoScaling(ctx, applicationautoscaling.New(b.session), GetTableID(b.TableName), AutoScalingParams{
ReadMinCapacity: b.Config.ReadMinCapacity,
ReadMaxCapacity: b.Config.ReadMaxCapacity,
ReadTargetValue: b.Config.ReadTargetValue,
WriteMinCapacity: b.Config.WriteMinCapacity,
WriteMaxCapacity: b.Config.WriteMaxCapacity,
WriteTargetValue: b.Config.WriteTargetValue,
}); err != nil {
return nil, trace.Wrap(err)
}
}
go func() {
if err := b.asyncPollStreams(ctx); err != nil {
b.Errorf("Stream polling loop exited: %v", err)
}
}()
// Wrap backend in a input sanitizer and return it.
return b, nil
}
// Create creates item if it does not exist
func (b *Backend) Create(ctx context.Context, item backend.Item) (*backend.Lease, error) {
err := b.create(ctx, item, modeCreate)
if trace.IsCompareFailed(err) {
err = trace.AlreadyExists(err.Error())
}
if err != nil {
return nil, trace.Wrap(err)
}
return b.newLease(item), nil
}
// Put puts value into backend (creates if it does not
// exists, updates it otherwise)
func (b *Backend) Put(ctx context.Context, item backend.Item) (*backend.Lease, error) {
err := b.create(ctx, item, modePut)
if err != nil {
return nil, trace.Wrap(err)
}
return b.newLease(item), nil
}
// Update updates value in the backend
func (b *Backend) Update(ctx context.Context, item backend.Item) (*backend.Lease, error) {
err := b.create(ctx, item, modeUpdate)
if trace.IsCompareFailed(err) {
err = trace.NotFound(err.Error())
}
if err != nil {
return nil, trace.Wrap(err)
}
return b.newLease(item), nil
}
// GetRange returns range of elements
func (b *Backend) GetRange(ctx context.Context, startKey []byte, endKey []byte, limit int) (*backend.GetResult, error) {
if len(startKey) == 0 {
return nil, trace.BadParameter("missing parameter startKey")
}
if len(endKey) == 0 {
return nil, trace.BadParameter("missing parameter endKey")
}
if limit <= 0 {
limit = backend.DefaultRangeLimit
}
result, err := b.getAllRecords(ctx, startKey, endKey, limit)
if err != nil {
return nil, trace.Wrap(err)
}
sort.Sort(records(result.records))
values := make([]backend.Item, len(result.records))
for i, r := range result.records {
values[i] = backend.Item{
Key: trimPrefix(r.FullPath),
Value: r.Value,
}
if r.Expires != nil {
values[i].Expires = time.Unix(*r.Expires, 0).UTC()
}
}
return &backend.GetResult{Items: values}, nil
}
func (b *Backend) getAllRecords(ctx context.Context, startKey []byte, endKey []byte, limit int) (*getResult, error) {
var result getResult
// this code is being extra careful here not to introduce endless loop
// by some unfortunate series of events
for i := 0; i < backend.DefaultRangeLimit/100; i++ {
re, err := b.getRecords(ctx, prependPrefix(startKey), prependPrefix(endKey), limit, result.lastEvaluatedKey)
if err != nil {
return nil, trace.Wrap(err)
}
result.records = append(result.records, re.records...)
// If the limit was exceeded or there are no more records to fetch return the current result
// otherwise updated lastEvaluatedKey and proceed with obtaining new records.
if (limit != 0 && len(result.records) >= limit) || len(re.lastEvaluatedKey) == 0 {
if len(result.records) == backend.DefaultRangeLimit {
b.Warnf("Range query hit backend limit. (this is a bug!) startKey=%q,limit=%d", startKey, backend.DefaultRangeLimit)
}
result.lastEvaluatedKey = nil
return &result, nil
}
result.lastEvaluatedKey = re.lastEvaluatedKey
}
return nil, trace.BadParameter("backend entered endless loop")
}
// DeleteRange deletes range of items with keys between startKey and endKey
func (b *Backend) DeleteRange(ctx context.Context, startKey, endKey []byte) error {
if len(startKey) == 0 {
return trace.BadParameter("missing parameter startKey")
}
if len(endKey) == 0 {
return trace.BadParameter("missing parameter endKey")
}
// keep fetching and deleting until no records left,
// keep the very large limit, just in case if someone else
// keeps adding records
for i := 0; i < backend.DefaultRangeLimit/100; i++ {
result, err := b.getRecords(ctx, prependPrefix(startKey), prependPrefix(endKey), 100, nil)
if err != nil {
return trace.Wrap(err)
}
if len(result.records) == 0 {
return nil
}
requests := make([]*dynamodb.WriteRequest, 0, len(result.records))
for _, record := range result.records {
requests = append(requests, &dynamodb.WriteRequest{
DeleteRequest: &dynamodb.DeleteRequest{
Key: map[string]*dynamodb.AttributeValue{
hashKeyKey: {
S: aws.String(hashKey),
},
fullPathKey: {
S: aws.String(record.FullPath),
},
},
},
})
}
input := dynamodb.BatchWriteItemInput{
RequestItems: map[string][]*dynamodb.WriteRequest{
b.TableName: requests,
},
}
if _, err = b.svc.BatchWriteItemWithContext(ctx, &input); err != nil {
return trace.Wrap(err)
}
}
return trace.ConnectionProblem(nil, "not all items deleted, too many requests")
}
// Get returns a single item or not found error
func (b *Backend) Get(ctx context.Context, key []byte) (*backend.Item, error) {
r, err := b.getKey(ctx, key)
if err != nil {
return nil, err
}
item := &backend.Item{
Key: trimPrefix(r.FullPath),
Value: r.Value,
ID: r.ID,
}
if r.Expires != nil {
item.Expires = time.Unix(*r.Expires, 0)
}
return item, nil
}
// CompareAndSwap compares and swap values in atomic operation
// CompareAndSwap compares item with existing item
// and replaces is with replaceWith item
func (b *Backend) CompareAndSwap(ctx context.Context, expected backend.Item, replaceWith backend.Item) (*backend.Lease, error) {
if len(expected.Key) == 0 {
return nil, trace.BadParameter("missing parameter Key")
}
if len(replaceWith.Key) == 0 {
return nil, trace.BadParameter("missing parameter Key")
}
if !bytes.Equal(expected.Key, replaceWith.Key) {
return nil, trace.BadParameter("expected and replaceWith keys should match")
}
r := record{
HashKey: hashKey,
FullPath: prependPrefix(replaceWith.Key),
Value: replaceWith.Value,
Timestamp: time.Now().UTC().Unix(),
ID: time.Now().UTC().UnixNano(),
}
if !replaceWith.Expires.IsZero() {
r.Expires = aws.Int64(replaceWith.Expires.UTC().Unix())
}
av, err := dynamodbattribute.MarshalMap(r)
if err != nil {
return nil, trace.Wrap(err)
}
input := dynamodb.PutItemInput{
Item: av,
TableName: aws.String(b.TableName),
}
input.SetConditionExpression("#v = :prev")
input.SetExpressionAttributeNames(map[string]*string{
"#v": aws.String("Value"),
})
input.SetExpressionAttributeValues(map[string]*dynamodb.AttributeValue{
":prev": {
B: expected.Value,
},
})
_, err = b.svc.PutItemWithContext(ctx, &input)
err = convertError(err)
if err != nil {
// in this case let's use more specific compare failed error
if trace.IsAlreadyExists(err) {
return nil, trace.CompareFailed(err.Error())
}
return nil, trace.Wrap(err)
}
return b.newLease(replaceWith), nil
}
// Delete deletes item by key
func (b *Backend) Delete(ctx context.Context, key []byte) error {
if len(key) == 0 {
return trace.BadParameter("missing parameter key")
}
if _, err := b.getKey(ctx, key); err != nil {
return err
}
return b.deleteKey(ctx, key)
}
// NewWatcher returns a new event watcher
func (b *Backend) NewWatcher(ctx context.Context, watch backend.Watch) (backend.Watcher, error) {
return b.buf.NewWatcher(ctx, watch)
}
// KeepAlive keeps object from expiring, updates lease on the existing object,
// expires contains the new expiry to set on the lease,
// some backends may ignore expires based on the implementation
// in case if the lease managed server side
func (b *Backend) KeepAlive(ctx context.Context, lease backend.Lease, expires time.Time) error {
if len(lease.Key) == 0 {
return trace.BadParameter("lease is missing key")
}
input := &dynamodb.UpdateItemInput{
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":expires": {
N: aws.String(strconv.FormatInt(expires.UTC().Unix(), 10)),
},
":timestamp": {
N: aws.String(strconv.FormatInt(b.clock.Now().UTC().Unix(), 10)),
},
},
TableName: aws.String(b.TableName),
Key: map[string]*dynamodb.AttributeValue{
hashKeyKey: {
S: aws.String(hashKey),
},
fullPathKey: {
S: aws.String(prependPrefix(lease.Key)),
},
},
UpdateExpression: aws.String("SET Expires = :expires"),
}
input.SetConditionExpression("attribute_exists(FullPath) AND (attribute_not_exists(Expires) OR Expires >= :timestamp)")
_, err := b.svc.UpdateItemWithContext(ctx, input)
err = convertError(err)
if trace.IsCompareFailed(err) {
err = trace.NotFound(err.Error())
}
return err
}
func (b *Backend) isClosed() bool {
return atomic.LoadInt32(&b.closedFlag) == 1
}
func (b *Backend) setClosed() {
atomic.StoreInt32(&b.closedFlag, 1)
}
// Close closes the DynamoDB driver
// and releases associated resources
func (b *Backend) Close() error {
b.setClosed()
return b.buf.Close()
}
// CloseWatchers closes all the watchers
// without closing the backend
func (b *Backend) CloseWatchers() {
b.buf.Clear()
}
type tableStatus int
const (
tableStatusError = iota
tableStatusMissing
tableStatusNeedsMigration
tableStatusOK
)
// Clock returns wall clock
func (b *Backend) Clock() clockwork.Clock {
return b.clock
}
func (b *Backend) newLease(item backend.Item) *backend.Lease {
var lease backend.Lease
if item.Expires.IsZero() {
return &lease
}
lease.Key = item.Key
return &lease
}
// getTableStatus checks if a given table exists
func (b *Backend) getTableStatus(ctx context.Context, tableName string) (tableStatus, error) {
td, err := b.svc.DescribeTableWithContext(ctx, &dynamodb.DescribeTableInput{
TableName: aws.String(tableName),
})
err = convertError(err)
if err != nil {
if trace.IsNotFound(err) {
return tableStatusMissing, nil
}
return tableStatusError, trace.Wrap(err)
}
for _, attr := range td.Table.AttributeDefinitions {
if *attr.AttributeName == oldPathAttr {
return tableStatusNeedsMigration, nil
}
}
return tableStatusOK, nil
}
// createTable creates a DynamoDB table with a requested name and applies
// the back-end schema to it. The table must not exist.
//
// rangeKey is the name of the 'range key' the schema requires.
// currently is always set to "FullPath" (used to be something else, that's
// why it's a parameter for migration purposes)
func (b *Backend) createTable(ctx context.Context, tableName string, rangeKey string) error {
pThroughput := dynamodb.ProvisionedThroughput{
ReadCapacityUnits: aws.Int64(b.ReadCapacityUnits),
WriteCapacityUnits: aws.Int64(b.WriteCapacityUnits),
}
def := []*dynamodb.AttributeDefinition{
{
AttributeName: aws.String(hashKeyKey),
AttributeType: aws.String("S"),
},
{
AttributeName: aws.String(rangeKey),
AttributeType: aws.String("S"),
},
}
elems := []*dynamodb.KeySchemaElement{
{
AttributeName: aws.String(hashKeyKey),
KeyType: aws.String("HASH"),
},
{
AttributeName: aws.String(rangeKey),
KeyType: aws.String("RANGE"),
},
}
c := dynamodb.CreateTableInput{
TableName: aws.String(tableName),
AttributeDefinitions: def,
KeySchema: elems,
ProvisionedThroughput: &pThroughput,
}
_, err := b.svc.CreateTableWithContext(ctx, &c)
if err != nil {
return trace.Wrap(err)
}
b.Infof("Waiting until table %q is created.", tableName)
err = b.svc.WaitUntilTableExistsWithContext(ctx, &dynamodb.DescribeTableInput{
TableName: aws.String(tableName),
})
if err == nil {
b.Infof("Table %q has been created.", tableName)
}
return trace.Wrap(err)
}
type getResult struct {
records []record
// lastEvaluatedKey is the primary key of the item where the operation stopped, inclusive of the
// previous result set. Use this value to start a new operation, excluding this
// value in the new request.
lastEvaluatedKey map[string]*dynamodb.AttributeValue
}
// getRecords retrieves all keys by path
func (b *Backend) getRecords(ctx context.Context, startKey, endKey string, limit int, lastEvaluatedKey map[string]*dynamodb.AttributeValue) (*getResult, error) {
query := "HashKey = :hashKey AND FullPath BETWEEN :fullPath AND :rangeEnd"
attrV := map[string]interface{}{
":fullPath": startKey,
":hashKey": hashKey,
":timestamp": b.clock.Now().UTC().Unix(),
":rangeEnd": endKey,
}
// filter out expired items, otherwise they might show up in the query
// http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/howitworks-ttl.html
filter := "attribute_not_exists(Expires) OR Expires >= :timestamp"
av, err := dynamodbattribute.MarshalMap(attrV)
if err != nil {
return nil, convertError(err)
}
input := dynamodb.QueryInput{
KeyConditionExpression: aws.String(query),
TableName: &b.TableName,
ExpressionAttributeValues: av,
FilterExpression: aws.String(filter),
ConsistentRead: aws.Bool(true),
ExclusiveStartKey: lastEvaluatedKey,
}
if limit > 0 {
input.Limit = aws.Int64(int64(limit))
}
out, err := b.svc.QueryWithContext(ctx, &input)
if err != nil {
return nil, trace.Wrap(err)
}
var result getResult
for _, item := range out.Items {
var r record
if err := dynamodbattribute.UnmarshalMap(item, &r); err != nil {
return nil, trace.Wrap(err)
}
result.records = append(result.records, r)
}
sort.Sort(records(result.records))
result.records = removeDuplicates(result.records)
result.lastEvaluatedKey = out.LastEvaluatedKey
return &result, nil
}
// isExpired returns 'true' if the given object (record) has a TTL and
// it's due.
func (r *record) isExpired(now time.Time) bool {
if r.Expires == nil {
return false
}
expiryDateUTC := time.Unix(*r.Expires, 0).UTC()
return now.UTC().After(expiryDateUTC)
}
func removeDuplicates(elements []record) []record {
// Use map to record duplicates as we find them.
encountered := map[string]bool{}
result := []record{}
for v := range elements {
if encountered[elements[v].FullPath] {
// Do not add duplicate.
} else {
// Record this element as an encountered element.
encountered[elements[v].FullPath] = true
// Append to result slice.
result = append(result, elements[v])
}
}
// Return the new slice.
return result
}
const (
modeCreate = iota
modePut
modeUpdate
)
// prependPrefix adds leading 'teleport/' to the key for backwards compatibility
// with previous implementation of DynamoDB backend
func prependPrefix(key []byte) string {
return keyPrefix + string(key)
}
// trimPrefix removes leading 'teleport' from the key
func trimPrefix(key string) []byte {
return []byte(strings.TrimPrefix(key, keyPrefix))
}
// create helper creates a new key/value pair in Dynamo with a given expiration
// depending on mode, either creates, updates or forces create/update
func (b *Backend) create(ctx context.Context, item backend.Item, mode int) error {
r := record{
HashKey: hashKey,
FullPath: prependPrefix(item.Key),
Value: item.Value,
Timestamp: time.Now().UTC().Unix(),
ID: time.Now().UTC().UnixNano(),
}
if !item.Expires.IsZero() {
r.Expires = aws.Int64(item.Expires.UTC().Unix())
}
av, err := dynamodbattribute.MarshalMap(r)
if err != nil {
return trace.Wrap(err)
}
input := dynamodb.PutItemInput{
Item: av,
TableName: aws.String(b.TableName),
}
switch mode {
case modeCreate:
input.SetConditionExpression("attribute_not_exists(FullPath)")
case modeUpdate:
input.SetConditionExpression("attribute_exists(FullPath)")
case modePut:
default:
return trace.BadParameter("unrecognized mode")
}
_, err = b.svc.PutItemWithContext(ctx, &input)
err = convertError(err)
if err != nil {
return trace.Wrap(err)
}
return nil
}
func (b *Backend) deleteKey(ctx context.Context, key []byte) error {
av, err := dynamodbattribute.MarshalMap(keyLookup{
HashKey: hashKey,
FullPath: prependPrefix(key),
})
if err != nil {
return trace.Wrap(err)
}
input := dynamodb.DeleteItemInput{Key: av, TableName: aws.String(b.TableName)}
if _, err = b.svc.DeleteItemWithContext(ctx, &input); err != nil {
return trace.Wrap(err)
}
return nil
}
func (b *Backend) getKey(ctx context.Context, key []byte) (*record, error) {
av, err := dynamodbattribute.MarshalMap(keyLookup{
HashKey: hashKey,
FullPath: prependPrefix(key),
})
if err != nil {
return nil, trace.Wrap(err)
}
input := dynamodb.GetItemInput{
Key: av,
TableName: aws.String(b.TableName),
ConsistentRead: aws.Bool(true),
}
out, err := b.svc.GetItemWithContext(ctx, &input)
if err != nil {
// we deliberately use a "generic" trace error here, since we don't want
// callers to make assumptions about the nature of the failure.
return nil, trace.WrapWithMessage(err, "failed to get %q (dynamo error)", string(key))
}
if len(out.Item) == 0 {
return nil, trace.NotFound("%q is not found", string(key))
}
var r record
if err := dynamodbattribute.UnmarshalMap(out.Item, &r); err != nil {
return nil, trace.WrapWithMessage(err, "failed to unmarshal dynamo item %q", string(key))
}
// Check if key expired, if expired delete it
if r.isExpired(b.clock.Now()) {
if err := b.deleteKey(ctx, key); err != nil {
b.Warnf("Failed deleting expired key %q: %v", key, err)
}
return nil, trace.NotFound("%q is not found", key)
}
return &r, nil
}
func convertError(err error) error {
if err == nil {
return nil
}
aerr, ok := err.(awserr.Error)
if !ok {
return err
}
switch aerr.Code() {
case dynamodb.ErrCodeConditionalCheckFailedException:
return trace.CompareFailed(aerr.Error())
case dynamodb.ErrCodeProvisionedThroughputExceededException:
return trace.ConnectionProblem(aerr, aerr.Error())
case dynamodb.ErrCodeResourceNotFoundException, applicationautoscaling.ErrCodeObjectNotFoundException:
return trace.NotFound(aerr.Error())
case dynamodb.ErrCodeItemCollectionSizeLimitExceededException:
return trace.BadParameter(aerr.Error())
case dynamodb.ErrCodeInternalServerError:
return trace.BadParameter(aerr.Error())
case dynamodbstreams.ErrCodeExpiredIteratorException, dynamodbstreams.ErrCodeLimitExceededException, dynamodbstreams.ErrCodeTrimmedDataAccessException:
return trace.ConnectionProblem(aerr, aerr.Error())
default:
return err
}
}
type records []record
// Len is part of sort.Interface.
func (r records) Len() int {
return len(r)
}
// Swap is part of sort.Interface.
func (r records) Swap(i, j int) {
r[i], r[j] = r[j], r[i]
}
// Less is part of sort.Interface.
func (r records) Less(i, j int) bool {
return r[i].FullPath < r[j].FullPath
}