Skip to content

Commit 31a5925

Browse files
committed
fix(configure): address CodeRabbit findings on SDK migration
Resolve the 7 Major CodeRabbit findings on PR #1279 at root cause. 1. azure sanity: guard sub.State before dereferencing it. State is an optional pointer; build azureSubscriptionInfo with the same nil-check pattern used for the other optional fields, so an omitted state cannot panic. 2. azure wizard: bind to the Azure CLI session explicitly. The wizard now builds credentials via a shared newAzureWizardCredential() backed by azidentity.NewAzureCLICredential, instead of DefaultAzureCredential whose chain prioritizes environment / workload / managed identity and could pick a different principal than the operator's "az login". The CI sanity test keeps DefaultAzureCredential (it must resolve the CI service-principal env vars). listAzureSubscriptions, resolveAzureTenantID and the SP provisioner all use the CLI credential. 3. gcp: bound every SDK helper with a 60s timeout. listGCPProjects, createGCPServiceAccount, grantGCPIAMRole and createGCPServiceAccountKey inherited context.Background() and could hang; each now derives a context.WithTimeout(ctx, gcpSDKCallTimeout). 4. gcp: preserve conditional IAM bindings. GetIamPolicy now requests RequestedPolicyVersion 3 and the policy is written back at Version 3, so a read-modify-write no longer silently drops condition bindings. Binding mutation is extracted into addMemberToPolicyBinding to keep complexity in check. 5. gcp: prevent orphaned service-account keys. createGCPServiceAccountKey reserves the destination file with O_EXCL before minting the remote key, and deletes the newly created remote key if decode/write fails, so a local failure cannot leave an active unused credential behind. 6. gcp: return an empty key path when no key was written. gcpStepCreateKey returns the key file path only after a successful write; on skip or an unknown choice it returns "" so getGCPCredentialsFilePath prompts for an existing credentials file instead of loading a missing one. 7. azure SP: roll back partial creation. createAzureServicePrincipal now deletes the just-created application (cascading to its password credential and derived service principal) if any later step -- add-password, SP create, role resolve, or role assign -- fails. A new DeleteApplication provisioner method backs this; if the compensating delete also fails the error names the orphaned app so the operator can remove it by hand. Unit tests assert rollback fires for each post-create failure, not on success, and that a failed rollback is surfaced.
1 parent 3e3df37 commit 31a5925

5 files changed

Lines changed: 221 additions & 65 deletions

File tree

ci_cd_sanity_tests/pkg/sanity/azure/azure.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,10 @@ func runAccountShowCheck(ctx context.Context, subscriptionID string, cred azcore
260260
}
261261

262262
sub := resp.Subscription
263-
info := azureSubscriptionInfo{State: string(*sub.State)}
263+
info := azureSubscriptionInfo{}
264+
if sub.State != nil {
265+
info.State = string(*sub.State)
266+
}
264267
if sub.SubscriptionID != nil {
265268
info.ID = *sub.SubscriptionID
266269
}

cmd/configure_azure.go

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"time"
1616

17+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
1718
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
1819
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
1920
"github.com/aws/aws-sdk-go-v2/aws"
@@ -258,14 +259,29 @@ func promptForAzureCredentialFields(reader *bufio.Reader, creds *AzureCredential
258259
return nil
259260
}
260261

