• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kubernetes-sigs / blob-csi-driver / 6947043498

21 Nov 2023 04:52PM UTC coverage: 80.422%. Remained the same
6947043498

Pull #1123

github

web-flow
chore(deps): bump github.com/onsi/ginkgo/v2 from 2.13.0 to 2.13.1

Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.13.0 to 2.13.1.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.13.0...v2.13.1)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Pull Request #1123: chore(deps): bump github.com/onsi/ginkgo/v2 from 2.13.0 to 2.13.1

1980 of 2462 relevant lines covered (80.42%)

7.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.65
/pkg/blob/controllerserver.go
1
/*
2
Copyright 2017 The Kubernetes Authors.
3

4
Licensed under the Apache License, Version 2.0 (the "License");
5
you may not use this file except in compliance with the License.
6
You may obtain a copy of the License at
7

8
    http://www.apache.org/licenses/LICENSE-2.0
9

10
Unless required by applicable law or agreed to in writing, software
11
distributed under the License is distributed on an "AS IS" BASIS,
12
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
See the License for the specific language governing permissions and
14
limitations under the License.
15
*/
16

17
package blob
18

19
import (
20
        "context"
21
        "fmt"
22
        "net/url"
23
        "os/exec"
24
        "strconv"
25
        "strings"
26
        "time"
27

28
        "google.golang.org/grpc/codes"
29
        "google.golang.org/grpc/status"
30

31
        "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
32
        "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas"
33
        "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/service"
34
        "github.com/Azure/azure-sdk-for-go/services/storage/mgmt/2021-09-01/storage"
35
        azstorage "github.com/Azure/azure-sdk-for-go/storage"
36
        "github.com/container-storage-interface/spec/lib/go/csi"
37

38
        "k8s.io/apimachinery/pkg/util/wait"
39
        "k8s.io/klog/v2"
40
        "k8s.io/utils/pointer"
41

42
        "sigs.k8s.io/blob-csi-driver/pkg/util"
43
        azcache "sigs.k8s.io/cloud-provider-azure/pkg/cache"
44
        "sigs.k8s.io/cloud-provider-azure/pkg/metrics"
45
        "sigs.k8s.io/cloud-provider-azure/pkg/provider"
46
        azure "sigs.k8s.io/cloud-provider-azure/pkg/provider"
47
)
48

49
const (
50
        privateEndpoint = "privateendpoint"
51

52
        waitForCopyInterval = 5 * time.Second
53
        waitForCopyTimeout  = 3 * time.Minute
54
)
55

56
// CreateVolume provisions a volume
57
func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
22✔
58
        if err := d.ValidateControllerServiceRequest(csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME); err != nil {
22✔
59
                klog.Errorf("invalid create volume req: %v", req)
×
60
                return nil, err
×
61
        }
×
62

63
        volName := req.GetName()
22✔
64
        if len(volName) == 0 {
23✔
65
                return nil, status.Error(codes.InvalidArgument, "CreateVolume Name must be provided")
1✔
66
        }
1✔
67

68
        if err := isValidVolumeCapabilities(req.GetVolumeCapabilities()); err != nil {
23✔
69
                return nil, status.Error(codes.InvalidArgument, err.Error())
2✔
70
        }
2✔
71

72
        if acquired := d.volumeLocks.TryAcquire(volName); !acquired {
19✔
73
                // logging the job status if it's volume cloning
×
74
                if req.GetVolumeContentSource() != nil {
×
75
                        jobState, percent, err := d.azcopy.GetAzcopyJob(volName)
×
76
                        klog.V(2).Infof("azcopy job status: %s, copy percent: %s%%, error: %v", jobState, percent, err)
×
77
                }
×
78
                return nil, status.Errorf(codes.Aborted, volumeOperationAlreadyExistsFmt, volName)
×
79
        }
80
        defer d.volumeLocks.Release(volName)
19✔
81

19✔
82
        volSizeBytes := int64(req.GetCapacityRange().GetRequiredBytes())
19✔
83
        requestGiB := int(util.RoundUpGiB(volSizeBytes))
19✔
84

19✔
85
        parameters := req.GetParameters()
19✔
86
        if parameters == nil {
20✔
87
                parameters = make(map[string]string)
1✔
88
        }
1✔
89
        var storageAccountType, subsID, resourceGroup, location, account, containerName, containerNamePrefix, protocol, customTags, secretName, secretNamespace, pvcNamespace string
19✔
90
        var isHnsEnabled, requireInfraEncryption, enableBlobVersioning, createPrivateEndpoint, enableNfsV3 *bool
19✔
91
        var vnetResourceGroup, vnetName, subnetName, accessTier, networkEndpointType, storageEndpointSuffix string
19✔
92
        var matchTags, useDataPlaneAPI, getLatestAccountKey bool
19✔
93
        var softDeleteBlobs, softDeleteContainers int32
19✔
94
        var vnetResourceIDs []string
19✔
95
        var err error
19✔
96
        // set allowBlobPublicAccess as false by default
19✔
97
        allowBlobPublicAccess := pointer.Bool(false)
19✔
98

19✔
99
        containerNameReplaceMap := map[string]string{}
19✔
100

19✔
101
        // store account key to k8s secret by default
19✔
102
        storeAccountKey := true
19✔
103

19✔
104
        // Apply ProvisionerParameters (case-insensitive). We leave validation of
19✔
105
        // the values to the cloud provider.
