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

astronomer / astro-cli / 6940680e-abe8-4ad0-acdd-f0d272158db4

02 Jun 2026 07:30PM UTC coverage: 44.762% (+0.07%) from 44.695%
6940680e-abe8-4ad0-acdd-f0d272158db4

push

circleci

web-flow
fix(deploy): preserve symlinks and honor .dockerignore in client build context (#2162)

53 of 64 new or added lines in 2 files covered. (82.81%)

2653 existing lines in 5 files now uncovered.

25022 of 55900 relevant lines covered (44.76%)

7.74 hits per line

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

82.21
/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
        "github.com/astronomer/astro-cli/cloud/deployment"
22
        "github.com/astronomer/astro-cli/cloud/organization"
23
        "github.com/astronomer/astro-cli/config"
24
        "github.com/astronomer/astro-cli/docker"
25
        "github.com/astronomer/astro-cli/pkg/ansi"
26
        "github.com/astronomer/astro-cli/pkg/azure"
27
        "github.com/astronomer/astro-cli/pkg/fileutil"
28
        "github.com/astronomer/astro-cli/pkg/httputil"
29
        "github.com/astronomer/astro-cli/pkg/input"
30
        "github.com/astronomer/astro-cli/pkg/logger"
31
        "github.com/astronomer/astro-cli/pkg/util"
32
)
33

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

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

45
        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"
46
        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"
47

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

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

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

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

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

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

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

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

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

128
const accessYourDeploymentFmt = `
129

130
 Access your Deployment:
131

132
 Deployment View: %s
133
 Airflow UI: %s
134
`
135

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

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

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

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

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

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

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

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

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

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

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

194
        return versionID, nil
21✔
195
}
196

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

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

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

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

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

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

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

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

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

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

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

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

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

279
        createDeployRequest.Git = deployGit
41✔
280

41✔
281
        deploy, err := createDeploy(deployInfo.organizationID, deployInfo.deploymentID, createDeployRequest, astroV1Client)
41✔
282
        if err != nil {
41✔
283
                return err
×
284
        }
×
285
        deployID := deploy.Id
41✔
286
        if deploy.DagsUploadUrl != nil {
82✔
287
                dagsUploadURL = *deploy.DagsUploadUrl
41✔
288
        } else {
41✔
289
                dagsUploadURL = ""
×
290
        }
×
291
        if deploy.ImageTag != "" {
41✔
292
                nextTag = deploy.ImageTag
×
293
        } else {
41✔
294
                nextTag = ""
41✔
295
        }
41✔
296

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

1✔
301
                        if !i {
2✔
302
                                fmt.Println("Canceling deploy...")
1✔
303
                                return nil
1✔
304
                        }
1✔
305
                }
306
                if deployInput.Pytest != "" {
30✔
307
                        runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, astroV1Client)
12✔
308
                        if err != nil {
12✔
309
                                return err
×
310
                        }
×
311

312
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecretString)
12✔
313
                        if err != nil {
14✔
314
                                return err
2✔
315
                        }
2✔
316
                }
317

318
                if !deployInfo.dagDeployEnabled {
17✔
319
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
1✔
320
                }
1✔
321

322
                fmt.Println("Initiating DAG deploy for: " + deployInfo.deploymentID)
15✔
323
                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astrov1.DeploymentType(deployInfo.deploymentType), deployInput.NoDagsBaseDir)
15✔
324
                if err != nil {
15✔
325
                        if strings.Contains(err.Error(), dagDeployDisabled) {
×
326
                                return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
327
                        }
×
328

329
                        return err
×
330
                }
331

332
                // finish deploy
333
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, astroV1Client)
15✔
334
                if err != nil {
15✔
335
                        return err
×
336
                }
×
337

338
                if deployInput.WaitForStatus {
16✔
339
                        // Keeping wait timeout low since dag only deploy is faster
1✔
340
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, dagOnlyDeploySleepTime, tickNum, int(deployInput.WaitTime.Seconds()), astroV1Client)
1✔
341
                        if err != nil {
2✔
342
                                return err
1✔
343
                        }
1✔
344

345
                        fmt.Println(
×
346
                                "\nSuccessfully uploaded DAGs with version " + ansi.Bold(dagTarballVersion) + " to Astro. Navigate to the Airflow UI to confirm that your deploy was successful." +
×
347
                                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold(deploymentURL), ansi.Bold(deployInfo.webserverURL)),
×
348
                        )
×
349

×
350
                        return nil
×
351
                }
352

353
                fmt.Println(
14✔
354
                        "\nSuccessfully uploaded DAGs with version " + ansi.Bold(
14✔
355
                                dagTarballVersion,
14✔
356
                        ) + " to Astro. Navigate to the Airflow UI to confirm that your deploy was successful. The Airflow UI takes about 1 minute to update." +
14✔
357
                                fmt.Sprintf(
14✔
358
                                        accessYourDeploymentFmt,
14✔
359
                                        ansi.Bold(deploymentURL),
14✔
360
                                        ansi.Bold(deployInfo.webserverURL),
14✔
361
                                ),
14✔
362
                )
