github.com/eliastor/durgaform@v0.0.0-20220816172711-d0ab2d17673e/internal/cloud/backend_state.go (about) 1 package cloud 2 3 import ( 4 "bytes" 5 "context" 6 "crypto/md5" 7 "encoding/base64" 8 "encoding/json" 9 "errors" 10 "fmt" 11 12 tfe "github.com/hashicorp/go-tfe" 13 14 "github.com/eliastor/durgaform/internal/command/jsonstate" 15 "github.com/eliastor/durgaform/internal/states/remote" 16 "github.com/eliastor/durgaform/internal/states/statefile" 17 "github.com/eliastor/durgaform/internal/states/statemgr" 18 ) 19 20 type remoteClient struct { 21 client *tfe.Client 22 lockInfo *statemgr.LockInfo 23 organization string 24 runID string 25 stateUploadErr bool 26 workspace *tfe.Workspace 27 forcePush bool 28 } 29 30 // Get the remote state. 31 func (r *remoteClient) Get() (*remote.Payload, error) { 32 ctx := context.Background() 33 34 sv, err := r.client.StateVersions.ReadCurrent(ctx, r.workspace.ID) 35 if err != nil { 36 if err == tfe.ErrResourceNotFound { 37 // If no state exists, then return nil. 38 return nil, nil 39 } 40 return nil, fmt.Errorf("failed to retrieve state: %w", err) 41 } 42 43 state, err := r.client.StateVersions.Download(ctx, sv.DownloadURL) 44 if err != nil { 45 return nil, fmt.Errorf("failed to download state: %w", err) 46 } 47 48 // If the state is empty, then return nil. 49 if len(state) == 0 { 50 return nil, nil 51 } 52 53 // Get the MD5 checksum of the state. 54 sum := md5.Sum(state) 55 56 return &remote.Payload{ 57 Data: state, 58 MD5: sum[:], 59 }, nil 60 } 61 62 // Put the remote state. 63 func (r *remoteClient) Put(state []byte) error { 64 ctx := context.Background() 65 66 // Read the raw state into a Durgaform state. 67 stateFile, err := statefile.Read(bytes.NewReader(state)) 68 if err != nil { 69 return fmt.Errorf("failed to read state: %w", err) 70 } 71 72 ov, err := jsonstate.MarshalOutputs(stateFile.State.RootModule().OutputValues) 73 if err != nil { 74 return fmt.Errorf("failed to translate outputs: %w", err) 75 } 76 o, err := json.Marshal(ov) 77 if err != nil { 78 return fmt.Errorf("failed to marshal outputs to json: %w", err) 79 } 80 81 options := tfe.StateVersionCreateOptions{ 82 Lineage: tfe.String(stateFile.Lineage), 83 Serial: tfe.Int64(int64(stateFile.Serial)), 84 MD5: tfe.String(fmt.Sprintf("%x", md5.Sum(state))), 85 State: tfe.String(base64.StdEncoding.EncodeToString(state)), 86 Force: tfe.Bool(r.forcePush), 87 JSONStateOutputs: tfe.String(base64.StdEncoding.EncodeToString(o)), 88 } 89 90 // If we have a run ID, make sure to add it to the options 91 // so the state will be properly associated with the run. 92 if r.runID != "" { 93 options.Run = &tfe.Run{ID: r.runID} 94 } 95 96 // Create the new state. 97 _, err = r.client.StateVersions.Create(ctx, r.workspace.ID, options) 98 if err != nil { 99 r.stateUploadErr = true 100 return fmt.Errorf("failed to upload state: %w", err) 101 } 102 103 return nil 104 } 105 106 // Delete the remote state. 107 func (r *remoteClient) Delete() error { 108 err := r.client.Workspaces.Delete(context.Background(), r.organization, r.workspace.Name) 109 if err != nil && err != tfe.ErrResourceNotFound { 110 return fmt.Errorf("failed to delete workspace %s: %w", r.workspace.Name, err) 111 } 112 113 return nil 114 } 115 116 // EnableForcePush to allow the remote client to overwrite state 117 // by implementing remote.ClientForcePusher 118 func (r *remoteClient) EnableForcePush() { 119 r.forcePush = true 120 } 121 122 // Lock the remote state. 123 func (r *remoteClient) Lock(info *statemgr.LockInfo) (string, error) { 124 ctx := context.Background() 125 126 lockErr := &statemgr.LockError{Info: r.lockInfo} 127 128 // Lock the workspace. 129 _, err := r.client.Workspaces.Lock(ctx, r.workspace.ID, tfe.WorkspaceLockOptions{ 130 Reason: tfe.String("Locked by Durgaform"), 131 }) 132 if err != nil { 133 if err == tfe.ErrWorkspaceLocked { 134 lockErr.Info = info 135 err = fmt.Errorf("%s (lock ID: \"%s/%s\")", err, r.organization, r.workspace.Name) 136 } 137 lockErr.Err = err 138 return "", lockErr 139 } 140 141 r.lockInfo = info 142 143 return r.lockInfo.ID, nil 144 } 145 146 // Unlock the remote state. 147 func (r *remoteClient) Unlock(id string) error { 148 ctx := context.Background() 149 150 // We first check if there was an error while uploading the latest 151 // state. If so, we will not unlock the workspace to prevent any 152 // changes from being applied until the correct state is uploaded. 153 if r.stateUploadErr { 154 return nil 155 } 156 157 lockErr := &statemgr.LockError{Info: r.lockInfo} 158 159 // With lock info this should be treated as a normal unlock. 160 if r.lockInfo != nil { 161 // Verify the expected lock ID. 162 if r.lockInfo.ID != id { 163 lockErr.Err = errors.New("lock ID does not match existing lock") 164 return lockErr 165 } 166 167 // Unlock the workspace. 168 _, err := r.client.Workspaces.Unlock(ctx, r.workspace.ID) 169 if err != nil { 170 lockErr.Err = err 171 return lockErr 172 } 173 174 return nil 175 } 176 177 // Verify the optional force-unlock lock ID. 178 if r.organization+"/"+r.workspace.Name != id { 179 lockErr.Err = fmt.Errorf( 180 "lock ID %q does not match existing lock ID \"%s/%s\"", 181 id, 182 r.organization, 183 r.workspace.Name, 184 ) 185 return lockErr 186 } 187 188 // Force unlock the workspace. 189 _, err := r.client.Workspaces.ForceUnlock(ctx, r.workspace.ID) 190 if err != nil { 191 lockErr.Err = err 192 return lockErr 193 } 194 195 return nil 196 }