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

astronomer / astro-cli / 28970289308

08 Jul 2026 07:35PM UTC coverage: 43.611% (-1.7%) from 45.328%
28970289308

Pull #2198

github

web-flow
Merge c33eb2682 into 44bc04a46
Pull Request #2198: Expose non-DAG bundle deploys and bundle management

376 of 3398 new or added lines in 11 files covered. (11.07%)

17 existing lines in 2 files now uncovered.

25531 of 58542 relevant lines covered (43.61%)

8.25 hits per line

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

81.06
/cloud/deploy/deploy.go
1
package deploy
2

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

13
        "github.com/moby/patternmatcher"
14
        "github.com/moby/patternmatcher/ignorefile"
15
        "github.com/pkg/errors"
16

17
        "github.com/astronomer/astro-cli/airflow"
18
        "github.com/astronomer/astro-cli/airflow/types"
19
        airflowversions "github.com/astronomer/astro-cli/airflow_versions"
20
        "github.com/astronomer/astro-cli/astro-client-v1"
21
        astrov1alpha1 "github.com/astronomer/astro-cli/astro-client-v1alpha1"
22
        "github.com/astronomer/astro-cli/cloud/deployment"
23
        "github.com/astronomer/astro-cli/cloud/organization"
24
        "github.com/astronomer/astro-cli/config"
25
        "github.com/astronomer/astro-cli/docker"
26
        "github.com/astronomer/astro-cli/pkg/ansi"
27
        "github.com/astronomer/astro-cli/pkg/azure"
28
        "github.com/astronomer/astro-cli/pkg/fileutil"
29
        "github.com/astronomer/astro-cli/pkg/httputil"
30
        "github.com/astronomer/astro-cli/pkg/input"
31
        "github.com/astronomer/astro-cli/pkg/logger"
32
        "github.com/astronomer/astro-cli/pkg/util"
33
)
34

35
const (
36
        parse                  = "parse"
37
        astroDomain            = "astronomer.io"
38
        registryUsername       = "cli"
39
        runtimeImageLabel      = airflow.RuntimeImageLabel
40
        dagParseAllowedVersion = "4.1.0"
41

42
        composeImageBuildingPromptMsg     = "Building image..."
43
        composeSkipImageBuildingPromptMsg = "Skipping building image..."
44
        deploymentHeaderMsg               = "Authenticated to %s \n\n"
45

46
        warningInvalidImageNameMsg         = "WARNING! The image in your Dockerfile '%s' is not based on Astro Runtime and is not supported. Change your Dockerfile with an image that pulls from 'quay.io/astronomer/astro-runtime' to proceed.\n"
47
        warningInvalidPrebuiltImageNameMsg = "WARNING! The image '%s' does not appear to be based on Astro Runtime (the '%s' label is missing). Ensure your image is built FROM quay.io/astronomer/astro-runtime to proceed.\n"
48

49
        allTests                 = "all-tests"
50
        parseAndPytest           = "parse-and-all-tests"
51
        enableDagDeployMsg       = "DAG-only deploys are not enabled for this Deployment. Run 'astro deployment update %s --dag-deploy enable' to enable DAG-only deploys"
52
        dagDeployDisabled        = "dag deploy is not enabled for deployment"
53
        invalidWorkspaceID       = "Invalid workspace id %s was provided through the --workspace-id flag\n"
54
        errCiCdEnforcementUpdate = "cannot deploy since ci/cd enforcement is enabled for the deployment %s. Please use API Tokens instead"
55
)
56

57
var (
58
        pytestFile string
59
        dockerfile = "Dockerfile"
60

61
        deployImagePlatformSupport = []string{"linux/amd64"}
62

63
        // Monkey patched to write unit tests
64
        airflowImageHandler  = airflow.ImageHandlerInit
65
        containerHandlerInit = airflow.ContainerHandlerInit
66
        azureUploader        = azure.Upload
67
        canCiCdDeploy        = deployment.CanCiCdDeploy
68
        dagTarballVersion    = ""
69
        dagsUploadURL        = ""
70
        nextTag              = ""
71
)
72

73
var (
74
        errDagsParseFailed = errors.New("your local DAGs did not parse. Fix the listed errors or use `astro deploy [deployment-id] -f` to force deploy") //nolint:revive
75
        envFileMissing     = errors.New("Env file path is incorrect: ")                                                                                  //nolint:revive
76
)
77

78
var (
79
        sleepTime              = 90
80
        dagOnlyDeploySleepTime = 30
81
        tickNum                = 10
82
)
83

84
type deploymentInfo struct {
85
        deploymentID             string
86
        namespace                string
87
        deployImage              string
88
        currentVersion           string
89
        organizationID           string
90
        workspaceID              string
91
        webserverURL             string
92
        deploymentType           string
93
        desiredDagTarballVersion string
94
        dagDeployEnabled         bool
95
        cicdEnforcement          bool
96
        name                     string
97
        isRemoteExecutionEnabled bool
98
}
99

100
type InputDeploy struct {
101
        Path              string
102
        RuntimeID         string
103
        WsID              string
104
        Pytest            string
105
        EnvFile           string
106
        ImageName         string
107
        DeploymentName    string
108
        Prompt            bool
109
        Dags              bool
110
        NoDagsBaseDir     bool
111
        Image             bool
112
        WaitForStatus     bool
113
        WaitTime          time.Duration
114
        DagsPath          string
115
        Description       string
116
        BuildSecretString string
117
        Force             bool
118
        DagBundleName     string
119
}
120

121
// InputClientDeploy contains inputs for client image deployments
122
type InputClientDeploy struct {
123
        Path              string
124
        ImageName         string
125
        Platform          string
126
        BuildSecretString string
127
        DeploymentID      string
128
}
129

130
const accessYourDeploymentFmt = `
131

132
 Access your Deployment:
133

134
 Deployment View: %s
135
 Airflow UI: %s
136
`
137

138
func removeDagsFromDockerIgnore(fullpath string) error {
25✔
139
        original, err := os.ReadFile(fullpath)
25✔
140
        if err != nil {
25✔
141
                return err
×
142
        }
×
143

144
        hadTrailingNewline := len(original) > 0 && original[len(original)-1] == '\n'
25✔
145

25✔
146
        var buf bytes.Buffer
25✔
147
        scanner := bufio.NewScanner(bytes.NewReader(original))
25✔
148
        for scanner.Scan() {
32✔
149
                text := scanner.Text()
7✔
150
                if text != "dags/" {
11✔
151
                        _, err = buf.WriteString(text + "\n")
4✔
152
                        if err != nil {
4✔
153
                                return err
×
154
                        }
×
155
                }
156
        }
157

158
        if err := scanner.Err(); err != nil {
25✔
159
                return err
×
160
        }
×
161

162
        result := bytes.TrimRight(buf.Bytes(), "\n")
25✔
163
        if hadTrailingNewline && len(result) > 0 {
27✔
164
                result = append(result, '\n')
2✔
165
        }
2✔
166

167
        return os.WriteFile(fullpath, result, 0o666) //nolint:gosec, mnd
25✔
168
}
169

170
func shouldIncludeMonitoringDag(deploymentType astrov1.DeploymentType) bool {
21✔
171
        return !organization.IsOrgHosted() && !deployment.IsDeploymentDedicated(deploymentType) && !deployment.IsDeploymentStandard(deploymentType)
21✔
172
}
21✔
173