14✔
363
        } else {
22✔
364
                fullpath := filepath.Join(deployInput.Path, ".dockerignore")
22✔
365
                fileExist, _ := fileutil.Exists(fullpath, nil)
22✔
366
                if fileExist {
44✔
367
                        err := removeDagsFromDockerIgnore(fullpath)
22✔
368
                        if err != nil {
22✔
369
                                return errors.Wrap(err, "Found dags entry in .dockerignore file. Remove this entry and try again")
×
370
                        }
×
371
                }
372
                envFileExists, _ := fileutil.Exists(deployInput.EnvFile, nil)
22✔
373
                if !envFileExists && deployInput.EnvFile != ".env" {
23✔
374
                        return fmt.Errorf("%w %s", envFileMissing, deployInput.EnvFile)
1✔
375
                }
1✔
376

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

×
380
                        if !i {
×
381
                                fmt.Println("Canceling deploy...")
×
382
                                return nil
×
383
                        }
×
384
                }
385

386
                // Build our image
387
                runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, astroV1Client)
21✔
388
                if err != nil {
21✔
389
                        return err
×
390
                }
×
391

392
                if len(dagFiles) > 0 {
28✔
393
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecretString)
7✔
394
                        if err != nil {
8✔
395
                                return err
1✔
396
                        }
1✔
397
                } else {
14✔
398
                        fmt.Println("No DAGs found. Skipping testing...")
14✔
399
                }
14✔
400

401
                repository := deploy.ImageRepository
20✔
402
                // TODO: Resolve the edge case where two people push the same nextTag at the same time
20✔
403
                remoteImage := fmt.Sprintf("%s:%s", repository, nextTag)
20✔
404

20✔
405
                imageHandler := airflowImageHandler(deployInfo.deployImage)
20✔
406
                fmt.Println("Pushing image to Astronomer registry")
20✔
407
                _, err = imageHandler.Push(remoteImage, registryUsername, c.Token, false)
20✔
408
                if err != nil {
20✔
409
                        return err
×
410
                }
×
411

412
                if deployInfo.dagDeployEnabled && len(dagFiles) > 0 {
26✔
413
                        if !deployInput.Image {
12✔
414
                                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astrov1.DeploymentType(deployInfo.deploymentType), deployInput.NoDagsBaseDir)
6✔
415
                                if err != nil {
6✔
416
                                        return err
×
417
                                }
×
418
                        } else {
×
419
                                fmt.Println("Image Deploy only. Skipping deploying DAG...")
×
420
                        }
×
421
                }
422
                // finish deploy
423
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, astroV1Client)
20✔
424
                if err != nil {
20✔
425
                        return err
×
426
                }
×
427

428
                if deployInput.WaitForStatus {
22✔
429
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, sleepTime, tickNum, int(deployInput.WaitTime.Seconds()), astroV1Client)
2✔
430
                        if err != nil {
4✔
431
                                return err
2✔
432
                        }
2✔
433
                }
434

435
                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✔
436
                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold("https://"+deploymentURL), ansi.Bold("https://"+deployInfo.webserverURL)))
18✔
437
        }
438

439
        return nil
32✔
440
}
441

442
func getDeploymentInfo(
443
        deploymentID, wsID, deploymentName string,
444
        prompt bool,
445
        astroV1Client astrov1.APIClient,
446
) (deploymentInfo, error) {
43✔
447
        // Use config deployment if provided
43✔
448
        if deploymentID == "" {
57✔
449
                deploymentID = config.CFG.ProjectDeployment.GetProjectString()
14✔
450
                if deploymentID != "" {
14✔
451
                        fmt.Printf("Deployment ID found in the config file. This Deployment ID will be used for the deploy\n")
×
452
                }
×
453
        }
454

455
        if deploymentID != "" && deploymentName != "" {
51✔
456
                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✔
457
        }
8✔
458

459
        // check if deploymentID or if force prompt was requested was given by user
460
        if deploymentID == "" || prompt {
70✔
461
                currentDeployment, err := deployment.GetDeployment(wsID, deploymentID, deploymentName, false, nil, astroV1Client)
27✔
462
                if err != nil {
27✔
463
                        return deploymentInfo{}, err
×
464
                }
×
465
                deploymentByID, err := deployment.GetDeploymentByID(currentDeployment.OrganizationId, currentDeployment.Id, astroV1Client)
27✔
466
                if err != nil {
27✔
467
                        return deploymentInfo{}, err
×
468
                }
×
469
                var desiredDagTarballVersion string
27✔
470
                if deploymentByID.DesiredDagTarballVersion != nil {
45✔
471
                        desiredDagTarballVersion = *deploymentByID.DesiredDagTarballVersion
18✔
472
                } else {
27✔
473
                        desiredDagTarballVersion = ""
9✔
474
                }
9✔
475

476
                return deploymentInfo{
27✔
477
                        currentDeployment.Id,
27✔
478
                        currentDeployment.Namespace,
27✔
479
                        airflow.ImageName(currentDeployment.Namespace, "latest"),
27✔
480
                        currentDeployment.RuntimeVersion,
27✔
481
                        currentDeployment.OrganizationId,
27✔
482
                        currentDeployment.WorkspaceId,
27✔
483
                        currentDeployment.WebServerUrl,
27✔
484
                        string(*currentDeployment.Type),
27✔
485
                        desiredDagTarballVersion,
27✔
486
                        currentDeployment.IsDagDeployEnabled,
27✔
487
                        currentDeployment.IsCicdEnforced,
27✔
488
                        currentDeployment.Name,
27✔
489
                        deployment.IsRemoteExecutionEnabled(&currentDeployment),
27✔
490
                }, nil
27✔
491
        }
492
        c, err := config.GetCurrentContext()
16✔
493
        if err != nil {
16✔
494
                return deploymentInfo{}, err
×
495
        }
×
496
        deployInfo, err := fetchDeploymentDetails(deploymentID, c.Organization, astroV1Client)
16✔
497
        if err != nil {
16✔
498
                return deploymentInfo{}, err
×
499
        }
×
500
        deployInfo.deploymentID = deploymentID
16✔
501
        return deployInfo, nil
16✔
502
}
503

