Skip to content

Commit aaf8f0d

Browse files
committed
feat(gcp): add oidc-storage-method flag for workload identity providers
Introduce a new --oidc-storage-method flag that allows users to choose how OIDC JWK files are stored when provisioning GCP workload identity providers. Two methods are now supported: - "public-bucket" (default): Creates a public GCS bucket to host OIDC configuration and JWK files - "pool-jwk-file": Attaches the JWK directly to the workload identity pool provider without creating a bucket The pool-jwk-file method is useful for environments with strict bucket policies or when a bucketless configuration is preferred. The implementation includes create and update operations for identity providers with embedded JWKs. Assisted-by: Claude Sonnet 4.6, gemini-3.1-pro-preview
1 parent 2ed316a commit aaf8f0d

6 files changed

Lines changed: 263 additions & 20 deletions

File tree

pkg/cmd/provisioning/gcp/create_all.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package gcp
22

33
import (
44
"context"
5+
"fmt"
56
"log"
67
"os"
78
"path"
@@ -46,7 +47,7 @@ func createAllCmd(cmd *cobra.Command, args []string) {
4647
log.Fatalf("Failed to create workload identity pool: %s", err)
4748
}
4849

49-
if err = createWorkloadIdentityProvider(ctx, gcpClient, CreateAllOpts.Name, CreateAllOpts.Region, CreateAllOpts.Project, CreateAllOpts.Name, publicKeyPath, CreateAllOpts.TargetDir, false); err != nil {
50+
if err = createWorkloadIdentityProvider(ctx, gcpClient, CreateAllOpts.Name, CreateAllOpts.Region, CreateAllOpts.Project, CreateAllOpts.Name, publicKeyPath, CreateAllOpts.TargetDir, CreateAllOpts.OidcStorageMethod, false); err != nil {
5051
log.Fatalf("Failed to create workload identity provider: %s", err)
5152
}
5253

@@ -63,6 +64,8 @@ func validationForCreateAllCmd(cmd *cobra.Command, args []string) {
6364
log.Fatalf("Name can be at most 32 characters long")
6465
}
6566

67+
validateOidcStorageMethod(CreateAllOpts.OidcStorageMethod)
68+
6669
if CreateAllOpts.TargetDir == "" {
6770
pwd, err := os.Getwd()
6871
if err != nil {
@@ -117,6 +120,7 @@ func NewCreateAllCmd() *cobra.Command {
117120
createAllCmd.PersistentFlags().StringVar(&CreateAllOpts.TargetDir, "output-dir", "", "Directory to place generated files (defaults to current directory)")
118121
createAllCmd.PersistentFlags().BoolVar(&CreateAllOpts.EnableTechPreview, "enable-tech-preview", false, "Opt into processing CredentialsRequests marked as tech-preview")
119122
createAllCmd.PersistentFlags().StringVar(&CreateAllOpts.PublicKeyPath, "public-key-file", "", "Path to public ServiceAccount signing key")
123+
createAllCmd.PersistentFlags().StringVar(&CreateAllOpts.OidcStorageMethod, "oidc-storage-method", OidcStorageMethodPublicBucket, fmt.Sprintf("Method for storing OIDC JWK files. %q (default) creates a public GCS bucket; %q attaches the JWK directly to the workload identity pool provider without creating a bucket", OidcStorageMethodPublicBucket, OidcStorageMethodPoolJwkFile))
120124

121125
return createAllCmd
122126
}

pkg/cmd/provisioning/gcp/create_workload_identity_provider.go

Lines changed: 116 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,30 @@ const (
4848
createIdentityProviderScriptName = "05-create-workload-identity-provider.sh"
4949
// createIdentityProviderCmd is gcloud cli command to create workload identity provider
5050
createIdentityProviderCmd = "gcloud iam workload-identity-pools providers create-oidc %s --location=global --workload-identity-pool=%s --display-name=%s --description=\"%s\" --issuer-uri=%s --allowed-audiences=%s --attribute-mapping=\"google.subject=assertion.sub\""
51+
// createIdentityProviderWithJwkFileCmd is gcloud cli command to create workload identity provider with attached JWK file
52+
createIdentityProviderWithJwkFileCmd = "gcloud iam workload-identity-pools providers create-oidc %s --location=global --workload-identity-pool=%s --display-name=%s --description=\"%s\" --issuer-uri=%s --allowed-audiences=%s --attribute-mapping=\"google.subject=assertion.sub\" --jwk-json-path=%s"
5153
// openShiftAudience is the only acceptable value for the `aud` field (audience) in the OIDC token shared by
5254
// OpenShift components
5355
openShiftAudience = "openshift"
56+
57+
// OidcStorageMethodPublicBucket is the default storage method that creates a public GCS bucket to host OIDC config
58+
OidcStorageMethodPublicBucket = "public-bucket"
59+
// OidcStorageMethodPoolJwkFile is the storage method that attaches the JWK directly to the workload identity pool provider
60+
OidcStorageMethodPoolJwkFile = "pool-jwk-file"
61+
62+
// oidcJwksUpdateMask is the field mask used when patching the JWKS on a workload identity pool provider
63+
oidcJwksUpdateMask = "oidc.jwks_json"
5464
)
5565

66+
func validateOidcStorageMethod(method string) {
67+
switch method {
68+
case OidcStorageMethodPublicBucket, OidcStorageMethodPoolJwkFile:
69+
// valid
70+
default:
71+
log.Fatalf("Invalid --oidc-storage-method %q, must be one of: %s, %s", method, OidcStorageMethodPublicBucket, OidcStorageMethodPoolJwkFile)
72+
}
73+
}
74+
5675
func createWorkloadIdentityProviderCmd(cmd *cobra.Command, args []string) {
5776
ctx := context.Background()
5877

@@ -71,39 +90,116 @@ func createWorkloadIdentityProviderCmd(cmd *cobra.Command, args []string) {
7190
publicKeyPath = filepath.Join(CreateWorkloadIdentityProviderOpts.TargetDir, provisioning.PublicKeyFile)
7291
}
7392

74-
err = createWorkloadIdentityProvider(ctx, gcpClient, CreateWorkloadIdentityProviderOpts.Name, CreateWorkloadIdentityProviderOpts.Region, CreateWorkloadIdentityProviderOpts.Project, CreateWorkloadIdentityProviderOpts.WorkloadIdentityPool, publicKeyPath, CreateWorkloadIdentityProviderOpts.TargetDir, CreateWorkloadIdentityProviderOpts.DryRun)
93+
err = createWorkloadIdentityProvider(ctx, gcpClient, CreateWorkloadIdentityProviderOpts.Name, CreateWorkloadIdentityProviderOpts.Region, CreateWorkloadIdentityProviderOpts.Project, CreateWorkloadIdentityProviderOpts.WorkloadIdentityPool, publicKeyPath, CreateWorkloadIdentityProviderOpts.TargetDir, CreateWorkloadIdentityProviderOpts.OidcStorageMethod, CreateWorkloadIdentityProviderOpts.DryRun)
7594
if err != nil {
7695
log.Fatal(err)
7796
}
7897
}
7998

80-
func createWorkloadIdentityProvider(ctx context.Context, client gcp.Client, name, region, project, workloadIdentityPool string, publicKeyPath, targetDir string, generateOnly bool) error {
81-
// Create a storage bucket
99+
func createWorkloadIdentityProvider(ctx context.Context, client gcp.Client, name, region, project, workloadIdentityPool string, publicKeyPath, targetDir, oidcStorageMethod string, generateOnly bool) error {
82100
bucketName := fmt.Sprintf("%s-oidc", name)
83-
if err := createOIDCBucket(ctx, client, bucketName, region, project, targetDir, generateOnly); err != nil {
84-
return err
85-
}
86101
issuerURL := fmt.Sprintf("https://storage.googleapis.com/%s", bucketName)
87102

88-
// Create the OIDC config file
89-
if err := createOIDCConfiguration(ctx, client, bucketName, issuerURL, targetDir, generateOnly); err != nil {
90-
return err
103+
switch oidcStorageMethod {
104+
case OidcStorageMethodPoolJwkFile:
105+
// Build the JWKS and attach it directly to the identity pool provider
106+
if err := createIdentityProviderWithJwkFile(ctx, client, name, project, issuerURL, workloadIdentityPool, publicKeyPath, targetDir, generateOnly); err != nil {
107+
return err
108+
}
109+
case OidcStorageMethodPublicBucket:
110+
if err := createOIDCBucket(ctx, client, bucketName, region, project, targetDir, generateOnly); err != nil {
111+
return err
112+
}
113+
114+
// Create the OIDC config file
115+
if err := createOIDCConfiguration(ctx, client, bucketName, issuerURL, targetDir, generateOnly); err != nil {
116+
return err
117+
}
118+
119+
// Create the OIDC key list
120+
if err := createJSONWebKeySet(ctx, client, publicKeyPath, bucketName, targetDir, generateOnly); err != nil {
121+
return err
122+
}
123+
124+
// Create the workload identity provider
125+
if err := createIdentityProvider(ctx, client, name, project, issuerURL, workloadIdentityPool, targetDir, generateOnly); err != nil {
126+
return err
127+
}
128+
default:
129+
return fmt.Errorf("unsupported oidc-storage-method %q", oidcStorageMethod)
91130
}
92131

93-
// Create the OIDC key list
94-
if err := createJSONWebKeySet(ctx, client, publicKeyPath, bucketName, targetDir, generateOnly); err != nil {
132+
// Create the installer manifest file
133+
if err := provisioning.CreateClusterAuthentication(issuerURL, targetDir); err != nil {
95134
return err
96135
}
97136

98-
// Create the workload identity provider
99-
err := createIdentityProvider(ctx, client, name, project, issuerURL, workloadIdentityPool, targetDir, generateOnly)
137+
return nil
138+
}
139+
140+
func createIdentityProviderWithJwkFile(ctx context.Context, client gcp.Client, name, project, issuerURL, workloadIdentityPool, publicKeyPath, targetDir string, generateOnly bool) error {
141+
jwks, err := provisioning.BuildJsonWebKeySet(publicKeyPath)
100142
if err != nil {
101-
return err
143+
return errors.Wrap(err, "failed to build JSON web key set from the public key")
102144
}
103145

104-
// Create the installer manifest file
105-
if err := provisioning.CreateClusterAuthentication(issuerURL, targetDir); err != nil {
106-
return err
146+
if generateOnly {
147+
jwksFilePath := filepath.Join(targetDir, gcpOidcKeysFilename)
148+
log.Printf("Saving JSON web key set (JWKS) locally at %s", jwksFilePath)
149+
if err := os.WriteFile(jwksFilePath, jwks, fileModeCcoctlDryRun); err != nil {
150+
return errors.Wrapf(err, "failed to save JSON web key set (JWKS) locally at %s", jwksFilePath)
151+
}
152+
153+
createIdentityProviderScript := provisioning.CreateShellScript([]string{createIdentityProviderWithJwkFileCmd})
154+
createIdentityProviderScriptFilepath := filepath.Join(targetDir, createIdentityProviderScriptName)
155+
script := fmt.Sprintf(createIdentityProviderScript, name, workloadIdentityPool, name, createdByCcoctl, issuerURL, openShiftAudience, gcpOidcKeysFilename)
156+
log.Printf("Saving shell script to create workload identity provider locally at %s", createIdentityProviderScriptFilepath)
157+
if err := os.WriteFile(createIdentityProviderScriptFilepath, []byte(script), fileModeCcoctlDryRun); err != nil {
158+
return errors.Wrapf(err, "failed to save shell script to create workload identity provider locally at %s", createIdentityProviderScriptFilepath)
159+
}
160+
return nil
161+
}
162+
163+
providerResource := fmt.Sprintf("projects/%s/locations/global/workloadIdentityPools/%s/providers/%s", project, workloadIdentityPool, name)
164+
existingProvider, err := client.GetWorkloadIdentityProvider(ctx, providerResource)
165+
if err != nil {
166+
if gerr, ok := err.(*googleapi.Error); ok && gerr.Code == 404 {
167+
provider := &iam.WorkloadIdentityPoolProvider{
168+
Name: name,
169+
DisplayName: name,
170+
Description: createdByCcoctl,
171+
Disabled: false,
172+
State: "ACTIVE",
173+
Oidc: &iam.Oidc{
174+
AllowedAudiences: []string{openShiftAudience},
175+
IssuerUri: issuerURL,
176+
JwksJson: string(jwks),
177+
},
178+
AttributeMapping: map[string]string{
179+
"google.subject": "assertion.sub",
180+
},
181+
}
182+
183+
_, err := client.CreateWorkloadIdentityProvider(ctx, fmt.Sprintf("projects/%s/locations/global/workloadIdentityPools/%s", project, workloadIdentityPool), name, provider)
184+
if err != nil {
185+
return errors.Wrapf(err, "failed to create workload identity provider %s", name)
186+
}
187+
log.Printf("workload identity provider created with name %s", name)
188+
} else {
189+
return errors.Wrapf(err, "failed to check if there is existing workload identity provider %s in pool %s", name, workloadIdentityPool)
190+
}
191+
} else {
192+
log.Printf("Workload identity provider %s already exists in pool %s, updating JWK set", existingProvider.Name, workloadIdentityPool)
193+
updatedProvider := &iam.WorkloadIdentityPoolProvider{
194+
Oidc: &iam.Oidc{
195+
JwksJson: string(jwks),
196+
},
197+
}
198+
_, err := client.UpdateWorkloadIdentityProvider(ctx, providerResource, updatedProvider, oidcJwksUpdateMask)
199+
if err != nil {
200+
return errors.Wrapf(err, "failed to update workload identity provider %s", name)
201+
}
202+
log.Printf("workload identity provider %s updated with new JWK set", name)
107203
}
108204

109205
return nil
@@ -271,6 +367,8 @@ func validationForCreateWorkloadIdentityProviderCmd(cmd *cobra.Command, args []s
271367
log.Fatalf("Name can be at most 32 characters long")
272368
}
273369

370+
validateOidcStorageMethod(CreateWorkloadIdentityProviderOpts.OidcStorageMethod)
371+
274372
if CreateWorkloadIdentityProviderOpts.TargetDir == "" {
275373
pwd, err := os.Getwd()
276374
if err != nil {
@@ -318,6 +416,7 @@ func NewCreateWorkloadIdentityProviderCmd() *cobra.Command {
318416
createWorkloadIdentityProviderCmd.PersistentFlags().StringVar(&CreateWorkloadIdentityProviderOpts.PublicKeyPath, "public-key-file", "", "Path to public ServiceAccount signing key")
319417
createWorkloadIdentityProviderCmd.PersistentFlags().BoolVar(&CreateWorkloadIdentityProviderOpts.DryRun, "dry-run", false, "Skip creating objects, and just save what would have been created into files")
320418
createWorkloadIdentityProviderCmd.PersistentFlags().StringVar(&CreateWorkloadIdentityProviderOpts.TargetDir, "output-dir", "", "Directory to place generated files (defaults to current directory)")
419+
createWorkloadIdentityProviderCmd.PersistentFlags().StringVar(&CreateWorkloadIdentityProviderOpts.OidcStorageMethod, "oidc-storage-method", OidcStorageMethodPublicBucket, fmt.Sprintf("Method for storing OIDC JWK files. %q (default) creates a public GCS bucket; %q attaches the JWK directly to the workload identity pool provider without creating a bucket", OidcStorageMethodPublicBucket, OidcStorageMethodPoolJwkFile))
321420

322421
return createWorkloadIdentityProviderCmd
323422
}

pkg/cmd/provisioning/gcp/create_workload_identity_provider_test.go

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ func TestCreateWorkloadIdentityProvider(t *testing.T) {
5151
mockGCPClient func(mockCtrl *gomock.Controller) *mockgcp.MockClient
5252
setup func(*testing.T) string
5353
verify func(t *testing.T, tempDirName string)
54-
cleanup func(*testing.T)
5554
generateOnly bool
5655
expectError bool
5756
}{
@@ -188,7 +187,7 @@ func TestCreateWorkloadIdentityProvider(t *testing.T) {
188187
defer os.RemoveAll(tempDirName)
189188

190189
testPublicKeyPath := filepath.Join(tempDirName, testPublicKeyFile)
191-
err := createWorkloadIdentityProvider(context.TODO(), mockGCPClient, testInfraName, testRegionName, testProject, testName, testPublicKeyPath, tempDirName, test.generateOnly)
190+
err := createWorkloadIdentityProvider(context.TODO(), mockGCPClient, testInfraName, testRegionName, testProject, testName, testPublicKeyPath, tempDirName, OidcStorageMethodPublicBucket, test.generateOnly)
192191

193192
if test.expectError {
194193
require.Error(t, err, "expected error returned")
@@ -252,3 +251,121 @@ func mockCreateWorkloadIdentityProviderSuccess(mockGCPClient *mockgcp.MockClient
252251
Done: true,
253252
}, nil).Times(1)
254253
}
254+
255+
func mockUpdateWorkloadIdentityProviderSuccess(mockGCPClient *mockgcp.MockClient) {
256+
mockGCPClient.EXPECT().UpdateWorkloadIdentityProvider(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&iam.Operation{
257+
Done: true,
258+
}, nil).Times(1)
259+
}
260+
261+
func setupTempDirWithKeyAndManifests(t *testing.T) string {
262+
t.Helper()
263+
tempDirName, err := os.MkdirTemp(os.TempDir(), testDirPrefix)
264+
require.NoError(t, err, "Failed to create temp directory")
265+
err = os.MkdirAll(filepath.Join(tempDirName, provisioning.ManifestsDirName), 0755)
266+
require.NoError(t, err, "Failed to create manifests directory")
267+
err = os.WriteFile(filepath.Join(tempDirName, testPublicKeyFile), []byte(testPublicKeyData), 0600)
268+
require.NoError(t, err, "errored while setting up environment for test")
269+
return tempDirName
270+
}
271+
272+
func TestCreateWorkloadIdentityProviderWithPoolJwkFile(t *testing.T) {
273+
tests := []struct {
274+
name string
275+
mockGCPClient func(mockCtrl *gomock.Controller) *mockgcp.MockClient
276+
setup func(*testing.T) string
277+
verify func(t *testing.T, tempDirName string)
278+
generateOnly bool
279+
expectError bool
280+
}{
281+
{
282+
name: "pool-jwk-file: public key not found",
283+
mockGCPClient: func(mockCtrl *gomock.Controller) *mockgcp.MockClient {
284+
mockGCPClient := mockgcp.NewMockClient(mockCtrl)
285+
return mockGCPClient
286+
},
287+
setup: func(t *testing.T) string {
288+
tempDirName, err := os.MkdirTemp(os.TempDir(), testDirPrefix)
289+
require.NoError(t, err, "Failed to create temp directory")
290+
return tempDirName
291+
},
292+
expectError: true,
293+
},
294+
{
295+
name: "pool-jwk-file: identity provider created with embedded JWK",
296+
mockGCPClient: func(mockCtrl *gomock.Controller) *mockgcp.MockClient {
297+
mockGCPClient := mockgcp.NewMockClient(mockCtrl)
298+
mockGetWorkloadIdentityProviderFailure(mockGCPClient)
299+
mockCreateWorkloadIdentityProviderSuccess(mockGCPClient)
300+
return mockGCPClient
301+
},
302+
setup: setupTempDirWithKeyAndManifests,
303+
verify: func(t *testing.T, tempDirName string) {},
304+
expectError: false,
305+
},
306+
{
307+
name: "pool-jwk-file: existing identity provider updated with new JWK",
308+
mockGCPClient: func(mockCtrl *gomock.Controller) *mockgcp.MockClient {
309+
mockGCPClient := mockgcp.NewMockClient(mockCtrl)
310+
mockGetWorkloadIdentityProviderSuccess(mockGCPClient)
311+
mockUpdateWorkloadIdentityProviderSuccess(mockGCPClient)
312+
return mockGCPClient
313+
},
314+
setup: setupTempDirWithKeyAndManifests,
315+
verify: func(t *testing.T, tempDirName string) {},
316+
expectError: false,
317+
},
318+
{
319+
name: "pool-jwk-file: generate files only",
320+
mockGCPClient: func(mockCtrl *gomock.Controller) *mockgcp.MockClient {
321+
mockGCPClient := mockgcp.NewMockClient(mockCtrl)
322+
return mockGCPClient
323+
},
324+
setup: setupTempDirWithKeyAndManifests,
325+
verify: func(t *testing.T, tempDirName string) {
326+
// Verify JWKS file was saved locally
327+
jwks, err := os.ReadFile(filepath.Join(tempDirName, gcpOidcKeysFilename))
328+
require.NoError(t, err, "error reading JWKS file")
329+
330+
var jwksJSON map[string]interface{}
331+
err = json.Unmarshal(jwks, &jwksJSON)
332+
require.NoError(t, err, "JWKS is not valid JSON")
333+
334+
keys, ok := jwksJSON["keys"].([]interface{})
335+
require.True(t, ok, "no keys in JSON web key set")
336+
assert.Len(t, keys, 1, "expected exactly one key in JWKS")
337+
338+
// Verify identity provider script was saved (not bucket script)
339+
_, err = os.Stat(filepath.Join(tempDirName, createIdentityProviderScriptName))
340+
assert.NoError(t, err, "identity provider script should exist")
341+
342+
_, err = os.Stat(filepath.Join(tempDirName, createOidcBucketScriptName))
343+
assert.True(t, os.IsNotExist(err), "bucket creation script should NOT exist for pool-jwk-file")
344+
},
345+
generateOnly: true,
346+
expectError: false,
347+
},
348+
}
349+
350+
for _, test := range tests {
351+
t.Run(test.name, func(t *testing.T) {
352+
mockCtrl := gomock.NewController(t)
353+
defer mockCtrl.Finish()
354+
355+
mockGCPClient := test.mockGCPClient(mockCtrl)
356+
357+
tempDirName := test.setup(t)
358+
defer os.RemoveAll(tempDirName)
359+
360+
testPublicKeyPath := filepath.Join(tempDirName, testPublicKeyFile)
361+
err := createWorkloadIdentityProvider(context.TODO(), mockGCPClient, testInfraName, testRegionName, testProject, testName, testPublicKeyPath, tempDirName, OidcStorageMethodPoolJwkFile, test.generateOnly)
362+
363+
if test.expectError {
364+
require.Error(t, err, "expected error returned")
365+
} else {
366+
require.NoError(t, err)
367+
test.verify(t, tempDirName)
368+
}
369+
})
370+
}
371+
}

pkg/cmd/provisioning/gcp/gcp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type options struct {
1515
WorkloadIdentityPool string
1616
WorkloadIdentityProvider string
1717
CredRequestDir string
18+
OidcStorageMethod string
1819
DryRun bool
1920
EnableTechPreview bool
2021
}

pkg/gcp/client.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type Client interface {
6060
UndeleteWorkloadIdentityPool(context.Context, string, *iam.UndeleteWorkloadIdentityPoolRequest) (*iam.Operation, error)
6161
CreateWorkloadIdentityProvider(context.Context, string, string, *iam.WorkloadIdentityPoolProvider) (*iam.Operation, error)
6262
GetWorkloadIdentityProvider(context.Context, string) (*iam.WorkloadIdentityPoolProvider, error)
63+
UpdateWorkloadIdentityProvider(context.Context, string, *iam.WorkloadIdentityPoolProvider, string) (*iam.Operation, error)
6364

6465
//CloudResourceManager
6566
GetProjectName() string
@@ -279,6 +280,12 @@ func (c *gcpClient) GetWorkloadIdentityProvider(ctx context.Context, resource st
279280
return c.iamService.Projects.Locations.WorkloadIdentityPools.Providers.Get(resource).Context(ctx).Do()
280281
}
281282

283+
func (c *gcpClient) UpdateWorkloadIdentityProvider(ctx context.Context, resource string, provider *iam.WorkloadIdentityPoolProvider, updateMask string) (*iam.Operation, error) {
284+
ctx, cancel := contextWithTimeout(ctx)
285+
defer cancel()
286+
return c.iamService.Projects.Locations.WorkloadIdentityPools.Providers.Patch(resource, provider).UpdateMask(updateMask).Context(ctx).Do()
287+
}
288+
282289
func (c *gcpClient) ListServicesEnabled() (map[string]bool, error) {
283290
serviceMap := map[string]bool{}
284291

0 commit comments

Comments
 (0)