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

astronomer / astro-cli / fc632fcc-6f64-4bcc-a917-49c509c77432

28 Nov 2025 01:32AM UTC coverage: 39.41% (+6.3%) from 33.132%
fc632fcc-6f64-4bcc-a917-49c509c77432

Pull #1988

circleci

Simpcyclassy
Add Houston API 1.1.0 support - remove desiredRuntimeVersion and AC fields

- Add version 1.1.0 queries for DeploymentGetRequest
- Add version 1.1.0 queries for DeploymentsGetRequest
- Add version 1.1.0 queries for PaginatedDeploymentsGetRequest
- Add version 1.1.0 mutations for DeploymentCreateRequest
- Add version 1.1.0 mutations for DeploymentUpdateRequest
- Remove airflowVersion, desiredAirflowVersion, desiredRuntimeVersion fields
- Use runtimeVersion and runtimeAirflowVersion instead

Houston API 1.1.0 no longer returns desired version and AC fields.
All version information is now managed through runtimeVersion.
Pull Request #1988: Add Houston API 1.1.0 support - remove desiredRuntimeVersion and AC fields

23684 of 60096 relevant lines covered (39.41%)

11.04 hits per line

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

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

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

11
        airflowversions "github.com/astronomer/astro-cli/airflow_versions"
12
        astrocore "github.com/astronomer/astro-cli/astro-client-core"
13
        astroplatformcore "github.com/astronomer/astro-cli/astro-client-platform-core"
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
        PlatformCoreClient astroplatformcore.CoreClient
29
        CoreClient         astrocore.CoreClient
30
}
31

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

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

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

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

54
        // retrieve metadata about the local Git checkout. returns nil if not available
55
        deployGit, commitMessage := retrieveLocalGitMetadata(input.BundlePath)
4✔
56

4✔
57
        // if no description was provided, use the commit message from the local Git checkout
4✔
58
        if input.Description == "" {
7✔
59
                input.Description = commitMessage
3✔
60
        }
3✔
61

62
        // initialize the deploy
63
        deploy, err := createBundleDeploy(c.Organization, input, deployGit, input.CoreClient)
4✔
64
        if err != nil {
4✔
65
                return err
×
66
        }
×
67

68
        // check we received an upload URL
69
        if deploy.BundleUploadUrl == nil {
5✔
70
                return errors.New("no bundle upload URL received from Astro")
1✔
71
        }
1✔
72

73
        // upload the bundle
74
        tarballVersion, err := UploadBundle(config.WorkingPath, input.BundlePath, *deploy.BundleUploadUrl, false, currentDeployment.RuntimeVersion)
3✔
75
        if err != nil {
3✔
76
                return err
×
77
        }
×
78

79
        // finalize the deploy
80
        err = finalizeBundleDeploy(c.Organization, input.DeploymentID, deploy.Id, tarballVersion, input.CoreClient)
3✔
81
        if err != nil {
3✔
82
                return err
×
83
        }
×
84
        fmt.Println("Successfully uploaded bundle with version " + tarballVersion + " to Astro.")
3✔
85

3✔
86
        // if requested, wait for the deploy to finish by polling the deployment until it is healthy
3✔
87
        if input.Wait {
3✔
88
                err = deployment.HealthPoll(currentDeployment.Id, currentDeployment.WorkspaceId, dagOnlyDeploySleepTime, tickNum, timeoutNum, input.PlatformCoreClient)
×
89
                if err != nil {
×
90
                        return err
×
91
                }
×
92
        }
93

94
        return nil
3✔
95
}
96

97
type DeleteBundleInput struct {
98
        MountPath          string
99
        DeploymentID       string
100
        WorkspaceID        string
101
        BundleType         string
102
        Description        string
103
        Wait               bool
104
        CoreClient         astrocore.CoreClient
105
        PlatformCoreClient astroplatformcore.CoreClient
106
}
107