504
func parseOrPytestDAG(pytest, runtimeVersion, envFile, deployImage, namespace, buildSecretString string) error {
19✔
505
        validDAGParseVersion := airflowversions.CompareRuntimeVersions(runtimeVersion, dagParseAllowedVersion) >= 0
19✔
506
        if !validDAGParseVersion {
19✔
507
                fmt.Println("\nruntime image is earlier than 4.1.0, this deploy will skip DAG parse...")
×
508
        }
×
509

510
        containerHandler, err := containerHandlerInit(config.WorkingPath, envFile, "Dockerfile", namespace)
19✔
511
        if err != nil {
19✔
512
                return err
×
513
        }
×
514

515
        switch {
19✔
516
        case pytest == parse && validDAGParseVersion:
7✔
517
                // parse dags
7✔
518
                fmt.Println("Testing image...")
7✔
519
                err := parseDAGs(deployImage, buildSecretString, containerHandler)
7✔
520
                if err != nil {
9✔
521
                        return err
2✔
522
                }
2✔
523
        case pytest != "" && pytest != parse && pytest != parseAndPytest:
6✔
524
                // check pytests
6✔
525
                fmt.Println("Testing image...")
6✔
526
                err := checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
527
                if err != nil {
7✔
528
                        return err
1✔
529
                }
1✔
530
        case pytest == parseAndPytest:
6✔
531
                // parse dags and check pytests
6✔
532
                fmt.Println("Testing image...")
6✔
533
                err := parseDAGs(deployImage, buildSecretString, containerHandler)
6✔
534
                if err != nil {
6✔
535
                        return err
×
536
                }
×
537

538
                err = checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
539
                if err != nil {
6✔
540
                        return err
×
541
                }
×
542
        }
543
        return nil
16✔
544
}
545

546
func parseDAGs(deployImage, buildSecretString string, containerHandler airflow.ContainerHandler) error {
13✔
547
        if !config.CFG.SkipParse.GetBool() && !util.CheckEnvBool(os.Getenv("ASTRONOMER_SKIP_PARSE")) {
26✔
548
                err := containerHandler.Parse("", deployImage, buildSecretString)
13✔
549
                if err != nil {
15✔
550
                        fmt.Println(err)
2✔
551
                        return errDagsParseFailed
2✔
552
                }
2✔
553
        } else {
×
554
                fmt.Println("Skipping parsing dags due to skip parse being set to true in either the config.yaml or local environment variables")
×
555
        }
×
556

557
        return nil
11✔
558
}
559

560
// Validate code with pytest
561
func checkPytest(pytest, deployImage, buildSecretString string, containerHandler airflow.ContainerHandler) error {
14✔
562
        if pytest != allTests && pytest != parseAndPytest {
18✔
563
                pytestFile = pytest
4✔
564
        }
4✔
565

566
        exitCode, err := containerHandler.Pytest(pytestFile, "", deployImage, "", buildSecretString)
14✔
567
        if err != nil {
17✔
568
                if strings.Contains(exitCode, "1") { // exit code is 1 meaning tests failed
4✔
569
                        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✔
570
                }
1✔
571
                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✔
572
        }
573

574
        fmt.Print("\nAll Pytests passed!\n")
11✔
575
        return err
11✔
576
}
577

578
func fetchDeploymentDetails(deploymentID, organizationID string, astroV1Client astrov1.APIClient) (deploymentInfo, error) {
24✔
579
        resp, err := astroV1Client.GetDeploymentWithResponse(httpContext.Background(), organizationID, deploymentID)
24✔
580
        if err != nil {
24✔
581
                return deploymentInfo{}, err
×
582
        }
×
583

584
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
24✔
585
        if err != nil {
25✔
586
                return deploymentInfo{}, err
1✔
587
        }
1✔
588

589
        currentVersion := resp.JSON200.RuntimeVersion
23✔
590
        namespace := resp.JSON200.Namespace
23✔
591
        workspaceID := resp.JSON200.WorkspaceId
23✔
592
        webserverURL := resp.JSON200.WebServerUrl
23✔
593
        dagDeployEnabled := resp.JSON200.IsDagDeployEnabled
23✔
594
        cicdEnforcement := resp.JSON200.IsCicdEnforced
23✔
595
        isRemoteExecutionEnabled := deployment.IsRemoteExecutionEnabled(resp.JSON200)
23✔
596
        var desiredDagTarballVersion string
23✔
597
        if resp.JSON200.DesiredDagTarballVersion != nil {
30✔
598
                desiredDagTarballVersion = *resp.JSON200.DesiredDagTarballVersion
7✔
599
        } else {
23✔
600
                desiredDagTarballVersion = ""
16✔
601
        }
16✔
602

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

23✔
606
        return deploymentInfo{
23✔
607
                namespace:                namespace,
23✔
608
                deployImage:              deployImage,
23✔
609
                currentVersion:           currentVersion,
23✔
610
                organizationID:           organizationID,
23✔
611
                workspaceID:              workspaceID,
23✔
612
                webserverURL:             webserverURL,
23✔
613
                dagDeployEnabled:         dagDeployEnabled,
23✔
614
                desiredDagTarballVersion: desiredDagTarballVersion,
23✔
615
                cicdEnforcement:          cicdEnforcement,
23✔
616
                isRemoteExecutionEnabled: isRemoteExecutionEnabled,
23✔
617
        }, nil
23✔
618
}
619

620
func buildImageWithoutDags(path, buildSecretString string, imageHandler airflow.ImageHandler) error {
29✔
621
        fullpath := filepath.Join(path, ".dockerignore")
29✔
622

29✔
623
        // Snapshot the original bytes so we can restore byte-for-byte after the build
29✔
624
        // (preserves CRLF, trailing whitespace, etc.).
29✔
625
        originalBytes, err := os.ReadFile(fullpath)
29✔
626
        originalExisted := err == nil
29✔
627
        if err != nil && !os.IsNotExist(err) {
29✔
628
                return err
×
629
        }
×
630

631
        defer func() {
58✔
632
                if originalExisted {
57✔
633
                        _ = os.WriteFile(fullpath, originalBytes, 0o644) //nolint:gosec,mnd
28✔
634
                } else {
29✔
635
                        _ = os.Remove(fullpath)
1✔
636
                }
1✔
637
        }()
638

639
        switch {
29✔
640
        case !originalExisted:
1✔
641
                if err := os.WriteFile(fullpath, []byte("dags/\n"), 0o644); err != nil { //nolint:gosec,mnd
1✔
642
                        return err
×
643
                }
×
644
        case !dockerignoreContainsDags(originalBytes):
26✔
645
                modified := append([]byte{}, originalBytes...)
26✔
646
                if len(modified) > 0 && modified[len(modified)-1] != '\n' {
28✔
647
                        modified = append(modified, '\n')
2✔
648
                }
2✔
649
                modified = append(modified, []byte("dags/\n")...)
26✔
650
                if err := os.WriteFile(fullpath, modified, 0o644); err != nil { //nolint:gosec,mnd
26✔
651
                        return err
×
652
                }
×
653
        }
654

655
        return imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
29✔
656
}
657

658
// dockerignoreContainsDags reports whether content has a line equal to "dags/".
659
// Uses bufio.Scanner so CRLF line endings are handled identically to LF.
660
func dockerignoreContainsDags(content []byte) bool {
28✔
661
        scanner := bufio.NewScanner(bytes.NewReader(content))
28✔
662
        for scanner.Scan() {
40✔
663
                if scanner.Text() == "dags/" {
14✔
664
                        return true
2✔
665
                }
2✔
666
        }
667
        return false
26✔
668
}
669

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

37✔
673
        if imageName == "" {
65✔
674
                // Build our image
28✔
675
                fmt.Println(composeImageBuildingPromptMsg)
28✔
676

28✔
677
                if dagDeployEnabled || isRemoteExecutionEnabled {
48✔
678
                        err := buildImageWithoutDags(path, buildSecretString, imageHandler)
20✔
679
                        if err != nil {
20✔
680
                                return "", err
×
681
                        }
×
682
                } else {
8✔
683
                        err := imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
8✔
684
                        if err != nil {
9✔
685
                                return "", err
1✔
686
                        }
1✔
687
                }
688
        } else {
9✔
689
                // skip build if an imageName is passed
9✔
690
                fmt.Println(composeSkipImageBuildingPromptMsg)
9✔
691

9✔
692
                err := imageHandler.TagLocalImage(imageName)
9✔
693
                if err != nil {
9✔
694
                        return "", err
×
695
                }
×
696
        }
697

698
        version, err = imageHandler.GetLabel("", runtimeImageLabel)
36✔
699
        if err != nil {
36✔
700
                fmt.Println("unable get runtime version from image")
×
701
        }
×
702

703
        if config.CFG.ShowWarnings.GetBool() && version == "" {
37✔
704
                if imageName != "" {
1✔
705
                        // Registry image names are arbitrary and do not convey base image
×
706
                        // information; reference the missing label in the warning instead.
×
707
                        fmt.Printf(warningInvalidPrebuiltImageNameMsg, imageName, runtimeImageLabel)
×
708
                } else {
1✔
709
                        // Parse the Dockerfile to include the FROM image in the warning,
1✔
710
                        // giving the user a concrete reference for what needs to change.
1✔
711
                        cmds, err := docker.ParseFile(filepath.Join(path, dockerfile))
1✔
712
                        if err != nil {
2✔
713
                                return "", errors.Wrapf(err, "failed to parse dockerfile: %s", filepath.Join(path, dockerfile))
1✔
714
                        }
1✔
715
                        fmt.Printf(warningInvalidImageNameMsg, docker.GetImageFromParsedFile(cmds))
×
716
                }
717
                fmt.Println("Canceling deploy...")
×
718
                os.Exit(1)
×
719
        }
720

721
        resp, err := astroV1Client.GetDeploymentOptionsWithResponse(httpContext.Background(), organizationID, &astrov1.GetDeploymentOptionsParams{})
35✔
722
        if err != nil {
36✔
723
                return "", err
1✔
724
        }
1✔
725
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
34✔
726
        if err != nil {
34✔
727
                return "", err
×
728
        }
×
729
        deploymentOptionsRuntimeVersions := []string{}
34✔
730
        for _, runtimeRelease := range resp.JSON200.RuntimeReleases {
238✔
731
                deploymentOptionsRuntimeVersions = append(deploymentOptionsRuntimeVersions, runtimeRelease.Version)
204✔
732
        }
204✔
733

734
        if !ValidRuntimeVersion(currentVersion, version, deploymentOptionsRuntimeVersions) {
34✔
735
                fmt.Println("Canceling deploy...")
×
736
                os.Exit(1)
×
737
        }
×
738

739
        WarnIfNonLatestVersion(version, httputil.NewHTTPClient())
34✔
740

34✔
741
        return version, nil
34✔
742
}
743

744
// finalize deploy
745
func finalizeDeploy(deployID, deploymentID, organizationID, dagTarballVersion string, dagDeploy bool, astroV1Client astrov1.APIClient) error {
35✔
746
        finalizeDeployRequest := astrov1.FinalizeDeployRequest{}
35✔
747
        if dagDeploy {
59✔
748
                finalizeDeployRequest.DagTarballVersion = &dagTarballVersion
24✔
749
        }
24✔
750
        resp, err := astroV1Client.FinalizeDeployWithResponse(httpContext.Background(), organizationID, deploymentID, deployID, finalizeDeployRequest)
35✔
751
        if err != nil {
35✔
752
                return err
×
753
        }
×
754
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
35✔
755
        if err != nil {
35✔
756
                return err
×
757
        }
×
758
        if resp.JSON200.DagTarballVersion != nil {
70✔
759
                fmt.Println("Deployed DAG bundle: ", *resp.JSON200.DagTarballVersion)
35✔
760
        }
35✔
761
        if resp.JSON200.ImageTag != "" {
70✔
762
                fmt.Println("Deployed Image Tag: ", resp.JSON200.ImageTag)
35✔
763
        }
35✔
764
        return nil
35✔
765
}
766

767
func createDeploy(organizationID, deploymentID string, request astrov1.CreateDeployRequest, astroV1Client astrov1.APIClient) (*astrov1.Deploy, error) {
41✔
768
        resp, err := astroV1Client.CreateDeployWithResponse(httpContext.Background(), organizationID, deploymentID, request)
41✔
769
        if err != nil {
41✔
770
                return nil, err
×
771
        }
×
772
        err = astrov1.NormalizeAPIError(resp.HTTPResponse, resp.Body)
41✔
773
        if err != nil {
41✔
774
                return nil, err
×
775
        }
×
776
        return resp.JSON200, err
41✔
777
}
778

779
func ValidRuntimeVersion(currentVersion, tag string, deploymentOptionsRuntimeVersions []string) bool {
44✔
780
        // Allow old deployments which do not have runtimeVersion tag
44✔
781
        if currentVersion == "" {
45✔
782
                return true
1✔
783
        }
1✔
784

785
        // Check that the tag is not a downgrade
786
        if airflowversions.CompareRuntimeVersions(tag, currentVersion) < 0 {
46✔
787
                fmt.Printf("Cannot deploy a downgraded Astro Runtime version. Modify your Astro Runtime version to %s or higher in your Dockerfile\n", currentVersion)
3✔
788
                return false
3✔
789
        }
3✔
790

791
        // Check that the tag is supported by the deployment
792
        tagInDeploymentOptions := false
40✔
793
        for _, runtimeVersion := range deploymentOptionsRuntimeVersions {
111✔
794
                if airflowversions.CompareRuntimeVersions(tag, runtimeVersion) == 0 {
110✔
795
                        tagInDeploymentOptions = true
39✔
796
                        break
39✔
797
                }
798
        }
799
        if !tagInDeploymentOptions {
41✔
800
                fmt.Println("Cannot deploy an unsupported Astro Runtime version. Modify your Astro Runtime version to a supported version in your Dockerfile")
1✔
801
                fmt.Printf("Supported versions: %s\n", strings.Join(deploymentOptionsRuntimeVersions, ", "))
1✔
802
                return false
1✔
803
        }
1✔
804

805
        // If upgrading from Airflow 2 to Airflow 3, we require at least Runtime 12.0.0 (Airflow 2.10.0)
806
        currentVersionAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(currentVersion)
39✔
807
        tagAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(tag)
39✔
808
        if currentVersionAirflowMajorVersion == "2" && tagAirflowMajorVersion == "3" {
41✔
809
                if airflowversions.CompareRuntimeVersions(currentVersion, "12.0.0") < 0 {
3✔
810
                        fmt.Println("Can only upgrade deployment from Airflow 2 to Airflow 3 with deployment at Astro Runtime 12.0.0 or higher")
1✔
811
                        return false
1✔
812
                }
1✔
813
        }
814

815
        return true
38✔
816
}
817

818
func WarnIfNonLatestVersion(version string, httpClient *httputil.HTTPClient) {
37✔
819
        client := airflowversions.NewClient(httpClient, false, false)
37✔
820
        latestRuntimeVersion, err := airflowversions.GetDefaultImageTag(client, "", "", false)
37✔
821
        if err != nil {
39✔
822
                logger.Debugf("unable to get latest runtime version: %s", err)
2✔
823
                return
2✔
824
        }
2✔
825

826
        if airflowversions.CompareRuntimeVersions(version, latestRuntimeVersion) < 0 {
70✔
827
                fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
35✔
828
        }
35✔
829
}
830

831
// ClientBuildContext represents a prepared build context for client deployment
832
type ClientBuildContext struct {
833
        // TempDir is the temporary directory containing the build context
834
        TempDir string
835
        // CleanupFunc should be called to clean up the temporary directory
836
        CleanupFunc func()
837
}
838