174
func deployDags(path, dagsPath, dagsUploadURL, currentRuntimeVersion string, deploymentType astrov1.DeploymentType, noDagsBaseDir bool) (string, error) {
21✔
175
        if shouldIncludeMonitoringDag(deploymentType) {
38✔
176
                monitoringDagPath := filepath.Join(dagsPath, "astronomer_monitoring_dag.py")
17✔
177

17✔
178
                // Create monitoring dag file
17✔
179
                err := fileutil.WriteStringToFile(monitoringDagPath, airflow.Af2MonitoringDag)
17✔
180
                if err != nil {
17✔
181
                        return "", err
×
182
                }
×
183

184
                // Remove the monitoring dag file after the upload
185
                defer os.Remove(monitoringDagPath)
17✔
186
        }
187

188
        // By default, prepend dags/ directory prefix. Use --no-dags-base-dir to place files at bundle root
189
        // (needed for some Airflow 3.x deployments where sys.path includes the bundle root, not dags/).
190
        prependBaseDir := !noDagsBaseDir
21✔
191
        versionID, err := UploadBundle(path, dagsPath, dagsUploadURL, prependBaseDir, currentRuntimeVersion)
21✔
192
        if err != nil {
21✔
193
                return "", err
×
194
        }
×
195

196
        return versionID, nil
21✔
197
}
198

199
// Deploy pushes a new docker image
200
func Deploy(deployInput InputDeploy, astroV1Client astrov1.APIClient, astroV1Alpha1Client astrov1alpha1.APIClient) error { //nolint
44✔
201
        c, err := config.GetCurrentContext()
44✔
202
        if err != nil {
45✔
203
                return err
1✔
204
        }
1✔
205

206
        if c.Domain == astroDomain {
46✔
207
                fmt.Printf(deploymentHeaderMsg, "Astro")
3✔
208
        } else {
43✔
209
                fmt.Printf(deploymentHeaderMsg, c.Domain)
40✔
210
        }
40✔
211

212
        deployInfo, err := getDeploymentInfo(deployInput.RuntimeID, deployInput.WsID, deployInput.DeploymentName, deployInput.Prompt, astroV1Client)
43✔
213
        if err != nil {
43✔
214
                return err
×
215
        }
×
216

217
        var dagsPath string
43✔
218
        if deployInput.DagsPath != "" {
58✔
219
                dagsPath = deployInput.DagsPath
15✔
220
        } else {
43✔
221
                dagsPath = filepath.Join(deployInput.Path, "dags")
28✔
222
        }
28✔
223

224
        var dagFiles []string
43✔
225
        if !deployInfo.isRemoteExecutionEnabled {
80✔
226
                dagFiles = fileutil.GetFilesWithSpecificExtension(dagsPath, ".py")
37✔
227
        }
37✔
228

229
        if deployInfo.cicdEnforcement {
44✔
230
                if !canCiCdDeploy(c.Token) {
2✔
231
                        return fmt.Errorf(errCiCdEnforcementUpdate, deployInfo.name) //nolint
1✔
232
                }
1✔
233
        }
234

235
        if deployInput.WsID != deployInfo.workspaceID {
43✔
236
                fmt.Printf(invalidWorkspaceID, deployInput.WsID)
1✔
237
                return nil
1✔
238
        }
1✔
239

240
        if deployInput.Image && !deployInfo.isRemoteExecutionEnabled {
44✔
241
                if !deployInfo.dagDeployEnabled {
3✔
242
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
243
                }
×
244
        }
245

246
        deploymentURL, err := deployment.GetDeploymentURL(deployInfo.deploymentID, deployInfo.workspaceID)
41✔
247
        if err != nil {
41✔
248
                return err
×
249
        }
×
250

251
        // Check if git metadata is enabled (default: true).
252
        // Skip when --image-name is provided: the local working directory does not necessarily
253
        // reflect the contents of a prebuilt image, so attaching its git metadata would be misleading.
254
        var deployGit *astrov1.CreateDeployGitRequest
41✔
255
        var commitMessage string
41✔
256
        if config.CFG.DeployGitMetadata.GetBool() && deployInput.ImageName == "" {
74✔
257
                deployGit, commitMessage = retrieveLocalGitMetadata(deployInput.Path)
33✔
258
        }
33✔
259

260
        // Use commit message as description fallback
261
        description := deployInput.Description
41✔
262
        if description == "" {
82✔
263
                description = commitMessage
41✔
264
        }
41✔
265

266
        // Build the deploy request with git metadata
267
        createDeployRequest := astrov1.CreateDeployRequest{
41✔
268
                Description: &description,
41✔
269
        }
41✔
270

41✔
271
        // Set deploy type
41✔
272
        switch {
41✔
273
        case deployInput.Dags:
19✔
274
                createDeployRequest.Type = astrov1.CreateDeployRequestTypeDAGONLY
19✔
275
        case deployInput.Image:
2✔
276
                createDeployRequest.Type = astrov1.CreateDeployRequestTypeIMAGEONLY
2✔
277
        default:
20✔
278
                createDeployRequest.Type = astrov1.CreateDeployRequestTypeIMAGEANDDAG
20✔
279
        }
280

281
        createDeployRequest.Git = deployGit
41✔
282

41✔
283
        var deployID, imageRepository string
41✔
284
        if deployInput.DagBundleName != "" {
41✔
NEW
285
                // dagBundleName exists only on the v1alpha1 deploy API, so route just the
×
NEW
286
                // create call there; the upload and finalize steps below stay on v1 and
×
NEW
287
                // operate on the same underlying deploy. This cross-API bridge is
×
NEW
288
                // intentional and temporary — collapse back into the v1 create once
×
NEW
289
                // dagBundleName reaches the v1 deploy API.
×
NEW
290
                deploy, err := createDeployWithDagBundle(deployInfo.organizationID, deployInfo.deploymentID, description, deployInput.DagBundleName, deployInput.Dags, deployGit, astroV1Alpha1Client)
×
NEW
291
                if err != nil {
×
NEW
292
                        return err
×
NEW
293
                }
×
NEW
294
                deployID = deploy.Id
×
NEW
295
                imageRepository = deploy.ImageRepository
×
NEW
296
                if deploy.DagsUploadUrl != nil {
×
NEW
297
                        dagsUploadURL = *deploy.DagsUploadUrl
×
NEW
298
                } else {
×
NEW
299
                        dagsUploadURL = ""
×
NEW
300
                }
×
NEW
301
                if deploy.ImageTag != "" {
×
NEW
302
                        nextTag = deploy.ImageTag
×
NEW
303
                } else {
×
NEW
304
                        nextTag = ""
×
NEW
305
                }
×
306
        } else {
41✔
307
                deploy, err := createDeploy(deployInfo.organizationID, deployInfo.deploymentID, createDeployRequest, astroV1Client)
41✔
308
                if err != nil {
41✔
NEW
309
                        return err
×
NEW
310
                }
×
311
                deployID = deploy.Id
41✔
312
                imageRepository = deploy.ImageRepository
41✔
313
                if deploy.DagsUploadUrl != nil {
82✔
314
                        dagsUploadURL = *deploy.DagsUploadUrl
41✔
315
                } else {
41✔
NEW
316
                        dagsUploadURL = ""
×
NEW
317
                }
×
318
                if deploy.ImageTag != "" {
41✔
NEW
319
                        nextTag = deploy.ImageTag
×
320
                } else {
41✔
321
                        nextTag = ""
41✔
322
                }
41✔
323
        }
324

325
        if deployInput.Dags {
60✔
326
                if len(dagFiles) == 0 && config.CFG.ShowWarnings.GetBool() && !deployInput.Force {
20✔
327
                        i, _ := input.Confirm("Warning: No DAGs found. This will delete any existing DAGs. Are you sure you want to deploy?")
1✔
328

1✔
329
                        if !i {
2✔
330
                                fmt.Println("Canceling deploy...")
1✔
331
                                return nil
1✔
332
                        }
1✔
333
                }
334
                if deployInput.Pytest != "" {
30✔
335
                        runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, astroV1Client)
12✔
336
                        if err != nil {
12✔
337
                                return err
×
338
                        }
×
339

340
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecretString)
12✔
341
                        if err != nil {
14✔
342
                                return err
2✔
343
                        }
2✔
344
                }