108
func DeleteBundle(input *DeleteBundleInput) error {
1✔
109
        c, err := config.GetCurrentContext()
1✔
110
        if err != nil {
1✔
111
                return err
×
112
        }
×
113

114
        // initialize the deploy
115
        createInput := &DeployBundleInput{
1✔
116
                MountPath:    input.MountPath,
1✔
117
                DeploymentID: input.DeploymentID,
1✔
118
                BundleType:   input.BundleType,
1✔
119
                Description:  input.Description,
1✔
120
        }
1✔
121
        deploy, err := createBundleDeploy(c.Organization, createInput, nil, input.CoreClient)
1✔
122
        if err != nil {
1✔
123
                return err
×
124
        }
×
125

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

1✔
133
        // if requested, wait for the deploy to finish by polling the deployment until it is healthy
1✔
134
        if input.Wait {
1✔
135
                err = deployment.HealthPoll(input.DeploymentID, input.WorkspaceID, dagOnlyDeploySleepTime, tickNum, timeoutNum, input.PlatformCoreClient)
×
136
                if err != nil {
×
137
                        return err
×
138
                }
×
139
        }
140

141
        return nil
1✔
142
}
143

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

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

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

164
                        // If the target is not absolute, join it with the directory containing the link
165
                        if !filepath.IsAbs(target) {
13✔
166
                                target = filepath.Join(filepath.Dir(path), target)
6✔
167
                        }
6✔
168

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

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

187
        return nil
5✔
188
}
189

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

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

215
        // Generate the bundle tar
216
        err := fileutil.Tar(bundlePath, tarFilePath, prependBaseDir, []string{".git/"})
23✔
217
        if err != nil {
23✔
218
                return "", err
×
219
        }
×
220

221
        // Gzip the tar
222
        err = fileutil.GzipFile(tarFilePath, tarGzFilePath)
23✔
223
        if err != nil {
23✔
224
                return "", err
×
225
        }
×
226

227
        tarGzFile, err := os.Open(tarGzFilePath)
23✔
228
        if err != nil {
23✔
229
                return "", err
×
230
        }
×
231
        defer tarGzFile.Close()
23✔
232

23✔
233
        versionID, err := azureUploader(uploadURL, tarGzFile)
23✔
234
        if err != nil {
23✔
235
                return "", err
×
236
        }
×
237

238
        return versionID, nil
23✔
239
}
240

241
func createBundleDeploy(organizationID string, input *DeployBundleInput, deployGit *astrocore.DeployGit, coreClient astrocore.CoreClient) (*astrocore.Deploy, error) {
5✔
242
        request := astrocore.CreateDeployRequest{
5✔
243
                Description:     &input.Description,
5✔
244
                Type:            astrocore.CreateDeployRequestTypeBUNDLE,
5✔
245
                BundleMountPath: &input.MountPath,
5✔
246
                BundleType:      &input.BundleType,
5✔
247
        }
5✔
248
        if deployGit != nil {
7✔
249
                request.Git = &astrocore.CreateDeployGitRequest{
2✔
250
                        Provider:   astrocore.CreateDeployGitRequestProvider(deployGit.Provider),
2✔
251
                        Repo:       deployGit.Repo,
2✔
252
                        Account:    deployGit.Account,
2✔
253
                        Path:       deployGit.Path,
2✔
254
                        Branch:     deployGit.Branch,
2✔
255
                        CommitSha:  deployGit.CommitSha,
2✔
256
                        CommitUrl:  fmt.Sprintf("https://github.com/%s/%s/commit/%s", deployGit.Account, deployGit.Repo, deployGit.CommitSha),
2✔
257
                        AuthorName: deployGit.AuthorName,
2✔
258
                }
2✔
259
        }
2✔
260
        resp, err := coreClient.CreateDeployWithResponse(context.Background(), organizationID, input.DeploymentID, request)
5✔
261
        if err != nil {
5✔
262
                return nil, err
×
263
        }
×
264
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
5✔
265
        if err != nil {
5✔
266
                return nil, err
×
267
        }
×
268
        return resp.JSON200, nil
5✔
269
}
270

