Skip to content

Commit 3125e4f

Browse files
committed
fix(oci/vault): SDK paths for secret versions and bundle getByName, emulator-local KMS endpoints, compartment checks, oci-go-sdk round trip
1 parent 3d39f21 commit 3125e4f

20 files changed

Lines changed: 583 additions & 171 deletions

‎docs/services.md‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,8 +1458,16 @@ management, `/20190301` the secret-retrieval data plane.
14581458
| `GetKeyVersion` | `GET /keys/{keyId}/keyVersions/{keyVersionId}` |
14591459

14601460
The KMS management endpoint is per-vault in real OCI, where the vault is
1461-
addressed by host. CloudEmu serves every vault from one host, so `/keys` takes
1462-
`vaultId` as a query parameter instead.
1461+
addressed by host and `CreateKey` / `ListKeys` send no `vaultId`. A vault here
1462+
reports its `managementEndpoint` and `cryptoEndpoint` as
1463+
`http://<label>-management.kms.localhost:<port>` and `…-crypto…`, on the port
1464+
the request came in on, with a short per-vault label. A KMS client built on that
1465+
endpoint stays on the emulator, and `/keys` reads the vault from the request's
1466+
host label. A client on the plain emulator address can pass `?vaultId=`
1467+
instead, and with a single active vault in the compartment it may omit both.
1468+
`*.localhost` resolves through the system resolver on macOS and most Linux
1469+
desktops, but not through Go's pure resolver, so a statically built client may
1470+
need a dialer that maps those hosts to the emulator.
14631471

14641472
#### Secrets (`/20180608`)
14651473

@@ -1469,29 +1477,32 @@ addressed by host. CloudEmu serves every vault from one host, so `/keys` takes
14691477
| `ListSecrets` | `GET /secrets` |
14701478
| `GetSecret` | `GET /secrets/{secretId}` |
14711479
| `UpdateSecret` | `PUT /secrets/{secretId}` |
1472-
| `GetSecretByName` | `GET /secrets/actions/getByName` |
14731480
| `ScheduleSecretDeletion` | `POST /secrets/{secretId}/actions/scheduleDeletion` |
14741481
| `CancelSecretDeletion` | `POST /secrets/{secretId}/actions/cancelDeletion` |
14751482
| `ChangeSecretCompartment` | `POST /secrets/{secretId}/actions/changeCompartment` |
14761483
| `ListSecretVersions` | `GET /secrets/{secretId}/versions` |
1477-
| `GetSecretVersion` | `GET /secrets/{secretId}/versions/{versionNumber}` |
1478-
| `ScheduleSecretVersionDeletion` | `POST /secrets/{secretId}/versions/{n}/actions/scheduleDeletion` |
1479-
| `CancelSecretVersionDeletion` | `POST /secrets/{secretId}/versions/{n}/actions/cancelDeletion` |
1484+
| `GetSecretVersion` | `GET /secrets/{secretId}/version/{versionNumber}` |
1485+
| `ScheduleSecretVersionDeletion` | `POST /secrets/{secretId}/version/{n}/actions/scheduleDeletion` |
1486+
| `CancelSecretVersionDeletion` | `POST /secrets/{secretId}/version/{n}/actions/cancelDeletion` |
1487+
1488+
Only the list is plural: a single version is addressed under `/version/{n}`, as
1489+
the Vaults API does. There is no `GetSecretByName` on this surface; a name
1490+
lookup is `ListSecrets?name=` or the bundle-side `getByName` below.
14801491

14811492
#### Secret bundles — the retrieval data plane (`/20190301`)
14821493

14831494
| Operation | Route |
14841495
|-----------|-------|
14851496
| `GetSecretBundle` | `GET /secretbundles/{secretId}` |
14861497
| `ListSecretBundleVersions` | `GET /secretbundles/{secretId}/versions` |
1487-
| `GetSecretBundleByName` | `GET /secretbundles/actions/getByName` |
1498+
| `GetSecretBundleByName` | `POST /secretbundles/actions/getByName` |
14881499