345

346
                if !deployInfo.dagDeployEnabled {
17✔
347
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
1✔
348
                }
1✔
349

350
                fmt.Println("Initiating DAG deploy for: " + deployInfo.deploymentID)
15✔
351
                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astrov1.DeploymentType(deployInfo.deploymentType), deployInput.NoDagsBaseDir)
15✔
352
                if err != nil {
15✔
353
                        if strings.Contains(err.Error(), dagDeployDisabled) {
×
354
                                return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
355
                        }
×
356

357
                        return err
×
358
                }
359

360
                // finish deploy
361
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, astroV1Client)
15✔
362
                if err != nil {
15✔
363
                        return err
×
364
                }
×
365

366
                if deployInput.WaitForStatus {
16✔
367
                        // Keeping wait timeout low since dag only deploy is faster
1✔
368
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, dagOnlyDeploySleepTime, tickNum, int(deployInput.WaitTime.Seconds()), astroV1Client)
1✔
369
                        if err != nil {
2✔
370
                                return err
1✔
371
                        }
1✔
372

373
                        fmt.Println(
×
374
                                "\nSuccessfully uploaded DAGs with version " + ansi.Bold(dagTarballVersion) + " to Astro. Navigate to the Airflow UI to confirm that your deploy was successful." +
×
375
                                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold(deploymentURL), ansi.Bold(deployInfo.webserverURL)),
×
376
                        )
×
377

×
378
                        return nil
×
379
                }
380

381
                fmt.Println(
14✔
382
                        "\nSuccessfully uploaded DAGs with version " + ansi.Bold(
14✔
383
                                dagTarballVersion,
14✔
384
                        ) + " to Astro. Navigate to the Airflow UI to confirm that your deploy was successful. The Airflow UI takes about 1 minute to update." +
14✔
385
                                fmt.Sprintf(
14✔
386
                                        accessYourDeploymentFmt,
14✔
387
                                        ansi.Bold(deploymentURL),
14✔
388
                                        ansi.Bold(deployInfo.webserverURL),
14✔
389
                                ),
14✔
390
                )
14✔
391
        } else {
22✔
392
                fullpath := filepath.Join(deployInput.Path, ".dockerignore")
22✔
393
                fileExist, _ := fileutil.Exists(fullpath, nil)
22✔
394
                if fileExist {
44✔
395
                        err := removeDagsFromDockerIgnore(fullpath)
22✔
396
                        if err != nil {
22✔
397
                                return errors.Wrap(err, "Found dags entry in .dockerignore file. Remove this entry and try again")
×
398
                        }
×
399
                }
400
                envFileExists, _ := fileutil.Exists(deployInput.EnvFile, nil)
22✔
401
                if !envFileExists && deployInput.EnvFile != ".env" {
23✔
402
                        return fmt.Errorf("%w %s", envFileMissing, deployInput.EnvFile)
1✔
403
                }
1✔
404

405
                if deployInfo.dagDeployEnabled && len(dagFiles) == 0 && config.CFG.ShowWarnings.GetBool() && !deployInput.Image && !deployInput.Force {
21✔
406
                        i, _ := input.Confirm("Warning: No DAGs found. This will delete any existing DAGs. Are you sure you want to deploy?")
×
407

×
408
                        if !i {
×
409
                                fmt.Println("Canceling deploy...")
×
410
                                return nil
×
411
                        }
×
412
                }
413

414
                // Build our image
415
                runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, astroV1Client)
21✔
416
                if err != nil {
21✔
417
                        return err
×
418
                }
×
419

420
                if len(dagFiles) > 0 {
28✔
421
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecretString)
7✔
422
                        if err != nil {
8✔
423
                                return err
1✔
424
                        }
1✔
425
                } else {
14✔
426
                        fmt.Println("No DAGs found. Skipping testing...")
14✔
427
                }
14✔
428

429
                repository := imageRepository
20✔
430
                // TODO: Resolve the edge case where two people push the same nextTag at the same time
20✔
431
                remoteImage := fmt.Sprintf("%s:%s", repository, nextTag)
20✔
432

20✔
433
                imageHandler := airflowImageHandler(deployInfo.deployImage)
20✔
434
                fmt.Println("Pushing image to Astronomer registry")
20✔
435
                _, err = imageHandler.Push(remoteImage, registryUsername, c.Token, false)
20✔
436
                if err != nil {
20✔
437
                        return err
×
438
                }
×
439

440
                if deployInfo.dagDeployEnabled && len(dagFiles) > 0 {
26✔
441
                        if !deployInput.Image {
12✔
442
                                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astrov1.DeploymentType(deployInfo.deploymentType), deployInput.NoDagsBaseDir)
6✔
443
                                if err != nil {
6✔
444
                                        return err
×
445
                                }
×
446
                        } else {
×
447
                                fmt.Println("Image Deploy only. Skipping deploying DAG...")
×
448
                        }
×
449
                }
450
                // finish deploy
451
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, astroV1Client)
20✔
452
                if err != nil {
20✔
453
                        return err
×
454
                }
×
455

456
                if deployInput.WaitForStatus {
22✔
457
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, sleepTime, tickNum, int(deployInput.WaitTime.Seconds()), astroV1Client)
2✔
458
                        if err != nil {
4✔
459
                                return err
2✔
460
                        }
2✔
461
                }
462

463
                fmt.Println("Successfully pushed image to Astronomer registry. Navigate to the Astronomer UI for confirmation that your deploy was successful. To deploy dags only run astro deploy --dags." +
18✔
464
                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold("https://"+deploymentURL), ansi.Bold("https://"+deployInfo.webserverURL)))
18✔
465
        }
466

467
        return nil
32✔
468
}
469

470
func getDeploymentInfo(
471
        deploymentID, wsID, deploymentName string,
472
        prompt bool,
473
        astroV1Client astrov1.APIClient,
474
) (deploymentInfo, error) {
43✔
475
        // Use config deployment if provided
43✔
476
        if deploymentID == "" {
57✔
477
                deploymentID = config.CFG.ProjectDeployment.GetProjectString()
14✔
478
                if deploymentID != "" {
14✔
479
                        fmt.Printf("Deployment ID found in the config file. This Deployment ID will be used for the deploy\n")
×
480
                }
×
481
        }
482

483
        if deploymentID != "" && deploymentName != "" {
51✔
484
                fmt.Printf("Both a Deployment ID and Deployment name have been supplied. The Deployment ID %s will be used for the Deploy\n", deploymentID)
8✔
485
        }
8✔
486

487
        // check if deploymentID or if force prompt was requested was given by user
488
        if deploymentID == "" || prompt {
70✔
489
                currentDeployment, err := deployment.GetDeployment(wsID, deploymentID, deploymentName, false, nil, astroV1Client)
27✔
490
                if err != nil {
27✔
491
                        return deploymentInfo{}, err
×
492
                }
×
493
                deploymentByID, err := deployment.GetDeploymentByID(currentDeployment.OrganizationId, currentDeployment.Id, astroV1Client)
27✔
494
                if err != nil {
27✔
495
                        return deploymentInfo{}, err
×
496
                }
×
497
                var desiredDagTarballVersion string
27✔
498
                if deploymentByID.DesiredDagTarballVersion != nil {
45✔
499
                        desiredDagTarballVersion = *deploymentByID.DesiredDagTarballVersion
18✔
500
                } else {
27✔
501
                        desiredDagTarballVersion = ""
9✔
502
                }
9✔
503

504
                return deploymentInfo{
27✔
505
                        currentDeployment.Id,
27✔
506
                        currentDeployment.Namespace,
27✔
507
                        airflow.ImageName(currentDeployment.Namespace, "latest"),
27✔
508
                        currentDeployment.RuntimeVersion,
27✔
509
                        currentDeployment.OrganizationId,
27✔
510
                        currentDeployment.WorkspaceId,
27✔
511
                        currentDeployment.WebServerUrl,
27✔
512
                        string(*currentDeployment.Type),
27✔
513
                        desiredDagTarballVersion,
27✔
514
                        currentDeployment.IsDagDeployEnabled,
27✔
515
                        currentDeployment.IsCicdEnforced,
27✔
516
                        currentDeployment.Name,
27✔
517
                        deployment.IsRemoteExecutionEnabled(&currentDeployment),
27✔
518
                }, nil
27✔
519
        }
520
        c, err := config.GetCurrentContext()
16✔
521
        if err != nil {
16✔
522
                return deploymentInfo{}, err
×
523
        }
×
524
        deployInfo, err := fetchDeploymentDetails(deploymentID, c.Organization, astroV1Client)
16✔
525
        if err != nil {
16✔
526
                return deploymentInfo{}, err
×
527
        }
×
528
        deployInfo.deploymentID = deploymentID
16✔
529
        return deployInfo, nil
16✔
530
}
531

