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

astronomer / astro-cli / 30828250517

03 Aug 2026 03:35PM UTC coverage: 44.264% (+0.4%) from 43.896%
30828250517

Pull #2236

github

web-flow
Merge cce4a0619 into 9d9971fc7
Pull Request #2236: feat(deploy): opt-in Cosmos Boost pre-deploy step

427 of 479 new or added lines in 11 files covered. (89.14%)

76 existing lines in 2 files now uncovered.

26341 of 59509 relevant lines covered (44.26%)

8.86 hits per line

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

71.66
/cloud/deploy/bundle.go
1
package deploy
2

3
import (
4
        "context"
5
        "errors"
6
        "fmt"
7
        "os"
8
        "path/filepath"
9
        "strings"
10
        "time"
11

12
        airflowversions "github.com/astronomer/astro-cli/airflow_versions"
13
        "github.com/astronomer/astro-cli/astro-client-v1"
14
        "github.com/astronomer/astro-cli/cloud/deployment"
15
        "github.com/astronomer/astro-cli/config"
16
        "github.com/astronomer/astro-cli/pkg/cosmosboost"
17
        "github.com/astronomer/astro-cli/pkg/fileutil"
18
        "github.com/astronomer/astro-cli/pkg/git"
19
        "github.com/astronomer/astro-cli/pkg/logger"
20
)
21

22
type DeployBundleInput struct {
23
        BundlePath    string
24
        MountPath     string
25
        DeploymentID  string
26
        BundleType    string
27
        Description   string
28
        Wait          bool
29
        WaitTime      time.Duration
30
        AstroV1Client astrov1.APIClient
31
}
32

33
func DeployBundle(input *DeployBundleInput) error {
9✔
34
        c, err := config.GetCurrentContext()
9✔
35
        if err != nil {
9✔
36
                return err
×
37
        }
×
38

39
        // get the current deployment so we can check the deploy is valid
40
        currentDeployment, err := deployment.GetDeploymentByID(c.Organization, input.DeploymentID, input.AstroV1Client)
9✔
41
        if err != nil {
9✔
42
                return err
×
43
        }
×
44

45
        // if CI/CD is enforced, check the subject can deploy
46
        if currentDeployment.IsCicdEnforced && !canCiCdDeploy(c.Token) {
10✔
47
                return fmt.Errorf(errCiCdEnforcementUpdate, currentDeployment.Name)
1✔
48
        }
1✔
49

50
        // check the deployment is enabled for DAG deploys
51
        if !currentDeployment.IsDagDeployEnabled {
9✔
52
                return fmt.Errorf(enableDagDeployMsg, input.DeploymentID)
1✔
53
        }
1✔
54

55
        // Check if git metadata is enabled (default: true)
56
        var deployGit *astrov1.CreateDeployGitRequest
7✔
57
        var commitMessage string
7✔
58
        if config.CFG.DeployGitMetadata.GetBool() {
13✔
59
                deployGit, commitMessage = retrieveLocalGitMetadata(input.BundlePath)
6✔
60
        }
6✔
61

62
        // if no description was provided, use the commit message from the local Git checkout
63
        if input.Description == "" {
13✔
64
                input.Description = commitMessage
6✔
65
        }
6✔
66

67
        // initialize the deploy
68
        deploy, err := createBundleDeploy(c.Organization, input, deployGit, input.AstroV1Client)
7✔
69
        if err != nil {
7✔
70
                return err
×
71
        }
×
72

73
        // check we received an upload URL
74
        if deploy.BundleUploadUrl == nil {
8✔
75
                return errors.New("no bundle upload URL received from Astro")
1✔
76
        }
1✔
77

78
        // upload the bundle
79
        tarballVersion, err := UploadBundle(config.WorkingPath, input.BundlePath, *deploy.BundleUploadUrl, false, currentDeployment.RuntimeVersion)
6✔
80
        if err != nil {
6✔
81
                return err
×
82
        }
×
83

84
        // finalize the deploy
85
        err = finalizeBundleDeploy(c.Organization, input.DeploymentID, deploy.Id, tarballVersion, input.AstroV1Client)
6✔
86
        if err != nil {
6✔
87
                return err
×
88
        }
×
89
        fmt.Println("Successfully uploaded bundle with version " + tarballVersion + " to Astro.")
6✔
90

6✔
91
        // if requested, wait for the deploy to finish by polling the deployment until it is healthy
6✔
92
        if input.Wait {
6✔
93
                err = deployment.HealthPoll(currentDeployment.Id, currentDeployment.WorkspaceId, dagOnlyDeploySleepTime, tickNum, int(input.WaitTime.Seconds()), input.AstroV1Client)
×
94
                if err != nil {
×
95
                        return err
×
96
                }
×
97
        }
98

99
        return nil
6✔
100
}
101