19✔
106
        for k, v := range parameters {
126✔
107
                switch strings.ToLower(k) {
107✔
108
                case skuNameField:
10✔
109
                        storageAccountType = v
10✔
110
                case storageAccountTypeField:
11✔
111
                        storageAccountType = v
11✔
112
                case locationField:
10✔
113
                        location = v
10✔
114
                case storageAccountField:
11✔
115
                        account = v
11✔
116
                case subscriptionIDField:
2✔
117
                        subsID = v
2✔
118
                case resourceGroupField:
11✔
119
                        resourceGroup = v
11✔
120
                case containerNameField:
12✔
121
                        containerName = v
12✔
122
                case containerNamePrefixField:
2✔
123
                        containerNamePrefix = v
2✔
124
                case protocolField:
11✔
125
                        protocol = v
11✔
126
                case tagsField:
1✔
127
                        customTags = v
1✔
128
                case matchTagsField:
1✔
129
                        matchTags = strings.EqualFold(v, trueValue)
1✔
130
                case secretNameField:
×
131
                        secretName = v
×
132
                case secretNamespaceField:
×
133
                        secretNamespace = v
×
134
                case isHnsEnabledField:
×
135
                        if strings.EqualFold(v, trueValue) {
×
136
                                isHnsEnabled = pointer.Bool(true)
×
137
                        }
×
138
                case softDeleteBlobsField:
×
139
                        days, err := parseDays(v)
×
140
                        if err != nil {
×
141
                                return nil, err
×
142
                        }
×
143
                        softDeleteBlobs = days
×
144
                case softDeleteContainersField:
×
145
                        days, err := parseDays(v)
×
146
                        if err != nil {
×
147
                                return nil, err
×
148
                        }
×
149
                        softDeleteContainers = days
×
150
                case enableBlobVersioningField:
×
151
                        enableBlobVersioning = pointer.Bool(strings.EqualFold(v, trueValue))
×
152
                case storeAccountKeyField:
4✔
153
                        if strings.EqualFold(v, falseValue) {
7✔
154
                                storeAccountKey = false
3✔
155
                        }
3✔
156
                case getLatestAccountKeyField:
1✔
157
                        if getLatestAccountKey, err = strconv.ParseBool(v); err != nil {
2✔
158
                                return nil, status.Errorf(codes.InvalidArgument, "invalid %s: %s in volume context", getLatestAccountKeyField, v)
1✔
159
                        }
1✔
160
                case allowBlobPublicAccessField:
×
161
                        if strings.EqualFold(v, trueValue) {
×
162
                                allowBlobPublicAccess = pointer.Bool(true)
×
163
                        }
×
164
                case requireInfraEncryptionField:
×
165
                        if strings.EqualFold(v, trueValue) {
×
166
                                requireInfraEncryption = pointer.Bool(true)
×
167
                        }
×
168
                case pvcNamespaceKey:
×
169
                        pvcNamespace = v
×
170
                        containerNameReplaceMap[pvcNamespaceMetadata] = v
×
171
                case pvcNameKey:
×
172
                        containerNameReplaceMap[pvcNameMetadata] = v
×
173
                case pvNameKey:
×
174
                        containerNameReplaceMap[pvNameMetadata] = v
×
175
                case serverNameField:
×
176
                case storageAuthTypeField:
1✔
177
                case storageIentityClientIDField:
1✔
178
                case storageIdentityObjectIDField:
1✔
179
                case storageIdentityResourceIDField:
1✔
180
                case msiEndpointField:
1✔
181
                case storageAADEndpointField:
1✔
182
                        // no op, only used in NodeStageVolume
183
                case storageEndpointSuffixField:
×
184
                        storageEndpointSuffix = v
×
185
                case vnetResourceGroupField:
×
186
                        vnetResourceGroup = v
×
187
                case vnetNameField:
×
188
                        vnetName = v
×
189
                case subnetNameField:
×
190
                        subnetName = v
×
191
                case accessTierField:
×
192
                        accessTier = v
×
193
                case networkEndpointTypeField:
×
194
                        networkEndpointType = v
×
195
                case mountPermissionsField:
12✔
196
                        // only do validations here, used in NodeStageVolume, NodePublishVolume
12✔
197
                        if v != "" {
24✔
198
                                if _, err := strconv.ParseUint(v, 8, 32); err != nil {
13✔
199
                                        return nil, status.Errorf(codes.InvalidArgument, fmt.Sprintf("invalid mountPermissions %s in storage class", v))
1✔
200
                                }
1✔
201
                        }
202
                case useDataPlaneAPIField:
1✔
203
                        useDataPlaneAPI = strings.EqualFold(v, trueValue)
1✔
204
                default:
1✔
205
                        return nil, status.Errorf(codes.InvalidArgument, fmt.Sprintf("invalid parameter %q in storage class", k))
1✔
206
                }
207
        }
208

209
        if pointer.BoolDeref(enableBlobVersioning, false) {
16✔
210
                if isNFSProtocol(protocol) || pointer.BoolDeref(isHnsEnabled, false) {
×
211
                        return nil, status.Errorf(codes.InvalidArgument, "enableBlobVersioning is not supported for NFS protocol or HNS enabled account")
×
212
                }
×
213
        }
214

215
        if matchTags && account != "" {
17✔
216
                return nil, status.Errorf(codes.InvalidArgument, fmt.Sprintf("matchTags must set as false when storageAccount(%s) is provided", account))
1✔
217
        }
1✔
218

219
        if subsID != "" && subsID != d.cloud.SubscriptionID {
17✔
220
                if isNFSProtocol(protocol) {
3✔
221
                        return nil, status.Errorf(codes.InvalidArgument, fmt.Sprintf("NFS protocol is not supported in cross subscription(%s)", subsID))
1✔
222
                }
1✔
223
                if !storeAccountKey {
2✔
224
                        return nil, status.Errorf(codes.InvalidArgument, fmt.Sprintf("storeAccountKey must set as true in cross subscription(%s)", subsID))
1✔
225
                }
1✔
226
        }
227

228
        if resourceGroup == "" {
18✔
229
                resourceGroup = d.cloud.ResourceGroup
5✔
230
        }
5✔
231

232
        if secretNamespace == "" {
26✔
233
                if pvcNamespace == "" {
26✔
234
                        secretNamespace = defaultNamespace
13✔
235
                } else {
13✔
236
                        secretNamespace = pvcNamespace
×
237
                }
×
238
        }
239

240
        if protocol == "" {
17✔
241
                protocol = Fuse
4✔
242
        }
4✔
243
        if !isSupportedProtocol(protocol) {
14✔
244
                return nil, status.Errorf(codes.InvalidArgument, "protocol(%s) is not supported, supported protocol list: %v", protocol, supportedProtocolList)
1✔
245
        }
1✔
246
        if !isSupportedAccessTier(accessTier) {
12✔
247
                return nil, status.Errorf(codes.InvalidArgument, "accessTier(%s) is not supported, supported AccessTier list: %v", accessTier, storage.PossibleAccessTierValues())
×
248
        }