532
func parseOrPytestDAG(pytest, runtimeVersion, envFile, deployImage, namespace, buildSecretString string) error {
19✔
533
        validDAGParseVersion := airflowversions.CompareRuntimeVersions(runtimeVersion, dagParseAllowedVersion) >= 0
19✔
534
        if !validDAGParseVersion {
19✔
535
                fmt.Println("\nruntime image is earlier than 4.1.0, this deploy will skip DAG parse...")
×
536
        }
×
537

538
        containerHandler, err := containerHandlerInit(config.WorkingPath, envFile, "Dockerfile", namespace)
19✔
539
        if err != nil {
19✔
540
                return err
×
541
        }
×
542

543
        switch {
19✔
544
        case pytest == parse && validDAGParseVersion:
7✔
545
                // parse dags
7✔
546
                fmt.Println("Testing image...")
7✔
547
                err := parseDAGs(deployImage, buildSecretString, containerHandler)
7✔
548
                if err != nil {
9✔
549
                        return err
2✔
550
                }
2✔
551
        case pytest != "" && pytest != parse && pytest != parseAndPytest:
6✔
552
                // check pytests
6✔
553
                fmt.Println("Testing image...")
6✔
554
                err := checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
555
                if err != nil {
7✔
556
                        return err
1✔
557
                }
1✔
558
        case pytest == parseAndPytest:
6✔
559
                // parse dags and check pytests
6✔
560
                fmt.Println("Testing image...")
6✔
561
                err := parseDAGs(deployImage, buildSecretString, containerHandler)
6✔
562
                if err != nil {
6✔
563
                        return err
×
564
                }
×
565

566
                err = checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
567
                if err != nil {
6✔
568
                        return err
×
569
                }
×
570
        }
571
        return nil
16✔
572
}
573

574
func parseDAGs(deployImage, buildSecretString string, containerHandler airflow.ContainerHandler) error {
13✔
575
        if !config.CFG.SkipParse.GetBool() && !util.CheckEnvBool(os.Getenv("ASTRONOMER_SKIP_PARSE")) {
26✔
576
                err := containerHandler.Parse("", deployImage, buildSecretString)
13✔
577
                if err != nil {
15✔
578
                        fmt.Println(err)
2✔
579
                        return errDagsParseFailed
2✔
580
                }
2✔
581
        } else {
×
582
                fmt.Println("Skipping parsing dags due to skip parse being set to true in either the config.yaml or local environment variables")
×
583
        }
×
584

585
        return nil
11✔
586
}
587

588
// Validate code with pytest
589
func checkPytest(pytest, deployImage, buildSecretString string, containerHandler airflow.ContainerHandler) error {
14✔
590
        if pytest != allTests && pytest != parseAndPytest {
18✔
591
                pytestFile = pytest
4✔
592
        }
4✔
593

594
        exitCode, err := containerHandler.Pytest(pytestFile, "", deployImage, "", buildSecretString)
14✔
595
        if err != nil {
17✔
596
                if strings.Contains(exitCode, "1") { // exit code is 1 meaning tests failed
4✔
597
                        return errors.New("at least 1 pytest in your tests directory failed. Fix the issues listed or rerun the command without the '--pytest' flag to deploy")
1✔
598
                }
1✔
599
                return errors.Wrap(err, "Something went wrong while Pytesting your DAGs,\nif the issue persists rerun the command without the '--pytest' flag to deploy")
2✔
600
        }
601

602
        fmt.Print("\nAll Pytests passed!\n")
11✔
603
        return err
11✔
604
}
605

606
func fetchDeploymentDetails(deploymentID, organizationID string, astroV1Client astrov1.APIClient) (deploymentInfo, error) {
24✔
607
        resp, err := astroV1Client.GetDeploymentWithResponse(httpContext.Background(), organizationID, deploymentID)
24✔
608
        if err != nil {
24✔
609
                return deploymentInfo{}, err
×
610
        }
×
611

612
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
24✔
613
        if err != nil {
25✔
614
                return deploymentInfo{}, err
1✔
615
        }
1✔
616

617
        currentVersion := resp.JSON200.RuntimeVersion
23✔
618
        namespace := resp.JSON200.Namespace
23✔
619
        workspaceID := resp.JSON200.WorkspaceId
23✔
620
        webserverURL := resp.JSON200.WebServerUrl
23✔
621
        dagDeployEnabled := resp.JSON200.IsDagDeployEnabled
23✔
622
        cicdEnforcement := resp.JSON200.IsCicdEnforced
23✔
623
        isRemoteExecutionEnabled := deployment.IsRemoteExecutionEnabled(resp.JSON200)
23✔
624
        var desiredDagTarballVersion string
23✔
625
        if resp.JSON200.DesiredDagTarballVersion != nil {
30✔
626
                desiredDagTarballVersion = *resp.JSON200.DesiredDagTarballVersion
7✔
627
        } else {
23✔
628
                desiredDagTarballVersion = ""
16✔
629
        }
16✔
630

631
        // We use latest and keep this tag around after deployments to keep subsequent deploys quick
632
        deployImage := airflow.ImageName(namespace, "latest")
23✔
633

23✔
634
        return deploymentInfo{
23✔
635
                namespace:                namespace,
23✔
636
                deployImage:              deployImage,
23✔
637
                currentVersion:           currentVersion,
23✔
638
                organizationID:           organizationID,
23✔
639
                workspaceID:              workspaceID,
23✔
640
                webserverURL:             webserverURL,
23✔
641
                dagDeployEnabled:         dagDeployEnabled,
23✔
642
                desiredDagTarballVersion: desiredDagTarballVersion,
23✔
643
                cicdEnforcement:          cicdEnforcement,
23✔
644
                isRemoteExecutionEnabled: isRemoteExecutionEnabled,
23✔
645
        }, nil
23✔
646
}
647

