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

astronomer / astro-cli / 716c27ab-af54-4fbb-904c-9fa0a6da08dc

26 May 2026 02:32PM UTC coverage: 44.676% (+5.0%) from 39.653%
716c27ab-af54-4fbb-904c-9fa0a6da08dc

push

circleci

web-flow
Migrate CLI to v1 public API; retire v1beta1 and v1alpha1 (except IDE) (#2093)

1848 of 18362 new or added lines in 58 files covered. (10.06%)

925 existing lines in 15 files now uncovered.

24957 of 55862 relevant lines covered (44.68%)

7.74 hits per line

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

70.12
/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/fileutil"
17
        "github.com/astronomer/astro-cli/pkg/git"
18
        "github.com/astronomer/astro-cli/pkg/logger"
19
)
20

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

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

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

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

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

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

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

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

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

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

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

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

98
        return nil
5✔
99
}
100

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

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

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

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

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

145
        return nil
1✔
146
}
147

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

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

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

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

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

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

191
        return nil
5✔
192
}
193

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

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

219
        // Generate the bundle tar
220
        err := fileutil.Tar(bundlePath, tarFilePath, prependBaseDir, []string{".git/"})
26✔
221
        if err != nil {
26✔
222
                return "", err
×
223
        }
×
224

225
        // Gzip the tar
226
        err = fileutil.GzipFile(tarFilePath, tarGzFilePath)
26✔
227
        if err != nil {
26✔
228
                return "", err
×
229
        }
×
230

231
        tarGzFile, err := os.Open(tarGzFilePath)
26✔
232
        if err != nil {
26✔
233
                return "", err
×
234
        }
×
235
        defer tarGzFile.Close()
26✔
236

26✔
237
        versionID, err := azureUploader(uploadURL, tarGzFile)
26✔
238
        if err != nil {
26✔
239
                return "", err
×
240
        }
×
241

242
        return versionID, nil
26✔
243
}
244

245
func createBundleDeploy(organizationID string, input *DeployBundleInput, deployGit *astrov1.CreateDeployGitRequest, astroV1Client astrov1.APIClient) (*astrov1.Deploy, error) {
7✔
246
        request := astrov1.CreateDeployRequest{
7✔
247
                Description:     &input.Description,
7✔
248
                Type:            astrov1.CreateDeployRequestTypeBUNDLE,
7✔
249
                BundleMountPath: &input.MountPath,
7✔
250
                BundleType:      &input.BundleType,
7✔
251
                Git:             deployGit,
7✔
252
        }
7✔
253
        resp, err := astroV1Client.CreateDeployWithResponse(context.Background(), organizationID, input.DeploymentID, request)
7✔
254
        if err != nil {
7✔
255
                return nil, err
×
256
        }
×
257
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
7✔
258
        if err != nil {
7✔
259
                return nil, err
×
260
        }
×
261
        return resp.JSON200, nil
7✔
262
}
263

264
func finalizeBundleDeploy(organizationID, deploymentID, deployID, tarballVersion string, astroV1Client astrov1.APIClient) error {
6✔
265
        request := astrov1.FinalizeDeployRequest{
6✔
266
                BundleTarballVersion: &tarballVersion,
6✔
267
        }
6✔
268
        resp, err := astroV1Client.FinalizeDeployWithResponse(context.Background(), organizationID, deploymentID, deployID, request)
6✔
269
        if err != nil {
6✔
270
                return err
×
271
        }
×
272
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
6✔
273
        if err != nil {
6✔
274
                return err
×
275
        }
×
276
        return nil
6✔
277
}
278

279
// retrieveLocalGitMetadata retrieves git metadata from the local repository for deploy tracking.
280
// Returns nil and empty string if the repository has uncommitted changes or if git metadata cannot be retrieved.
281
func retrieveLocalGitMetadata(bundlePath string) (deployGit *astrov1.CreateDeployGitRequest, commitMessage string) {
38✔
282
        if git.HasUncommittedChanges(bundlePath) {
39✔
283
                fmt.Println("Local repository has uncommitted changes, skipping Git metadata retrieval")
1✔
284
                return nil, ""
1✔
285
        }
1✔
286

287
        // get the raw remote URL (needed for the GENERIC provider), assume the remote is named "origin"
288
        remoteURL, err := git.GetRemoteURL(bundlePath, "origin")
37✔
289
        if err != nil {
38✔
290
                logger.Debugf("Failed to retrieve remote repository details, skipping Git metadata retrieval: %s", err)
1✔
291
                return nil, ""
1✔
292
        }
1✔
293
        repoURL, err := git.ParseRemoteURL(remoteURL)
36✔
294
        if err != nil {
36✔
NEW
295
                logger.Debugf("Failed to parse remote repository URL, skipping Git metadata retrieval: %s", err)
×
296
                return nil, ""
×
297
        }
×
298

299
        deployGit = &astrov1.CreateDeployGitRequest{}
36✔
300

36✔
301
        // get the path of the bundle within the repository
36✔
302
        path, err := git.GetLocalRepositoryPathPrefix(bundlePath, bundlePath)
36✔
303
        if err != nil {
69✔
304
                logger.Debugf("Failed to retrieve local repository path prefix, skipping Git metadata retrieval: %s", err)
33✔
305
                return nil, ""
33✔
306
        }
33✔
307
        if path != "" {
4✔
308
                deployGit.Path = &path
1✔
309
        }
1✔
310

311
        // get the branch of the local commit
312
        branch, err := git.GetBranch(bundlePath)
3✔
313
        if err != nil {
3✔
314
                logger.Debugf("Failed to retrieve branch name, skipping Git metadata retrieval: %s", err)
×
315
                return nil, ""
×
316
        }
×
317
        deployGit.Branch = &branch
3✔
318

3✔
319
        // get the local commit
3✔
320
        sha, message, authorName, _, err := git.GetHeadCommit(bundlePath)
3✔
321
        if err != nil {
3✔
322
                logger.Debugf("Failed to retrieve commit, skipping Git metadata retrieval: %s", err)
×
323
                return nil, ""
×
324
        }
×
325
        deployGit.CommitSha = sha
3✔
326
        if authorName != "" {
6✔
327
                deployGit.AuthorName = &authorName
3✔
328
        }
3✔
329

330
        // populate provider-specific fields. GitHub gets first-class treatment; everything else is GENERIC.
331
        if repoURL.Host == "github.com" {
5✔
332
                account, repo, ok := splitGithubPath(repoURL.Path)
2✔
333
                if !ok {
2✔
NEW
334
                        logger.Debugf("Failed to parse GitHub repository path, skipping Git metadata retrieval: %s", repoURL.Path)
×
NEW
335
                        return nil, ""
×
NEW
336
                }
×
337
                deployGit.Provider = astrov1.CreateDeployGitRequestProviderGITHUB
2✔
338
                deployGit.Account = &account
2✔
339
                deployGit.Repo = &repo
2✔
340
                commitURL := fmt.Sprintf("https://%s/%s/%s/commit/%s", repoURL.Host, account, repo, sha)
2✔
341
                deployGit.CommitUrl = &commitURL
2✔
342
        } else {
1✔
343
                deployGit.Provider = astrov1.CreateDeployGitRequestProviderGENERIC
1✔
344
                deployGit.RemoteUrl = &remoteURL
1✔
345
        }
1✔
346

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

3✔
349
        return deployGit, message
3✔
350
}
351

352
func splitGithubPath(path string) (account, repo string, ok bool) {
2✔
353
        trimmed := strings.TrimPrefix(path, "/")
2✔
354
        slash := strings.Index(trimmed, "/")
2✔
355
        if slash == -1 {
2✔
NEW
356
                return "", "", false
×
NEW
357
        }
×
358
        return trimmed[:slash], trimmed[slash+1:], true
2✔
359
}
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

© 2026 Coveralls, Inc