×
249

250
        if containerName != "" && containerNamePrefix != "" {
13✔
251
                return nil, status.Errorf(codes.InvalidArgument, "containerName(%s) and containerNamePrefix(%s) could not be specified together", containerName, containerNamePrefix)
1✔
252
        }
1✔
253
        if !isSupportedContainerNamePrefix(containerNamePrefix) {
12✔
254
                return nil, status.Errorf(codes.InvalidArgument, "containerNamePrefix(%s) can only contain lowercase letters, numbers, hyphens, and length should be less than 21", containerNamePrefix)
1✔
255
        }
1✔
256

257
        enableHTTPSTrafficOnly := true
10✔
258
        if strings.EqualFold(networkEndpointType, privateEndpoint) {
10✔
259
                createPrivateEndpoint = pointer.BoolPtr(true)
×
260
        }
×
261
        accountKind := string(storage.KindStorageV2)
10✔
262
        if isNFSProtocol(protocol) {
11✔
263
                isHnsEnabled = pointer.Bool(true)
1✔
264
                enableNfsV3 = pointer.Bool(true)
1✔
265
                // NFS protocol does not need account key
1✔
266
                storeAccountKey = false
1✔
267
                if !pointer.BoolDeref(createPrivateEndpoint, false) {
2✔
268
                        // set VirtualNetworkResourceIDs for storage account firewall setting
1✔
269
                        vnetResourceID := d.getSubnetResourceID(vnetResourceGroup, vnetName, subnetName)
1✔
270
                        klog.V(2).Infof("set vnetResourceID(%s) for NFS protocol", vnetResourceID)
1✔
271
                        vnetResourceIDs = []string{vnetResourceID}
1✔
272
                        if err := d.updateSubnetServiceEndpoints(ctx, vnetResourceGroup, vnetName, subnetName); err != nil {
2✔
273
                                return nil, status.Errorf(codes.Internal, "update service endpoints failed with error: %v", err)
1✔
274
                        }
1✔
275
                }
276
        }
277

278
        if strings.HasPrefix(strings.ToLower(storageAccountType), "premium") {
10✔
279
                accountKind = string(storage.KindBlockBlobStorage)
1✔
280
        }
1✔
281
        if IsAzureStackCloud(d.cloud) {
10✔
282
                accountKind = string(storage.KindStorage)
1✔
283
                if storageAccountType != "" && storageAccountType != string(storage.SkuNameStandardLRS) && storageAccountType != string(storage.SkuNamePremiumLRS) {
2✔
284
                        return nil, status.Errorf(codes.InvalidArgument, fmt.Sprintf("Invalid skuName value: %s, as Azure Stack only supports %s and %s Storage Account types.", storageAccountType, storage.SkuNamePremiumLRS, storage.SkuNameStandardLRS))
1✔
285
                }
1✔
286
        }
287

288
        tags, err := util.ConvertTagsToMap(customTags)
8✔
289
        if err != nil {
9✔
290
                return nil, status.Errorf(codes.InvalidArgument, err.Error())
1✔
291
        }
1✔
292

293
        if strings.TrimSpace(storageEndpointSuffix) == "" {
14✔
294
                if d.cloud.Environment.StorageEndpointSuffix != "" {
7✔
295
                        storageEndpointSuffix = d.cloud.Environment.StorageEndpointSuffix
×
296
                } else {
7✔
297
                        storageEndpointSuffix = defaultStorageEndPointSuffix
7✔
298
                }
7✔
299
        }
300

301
        accountOptions := &azure.AccountOptions{
7✔
302
                Name:                            account,
7✔
303
                Type:                            storageAccountType,
7✔
304
                Kind:                            accountKind,
7✔
305
                SubscriptionID:                  subsID,
7✔
306
                ResourceGroup:                   resourceGroup,
7✔
307
                Location:                        location,
7✔
308
                EnableHTTPSTrafficOnly:          enableHTTPSTrafficOnly,
7✔
309
                VirtualNetworkResourceIDs:       vnetResourceIDs,
7✔
310
                Tags:                            tags,
7✔
311
                MatchTags:                       matchTags,
7✔
312
                IsHnsEnabled:                    isHnsEnabled,
7✔
313
                EnableNfsV3:                     enableNfsV3,
7✔
314
                AllowBlobPublicAccess:           allowBlobPublicAccess,
7✔
315
                RequireInfrastructureEncryption: requireInfraEncryption,
7✔
316
                VNetResourceGroup:               vnetResourceGroup,
7✔
317
                VNetName:                        vnetName,
7✔
318
                SubnetName:                      subnetName,
7✔
319
                AccessTier:                      accessTier,
7✔
320
                CreatePrivateEndpoint:           createPrivateEndpoint,
7✔
321
                StorageType:                     provider.StorageTypeBlob,
7✔
322
                StorageEndpointSuffix:           storageEndpointSuffix,
7✔
323
                EnableBlobVersioning:            enableBlobVersioning,
7✔
324
                SoftDeleteBlobs:                 softDeleteBlobs,
7✔
325
                SoftDeleteContainers:            softDeleteContainers,
7✔
326
                GetLatestAccountKey:             getLatestAccountKey,
7✔
327
        }
7✔
328

7✔
329
        var volumeID string
7✔
330
        requestName := "controller_create_volume"
7✔
331
        if req.GetVolumeContentSource() != nil {
9✔
332
                switch req.VolumeContentSource.Type.(type) {
2✔
333
                case *csi.VolumeContentSource_Snapshot:
1✔
334
                        requestName = "controller_create_volume_from_snapshot"
1✔
335
                case *csi.VolumeContentSource_Volume:
1✔
336
                        requestName = "controller_create_volume_from_volume"
1✔
337
                }
338
        }
339
        mc := metrics.NewMetricContext(blobCSIDriverName, requestName, d.cloud.ResourceGroup, d.cloud.SubscriptionID, d.Name)
7✔
340
        isOperationSucceeded := false
7✔
341
        defer func() {
14✔
342
                mc.ObserveOperationWithResult(isOperationSucceeded, VolumeID, volumeID)
7✔
343
        }()
7✔
344

345
        var accountKey string
7✔
346
        accountName := account
7✔
347
        secrets := req.GetSecrets()
7✔
348
        if len(secrets) == 0 && accountName == "" {
8✔
349
                if v, ok := d.volMap.Load(volName); ok {
1✔
350
                        accountName = v.(string)
×
351
                } else {
1✔
352
                        lockKey := fmt.Sprintf("%s%s%s%s%s%v", storageAccountType, accountKind, resourceGroup, location, protocol, pointer.BoolDeref(createPrivateEndpoint, false))
1✔
353
                        // search in cache first
1✔
354
                        cache, err := d.accountSearchCache.Get(lockKey, azcache.CacheReadTypeDefault)
1✔
355
                        if err != nil {
1✔
356
                                return nil, status.Errorf(codes.Internal, err.Error())
×
357
                        }
×
358
                        if cache != nil {
1✔
359
                                accountName = cache.(string)
×
360
                        } else {
1✔
361
                                d.volLockMap.LockEntry(lockKey)
1✔
362
                                err = wait.ExponentialBackoff(d.cloud.RequestBackoff(), func() (bool, error) {
2✔
363
                                        var retErr error
1✔
364
                                        accountName, accountKey, retErr = d.cloud.EnsureStorageAccount(ctx, accountOptions, protocol)
1✔
365
                                        if isRetriableError(retErr) {
1✔
366
                                                klog.Warningf("EnsureStorageAccount(%s) failed with error(%v), waiting for retrying", account, retErr)
×
367
                                                return false, nil
×
368
                                        }
×
369
                                        return true, retErr
1✔
370
                                })
371
                                d.volLockMap.UnlockEntry(lockKey)
1✔
372
                                if err != nil {
2✔
373
                                        return nil, status.Errorf(codes.Internal, "ensure storage account failed with %v", err)
1✔
374
                                }
1✔
375
                                d.accountSearchCache.Set(lockKey, accountName)
×
376
                                d.volMap.Store(volName, accountName)
×
377
                        }
378
                }
379
        }
380

381
        if pointer.BoolDeref(createPrivateEndpoint, false) && isNFSProtocol(protocol) {
6✔
382
                // As for blobfuse/blobfuse2, serverName, i.e.,AZURE_STORAGE_BLOB_ENDPOINT env variable can't include
×
383
                // "privatelink", issue: https://github.com/Azure/azure-storage-fuse/issues/1014
×
384
                //
×
385
                // And use public endpoint will be befine to blobfuse/blobfuse2, because it will be resolved to private endpoint
×
386
                // by private dns zone, which includes CNAME record, documented here:
×
387
                // https://learn.microsoft.com/en-us/azure/storage/common/storage-private-endpoints?toc=%2Fazure%2Fstorage%2Fblobs%2Ftoc.json&bc=%2Fazure%2Fstorage%2Fblobs%2Fbreadcrumb%2Ftoc.json#dns-changes-for-private-endpoints
×
388
                setKeyValueInMap(parameters, serverNameField, fmt.Sprintf("%s.privatelink.blob.%s", accountName, storageEndpointSuffix))
×
389
        }
×
390

391
        accountOptions.Name = accountName
6✔
392
        if len(secrets) == 0 && useDataPlaneAPI {
7✔
393
                if accountKey == "" {
2✔
394
                        if accountName, accountKey, err = d.GetStorageAccesskey(ctx, accountOptions, secrets, secretName, secretNamespace); err != nil {
2✔
395
                                return nil, status.Errorf(codes.Internal, "failed to GetStorageAccesskey on account(%s) rg(%s), error: %v", accountOptions.Name, accountOptions.ResourceGroup, err)
1✔
396
                        }
1✔
397
                }
398
                secrets = createStorageAccountSecret(accountName, accountKey)
×
399
        }
400

401
        // replace pv/pvc name namespace metadata in subDir
402
        containerName = replaceWithMap(containerName, containerNameReplaceMap)
5✔
403
        validContainerName := containerName
5✔
404
        if validContainerName == "" {
5✔
405
                validContainerName = volName
×
406
                if containerNamePrefix != "" {
×
407
                        validContainerName = containerNamePrefix + "-" + volName
×
408
                }
×
409
                validContainerName = getValidContainerName(validContainerName, protocol)
×
410
                setKeyValueInMap(parameters, containerNameField, validContainerName)
×
411
        }
412

413
        if req.GetVolumeContentSource() != nil {
7✔
414
                if accountKey == "" {
4✔
415
                        if _, accountKey, err = d.GetStorageAccesskey(ctx, accountOptions, secrets, secretName, secretNamespace); err != nil {
2✔
416
                                return nil, status.Errorf(codes.Internal, "failed to GetStorageAccesskey on account(%s) rg(%s), error: %v", accountOptions.Name, accountOptions.ResourceGroup, err)
×
417
                        }
×
418
                }
419
                if err := d.copyVolume(ctx, req, accountKey, validContainerName, storageEndpointSuffix); err != nil {
4✔
420
                        return nil, err
2✔
421
                }
2✔
422
        } else {
3✔
423
                klog.V(2).Infof("begin to create container(%s) on account(%s) type(%s) subsID(%s) rg(%s) location(%s) size(%d)", validContainerName, accountName, storageAccountType, subsID, resourceGroup, location, requestGiB)
3✔
424
                if err := d.CreateBlobContainer(ctx, subsID, resourceGroup, accountName, validContainerName, secrets); err != nil {
4✔
425
                        return nil, status.Errorf(codes.Internal, "failed to create container(%s) on account(%s) type(%s) rg(%s) location(%s) size(%d), error: %v", validContainerName, accountName, storageAccountType, resourceGroup, location, requestGiB, err)
1✔
426
                }
1✔
427
        }
428

429
        if storeAccountKey && len(req.GetSecrets()) == 0 {
4✔
430
                if accountKey == "" {
4✔
431
                        if accountName, accountKey, err = d.GetStorageAccesskey(ctx, accountOptions, secrets, secretName, secretNamespace); err != nil {
3✔
432
                                return nil, status.Errorf(codes.Internal, "failed to GetStorageAccesskey on account(%s) rg(%s), error: %v", accountOptions.Name, accountOptions.ResourceGroup, err)
1✔
433
                        }
1✔
434
                }
435

436
                secretName, err := setAzureCredentials(ctx, d.cloud.KubeClient, accountName, accountKey, secretNamespace)
1✔
437
                if err != nil {
1✔
438
                        return nil, status.Errorf(codes.Internal, "failed to store storage account key: %v", err)
×
439
                }
×
440
                if secretName != "" {
1✔
441
                        klog.V(2).Infof("store account key to k8s secret(%v) in %s namespace", secretName, secretNamespace)
×
442
                }
×
443
        }
