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

astronomer / astro-cli / 23d6d326-ef83-487f-9901-f16bd900b13d

25 Nov 2025 12:02PM UTC coverage: 33.132% (-5.5%) from 38.64%
23d6d326-ef83-487f-9901-f16bd900b13d

push

circleci

web-flow
feat: add runtime version validation for remote client deployments (#1983)

76 of 80 new or added lines in 4 files covered. (95.0%)

3547 existing lines in 30 files now uncovered.

20844 of 62912 relevant lines covered (33.13%)

8.49 hits per line

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

80.64
/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/astronomer/astro-cli/airflow"
14
        "github.com/astronomer/astro-cli/airflow/types"
15
        airflowversions "github.com/astronomer/astro-cli/airflow_versions"
16
        astrocore "github.com/astronomer/astro-cli/astro-client-core"
17
        astroplatformcore "github.com/astronomer/astro-cli/astro-client-platform-core"
18
        "github.com/astronomer/astro-cli/cloud/deployment"
19
        "github.com/astronomer/astro-cli/cloud/organization"
20
        "github.com/astronomer/astro-cli/config"
21
        "github.com/astronomer/astro-cli/docker"
22
        "github.com/astronomer/astro-cli/pkg/ansi"
23
        "github.com/astronomer/astro-cli/pkg/azure"
24
        "github.com/astronomer/astro-cli/pkg/fileutil"
25
        "github.com/astronomer/astro-cli/pkg/httputil"
26
        "github.com/astronomer/astro-cli/pkg/input"
27
        "github.com/astronomer/astro-cli/pkg/logger"
28
        "github.com/astronomer/astro-cli/pkg/util"
29
        "github.com/pkg/errors"
30
)
31

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

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

43
        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"
44

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

53
var (
54
        pytestFile string
55
        dockerfile = "Dockerfile"
56

57
        deployImagePlatformSupport = []string{"linux/amd64"}
58

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

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

74
var (
75
        sleepTime              = 90
76
        dagOnlyDeploySleepTime = 30
77
        tickNum                = 10
78
)
79

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

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

114
// InputClientDeploy contains inputs for client image deployments
115
type InputClientDeploy struct {
116
        Path              string
117
        ImageName         string
118
        Platform          string
119
        BuildSecretString string
120
        DeploymentID      string
121
}
122

123
const accessYourDeploymentFmt = `
124

125
 Access your Deployment:
126

127
 Deployment View: %s
128
 Airflow UI: %s
129
`
130

131
func removeDagsFromDockerIgnore(fullpath string) error {
58✔
132
        f, err := os.Open(fullpath)
58✔
133
        if err != nil {
58✔
134
                return err
×
135
        }
×
136

137
        defer f.Close()
58✔
138

58✔
139
        var bs []byte
58✔
140
        buf := bytes.NewBuffer(bs)
58✔
141

58✔
142
        scanner := bufio.NewScanner(f)
58✔
143
        for scanner.Scan() {
96✔
144
                text := scanner.Text()
38✔
145
                if text != "dags/" {
57✔
146
                        _, err = buf.WriteString(text + "\n")
19✔
147
                        if err != nil {
19✔
148
                                return err
×
149
                        }
×
150
                }
151
        }
152

153
        if err := scanner.Err(); err != nil {
58✔
154
                return err
×
155
        }
×
156
        err = os.WriteFile(fullpath, bytes.Trim(buf.Bytes(), "\n"), 0o666) //nolint:gosec, mnd
58✔
157
        if err != nil {
58✔
158
                return err
×
159
        }
×
160

161
        return nil
58✔
162
}
163

164
func shouldIncludeMonitoringDag(deploymentType astroplatformcore.DeploymentType) bool {
20✔
165
        return !organization.IsOrgHosted() && !deployment.IsDeploymentDedicated(deploymentType) && !deployment.IsDeploymentStandard(deploymentType)
20✔
166
}
20✔
167

168
func deployDags(path, dagsPath, dagsUploadURL, currentRuntimeVersion string, deploymentType astroplatformcore.DeploymentType) (string, error) {
20✔
169
        if shouldIncludeMonitoringDag(deploymentType) {
36✔
170
                monitoringDagPath := filepath.Join(dagsPath, "astronomer_monitoring_dag.py")
16✔
171

16✔
172
                // Create monitoring dag file
16✔
173
                err := fileutil.WriteStringToFile(monitoringDagPath, airflow.Af2MonitoringDag)
16✔
174
                if err != nil {
16✔
175
                        return "", err
×
176
                }
×
177

178
                // Remove the monitoring dag file after the upload
179
                defer os.Remove(monitoringDagPath)
16✔
180
        }
181

182
        versionID, err := UploadBundle(path, dagsPath, dagsUploadURL, true, currentRuntimeVersion)
20✔
183
        if err != nil {
20✔
184
                return "", err
×
185
        }
×
186

187
        return versionID, nil
20✔
188
}
189

190
// Deploy pushes a new docker image
191
func Deploy(deployInput InputDeploy, platformCoreClient astroplatformcore.CoreClient, coreClient astrocore.CoreClient) error { //nolint
41✔
192
        c, err := config.GetCurrentContext()
41✔
193
        if err != nil {
42✔
194
                return err
1✔
195
        }
1✔
196

197
        if c.Domain == astroDomain {
43✔
198
                fmt.Printf(deploymentHeaderMsg, "Astro")
3✔
199
        } else {
40✔
200
                fmt.Printf(deploymentHeaderMsg, c.Domain)
37✔
201
        }
37✔
202

203
        deployInfo, err := getDeploymentInfo(deployInput.RuntimeID, deployInput.WsID, deployInput.DeploymentName, deployInput.Prompt, platformCoreClient, coreClient)
40✔
204
        if err != nil {
40✔
205
                return err
×
206
        }
×
207

208
        var dagsPath string
40✔
209
        if deployInput.DagsPath != "" {
55✔
210
                dagsPath = deployInput.DagsPath
15✔
211
        } else {
40✔
212
                dagsPath = filepath.Join(deployInput.Path, "dags")
25✔
213
        }
25✔
214

215
        var dagFiles []string
40✔
216
        if !deployInfo.isRemoteExecutionEnabled {
75✔
217
                dagFiles = fileutil.GetFilesWithSpecificExtension(dagsPath, ".py")
35✔
218
        }
35✔
219

220
        if deployInfo.cicdEnforcement {
41✔
221
                if !canCiCdDeploy(c.Token) {
2✔
222
                        return fmt.Errorf(errCiCdEnforcementUpdate, deployInfo.name) //nolint
1✔
223
                }
1✔
224
        }
225

226
        if deployInput.WsID != deployInfo.workspaceID {
40✔
227
                fmt.Printf(invalidWorkspaceID, deployInput.WsID)
1✔
228
                return nil
1✔
229
        }
1✔
230

231
        if deployInput.Image {
41✔
232
                if !deployInfo.dagDeployEnabled {
3✔
233
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
234
                }
×
235
        }
236

237
        deploymentURL, err := deployment.GetDeploymentURL(deployInfo.deploymentID, deployInfo.workspaceID)
38✔
238
        if err != nil {
38✔
239
                return err
×
240
        }
×
241
        createDeployRequest := astroplatformcore.CreateDeployRequest{
38✔
242
                Description: &deployInput.Description,
38✔
243
        }
38✔
244
        switch {
38✔
245
        case deployInput.Dags:
18✔
246
                createDeployRequest.Type = astroplatformcore.CreateDeployRequestTypeDAGONLY
18✔
247
        case deployInput.Image:
1✔
248
                createDeployRequest.Type = astroplatformcore.CreateDeployRequestTypeIMAGEONLY
1✔
249
        default:
19✔
250
                createDeployRequest.Type = astroplatformcore.CreateDeployRequestTypeIMAGEANDDAG
19✔
251
        }
252
        deploy, err := createDeploy(deployInfo.organizationID, deployInfo.deploymentID, createDeployRequest, platformCoreClient)
38✔
253
        if err != nil {
38✔
254
                return err
×
255
        }
×
256
        deployID := deploy.Id
38✔
257
        if deploy.DagsUploadUrl != nil {
76✔
258
                dagsUploadURL = *deploy.DagsUploadUrl
38✔
259
        } else {
38✔
260
                dagsUploadURL = ""
×
261
        }
×
262
        if deploy.ImageTag != "" {
38✔
263
                nextTag = deploy.ImageTag
×
264
        } else {
38✔
265
                nextTag = ""
38✔
266
        }
38✔
267

268
        if deployInput.Dags {
56✔
269
                if len(dagFiles) == 0 && config.CFG.ShowWarnings.GetBool() {
19✔
270
                        i, _ := input.Confirm("Warning: No DAGs found. This will delete any existing DAGs. Are you sure you want to deploy?")
1✔
271

1✔
272
                        if !i {
2✔
273
                                fmt.Println("Canceling deploy...")
1✔
274
                                return nil
1✔
275
                        }
1✔
276
                }
277
                if deployInput.Pytest != "" {
29✔
278
                        runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, platformCoreClient)
12✔
279
                        if err != nil {
12✔
280
                                return err
×
281
                        }
×
282

283
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecretString)
12✔
284
                        if err != nil {
14✔
285
                                return err
2✔
286
                        }
2✔
287
                }
288

289
                if !deployInfo.dagDeployEnabled {
16✔
290
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
1✔
291
                }
1✔
292

293
                fmt.Println("Initiating DAG deploy for: " + deployInfo.deploymentID)
14✔
294
                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astroplatformcore.DeploymentType(deployInfo.deploymentType))