102
type DeleteBundleInput struct {
103
        MountPath     string
104
        DeploymentID  string
105
        WorkspaceID   string
106
        BundleType    string
107
        Description   string
108
        Wait          bool
109
        WaitTime      time.Duration
110
        AstroV1Client astrov1.APIClient
111
}
112

113
func DeleteBundle(input *DeleteBundleInput) error {
1✔
114
        c, err := config.GetCurrentContext()
1✔
115
        if err != nil {
1✔
116
                return err
×
117
        }
×
118

119
        // initialize the deploy
120
        createInput := &DeployBundleInput{
1✔
121
                MountPath:    input.MountPath,
1✔
122
                DeploymentID: input.DeploymentID,
1✔
123
                BundleType:   input.BundleType,
1✔
124
                Description:  input.Description,
1✔
125
        }
1✔
126
        deploy, err := createBundleDeploy(c.Organization, createInput, nil, input.AstroV1Client)
1✔
127
        if err != nil {
1✔
128
                return err
×
129
        }
×
130

131
        // immediately finalize with no version, which will delete the bundle from the deployment
132
        err = finalizeBundleDeploy(c.Organization, input.DeploymentID, deploy.Id, "", input.AstroV1Client)
1✔
133
        if err != nil {
1✔
134
                return err
×
135
        }
×
136
        fmt.Println("Successfully requested bundle delete for mount path " + input.MountPath + " from Astro.")
1✔
137

1✔
138
        // if requested, wait for the deploy to finish by polling the deployment until it is healthy
1✔
139
        if input.Wait {
1✔
140
                err = deployment.HealthPoll(input.DeploymentID, input.WorkspaceID, dagOnlyDeploySleepTime, tickNum, int(input.WaitTime.Seconds()), input.AstroV1Client)
×
141
                if err != nil {
×
142
                        return err
×
143
                }
×
144
        }
145

146
        return nil
1✔
147
}
148

149
// ValidateBundleSymlinks checks if any symlinks within the bundlePath point outside of it
150
func ValidateBundleSymlinks(bundlePath string) error {
8✔
151
        absBundlePath, err := filepath.Abs(bundlePath)
8✔
152
        if err != nil {
8✔
153
                return fmt.Errorf("failed to get absolute path for bundle directory: %w", err)
×
154
        }
×
155

156
        err = filepath.WalkDir(bundlePath, func(path string, d os.DirEntry, err error) error {
30✔
157
                if err != nil {
22✔
158
                        return err // Propagate errors from WalkDir itself
×
159
                }
×
160

161
                // Check only for symlinks
162
                if d.Type()&os.ModeSymlink != 0 {
29✔
163
                        target, err := os.Readlink(path)
7✔
164
                        if err != nil {
7✔
165
                                logger.Debugf("Could not read symlink %s: %v", path, err)
×
166
                                return nil
×
167
                        }
×
168

169
                        // If the target is not absolute, join it with the directory containing the link
170
                        if !filepath.IsAbs(target) {
13✔
171
                                target = filepath.Join(filepath.Dir(path), target)
6✔
172
                        }
6✔
173

174
                        // Get the absolute path of the target
175
                        absTarget, err := filepath.Abs(target)
7✔
176
                        if err != nil {
7✔
177
                                logger.Debugf("Could not get absolute path for symlink target %s -> %s: %v", path, target, err)
×
178
                                return nil
×
179
                        }
×
180

181
                        // Check if the absolute target path is outside the absolute bundle path directory
182
                        if !strings.HasPrefix(absTarget, absBundlePath) {
10✔
183
                                return fmt.Errorf("symlink %s points to %s which is outside the bundle directory %s", path, target, absBundlePath)
3✔
184
                        }
3✔
185
                }
186
                return nil
19✔
187
        })
188
        if err != nil {
11✔
189
                return fmt.Errorf("bundle validation failed: %w", err)
3✔
190
        }
3✔
191

192
        return nil
5✔
193
}
194