444

445
        var uuid string
1✔
446
        if containerName != "" {
2✔
447
                // add volume name as suffix to differentiate volumeID since "containerName" is specified
1✔
448
                // not necessary for dynamic container name creation since volumeID already contains volume name
1✔
449
                uuid = volName
1✔
450
        }
1✔
451
        volumeID = fmt.Sprintf(volumeIDTemplate, resourceGroup, accountName, validContainerName, uuid, secretNamespace, subsID)
1✔
452
        klog.V(2).Infof("create container %s on storage account %s successfully", validContainerName, accountName)
1✔
453

1✔
454
        if useDataPlaneAPI {
1✔
455
                d.dataPlaneAPIVolCache.Set(volumeID, "")
×
456
                d.dataPlaneAPIVolCache.Set(accountName, "")
×
457
        }
×
458

459
        isOperationSucceeded = true
1✔
460
        // reset secretNamespace field in VolumeContext
1✔
461
        setKeyValueInMap(parameters, secretNamespaceField, secretNamespace)
1✔
462
        return &csi.CreateVolumeResponse{
1✔
463
                Volume: &csi.Volume{
1✔
464
                        VolumeId:      volumeID,
1✔
465
                        CapacityBytes: req.GetCapacityRange().GetRequiredBytes(),
1✔
466
                        VolumeContext: parameters,
1✔
467
                        ContentSource: req.GetVolumeContentSource(),
1✔
468
                },
1✔
469
        }, nil
1✔
470
}
471

472
// DeleteVolume delete a volume
473
func (d *Driver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) (*csi.DeleteVolumeResponse, error) {
5✔
474
        volumeID := req.GetVolumeId()
5✔
475
        if len(volumeID) == 0 {
6✔
476
                return nil, status.Error(codes.InvalidArgument, "Volume ID missing in request")
1✔
477
        }
1✔
478

479
        if err := d.ValidateControllerServiceRequest(csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME); err != nil {
4✔
480
                return nil, status.Errorf(codes.Internal, "invalid delete volume req: %v", req)
×
481
        }
×
482

483
        if acquired := d.volumeLocks.TryAcquire(volumeID); !acquired {
4✔
484
                return nil, status.Errorf(codes.Aborted, volumeOperationAlreadyExistsFmt, volumeID)
×
485
        }
×
486
        defer d.volumeLocks.Release(volumeID)
4✔
487

4✔
488
        resourceGroupName, accountName, containerName, _, subsID, err := GetContainerInfo(volumeID)
4✔
489
        if err != nil {
5✔
490
                // According to CSI Driver Sanity Tester, should succeed when an invalid volume id is used
1✔
491
                klog.Errorf("GetContainerInfo(%s) in DeleteVolume failed with error: %v", volumeID, err)
1✔
492
                return &csi.DeleteVolumeResponse{}, nil
1✔
493
        }
1✔
494

495
        secrets := req.GetSecrets()
3✔
496
        if len(secrets) == 0 && d.useDataPlaneAPI(volumeID, accountName) {
4✔
497
                _, accountName, accountKey, _, _, err := d.GetAuthEnv(ctx, volumeID, "", nil, secrets)
1✔
498
                if err != nil {
2✔
499
                        return nil, status.Errorf(codes.Internal, "GetAuthEnv(%s) failed with %v", volumeID, err)
1✔
500
                }
1✔
501
                if accountName != "" && accountKey != "" {
×
502
                        secrets = createStorageAccountSecret(accountName, accountKey)
×
503
                }
×
504
        }
505

506
        mc := metrics.NewMetricContext(blobCSIDriverName, "controller_delete_volume", d.cloud.ResourceGroup, d.cloud.SubscriptionID, d.Name)
2✔
507
        isOperationSucceeded := false
2✔
508
        defer func() {
4✔
509
                mc.ObserveOperationWithResult(isOperationSucceeded, VolumeID, volumeID)
2✔
510
        }()
2✔
511

512
        if resourceGroupName == "" {
3✔
513
                resourceGroupName = d.cloud.ResourceGroup
1✔
514
        }
1✔
515
        klog.V(2).Infof("deleting container(%s) rg(%s) account(%s) volumeID(%s)", containerName, resourceGroupName, accountName, volumeID)
2✔
516
        if err := d.DeleteBlobContainer(ctx, subsID, resourceGroupName, accountName, containerName, secrets); err != nil {
4✔
517
                return nil, status.Errorf(codes.Internal, "failed to delete container(%s) under rg(%s) account(%s) volumeID(%s), error: %v", containerName, resourceGroupName, accountName, volumeID, err)
2✔
518
        }
2✔
519

520
        isOperationSucceeded = true
×
521
        klog.V(2).Infof("container(%s) under rg(%s) account(%s) volumeID(%s) is deleted successfully", containerName, resourceGroupName, accountName, volumeID)
×
522
        return &csi.DeleteVolumeResponse{}, nil
×
523
}
524

