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

astronomer / astro-cli / 30890018300

04 Aug 2026 07:59AM UTC coverage: 44.265% (+0.4%) from 43.896%
30890018300

Pull #2236

github

web-flow
Merge 15472c87d into 9d9971fc7
Pull Request #2236: feat(opt-in): Add dbt projects hash pre-deployment

428 of 480 new or added lines in 11 files covered. (89.17%)

26342 of 59510 relevant lines covered (44.26%)

8.86 hits per line

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

80.76
/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/cosmosboost"
29
        "github.com/astronomer/astro-cli/pkg/fileutil"
30
        "github.com/astronomer/astro-cli/pkg/httputil"
31
        "github.com/astronomer/astro-cli/pkg/input"
32
        "github.com/astronomer/astro-cli/pkg/logger"
33
        "github.com/astronomer/astro-cli/pkg/util"
34
)
35

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

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

47
        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"
48
        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"
49

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

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

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

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

74
var (
75
        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
76
        envFileMissing     = errors.New("Env file path is incorrect: ")                                                                                  //nolint:revive
77
)
78

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

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

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

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

131
const accessYourDeploymentFmt = `
132

133
 Access your Deployment:
134

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

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

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

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

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

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

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

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

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

17✔
179
                var monitoringDag string
17✔
180
                switch airflowversions.AirflowMajorVersionForRuntimeVersion(currentRuntimeVersion) {
17✔
181
                case "2":
17✔
182
                        monitoringDag = airflow.Af2MonitoringDag
17✔
183
                case "3":
×
184
                        monitoringDag = airflow.Af3MonitoringDag
×
185
                default:
×
186
                        return "", errors.New("unsupported Airflow major version for runtime version " + currentRuntimeVersion)
×
187
                }
188

189
                // Create monitoring dag file
190
                err := fileutil.WriteStringToFile(monitoringDagPath, monitoringDag)
17✔
191
                if err != nil {
17✔
192
                        return "", err
×
193
                }
×
194

195
                // Remove the monitoring dag file after the upload
196
                defer os.Remove(monitoringDagPath)
17✔
197
        }
198

199
        // By default, prepend dags/ directory prefix. Use --no-dags-base-dir to place files at bundle root
200
        // (needed for some Airflow 3.x deployments where sys.path includes the bundle root, not dags/).
201
        prependBaseDir := !noDagsBaseDir
21✔
202
        versionID, err := UploadBundle(path, dagsPath, dagsUploadURL, prependBaseDir, currentRuntimeVersion)
21✔
203
        if err != nil {
21✔
204
                return "", err
×
205
        }
×
206

207
        return versionID, nil
21✔
208
}
209

210
// Deploy pushes a new docker image
211
func Deploy(deployInput InputDeploy, astroV1Client astrov1.APIClient, astroV1Alpha1Client astrov1alpha1.APIClient) error { //nolint
44✔
212
        c, err := config.GetCurrentContext()
44✔
213
        if err != nil {
45✔
214
                return err
1✔
215
        }
1✔
216

217
        if c.Domain == astroDomain {
46✔
218
                fmt.Printf(deploymentHeaderMsg, "Astro")
3✔
219
        } else {
43✔
220
                fmt.Printf(deploymentHeaderMsg, c.Domain)
40✔
221
        }
40✔
222

223
        deployInfo, err := getDeploymentInfo(deployInput.RuntimeID, deployInput.WsID, deployInput.DeploymentName, deployInput.Prompt, astroV1Client)
43✔
224
        if err != nil {
43✔
225
                return err
×
226
        }
×
227

228
        var dagsPath string
43✔
229
        if deployInput.DagsPath != "" {
58✔
230
                dagsPath = deployInput.DagsPath
15✔
231
        } else {
43✔
232
                dagsPath = filepath.Join(deployInput.Path, "dags")
28✔
233
        }
28✔
234

235
        var dagFiles []string
43✔
236
        if !deployInfo.isRemoteExecutionEnabled {
80✔
237
                dagFiles = fileutil.GetFilesWithSpecificExtension(dagsPath, ".py")
37✔
238
        }
37✔
239

240
        if deployInfo.cicdEnforcement {
44✔
241
                if !canCiCdDeploy(c.Token) {
2✔
242
                        return fmt.Errorf(errCiCdEnforcementUpdate, deployInfo.name) //nolint
1✔
243
                }
1✔
244
        }
245

246
        if deployInput.WsID != deployInfo.workspaceID {
43✔
247
                fmt.Printf(invalidWorkspaceID, deployInput.WsID)
1✔
248
                return nil
1✔
249
        }
1✔
250

251
        if deployInput.Image && !deployInfo.isRemoteExecutionEnabled {
44✔
252
                if !deployInfo.dagDeployEnabled {
3✔
253
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
254
                }
×
255
        }
256

257
        deploymentURL, err := deployment.GetDeploymentURL(deployInfo.deploymentID, deployInfo.workspaceID)
41✔
258
        if err != nil {
41✔
259
                return err
×
260
        }
×
261

262
        // Check if git metadata is enabled (default: true).
263
        // Skip when --image-name is provided: the local working directory does not necessarily
264
        // reflect the contents of a prebuilt image, so attaching its git metadata would be misleading.
265
        var deployGit *astrov1.CreateDeployGitRequest
41✔
266
        var commitMessage string
41✔
267
        if config.CFG.DeployGitMetadata.GetBool() && deployInput.ImageName == "" {
74✔
268
                deployGit, commitMessage = retrieveLocalGitMetadata(deployInput.Path)
33✔
269
        }
33✔
270

271
        // Use commit message as description fallback
272
        description := deployInput.Description
41✔
273
        if description == "" {
82✔
274
                description = commitMessage
41✔
275
        }
41✔
276

277
        // Build the deploy request with git metadata
278
        createDeployRequest := astrov1.CreateDeployRequest{
41✔
279
                Description: &description,
41✔
280
        }
41✔
281

41✔
282
        // Set deploy type
41✔
283
        switch {
41✔
284
        case deployInput.Dags:
19✔
285
                createDeployRequest.Type = astrov1.CreateDeployRequestTypeDAGONLY
19✔
286
        case deployInput.Image:
2✔
287
                createDeployRequest.Type = astrov1.CreateDeployRequestTypeIMAGEONLY
2✔
288
        default:
20✔
289
                createDeployRequest.Type = astrov1.CreateDeployRequestTypeIMAGEANDDAG
20✔
290
        }
291

292
        createDeployRequest.Git = deployGit
41✔
293

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

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

1✔
340
                        if !i {
2✔
341
                                fmt.Println("Canceling deploy...")
1✔
342
                                return nil
1✔
343
                        }
1✔
344
                }
345
                if deployInput.Pytest != "" {
30✔
346
                        runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecrets, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, astroV1Client)
12✔
347
                        if err != nil {
12✔
348
                                return err
×
349
                        }
×
350

351
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecrets)
12✔
352
                        if err != nil {
14✔
353
                                return err
2✔
354
                        }
2✔
355
                }
356

357
                if !deployInfo.dagDeployEnabled {
17✔
358
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
1✔
359
                }
1✔
360

361
                fmt.Println("Initiating DAG deploy for: " + deployInfo.deploymentID)
15✔
362
                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astrov1.DeploymentType(deployInfo.deploymentType), deployInput.NoDagsBaseDir)
15✔
363
                if err != nil {
15✔
364
                        if strings.Contains(err.Error(), dagDeployDisabled) {
×
365
                                return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
366
                        }
×
367

368
                        return err
×
369
                }
370

371
                // finish deploy
372
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, astroV1Client)
15✔
373
                if err != nil {
15✔
374
                        return err
×
375
                }
×
376

377
                if deployInput.WaitForStatus {
16✔
378
                        // Keeping wait timeout low since dag only deploy is faster
1✔
379
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, dagOnlyDeploySleepTime, tickNum, int(deployInput.WaitTime.Seconds()), astroV1Client)
1✔
380
                        if err != nil {
2✔
381
                                return err
1✔
382
                        }
1✔
383

384
                        fmt.Println(
×
385
                                "\nSuccessfully uploaded DAGs with version " + ansi.Bold(dagTarballVersion) + " to Astro. Navigate to the Airflow UI to confirm that your deploy was successful." +
×
386
                                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold(deploymentURL), ansi.Bold(deployInfo.webserverURL)),
×
387
                        )
×
388

×
389
                        return nil
×
390
                }
391

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

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

×
419
                        if !i {
×
420
                                fmt.Println("Canceling deploy...")
×
421
                                return nil
×
422
                        }
×
423
                }
424

425
                // Build our image
426
                runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecrets, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, astroV1Client)
21✔
427
                if err != nil {
21✔
428
                        return err
×
429
                }
×
430

431
                if len(dagFiles) > 0 {
28✔
432
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecrets)
7✔
433
                        if err != nil {
8✔
434
                                return err
1✔
435
                        }
1✔
436
                } else {
14✔
437
                        fmt.Println("No DAGs found. Skipping testing...")
14✔
438
                }
14✔
439

440
                repository := imageRepository
20✔
441
                // TODO: Resolve the edge case where two people push the same nextTag at the same time
20✔
442
                remoteImage := fmt.Sprintf("%s:%s", repository, nextTag)
20✔
443

20✔
444
                imageHandler := airflowImageHandler(deployInfo.deployImage)
20✔
445
                fmt.Println("Pushing image to Astronomer registry")
20✔
446
                _, err = imageHandler.Push(remoteImage, registryUsername, c.Token, false)
20✔
447
                if err != nil {
20✔
448
                        return err
×
449
                }
×
450

451
                if deployInfo.dagDeployEnabled && len(dagFiles) > 0 {
26✔
452
                        if !deployInput.Image {
12✔
453
                                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astrov1.DeploymentType(deployInfo.deploymentType), deployInput.NoDagsBaseDir)
6✔
454
                                if err != nil {
6✔
455
                                        return err
×
456
                                }
×
457
                        } else {
×
458
                                fmt.Println("Image Deploy only. Skipping deploying DAG...")
×
459
                        }
×
460
                }
461
                // finish deploy
462
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, astroV1Client)
20✔
463
                if err != nil {
20✔
464
                        return err
×
465
                }
×
466

467
                if deployInput.WaitForStatus {
22✔
468
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, sleepTime, tickNum, int(deployInput.WaitTime.Seconds()), astroV1Client)
2✔
469
                        if err != nil {
4✔
470
                                return err
2✔
471
                        }
2✔
472
                }
473

474
                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✔
475
                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold("https://"+deploymentURL), ansi.Bold("https://"+deployInfo.webserverURL)))
18✔
476
        }
477

478
        return nil
32✔
479
}
480

481
func getDeploymentInfo(
482
        deploymentID, wsID, deploymentName string,
483
        prompt bool,
484
        astroV1Client astrov1.APIClient,
485
) (deploymentInfo, error) {
43✔
486
        // Use config deployment if provided
43✔
487
        if deploymentID == "" {
57✔
488
                deploymentID = config.CFG.ProjectDeployment.GetProjectString()
14✔
489
                if deploymentID != "" {
14✔
490
                        fmt.Printf("Deployment ID found in the config file. This Deployment ID will be used for the deploy\n")
×
491
                }
×
492
        }
493

494
        if deploymentID != "" && deploymentName != "" {
51✔
495
                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✔
496
        }
8✔
497

498
        // check if deploymentID or if force prompt was requested was given by user
499
        if deploymentID == "" || prompt {
70✔
500
                currentDeployment, err := deployment.GetDeployment(wsID, deploymentID, deploymentName, false, nil, astroV1Client)
27✔
501
                if err != nil {
27✔
502
                        return deploymentInfo{}, err
×
503
                }
×
504
                deploymentByID, err := deployment.GetDeploymentByID(currentDeployment.OrganizationId, currentDeployment.Id, astroV1Client)
27✔
505
                if err != nil {
27✔
506
                        return deploymentInfo{}, err
×
507
                }
×
508
                var desiredDagTarballVersion string
27✔
509
                if deploymentByID.DesiredDagTarballVersion != nil {
45✔
510
                        desiredDagTarballVersion = *deploymentByID.DesiredDagTarballVersion
18✔
511
                } else {
27✔
512
                        desiredDagTarballVersion = ""
9✔
513
                }
9✔
514

515
                return deploymentInfo{
27✔
516
                        currentDeployment.Id,
27✔
517
                        currentDeployment.Namespace,
27✔
518
                        airflow.ImageName(currentDeployment.Namespace, "latest"),
27✔
519
                        currentDeployment.RuntimeVersion,
27✔
520
                        currentDeployment.OrganizationId,
27✔
521
                        currentDeployment.WorkspaceId,
27✔
522
                        currentDeployment.WebServerUrl,
27✔
523
                        string(*currentDeployment.Type),
27✔
524
                        desiredDagTarballVersion,
27✔
525
                        currentDeployment.IsDagDeployEnabled,
27✔
526
                        currentDeployment.IsCicdEnforced,
27✔
527
                        currentDeployment.Name,
27✔
528
                        deployment.IsRemoteExecutionEnabled(&currentDeployment),
27✔
529
                }, nil
27✔
530
        }
531
        c, err := config.GetCurrentContext()
16✔
532
        if err != nil {
16✔
533
                return deploymentInfo{}, err
×
534
        }
×
535
        deployInfo, err := fetchDeploymentDetails(deploymentID, c.Organization, astroV1Client)
16✔
536
        if err != nil {
16✔
537
                return deploymentInfo{}, err
×
538
        }
×
539
        deployInfo.deploymentID = deploymentID
16✔
540
        return deployInfo, nil
16✔
541
}
542

543
func parseOrPytestDAG(pytest, runtimeVersion, envFile, deployImage, namespace string, buildSecrets []string) error {
19✔
544
        validDAGParseVersion := airflowversions.CompareRuntimeVersions(runtimeVersion, dagParseAllowedVersion) >= 0
19✔
545
        if !validDAGParseVersion {
19✔
546
                fmt.Println("\nruntime image is earlier than 4.1.0, this deploy will skip DAG parse...")
×
547
        }
×
548

549
        containerHandler, err := containerHandlerInit(config.WorkingPath, envFile, "Dockerfile", namespace)
19✔
550
        if err != nil {
19✔
551
                return err
×
552
        }
×
553

554
        switch {
19✔
555
        case pytest == parse && validDAGParseVersion:
7✔
556
                // parse dags
7✔
557
                fmt.Println("Testing image...")
7✔
558
                err := parseDAGs(deployImage, buildSecrets, containerHandler)
7✔
559
                if err != nil {
9✔
560
                        return err
2✔
561
                }
2✔
562
        case pytest != "" && pytest != parse && pytest != parseAndPytest:
6✔
563
                // check pytests
6✔
564
                fmt.Println("Testing image...")
6✔
565
                err := checkPytest(pytest, deployImage, buildSecrets, containerHandler)
6✔
566
                if err != nil {
7✔
567
                        return err
1✔
568
                }
1✔
569
        case pytest == parseAndPytest:
6✔
570
                // parse dags and check pytests
6✔
571
                fmt.Println("Testing image...")
6✔
572
                err := parseDAGs(deployImage, buildSecrets, containerHandler)
6✔
573
                if err != nil {
6✔
574
                        return err
×
575
                }
×
576

577
                err = checkPytest(pytest, deployImage, buildSecrets, containerHandler)
6✔
578
                if err != nil {
6✔
579
                        return err
×
580
                }
×
581
        }
582
        return nil
16✔
583
}
584

585
func parseDAGs(deployImage string, buildSecrets []string, containerHandler airflow.ContainerHandler) error {
13✔
586
        if !config.CFG.SkipParse.GetBool() && !util.CheckEnvBool(os.Getenv("ASTRONOMER_SKIP_PARSE")) {
26✔
587
                err := containerHandler.Parse("", deployImage, buildSecrets)
13✔
588
                if err != nil {
15✔
589
                        fmt.Println(err)
2✔
590
                        return errDagsParseFailed
2✔
591
                }
2✔
592
        } else {
×
593
                fmt.Println("Skipping parsing dags due to skip parse being set to true in either the config.yaml or local environment variables")
×
594
        }
×
595

596
        return nil
11✔
597
}
598

599
// Validate code with pytest
600
func checkPytest(pytest, deployImage string, buildSecrets []string, containerHandler airflow.ContainerHandler) error {
14✔
601
        if pytest != allTests && pytest != parseAndPytest {
18✔
602
                pytestFile = pytest
4✔
603
        }
4✔
604

605
        exitCode, err := containerHandler.Pytest(pytestFile, "", deployImage, "", buildSecrets)
14✔
606
        if err != nil {
17✔
607
                if strings.Contains(exitCode, "1") { // exit code is 1 meaning tests failed
4✔
608
                        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✔
609
                }
1✔
610
                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✔
611
        }
612

613
        fmt.Print("\nAll Pytests passed!\n")
11✔
614
        return err
11✔
615
}
616

617
func fetchDeploymentDetails(deploymentID, organizationID string, astroV1Client astrov1.APIClient) (deploymentInfo, error) {
24✔
618
        resp, err := astroV1Client.GetDeploymentWithResponse(httpContext.Background(), organizationID, deploymentID)
24✔
619
        if err != nil {
24✔
620
                return deploymentInfo{}, err
×
621
        }
×
622

623
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
24✔
624
        if err != nil {
25✔
625
                return deploymentInfo{}, err
1✔
626
        }
1✔
627

628
        currentVersion := resp.JSON200.RuntimeVersion
23✔
629
        namespace := resp.JSON200.Namespace
23✔
630
        workspaceID := resp.JSON200.WorkspaceId
23✔
631
        webserverURL := resp.JSON200.WebServerUrl
23✔
632
        dagDeployEnabled := resp.JSON200.IsDagDeployEnabled
23✔
633
        cicdEnforcement := resp.JSON200.IsCicdEnforced
23✔
634
        isRemoteExecutionEnabled := deployment.IsRemoteExecutionEnabled(resp.JSON200)
23✔
635
        var desiredDagTarballVersion string
23✔
636
        if resp.JSON200.DesiredDagTarballVersion != nil {
30✔
637
                desiredDagTarballVersion = *resp.JSON200.DesiredDagTarballVersion
7✔
638
        } else {
23✔
639
                desiredDagTarballVersion = ""
16✔
640
        }
16✔
641

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

23✔
645
        return deploymentInfo{
23✔
646
                namespace:                namespace,
23✔
647
                deployImage:              deployImage,
23✔
648
                currentVersion:           currentVersion,
23✔
649
                organizationID:           organizationID,
23✔
650
                workspaceID:              workspaceID,
23✔
651
                webserverURL:             webserverURL,
23✔
652
                dagDeployEnabled:         dagDeployEnabled,
23✔
653
                desiredDagTarballVersion: desiredDagTarballVersion,
23✔
654
                cicdEnforcement:          cicdEnforcement,
23✔
655
                isRemoteExecutionEnabled: isRemoteExecutionEnabled,
23✔
656
        }, nil
23✔
657
}
658

659
func buildImageWithoutDags(path string, buildSecrets []string, imageHandler airflow.ImageHandler) error {
29✔
660
        fullpath := filepath.Join(path, ".dockerignore")
29✔
661

29✔
662
        // Snapshot the original bytes so we can restore byte-for-byte after the build
29✔
663
        // (preserves CRLF, trailing whitespace, etc.).
29✔
664
        originalBytes, err := os.ReadFile(fullpath)
29✔
665
        originalExisted := err == nil
29✔
666
        if err != nil && !os.IsNotExist(err) {
29✔
667
                return err
×
668
        }
×
669

670
        defer func() {
58✔
671
                if originalExisted {
57✔
672
                        _ = os.WriteFile(fullpath, originalBytes, 0o644) //nolint:gosec,mnd
28✔
673
                } else {
29✔
674
                        _ = os.Remove(fullpath)
1✔
675
                }
1✔
676
        }()
677

678
        switch {
29✔
679
        case !originalExisted:
1✔
680
                if err := os.WriteFile(fullpath, []byte("dags/\n"), 0o644); err != nil { //nolint:gosec,mnd
1✔
681
                        return err
×
682
                }
×
683
        case !dockerignoreContainsDags(originalBytes):
26✔
684
                modified := append([]byte{}, originalBytes...)
26✔
685
                if len(modified) > 0 && modified[len(modified)-1] != '\n' {
28✔
686
                        modified = append(modified, '\n')
2✔
687
                }
2✔
688
                modified = append(modified, []byte("dags/\n")...)
26✔
689
                if err := os.WriteFile(fullpath, modified, 0o644); err != nil { //nolint:gosec,mnd
26✔
690
                        return err
×
691
                }
×
692
        }
693

694
        return imageHandler.Build("", buildSecrets, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
29✔
695
}
696

697
// dockerignoreContainsDags reports whether content has a line equal to "dags/".
698
// Uses bufio.Scanner so CRLF line endings are handled identically to LF.
699
func dockerignoreContainsDags(content []byte) bool {
28✔
700
        scanner := bufio.NewScanner(bytes.NewReader(content))
28✔
701
        for scanner.Scan() {
40✔
702
                if scanner.Text() == "dags/" {
14✔
703
                        return true
2✔
704
                }
2✔
705
        }
706
        return false
26✔
707
}
708

709
func buildImage(path, currentVersion, deployImage, imageName, organizationID string, buildSecrets []string, dagDeployEnabled, isRemoteExecutionEnabled bool, astroV1Client astrov1.APIClient) (version string, err error) {
38✔
710
        imageHandler := airflowImageHandler(deployImage)
38✔
711

38✔
712
        if imageName == "" {
67✔
713
                // Build our image
29✔
714
                fmt.Println(composeImageBuildingPromptMsg)
29✔
715

29✔
716
                // Cosmos Boost pre-deploy step, opt-in via cosmos_boost.pre_deploy;
29✔
717
                // with the setting off the build does not touch the tree at all.
29✔
718
                // Cleanup runs first and is fatal on failure (a stale artifact must
29✔
719
                // not ship inside the image), while stamping is best-effort (a
29✔
720
                // missing artifact is safe). Artifacts left by earlier enabled
29✔
721
                // deploys are removed with `astro dbt cleanup`.
29✔
722
                if config.CFG.CosmosBoostPreDeploy.GetBool() {
30✔
723
                        if err := cosmosboost.EnsureClean(path); err != nil {
1✔
NEW
724
                                return "", err
×
NEW
725
                        }
×
726
                        cosmosboost.BestEffortPreDeploy(path)
1✔
727
                }
728

729
                if dagDeployEnabled || isRemoteExecutionEnabled {
49✔
730
                        err := buildImageWithoutDags(path, buildSecrets, imageHandler)
20✔
731
                        if err != nil {
20✔
732
                                return "", err
×
733
                        }
×
734
                } else {
9✔
735
                        err := imageHandler.Build("", buildSecrets, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
9✔
736
                        if err != nil {
11✔
737
                                return "", err
2✔
738
                        }
2✔
739
                }
740
        } else {
9✔
741
                // skip build if an imageName is passed
9✔
742
                fmt.Println(composeSkipImageBuildingPromptMsg)
9✔
743

9✔
744
                err := imageHandler.TagLocalImage(imageName)
9✔
745
                if err != nil {
9✔
746
                        return "", err
×
747
                }
×
748
        }
749

750
        version, err = imageHandler.GetLabel("", runtimeImageLabel)
36✔
751
        if err != nil {
36✔
752
                fmt.Println("unable get runtime version from image")
×
753
        }
×
754

755
        if config.CFG.ShowWarnings.GetBool() && version == "" {
37✔
756
                if imageName != "" {
1✔
757
                        // Registry image names are arbitrary and do not convey base image
×
758
                        // information; reference the missing label in the warning instead.
×
759
                        fmt.Printf(warningInvalidPrebuiltImageNameMsg, imageName, runtimeImageLabel)
×
760
                } else {
1✔
761
                        // Parse the Dockerfile to include the FROM image in the warning,
1✔
762
                        // giving the user a concrete reference for what needs to change.
1✔
763
                        cmds, err := docker.ParseFile(filepath.Join(path, dockerfile))
1✔
764
                        if err != nil {
2✔
765
                                return "", errors.Wrapf(err, "failed to parse dockerfile: %s", filepath.Join(path, dockerfile))
1✔
766
                        }
1✔
767
                        fmt.Printf(warningInvalidImageNameMsg, docker.GetImageFromParsedFile(cmds))
×
768
                }
769
                fmt.Println("Canceling deploy...")
×
770
                os.Exit(1)
×
771
        }
772

773
        resp, err := astroV1Client.GetDeploymentOptionsWithResponse(httpContext.Background(), organizationID, &astrov1.GetDeploymentOptionsParams{})
35✔
774
        if err != nil {
36✔
775
                return "", err
1✔
776
        }
1✔
777
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
34✔
778
        if err != nil {
34✔
779
                return "", err
×
780
        }
×
781
        deploymentOptionsRuntimeVersions := []string{}
34✔
782
        for _, runtimeRelease := range resp.JSON200.RuntimeReleases {
238✔
783
                deploymentOptionsRuntimeVersions = append(deploymentOptionsRuntimeVersions, runtimeRelease.Version)
204✔
784
        }
204✔
785

786
        if !ValidRuntimeVersion(currentVersion, version, deploymentOptionsRuntimeVersions) {
34✔
787
                fmt.Println("Canceling deploy...")
×
788
                os.Exit(1)
×
789
        }
×
790

791
        WarnIfNonLatestVersion(version, httputil.NewHTTPClient())
34✔
792

34✔
793
        return version, nil
34✔
794
}
795

796
// finalize deploy
797
func finalizeDeploy(deployID, deploymentID, organizationID, dagTarballVersion string, dagDeploy bool, astroV1Client astrov1.APIClient) error {
35✔
798
        finalizeDeployRequest := astrov1.FinalizeDeployRequest{}
35✔
799
        if dagDeploy {
59✔
800
                finalizeDeployRequest.DagTarballVersion = &dagTarballVersion
24✔
801
        }
24✔
802
        resp, err := astroV1Client.FinalizeDeployWithResponse(httpContext.Background(), organizationID, deploymentID, deployID, finalizeDeployRequest)
35✔
803
        if err != nil {
35✔
804
                return err
×
805
        }
×
806
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
35✔
807
        if err != nil {
35✔
808
                return err
×
809
        }
×
810
        if resp.JSON200.DagTarballVersion != nil {
70✔
811
                fmt.Println("Deployed DAG bundle: ", *resp.JSON200.DagTarballVersion)
35✔
812
        }
35✔
813
        if resp.JSON200.ImageTag != "" {
70✔
814
                fmt.Println("Deployed Image Tag: ", resp.JSON200.ImageTag)
35✔
815
        }
35✔
816
        return nil
35✔
817
}
818

819
func createDeploy(organizationID, deploymentID string, request astrov1.CreateDeployRequest, astroV1Client astrov1.APIClient) (*astrov1.Deploy, error) {
41✔
820
        resp, err := astroV1Client.CreateDeployWithResponse(httpContext.Background(), organizationID, deploymentID, request)
41✔
821
        if err != nil {
41✔
822
                return nil, err
×
823
        }
×
824
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
41✔
825
        if err != nil {
41✔
826
                return nil, err
×
827
        }
×
828
        return resp.JSON200, err
41✔
829
}
830

831
// createDeployWithDagBundle creates a deploy targeting a named DAG bundle via the
832
// v1alpha1 deploy API, the only tier that accepts dagBundleName. The v1alpha1
833
// type vocabulary is simpler than v1's: the server expands IMAGE to IMAGE_AND_DAG
834
// (or DAG to DAG_ONLY) based on the deployment, so a plain deploy maps to IMAGE
835
// and a --dags deploy maps to DAG. Remove once dagBundleName reaches the v1 API.
836
func createDeployWithDagBundle(organizationID, deploymentID, description, dagBundleName string, dags bool, git *astrov1.CreateDeployGitRequest, client astrov1alpha1.APIClient) (*astrov1alpha1.Deploy, error) {
4✔
837
        deployType := astrov1alpha1.CreateDeployRequestTypeIMAGE
4✔
838
        if dags {
5✔
839
                deployType = astrov1alpha1.CreateDeployRequestTypeDAG
1✔
840
        }
1✔
841
        request := astrov1alpha1.CreateDeployRequest{
4✔
842
                Type:          deployType,
4✔
843
                Description:   &description,
4✔
844
                DagBundleName: &dagBundleName,
4✔
845
                Git:           toV1Alpha1GitRequest(git),
4✔
846
        }
4✔
847
        resp, err := client.CreateDeployWithResponse(httpContext.Background(), organizationID, deploymentID, request)
4✔
848
        if err != nil {
5✔
849
                return nil, err
1✔
850
        }
1✔
851
        err = astrov1alpha1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
3✔
852
        if err != nil {
4✔
853
                return nil, err
1✔
854
        }
1✔
855
        return resp.JSON200, nil
2✔
856
}
857

858
func toV1Alpha1GitRequest(git *astrov1.CreateDeployGitRequest) *astrov1alpha1.CreateDeployGitRequest {
6✔
859
        if git == nil {
11✔
860
                return nil
5✔
861
        }
5✔
862
        return &astrov1alpha1.CreateDeployGitRequest{
1✔
863
                Account:         git.Account,
1✔
864
                AuthorName:      git.AuthorName,
1✔
865
                AuthorUrl:       git.AuthorUrl,
1✔
866
                AuthorUsername:  git.AuthorUsername,
1✔
867
                BeforeCommitSha: git.BeforeCommitSha,
1✔
868
                Branch:          git.Branch,
1✔
869
                CommitSha:       git.CommitSha,
1✔
870
                CommitUrl:       git.CommitUrl,
1✔
871
                Path:            git.Path,
1✔
872
                Provider:        astrov1alpha1.CreateDeployGitRequestProvider(git.Provider),
1✔
873
                RemoteUrl:       git.RemoteUrl,
1✔
874
                Repo:            git.Repo,
1✔
875
        }
1✔
876
}
877

878
func ValidRuntimeVersion(currentVersion, tag string, deploymentOptionsRuntimeVersions []string) bool {
44✔
879
        // Allow old deployments which do not have runtimeVersion tag
44✔
880
        if currentVersion == "" {
45✔
881
                return true
1✔
882
        }
1✔
883

884
        // Check that the tag is not a downgrade
885
        if airflowversions.CompareRuntimeVersions(tag, currentVersion) < 0 {
46✔
886
                fmt.Printf("Cannot deploy a downgraded Astro Runtime version. Modify your Astro Runtime version to %s or higher in your Dockerfile\n", currentVersion)
3✔
887
                return false
3✔
888
        }
3✔
889

890
        // Check that the tag is supported by the deployment
891
        tagInDeploymentOptions := false
40✔
892
        for _, runtimeVersion := range deploymentOptionsRuntimeVersions {
111✔
893
                if airflowversions.CompareRuntimeVersions(tag, runtimeVersion) == 0 {
110✔
894
                        tagInDeploymentOptions = true
39✔
895
                        break
39✔
896
                }
897
        }
898
        if !tagInDeploymentOptions {
41✔
899
                fmt.Println("Cannot deploy an unsupported Astro Runtime version. Modify your Astro Runtime version to a supported version in your Dockerfile")
1✔
900
                fmt.Printf("Supported versions: %s\n", strings.Join(deploymentOptionsRuntimeVersions, ", "))
1✔
901
                return false
1✔
902
        }
1✔
903

904
        // If upgrading from Airflow 2 to Airflow 3, we require at least Runtime 12.0.0 (Airflow 2.10.0)
905
        currentVersionAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(currentVersion)
39✔
906
        tagAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(tag)
39✔
907
        if currentVersionAirflowMajorVersion == "2" && tagAirflowMajorVersion == "3" {
41✔
908
                if airflowversions.CompareRuntimeVersions(currentVersion, "12.0.0") < 0 {
3✔
909
                        fmt.Println("Can only upgrade deployment from Airflow 2 to Airflow 3 with deployment at Astro Runtime 12.0.0 or higher")
1✔
910
                        return false
1✔
911
                }
1✔
912
        }
913

914
        return true
38✔
915
}
916

917
func WarnIfNonLatestVersion(version string, httpClient *httputil.HTTPClient) {
37✔
918
        client := airflowversions.NewClient(httpClient, false, false)
37✔
919
        latestRuntimeVersion, err := airflowversions.GetDefaultImageTag(client, "", "", false)
37✔
920
        if err != nil {
39✔
921
                logger.Debugf("unable to get latest runtime version: %s", err)
2✔
922
                return
2✔
923
        }
2✔
924

925
        if airflowversions.CompareRuntimeVersions(version, latestRuntimeVersion) < 0 {
70✔
926
                fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
35✔
927
        }
35✔
928
}
929

930
// ClientBuildContext represents a prepared build context for client deployment
931
type ClientBuildContext struct {
932
        // TempDir is the temporary directory containing the build context
933
        TempDir string
934
        // CleanupFunc should be called to clean up the temporary directory
935
        CleanupFunc func()
936
}
937

938
// prepareClientBuildContext creates a temporary build context with client dependency files
939
// This avoids modifying the original project files, preventing race conditions with concurrent deployments.
940
func prepareClientBuildContext(sourcePath string) (*ClientBuildContext, error) {
10✔
941
        // Create a temporary directory for the build context
10✔
942
        tempBuildDir, err := os.MkdirTemp("", "astro-client-build-*")
10✔
943
        if err != nil {
10✔
944
                return nil, fmt.Errorf("failed to create temporary build directory: %w", err)
×
945
        }
×
946

947
        // Cleanup function to be called by the caller
948
        cleanup := func() {
20✔
949
                os.RemoveAll(tempBuildDir)
10✔
950
        }
10✔
951

952
        // Always return cleanup function if we created a temp directory, even on error
953
        buildContext := &ClientBuildContext{
10✔
954
                TempDir:     tempBuildDir,
10✔
955
                CleanupFunc: cleanup,
10✔
956
        }
10✔
957

10✔
958
        // Check if source directory exists first
10✔
959
        if exists, err := fileutil.Exists(sourcePath, nil); err != nil {
10✔
960
                return buildContext, fmt.Errorf("failed to check if source directory exists: %w", err)
×
961
        } else if !exists {
11✔
962
                return buildContext, fmt.Errorf("source directory does not exist: %s", sourcePath)
1✔
963
        }
1✔
964

965
        // Build a skip predicate from the project's .dockerignore so excluded
966
        // paths (e.g. infra/ with terragrunt provider-cache symlinks) are not
967
        // copied into the build context. This mirrors what the Docker builder does
968
        // for in-place builds; the client deploy copies the context first, so it
969
        // must honor .dockerignore itself.
970
        skip, err := dockerignoreSkipFunc(sourcePath)
9✔
971
        if err != nil {
9✔
972
                return buildContext, fmt.Errorf("failed to read .dockerignore: %w", err)
×
973
        }
×
974

975
        // Copy all project files to the temporary directory
976
        err = fileutil.CopyDirectoryFiltered(sourcePath, tempBuildDir, skip)
9✔
977
        if err != nil {
9✔
978
                return buildContext, fmt.Errorf("failed to copy project files to temporary directory: %w", err)
×
979
        }
×
980

981
        // Process client dependency files
982
        err = setupClientDependencyFiles(tempBuildDir)
9✔
983
        if err != nil {
11✔
984
                return buildContext, fmt.Errorf("failed to setup client dependency files: %w", err)
2✔
985
        }
2✔
986

987
        return buildContext, nil
7✔
988
}
989

990
// alwaysIncludedBuildFiles are never excluded from the client build context,
991
// even if a user's .dockerignore would match them. The Docker builder applies
992
// the same special-casing to the Dockerfile and .dockerignore, and the client
993
// deploy additionally needs its client dependency files.
994
var alwaysIncludedBuildFiles = map[string]bool{
995
        "Dockerfile.client":       true,
996
        ".dockerignore":           true,
997
        "requirements-client.txt": true,
998
        "packages-client.txt":     true,
999
}
1000

1001
// dockerignoreSkipFunc parses the .dockerignore at sourcePath (if any) and
1002
// returns a predicate, suitable for fileutil.CopyDirectoryFiltered, that
1003
// reports whether a path should be excluded from the build context. It returns
1004
// nil (copy everything) when there is no .dockerignore file.
1005
func dockerignoreSkipFunc(sourcePath string) (func(relPath string, isDir bool) bool, error) {
13✔
1006
        f, err := os.Open(filepath.Join(sourcePath, ".dockerignore"))
13✔
1007
        if err != nil {
22✔
1008
                if os.IsNotExist(err) {
18✔
1009
                        return nil, nil
9✔
1010
                }
9✔
1011
                return nil, err
×
1012
        }
1013
        defer f.Close()
4✔
1014

4✔
1015
        patterns, err := ignorefile.ReadAll(f)
4✔
1016
        if err != nil {
4✔
1017
                return nil, err
×
1018
        }
×
1019

1020
        pm, err := patternmatcher.New(patterns)
4✔
1021
        if err != nil {
4✔
1022
                return nil, err
×
1023
        }
×
1024

1025
        return func(relPath string, isDir bool) bool {
26✔
1026
                if alwaysIncludedBuildFiles[relPath] {
30✔
1027
                        return false
8✔
1028
                }
8✔
1029
                matched, err := pm.MatchesOrParentMatches(relPath)
14✔
1030
                if err != nil || !matched {
22✔
1031
                        return false
8✔
1032
                }
8✔
1033
                // When exclusion ("!") patterns exist, a child of a matched directory
1034
                // may be re-included, so we must descend rather than prune the dir.
1035
                if isDir && pm.Exclusions() {
7✔
1036
                        return false
1✔
1037
                }
1✔
1038
                return true
5✔
1039
        }, nil
1040
}
1041

1042
// setupClientDependencyFiles processes client-specific dependency files in the build context
1043
func setupClientDependencyFiles(buildDir string) error {
12✔
1044
        // Define dependency file pairs (client file -> regular file)
12✔
1045
        dependencyFiles := map[string]string{
12✔
1046
                "requirements-client.txt": "requirements.txt",
12✔
1047
                "packages-client.txt":     "packages.txt",
12✔
1048
        }
12✔
1049

12✔
1050
        // Process client dependency files in the build directory
12✔
1051
        for clientFile, regularFile := range dependencyFiles {
34✔
1052
                clientPath := filepath.Join(buildDir, clientFile)
22✔
1053
                regularPath := filepath.Join(buildDir, regularFile)
22✔
1054

22✔
1055
                // Copy client file content to the regular file location (requires client file to exist)
22✔
1056
                if err := fileutil.CopyFile(clientPath, regularPath); err != nil {
25✔
1057
                        return fmt.Errorf("failed to copy %s to %s in build context: %w", clientFile, regularFile, err)
3✔
1058
                }
3✔
1059
        }
1060

1061
        return nil
9✔
1062
}
1063

1064
// DeployClientImage handles the client deploy functionality
1065
func DeployClientImage(deployInput InputClientDeploy, astroV1Client astrov1.APIClient) error { //nolint:gocritic
6✔
1066
        c, err := config.GetCurrentContext()
6✔
1067
        if err != nil {
6✔
1068
                return errors.Wrap(err, "failed to get current context")
×
1069
        }
×
1070

1071
        // Validate deployment runtime version if deployment ID is provided
1072
        if err := validateClientImageRuntimeVersion(deployInput, astroV1Client); err != nil {
6✔
1073
                return err
×
1074
        }
×
1075

1076
        // Get the remote client registry endpoint from config
1077
        registryEndpoint := config.CFG.RemoteClientRegistry.GetString()
6✔
1078
        if registryEndpoint == "" {
7✔
1079
                fmt.Println("The Astro CLI is not configured to push client images to your private registry.")
1✔
1080
                fmt.Println("For remote Deployments, client images must be stored in your private registry, not in Astronomer managed registries.")
1✔
1081
                fmt.Println("Please provide your private registry information so the Astro CLI can push client images.")
1✔
1082
                return errors.New("remote client registry is not configured. To configure it, run: 'astro config set remote.client_registry <endpoint>' and try again.")
1✔
1083
        }
1✔
1084

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

5✔
1089
        // Build the full remote image name
5✔
1090
        remoteImage := fmt.Sprintf("%s:%s", registryEndpoint, imageTag)
5✔
1091

5✔
1092
        // Create an image handler for building and pushing
5✔
1093
        imageHandler := airflowImageHandler(remoteImage)
5✔
1094

5✔
1095
        if deployInput.ImageName != "" {
6✔
1096
                // Use the provided local image (tag will be ignored, remote tag is always timestamp-based)
1✔
1097
                fmt.Println("Using provided image:", deployInput.ImageName)
1✔
1098
                err := imageHandler.TagLocalImage(deployInput.ImageName)
1✔
1099
                if err != nil {
1✔
1100
                        return fmt.Errorf("failed to tag local image: %w", err)
×
1101
                }
×
1102
        } else {
4✔
1103
                // Authenticate with the base image registry before building
4✔
1104
                // This is needed because Dockerfile.client uses base images from a private registry
4✔
1105

4✔
1106
                // Skip registry login if the base image registry is not from astronomer, check the content of the Dockerfile.client file
4✔
1107
                dockerfileClientContent, err := fileutil.ReadFileToString(filepath.Join(deployInput.Path, "Dockerfile.client"))
4✔
1108
                if util.IsAstronomerRegistry(dockerfileClientContent) || err != nil {
8✔
1109
                        // login to the registry
4✔
1110
                        if err != nil {
5✔
1111
                                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✔
1112
                        }
1✔
1113
                        baseImageRegistry := config.CFG.RemoteBaseImageRegistry.GetString()
4✔
1114
                        fmt.Printf("Authenticating with base image registry: %s\n", baseImageRegistry)
4✔
1115
                        err := airflow.DockerLogin(baseImageRegistry, registryUsername, c.Token)
4✔
1116
                        if err != nil {
5✔
1117
                                fmt.Println("Failed to authenticate with Astronomer registry that contains the base agent image used in the Dockerfile.client file.")
1✔
1118
                                fmt.Println("This could be because either your token has expired or you don't have permission to pull the base agent image.")
1✔
1119
                                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✔
1120
                                return fmt.Errorf("failed to authenticate with registry %s: %w", baseImageRegistry, err)
1✔
1121
                        }
1✔
1122
                }
1123

1124
                // Build the client image from the current directory
1125
                // Determine target platforms for client deploy
1126
                var targetPlatforms []string
3✔
1127
                if deployInput.Platform != "" {
3✔
1128
                        // Parse comma-separated platforms from --platform flag
×
1129
                        targetPlatforms = strings.Split(deployInput.Platform, ",")
×
1130
                        // Trim whitespace from each platform
×
1131
                        for i, platform := range targetPlatforms {
×
1132
                                targetPlatforms[i] = strings.TrimSpace(platform)
×
1133
                        }
×
1134
                        fmt.Printf("Building client image for platforms: %s\n", strings.Join(targetPlatforms, ", "))
×
1135
                } else {
3✔
1136
                        // Use empty slice to let Docker build for host platform by default
3✔
1137
                        targetPlatforms = []string{}
3✔
1138
                        fmt.Println("Building client image for host platform")
3✔
1139
                }
3✔
1140

1141
                // Prepare build context with client dependency files
1142
                buildContext, err := prepareClientBuildContext(deployInput.Path)
3✔
1143
                if buildContext != nil && buildContext.CleanupFunc != nil {
6✔
1144
                        defer buildContext.CleanupFunc()
3✔
1145
                }
3✔
1146
                if err != nil {
3✔
1147
                        return fmt.Errorf("failed to prepare client build context: %w", err)
×
1148
                }
×
1149

1150
                // Build the image from the prepared context
1151
                buildConfig := types.ImageBuildConfig{
3✔
1152
                        Path:            buildContext.TempDir,
3✔
1153
                        TargetPlatforms: targetPlatforms,
3✔
1154
                }
3✔
1155

3✔
1156
                err = imageHandler.Build("Dockerfile.client", deployInput.BuildSecrets, buildConfig)
3✔
1157
                if err != nil {
4✔
1158
                        return fmt.Errorf("failed to build client image: %w", err)
1✔
1159
                }
1✔
1160
        }
1161

1162
        // Push the image to the remote registry (assumes docker login was done externally)
1163
        fmt.Println("Pushing client image to configured remote registry")
3✔
1164
        _, err = imageHandler.Push(remoteImage, "", "", false)
3✔
1165
        if err != nil {
4✔
1166
                if errors.Is(err, airflow.ErrImagePush403) {
1✔
1167
                        fmt.Printf("\n--------------------------------\n")
×
1168
                        fmt.Printf("Failed to push client image to %s\n", registryEndpoint)
×
1169
                        fmt.Println("It could be due to either your registry token has expired or you don't have permission to push the client image")
×
1170
                        fmt.Printf("Please ensure that you have logged in to `%s` via `docker login` and try again\n\n", registryEndpoint)
×
1171
                }
×
1172
                return fmt.Errorf("failed to push client image: %w", err)
1✔
1173
        }
1174

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

2✔
1177
        fmt.Printf("\n--------------------------------\n")
2✔
1178
        fmt.Println("The client image has been pushed to your private registry.")
2✔
1179
        fmt.Println("Your next step would be to update the agent component to use the new client image.")
2✔
1180
        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✔
1181
        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✔
1182
        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✔
1183

2✔
1184
        return nil
2✔
1185
}
1186

1187
// validateClientImageRuntimeVersion validates that the client image runtime version
1188
// is not newer than the deployment runtime version
1189
func validateClientImageRuntimeVersion(deployInput InputClientDeploy, astroV1Client astrov1.APIClient) error { //nolint:gocritic
16✔
1190
        // Skip validation if no deployment ID provided
16✔
1191
        if deployInput.DeploymentID == "" {
23✔
1192
                return nil
7✔
1193
        }
7✔
1194

1195
        // Get current context for organization info
1196
        c, err := config.GetCurrentContext()
9✔
1197
        if err != nil {
10✔
1198
                return errors.Wrap(err, "failed to get current context")
1✔
1199
        }
1✔
1200

1201
        // Get deployment information
1202
        deployInfo, err := fetchDeploymentDetails(deployInput.DeploymentID, c.Organization, astroV1Client)
8✔
1203
        if err != nil {
9✔
1204
                return errors.Wrap(err, "failed to get deployment information")
1✔
1205
        }
1✔
1206

1207
        // Parse Dockerfile.client to get client image runtime version
1208
        dockerfileClientPath := filepath.Join(deployInput.Path, "Dockerfile.client")
7✔
1209
        if _, err := os.Stat(dockerfileClientPath); os.IsNotExist(err) {
8✔
1210
                return errors.New("Dockerfile.client is required for client image runtime version validation")
1✔
1211
        }
1✔
1212

1213
        cmds, err := docker.ParseFile(dockerfileClientPath)
6✔
1214
        if err != nil {
7✔
1215
                return errors.Wrapf(err, "failed to parse Dockerfile.client: %s", dockerfileClientPath)
1✔
1216
        }
1✔
1217

1218
        baseImage := docker.GetImageFromParsedFile(cmds)
5✔
1219
        if baseImage == "" {
6✔
1220
                return errors.New("failed to find base image in Dockerfile.client")
1✔
1221
        }
1✔
1222

1223
        // Extract runtime version from the base image tag
1224
        clientRuntimeVersion, err := extractRuntimeVersionFromImage(baseImage)
4✔
1225
        if err != nil {
5✔
1226
                return errors.Wrapf(err, "failed to extract runtime version from client image %s", baseImage)
1✔
1227
        }
1✔
1228

1229
        // Compare versions
1230
        if airflowversions.CompareRuntimeVersions(clientRuntimeVersion, deployInfo.currentVersion) > 0 {
4✔
1231
                return fmt.Errorf(`client image runtime version validation failed:
1✔
1232

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

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

1✔
1239
This validation ensures compatibility between your client image and the deployment environment`,
1✔
1240
                        clientRuntimeVersion, deployInfo.currentVersion, deployInfo.currentVersion, clientRuntimeVersion)
1✔
1241
        }
1✔
1242

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

2✔
1246
        return nil
2✔
1247
}
1248

1249
// extractRuntimeVersionFromImage extracts the runtime version from an image tag
1250
// Example: "images.astronomer.cloud/baseimages/astro-remote-execution-agent:3.1-1-python-3.12-astro-agent-1.1.0"
1251
// Returns: "3.1-1"
1252
func extractRuntimeVersionFromImage(imageName string) (string, error) {
9✔
1253
        // Split image name to get the tag part
9✔
1254
        parts := strings.Split(imageName, ":")
9✔
1255
        if len(parts) < 2 {
10✔
1256
                return "", errors.New("image name does not contain a tag")
1✔
1257
        }
1✔
1258

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

8✔
1261
        // Use the existing ParseImageTag function from airflow_versions package
8✔
1262
        tagInfo, err := airflowversions.ParseImageTag(imageTag)
8✔
1263
        if err != nil {
10✔
1264
                return "", errors.Wrapf(err, "failed to parse image tag: %s", imageTag)
2✔
1265
        }
2✔
1266

1267
        return tagInfo.RuntimeVersion, nil
6✔
1268
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc