Skip to content

Commit 7beeea3

Browse files
author
Mathew Robinson
committed
feat: Add DynamoDB based locking to prevent race conditions in index.yaml generation
I have set this up so it is sufficiently abstracted that other lock backends could be added in the future. Fixes hypnoglow#18
1 parent 11a1da3 commit 7beeea3

File tree

12 files changed

+345
-11
lines changed

12 files changed

+345
-11
lines changed

cmd/helm-s3/delete.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ import (
1010
"github.com/hypnoglow/helm-s3/internal/awss3"
1111
"github.com/hypnoglow/helm-s3/internal/awsutil"
1212
"github.com/hypnoglow/helm-s3/internal/helmutil"
13+
"github.com/hypnoglow/helm-s3/internal/locks"
1314
)
1415

1516
const deleteDesc = `This command removes a chart from the repository.
1617
17-
'helm s3 init' takes two arguments:
18+
'helm s3 delete' takes two arguments:
1819
- NAME - name of the chart to delete,
1920
- REPO - target repository.
2021
`
@@ -44,6 +45,8 @@ func newDeleteCommand(opts *options) *cobra.Command {
4445
RunE: func(cmd *cobra.Command, args []string) error {
4546
act.printer = cmd
4647
act.acl = opts.acl
48+
act.dynamodbLockTableName = opts.dynamodbLockTableName
49+
act.lockTimeoutSeconds = opts.lockTimeoutSeconds
4750
act.chartName = args[0]
4851
act.repoName = args[1]
4952
return act.run(cmd.Context())
@@ -62,7 +65,9 @@ type deleteAction struct {
6265

6366
// global flags
6467

65-
acl string
68+
acl string
69+
dynamodbLockTableName string
70+
lockTimeoutSeconds int
6671

6772
// args
6873

@@ -87,6 +92,23 @@ func (act *deleteAction) run(ctx context.Context) error {
8792
storage := awss3.New(sess)
8893

8994
// Fetch current index.
95+
var lock locks.Lock
96+
if act.dynamodbLockTableName != "" {
97+
lock, err = locks.NewDynamoDBLockWithDefaultConfig(act.dynamodbLockTableName)
98+
if err != nil {
99+
return errors.WithMessage(err, "loading AWS config")
100+
}
101+
} else {
102+
lock = locks.NewFalseLock()
103+
}
104+
105+
lockID := awss3.LockID(repoEntry.IndexURL())
106+
err = locks.WaitForLock(ctx, lock, lockID, act.lockTimeoutSeconds)
107+
if err != nil {
108+
return errors.WithMessage(err, "waiting for index.yaml lock")
109+
}
110+
defer lock.Unlock(ctx, lockID)
111+
90112
b, err := storage.FetchRaw(ctx, repoEntry.IndexURL())
91113
if err != nil {
92114
return errors.WithMessage(err, "fetch current repo index")

cmd/helm-s3/lock.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package main
2+
3+
import (
4+
"context"
5+
6+
"github.com/hypnoglow/helm-s3/internal/awss3"
7+
"github.com/hypnoglow/helm-s3/internal/helmutil"
8+
"github.com/hypnoglow/helm-s3/internal/locks"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
const lockDesc = `This command creates or removes dynamodb locks for a repository.
13+
14+
'helm s3 lock' takes one argument:
15+
- REPO - target repository.
16+
`
17+
18+
const lockExample = ` helm s3 lock my-repo - creates a dyanmodb lock for 'my-repo'.
19+
helm s3 lock --unlock my-repo - removes the dynamodb lock for 'my-repo'`
20+
21+
func newLockCommand(opts *options) *cobra.Command {
22+
act := &lockAction{}
23+
24+
cmd := &cobra.Command{
25+
Use: "lock REPO",
26+
Short: "Lock or unlock the given repository.",
27+
Long: lockDesc,
28+
Example: lockExample,
29+
Args: wrapPositionalArgsBadUsage(cobra.ExactArgs(1)),
30+
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
31+
// No completions for the NAME and REPO arguments.
32+
return nil, cobra.ShellCompDirectiveNoFileComp
33+
},
34+
RunE: func(cmd *cobra.Command, args []string) error {
35+
repoEntry, err := helmutil.LookupRepoEntry(args[0])
36+
if err != nil {
37+
return err
38+
}
39+
40+
act.lock, err = locks.NewDynamoDBLockWithDefaultConfig(opts.dynamodbLockTableName)
41+
if err != nil {
42+
return err
43+
}
44+
45+
act.lockID = awss3.LockID(repoEntry.URL())
46+
return act.run(cmd.Context())
47+
},
48+
}
49+
50+
flags := cmd.Flags()
51+
flags.BoolVar(&act.unlock, "unlock", act.unlock, "If provided unlock the lock instead of acquiring it.")
52+
_ = cobra.MarkFlagRequired(flags, "version")
53+
54+
return cmd
55+
}
56+
57+
type lockAction struct {
58+
lock locks.Lock
59+
unlock bool
60+
lockID string
61+
}
62+
63+
func (act *lockAction) run(ctx context.Context) error {
64+
if act.unlock {
65+
return act.lock.Unlock(ctx, act.lockID)
66+
}
67+
68+
return act.lock.Lock(ctx, act.lockID)
69+
}

cmd/helm-s3/options.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,21 @@ import (
77

88
// options represents global command options (global flags).
99
type options struct {
10-
timeout time.Duration
11-
acl string
12-
verbose bool
10+
timeout time.Duration
11+
acl string
12+
verbose bool
13+
dynamodbLockTableName string
14+
lockTimeoutSeconds int
1315
}
1416

1517
// newDefaultOptions returns default options.
1618
func newDefaultOptions() *options {
1719
return &options{
18-
timeout: 5 * time.Minute,
19-
acl: os.Getenv("S3_ACL"),
20-
verbose: false,
20+
timeout: 5 * time.Minute,
21+
acl: os.Getenv("S3_ACL"),
22+
verbose: false,
23+
dynamodbLockTableName: "",
24+
// default to 10 minutes
25+
lockTimeoutSeconds: 10 * 60,
2126
}
2227
}

cmd/helm-s3/push.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/hypnoglow/helm-s3/internal/awss3"
1212
"github.com/hypnoglow/helm-s3/internal/awsutil"
1313
"github.com/hypnoglow/helm-s3/internal/helmutil"
14+
"github.com/hypnoglow/helm-s3/internal/locks"
1415
)
1516

1617
const pushDesc = `This command uploads a chart to the repository.
@@ -57,6 +58,8 @@ func newPushCommand(opts *options) *cobra.Command {
5758
RunE: func(cmd *cobra.Command, args []string) error {
5859
act.printer = cmd
5960
act.acl = opts.acl
61+
act.dynamodbLockTableName = opts.dynamodbLockTableName
62+
act.lockTimeoutSeconds = opts.lockTimeoutSeconds
6063
act.chartPath = args[0]
6164
act.repoName = args[1]
6265
return act.run(cmd.Context())
@@ -85,7 +88,9 @@ type pushAction struct {
8588

8689
// global args
8790

88-
acl string
91+
acl string
92+
lockTimeoutSeconds int
93+
dynamodbLockTableName string
8994

9095
// args
9196

@@ -197,6 +202,23 @@ func (act *pushAction) run(ctx context.Context) error {
197202

198203
// Fetch current index, update it and upload it back.
199204

205+
var lock locks.Lock
206+
if act.dynamodbLockTableName != "" {
207+
lock, err = locks.NewDynamoDBLockWithDefaultConfig(act.dynamodbLockTableName)
208+
if err != nil {
209+
return errors.WithMessage(err, "loading AWS config")
210+
}
211+
} else {
212+
lock = locks.NewFalseLock()
213+
}
214+
215+
lockID := awss3.LockID(repoEntry.IndexURL())
216+
err = locks.WaitForLock(ctx, lock, lockID, act.lockTimeoutSeconds)
217+
if err != nil {
218+
return errors.WithMessage(err, "waiting for index.yaml lock")
219+
}
220+
defer lock.Unlock(ctx, lockID)
221+
200222
b, err := storage.FetchRaw(ctx, repoEntry.IndexURL())
201223
if err != nil {
202224
return errors.WithMessage(err, "fetch current repo index")

cmd/helm-s3/reindex.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/hypnoglow/helm-s3/internal/awss3"
1111
"github.com/hypnoglow/helm-s3/internal/awsutil"
1212
"github.com/hypnoglow/helm-s3/internal/helmutil"
13+
"github.com/hypnoglow/helm-s3/internal/locks"
1314
)
1415

1516
const reindexDesc = `This command performs a reindex of the repository.
@@ -59,8 +60,10 @@ type reindexAction struct {
5960

6061
// global flags
6162

62-
acl string
63-
verbose bool
63+
acl string
64+
verbose bool
65+
dynamodbLockTableName string
66+
lockTimeoutSeconds int
6467

6568
// args
6669

@@ -85,6 +88,23 @@ func (act *reindexAction) run(ctx context.Context) error {
8588

8689
items, errs := storage.Traverse(ctx, repoEntry.URL())
8790

91+
var lock locks.Lock
92+
if act.dynamodbLockTableName != "" {
93+
lock, err = locks.NewDynamoDBLockWithDefaultConfig(act.dynamodbLockTableName)
94+
if err != nil {
95+
return errors.WithMessage(err, "loading AWS config")
96+
}
97+
} else {
98+
lock = locks.NewFalseLock()
99+
}
100+
101+
lockID := awss3.LockID(repoEntry.IndexURL())
102+
err = locks.WaitForLock(ctx, lock, lockID, act.lockTimeoutSeconds)
103+
if err != nil {
104+
return errors.WithMessage(err, "waiting for index.yaml lock")
105+
}
106+
defer lock.Unlock(ctx, lockID)
107+
88108
builtIndex := make(chan helmutil.Index, 1)
89109
go func() {
90110
idx := helmutil.NewIndex()

cmd/helm-s3/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ func newRootCmd() *cobra.Command {
7777
flags.StringVar(&opts.acl, "acl", opts.acl, "S3 Object ACL to use for charts and indexes. Can be sourced from S3_ACL environment variable.")
7878
flags.DurationVar(&opts.timeout, "timeout", opts.timeout, "Timeout for the whole operation to complete.")
7979
flags.BoolVar(&opts.verbose, "verbose", opts.verbose, "Enable verbose output.")
80+
flags.StringVar(&opts.dynamodbLockTableName, "lock-table-name", opts.dynamodbLockTableName, "DynamoDB table name to use for distributed locking.")
81+
flags.IntVar(&opts.lockTimeoutSeconds, "lock-timeout", opts.lockTimeoutSeconds, "How long to wait for a lock to be free in seconds")
8082

8183
cmd.SetFlagErrorFunc(func(command *cobra.Command, err error) error {
8284
return newBadUsageError(err)
@@ -92,6 +94,7 @@ func newRootCmd() *cobra.Command {
9294
newReindexCommand(opts),
9395
newDeleteCommand(opts),
9496
newVersionCommand(),
97+
newLockCommand(opts),
9598
)
9699

97100
return cmd

go.mod

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ require (
2222
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
2323
github.com/BurntSushi/toml v1.3.2 // indirect
2424
github.com/Microsoft/hcsshim v0.11.4 // indirect
25+
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
26+
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
27+
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
28+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
29+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
30+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
31+
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
32+
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.34.3 // indirect
33+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
34+
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.9.16 // indirect
35+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
36+
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
37+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
38+
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
39+
github.com/aws/smithy-go v1.20.3 // indirect
2540
github.com/beorn7/perks v1.0.1 // indirect
2641
github.com/cespare/xxhash/v2 v2.2.0 // indirect
2742
github.com/containerd/containerd v1.7.12 // indirect

go.sum

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,36 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
2020
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
2121
github.com/aws/aws-sdk-go v1.54.20 h1:FZ2UcXya7bUkvkpf7TaPmiL7EubK0go1nlXGLRwEsoo=
2222
github.com/aws/aws-sdk-go v1.54.20/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
23+
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
24+
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
25+
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
26+
github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg=
27+
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
28+
github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
29+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw=
30+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
31+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
32+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
33+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
34+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
35+
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
36+
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
37+
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.34.3 h1:nEhZKd1JQ4EB1tekcqW1oIVpDC1ZFrjrp/cLC5MXjFQ=
38+
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.34.3/go.mod h1:q9vzW3Xr1KEXa8n4waHiFt1PrppNDlMymlYP+xpsFbY=
39+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
40+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
41+
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.9.16 h1:lhAX5f7KpgwyieXjbDnRTjPEUI0l3emSRyxXj1PXP8w=
42+
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.9.16/go.mod h1:AblAlCwvi7Q/SFowvckgN+8M3uFPlopSYeLlbNDArhA=
43+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
44+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
45+
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM=
46+
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU=
47+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE=
48+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
49+
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
50+
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
51+
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
52+
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
2353
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
2454
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
2555
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

internal/awss3/lock.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package awss3
2+
3+
import "fmt"
4+
5+
// LockID returns the lockID for the bucket specified by the given URI.
6+
func LockID(uri string) string {
7+
bucket, _, err := parseURI(uri)
8+
if err != nil {
9+
return ""
10+
}
11+
12+
return fmt.Sprintf("lock/%s", bucket)
13+
}

0 commit comments

Comments
 (0)