525
// ValidateVolumeCapabilities return the capabilities of the volume
526
func (d *Driver) ValidateVolumeCapabilities(ctx context.Context, req *csi.ValidateVolumeCapabilitiesRequest) (*csi.ValidateVolumeCapabilitiesResponse, error) {
8✔
527
        volumeID := req.GetVolumeId()
8✔
528
        if len(volumeID) == 0 {
9✔
529
                return nil, status.Error(codes.InvalidArgument, "Volume ID missing in request")
1✔
530
        }
1✔
531
        if err := isValidVolumeCapabilities(req.GetVolumeCapabilities()); err != nil {
9✔
532
                return nil, status.Error(codes.InvalidArgument, err.Error())
2✔
533
        }
2✔
534

535
        resourceGroupName, accountName, containerName, _, subsID, err := GetContainerInfo(volumeID)
5✔
536
        if err != nil {
6✔
537
                klog.Errorf("GetContainerInfo(%s) in ValidateVolumeCapabilities failed with error: %v", volumeID, err)
1✔
538
                return nil, status.Error(codes.NotFound, err.Error())
1✔
539
        }
1✔
540

541
        var exist bool
4✔
542
        secrets := req.GetSecrets()
4✔
543
        if len(secrets) > 0 {
5✔
544
                container, err := getContainerReference(containerName, secrets, d.cloud.Environment)
1✔
545
                if err != nil {
2✔
546
                        return nil, status.Error(codes.Internal, err.Error())
1✔
547
                }
1✔
548
                exist, err = container.Exists()
×
549
                if err != nil {
×
550
                        return nil, status.Error(codes.Internal, err.Error())
×
551
                }
×
552
        } else {
3✔
553
                if resourceGroupName == "" {
3✔
554
                        resourceGroupName = d.cloud.ResourceGroup
×
555
                }
×
556
                blobContainer, retryErr := d.cloud.BlobClient.GetContainer(ctx, subsID, resourceGroupName, accountName, containerName)
3✔
557
                err = retryErr.Error()
3✔
558
                if err != nil {
4✔
559
                        return nil, status.Error(codes.Internal, err.Error())
1✔
560
                }
1✔
561
                if blobContainer.ContainerProperties == nil {
3✔
562
                        return nil, status.Errorf(codes.Internal, "ContainerProperties of volume(%s) is nil", volumeID)
1✔
563
                }
1✔
564
                exist = blobContainer.ContainerProperties.Deleted != nil && !*blobContainer.ContainerProperties.Deleted
1✔
565
        }
566
        if !exist {
2✔
567
                return nil, status.Errorf(codes.NotFound, "requested volume(%s) does not exist", volumeID)
1✔
568
        }
1✔
569
        klog.V(2).Infof("ValidateVolumeCapabilities on volume(%s) succeeded", volumeID)
×
570

×
571
        // blob driver supports all AccessModes, no need to check capabilities here
×
572
        return &csi.ValidateVolumeCapabilitiesResponse{
×
573
                Confirmed: &csi.ValidateVolumeCapabilitiesResponse_Confirmed{
×
574
                        VolumeCapabilities: req.GetVolumeCapabilities(),
×
575
                },
×
576
                Message: "",
×
577
        }, nil
×
578
}
579

580
func (d *Driver) ControllerPublishVolume(_ context.Context, _ *csi.ControllerPublishVolumeRequest) (*csi.ControllerPublishVolumeResponse, error) {
1✔
581
        return nil, status.Error(codes.Unimplemented, "ControllerPublishVolume is not yet implemented")
1✔
582
}
1✔
583

584
func (d *Driver) ControllerUnpublishVolume(_ context.Context, _ *csi.ControllerUnpublishVolumeRequest) (*csi.ControllerUnpublishVolumeResponse, error) {
1✔
585
        return nil, status.Error(codes.Unimplemented, "ControllerUnpublishVolume is not yet implemented")
1✔
586
}
1✔
587

588
// ControllerGetVolume get volume
589
func (d *Driver) ControllerGetVolume(context.Context, *csi.ControllerGetVolumeRequest) (*csi.ControllerGetVolumeResponse, error) {
1✔
590
        return nil, status.Error(codes.Unimplemented, "ControllerGetVolume is not yet implemented")
1✔
591
}
1✔
592

593
// GetCapacity returns the capacity of the total available storage pool
594
func (d *Driver) GetCapacity(_ context.Context, _ *csi.GetCapacityRequest) (*csi.GetCapacityResponse, error) {
1✔
595
        return nil, status.Error(codes.Unimplemented, "GetCapacity is not yet implemented")
1✔
596
}
1✔
597

598
// ListVolumes return all available volumes
599
func (d *Driver) ListVolumes(_ context.Context, _ *csi.ListVolumesRequest) (*csi.ListVolumesResponse, error) {
1✔
600
        return nil, status.Error(codes.Unimplemented, "ListVolumes is not yet implemented")
1✔
601
}
1✔
602

603
// CreateSnapshot create snapshot
604
func (d *Driver) CreateSnapshot(_ context.Context, _ *csi.CreateSnapshotRequest) (*csi.CreateSnapshotResponse, error) {
1✔
605
        return nil, status.Error(codes.Unimplemented, "CreateSnapshot is not yet implemented")
1✔
606
}
1✔
607

608
// DeleteSnapshot delete snapshot
609
func (d *Driver) DeleteSnapshot(_ context.Context, _ *csi.DeleteSnapshotRequest) (*csi.DeleteSnapshotResponse, error) {
1✔
610
        return nil, status.Error(codes.Unimplemented, "DeleteSnapshot is not yet implemented")
1✔
611
}
1✔
612

613
// ListSnapshots list snapshots
614
func (d *Driver) ListSnapshots(_ context.Context, _ *csi.ListSnapshotsRequest) (*csi.ListSnapshotsResponse, error) {
1✔
615
        return nil, status.Error(codes.Unimplemented, "ListSnapshots is not yet implemented")
1✔
616
}
1✔
617

618
// ControllerGetCapabilities returns the capabilities of the Controller plugin
619
func (d *Driver) ControllerGetCapabilities(_ context.Context, _ *csi.ControllerGetCapabilitiesRequest) (*csi.ControllerGetCapabilitiesResponse, error) {
1✔
620
        return &csi.ControllerGetCapabilitiesResponse{
1✔
621
                Capabilities: d.Cap,
1✔
622
        }, nil
1✔
623
}
1✔
624