262+
// newAzureWizardCredential builds the credential used by the interactive Azure
263+
// setup wizard. It binds explicitly to the Azure CLI session (the "az login"
264+
// the operator runs in Step 1) via AzureCLICredential rather than
265+
// DefaultAzureCredential, whose chain prioritizes environment / workload /
266+
// managed-identity credentials and could otherwise resolve to a different
267+
// principal than the one the operator just signed in as.
268+
func newAzureWizardCredential() (azcore.TokenCredential, error) {
269+
cred, err := azidentity.NewAzureCLICredential(nil)
270+
if err != nil {
271+
return nil, fmt.Errorf("failed to build Azure CLI credential: %w\n"+
272+
"Ensure you are authenticated: run 'az login' (Step 1) before continuing", err)
273+
}
274+
return cred, nil
275+
}
276+
261277
// listAzureSubscriptions retrieves the operator's subscriptions via the ARM
262278
// Subscriptions SDK and prints them in a table matching "az account list"
263-
// output. DefaultAzureCredential picks up the "az login" session via its
264-
// AzureCLICredential leg so the operator's active session is reused.
279+
// output. It uses the Azure CLI credential so the listing matches the
280+
// operator's active "az login" session.
265281
func listAzureSubscriptions(ctx context.Context) error {
266-
cred, err := azidentity.NewDefaultAzureCredential(nil)
282+
cred, err := newAzureWizardCredential()
267283
if err != nil {
268-
return fmt.Errorf("failed to build credential: %w", err)
284+
return err
269285
}
270286

271287
client, err := armsubscriptions.NewClient(cred, nil)
@@ -308,10 +324,9 @@ func listAzureSubscriptions(ctx context.Context) error {
308324
// SDK on behalf of a human operator who does not yet have a credential. This
309325
// is the only CLI call retained in this wizard.
310326
//
311-
// Step 2 (list subscriptions): performed via the ARM Subscriptions SDK.
312-
// DefaultAzureCredential automatically picks up the session that "az login"
313-
// just established (via its AzureCLICredential leg). Fails loud if the SDK
314-
// cannot authenticate (no CLI fallback).
327+
// Step 2 (list subscriptions): performed via the ARM Subscriptions SDK using
328+
// the Azure CLI credential, which reuses the session that "az login" just
329+
// established. Fails loud if the SDK cannot authenticate (no CLI fallback).
315330
//
316331
// Step 3 (create service principal): performed via the Microsoft Graph SDK
317332
// (application + service principal + password credential) and armauthorization
@@ -342,13 +357,13 @@ func azureStepLogin(reader *bufio.Reader) error {
342357

343358
// azureStepListSubscriptions lists subscriptions via SDK and prompts the
344359
// operator to enter their subscription ID. It fails loud (no CLI fallback): if
345-
// DefaultAzureCredential cannot authenticate, it returns an error instructing
346-
// the operator to run "az login" first.
360+
// the Azure CLI credential cannot authenticate, it returns an error
361+
// instructing the operator to run "az login" first.
347362
func azureStepListSubscriptions(ctx context.Context, reader *bufio.Reader) (string, error) {
348363
fmt.Println()
349364
fmt.Println("Step 2: Get Subscription ID")
350365
fmt.Println("---------------------------")
351-
fmt.Println("Listing your Azure subscriptions via SDK (DefaultAzureCredential)...")
366+
fmt.Println("Listing your Azure subscriptions via SDK (Azure CLI credential)...")
352367
fmt.Println()
353368

354369
listCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
@@ -374,12 +389,11 @@ func azureStepListSubscriptions(ctx context.Context, reader *bufio.Reader) (stri
374389
}
375390

376391
// resolveAzureTenantID looks up the tenant ID for the given subscription via
377-
// the ARM Subscriptions SDK. DefaultAzureCredential reuses the "az login"
378-
// session.
392+
// the ARM Subscriptions SDK, using the Azure CLI ("az login") credential.
379393
func resolveAzureTenantID(ctx context.Context, subscriptionID string) (string, error) {
380-
cred, err := azidentity.NewDefaultAzureCredential(nil)
394+
cred, err := newAzureWizardCredential()
381395
if err != nil {
382-
return "", fmt.Errorf("failed to build credential: %w", err)
396+
return "", err
383397
}
384398
client, err := armsubscriptions.NewClient(cred, nil)
385399
if err != nil {

cmd/configure_azure_sp.go

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ package main
33
import (
44
"context"
55
"fmt"
6+
"time"
67

7-
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
88
armauthorization "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2"
99
"github.com/google/uuid"
1010
msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go"
@@ -52,6 +52,11 @@ type azureSPProvisioner interface {
5252
// AssignRole creates a role assignment binding principalID to
5353
// roleDefinitionID at the given scope.
5454
AssignRole(ctx context.Context, scope, principalID, roleDefinitionID string) error
55+
// DeleteApplication deletes the application registration identified by
56+
// objectID. Deleting the application also removes its password credentials
57+
// and the service principal created from it, so it serves as the
58+
// compensating action for a partially completed creation flow.
59+
DeleteApplication(ctx context.Context, objectID string) error
5560
}
5661

5762
// createAzureServicePrincipal performs the full create-for-rbac equivalent:
@@ -73,23 +78,38 @@ func createAzureServicePrincipal(ctx context.Context, p azureSPProvisioner, subs
7378
return azureSPResult{}, fmt.Errorf("failed to create application registration: %w", err)
7479
}
7580

81+
// rollback deletes the just-created application (which cascades to its
82+
// password credentials and the derived service principal) so a failure in
83+
// a later step does not orphan Azure AD objects. The cleanup runs on a
84+
// fresh context in case the parent is already canceled/expired. If the
85+
// cleanup itself fails, the operator is told exactly what to delete by hand.
86+
rollback := func(cause error) (azureSPResult, error) {
87+
cleanupCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
88+
defer cancel()
89+
if delErr := p.DeleteApplication(cleanupCtx, objectID); delErr != nil {
90+
return azureSPResult{}, fmt.Errorf("%w; additionally failed to roll back application %q (appId %s) -- delete it manually: %w",
91+
cause, objectID, appID, delErr)
92+
}
93+
return azureSPResult{}, fmt.Errorf("%w (rolled back: deleted application %q)", cause, objectID)
94+
}
95+
7696
secret, err := p.AddPassword(ctx, objectID)
7797
if err != nil {
78-
return azureSPResult{}, fmt.Errorf("failed to add password credential: %w", err)
98+
return rollback(fmt.Errorf("failed to add password credential: %w", err))
7999
}
80100

81101
principalID, err := p.CreateServicePrincipal(ctx, appID)
82102
if err != nil {
83-
return azureSPResult{}, fmt.Errorf("failed to create service principal: %w", err)
103+
return rollback(fmt.Errorf("failed to create service principal: %w", err))
84104
}
85105

86106
roleDefID, err := p.ResolveRoleDefinitionID(ctx, scope, azureSPRoleName)
87107
if err != nil {
88-
return azureSPResult{}, fmt.Errorf("failed to resolve %q role definition: %w", azureSPRoleName, err)
108+
return rollback(fmt.Errorf("failed to resolve %q role definition: %w", azureSPRoleName, err))
89109
}
90110

91111
if err := p.AssignRole(ctx, scope, principalID, roleDefID); err != nil {
92-
return azureSPResult{}, fmt.Errorf("failed to assign %q role at %s: %w", azureSPRoleName, scope, err)
112+
return rollback(fmt.Errorf("failed to assign %q role at %s: %w", azureSPRoleName, scope, err))
93113
}
94114

95115
return azureSPResult{
@@ -108,15 +128,15 @@ type graphSPProvisioner struct {
108128
roleAsgn *armauthorization.RoleAssignmentsClient
109129
}
110130

111-
// newGraphSPProvisioner builds a graphSPProvisioner authenticated with
112-
// DefaultAzureCredential. The credential chain includes AzureCLICredential, so
113-
// the session established by "az login" is reused (no separate auth step).
131+
// newGraphSPProvisioner builds a graphSPProvisioner authenticated with the
132+
// Azure CLI credential, so the session established by "az login" (wizard
133+
// Step 1) is reused -- matching the principal used by the rest of the wizard.
114134
// subscriptionID seeds the RoleAssignmentsClient; the actual scope is passed
115135
// per-call to its Create method.
116136
func newGraphSPProvisioner(subscriptionID string) (*graphSPProvisioner, error) {
117-
cred, err := azidentity.NewDefaultAzureCredential(nil)
137+
cred, err := newAzureWizardCredential()
118138
if err != nil {
119-
return nil, fmt.Errorf("failed to build DefaultAzureCredential: %w", err)
139+
return nil, err
120140
}
121141

122142
graph, err := msgraphsdk.NewGraphServiceClientWithCredentials(
@@ -187,6 +207,10 @@ func (g *graphSPProvisioner) CreateServicePrincipal(ctx context.Context, appID s
187207
return *principalID, nil
188208
}
189209

210+
func (g *graphSPProvisioner) DeleteApplication(ctx context.Context, objectID string) error {
211+
return g.graph.Applications().ByApplicationId(objectID).Delete(ctx, nil)
212+
}
213+
190214
func (g *graphSPProvisioner) ResolveRoleDefinitionID(ctx context.Context, scope, roleName string) (string, error) {
191215
filter := fmt.Sprintf("roleName eq '%s'", roleName)
192216
pager := g.roleDefs.NewListPager(scope, &armauthorization.RoleDefinitionsClientListOptions{

cmd/configure_azure_sp_test.go

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import (
1313
// createAzureServicePrincipal requests the correct app name, role and scope,
1414
// and surfaces the generated secret.
1515
type mockSPProvisioner struct {
16+
// optional injected errors
17+
createAppErr error
18+
addPwErr error
19+
createSPErr error
20+
resolveErr error
21+
assignErr error
22+
deleteAppErr error
23+
1624
// captured inputs
1725
createAppName string
1826
addPasswordObjID string
@@ -22,6 +30,7 @@ type mockSPProvisioner struct {
2230
assignScope string
2331
assignPrincipalID string
2432
assignRoleDefID string
33+
deleteAppObjID string
2534

2635
// canned outputs
2736
appObjectID string
@@ -30,16 +39,10 @@ type mockSPProvisioner struct {
3039
principalID string
3140
roleDefID string
3241

33-
// optional injected errors
34-
createAppErr error
35-
addPwErr error
36-
createSPErr error
37-
resolveErr error
38-
assignErr error
39-
4042
// call flags
4143
resolveRoleCalled bool
4244
assignRoleCalled bool
45+
deleteAppCalled bool
4346
}
4447

4548
func (m *mockSPProvisioner) CreateApplication(_ context.Context, displayName string) (string, string, error) {
@@ -84,6 +87,12 @@ func (m *mockSPProvisioner) AssignRole(_ context.Context, scope, principalID, ro
8487
return m.assignErr
8588
}
8689

90+
func (m *mockSPProvisioner) DeleteApplication(_ context.Context, objectID string) error {
91+
m.deleteAppCalled = true
92+
m.deleteAppObjID = objectID
93+
return m.deleteAppErr
94+
}
95+
8796
const (
8897
testSubID = "11111111-2222-3333-4444-555555555555"
8998
testTenantID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
@@ -127,6 +136,9 @@ func TestCreateAzureServicePrincipal_Success(t *testing.T) {
127136
assert.Equal(t, "app-client-id", result.AppID)
128137
assert.Equal(t, "super-secret-password", result.ClientSecret)
129138
assert.Equal(t, testTenantID, result.TenantID)
139+
140+
// No rollback on success.
141+
assert.False(t, m.deleteAppCalled, "DeleteApplication must not be called on success")
130142
}
131143

132144
func TestCreateAzureServicePrincipal_ErrorPropagation(t *testing.T) {
@@ -136,38 +148,44 @@ func TestCreateAzureServicePrincipal_ErrorPropagation(t *testing.T) {
136148
wantErrPart string
137149
wantNoAssign bool
138150
wantNoResolve bool
151+
wantRollback bool // DeleteApplication should be called to clean up
139152
}{
140153
{
141154
name: "create application fails",
142155
setup: func(m *mockSPProvisioner) { m.createAppErr = errors.New("graph 403") },
143156
wantErrPart: "failed to create application registration",
144157
wantNoAssign: true,
145158
wantNoResolve: true,
159+
wantRollback: false, // nothing was created, nothing to roll back
146160
},
147161
{
148162
name: "add password fails",
149163
setup: func(m *mockSPProvisioner) { m.addPwErr = errors.New("graph addPassword 400") },
150164
wantErrPart: "failed to add password credential",
151165
wantNoAssign: true,
152166
wantNoResolve: true,
167+
wantRollback: true,
153168
},
154169
{
155170
name: "create service principal fails",
156171
setup: func(m *mockSPProvisioner) { m.createSPErr = errors.New("graph sp 409") },
157172
wantErrPart: "failed to create service principal",
158173
wantNoAssign: true,
159174
wantNoResolve: true,
175+
wantRollback: true,
160176
},
161177
{
162178
name: "resolve role fails",
163179
setup: func(m *mockSPProvisioner) { m.resolveErr = errors.New("role not found") },
164180
wantErrPart: "failed to resolve",
165181
wantNoAssign: true,
182+
wantRollback: true,
166183
},
167184
{
168-
name: "assign role fails",
169-
setup: func(m *mockSPProvisioner) { m.assignErr = errors.New("rbac 403") },
170-
wantErrPart: "failed to assign",
185+
name: "assign role fails",
186+
setup: func(m *mockSPProvisioner) { m.assignErr = errors.New("rbac 403") },
187+
wantErrPart: "failed to assign",
188+
wantRollback: true,
171189
},
172190
}
173191

@@ -192,10 +210,38 @@ func TestCreateAzureServicePrincipal_ErrorPropagation(t *testing.T) {
192210
if tt.wantNoAssign {
193211
assert.False(t, m.assignRoleCalled, "AssignRole should not have been called")
194212
}
213+
assert.Equal(t, tt.wantRollback, m.deleteAppCalled,
214+
"DeleteApplication call expectation mismatch")
215+
if tt.wantRollback {
216+
assert.Equal(t, "app-object-id", m.deleteAppObjID,
217+
"rollback should delete the created application by object ID")
218+
}
195219
})
196220
}
197221
}
198222

223+
// TestCreateAzureServicePrincipal_RollbackFailureSurfaced verifies that when
224+
// the compensating delete also fails, the error names the orphaned application
225+
// so the operator can delete it manually.
226+
func TestCreateAzureServicePrincipal_RollbackFailureSurfaced(t *testing.T) {
227+
m := &mockSPProvisioner{
228+
appObjectID: "app-object-id",
229+
appID: "app-client-id",
230+
secret: "secret",
231+
principalID: "sp-principal-id",
232+
roleDefID: "role-def-id",
233+
assignErr: errors.New("rbac 403"),
234+
deleteAppErr: errors.New("delete 500"),
235+
}
236+
237+
_, err := createAzureServicePrincipal(context.Background(), m, testSubID, testTenantID)
238+
require.Error(t, err)
239+
assert.True(t, m.deleteAppCalled)
240+
assert.Contains(t, err.Error(), "failed to assign")
241+
assert.Contains(t, err.Error(), "failed to roll back")
242+
assert.Contains(t, err.Error(), "app-object-id")
243+
}
244+
199245
func TestCreateAzureServicePrincipal_ScopeFormat(t *testing.T) {
200246
m := &mockSPProvisioner{
201247
appObjectID: "o", appID: "a", secret: "s", principalID: "p", roleDefID: "r",

0 commit comments

Comments
 (0)