271
func finalizeBundleDeploy(organizationID, deploymentID, deployID, tarballVersion string, coreClient astrocore.CoreClient) error {
4✔
272
        request := astrocore.UpdateDeployRequest{
4✔
273
                BundleTarballVersion: &tarballVersion,
4✔
274
        }
4✔
275
        resp, err := coreClient.UpdateDeployWithResponse(context.Background(), organizationID, deploymentID, deployID, request)
4✔
276
        if err != nil {
4✔
277
                return err
×
278
        }
×
279
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
4✔
280
        if err != nil {
4✔
281
                return err
×
282
        }
×
283
        return nil
4✔
284
}
285

286
func retrieveLocalGitMetadata(bundlePath string) (deployGit *astrocore.DeployGit, commitMessage string) {
4✔
287
        if git.HasUncommittedChanges(bundlePath) {
5✔
288
                fmt.Println("Local repository has uncommitted changes, skipping Git metadata retrieval")
1✔
289
                return nil, ""
1✔
290
        }
1✔
291

292
        deployGit = &astrocore.DeployGit{}
3✔
293

3✔
294
        // get the remote repository details, assume the remote is named "origin"
3✔
295
        repoURL, err := git.GetRemoteRepository(bundlePath, "origin")
3✔
296
        if err != nil {
4✔
297
                logger.Debugf("Failed to retrieve remote repository details, skipping Git metadata retrieval: %s", err)
1✔
298
                return nil, ""
1✔
299
        }
1✔
300
        switch repoURL.Host {
2✔
301
        case "github.com":
2✔
302
                deployGit.Provider = astrocore.DeployGitProviderGITHUB
2✔
303
        default:
×
304
                logger.Debugf("Unsupported Git provider, skipping Git metadata retrieval: %s", repoURL.Host)
×
305
                return nil, ""
×
306
        }
307
        urlPath := strings.TrimPrefix(repoURL.Path, "/")
2✔
308
        firstSlashIndex := strings.Index(urlPath, "/")
2✔
309
        if firstSlashIndex == -1 {
2✔
310
                logger.Debugf("Failed to parse remote repository path, skipping Git metadata retrieval: %s", repoURL.Path)
×
311
                return nil, ""
×
312
        }
×
313
        deployGit.Account = urlPath[:firstSlashIndex]
2✔
314
        deployGit.Repo = urlPath[firstSlashIndex+1:]
2✔
315

2✔
316
        // get the path of the bundle within the repository
2✔
317
        path, err := git.GetLocalRepositoryPathPrefix(bundlePath, bundlePath)
2✔
318
        if err != nil {
2✔
319
                logger.Debugf("Failed to retrieve local repository path prefix, skipping Git metadata retrieval: %s", err)
×
320
                return nil, ""
×
321
        }
×
322
        if path != "" {
3✔
323
                deployGit.Path = &path
1✔
324
        }
1✔
325

326
        // get the branch of the local commit
327
        branch, err := git.GetBranch(bundlePath)
2✔
328
        if err != nil {
2✔
329
                logger.Debugf("Failed to retrieve branch name, skipping Git metadata retrieval: %s", err)
×
330
                return nil, ""
×
331
        }
×
332
        deployGit.Branch = branch
2✔
333

2✔
334
        // get the local commit
2✔
335
        sha, message, authorName, _, err := git.GetHeadCommit(bundlePath)
2✔
336
        if err != nil {
2✔
337
                logger.Debugf("Failed to retrieve commit, skipping Git metadata retrieval: %s", err)
×
338
                return nil, ""
×
339
        }
×
340
        deployGit.CommitSha = sha
2✔
341
        if authorName != "" {
4✔
342
                deployGit.AuthorName = &authorName
2✔
343
        }
2✔
344

345
        // derive the remote URL of the local commit
346
        switch repoURL.Host {
2✔
347
        case "github.com":
2✔
348
                deployGit.CommitUrl = fmt.Sprintf("https://%s/%s/%s/commit/%s", repoURL.Host, deployGit.Account, deployGit.Repo, sha)
2✔
349
        default:
×
350
                logger.Debugf("Unsupported Git provider, skipping Git metadata retrieval: %s", repoURL.Host)
×
351
                return nil, ""
×
352
        }
353

354
        logger.Debugf("Retrieved Git metadata: %+v", deployGit)
2✔
355

2✔
356
        return deployGit, message
2✔
357
}
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