195
func UploadBundle(tarDirPath, bundlePath, uploadURL string, prependBaseDir bool, currentRuntimeVersion string) (string, error) {
31✔
196
        // If Airflow 3.x, check for symlinks pointing outside the bundle directory
31✔
197
        if airflowversions.AirflowMajorVersionForRuntimeVersion(currentRuntimeVersion) == "3" {
31✔
198
                err := ValidateBundleSymlinks(bundlePath)
×
199
                if err != nil {
×
200
                        return "", err
×
201
                }
×
202
        }
203

204
        tarFilePath := filepath.Join(tarDirPath, "bundle.tar")
31✔
205
        tarGzFilePath := tarFilePath + ".gz"
31✔
206
        defer func() {
62✔
207
                tarFiles := []string{tarFilePath, tarGzFilePath}
31✔
208
                for _, file := range tarFiles {
93✔
209
                        err := os.Remove(file)
62✔
210
                        if err != nil {
64✔
211
                                if os.IsNotExist(err) {
4✔
212
                                        continue
2✔
213
                                }
214
                                fmt.Println("\nFailed to delete archived file: ", err.Error())
×
215
                                fmt.Println("\nPlease delete the archived file manually from path: " + file)
×
216
                        }
217
                }
218
        }()
219

220
        // Cosmos Boost pre-deploy step. Removing leftover artifacts is mandatory —
221
        // a stale one must not ship inside the bundle — while stamping fresh ones
222
        // is opt-in and best-effort.
223
        if err := cosmosboost.EnsureClean(bundlePath); err != nil {
32✔
224
                return "", err
1✔
225
        }
1✔
226
        if config.CFG.CosmosBoostPreDeploy.GetBool() {
32✔
227
                cosmosboost.BestEffortPreDeploy(bundlePath)
2✔
228
        }
2✔
229

230
        // Generate the bundle tar
231
        err := fileutil.Tar(bundlePath, tarFilePath, prependBaseDir, []string{".git/"})
30✔
232
        if err != nil {
30✔
UNCOV
233
                return "", err
×
UNCOV
234
        }
×
235

236
        // Gzip the tar
237
        err = fileutil.GzipFile(tarFilePath, tarGzFilePath)
30✔
238
        if err != nil {
30✔
UNCOV
239
                return "", err
×
UNCOV
240
        }
×
241

242
        tarGzFile, err := os.Open(tarGzFilePath)
30✔
243
        if err != nil {
30✔
UNCOV
244
                return "", err
×
UNCOV
245
        }
×
246
        defer tarGzFile.Close()
30✔
247

30✔
248
        versionID, err := azureUploader(uploadURL, tarGzFile)
30✔
249
        if err != nil {
30✔
UNCOV
250
                return "", err
×
UNCOV
251
        }
×
252

253
        return versionID, nil
30✔
254
}
255

256
func createBundleDeploy(organizationID string, input *DeployBundleInput, deployGit *astrov1.CreateDeployGitRequest, astroV1Client astrov1.APIClient) (*astrov1.Deploy, error) {
8✔
257
        request := astrov1.CreateDeployRequest{
8✔
258
                Description:     &input.Description,
8✔
259
                Type:            astrov1.CreateDeployRequestTypeBUNDLE,
8✔
260
                BundleMountPath: &input.MountPath,
8✔
261
                BundleType:      &input.BundleType,
8✔
262
                Git:             deployGit,
8✔
263
        }
8✔
264
        resp, err := astroV1Client.CreateDeployWithResponse(context.Background(), organizationID, input.DeploymentID, request)
8✔
265
        if err != nil {
8✔
UNCOV
266
                return nil, err
×
UNCOV
267
        }
×
268
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
8✔
269
        if err != nil {
8✔
270
                return nil, err
×
UNCOV
271
        }
×
272
        return resp.JSON200, nil
8✔
273
}
274

275
func finalizeBundleDeploy(organizationID, deploymentID, deployID, tarballVersion string, astroV1Client astrov1.APIClient) error {
7✔
276
        request := astrov1.FinalizeDeployRequest{
7✔
277
                BundleTarballVersion: &tarballVersion,
7✔
278
        }
7✔
279
        resp, err := astroV1Client.FinalizeDeployWithResponse(context.Background(), organizationID, deploymentID, deployID, request)
7✔
280
        if err != nil {
7✔
UNCOV
281
                return err
×
UNCOV
282
        }
×
283
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
7✔
284
        if err != nil {
7✔
285
                return err
×
UNCOV
286
        }
×
287
        return nil
7✔
288
}
289