625
// ControllerExpandVolume controller expand volume
626
func (d *Driver) ControllerExpandVolume(_ context.Context, req *csi.ControllerExpandVolumeRequest) (*csi.ControllerExpandVolumeResponse, error) {
4✔
627
        if len(req.GetVolumeId()) == 0 {
5✔
628
                return nil, status.Error(codes.InvalidArgument, "Volume ID missing in request")
1✔
629
        }
1✔
630

631
        if req.GetCapacityRange() == nil {
4✔
632
                return nil, status.Error(codes.InvalidArgument, "Capacity Range missing in request")
1✔
633
        }
1✔
634

635
        if err := d.ValidateControllerServiceRequest(csi.ControllerServiceCapability_RPC_EXPAND_VOLUME); err != nil {
2✔
636
                return nil, status.Errorf(codes.Internal, "invalid expand volume req: %v", req)
×
637
        }
×
638

639
        volSizeBytes := int64(req.GetCapacityRange().GetRequiredBytes())
2✔
640
        requestGiB := int64(util.RoundUpGiB(volSizeBytes))
2✔
641

2✔
642
        if volSizeBytes > containerMaxSize {
3✔
643
                return nil, status.Errorf(codes.OutOfRange, "required bytes (%d) exceeds the maximum supported bytes (%d)", volSizeBytes, containerMaxSize)
1✔
644
        }
1✔
645

646
        klog.V(2).Infof("ControllerExpandVolume(%s) successfully, currentQuota: %d Gi", req.VolumeId, requestGiB)
1✔
647

1✔
648
        return &csi.ControllerExpandVolumeResponse{CapacityBytes: req.GetCapacityRange().GetRequiredBytes()}, nil
1✔
649
}
650

651
// CreateBlobContainer creates a blob container
652
func (d *Driver) CreateBlobContainer(ctx context.Context, subsID, resourceGroupName, accountName, containerName string, secrets map[string]string) error {
9✔
653
        if containerName == "" {
10✔
654
                return fmt.Errorf("containerName is empty")
1✔
655
        }
1✔
656
        return wait.ExponentialBackoff(d.cloud.RequestBackoff(), func() (bool, error) {
16✔
657
                var err error
8✔
658
                if len(secrets) > 0 {
9✔
659
                        container, getErr := getContainerReference(containerName, secrets, d.cloud.Environment)
1✔
660
                        if getErr != nil {
2✔
661
                                return true, getErr
1✔
662
                        }
1✔
663
                        _, err = container.CreateIfNotExists(&azstorage.CreateContainerOptions{Access: azstorage.ContainerAccessTypePrivate})
×
664
                } else {
7✔
665
                        blobContainer := storage.BlobContainer{
7✔
666
                                ContainerProperties: &storage.ContainerProperties{
7✔
667
                                        PublicAccess: storage.PublicAccessNone,
7✔
668
                                },
7✔
669
                        }
7✔
670
                        err = d.cloud.BlobClient.CreateContainer(ctx, subsID, resourceGroupName, accountName, containerName, blobContainer).Error()
7✔
671
                }
7✔
672
                if err != nil {
11✔
673
                        if strings.Contains(err.Error(), containerBeingDeletedDataplaneAPIError) ||
4✔
674
                                strings.Contains(err.Error(), containerBeingDeletedManagementAPIError) {
7✔
675
                                klog.Warningf("CreateContainer(%s, %s, %s) failed with error(%v), retry", resourceGroupName, accountName, containerName, err)
3✔
676
                                return false, nil
3✔
677
                        }
3✔
678
                }
679
                return true, err
4✔
680
        })
681
}
682

683
// DeleteBlobContainer deletes a blob container
684
func (d *Driver) DeleteBlobContainer(ctx context.Context, subsID, resourceGroupName, accountName, containerName string, secrets map[string]string) error {
8✔
685
        if containerName == "" {
9✔
686
                return fmt.Errorf("containerName is empty")
1✔
687
        }
1✔
688
        return wait.ExponentialBackoff(d.cloud.RequestBackoff(), func() (bool, error) {
14✔
689
                var err error
7✔
690
                if len(secrets) > 0 {
10✔
691
                        container, getErr := getContainerReference(containerName, secrets, d.cloud.Environment)
3✔
692
                        if getErr != nil {
6✔
693
                                return true, getErr
3✔
694
                        }
3✔
695
                        _, err = container.DeleteIfExists(nil)
×
696
                } else {
4✔
697
                        err = d.cloud.BlobClient.DeleteContainer(ctx, subsID, resourceGroupName, accountName, containerName).Error()
4✔
698
                }
4✔
699
                if err != nil {
7✔
700
                        if strings.Contains(err.Error(), containerBeingDeletedDataplaneAPIError) ||
3✔
701
                                strings.Contains(err.Error(), containerBeingDeletedManagementAPIError) ||
3✔
702
                                strings.Contains(err.Error(), statusCodeNotFound) ||
3✔
703
                                strings.Contains(err.Error(), httpCodeNotFound) {
5✔
704
                                klog.Warningf("delete container(%s) on account(%s) failed with error(%v), return as success", containerName, accountName, err)
2✔
705
                                return true, nil
2✔
706
                        }
2✔
707
                        return false, fmt.Errorf("failed to delete container(%s) on account(%s), error: %w", containerName, accountName, err)
1✔
708
                }
709
                return true, err
1✔
710
        })
711
}
712