648
func buildImageWithoutDags(path, buildSecretString string, imageHandler airflow.ImageHandler) error {
29✔
649
        fullpath := filepath.Join(path, ".dockerignore")
29✔
650

29✔
651
        // Snapshot the original bytes so we can restore byte-for-byte after the build
29✔
652
        // (preserves CRLF, trailing whitespace, etc.).
29✔
653
        originalBytes, err := os.ReadFile(fullpath)
29✔
654
        originalExisted := err == nil
29✔
655
        if err != nil && !os.IsNotExist(err) {
29✔
656
                return err
×
657
        }
×
658

659
        defer func() {
58✔
660
                if originalExisted {
57✔
661
                        _ = os.WriteFile(fullpath, originalBytes, 0o644) //nolint:gosec,mnd
28✔
662
                } else {
29✔
663
                        _ = os.Remove(fullpath)
1✔
664
                }
1✔
665
        }()
666

667
        switch {
29✔
668
        case !originalExisted:
1✔
669
                if err := os.WriteFile(fullpath, []byte("dags/\n"), 0o644); err != nil { //nolint:gosec,mnd
1✔
670
                        return err
×
671
                }
×
672
        case !dockerignoreContainsDags(originalBytes):
26✔
673
                modified := append([]byte{}, originalBytes...)
26✔
674
                if len(modified) > 0 && modified[len(modified)-1] != '\n' {
28✔
675
                        modified = append(modified, '\n')
2✔
676
                }
2✔
677
                modified = append(modified, []byte("dags/\n")...)
26✔
678
                if err := os.WriteFile(fullpath, modified, 0o644); err != nil { //nolint:gosec,mnd
26✔
679
                        return err
×
680
                }
×
681
        }
682

683
        return imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
29✔
684
}
685

686
// dockerignoreContainsDags reports whether content has a line equal to "dags/".
687
// Uses bufio.Scanner so CRLF line endings are handled identically to LF.
688
func dockerignoreContainsDags(content []byte) bool {
28✔
689
        scanner := bufio.NewScanner(bytes.NewReader(content))
28✔
690
        for scanner.Scan() {
40✔
691
                if scanner.Text() == "dags/" {
14✔
692
                        return true
2✔
693
                }
2✔
694
        }
695
        return false
26✔
696
}
697

698
func buildImage(path, currentVersion, deployImage, imageName, organizationID, buildSecretString string, dagDeployEnabled, isRemoteExecutionEnabled bool, astroV1Client astrov1.APIClient) (version string, err error) {
37✔
699
        imageHandler := airflowImageHandler(deployImage)
37✔
700

37✔
701
        if imageName == "" {
65✔
702
                // Build our image
28✔
703
                fmt.Println(composeImageBuildingPromptMsg)
28✔
704

28✔
705
                if dagDeployEnabled || isRemoteExecutionEnabled {
48✔
706
                        err := buildImageWithoutDags(path, buildSecretString, imageHandler)
20✔
707
                        if err != nil {
20✔
708
                                return "", err
×
709
                        }
×
710
                } else {
8✔
711
                        err := imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
8✔
712
                        if err != nil {
9✔
713
                                return "", err
1✔
714
                        }
1✔
715
                }
716
        } else {
9✔
717
                // skip build if an imageName is passed
9✔
718
                fmt.Println(composeSkipImageBuildingPromptMsg)
9✔
719

9✔
720
                err := imageHandler.TagLocalImage(imageName)
9✔
721
                if err != nil {
9✔
722
                        return "", err
×
723
                }
×
724
        }
725

726
        version, err = imageHandler.GetLabel("", runtimeImageLabel)
36✔
727
        if err != nil {
36✔
728
                fmt.Println("unable get runtime version from image")
×
729
        }
×
730

731
        if config.CFG.ShowWarnings.GetBool() && version == "" {
37✔
732
                if imageName != "" {
1✔
733
                        // Registry image names are arbitrary and do not convey base image
×
734
                        // information; reference the missing label in the warning instead.
×
735
                        fmt.Printf(warningInvalidPrebuiltImageNameMsg, imageName, runtimeImageLabel)
×
736
                } else {
1✔
737
                        // Parse the Dockerfile to include the FROM image in the warning,
1✔
738
                        // giving the user a concrete reference for what needs to change.
1✔
739
                        cmds, err := docker.ParseFile(filepath.Join(path, dockerfile))
1✔
740
                        if err != nil {
2✔
741
                                return "", errors.Wrapf(err, "failed to parse dockerfile: %s", filepath.Join(path, dockerfile))
1✔
742
                        }
1✔
743
                        fmt.Printf(warningInvalidImageNameMsg, docker.GetImageFromParsedFile(cmds))
×
744
                }
745
                fmt.Println("Canceling deploy...")
×
746
                os.Exit(1)
×
747
        }
748

749
        resp, err := astroV1Client.GetDeploymentOptionsWithResponse(httpContext.Background(), organizationID, &astrov1.GetDeploymentOptionsParams{})
35✔
750
        if err != nil {
36✔
751
                return "", err
1✔
752
        }
1✔
753
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
34✔
754
        if err != nil {
34✔
755
                return "", err
×
756
        }
×
757
        deploymentOptionsRuntimeVersions := []string{}
34✔
758
        for _, runtimeRelease := range resp.JSON200.RuntimeReleases {
238✔
759
                deploymentOptionsRuntimeVersions = append(deploymentOptionsRuntimeVersions, runtimeRelease.Version)
204✔
760
        }
204✔
761

762
        if !ValidRuntimeVersion(currentVersion, version, deploymentOptionsRuntimeVersions) {
34✔
763
                fmt.Println("Canceling deploy...")
×
764
                os.Exit(1)
×
765
        }
×
766

767
        WarnIfNonLatestVersion(version, httputil.NewHTTPClient())
34✔
768

34✔
769
        return version, nil
34✔
770
}
771

772
// finalize deploy
773
func finalizeDeploy(deployID, deploymentID, organizationID, dagTarballVersion string, dagDeploy bool, astroV1Client astrov1.APIClient) error {
35✔
774
        finalizeDeployRequest := astrov1.FinalizeDeployRequest{}
35✔
775
        if dagDeploy {
59✔
776
                finalizeDeployRequest.DagTarballVersion = &dagTarballVersion
24✔
777
        }
24✔
778
        resp, err := astroV1Client.FinalizeDeployWithResponse(httpContext.Background(), organizationID, deploymentID, deployID, finalizeDeployRequest)
35✔
779
        if err != nil {
35✔
780
                return err
×
781
        }
×
782
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
35✔
783
        if err != nil {
35✔
784
                return err
×
785
        }
×
786
        if resp.JSON200.DagTarballVersion != nil {
70✔
787
                fmt.Println("Deployed DAG bundle: ", *resp.JSON200.DagTarballVersion)
35✔
788
        }
35✔
789
        if resp.JSON200.ImageTag != "" {
70✔
790
                fmt.Println("Deployed Image Tag: ", resp.JSON200.ImageTag)
35✔
791
        }
35✔
792
        return nil
35✔
793
}
794

795
func createDeploy(organizationID, deploymentID string, request astrov1.CreateDeployRequest, astroV1Client astrov1.APIClient) (*astrov1.Deploy, error) {
41✔
796
        resp, err := astroV1Client.CreateDeployWithResponse(httpContext.Background(), organizationID, deploymentID, request)
41✔
797
        if err != nil {
41✔
798
                return nil, err
×
799
        }
×
800
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
41✔
801
        if err != nil {
41✔
802
                return nil, err
×
803
        }
×
804
        return resp.JSON200, err
41✔
805
}
806

807
// createDeployWithDagBundle creates a deploy targeting a named DAG bundle via the
808
// v1alpha1 deploy API, the only tier that accepts dagBundleName. The v1alpha1
809
// type vocabulary is simpler than v1's: the server expands IMAGE to IMAGE_AND_DAG
810
// (or DAG to DAG_ONLY) based on the deployment, so a plain deploy maps to IMAGE
811
// and a --dags deploy maps to DAG. Remove once dagBundleName reaches the v1 API.
812
func createDeployWithDagBundle(organizationID, deploymentID, description, dagBundleName string, dags bool, git *astrov1.CreateDeployGitRequest, client astrov1alpha1.APIClient) (*astrov1alpha1.Deploy, error) {
4✔
813
        deployType := astrov1alpha1.CreateDeployRequestTypeIMAGE
4✔
814
        if dags {
5✔
815
                deployType = astrov1alpha1.CreateDeployRequestTypeDAG
1✔
816
        }
1✔
817
        request := astrov1alpha1.CreateDeployRequest{
4✔
818
                Type:          deployType,
4✔
819
                Description:   &description,
4✔
820
                DagBundleName: &dagBundleName,
4✔
821
                Git:           toV1Alpha1GitRequest(git),
4✔
822
        }
4✔
823
        resp, err := client.CreateDeployWithResponse(httpContext.Background(), organizationID, deploymentID, request)
4✔
824
        if err != nil {
5✔
825
                return nil, err
1✔
826
        }
1✔
827
        err = astrov1alpha1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
3✔
828
        if err != nil {
4✔
829
                return nil, err
1✔
830
        }
1✔
831
        return resp.JSON200, nil
2✔
832
}
833