14✔
295
                if err != nil {
14✔
296
                        if strings.Contains(err.Error(), dagDeployDisabled) {
×
297
                                return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
298
                        }
×
299

300
                        return err
×
301
                }
302

303
                // finish deploy
304
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, platformCoreClient)
14✔
305
                if err != nil {
14✔
306
                        return err
×
307
                }
×
308

309
                if deployInput.WaitForStatus {
15✔
310
                        // Keeping wait timeout low since dag only deploy is faster
1✔
311
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, dagOnlyDeploySleepTime, tickNum, int(deployInput.WaitTime.Seconds()), platformCoreClient)
1✔
312
                        if err != nil {
2✔
313
                                return err
1✔
314
                        }
1✔
315

316
                        fmt.Println(
×
317
                                "\nSuccessfully uploaded DAGs with version " + ansi.Bold(dagTarballVersion) + " to Astro. Navigate to the Airflow UI to confirm that your deploy was successful." +
×
318
                                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold(deploymentURL), ansi.Bold(deployInfo.webserverURL)),
×
319
                        )
×
320

×
321
                        return nil
×
322
                }
323

324
                fmt.Println(
13✔
325
                        "\nSuccessfully uploaded DAGs with version " + ansi.Bold(
13✔
326
                                dagTarballVersion,
13✔
327
                        ) + " to Astro. Navigate to the Airflow UI to confirm that your deploy was successful. The Airflow UI takes about 1 minute to update." +
13✔
328
                                fmt.Sprintf(
13✔
329
                                        accessYourDeploymentFmt,
13✔
330
                                        ansi.Bold(deploymentURL),
13✔
331
                                        ansi.Bold(deployInfo.webserverURL),
13✔
332
                                ),
13✔
333
                )