839
// prepareClientBuildContext creates a temporary build context with client dependency files
840
// This avoids modifying the original project files, preventing race conditions with concurrent deployments.
841
func prepareClientBuildContext(sourcePath string) (*ClientBuildContext, error) {
10✔
842
        // Create a temporary directory for the build context
10✔
843
        tempBuildDir, err := os.MkdirTemp("", "astro-client-build-*")
10✔
844
        if err != nil {
10✔
845
                return nil, fmt.Errorf("failed to create temporary build directory: %w", err)
×
846
        }
×
847

848
        // Cleanup function to be called by the caller
849
        cleanup := func() {
20✔
850
                os.RemoveAll(tempBuildDir)
10✔
851
        }
10✔
852

853
        // Always return cleanup function if we created a temp directory, even on error
854
        buildContext := &ClientBuildContext{
10✔
855
                TempDir:     tempBuildDir,
10✔
856
                CleanupFunc: cleanup,
10✔
857
        }
10✔
858

10✔
859
        // Check if source directory exists first
10✔
860
        if exists, err := fileutil.Exists(sourcePath, nil); err != nil {
10✔
861
                return buildContext, fmt.Errorf("failed to check if source directory exists: %w", err)
×
862
        } else if !exists {
11✔
863
                return buildContext, fmt.Errorf("source directory does not exist: %s", sourcePath)
1✔
864
        }
1✔
865

866
        // Build a skip predicate from the project's .dockerignore so excluded
867
        // paths (e.g. infra/ with terragrunt provider-cache symlinks) are not
868
        // copied into the build context. This mirrors what the Docker builder does
869
        // for in-place builds; the client deploy copies the context first, so it
870
        // must honor .dockerignore itself.
871
        skip, err := dockerignoreSkipFunc(sourcePath)
9✔
872
        if err != nil {
9✔
NEW
873
                return buildContext, fmt.Errorf("failed to read .dockerignore: %w", err)
×
NEW
874
        }
×
875

876
        // Copy all project files to the temporary directory
877
        err = fileutil.CopyDirectoryFiltered(sourcePath, tempBuildDir, skip)
9✔
878
        if err != nil {
9✔
879
                return buildContext, fmt.Errorf("failed to copy project files to temporary directory: %w", err)
×
880
        }
×
881

882
        // Process client dependency files
883
        err = setupClientDependencyFiles(tempBuildDir)
9✔
884
        if err != nil {
11✔
885
                return buildContext, fmt.Errorf("failed to setup client dependency files: %w", err)
2✔
886
        }
2✔
887

888
        return buildContext, nil
7✔
889
}
890

891
// alwaysIncludedBuildFiles are never excluded from the client build context,
892
// even if a user's .dockerignore would match them. The Docker builder applies
893
// the same special-casing to the Dockerfile and .dockerignore, and the client
894
// deploy additionally needs its client dependency files.
895
var alwaysIncludedBuildFiles = map[string]bool{
896
        "Dockerfile.client":       true,
897
        ".dockerignore":           true,
898
        "requirements-client.txt": true,
899
        "packages-client.txt":     true,
900
}
901

902
// dockerignoreSkipFunc parses the .dockerignore at sourcePath (if any) and
903
// returns a predicate, suitable for fileutil.CopyDirectoryFiltered, that
904
// reports whether a path should be excluded from the build context. It returns
905
// nil (copy everything) when there is no .dockerignore file.
906
func dockerignoreSkipFunc(sourcePath string) (func(relPath string, isDir bool) bool, error) {
13✔
907
        f, err := os.Open(filepath.Join(sourcePath, ".dockerignore"))
13✔
908
        if err != nil {
22✔
909
                if os.IsNotExist(err) {
18✔
910
                        return nil, nil
9✔
911
                }
9✔
NEW
912
                return nil, err
×
913
        }
914
        defer f.Close()
4✔
915

4✔
916
        patterns, err := ignorefile.ReadAll(f)
4✔
917
        if err != nil {
4✔
NEW
918
                return nil, err
×
NEW
919
        }
×
920

921
        pm, err := patternmatcher.New(patterns)
4✔
922
        if err != nil {
4✔
NEW
923
                return nil, err
×
NEW
924
        }
×
925

926
        return func(relPath string, isDir bool) bool {
26✔
927
                if alwaysIncludedBuildFiles[relPath] {
30✔
928
                        return false
8✔
929
                }
8✔
930
                matched, err := pm.MatchesOrParentMatches(relPath)
14✔
931
                if err != nil || !matched {
22✔
932
                        return false
8✔
933
                }
8✔
934
                // When exclusion ("!") patterns exist, a child of a matched directory
935
                // may be re-included, so we must descend rather than prune the dir.
936
                if isDir && pm.Exclusions() {
7✔
937
                        return false
1✔
938
                }
1✔
939
                return true
5✔
940
        }, nil
941
}
942

943
// setupClientDependencyFiles processes client-specific dependency files in the build context
944
func setupClientDependencyFiles(buildDir string) error {
12✔
945
        // Define dependency file pairs (client file -> regular file)
12✔
946
        dependencyFiles := map[string]string{
12✔
947
                "requirements-client.txt": "requirements.txt",
12✔
948
                "packages-client.txt":     "packages.txt",
12✔
949
        }
12✔
950

12✔
951
        // Process client dependency files in the build directory
12✔
952
        for clientFile, regularFile := range dependencyFiles {
33✔
953
                clientPath := filepath.Join(buildDir, clientFile)
21✔
954
                regularPath := filepath.Join(buildDir, regularFile)
21✔
955

21✔
956
                // Copy client file content to the regular file location (requires client file to exist)
21✔
957
                if err := fileutil.CopyFile(clientPath, regularPath); err != nil {
24✔
958
                        return fmt.Errorf("failed to copy %s to %s in build context: %w", clientFile, regularFile, err)
3✔
959
                }
3✔
960
        }
961

962
        return nil
9✔
963
}
964

965
// DeployClientImage handles the client deploy functionality
966
func DeployClientImage(deployInput InputClientDeploy, astroV1Client astrov1.APIClient) error { //nolint:gocritic
6✔
967
        c, err := config.GetCurrentContext()
6✔
968
        if err != nil {
6✔
969
                return errors.Wrap(err, "failed to get current context")
×
970
        }
×
971

972
        // Validate deployment runtime version if deployment ID is provided
973
        if err := validateClientImageRuntimeVersion(deployInput, astroV1Client); err != nil {
6✔
974
                return err
×
975
        }
×
976

977
        // Get the remote client registry endpoint from config
978
        registryEndpoint := config.CFG.RemoteClientRegistry.GetString()
6✔
979
        if registryEndpoint == "" {
7✔
980
                fmt.Println("The Astro CLI is not configured to push client images to your private registry.")
1✔
981
                fmt.Println("For remote Deployments, client images must be stored in your private registry, not in Astronomer managed registries.")
1✔
982
                fmt.Println("Please provide your private registry information so the Astro CLI can push client images.")
1✔
983
                return errors.New("remote client registry is not configured. To configure it, run: 'astro config set remote.client_registry <endpoint>' and try again.")
1✔
984
        }
1✔
985

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

5✔
990
        // Build the full remote image name
5✔
991
        remoteImage := fmt.Sprintf("%s:%s", registryEndpoint, imageTag)
5✔
992

5✔
993
        // Create an image handler for building and pushing
5✔
994
        imageHandler := airflowImageHandler(remoteImage)
5✔
995

5✔
996
        if deployInput.ImageName != "" {
6✔
997
                // Use the provided local image (tag will be ignored, remote tag is always timestamp-based)
1✔
998
                fmt.Println("Using provided image:", deployInput.ImageName)
1✔
999
                err := imageHandler.TagLocalImage(deployInput.ImageName)
1✔
1000
                if err != nil {
1✔
1001
                        return fmt.Errorf("failed to tag local image: %w", err)
×
1002
                }
×
1003
        } else {
4✔
1004
                // Authenticate with the base image registry before building
4✔
1005
                // This is needed because Dockerfile.client uses base images from a private registry
4✔
1006

4✔
1007
                // Skip registry login if the base image registry is not from astronomer, check the content of the Dockerfile.client file
4✔
1008
                dockerfileClientContent, err := fileutil.ReadFileToString(filepath.Join(deployInput.Path, "Dockerfile.client"))
4✔
1009
                if util.IsAstronomerRegistry(dockerfileClientContent) || err != nil {
8✔
1010
                        // login to the registry
4✔
1011
                        if err != nil {
5✔
1012
                                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✔
1013
                        }
1✔
1014
                        baseImageRegistry := config.CFG.RemoteBaseImageRegistry.GetString()
4✔
1015
                        fmt.Printf("Authenticating with base image registry: %s\n", baseImageRegistry)
4✔
1016
                        err := airflow.DockerLogin(baseImageRegistry, registryUsername, c.Token)
4✔
1017
                        if err != nil {
5✔
1018
                                fmt.Println("Failed to authenticate with Astronomer registry that contains the base agent image used in the Dockerfile.client file.")
1✔
1019
                                fmt.Println("This could be because either your token has expired or you don't have permission to pull the base agent image.")
1✔
1020
                                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✔
1021
                                return fmt.Errorf("failed to authenticate with registry %s: %w", baseImageRegistry, err)
1✔
1022
                        }
1✔
1023
                }
1024

1025
                // Build the client image from the current directory
1026
                // Determine target platforms for client deploy
1027
                var targetPlatforms []string
3✔
1028
                if deployInput.Platform != "" {
3✔
1029
                        // Parse comma-separated platforms from --platform flag
×
1030
                        targetPlatforms = strings.Split(deployInput.Platform, ",")
×
1031
                        // Trim whitespace from each platform
×
1032
                        for i, platform := range targetPlatforms {
×
1033
                                targetPlatforms[i] = strings.TrimSpace(platform)
×
1034
                        }
×
1035
                        fmt.Printf("Building client image for platforms: %s\n", strings.Join(targetPlatforms, ", "))
×
1036
                } else {
3✔
1037
                        // Use empty slice to let Docker build for host platform by default
3✔
1038
                        targetPlatforms = []string{}
3✔
1039
                        fmt.Println("Building client image for host platform")
3✔
1040
                }
3✔
1041

1042
                // Prepare build context with client dependency files
1043
                buildContext, err := prepareClientBuildContext(deployInput.Path)
3✔
1044
                if buildContext != nil && buildContext.CleanupFunc != nil {
6✔
1045
                        defer buildContext.CleanupFunc()
3✔
1046
                }
3✔
1047
                if err != nil {
3✔
1048
                        return fmt.Errorf("failed to prepare client build context: %w", err)
×
1049
                }
×
1050

1051
                // Build the image from the prepared context
1052
                buildConfig := types.ImageBuildConfig{
3✔
1053
                        Path:            buildContext.TempDir,
3✔
1054
                        TargetPlatforms: targetPlatforms,
3✔
1055
                }
3✔
1056

3✔
1057
                err = imageHandler.Build("Dockerfile.client", deployInput.BuildSecretString, buildConfig)
3✔
1058
                if err != nil {
4✔
1059
                        return fmt.Errorf("failed to build client image: %w", err)
1✔
1060
                }
1✔
1061
        }
1062

1063
        // Push the image to the remote registry (assumes docker login was done externally)
1064
        fmt.Println("Pushing client image to configured remote registry")
3✔
1065
        _, err = imageHandler.Push(remoteImage, "", "", false)
3✔
1066
        if err != nil {
4✔
1067
                if errors.Is(err, airflow.ErrImagePush403) {
1✔
1068
                        fmt.Printf("\n--------------------------------\n")
×
1069
                        fmt.Printf("Failed to push client image to %s\n", registryEndpoint)
×
1070
                        fmt.Println("It could be due to either your registry token has expired or you don't have permission to push the client image")
×
1071
                        fmt.Printf("Please ensure that you have logged in to `%s` via `docker login` and try again\n\n", registryEndpoint)
×
1072
                }
×
1073
                return fmt.Errorf("failed to push client image: %w", err)
1✔
1074
        }
1075

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

2✔
1078
        fmt.Printf("\n--------------------------------\n")
2✔
1079
        fmt.Println("The client image has been pushed to your private registry.")
2✔
1080
        fmt.Println("Your next step would be to update the agent component to use the new client image.")
2✔
1081
        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✔
1082
        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✔
1083
        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✔
1084

2✔
1085
        return nil
2✔
1086
}
1087

1088
// validateClientImageRuntimeVersion validates that the client image runtime version
1089
// is not newer than the deployment runtime version
1090
func validateClientImageRuntimeVersion(deployInput InputClientDeploy, astroV1Client astrov1.APIClient) error { //nolint:gocritic
16✔
1091
        // Skip validation if no deployment ID provided
16✔
1092
        if deployInput.DeploymentID == "" {
23✔
1093
                return nil
7✔
1094
        }
7✔
1095

1096
        // Get current context for organization info
1097
        c, err := config.GetCurrentContext()
9✔
1098
        if err != nil {
10✔
1099
                return errors.Wrap(err, "failed to get current context")
1✔
1100
        }
1✔
1101

1102
        // Get deployment information
1103
        deployInfo, err := fetchDeploymentDetails(deployInput.DeploymentID, c.Organization, astroV1Client)
8✔
1104
        if err != nil {
9✔
1105
                return errors.Wrap(err, "failed to get deployment information")
1✔
1106
        }
1✔
1107

1108
        // Parse Dockerfile.client to get client image runtime version
1109
        dockerfileClientPath := filepath.Join(deployInput.Path, "Dockerfile.client")
7✔
1110
        if _, err := os.Stat(dockerfileClientPath); os.IsNotExist(err) {
8✔
1111
                return errors.New("Dockerfile.client is required for client image runtime version validation")
1✔
1112
        }
1✔
1113

1114
        cmds, err := docker.ParseFile(dockerfileClientPath)
6✔
1115
        if err != nil {
7✔
1116
                return errors.Wrapf(err, "failed to parse Dockerfile.client: %s", dockerfileClientPath)
1✔
1117
        }
1✔
1118

1119
        baseImage := docker.GetImageFromParsedFile(cmds)
5✔
1120
        if baseImage == "" {
6✔
1121
                return errors.New("failed to find base image in Dockerfile.client")
1✔
1122
        }
1✔
1123

1124
        // Extract runtime version from the base image tag
1125
        clientRuntimeVersion, err := extractRuntimeVersionFromImage(baseImage)
4✔
1126
        if err != nil {
5✔
1127
                return errors.Wrapf(err, "failed to extract runtime version from client image %s", baseImage)
1✔
1128
        }
1✔
1129

1130
        // Compare versions
1131
        if airflowversions.CompareRuntimeVersions(clientRuntimeVersion, deployInfo.currentVersion) > 0 {
4✔
1132
                return fmt.Errorf(`client image runtime version validation failed:
1✔
1133

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

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

1✔
1140
This validation ensures compatibility between your client image and the deployment environment`,
1✔
1141
                        clientRuntimeVersion, deployInfo.currentVersion, deployInfo.currentVersion, clientRuntimeVersion)
1✔
1142
        }
1✔
1143

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

2✔
1147
        return nil
2✔
1148
}
1149

1150
// extractRuntimeVersionFromImage extracts the runtime version from an image tag
1151
// Example: "images.astronomer.cloud/baseimages/astro-remote-execution-agent:3.1-1-python-3.12-astro-agent-1.1.0"
1152
// Returns: "3.1-1"
1153
func extractRuntimeVersionFromImage(imageName string) (string, error) {
9✔
1154
        // Split image name to get the tag part
9✔
1155
        parts := strings.Split(imageName, ":")
9✔
1156
        if len(parts) < 2 {
10✔
1157
                return "", errors.New("image name does not contain a tag")
1✔
1158
        }
1✔
1159

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

8✔
1162
        // Use the existing ParseImageTag function from airflow_versions package
8✔
1163
        tagInfo, err := airflowversions.ParseImageTag(imageTag)
8✔
1164
        if err != nil {
10✔
1165
                return "", errors.Wrapf(err, "failed to parse image tag: %s", imageTag)
2✔
1166
        }
2✔
1167

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