834
func toV1Alpha1GitRequest(git *astrov1.CreateDeployGitRequest) *astrov1alpha1.CreateDeployGitRequest {
6✔
835
        if git == nil {
11✔
836
                return nil
5✔
837
        }
5✔
838
        return &astrov1alpha1.CreateDeployGitRequest{
1✔
839
                Account:         git.Account,
1✔
840
                AuthorName:      git.AuthorName,
1✔
841
                AuthorUrl:       git.AuthorUrl,
1✔
842
                AuthorUsername:  git.AuthorUsername,
1✔
843
                BeforeCommitSha: git.BeforeCommitSha,
1✔
844
                Branch:          git.Branch,
1✔
845
                CommitSha:       git.CommitSha,
1✔
846
                CommitUrl:       git.CommitUrl,
1✔
847
                Path:            git.Path,
1✔
848
                Provider:        astrov1alpha1.CreateDeployGitRequestProvider(git.Provider),
1✔
849
                RemoteUrl:       git.RemoteUrl,
1✔
850
                Repo:            git.Repo,
1✔
851
        }
1✔
852
}
853

854
func ValidRuntimeVersion(currentVersion, tag string, deploymentOptionsRuntimeVersions []string) bool {
44✔
855
        // Allow old deployments which do not have runtimeVersion tag
44✔
856
        if currentVersion == "" {
45✔
857
                return true
1✔
858
        }
1✔
859

860
        // Check that the tag is not a downgrade
861
        if airflowversions.CompareRuntimeVersions(tag, currentVersion) < 0 {
46✔
862
                fmt.Printf("Cannot deploy a downgraded Astro Runtime version. Modify your Astro Runtime version to %s or higher in your Dockerfile\n", currentVersion)
3✔
863
                return false
3✔
864
        }
3✔
865

866
        // Check that the tag is supported by the deployment
867
        tagInDeploymentOptions := false
40✔
868
        for _, runtimeVersion := range deploymentOptionsRuntimeVersions {
111✔
869
                if airflowversions.CompareRuntimeVersions(tag, runtimeVersion) == 0 {
110✔
870
                        tagInDeploymentOptions = true
39✔
871
                        break
39✔
872
                }
873
        }
874
        if !tagInDeploymentOptions {
41✔
875
                fmt.Println("Cannot deploy an unsupported Astro Runtime version. Modify your Astro Runtime version to a supported version in your Dockerfile")
1✔
876
                fmt.Printf("Supported versions: %s\n", strings.Join(deploymentOptionsRuntimeVersions, ", "))
1✔
877
                return false
1✔
878
        }
1✔
879

880
        // If upgrading from Airflow 2 to Airflow 3, we require at least Runtime 12.0.0 (Airflow 2.10.0)
881
        currentVersionAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(currentVersion)
39✔
882
        tagAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(tag)
39✔
883
        if currentVersionAirflowMajorVersion == "2" && tagAirflowMajorVersion == "3" {
41✔
884
                if airflowversions.CompareRuntimeVersions(currentVersion, "12.0.0") < 0 {
3✔
885
                        fmt.Println("Can only upgrade deployment from Airflow 2 to Airflow 3 with deployment at Astro Runtime 12.0.0 or higher")
1✔
886
                        return false
1✔
887
                }
1✔
888
        }
889

890
        return true
38✔
891
}
892

893
func WarnIfNonLatestVersion(version string, httpClient *httputil.HTTPClient) {
37✔
894
        client := airflowversions.NewClient(httpClient, false, false)
37✔
895
        latestRuntimeVersion, err := airflowversions.GetDefaultImageTag(client, "", "", false)
37✔
896
        if err != nil {
39✔
897
                logger.Debugf("unable to get latest runtime version: %s", err)
2✔
898
                return
2✔
899
        }
2✔
900

901
        if airflowversions.CompareRuntimeVersions(version, latestRuntimeVersion) < 0 {
70✔
902
                fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
35✔
903
        }
35✔
904
}
905

906
// ClientBuildContext represents a prepared build context for client deployment
907
type ClientBuildContext struct {
908
        // TempDir is the temporary directory containing the build context
909
        TempDir string
910
        // CleanupFunc should be called to clean up the temporary directory
911
        CleanupFunc func()
912
}
913

914
// prepareClientBuildContext creates a temporary build context with client dependency files
915
// This avoids modifying the original project files, preventing race conditions with concurrent deployments.
916
func prepareClientBuildContext(sourcePath string) (*ClientBuildContext, error) {
10✔
917
        // Create a temporary directory for the build context
10✔
918
        tempBuildDir, err := os.MkdirTemp("", "astro-client-build-*")
10✔
919
        if err != nil {
10✔
920
                return nil, fmt.Errorf("failed to create temporary build directory: %w", err)
×
921
        }
×
922

923
        // Cleanup function to be called by the caller
924
        cleanup := func() {
20✔
925
                os.RemoveAll(tempBuildDir)
10✔
926
        }
10✔
927

928
        // Always return cleanup function if we created a temp directory, even on error
929
        buildContext := &ClientBuildContext{
10✔
930
                TempDir:     tempBuildDir,
10✔
931
                CleanupFunc: cleanup,
10✔
932
        }
10✔
933

10✔
934
        // Check if source directory exists first
10✔
935
        if exists, err := fileutil.Exists(sourcePath, nil); err != nil {
10✔
936
                return buildContext, fmt.Errorf("failed to check if source directory exists: %w", err)
×
937
        } else if !exists {
11✔
938
                return buildContext, fmt.Errorf("source directory does not exist: %s", sourcePath)
1✔
939
        }
1✔
940

941
        // Build a skip predicate from the project's .dockerignore so excluded
942
        // paths (e.g. infra/ with terragrunt provider-cache symlinks) are not
943
        // copied into the build context. This mirrors what the Docker builder does
944
        // for in-place builds; the client deploy copies the context first, so it
945
        // must honor .dockerignore itself.
946
        skip, err := dockerignoreSkipFunc(sourcePath)
9✔
947
        if err != nil {
9✔
948
                return buildContext, fmt.Errorf("failed to read .dockerignore: %w", err)
×
949
        }
×
950

951
        // Copy all project files to the temporary directory
952
        err = fileutil.CopyDirectoryFiltered(sourcePath, tempBuildDir, skip)
9✔
953
        if err != nil {
9✔
954
                return buildContext, fmt.Errorf("failed to copy project files to temporary directory: %w", err)
×
955
        }
×
956

957
        // Process client dependency files
958
        err = setupClientDependencyFiles(tempBuildDir)
9✔
959
        if err != nil {
11✔
960
                return buildContext, fmt.Errorf("failed to setup client dependency files: %w", err)
2✔
961
        }
2✔
962

963
        return buildContext, nil
7✔
964
}
965

966
// alwaysIncludedBuildFiles are never excluded from the client build context,
967
// even if a user's .dockerignore would match them. The Docker builder applies
968
// the same special-casing to the Dockerfile and .dockerignore, and the client
969
// deploy additionally needs its client dependency files.
970
var alwaysIncludedBuildFiles = map[string]bool{
971
        "Dockerfile.client":       true,
972
        ".dockerignore":           true,
973
        "requirements-client.txt": true,
974
        "packages-client.txt":     true,
975
}
976

977
// dockerignoreSkipFunc parses the .dockerignore at sourcePath (if any) and
978
// returns a predicate, suitable for fileutil.CopyDirectoryFiltered, that
979
// reports whether a path should be excluded from the build context. It returns
980
// nil (copy everything) when there is no .dockerignore file.
981
func dockerignoreSkipFunc(sourcePath string) (func(relPath string, isDir bool) bool, error) {
13✔
982
        f, err := os.Open(filepath.Join(sourcePath, ".dockerignore"))
13✔
983
        if err != nil {
22✔
984
                if os.IsNotExist(err) {
18✔
985
                        return nil, nil
9✔
986
                }
9✔
987
                return nil, err
×
988
        }
989
        defer f.Close()
4✔
990

4✔
991
        patterns, err := ignorefile.ReadAll(f)
4✔
992
        if err != nil {
4✔
993
                return nil, err
×
994
        }
×
995

996
        pm, err := patternmatcher.New(patterns)
4✔
997
        if err != nil {
4✔
998
                return nil, err
×
999
        }
×
1000

1001
        return func(relPath string, isDir bool) bool {
26✔
1002
                if alwaysIncludedBuildFiles[relPath] {
30✔
1003
                        return false
8✔
1004
                }
8✔
1005
                matched, err := pm.MatchesOrParentMatches(relPath)
14✔
1006
                if err != nil || !matched {
22✔
1007
                        return false
8✔
1008
                }
8✔
1009
                // When exclusion ("!") patterns exist, a child of a matched directory
1010
                // may be re-included, so we must descend rather than prune the dir.
1011
                if isDir && pm.Exclusions() {
7✔
1012
                        return false
1✔
1013
                }
1✔
1014
                return true
5✔
1015
        }, nil
1016
}
1017

1018
// setupClientDependencyFiles processes client-specific dependency files in the build context
1019
func setupClientDependencyFiles(buildDir string) error {
12✔
1020
        // Define dependency file pairs (client file -> regular file)
12✔
1021
        dependencyFiles := map[string]string{
12✔
1022
                "requirements-client.txt": "requirements.txt",
12✔
1023
                "packages-client.txt":     "packages.txt",
12✔
1024
        }
12✔
1025

12✔
1026
        // Process client dependency files in the build directory
12✔
1027
        for clientFile, regularFile := range dependencyFiles {
34✔
1028
                clientPath := filepath.Join(buildDir, clientFile)
22✔
1029
                regularPath := filepath.Join(buildDir, regularFile)
22✔
1030

22✔
1031
                // Copy client file content to the regular file location (requires client file to exist)
22✔
1032
                if err := fileutil.CopyFile(clientPath, regularPath); err != nil {
25✔
1033
                        return fmt.Errorf("failed to copy %s to %s in build context: %w", clientFile, regularFile, err)
3✔
1034
                }
3✔
1035
        }
1036

1037
        return nil
9✔
1038
}
1039

1040
// DeployClientImage handles the client deploy functionality
1041
func DeployClientImage(deployInput InputClientDeploy, astroV1Client astrov1.APIClient) error { //nolint:gocritic
6✔
1042
        c, err := config.GetCurrentContext()
6✔
1043
        if err != nil {
6✔
1044
                return errors.Wrap(err, "failed to get current context")
×
1045
        }
×
1046

1047
        // Validate deployment runtime version if deployment ID is provided
1048
        if err := validateClientImageRuntimeVersion(deployInput, astroV1Client); err != nil {
6✔
1049
                return err
×
1050
        }
×
1051

1052
        // Get the remote client registry endpoint from config
1053
        registryEndpoint := config.CFG.RemoteClientRegistry.GetString()
6✔
1054
        if registryEndpoint == "" {
7✔
1055
                fmt.Println("The Astro CLI is not configured to push client images to your private registry.")
1✔
1056
                fmt.Println("For remote Deployments, client images must be stored in your private registry, not in Astronomer managed registries.")
1✔
1057
                fmt.Println("Please provide your private registry information so the Astro CLI can push client images.")
1✔
1058
                return errors.New("remote client registry is not configured. To configure it, run: 'astro config set remote.client_registry <endpoint>' and try again.")
1✔
1059
        }
1✔
1060

1061
        // Use consistent deploy-<timestamp> tagging mechanism like regular deploys
1062
        // The ImageName flag only specifies which local image to use, not the remote tag
1063
        imageTag := "deploy-" + time.Now().UTC().Format("2006-01-02T15-04")
5✔
1064

5✔
1065
        // Build the full remote image name
5✔
1066
        remoteImage := fmt.Sprintf("%s:%s", registryEndpoint, imageTag)
5✔
1067

5✔
1068
        // Create an image handler for building and pushing
5✔
1069
        imageHandler := airflowImageHandler(remoteImage)
5✔
1070

5✔
1071
        if deployInput.ImageName != "" {
6✔
1072
                // Use the provided local image (tag will be ignored, remote tag is always timestamp-based)
1✔
1073
                fmt.Println("Using provided image:", deployInput.ImageName)
1✔
1074
                err := imageHandler.TagLocalImage(deployInput.ImageName)
1✔
1075
                if err != nil {
1✔
1076
                        return fmt.Errorf("failed to tag local image: %w", err)
×
1077
                }
×
1078
        } else {
4✔
1079
                // Authenticate with the base image registry before building
4✔
1080
                // This is needed because Dockerfile.client uses base images from a private registry
4✔
1081

4✔
1082
                // Skip registry login if the base image registry is not from astronomer, check the content of the Dockerfile.client file
4✔
1083
                dockerfileClientContent, err := fileutil.ReadFileToString(filepath.Join(deployInput.Path, "Dockerfile.client"))
4✔
1084
                if util.IsAstronomerRegistry(dockerfileClientContent) || err != nil {
8✔
1085
                        // login to the registry
4✔
1086
                        if err != nil {
5✔
1087
                                fmt.Println("WARNING: Failed to read Dockerfile.client, so will assume the base image is using images.astronomer.cloud and try to login to the registry")
1✔
1088
                        }
1✔
1089
                        baseImageRegistry := config.CFG.RemoteBaseImageRegistry.GetString()
4✔
1090
                        fmt.Printf("Authenticating with base image registry: %s\n", baseImageRegistry)
4✔
1091
                        err := airflow.DockerLogin(baseImageRegistry, registryUsername, c.Token)
4✔
1092
                        if err != nil {
5✔
1093
                                fmt.Println("Failed to authenticate with Astronomer registry that contains the base agent image used in the Dockerfile.client file.")
1✔
1094
                                fmt.Println("This could be because either your token has expired or you don't have permission to pull the base agent image.")
1✔
1095
                                fmt.Println("Please re-login via `astro login` to refresh the credentials or validate that `ASTRO_API_TOKEN` environment variable is set with the correct token and try again")
1✔
1096
                                return fmt.Errorf("failed to authenticate with registry %s: %w", baseImageRegistry, err)
1✔
1097
                        }
1✔
1098
                }
1099

1100
                // Build the client image from the current directory
1101
                // Determine target platforms for client deploy
1102
                var targetPlatforms []string
3✔
1103
                if deployInput.Platform != "" {
3✔
1104
                        // Parse comma-separated platforms from --platform flag
×
1105
                        targetPlatforms = strings.Split(deployInput.Platform, ",")
×
1106
                        // Trim whitespace from each platform
×
1107
                        for i, platform := range targetPlatforms {
×
1108
                                targetPlatforms[i] = strings.TrimSpace(platform)
×
1109
                        }
×
1110
                        fmt.Printf("Building client image for platforms: %s\n", strings.Join(targetPlatforms, ", "))
×
1111
                } else {
3✔
1112
                        // Use empty slice to let Docker build for host platform by default
3✔
1113
                        targetPlatforms = []string{}
3✔
1114
                        fmt.Println("Building client image for host platform")
3✔
1115
                }
3✔
1116

1117
                // Prepare build context with client dependency files
1118
                buildContext, err := prepareClientBuildContext(deployInput.Path)
3✔
1119
                if buildContext != nil && buildContext.CleanupFunc != nil {
6✔
1120
                        defer buildContext.CleanupFunc()
3✔
1121
                }
3✔
1122
                if err != nil {
3✔
1123
                        return fmt.Errorf("failed to prepare client build context: %w", err)
×
1124
                }
×
1125

1126
                // Build the image from the prepared context
1127
                buildConfig := types.ImageBuildConfig{
3✔
1128
                        Path:            buildContext.TempDir,
3✔
1129
                        TargetPlatforms: targetPlatforms,
3✔
1130
                }
3✔
1131

3✔
1132
                err = imageHandler.Build("Dockerfile.client", deployInput.BuildSecretString, buildConfig)
3✔
1133
                if err != nil {
4✔
1134
                        return fmt.Errorf("failed to build client image: %w", err)
1✔
1135
                }
1✔
1136
        }
1137

1138
        // Push the image to the remote registry (assumes docker login was done externally)
1139
        fmt.Println("Pushing client image to configured remote registry")
3✔
1140
        _, err = imageHandler.Push(remoteImage, "", "", false)
3✔
1141
        if err != nil {
4✔
1142
                if errors.Is(err, airflow.ErrImagePush403) {
1✔
1143
                        fmt.Printf("\n--------------------------------\n")
×
1144
                        fmt.Printf("Failed to push client image to %s\n", registryEndpoint)
×
1145
                        fmt.Println("It could be due to either your registry token has expired or you don't have permission to push the client image")
×
1146
                        fmt.Printf("Please ensure that you have logged in to `%s` via `docker login` and try again\n\n", registryEndpoint)
×
1147
                }
×
1148
                return fmt.Errorf("failed to push client image: %w", err)
1✔
1149
        }
1150

1151
        fmt.Printf("Successfully pushed client image to %s\n", ansi.Bold(remoteImage))
2✔
1152

2✔
1153
        fmt.Printf("\n--------------------------------\n")
2✔
1154
        fmt.Println("The client image has been pushed to your private registry.")
2✔
1155
        fmt.Println("Your next step would be to update the agent component to use the new client image.")
2✔
1156
        fmt.Println("For that you would either need to update the helm chart values.yaml file or update your CI/CD pipeline to use the new client image.")
2✔
1157
        fmt.Printf("If you are using Astronomer provided Agent Helm chart, you would need to update the `image` field for each of the workers, dagProcessor, and triggerer component sections to the new image: %s\n", remoteImage)
2✔
1158
        fmt.Println("Once you have updated the helm chart values.yaml file, you can run 'helm upgrade' or update via your CI/CD pipeline to update the agent components")
2✔
1159

2✔
1160
        return nil
2✔
1161
}
1162