290
// retrieveLocalGitMetadata retrieves git metadata from the local repository for deploy tracking.
291
// Returns nil and empty string if the repository has uncommitted changes or if git metadata cannot be retrieved.
292
func retrieveLocalGitMetadata(bundlePath string) (deployGit *astrov1.CreateDeployGitRequest, commitMessage string) {
39✔
293
        if git.HasUncommittedChanges(bundlePath) {
40✔
294
                fmt.Println("Local repository has uncommitted changes, skipping Git metadata retrieval")
1✔
295
                return nil, ""
1✔
296
        }
1✔
297

298
        // get the raw remote URL (needed for the GENERIC provider), assume the remote is named "origin"
299
        remoteURL, err := git.GetRemoteURL(bundlePath, "origin")
38✔
300
        if err != nil {
40✔
301
                logger.Debugf("Failed to retrieve remote repository details, skipping Git metadata retrieval: %s", err)
2✔
302
                return nil, ""
2✔
303
        }
2✔
304
        repoURL, err := git.ParseRemoteURL(remoteURL)
36✔
305
        if err != nil {
36✔
UNCOV
306
                logger.Debugf("Failed to parse remote repository URL, skipping Git metadata retrieval: %s", err)
×
UNCOV
307
                return nil, ""
×
UNCOV
308
        }
×
309

310
        deployGit = &astrov1.CreateDeployGitRequest{}
36✔
311

36✔
312
        // get the path of the bundle within the repository
36✔
313
        path, err := git.GetLocalRepositoryPathPrefix(bundlePath, bundlePath)
36✔
314
        if err != nil {
69✔
315
                logger.Debugf("Failed to retrieve local repository path prefix, skipping Git metadata retrieval: %s", err)
33✔
316
                return nil, ""
33✔
317
        }
33✔
318
        if path != "" {
4✔
319
                deployGit.Path = &path
1✔
320
        }
1✔
321

322
        // get the branch of the local commit
323
        branch, err := git.GetBranch(bundlePath)
3✔
324
        if err != nil {
3✔
UNCOV
325
                logger.Debugf("Failed to retrieve branch name, skipping Git metadata retrieval: %s", err)
×
UNCOV
326
                return nil, ""
×
UNCOV
327
        }
×
328
        deployGit.Branch = &branch
3✔
329

3✔
330
        // get the local commit
3✔
331
        sha, message, authorName, _, err := git.GetHeadCommit(bundlePath)
3✔
332
        if err != nil {
3✔
UNCOV
333
                logger.Debugf("Failed to retrieve commit, skipping Git metadata retrieval: %s", err)
×
UNCOV
334
                return nil, ""
×
UNCOV
335
        }
×
336
        deployGit.CommitSha = sha
3✔
337
        if authorName != "" {
6✔
338
                deployGit.AuthorName = &authorName
3✔
339
        }
3✔
340

341
        // populate provider-specific fields. GitHub gets first-class treatment; everything else is GENERIC.
342
        if repoURL.Host == "github.com" {
5✔
343
                account, repo, ok := splitGithubPath(repoURL.Path)
2✔
344
                if !ok {
2✔
UNCOV
345
                        logger.Debugf("Failed to parse GitHub repository path, skipping Git metadata retrieval: %s", repoURL.Path)
×
UNCOV
346
                        return nil, ""
×
UNCOV
347
                }
×
348
                deployGit.Provider = astrov1.CreateDeployGitRequestProviderGITHUB
2✔
349
                deployGit.Account = &account
2✔
350
                deployGit.Repo = &repo
2✔
351
                commitURL := fmt.Sprintf("https://%s/%s/%s/commit/%s", repoURL.Host, account, repo, sha)
2✔
352
                deployGit.CommitUrl = &commitURL
2✔
353
        } else {
1✔
354
                deployGit.Provider = astrov1.CreateDeployGitRequestProviderGENERIC
1✔
355
                deployGit.RemoteUrl = &remoteURL
1✔
356
        }
1✔
357

358
        logger.Debugf("Retrieved Git metadata: %+v", deployGit)
3✔
359

3✔
360
        return deployGit, message
3✔
361
}
362

363
func splitGithubPath(path string) (account, repo string, ok bool) {
2✔
364
        trimmed := strings.TrimPrefix(path, "/")
2✔
365
        slash := strings.Index(trimmed, "/")
2✔
366
        if slash == -1 {
2✔
UNCOV
367
                return "", "", false
×
UNCOV
368
        }
×
369
        return trimmed[:slash], trimmed[slash+1:], true
2✔
370
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc