Skip to content

Commit 3e3607a

Browse files
authored
fix: scope merge cleanup to touched workspaces (#59)
1 parent d03a324 commit 3e3607a

4 files changed

Lines changed: 125 additions & 30 deletions

File tree

pkg/mocks/mock_tfc_api.go

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/tfc_api/api_client.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type ApiClient interface {
2222
LockUnlockWorkspace(ctx context.Context, workspace string, reason string, tag string, lock bool) error
2323
AddTags(ctx context.Context, workspace string, prefix string, value string) error
2424
RemoveTagsByQuery(ctx context.Context, workspace string, query string) error
25+
RemoveTagsByName(ctx context.Context, workspace string, names []string) error
2526
GetTagsByQuery(ctx context.Context, workspace string, query string) ([]string, error)
2627
}
2728

@@ -201,6 +202,25 @@ func (t *TFCClient) RemoveTagsByQuery(ctx context.Context, workspace string, que
201202
return nil
202203
}
203204

205+
// RemoveTagsByName removes tags from a workspace by exact name. Unlike RemoveTagsByQuery,
206+
// it does not perform substring matching and is safe to use with values that share common
207+
// substrings (e.g. "tfbuddylock-5" vs "tfbuddylock-50"). Empty input is a no-op.
208+
func (t *TFCClient) RemoveTagsByName(ctx context.Context, workspace string, names []string) error {
209+
ctx, span := otel.Tracer("TFC").Start(ctx, "RemoveTagsByName", trace.WithAttributes(
210+
attribute.String("workspace", workspace),
211+
))
212+
defer span.End()
213+
214+
if len(names) == 0 {
215+
return nil
216+
}
217+
tags := make([]*tfe.Tag, 0, len(names))
218+
for _, name := range names {
219+
tags = append(tags, &tfe.Tag{Name: name})
220+
}
221+
return t.Client.Workspaces.RemoveTags(ctx, workspace, tfe.WorkspaceRemoveTagsOptions{Tags: tags})
222+
}
223+
204224
// GetTagsByQuery returns a list of values of tags on a terraform workspace matching the query string.
205225
// It operates on strings reporesenting the value of the tag and internally converts it to and from the upstreams tag struct as needed. Attempting to query tags based on their tag ID will not match the tag.
206226
func (t *TFCClient) GetTagsByQuery(ctx context.Context, workspace string, query string) ([]string, error) {

pkg/tfc_trigger/tfc_trigger.go

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -454,13 +454,16 @@ func (t *TFCTrigger) TriggerCleanupEvent(ctx context.Context) error {
454454
if err != nil {
455455
return fmt.Errorf("could not read MergeRequest data from Gitlab API. %w", err)
456456
}
457-
var wsNames []string
458-
cfg, err := getProjectConfigFile(ctx, t.gl, t)
457+
triggeredWorkspaces, err := t.getTriggeredWorkspacesForRequest(ctx, mr)
459458
if err != nil {
460-
return fmt.Errorf("ignoring cleanup trigger for project, missing .tfbuddy.yaml. %w", err)
459+
return fmt.Errorf("could not determine workspaces for merge cleanup. %w", err)
461460
}
461+
if len(triggeredWorkspaces) == 0 {
462+
return nil
463+
}
464+
var wsNames []string
462465
tag := fmt.Sprintf("%s-%d", tfPrefix, mr.GetInternalID())
463-
for _, cfgWS := range cfg.Workspaces {
466+
for _, cfgWS := range triggeredWorkspaces {
464467
ws, err := t.tfc.GetWorkspaceByName(ctx,
465468
cfgWS.Organization,
466469
cfgWS.Name)
@@ -476,14 +479,27 @@ func (t *TFCTrigger) TriggerCleanupEvent(ctx context.Context) error {
476479
t.handleError(ctx, err, "error getting tags")
477480
continue
478481
}
479-
if len(tags) != 0 {
480-
err = t.tfc.RemoveTagsByQuery(ctx, ws.ID, tag)
481-
if err != nil {
482-
t.handleError(ctx, err, "Error removing locking tag from workspace")
483-
continue
482+
// TFC's tag query is a substring match, so a query for "tfbuddylock-5"
483+
// also returns "tfbuddylock-50" (locking MR #50). Filter for an exact
484+
// match before treating the workspace as one this MR locked.
485+
hasOurTag := false
486+
for _, name := range tags {
487+
if name == tag {
488+
hasOurTag = true
489+
break
484490
}
485-
wsNames = append(wsNames, cfgWS.Name)
486491
}
492+
if !hasOurTag {
493+
continue
494+
}
495+
if err := t.tfc.RemoveTagsByName(ctx, ws.ID, []string{tag}); err != nil {
496+
t.handleError(ctx, err, "Error removing locking tag from workspace")
497+
continue
498+
}
499+
wsNames = append(wsNames, cfgWS.Name)
500+
}
501+
if len(wsNames) == 0 {
502+
return nil
487503
}
488504
_, err = t.gl.CreateMergeRequestDiscussion(ctx, mr.GetInternalID(),
489505
t.GetProjectNameWithNamespace(),

pkg/tfc_trigger/tfc_trigger_test.go

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,44 +1104,87 @@ func TestTFCEvents_UnlockRemovesTags(t *testing.T) {
11041104
}
11051105
}
11061106

1107-
// TestTriggerCleanupEvent_ContinuesOnMissingWorkspace verifies that if one
1108-
// workspace in the .tfbuddy.yaml config no longer exists in TFC, the cleanup
1109-
// loop skips it and still removes the lock tag from the remaining workspaces
1110-
// (rather than crashing with a nil pointer dereference on the missing ws).
1111-
func TestTriggerCleanupEvent_ContinuesOnMissingWorkspace(t *testing.T) {
1107+
// TestTriggerCleanupEvent_ScopesCleanupToTriggeredWorkspaces verifies that
1108+
// merge cleanup only inspects workspaces touched by the MR. Unrelated
1109+
// workspaces in .tfbuddy.yaml must not be looked up or produce MR noise.
1110+
func TestTriggerCleanupEvent_ScopesCleanupToTriggeredWorkspaces(t *testing.T) {
11121111
ws := &tfc_trigger.ProjectConfig{
11131112
Workspaces: []*tfc_trigger.TFCWorkspace{
1114-
{Name: "deleted-ws", Organization: "zapier-test", Mode: "apply-before-merge"},
1115-
{Name: "service-tfbuddy", Organization: "zapier-test", Mode: "apply-before-merge"},
1113+
{Name: "deleted-ws", Organization: "zapier-test", Mode: "apply-before-merge", Dir: "deleted"},
1114+
{Name: "service-tfbuddy", Organization: "zapier-test", Mode: "apply-before-merge", Dir: "service"},
11161115
}}
11171116

11181117
mockCtrl := gomock.NewController(t)
11191118
defer mockCtrl.Finish()
11201119
testSuite := mocks.CreateTestSuite(mockCtrl, mocks.TestOverrides{ProjectConfig: ws}, t)
11211120

1122-
// First workspace is missing from TFC
1123-
testSuite.MockApiClient.EXPECT().GetWorkspaceByName(gomock.Any(), "zapier-test", "deleted-ws").
1124-
Return(nil, fmt.Errorf("resource not found"))
1125-
// Second workspace exists and has the locking tag
1121+
testSuite.MockGitMR.EXPECT().GetInternalID().Return(testSuite.MetaData.MRIID).AnyTimes()
1122+
testSuite.MockGitClient.EXPECT().GetMergeRequest(
1123+
gomock.Any(), testSuite.MetaData.MRIID, testSuite.MetaData.ProjectNameNS,
1124+
).Return(testSuite.MockGitMR, nil)
1125+
testSuite.MockGitClient.EXPECT().GetRepoFile(
1126+
gomock.Any(), testSuite.MetaData.ProjectNameNS, ".tfbuddy.yaml", testSuite.MetaData.SourceBranch,
1127+
).Return(testSuite.MetaData.TFBuddyConfig, nil)
1128+
testSuite.MockGitClient.EXPECT().GetMergeRequestModifiedFiles(
1129+
gomock.Any(), testSuite.MetaData.MRIID, testSuite.MetaData.ProjectNameNS,
1130+
).Return([]string{"service/main.tf"}, nil)
1131+
1132+
// Only the triggered workspace is cleaned up.
11261133
testSuite.MockApiClient.EXPECT().GetWorkspaceByName(gomock.Any(), "zapier-test", "service-tfbuddy").
11271134
Return(&tfe.Workspace{ID: "service-tfbuddy"}, nil)
11281135
testSuite.MockApiClient.EXPECT().GetTagsByQuery(gomock.Any(), "service-tfbuddy", "tfbuddylock-101").
11291136
Return([]string{"tfbuddylock-101"}, nil)
1130-
testSuite.MockApiClient.EXPECT().RemoveTagsByQuery(gomock.Any(), "service-tfbuddy", "tfbuddylock-101").
1137+
testSuite.MockApiClient.EXPECT().RemoveTagsByName(gomock.Any(), "service-tfbuddy", []string{"tfbuddylock-101"}).
11311138
Return(nil)
11321139

1133-
// handleError posts an MR comment when the first workspace fails
1134-
testSuite.MockGitClient.EXPECT().CreateMergeRequestComment(
1135-
gomock.Any(), testSuite.MetaData.MRIID, testSuite.MetaData.ProjectNameNS,
1136-
"Error: error getting workspace: resource not found",
1137-
).Return(nil)
1138-
1139-
// Summary discussion is posted with the successfully cleaned workspace
11401140
testSuite.MockGitClient.EXPECT().CreateMergeRequestDiscussion(
11411141
gomock.Any(), testSuite.MetaData.MRIID, testSuite.MetaData.ProjectNameNS,
11421142
"Released locks for workspaces: service-tfbuddy",
11431143
).Return(testSuite.MockGitDisc, nil)
11441144

1145+
tCfg, _ := tfc_trigger.NewTFCTriggerConfig(&tfc_trigger.TFCTriggerOptions{
1146+
Action: tfc_trigger.PlanAction,
1147+
Branch: testSuite.MetaData.SourceBranch,
1148+
CommitSHA: "abcd12233",
1149+
ProjectNameWithNamespace: testSuite.MetaData.ProjectNameNS,
1150+
MergeRequestIID: testSuite.MetaData.MRIID,
1151+
TriggerSource: tfc_trigger.MergeRequestEventTrigger,
1152+
})
1153+
trigger := tfc_trigger.NewTFCTrigger(testSuite.MockGitClient, testSuite.MockApiClient, testSuite.MockStreamClient, tCfg)
1154+
ctx, _ := otel.Tracer("FAKE").Start(context.Background(), "TEST")
1155+
if err := trigger.TriggerCleanupEvent(ctx); err != nil {
1156+
t.Fatalf("cleanup should not fail when unrelated workspaces exist, got: %v", err)
1157+
}
1158+
}
1159+
1160+
// TestTriggerCleanupEvent_DoesNotReleaseOtherMRsLocks verifies that the
1161+
// cleanup does not steal a tag from another MR whose IID happens to contain
1162+
// the merging MR's IID as a substring. The TFC ListTags `q` parameter does
1163+
// partial matching, so a query for "tfbuddylock-101" also returns
1164+
// "tfbuddylock-1010" — cleanup must filter for an exact match before
1165+
// treating the workspace as one this MR locked.
1166+
func TestTriggerCleanupEvent_DoesNotReleaseOtherMRsLocks(t *testing.T) {
1167+
ws := &tfc_trigger.ProjectConfig{
1168+
Workspaces: []*tfc_trigger.TFCWorkspace{
1169+
{Name: "shared-ws", Organization: "zapier-test", Mode: "apply-before-merge"},
1170+
}}
1171+
1172+
mockCtrl := gomock.NewController(t)
1173+
defer mockCtrl.Finish()
1174+
testSuite := mocks.CreateTestSuite(mockCtrl, mocks.TestOverrides{ProjectConfig: ws}, t)
1175+
1176+
// MR #101 is merging. The workspace is locked by MR #1010 (different MR
1177+
// whose IID contains "101" as a substring). TFC's partial-match query
1178+
// returns the foreign tag.
1179+
testSuite.MockApiClient.EXPECT().GetWorkspaceByName(gomock.Any(), "zapier-test", "shared-ws").
1180+
Return(&tfe.Workspace{ID: "shared-ws"}, nil)
1181+
testSuite.MockApiClient.EXPECT().GetTagsByQuery(gomock.Any(), "shared-ws", "tfbuddylock-101").
1182+
Return([]string{"tfbuddylock-1010"}, nil)
1183+
1184+
// Cleanup MUST NOT remove the foreign tag and MUST NOT include the
1185+
// workspace in the summary. (No RemoveTagsByName / RemoveTagsByQuery
1186+
// call is expected; gomock fails the test if one is made.)
1187+
11451188
testSuite.InitTestSuite()
11461189

11471190
tCfg, _ := tfc_trigger.NewTFCTriggerConfig(&tfc_trigger.TFCTriggerOptions{
@@ -1155,7 +1198,7 @@ func TestTriggerCleanupEvent_ContinuesOnMissingWorkspace(t *testing.T) {
11551198
trigger := tfc_trigger.NewTFCTrigger(testSuite.MockGitClient, testSuite.MockApiClient, testSuite.MockStreamClient, tCfg)
11561199
ctx, _ := otel.Tracer("FAKE").Start(context.Background(), "TEST")
11571200
if err := trigger.TriggerCleanupEvent(ctx); err != nil {
1158-
t.Fatalf("cleanup should not fail when a workspace is missing, got: %v", err)
1201+
t.Fatalf("cleanup should not fail, got: %v", err)
11591202
}
11601203
}
11611204

0 commit comments

Comments
 (0)