1163
// validateClientImageRuntimeVersion validates that the client image runtime version
1164
// is not newer than the deployment runtime version
1165
func validateClientImageRuntimeVersion(deployInput InputClientDeploy, astroV1Client astrov1.APIClient) error { //nolint:gocritic
16✔
1166
        // Skip validation if no deployment ID provided
16✔
1167
        if deployInput.DeploymentID == "" {
23✔
1168
                return nil
7✔
1169
        }
7✔
1170

1171
        // Get current context for organization info
1172
        c, err := config.GetCurrentContext()
9✔
1173
        if err != nil {
10✔
1174
                return errors.Wrap(err, "failed to get current context")
1✔
1175
        }
1✔
1176

1177
        // Get deployment information
1178
        deployInfo, err := fetchDeploymentDetails(deployInput.DeploymentID, c.Organization, astroV1Client)
8✔
1179
        if err != nil {
9✔
1180
                return errors.Wrap(err, "failed to get deployment information")
1✔
1181
        }
1✔
1182

1183
        // Parse Dockerfile.client to get client image runtime version
1184
        dockerfileClientPath := filepath.Join(deployInput.Path, "Dockerfile.client")
7✔
1185
        if _, err := os.Stat(dockerfileClientPath); os.IsNotExist(err) {
8✔
1186
                return errors.New("Dockerfile.client is required for client image runtime version validation")
1✔
1187
        }
1✔
1188

1189
        cmds, err := docker.ParseFile(dockerfileClientPath)
6✔
1190
        if err != nil {
7✔
1191
                return errors.Wrapf(err, "failed to parse Dockerfile.client: %s", dockerfileClientPath)
1✔
1192
        }
1✔
1193

1194
        baseImage := docker.GetImageFromParsedFile(cmds)
5✔
1195
        if baseImage == "" {
6✔
1196
                return errors.New("failed to find base image in Dockerfile.client")
1✔
1197
        }
1✔
1198

1199
        // Extract runtime version from the base image tag
1200
        clientRuntimeVersion, err := extractRuntimeVersionFromImage(baseImage)
4✔
1201
        if err != nil {
5✔
1202
                return errors.Wrapf(err, "failed to extract runtime version from client image %s", baseImage)
1✔
1203
        }
1✔
1204

1205
        // Compare versions
1206
        if airflowversions.CompareRuntimeVersions(clientRuntimeVersion, deployInfo.currentVersion) > 0 {
4✔
1207
                return fmt.Errorf(`client image runtime version validation failed:
1✔
1208

1✔
1209
The client image is based on Astro Runtime version %s, which is newer than the deployment's runtime version %s.
1✔
1210

1✔
1211
To fix this issue, you can either:
1✔
1212
1. Downgrade the client image version by updating the base image in Dockerfile.client to use runtime version %s or earlier
1✔
1213
2. Upgrade the deployment's runtime version to %s or higher
1✔
1214

1✔
1215
This validation ensures compatibility between your client image and the deployment environment`,
1✔
1216
                        clientRuntimeVersion, deployInfo.currentVersion, deployInfo.currentVersion, clientRuntimeVersion)
1✔
1217
        }
1✔
1218

1219
        fmt.Printf("✓ Client image runtime version %s is compatible with deployment runtime version %s\n",
2✔
1220
                clientRuntimeVersion, deployInfo.currentVersion)
2✔
1221

2✔
1222
        return nil
2✔
1223
}
1224

1225
// extractRuntimeVersionFromImage extracts the runtime version from an image tag
1226
// Example: "images.astronomer.cloud/baseimages/astro-remote-execution-agent:3.1-1-python-3.12-astro-agent-1.1.0"
1227
// Returns: "3.1-1"
1228
func extractRuntimeVersionFromImage(imageName string) (string, error) {
9✔
1229
        // Split image name to get the tag part
9✔
1230
        parts := strings.Split(imageName, ":")
9✔
1231
        if len(parts) < 2 {
10✔
1232
                return "", errors.New("image name does not contain a tag")
1✔
1233
        }
1✔
1234

1235
        imageTag := parts[len(parts)-1] // Get the last part as the tag
8✔
1236

8✔
1237
        // Use the existing ParseImageTag function from airflow_versions package
8✔
1238
        tagInfo, err := airflowversions.ParseImageTag(imageTag)
8✔
1239
        if err != nil {
10✔
1240
                return "", errors.Wrapf(err, "failed to parse image tag: %s", imageTag)
2✔
1241
        }
2✔
1242

1243
        return tagInfo.RuntimeVersion, nil
6✔
1244
}
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