14891500
A bundle read is the only way to get a secret's *value* back; the management
14901501
routes above return metadata only. The version is named by exactly one of
14911502
`versionNumber`, `secretVersionName` or `stage` — more than one is rejected
14921503
rather than silently ranked.
14931504

1494-
**Total: 32 operations**
1505+
**Total: 31 operations**
14951506

14961507
#### Behavior
14971508

@@ -1516,6 +1527,10 @@ secret is `ACTIVE` and never `ENABLED`. This matters to consumers —
15161527
rotate. `enableKey` / `disableKey` and the `ENABLING` / `DISABLING` / `DISABLED`
15171528
states are not served, so a key's lifecycle here is `ENABLED ⇄ PENDING_DELETION`.
15181529

1530+
With an identity service wired, creating a vault, key or secret in a
1531+
compartment that does not exist, or moving one into such a compartment, is
1532+
`404 NotAuthorizedOrNotFound`.
1533+
15191534
Deletion is scheduled, never immediate. A vault, key, secret or secret version
15201535
moves to `PENDING_DELETION` and stays there — nothing reaps it — until the
15211536
deletion is cancelled. A secret pending deletion releases its name, so the same

‎go.mod‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ require (
155155
github.com/databricks/databricks-sdk-go v0.144.0
156156
github.com/fxamacker/cbor/v2 v2.9.1
157157
github.com/google/gnostic-models v0.6.8
158+
github.com/oracle/oci-go-sdk/v65 v65.106.1
158159
github.com/stretchr/testify v1.11.1
159160
golang.org/x/crypto v0.54.0
160161
golang.org/x/net v0.57.0
@@ -209,6 +210,7 @@ require (
209210
github.com/go-openapi/jsonpointer v0.21.0 // indirect
210211
github.com/go-openapi/jsonreference v0.20.2 // indirect
211212
github.com/go-openapi/swag v0.23.0 // indirect
213+
github.com/gofrs/flock v0.10.0 // indirect
212214
github.com/gogo/protobuf v1.3.2 // indirect
213215
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
214216
github.com/golang/protobuf v1.5.4 // indirect
@@ -233,9 +235,11 @@ require (
233235
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
234236
github.com/samber/lo v1.38.1 // indirect
235237
github.com/shopspring/decimal v1.3.1 // indirect
238+
github.com/sony/gobreaker v0.5.0 // indirect
236239
github.com/spf13/pflag v1.0.5 // indirect
237240
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
238241
github.com/x448/float16 v0.8.4 // indirect
242+
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
239243
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
240244
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect
241245
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect

‎go.sum‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
416416
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
417417
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
418418
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
419+
github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k=
420+
github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
419421
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
420422
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
421423
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -479,6 +481,8 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM
479481
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
480482
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
481483
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
484+
github.com/oracle/oci-go-sdk/v65 v65.106.1 h1:TzBfZ5Lp87R4/OdOBm593gCTXlfIXDXb6e2SCG+7yQ0=
485+
github.com/oracle/oci-go-sdk/v65 v65.106.1/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
482486
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
483487
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
484488
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -494,13 +498,17 @@ github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
494498
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
495499
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
496500
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
501+
github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg=
502+
github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
497503
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
498504
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
499505
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
500506
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
501507
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
502508
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
503509
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
510+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
511+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
504512
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
505513
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
506514
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -511,6 +519,8 @@ github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
511519
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
512520
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
513521
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
522+
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
523+
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
514524
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
515525
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
516526
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=

‎providers/oci/vault/key.go‎

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,12 @@ func (m *Mock) CreateKey(spec *KeySpec) (*KeyInfo, error) {
107107
}
108108

109109
if spec.VaultID == "" {
110-
return nil, cerrors.New(cerrors.InvalidArgument, "vaultId is required")
110+
id, err := m.soleVaultLocked(spec.CompartmentID)
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
spec.VaultID = id
111116
}
112117

113118
if err := m.requireActiveVaultLocked(spec.VaultID); err != nil {
@@ -293,20 +298,6 @@ func (m *Mock) ChangeKeyCompartment(id, compartmentID string) error {
293298
return nil
294299
}
295300

296-
// KeyCompartment returns the compartment a key lives in, for the work request
297-
// the wire layer records.
298-
func (m *Mock) KeyCompartment(id string) string {
299-
m.mu.RLock()
300-
defer m.mu.RUnlock()
301-
302-
k, ok := m.keys.Get(id)
303-
if !ok {
304-
return ""
305-
}
306-
307-
return k.Scope.Compartment
308-
}
309-
310301
// CreateKeyVersion rotates a key: it mints a new version and makes it the
311302
// key's current one. Earlier versions stay readable, as in real OCI.
312303
func (m *Mock) CreateKeyVersion(keyID string) (*KeyVersionInfo, error) {

‎providers/oci/vault/key_test.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,13 @@ func TestUpdateAndMoveKey(t *testing.T) {
205205
assert.Equal(t, "b", got.FreeformTags["a"])
206206

207207
require.NoError(t, m.ChangeKeyCompartment(keyID, otherCompart))
208-
assert.Equal(t, otherCompart, m.KeyCompartment(keyID))
208+
209+
movedKey, err := m.GetKey(keyID)
210+
require.NoError(t, err)
211+
assert.Equal(t, otherCompart, movedKey.CompartmentID)
209212

210213
assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(m.ChangeKeyCompartment(keyID, "")))
211214
assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.ChangeKeyCompartment("ocid1.key.oc1.iad.x", otherCompart)))
212-
assert.Empty(t, m.KeyCompartment("ocid1.key.oc1.iad.x"))
213215

214216
_, err = m.UpdateKey("ocid1.key.oc1.iad.x", Update{})
215217
assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err))

‎providers/oci/vault/portable_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,14 +262,14 @@ func TestPortableRejectsAnAmbiguousName(t *testing.T) {
262262
assert.Len(t, list, 2)
263263

264264
// Scheduling one for deletion releases its name, so the other resolves.
265-
byName, err := m.GetOCISecretByName(vaultA, "shared")
265+
byName, err := m.GetSecretBundleByName(vaultA, "shared", BundleSelector{})
266266
require.NoError(t, err)
267-
_, err = m.ScheduleOCISecretDeletion(byName.ID, "")
267+
_, err = m.ScheduleOCISecretDeletion(byName.SecretID, "")
268268
require.NoError(t, err)
269269

270270
got, err := m.GetSecret(ctx, "shared")
271271
require.NoError(t, err)
272-
assert.NotEqual(t, byName.ID, got.ID)
272+
assert.NotEqual(t, byName.SecretID, got.ID)
273273
}
274274

275275
// The portable create refuses a name another vault already holds, rather than

‎providers/oci/vault/secret.go‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -160,22 +160,6 @@ func (m *Mock) GetOCISecret(id string) (*SecretInfo, error) {
160160
return &info, nil
161161
}
162162

163-
// GetOCISecretByName returns a secret by vault and name, OCI's getByName
164-
// action. It takes no compartmentId: the vault already names one.
165-
func (m *Mock) GetOCISecretByName(vaultID, name string) (*SecretInfo, error) {
166-
m.mu.RLock()
167-
defer m.mu.RUnlock()
168-
169-
s, err := m.secretByVaultAndNameLocked(vaultID, name)
170-
if err != nil {
171-
return nil, err
172-
}
173-
174-
info := toSecretInfo(s)
175-
176-
return &info, nil
177-
}
178-
179163
// ListOCISecrets returns the secrets in a compartment, further filtered to one
180164
// vault and one name when those are non-empty, ordered by OCID.
181165
func (m *Mock) ListOCISecrets(compartmentID, vaultID, name string) ([]SecretInfo, error) {

‎providers/oci/vault/secret_test.go‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -124,21 +124,21 @@ func TestListOCISecretsFiltersByCompartmentVaultAndName(t *testing.T) {
124124
assert.Empty(t, missing)
125125
}
126126

127-
func TestGetOCISecretByName(t *testing.T) {
127+
func TestGetSecretBundleByNameLookups(t *testing.T) {
128128
m := newTestMock()
129129
s := newSecret(t, m, testCompartment, "named", "v")
130130

131-
got, err := m.GetOCISecretByName(s.VaultID, "named")
131+
got, err := m.GetSecretBundleByName(s.VaultID, "named", BundleSelector{})
132132
require.NoError(t, err)
133-
assert.Equal(t, s.ID, got.ID)
133+
assert.Equal(t, s.ID, got.SecretID)
134134

135-
_, err = m.GetOCISecretByName(s.VaultID, "other")
135+
_, err = m.GetSecretBundleByName(s.VaultID, "other", BundleSelector{})
136136
assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err))
137137

138-
_, err = m.GetOCISecretByName("", "named")
138+
_, err = m.GetSecretBundleByName("", "named", BundleSelector{})
139139
assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err))
140140

