Skip to content

Commit 8f0a18d

Browse files
authored
fix: handle aws integration attach/detach on stack update (#47)
1 parent accca15 commit 8f0a18d

File tree

3 files changed

+221
-4
lines changed

3 files changed

+221
-4
lines changed

internal/logging/keys.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ const (
66
RunId = "run.id"
77
RunState = "run.state"
88

9-
StackName = "stack.name"
10-
StackId = "stack.id"
9+
StackName = "stack.name"
10+
StackId = "stack.id"
11+
StackAWSIntegrationId = "stack.aws_integration_id"
1112

1213
SpaceId = "space.id"
1314
SpaceName = "space.name"

internal/spacelift/repository/stack.go

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ package repository
22

33
import (
44
"context"
5+
"slices"
56

67
"github.com/pkg/errors"
78
"github.com/shurcooL/graphql"
89
"sigs.k8s.io/controller-runtime/pkg/client"
10+
"sigs.k8s.io/controller-runtime/pkg/log"
911

1012
"github.com/spacelift-io/spacelift-operator/api/v1beta1"
13+
"github.com/spacelift-io/spacelift-operator/internal/logging"
1114
spaceliftclient "github.com/spacelift-io/spacelift-operator/internal/spacelift/client"
1215
"github.com/spacelift-io/spacelift-operator/internal/spacelift/models"
1316
"github.com/spacelift-io/spacelift-operator/internal/spacelift/repository/slug"
@@ -102,17 +105,49 @@ func (r *stackRepository) attachAWSIntegration(ctx context.Context, stack *v1bet
102105
"read": graphql.Boolean(stack.Spec.AWSIntegration.Read),
103106
"write": graphql.Boolean(stack.Spec.AWSIntegration.Write),
104107
}
108+
105109
if err := c.Mutate(ctx, &mutation, awsIntegrationAttachVars); err != nil {
106110
return err
107111
}
108112

109113
return nil
110114
}
111115

116+
type awsIntegrationDetachMutation struct {
117+
AWSIntegrationDetach struct {
118+
ID string `graphql:"id"`
119+
} `graphql:"awsIntegrationDetach(id: $id)"`
120+
}
121+
122+
func (r *stackRepository) detachAWSIntegration(ctx context.Context, stack *v1beta1.Stack, id string) error {
123+
c, err := spaceliftclient.DefaultClient(ctx, r.client, stack.Namespace)
124+
if err != nil {
125+
return errors.Wrap(err, "unable to fetch spacelift client while detaching AWS integration")
126+
}
127+
var mutation awsIntegrationDetachMutation
128+
vars := map[string]any{
129+
"id": graphql.ID(id),
130+
}
131+
132+
if err := c.Mutate(ctx, &mutation, vars); err != nil {
133+
return err
134+
}
135+
136+
return nil
137+
}
138+
139+
type stackUpdateMutationAWSIntegration struct {
140+
ID string `graphql:"id"`
141+
IntegrationID string `graphql:"integrationId"`
142+
Read bool `graphql:"read"`
143+
Write bool `graphql:"write"`
144+
}
145+
112146
type stackUpdateMutation struct {
113147
StackUpdate struct {
114-
ID string `graphql:"id"`
115-
State string `graphql:"state"`
148+
ID string `graphql:"id"`
149+
State string `graphql:"state"`
150+
AttachedAWSIntegrations []stackUpdateMutationAWSIntegration `graphql:"attachedAwsIntegrations"`
116151
} `graphql:"stackUpdate(id: $id, input: $input)"`
117152
}
118153

@@ -134,6 +169,39 @@ func (r *stackRepository) Update(ctx context.Context, stack *v1beta1.Stack) (*mo
134169
return nil, errors.Wrap(err, "unable to create stack")
135170
}
136171

172+
logger := log.FromContext(ctx).WithValues(logging.StackId, mutation.StackUpdate.ID)
173+
174+
// First we check if there are any integrations to detach
175+
// Any existing attachment that does not match spec.AWSIntegration will be detached.
176+
attachedIntegrations := mutation.StackUpdate.AttachedAWSIntegrations
177+
for i, integration := range attachedIntegrations {
178+
if stack.Spec.AWSIntegration == nil ||
179+
stack.Spec.AWSIntegration.Id != integration.IntegrationID ||
180+
(stack.Spec.AWSIntegration.Id == integration.IntegrationID &&
181+
(stack.Spec.AWSIntegration.Read != integration.Read ||
182+
stack.Spec.AWSIntegration.Write != integration.Write)) {
183+
if err := r.detachAWSIntegration(ctx, stack, integration.ID); err != nil {
184+
return nil, errors.Wrap(err, "unable to detach AWS integration from stack")
185+
}
186+
logger.Info("Detached AWS integration from stack", logging.StackAWSIntegrationId, integration.IntegrationID)
187+
// If we are detaching an integration, we also reflect this change to the mutation array.
188+
// This allows an integration to be detached and reattached in a row if a read or write attribute has been changed.
189+
mutation.StackUpdate.AttachedAWSIntegrations = append(mutation.StackUpdate.AttachedAWSIntegrations[:i], mutation.StackUpdate.AttachedAWSIntegrations[i+1:]...)
190+
}
191+
}
192+
193+
if stack.Spec.AWSIntegration != nil {
194+
shouldAttachInteration := !slices.ContainsFunc(mutation.StackUpdate.AttachedAWSIntegrations, func(s stackUpdateMutationAWSIntegration) bool {
195+
return s.IntegrationID == stack.Spec.AWSIntegration.Id
196+
})
197+
if shouldAttachInteration {
198+
if err := r.attachAWSIntegration(ctx, stack); err != nil {
199+
return nil, errors.Wrap(err, "unable to attach AWS integration to stack")
200+
}
201+
logger.Info("Attached AWS integration to stack", logging.StackAWSIntegrationId, stack.Spec.AWSIntegration.Id)
202+
}
203+
}
204+
137205
// TODO(michalg): URL can never change here, should we still generate it for k8s api?
138206
url := c.URL("/stack/%s", mutation.StackUpdate.ID)
139207
return &models.Stack{

internal/spacelift/repository/stack_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,151 @@ func Test_stackRepository_Update(t *testing.T) {
191191
assert.Equal(t, "stack-name", actualVars["id"])
192192
assert.IsType(t, structs.StackInput{}, actualVars["input"])
193193
}
194+
195+
func Test_stackRepository_Update_WithAWSIntegration(t *testing.T) {
196+
originalClient := spaceliftclient.DefaultClient
197+
defer func() { spaceliftclient.DefaultClient = originalClient }()
198+
fakeClient := mocks.NewClient(t)
199+
spaceliftclient.DefaultClient = func(_ context.Context, _ client.Client, _ string) (spaceliftclient.Client, error) {
200+
return fakeClient, nil
201+
}
202+
203+
fakeStackId := "stack-id"
204+
var actualVars map[string]any
205+
fakeClient.EXPECT().
206+
Mutate(mock.Anything, mock.AnythingOfType("*repository.stackUpdateMutation"), mock.Anything).
207+
Run(func(_ context.Context, mutation any, vars map[string]interface{}, _ ...graphql.RequestOption) {
208+
actualVars = vars
209+
updateMutation := mutation.(*stackUpdateMutation)
210+
updateMutation.StackUpdate.ID = fakeStackId
211+
updateMutation.StackUpdate.AttachedAWSIntegrations = []stackUpdateMutationAWSIntegration{
212+
{
213+
ID: "attachment-id",
214+
IntegrationID: "another-integration-id",
215+
Read: true,
216+
Write: true,
217+
},
218+
}
219+
}).Return(nil)
220+
fakeClient.EXPECT().URL("/stack/%s", fakeStackId).Return("")
221+
222+
var detachVars map[string]any
223+
fakeClient.EXPECT().Mutate(mock.Anything, mock.AnythingOfType("*repository.awsIntegrationDetachMutation"), mock.Anything).
224+
Run(func(_ context.Context, _ any, vars map[string]any, _ ...graphql.RequestOption) {
225+
detachVars = vars
226+
}).
227+
Return(nil)
228+
var attachVars map[string]any
229+
fakeClient.EXPECT().Mutate(mock.Anything, mock.AnythingOfType("*repository.awsIntegrationAttachMutation"), mock.Anything).
230+
Run(func(_ context.Context, _ any, vars map[string]any, _ ...graphql.RequestOption) {
231+
attachVars = vars
232+
}).
233+
Return(nil)
234+
235+
repo := NewStackRepository(nil)
236+
237+
fakeStack := &v1beta1.Stack{
238+
ObjectMeta: v1.ObjectMeta{
239+
Name: "stack-name",
240+
},
241+
Spec: v1beta1.StackSpec{
242+
SpaceId: utils.AddressOf("space-id"),
243+
AWSIntegration: &v1beta1.AWSIntegration{
244+
Id: "integration-id",
245+
Read: true,
246+
Write: true,
247+
},
248+
},
249+
Status: v1beta1.StackStatus{
250+
Id: fakeStackId,
251+
},
252+
}
253+
_, err := repo.Update(context.Background(), fakeStack)
254+
require.NoError(t, err)
255+
assert.Equal(t, "stack-name", actualVars["id"])
256+
assert.IsType(t, structs.StackInput{}, actualVars["input"])
257+
assert.Equal(t, map[string]any{
258+
"id": graphql.ID("attachment-id"),
259+
}, detachVars)
260+
assert.Equal(t, map[string]any{
261+
"id": "integration-id",
262+
"stack": fakeStackId,
263+
"read": graphql.Boolean(true),
264+
"write": graphql.Boolean(true),
265+
}, attachVars)
266+
}
267+
268+
func Test_stackRepository_Update_WithAWSIntegration_UpdateExistingIntegration(t *testing.T) {
269+
originalClient := spaceliftclient.DefaultClient
270+
defer func() { spaceliftclient.DefaultClient = originalClient }()
271+
fakeClient := mocks.NewClient(t)
272+
spaceliftclient.DefaultClient = func(_ context.Context, _ client.Client, _ string) (spaceliftclient.Client, error) {
273+
return fakeClient, nil
274+
}
275+
276+
fakeStackId := "stack-id"
277+
var actualVars map[string]any
278+
fakeClient.EXPECT().
279+
Mutate(mock.Anything, mock.AnythingOfType("*repository.stackUpdateMutation"), mock.Anything).
280+
Run(func(_ context.Context, mutation any, vars map[string]interface{}, _ ...graphql.RequestOption) {
281+
actualVars = vars
282+
updateMutation := mutation.(*stackUpdateMutation)
283+
updateMutation.StackUpdate.ID = fakeStackId
284+
updateMutation.StackUpdate.AttachedAWSIntegrations = []stackUpdateMutationAWSIntegration{
285+
{
286+
ID: "attachment-id",
287+
IntegrationID: "integration-id",
288+
Read: true,
289+
Write: true,
290+
},
291+
}
292+
}).Return(nil)
293+
fakeClient.EXPECT().URL("/stack/%s", fakeStackId).Return("")
294+
295+
var detachVars map[string]any
296+
fakeClient.EXPECT().Mutate(mock.Anything, mock.AnythingOfType("*repository.awsIntegrationDetachMutation"), mock.Anything).
297+
Run(func(_ context.Context, _ any, vars map[string]any, _ ...graphql.RequestOption) {
298+
detachVars = vars
299+
}).
300+
Return(nil)
301+
var attachVars map[string]any
302+
fakeClient.EXPECT().Mutate(mock.Anything, mock.AnythingOfType("*repository.awsIntegrationAttachMutation"), mock.Anything).
303+
Run(func(_ context.Context, _ any, vars map[string]any, _ ...graphql.RequestOption) {
304+
attachVars = vars
305+
}).
306+
Return(nil)
307+
308+
repo := NewStackRepository(nil)
309+
310+
fakeStack := &v1beta1.Stack{
311+
ObjectMeta: v1.ObjectMeta{
312+
Name: "stack-name",
313+
},
314+
Spec: v1beta1.StackSpec{
315+
SpaceId: utils.AddressOf("space-id"),
316+
// Because Write has been changed from true to false
317+
// The integration should be detached and reattached
318+
AWSIntegration: &v1beta1.AWSIntegration{
319+
Id: "integration-id",
320+
Read: true,
321+
Write: false,
322+
},
323+
},
324+
Status: v1beta1.StackStatus{
325+
Id: fakeStackId,
326+
},
327+
}
328+
_, err := repo.Update(context.Background(), fakeStack)
329+
require.NoError(t, err)
330+
assert.Equal(t, "stack-name", actualVars["id"])
331+
assert.IsType(t, structs.StackInput{}, actualVars["input"])
332+
assert.Equal(t, map[string]any{
333+
"id": graphql.ID("attachment-id"),
334+
}, detachVars)
335+
assert.Equal(t, map[string]any{
336+
"id": "integration-id",
337+
"stack": fakeStackId,
338+
"read": graphql.Boolean(true),
339+
"write": graphql.Boolean(false),
340+
}, attachVars)
341+
}

0 commit comments

Comments
 (0)