forked from plumber-cd/terraform-backend-git
/
client.go
335 lines (262 loc) · 10.2 KB
/
client.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
package git
import (
"errors"
"fmt"
"net/http"
"os"
"path"
"strings"
"sync"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/leopoggiani/terraform-backend-git/types"
)
// NewStorageClient creates new StorageClient
func NewStorageClient() types.StorageClient {
return &StorageClient{
sessions: make(map[string]*storageSession),
sessionsMutex: sync.Mutex{},
}
}
// ParseMetadataParams read request parameters specific to Git storage type
func (storageClient *StorageClient) ParseMetadataParams(request *http.Request, metadata *types.RequestMetadata) error {
query := request.URL.Query()
params := RequestMetadataParams{
Repository: query.Get("repository"),
Ref: query.Get("ref"),
State: path.Clean(query.Get("state")),
}
if params.Repository == "" {
return errors.New("Missing parameter 'repository'")
}
if params.Ref == "" {
params.Ref = "master"
}
if params.State == "" {
return errors.New("Missing parameter 'state'")
}
metadata.Params = ¶ms
return nil
}
// Connect will clone this git repository to a virtual in-memory FS (or use previosly cloned cache),
// and put an exclusive lock (mutex, not TF lock) on it.
//
// This StorageClient implementation will use go-git and virtual in-memory FS as git local working tree.
// Each unique RequestMetadataParams.Repository will receive it's own in-memory FS instance.
// That FS will be shared among different requests to the same repository,
// and it will live in memory until backend restarts.
// This is to speed up various git actions and avoid fresh clone every time, which might be time consuming.
//
// Since simple TF-level actions like update state or lock/unlock in this implementation
// involves complex add-commit-push routines that aren't really an atomic operations,
// and the local working tree is shared by multiple requests to the same git repository,
// parallel requests can mess up the local working tree and leave it in broken state.
//
// Example - while one tree checked out the locking branch and preparing the lock metadata to write,
// another request came in for totally different state and it would checkout back to Ref and pull it from remote.
//
// Hence we just assume that Git type of storage does not support parallel connections to the same repository,
// and each connection will lock this repository from usage by other threads within the same backend instance.
//
// This is not currently implemented,
// but if the user values parallel connections feature over overall performance/memory requirements -
// we can use something else for the StorageClient.sessions map key.
// The locking/unlocking would still be required for thread-safety.
// That could be a configurable option.
func (storageClient *StorageClient) Connect(p types.RequestMetadataParams) error {
params := p.(*RequestMetadataParams)
storageClient.sessionsMutex.Lock()
defer storageClient.sessionsMutex.Unlock()
storageSession, ok := storageClient.sessions[params.Repository]
if !ok {
s, err := newStorageSession(params)
if err != nil {
return err
}
storageClient.sessions[params.Repository] = s
storageSession = s
}
storageSession.mutex.Lock()
return nil
}
// Disconnect from Git storage.
// There's nothing to "disconnect" really.
// We just need to unlock the local working copy for other threads.
func (storageClient *StorageClient) Disconnect(p types.RequestMetadataParams) {
params := p.(*RequestMetadataParams)
if storageSession, ok := storageClient.sessions[params.Repository]; ok {
storageSession.mutex.Unlock()
}
}
// LockState this implementation for Git storage will create and push a new branch to remote.
// The branch name will be the name of the state file prefixed by "locks/".
// Next to the state file in subject, there will be a ".lock" file added and commited, that will contain the lock metadata.
// If pushing that branch to remote fails (no fast-forward allowed),
// that would mean something else already aquired the lock before this.
// That approach would make a locking operation atomic.
//
// There's obviosly more than one way to implement the locking with Git.
// This implementation aims to avoid complex Git scenarios that would involve Git merges and dealing with Git conflicts.
// In other words, we are trying to keep the local working tree fast-forwardable at all times.
//
// And remember - git repository hosting the state is a "backend" storage and it's not meant to be used by people.
func (storageClient *StorageClient) LockState(p types.RequestMetadataParams, lock []byte) error {
params := p.(*RequestMetadataParams)
storageSession := storageClient.sessions[params.Repository]
if err := storageSession.checkout(params.Ref, CheckoutModeDefault); err != nil {
return err
}
if err := storageSession.pull(params.Ref); err != nil {
return err
}
lockBranchName := getLockBranchName(params)
// Delete any local leftowers from the past
if err := storageSession.deleteBranch(lockBranchName, false); err != nil {
return err
}
// Create local branch to start preparing a new lock metadata for push
if err := storageSession.checkout(lockBranchName, CheckoutModeCreate); err != nil {
return err
}
lockPath := getLockPath(params)
if err := storageSession.writeFile(lockPath, lock); err != nil {
return err
}
if err := storageSession.add(lockPath); err != nil {
return err
}
if err := storageSession.commit("Lock " + params.State); err != nil {
return err
}
if err := storageSession.push(); err != nil {
// The lock already aquired by someone else
if strings.HasPrefix(err.Error(), git.ErrNonFastForwardUpdate.Error()) {
return types.ErrLockingConflict
}
return err
}
return nil
}
// ReadStateLock read the lock metadata from storage.
// This will fetch locks refs and try to checkout using remote lock branch.
// If it can't pull ("no reference found" error), means the lock didn't exist - ErrLockMissing returned.
// Otherwise it will read the lock metadata from remote HEAD and return it in buffer.
func (storageClient *StorageClient) ReadStateLock(p types.RequestMetadataParams) ([]byte, error) {
params := p.(*RequestMetadataParams)
storageSession := storageClient.sessions[params.Repository]
if err := storageSession.fetch(locksRefSpecs); err != nil {
return nil, err
}
lockBranchName := getLockBranchName(params)
// Delete any local leftowers from the past
if err := storageSession.deleteBranch(lockBranchName, false); err != nil {
return nil, err
}
if err := storageSession.checkout(lockBranchName, CheckoutModeRemote); err != nil {
return nil, err
}
if err := storageSession.pull(lockBranchName); err != nil {
if err == plumbing.ErrReferenceNotFound {
return nil, types.ErrLockMissing
}
return nil, err
}
lock, err := storageSession.readFile(getLockPath(params))
if err != nil {
return nil, err
}
return lock, nil
}
// UnLockState for Git storage type, unlocking is a simple branch deleting remotely
func (storageClient *StorageClient) UnLockState(p types.RequestMetadataParams) error {
params := p.(*RequestMetadataParams)
storageSession := storageClient.sessions[params.Repository]
if err := storageSession.deleteBranch(getLockBranchName(params), true); err != nil {
return err
}
return nil
}
// ForceUnLockWorkaroundMessage suggest the user to delete locking branch
func (storageClient *StorageClient) ForceUnLockWorkaroundMessage(p types.RequestMetadataParams) string {
params := p.(*RequestMetadataParams)
return fmt.Sprintf("As a workaround - please delete the branch %s manually in remote git repository %s.\n",
getLockBranchName(params), params.Repository)
}
// GetState will checkout into Ref, pull the latest from remote, and try to read the state file from there.
// Will return ErrStateDidNotExisted if the state file did not existed.
func (storageClient *StorageClient) GetState(p types.RequestMetadataParams) ([]byte, error) {
var state []byte
params := p.(*RequestMetadataParams)
storageSession := storageClient.sessions[params.Repository]
if err := storageSession.checkout(params.Ref, CheckoutModeDefault); err != nil {
return state, err
}
if err := storageSession.pull(params.Ref); err != nil {
return state, err
}
state, err := storageSession.readFile(params.State)
if err != nil {
if err == os.ErrNotExist {
return state, types.ErrStateDidNotExisted
}
return state, err
}
return state, nil
}
// UpdateState write the state to storage.
// It will checkout the Ref, pull the latest and try to add and commit the state in the request.
// The file in repository will either be created or overwritten.
func (storageClient *StorageClient) UpdateState(p types.RequestMetadataParams, state []byte) error {
params := p.(*RequestMetadataParams)
storageSession := storageClient.sessions[params.Repository]
if err := storageSession.checkout(params.Ref, CheckoutModeDefault); err != nil {
return err
}
if err := storageSession.pull(params.Ref); err != nil {
return err
}
if err := storageSession.writeFile(params.State, state); err != nil {
return err
}
if err := storageSession.add(params.State); err != nil {
return err
}
if err := storageSession.commit("Update " + params.State); err != nil {
return err
}
if err := storageSession.push(); err != nil {
return err
}
return nil
}
// DeleteState delete the state from storage
// Checkout the Ref, pull the latest and attempt to delete the state file from there.
// Then commit and push.
func (storageClient *StorageClient) DeleteState(p types.RequestMetadataParams) error {
params := p.(*RequestMetadataParams)
storageSession := storageClient.sessions[params.Repository]
if err := storageSession.checkout(params.Ref, CheckoutModeDefault); err != nil {
return err
}
if err := storageSession.pull(params.Ref); err != nil {
return err
}
if err := storageSession.delete(params.State); err != nil {
return err
}
if err := storageSession.commit("Delete " + params.State); err != nil {
return err
}
if err := storageSession.push(); err != nil {
return err
}
return nil
}
// getLockPath calculates the path to a lock file
func getLockPath(params *RequestMetadataParams) string {
return params.State + ".lock"
}
// getLockBranchName calculates the locking branch name
func getLockBranchName(params *RequestMetadataParams) string {
return "locks/" + params.State
}