141-
_, err = m.GetOCISecretByName(s.VaultID, "")
141+
_, err = m.GetSecretBundleByName(s.VaultID, "", BundleSelector{})
142142
assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err))
143143

144144
_, err = m.GetOCISecret("ocid1.vaultsecret.oc1.iad.x")
@@ -286,13 +286,13 @@ func TestSecretNamesAreUniquePerVault(t *testing.T) {
286286
assert.Equal(t, vaultB, second.VaultID)
287287

288288
// Each vault's getByName resolves to its own secret.
289-
got, err := m.GetOCISecretByName(vaultA, "db-password")
289+
got, err := m.GetSecretBundleByName(vaultA, "db-password", BundleSelector{})
290290
require.NoError(t, err)
291-
assert.Equal(t, first.ID, got.ID)
291+
assert.Equal(t, first.ID, got.SecretID)
292292

293-
got, err = m.GetOCISecretByName(vaultB, "db-password")
293+
got, err = m.GetSecretBundleByName(vaultB, "db-password", BundleSelector{})
294294
require.NoError(t, err)
295-
assert.Equal(t, second.ID, got.ID)
295+
assert.Equal(t, second.ID, got.SecretID)
296296

297297
// Within one vault the name is still taken.
298298
_, err = m.CreateOCISecret(&SecretSpec{
@@ -326,7 +326,7 @@ func TestCancelDeletionAllowsTheSameNameInAnotherVault(t *testing.T) {
326326

327327
// A pending-deletion secret keeps its name until the deletion is cancelled, so
328328
// getByName must not return it in preference to the live secret beside it.
329-
func TestGetOCISecretByNamePrefersTheActiveSecret(t *testing.T) {
329+
func TestGetSecretBundleByNamePrefersTheActiveSecret(t *testing.T) {
330330
m := newTestMock()
331331
first := newSecret(t, m, testCompartment, "reused", "a")
332332

@@ -340,7 +340,7 @@ func TestGetOCISecretByNamePrefersTheActiveSecret(t *testing.T) {
340340
})
341341
require.NoError(t, err)
342342

343-
got, err := m.GetOCISecretByName(first.VaultID, "reused")
343+
got, err := m.GetSecretBundleByName(first.VaultID, "reused", BundleSelector{})
344344
require.NoError(t, err)
345-
assert.Equal(t, second.ID, got.ID)
345+
assert.Equal(t, second.ID, got.SecretID)
346346
}

‎providers/oci/vault/snapshot_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,9 @@ func TestSnapshotRestoreRoundTrip(t *testing.T) {
103103
assert.Equal(t, "db", gotSecret.FreeformTags["tier"])
104104

105105
// getByName still resolves inside the restored vault.
106-
byName, err := dst.GetOCISecretByName(v.ID, "db-password")
106+
byName, err := dst.GetSecretBundleByName(v.ID, "db-password", BundleSelector{})
107107
require.NoError(t, err)
108-
assert.Equal(t, s.ID, byName.ID)
108+
assert.Equal(t, s.ID, byName.SecretID)
109109

110110
// Secret versions and their stages survive.
111111
secretVersions, err := dst.ListOCISecretVersions(s.ID)

0 commit comments

Comments
 (0)