13✔
334
        } else {
20✔
335
                fullpath := filepath.Join(deployInput.Path, ".dockerignore")
20✔
336
                fileExist, _ := fileutil.Exists(fullpath, nil)
20✔
337
                if fileExist {
40✔
338
                        err := removeDagsFromDockerIgnore(fullpath)
20✔
339
                        if err != nil {
20✔
340
                                return errors.Wrap(err, "Found dags entry in .dockerignore file. Remove this entry and try again")
×
341
                        }
×
342
                }
343
                envFileExists, _ := fileutil.Exists(deployInput.EnvFile, nil)
20✔
344
                if !envFileExists && deployInput.EnvFile != ".env" {
21✔
345
                        return fmt.Errorf("%w %s", envFileMissing, deployInput.EnvFile)
1✔
346
                }
1✔
347

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

×
351
                        if !i {
×
352
                                fmt.Println("Canceling deploy...")
×
353
                                return nil
×
354
                        }
×
355
                }
356

357
                // Build our image
358
                runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, platformCoreClient)
19✔
359
                if err != nil {
19✔
360
                        return err
×
361
                }
×
362

363
                if len(dagFiles) > 0 {
26✔
364
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecretString)
7✔
365
                        if err != nil {
8✔
366
                                return err
1✔
367
                        }
1✔
368
                } else {
12✔
369
                        fmt.Println("No DAGs found. Skipping testing...")
12✔
370
                }
12✔
371

372
                repository := deploy.ImageRepository
18✔
373
                // TODO: Resolve the edge case where two people push the same nextTag at the same time
18✔
374
                remoteImage := fmt.Sprintf("%s:%s", repository, nextTag)
18✔
375

18✔
376
                imageHandler := airflowImageHandler(deployInfo.deployImage)
18✔
377
                fmt.Println("Pushing image to Astronomer registry")
18✔
378
                _, err = imageHandler.Push(remoteImage, registryUsername, c.Token, false)
18✔
379
                if err != nil {
18✔
380
                        return err
×
381
                }
×
382

383
                if deployInfo.dagDeployEnabled && len(dagFiles) > 0 {
24✔
384
                        if !deployInput.Image {
12✔
385
                                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astroplatformcore.DeploymentType(deployInfo.deploymentType))
6✔
386
                                if err != nil {
6✔
387
                                        return err
×
388
                                }
×
389
                        } else {
×
390
                                fmt.Println("Image Deploy only. Skipping deploying DAG...")
×
391
                        }
×
392
                }
393
                // finish deploy
394
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, platformCoreClient)
18✔
395
                if err != nil {
18✔
396
                        return err
×
397
                }
×
398

399
                if deployInput.WaitForStatus {
20✔
400
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, sleepTime, tickNum, int(deployInput.WaitTime.Seconds()), platformCoreClient)
2✔
401
                        if err != nil {
4✔
402
                                return err
2✔
403
                        }
2✔
404
                }
405

406
                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." +
16✔
407
                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold("https://"+deploymentURL), ansi.Bold("https://"+deployInfo.webserverURL)))
16✔
408
        }
409

410
        return nil
29✔
411
}
412

413
func getDeploymentInfo(
414
        deploymentID, wsID, deploymentName string,
415
        prompt bool,
416
        platformCoreClient astroplatformcore.CoreClient,
417
        coreClient astrocore.CoreClient,
418
) (deploymentInfo, error) {
40✔
419
        // Use config deployment if provided
40✔
420
        if deploymentID == "" {
54✔
421
                deploymentID = config.CFG.ProjectDeployment.GetProjectString()
14✔
422
                if deploymentID != "" {
14✔
423
                        fmt.Printf("Deployment ID found in the config file. This Deployment ID will be used for the deploy\n")
×
424
                }
×
425
        }
426

427
        if deploymentID != "" && deploymentName != "" {
48✔
428
                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✔
429
        }
8✔
430

431
        // check if deploymentID or if force prompt was requested was given by user
432
        if deploymentID == "" || prompt {
67✔
433
                currentDeployment, err := deployment.GetDeployment(wsID, deploymentID, deploymentName, false, nil, platformCoreClient, coreClient)
27✔
434
                if err != nil {
27✔
435
                        return deploymentInfo{}, err
×
436
                }
×
437
                coreDeployment, err := deployment.CoreGetDeployment(currentDeployment.OrganizationId, currentDeployment.Id, platformCoreClient)
27✔
438
                if err != nil {
27✔
439
                        return deploymentInfo{}, err
×
440
                }
×
441
                var desiredDagTarballVersion string
27✔
442
                if coreDeployment.DesiredDagTarballVersion != nil {
45✔
443
                        desiredDagTarballVersion = *coreDeployment.DesiredDagTarballVersion
18✔
444
                } else {
27✔
445
                        desiredDagTarballVersion = ""
9✔
446
                }
9✔
447

448
                return deploymentInfo{
27✔
449
                        currentDeployment.Id,
27✔
450
                        currentDeployment.Namespace,
27✔
451
                        airflow.ImageName(currentDeployment.Namespace, "latest"),
27✔
452
                        currentDeployment.RuntimeVersion,
27✔
453
                        currentDeployment.OrganizationId,
27✔
454
                        currentDeployment.WorkspaceId,
27✔
455
                        currentDeployment.WebServerUrl,
27✔
456
                        string(*currentDeployment.Type),
27✔
457
                        desiredDagTarballVersion,
27✔
458
                        currentDeployment.IsDagDeployEnabled,
27✔
459
                        currentDeployment.IsCicdEnforced,
27✔
460
                        currentDeployment.Name,
27✔
461
                        deployment.IsRemoteExecutionEnabled(&currentDeployment),
27✔
462
                }, nil
27✔
463
        }
464
        c, err := config.GetCurrentContext()
13✔
465
        if err != nil {
13✔
466
                return deploymentInfo{}, err
×
467
        }
×
468
        deployInfo, err := fetchDeploymentDetails(deploymentID, c.Organization, platformCoreClient)
13✔
469
        if err != nil {
13✔
470
                return deploymentInfo{}, err
×
471
        }
×
472
        deployInfo.deploymentID = deploymentID
13✔
473
        return deployInfo, nil
13✔
474
}
475

476
func parseOrPytestDAG(pytest, runtimeVersion, envFile, deployImage, namespace, buildSecretString string) error {
19✔
477
        validDAGParseVersion := airflowversions.CompareRuntimeVersions(runtimeVersion, dagParseAllowedVersion) >= 0
19✔
478
        if !validDAGParseVersion {
19✔
479
                fmt.Println("\nruntime image is earlier than 4.1.0, this deploy will skip DAG parse...")
×
480
        }
×
481

482
        containerHandler, err := containerHandlerInit(config.WorkingPath, envFile, "Dockerfile", namespace)
19✔
483
        if err != nil {
19✔
484
                return err
×
485
        }
×
486

487
        switch {
19✔
488
        case pytest == parse && validDAGParseVersion:
7✔
489
                // parse dags
7✔
490
                fmt.Println("Testing image...")
7✔
491
                err := parseDAGs(deployImage, buildSecretString, containerHandler)
7✔
492
                if err != nil {
9✔
493
                        return err
2✔
494
                }
2✔
495
        case pytest != "" && pytest != parse && pytest != parseAndPytest:
6✔
496
                // check pytests
6✔
497
                fmt.Println("Testing image...")
6✔
498
                err := checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
499
                if err != nil {
7✔
500
                        return err
1✔
501
                }
1✔
502
        case pytest == parseAndPytest:
6✔
503
                // parse dags and check pytests
6✔
504
                fmt.Println("Testing image...")
6✔
505
                err := parseDAGs(deployImage, buildSecretString, containerHandler)
6✔
506
                if err != nil {
6✔
507
                        return err
×
508
                }
×
509

510
                err = checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
511
                if err != nil {
6✔
512
                        return err
×
513
                }
×
514
        }
515
        return nil
16✔
516
}
517

518
func parseDAGs(deployImage, buildSecretString string, containerHandler airflow.ContainerHandler) error {
13✔
519
        if !config.CFG.SkipParse.GetBool() && !util.CheckEnvBool(os.Getenv("ASTRONOMER_SKIP_PARSE")) {
26✔
520
                err := containerHandler.Parse("", deployImage, buildSecretString)
13✔
521
                if err != nil {
15✔
522
                        fmt.Println(err)
2✔
523
                        return errDagsParseFailed
2✔
524
                }
2✔
525
        } else {
×
526
                fmt.Println("Skipping parsing dags due to skip parse being set to true in either the config.yaml or local environment variables")
×
527
        }
×
528

529
        return nil
11✔
530
}
531

532
// Validate code with pytest
533
func checkPytest(pytest, deployImage, buildSecretString string, containerHandler airflow.ContainerHandler) error {
14✔
534
        if pytest != allTests && pytest != parseAndPytest {
18✔
535
                pytestFile = pytest
4✔
536
        }
4✔
537

538
        exitCode, err := containerHandler.Pytest(pytestFile, "", deployImage, "", buildSecretString)
14✔
539
        if err != nil {
17✔
540
                if strings.Contains(exitCode, "1") { // exit code is 1 meaning tests failed
4✔
541
                        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✔
542
                }
1✔
543
                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✔
544
        }
545

546
        fmt.Print("\nAll Pytests passed!\n")
11✔
547
        return err
11✔
548
}
549

550
func fetchDeploymentDetails(deploymentID, organizationID string, platformCoreClient astroplatformcore.CoreClient) (deploymentInfo, error) {
21✔
551
        resp, err := platformCoreClient.GetDeploymentWithResponse(httpContext.Background(), organizationID, deploymentID)
21✔
552
        if err != nil {
21✔
553
                return deploymentInfo{}, err
×
554
        }
×
555

556
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
21✔
557
        if err != nil {
22✔
558
                return deploymentInfo{}, err
1✔
559
        }
1✔
560

561
        currentVersion := resp.JSON200.RuntimeVersion
20✔
562
        namespace := resp.JSON200.Namespace
20✔
563
        workspaceID := resp.JSON200.WorkspaceId
20✔
564
        webserverURL := resp.JSON200.WebServerUrl
20✔
565
        dagDeployEnabled := resp.JSON200.IsDagDeployEnabled
20✔
566
        cicdEnforcement := resp.JSON200.IsCicdEnforced
20✔
567
        isRemoteExecutionEnabled := deployment.IsRemoteExecutionEnabled(resp.JSON200)
20✔
568
        var desiredDagTarballVersion string
20✔
569
        if resp.JSON200.DesiredDagTarballVersion != nil {
25✔
570
                desiredDagTarballVersion = *resp.JSON200.DesiredDagTarballVersion
5✔
571
        } else {
20✔
572
                desiredDagTarballVersion = ""
15✔
573
        }
15✔
574

575
        // We use latest and keep this tag around after deployments to keep subsequent deploys quick
576
        deployImage := airflow.ImageName(namespace, "latest")
20✔
577

20✔
578
        return deploymentInfo{
20✔
579
                namespace:                namespace,
20✔
580
                deployImage:              deployImage,
20✔
581
                currentVersion:           currentVersion,
20✔
582
                organizationID:           organizationID,
20✔
583
                workspaceID:              workspaceID,
20✔
584
                webserverURL:             webserverURL,
20✔
585
                dagDeployEnabled:         dagDeployEnabled,
20✔
586
                desiredDagTarballVersion: desiredDagTarballVersion,
20✔
587
                cicdEnforcement:          cicdEnforcement,
20✔
588
                isRemoteExecutionEnabled: isRemoteExecutionEnabled,
20✔
589
        }, nil
20✔
590
}
591

592
func buildImageWithoutDags(path, buildSecretString string, imageHandler airflow.ImageHandler) error {
19✔
593
        // flag to determine if we are setting the dags folder in dockerignore
19✔
594
        dagsIgnoreSet := false
19✔
595
        // flag to determine if dockerignore file was created on runtime
19✔
596
        dockerIgnoreCreate := false
19✔
597
        fullpath := filepath.Join(path, ".dockerignore")
19✔
598

19✔
599
        defer func() {
38✔
600
                // remove dags from .dockerignore file if we set it
19✔
601
                if dagsIgnoreSet {
38✔
602
                        removeDagsFromDockerIgnore(fullpath) //nolint:errcheck
19✔
603
                }
19✔
604
                // remove created docker ignore file
605
                if dockerIgnoreCreate {
19✔
606
                        os.Remove(fullpath)
×
607
                }
×
608
        }()
609

610
        fileExist, _ := fileutil.Exists(fullpath, nil)
19✔
611
        if !fileExist {
19✔
612
                // Create a dockerignore file and add the dags folder entry
×
613
                err := fileutil.WriteStringToFile(fullpath, "dags/")
×
614
                if err != nil {
×
615
                        return err
×
616
                }
×
617
                dockerIgnoreCreate = true
×
618
        }
619
        lines, err := fileutil.Read(fullpath)
19✔
620
        if err != nil {
19✔
621
                return err
×
622
        }
×
623
        contains, _ := fileutil.Contains(lines, "dags/")
19✔
624
        if !contains {
38✔
625
                f, err := os.OpenFile(fullpath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) //nolint:mnd
19✔
626
                if err != nil {
19✔
627
                        return err
×
628
                }
×
629

630
                defer f.Close()
19✔
631

19✔
632
                if _, err := f.WriteString("\ndags/"); err != nil {
19✔
633
                        return err
×
634
                }
×
635

636
                dagsIgnoreSet = true
19✔
637
        }
638
        err = imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
19✔
639
        if err != nil {
19✔
640
                return err
×
641
        }
×
642

643
        // remove dags from .dockerignore file if we set it
644
        if dagsIgnoreSet {
38✔
645
                err = removeDagsFromDockerIgnore(fullpath)
19✔
646
                if err != nil {
19✔
647
                        return err
×
648
                }
×
649
        }
650

651
        return nil
19✔
652
}
653

654
func buildImage(path, currentVersion, deployImage, imageName, organizationID, buildSecretString string, dagDeployEnabled, isRemoteExecutionEnabled bool, platformCoreClient astroplatformcore.CoreClient) (version string, err error) {
34✔
655
        imageHandler := airflowImageHandler(deployImage)
34✔
656

34✔
657
        if imageName == "" {
61✔
658
                // Build our image
27✔
659
                fmt.Println(composeImageBuildingPromptMsg)
27✔
660

27✔
661
                if dagDeployEnabled || isRemoteExecutionEnabled {
46✔
662
                        err := buildImageWithoutDags(path, buildSecretString, imageHandler)
19✔
663
                        if err != nil {
19✔
664
                                return "", err
×
665
                        }
×
666
                } else {
8✔
667
                        err := imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
8✔
668
                        if err != nil {
9✔
669
                                return "", err
1✔
670
                        }
1✔
671
                }
672
        } else {
7✔
673
                // skip build if an imageName is passed
7✔
674
                fmt.Println(composeSkipImageBuildingPromptMsg)
7✔
675

7✔
676
                err := imageHandler.TagLocalImage(imageName)
7✔
677
                if err != nil {
7✔
678
                        return "", err
×
679
                }
×
680
        }
681

682
        // parse dockerfile
683
        cmds, err := docker.ParseFile(filepath.Join(path, dockerfile))
33✔
684
        if err != nil {
34✔
685
                return "", errors.Wrapf(err, "failed to parse dockerfile: %s", filepath.Join(path, dockerfile))
1✔
686
        }
1✔
687

688
        DockerfileImage := docker.GetImageFromParsedFile(cmds)
32✔
689

32✔
690
        version, err = imageHandler.GetLabel("", runtimeImageLabel)
32✔
691
        if err != nil {
32✔
692
                fmt.Println("unable get runtime version from image")
×
693
        }
×
694

695
        if config.CFG.ShowWarnings.GetBool() && version == "" {
32✔
696
                fmt.Printf(warningInvalidImageNameMsg, DockerfileImage)
×
697
                fmt.Println("Canceling deploy...")
×
698
                os.Exit(1)
×
699
        }
×
700

701
        resp, err := platformCoreClient.GetDeploymentOptionsWithResponse(httpContext.Background(), organizationID, &astroplatformcore.GetDeploymentOptionsParams{})
32✔
702
        if err != nil {
33✔
703
                return "", err
1✔
704
        }
1✔
705
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
31✔
706
        if err != nil {
31✔
707
                return "", err
×
708
        }
×
709
        deploymentOptionsRuntimeVersions := []string{}
31✔
710
        for _, runtimeRelease := range resp.JSON200.RuntimeReleases {
217✔
711
                deploymentOptionsRuntimeVersions = append(deploymentOptionsRuntimeVersions, runtimeRelease.Version)
186✔
712
        }
186✔
713

714
        if !ValidRuntimeVersion(currentVersion, version, deploymentOptionsRuntimeVersions) {
31✔
715
                fmt.Println("Canceling deploy...")
×
716
                os.Exit(1)
×
717
        }
×
718

719
        WarnIfNonLatestVersion(version, httputil.NewHTTPClient())
31✔
720

31✔
721
        return version, nil
31✔
722
}
723

724
// finalize deploy
725
func finalizeDeploy(deployID, deploymentID, organizationID, dagTarballVersion string, dagDeploy bool, platformCoreClient astroplatformcore.CoreClient) error {
32✔
726
        finalizeDeployRequest := astroplatformcore.FinalizeDeployRequest{}
32✔
727
        if dagDeploy {
54✔
728
                finalizeDeployRequest.DagTarballVersion = &dagTarballVersion
22✔
729
        }
22✔
730
        resp, err := platformCoreClient.FinalizeDeployWithResponse(httpContext.Background(), organizationID, deploymentID, deployID, finalizeDeployRequest)
32✔
731
        if err != nil {
32✔
732
                return err
×
733
        }
×
734
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
32✔
735
        if err != nil {
32✔
736
                return err
×
737
        }
×
738
        if resp.JSON200.DagTarballVersion != nil {
64✔
739
                fmt.Println("Deployed DAG bundle: ", *resp.JSON200.DagTarballVersion)
32✔
740
        }
32✔
741
        if resp.JSON200.ImageTag != "" {
64✔
742
                fmt.Println("Deployed Image Tag: ", resp.JSON200.ImageTag)
32✔
743
        }
32✔
744
        return nil
32✔
745
}
746

747
func createDeploy(organizationID, deploymentID string, request astroplatformcore.CreateDeployRequest, platformCoreClient astroplatformcore.CoreClient) (*astroplatformcore.Deploy, error) {
38✔
748
        resp, err := platformCoreClient.CreateDeployWithResponse(httpContext.Background(), organizationID, deploymentID, request)
38✔
749
        if err != nil {
38✔
750
                return nil, err
×
751
        }
×
752
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
38✔
753
        if err != nil {
38✔
754
                return nil, err
×
755
        }
×
756
        return resp.JSON200, err
38✔
757
}
758

759
func ValidRuntimeVersion(currentVersion, tag string, deploymentOptionsRuntimeVersions []string) bool {
41✔
760
        // Allow old deployments which do not have runtimeVersion tag
41✔
761
        if currentVersion == "" {
42✔
762
                return true
1✔
763
        }
1✔
764

765
        // Check that the tag is not a downgrade
766
        if airflowversions.CompareRuntimeVersions(tag, currentVersion) < 0 {
43✔
767
                fmt.Printf("Cannot deploy a downgraded Astro Runtime version. Modify your Astro Runtime version to %s or higher in your Dockerfile\n", currentVersion)
3✔
768
                return false
3✔
769
        }
3✔
770

771
        // Check that the tag is supported by the deployment
772
        tagInDeploymentOptions := false
37✔
773
        for _, runtimeVersion := range deploymentOptionsRuntimeVersions {
100✔
774
                if airflowversions.CompareRuntimeVersions(tag, runtimeVersion) == 0 {
99✔
775
                        tagInDeploymentOptions = true
36✔
776
                        break
36✔
777
                }
778
        }
779
        if !tagInDeploymentOptions {
38✔
780
                fmt.Println("Cannot deploy an unsupported Astro Runtime version. Modify your Astro Runtime version to a supported version in your Dockerfile")
1✔
781
                fmt.Printf("Supported versions: %s\n", strings.Join(deploymentOptionsRuntimeVersions, ", "))
1✔
782
                return false
1✔
783
        }
1✔
784

785
        // If upgrading from Airflow 2 to Airflow 3, we require at least Runtime 12.0.0 (Airflow 2.10.0)
786
        currentVersionAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(currentVersion)
36✔
787
        tagAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(tag)
36✔
788
        if currentVersionAirflowMajorVersion == "2" && tagAirflowMajorVersion == "3" {
38✔
789
                if airflowversions.CompareRuntimeVersions(currentVersion, "12.0.0") < 0 {
3✔
790
                        fmt.Println("Can only upgrade deployment from Airflow 2 to Airflow 3 with deployment at Astro Runtime 12.0.0 or higher")
1✔
791
                        return false
1✔
792
                }
1✔
793
        }
794

795
        return true
35✔
796
}
797

798
func WarnIfNonLatestVersion(version string, httpClient *httputil.HTTPClient) {
34✔
799
        client := airflowversions.NewClient(httpClient, false, false)
34✔
800
        latestRuntimeVersion, err := airflowversions.GetDefaultImageTag(client, "", "", false)
34✔
801
        if err != nil {
36✔
802
                logger.Debugf("unable to get latest runtime version: %s", err)
2✔
803
                return
2✔
804
        }
2✔
805

806
        if airflowversions.CompareRuntimeVersions(version, latestRuntimeVersion) < 0 {
64✔
807
                fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
32✔
808
        }
32✔
809
}
810

811
// ClientBuildContext represents a prepared build context for client deployment
812
type ClientBuildContext struct {
813
        // TempDir is the temporary directory containing the build context
814
        TempDir string
815
        // CleanupFunc should be called to clean up the temporary directory
816
        CleanupFunc func()
817
}
818

819
// prepareClientBuildContext creates a temporary build context with client dependency files
820
// This avoids modifying the original project files, preventing race conditions with concurrent deployments.
821
func prepareClientBuildContext(sourcePath string) (*ClientBuildContext, error) {
8✔
822
        // Create a temporary directory for the build context
8✔
823
        tempBuildDir, err := os.MkdirTemp("", "astro-client-build-*")
8✔
824
        if err != nil {
8✔
825
                return nil, fmt.Errorf("failed to create temporary build directory: %w", err)
×
826
        }
×
827

828
        // Cleanup function to be called by the caller
829
        cleanup := func() {
16✔
830
                os.RemoveAll(tempBuildDir)
8✔
831
        }
8✔
832

833
        // Always return cleanup function if we created a temp directory, even on error
834
        buildContext := &ClientBuildContext{
8✔
835
                TempDir:     tempBuildDir,
8✔
836
                CleanupFunc: cleanup,
8✔
837
        }
8✔
838

8✔
839
        // Check if source directory exists first
8✔
840
        if exists, err := fileutil.Exists(sourcePath, nil); err != nil {
8✔
841
                return buildContext, fmt.Errorf("failed to check if source directory exists: %w", err)
×
842
        } else if !exists {
9✔
843
                return buildContext, fmt.Errorf("source directory does not exist: %s", sourcePath)
1✔
844
        }
1✔
845

846
        // Copy all project files to the temporary directory
847
        err = fileutil.CopyDirectory(sourcePath, tempBuildDir)
7✔
848
        if err != nil {
7✔
849
                return buildContext, fmt.Errorf("failed to copy project files to temporary directory: %w", err)
×
850
        }
×
851

852
        // Process client dependency files
853
        err = setupClientDependencyFiles(tempBuildDir)
7✔
854
        if err != nil {
9✔
855
                return buildContext, fmt.Errorf("failed to setup client dependency files: %w", err)
2✔
856
        }
2✔
857

858
        return buildContext, nil
5✔
859
}
860

861
// setupClientDependencyFiles processes client-specific dependency files in the build context
862
func setupClientDependencyFiles(buildDir string) error {
10✔
863
        // Define dependency file pairs (client file -> regular file)
10✔
864
        dependencyFiles := map[string]string{
10✔
865
                "requirements-client.txt": "requirements.txt",
10✔
866
                "packages-client.txt":     "packages.txt",
10✔
867
        }
10✔
868

10✔
869
        // Process client dependency files in the build directory
10✔
870
        for clientFile, regularFile := range dependencyFiles {
27✔
871
                clientPath := filepath.Join(buildDir, clientFile)
17✔
872
                regularPath := filepath.Join(buildDir, regularFile)
17✔
873

17✔
874
                // Copy client file content to the regular file location (requires client file to exist)
17✔
875
                if err := fileutil.CopyFile(clientPath, regularPath); err != nil {
20✔
876
                        return fmt.Errorf("failed to copy %s to %s in build context: %w", clientFile, regularFile, err)
3✔
877
                }
3✔
878
        }
879

880
        return nil
7✔
881
}
882

883
// DeployClientImage handles the client deploy functionality
884
func DeployClientImage(deployInput InputClientDeploy, platformCoreClient astroplatformcore.CoreClient) error { //nolint:gocritic
6✔
885
        c, err := config.GetCurrentContext()
6✔
886
        if err != nil {
6✔
887
                return errors.Wrap(err, "failed to get current context")
×
888
        }
×
889

890
        // Validate deployment runtime version if deployment ID is provided
891
        if err := validateClientImageRuntimeVersion(deployInput, platformCoreClient); err != nil {
6✔
NEW
892
                return err
×
NEW
893
        }
×
894

895
        // Get the remote client registry endpoint from config
896
        registryEndpoint := config.CFG.RemoteClientRegistry.GetString()
6✔
897
        if registryEndpoint == "" {
7✔
898
                fmt.Println("The Astro CLI is not configured to push client images to your private registry.")
1✔
899
                fmt.Println("For remote Deployments, client images must be stored in your private registry, not in Astronomer managed registries.")
1✔
900
                fmt.Println("Please provide your private registry information so the Astro CLI can push client images.")
1✔
901
                return errors.New("remote client registry is not configured. To configure it, run: 'astro config set remote.client_registry <endpoint>' and try again.")
1✔
902
        }
1✔
903

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

5✔
908
        // Build the full remote image name
5✔
909
        remoteImage := fmt.Sprintf("%s:%s", registryEndpoint, imageTag)
5✔
910

5✔
911
        // Create an image handler for building and pushing
5✔
912
        imageHandler := airflowImageHandler(remoteImage)
5✔
913

5✔
914
        if deployInput.ImageName != "" {
6✔
915
                // Use the provided local image (tag will be ignored, remote tag is always timestamp-based)
1✔
916
                fmt.Println("Using provided image:", deployInput.ImageName)
1✔
917
                err := imageHandler.TagLocalImage(deployInput.ImageName)
1✔
918
                if err != nil {
1✔
919
                        return fmt.Errorf("failed to tag local image: %w", err)
×
920
                }
×
921
        } else {
4✔
922
                // Authenticate with the base image registry before building
4✔
923
                // This is needed because Dockerfile.client uses base images from a private registry
4✔
924

4✔
925
                // Skip registry login if the base image registry is not from astronomer, check the content of the Dockerfile.client file
4✔
926
                dockerfileClientContent, err := fileutil.ReadFileToString(filepath.Join(deployInput.Path, "Dockerfile.client"))
4✔
927
                if util.IsAstronomerRegistry(dockerfileClientContent) || err != nil {
8✔
928
                        // login to the registry
4✔
929
                        if err != nil {
5✔
930
                                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✔
931
                        }
1✔
932
                        baseImageRegistry := config.CFG.RemoteBaseImageRegistry.GetString()
4✔
933
                        fmt.Printf("Authenticating with base image registry: %s\n", baseImageRegistry)
4✔
934
                        err := airflow.DockerLogin(baseImageRegistry, registryUsername, c.Token)
4✔
935
                        if err != nil {
5✔
936
                                fmt.Println("Failed to authenticate with Astronomer registry that contains the base agent image used in the Dockerfile.client file.")
1✔
937
                                fmt.Println("This could be because either your token has expired or you don't have permission to pull the base agent image.")
1✔
938
                                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✔
939
                                return fmt.Errorf("failed to authenticate with registry %s: %w", baseImageRegistry, err)
1✔
940
                        }
1✔
941
                }
942

943
                // Build the client image from the current directory
944
                // Determine target platforms for client deploy
945
                var targetPlatforms []string
3✔
946
                if deployInput.Platform != "" {
3✔
947
                        // Parse comma-separated platforms from --platform flag
×
948
                        targetPlatforms = strings.Split(deployInput.Platform, ",")
×
949
                        // Trim whitespace from each platform
×
950
                        for i, platform := range targetPlatforms {
×
951
                                targetPlatforms[i] = strings.TrimSpace(platform)
×
952
                        }
×
953
                        fmt.Printf("Building client image for platforms: %s\n", strings.Join(targetPlatforms, ", "))
×
954
                } else {
3✔
955
                        // Use empty slice to let Docker build for host platform by default
3✔
956
                        targetPlatforms = []string{}
3✔
957
                        fmt.Println("Building client image for host platform")
3✔
958
                }
3✔
959

960
                // Prepare build context with client dependency files
961
                buildContext, err := prepareClientBuildContext(deployInput.Path)
3✔
962
                if buildContext != nil && buildContext.CleanupFunc != nil {
6✔
963
                        defer buildContext.CleanupFunc()
3✔
964
                }
3✔
965
                if err != nil {
3✔
966
                        return fmt.Errorf("failed to prepare client build context: %w", err)
×
967
                }
×
968

969
                // Build the image from the prepared context
970
                buildConfig := types.ImageBuildConfig{
3✔
971
                        Path:            buildContext.TempDir,
3✔
972
                        TargetPlatforms: targetPlatforms,
3✔
973
                }
3✔
974

3✔
975
                err = imageHandler.Build("Dockerfile.client", deployInput.BuildSecretString, buildConfig)
3✔
976
                if err != nil {
4✔
977
                        return fmt.Errorf("failed to build client image: %w", err)
1✔
978
                }
1✔
979
        }
980

981
        // Push the image to the remote registry (assumes docker login was done externally)
982
        fmt.Println("Pushing client image to configured remote registry")
3✔
983
        _, err = imageHandler.Push(remoteImage, "", "", false)
3✔
984
        if err != nil {
4✔
985
                if errors.Is(err, airflow.ErrImagePush403) {
1✔
986
                        fmt.Printf("\n--------------------------------\n")
×
987
                        fmt.Printf("Failed to push client image to %s\n", registryEndpoint)
×
988
                        fmt.Println("It could be due to either your registry token has expired or you don't have permission to push the client image")
×
989
                        fmt.Printf("Please ensure that you have logged in to `%s` via `docker login` and try again\n\n", registryEndpoint)
×
990
                }
×
991
                return fmt.Errorf("failed to push client image: %w", err)
1✔
992
        }
993

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

2✔
996
        fmt.Printf("\n--------------------------------\n")
2✔
997
        fmt.Println("The client image has been pushed to your private registry.")
2✔
998
        fmt.Println("Your next step would be to update the agent component to use the new client image.")
2✔
999
        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✔
1000
        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✔
1001
        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✔
1002

2✔
1003
        return nil
2✔
1004
}
1005

1006
// validateClientImageRuntimeVersion validates that the client image runtime version
1007
// is not newer than the deployment runtime version
1008
func validateClientImageRuntimeVersion(deployInput InputClientDeploy, platformCoreClient astroplatformcore.CoreClient) error { //nolint:gocritic
16✔
1009
        // Skip validation if no deployment ID provided
16✔
1010
        if deployInput.DeploymentID == "" {
23✔
1011
                return nil
7✔
1012
        }
7✔
1013

1014
        // Get current context for organization info
1015
        c, err := config.GetCurrentContext()
9✔
1016
        if err != nil {
10✔
1017
                return errors.Wrap(err, "failed to get current context")
1✔
1018
        }
1✔
1019

1020
        // Get deployment information
1021
        deployInfo, err := fetchDeploymentDetails(deployInput.DeploymentID, c.Organization, platformCoreClient)
8✔
1022
        if err != nil {
9✔
1023
                return errors.Wrap(err, "failed to get deployment information")
1✔
1024
        }
1✔
1025

1026
        // Parse Dockerfile.client to get client image runtime version
1027
        dockerfileClientPath := filepath.Join(deployInput.Path, "Dockerfile.client")
7✔
1028
        if _, err := os.Stat(dockerfileClientPath); os.IsNotExist(err) {
8✔
1029
                return errors.New("Dockerfile.client is required for client image runtime version validation")
1✔
1030
        }
1✔
1031

1032
        cmds, err := docker.ParseFile(dockerfileClientPath)
6✔
1033
        if err != nil {
7✔
1034
                return errors.Wrapf(err, "failed to parse Dockerfile.client: %s", dockerfileClientPath)
1✔
1035
        }
1✔
1036

1037
        baseImage := docker.GetImageFromParsedFile(cmds)
5✔
1038
        if baseImage == "" {
6✔
1039
                return errors.New("failed to find base image in Dockerfile.client")
1✔
1040
        }
1✔
1041

1042
        // Extract runtime version from the base image tag
1043
        clientRuntimeVersion, err := extractRuntimeVersionFromImage(baseImage)
4✔
1044
        if err != nil {
5✔
1045
                return errors.Wrapf(err, "failed to extract runtime version from client image %s", baseImage)
1✔
1046
        }
1✔
1047

1048
        // Compare versions
1049
        if airflowversions.CompareRuntimeVersions(clientRuntimeVersion, deployInfo.currentVersion) > 0 {
4✔
1050
                return fmt.Errorf(`client image runtime version validation failed:
1✔
1051

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

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

1✔
1058
This validation ensures compatibility between your client image and the deployment environment`,
1✔
1059
                        clientRuntimeVersion, deployInfo.currentVersion, deployInfo.currentVersion, clientRuntimeVersion)
1✔
1060
        }
1✔
1061

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

2✔
1065
        return nil
2✔
1066
}
1067

1068
// extractRuntimeVersionFromImage extracts the runtime version from an image tag
1069
// Example: "images.astronomer.cloud/baseimages/astro-remote-execution-agent:3.1-1-python-3.12-astro-agent-1.1.0"
1070
// Returns: "3.1-1"
1071
func extractRuntimeVersionFromImage(imageName string) (string, error) {
9✔
1072
        // Split image name to get the tag part
9✔
1073
        parts := strings.Split(imageName, ":")
9✔
1074
        if len(parts) < 2 {
10✔
1075
                return "", errors.New("image name does not contain a tag")
1✔
1076
        }
1✔
1077

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

8✔
1080
        // Use the existing ParseImageTag function from airflow_versions package
8✔
1081
        tagInfo, err := airflowversions.ParseImageTag(imageTag)
8✔
1082
        if err != nil {
10✔
1083
                return "", errors.Wrapf(err, "failed to parse image tag: %s", imageTag)
2✔
1084
        }
2✔
1085

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