713
// CopyBlobContainer copies a blob container in the same storage account
714
func (d *Driver) copyBlobContainer(_ context.Context, req *csi.CreateVolumeRequest, accountKey, dstContainerName, storageEndpointSuffix string) error {
6✔
715
        var sourceVolumeID string
6✔
716
        if req.GetVolumeContentSource() != nil && req.GetVolumeContentSource().GetVolume() != nil {
12✔
717
                sourceVolumeID = req.GetVolumeContentSource().GetVolume().GetVolumeId()
6✔
718

6✔
719
        }
6✔
720
        resourceGroupName, accountName, srcContainerName, _, _, err := GetContainerInfo(sourceVolumeID) //nolint:dogsled
6✔
721
        if err != nil {
8✔
722
                return status.Error(codes.NotFound, err.Error())
2✔
723
        }
2✔
724
        if srcContainerName == "" || dstContainerName == "" {
6✔
725
                return fmt.Errorf("srcContainerName(%s) or dstContainerName(%s) is empty", srcContainerName, dstContainerName)
2✔
726
        }
2✔
727

728
        klog.V(2).Infof("generate sas token for account(%s)", accountName)
2✔
729
        accountSasToken, genErr := generateSASToken(accountName, accountKey, storageEndpointSuffix, d.sasTokenExpirationMinutes)
2✔
730
        if genErr != nil {
2✔
731
                return genErr
×
732
        }
×
733

734
        timeAfter := time.After(waitForCopyTimeout)
2✔
735
        timeTick := time.Tick(waitForCopyInterval)
2✔
736
        srcPath := fmt.Sprintf("https://%s.blob.%s/%s%s", accountName, storageEndpointSuffix, srcContainerName, accountSasToken)
2✔
737
        dstPath := fmt.Sprintf("https://%s.blob.%s/%s%s", accountName, storageEndpointSuffix, dstContainerName, accountSasToken)
2✔
738

2✔
739
        jobState, percent, err := d.azcopy.GetAzcopyJob(dstContainerName)
2✔
740
        klog.V(2).Infof("azcopy job status: %s, copy percent: %s%%, error: %v", jobState, percent, err)
2✔
741
        if jobState == util.AzcopyJobError || jobState == util.AzcopyJobCompleted {
3✔
742
                return err
1✔
743
        }
1✔
744
        klog.V(2).Infof("begin to copy blob container %s to %s", srcContainerName, dstContainerName)
1✔
745
        for {
2✔
746
                select {
1✔
747
                case <-timeTick:
1✔
748
                        jobState, percent, err := d.azcopy.GetAzcopyJob(dstContainerName)
1✔
749
                        klog.V(2).Infof("azcopy job status: %s, copy percent: %s%%, error: %v", jobState, percent, err)
1✔
750
                        switch jobState {
1✔
751
                        case util.AzcopyJobError, util.AzcopyJobCompleted:
1✔
752
                                return err
1✔
753
                        case util.AzcopyJobNotFound:
×
754
                                klog.V(2).Infof("copy blob container %s to %s", srcContainerName, dstContainerName)
×
755
                                out, copyErr := exec.Command("azcopy", "copy", srcPath, dstPath, "--recursive", "--check-length=false").CombinedOutput()
×
756
                                if copyErr != nil {
×
757
                                        klog.Warningf("CopyBlobContainer(%s, %s, %s) failed with error(%v): %v", resourceGroupName, accountName, dstPath, copyErr, string(out))
×
758
                                } else {
×
759
                                        klog.V(2).Infof("copied blob container %s to %s successfully", srcContainerName, dstContainerName)
×
760
                                }
×
761
                                return copyErr
×
762
                        }
763
                case <-timeAfter:
×
764
                        return fmt.Errorf("timeout waiting for copy blob container %s to %s succeed", srcContainerName, dstContainerName)
×
765
                }
766
        }
767
}
768

769
// copyVolume copies a volume form volume or snapshot, snapshot is not supported now
770
func (d *Driver) copyVolume(ctx context.Context, req *csi.CreateVolumeRequest, accountKey, dstContainerName, storageEndpointSuffix string) error {
8✔
771
        vs := req.VolumeContentSource
8✔
772
        switch vs.Type.(type) {
8✔
773
        case *csi.VolumeContentSource_Snapshot:
2✔
774
                return status.Errorf(codes.InvalidArgument, "copy volume from volumeSnapshot is not supported")
2✔
775
        case *csi.VolumeContentSource_Volume:
6✔
776
                return d.copyBlobContainer(ctx, req, accountKey, dstContainerName, storageEndpointSuffix)
6✔
777
        default:
×
778
                return status.Errorf(codes.InvalidArgument, "%v is not a proper volume source", vs)
×
779
        }
780
}
781

782
// isValidVolumeCapabilities validates the given VolumeCapability array is valid
783
func isValidVolumeCapabilities(volCaps []*csi.VolumeCapability) error {
28✔
784
        if len(volCaps) == 0 {
30✔
785
                return fmt.Errorf("volume capabilities missing in request")
2✔
786
        }
2✔
787
        for _, c := range volCaps {
52✔
788
                if c.GetBlock() != nil {
28✔
789
                        return fmt.Errorf("block volume capability not supported")
2✔
790
                }
2✔
791
        }
792
        return nil
24✔
793
}
794

795
func parseDays(dayStr string) (int32, error) {
3✔
796
        days, err := strconv.Atoi(dayStr)
3✔
797
        if err != nil {
4✔
798
                return 0, status.Errorf(codes.InvalidArgument, fmt.Sprintf("invalid %s:%s in storage class", softDeleteBlobsField, dayStr))
1✔
799
        }
1✔
800
        if days <= 0 || days > 365 {
3✔
801
                return 0, status.Errorf(codes.InvalidArgument, fmt.Sprintf("invalid %s:%s in storage class, should be in range [1, 365]", softDeleteBlobsField, dayStr))
1✔
802
        }
1✔
803

804
        return int32(days), nil
1✔
805
}
806

807
// generateSASToken generate a sas token for storage account
808
func generateSASToken(accountName, accountKey, storageEndpointSuffix string, expiryTime int) (string, error) {
4✔
809
        credential, err := azblob.NewSharedKeyCredential(accountName, accountKey)
4✔
810
        if err != nil {
5✔
811
                return "", status.Errorf(codes.Internal, fmt.Sprintf("failed to generate sas token in creating new shared key credential, accountName: %s, err: %s", accountName, err.Error()))
1✔
812
        }
1✔
813
        serviceClient, err := service.NewClientWithSharedKeyCredential(fmt.Sprintf("https://%s.blob.%s/", accountName, storageEndpointSuffix), credential, nil)
3✔
814
        if err != nil {
3✔
815
                return "", status.Errorf(codes.Internal, fmt.Sprintf("failed to generate sas token in creating new client with shared key credential, accountName: %s, err: %s", accountName, err.Error()))
×
816
        }
×
817
        sasURL, err := serviceClient.GetSASURL(
3✔
818
                sas.AccountResourceTypes{Object: true, Service: false, Container: true},
3✔
819
                sas.AccountPermissions{Read: true, List: true, Write: true},
3✔
820
                sas.AccountServices{Blob: true}, time.Now(), time.Now().Add(time.Duration(expiryTime)*time.Minute))
3✔
821
        if err != nil {
3✔
822
                return "", err
×
823
        }
×
824
        u, err := url.Parse(sasURL)
3✔
825
        if err != nil {
3✔
826
                return "", err
×
827
        }
×
828
        return "?" + u.RawQuery, nil
3✔
829
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc