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

astronomer / astro-cli / 26297149101

22 May 2026 03:37PM UTC coverage: 39.653% (-0.1%) from 39.756%
26297149101

Pull #2132

github

web-flow
Merge 70a879ad1 into 6468bfbf2
Pull Request #2132: Migrate release process to GitHub Actions with RC support and post-release automation

26202 of 66078 relevant lines covered (39.65%)

9.54 hits per line

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

81.15
/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/pkg/errors"
14

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

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

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

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

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

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

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

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

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

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

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

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

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

127
const accessYourDeploymentFmt = `
128

129
 Access your Deployment:
130

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

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

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

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

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

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

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

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

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

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

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

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

193
        return versionID, nil
21✔
194
}
195

196
// Deploy pushes a new docker image
197
func Deploy(deployInput InputDeploy, platformCoreClient astroplatformcore.CoreClient, coreClient astrocore.CoreClient) error { //nolint
44✔
198
        c, err := config.GetCurrentContext()
44✔
199
        if err != nil {
45✔
200
                return err
1✔
201
        }
1✔
202

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

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

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

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

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

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

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

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

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

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

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

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

278
        // Add git metadata if available
279
        if deployGit != nil {
41✔
280
                createDeployRequest.Git = &astroplatformcore.CreateDeployGitRequest{
×
281
                        Provider:   astroplatformcore.CreateDeployGitRequestProvider(deployGit.Provider),
×
282
                        Account:    deployGit.Account,
×
283
                        Repo:       deployGit.Repo,
×
284
                        Path:       deployGit.Path,
×
285
                        Branch:     deployGit.Branch,
×
286
                        CommitSha:  deployGit.CommitSha,
×
287
                        CommitUrl:  deployGit.CommitUrl,
×
288
                        AuthorName: deployGit.AuthorName,
×
289
                }
×
290
        }
×
291

292
        deploy, err := createDeploy(deployInfo.organizationID, deployInfo.deploymentID, createDeployRequest, platformCoreClient)
41✔
293
        if err != nil {
41✔
294
                return err
×
295
        }
×
296
        deployID := deploy.Id
41✔
297
        if deploy.DagsUploadUrl != nil {
82✔
298
                dagsUploadURL = *deploy.DagsUploadUrl
41✔
299
        } else {
41✔
300
                dagsUploadURL = ""
×
301
        }
×
302
        if deploy.ImageTag != "" {
41✔
303
                nextTag = deploy.ImageTag
×
304
        } else {
41✔
305
                nextTag = ""
41✔
306
        }
41✔
307

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

1✔
312
                        if !i {
2✔
313
                                fmt.Println("Canceling deploy...")
1✔
314
                                return nil
1✔
315
                        }
1✔
316
                }
317
                if deployInput.Pytest != "" {
30✔
318
                        runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, platformCoreClient)
12✔
319
                        if err != nil {
12✔
320
                                return err
×
321
                        }
×
322

323
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecretString)
12✔
324
                        if err != nil {
14✔
325
                                return err
2✔
326
                        }
2✔
327
                }
328

329
                if !deployInfo.dagDeployEnabled {
17✔
330
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
1✔
331
                }
1✔
332

333
                fmt.Println("Initiating DAG deploy for: " + deployInfo.deploymentID)
15✔
334
                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astroplatformcore.DeploymentType(deployInfo.deploymentType), deployInput.NoDagsBaseDir)
15✔
335
                if err != nil {
15✔
336
                        if strings.Contains(err.Error(), dagDeployDisabled) {
×
337
                                return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
338
                        }
×
339

340
                        return err
×
341
                }
342

343
                // finish deploy
344
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, platformCoreClient)
15✔
345
                if err != nil {
15✔
346
                        return err
×
347
                }
×
348

349
                if deployInput.WaitForStatus {
16✔
350
                        // Keeping wait timeout low since dag only deploy is faster
1✔
351
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, dagOnlyDeploySleepTime, tickNum, int(deployInput.WaitTime.Seconds()), platformCoreClient)
1✔
352
                        if err != nil {
2✔
353
                                return err
1✔
354
                        }
1✔
355

356
                        fmt.Println(
×
357
                                "\nSuccessfully uploaded DAGs with version " + ansi.Bold(dagTarballVersion) + " to Astro. Navigate to the Airflow UI to confirm that your deploy was successful." +
×
358
                                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold(deploymentURL), ansi.Bold(deployInfo.webserverURL)),
×
359
                        )
×
360

×
361
                        return nil
×
362
                }
363

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

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

×
391
                        if !i {
×
392
                                fmt.Println("Canceling deploy...")
×
393
                                return nil
×
394
                        }
×
395
                }
396

397
                // Build our image
398
                runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, platformCoreClient)
21✔
399
                if err != nil {
21✔
400
                        return err
×
401
                }
×
402

403
                if len(dagFiles) > 0 {
28✔
404
                        err = parseOrPytestDAG(deployInput.Pytest, runtimeVersion, deployInput.EnvFile, deployInfo.deployImage, deployInfo.namespace, deployInput.BuildSecretString)
7✔
405
                        if err != nil {
8✔
406
                                return err
1✔
407
                        }
1✔
408
                } else {
14✔
409
                        fmt.Println("No DAGs found. Skipping testing...")
14✔
410
                }
14✔
411

412
                repository := deploy.ImageRepository
20✔
413
                // TODO: Resolve the edge case where two people push the same nextTag at the same time
20✔
414
                remoteImage := fmt.Sprintf("%s:%s", repository, nextTag)
20✔
415

20✔
416
                imageHandler := airflowImageHandler(deployInfo.deployImage)
20✔
417
                fmt.Println("Pushing image to Astronomer registry")
20✔
418
                _, err = imageHandler.Push(remoteImage, registryUsername, c.Token, false)
20✔
419
                if err != nil {
20✔
420
                        return err
×
421
                }
×
422

423
                if deployInfo.dagDeployEnabled && len(dagFiles) > 0 {
26✔
424
                        if !deployInput.Image {
12✔
425
                                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astroplatformcore.DeploymentType(deployInfo.deploymentType), deployInput.NoDagsBaseDir)
6✔
426
                                if err != nil {
6✔
427
                                        return err
×
428
                                }
×
429
                        } else {
×
430
                                fmt.Println("Image Deploy only. Skipping deploying DAG...")
×
431
                        }
×
432
                }
433
                // finish deploy
434
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, platformCoreClient)
20✔
435
                if err != nil {
20✔
436
                        return err
×
437
                }
×
438

439
                if deployInput.WaitForStatus {
22✔
440
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, sleepTime, tickNum, int(deployInput.WaitTime.Seconds()), platformCoreClient)
2✔
441
                        if err != nil {
4✔
442
                                return err
2✔
443
                        }
2✔
444
                }
445

446
                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✔
447
                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold("https://"+deploymentURL), ansi.Bold("https://"+deployInfo.webserverURL)))
18✔
448
        }
449

450
        return nil
32✔
451
}
452

453
func getDeploymentInfo(
454
        deploymentID, wsID, deploymentName string,
455
        prompt bool,
456
        platformCoreClient astroplatformcore.CoreClient,
457
        coreClient astrocore.CoreClient,
458
) (deploymentInfo, error) {
43✔
459
        // Use config deployment if provided
43✔
460
        if deploymentID == "" {
57✔
461
                deploymentID = config.CFG.ProjectDeployment.GetProjectString()
14✔
462
                if deploymentID != "" {
14✔
463
                        fmt.Printf("Deployment ID found in the config file. This Deployment ID will be used for the deploy\n")
×
464
                }
×
465
        }
466

467
        if deploymentID != "" && deploymentName != "" {
51✔
468
                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✔
469
        }
8✔
470

471
        // check if deploymentID or if force prompt was requested was given by user
472
        if deploymentID == "" || prompt {
70✔
473
                currentDeployment, err := deployment.GetDeployment(wsID, deploymentID, deploymentName, false, nil, platformCoreClient, coreClient)
27✔
474
                if err != nil {
27✔
475
                        return deploymentInfo{}, err
×
476
                }
×
477
                coreDeployment, err := deployment.CoreGetDeployment(currentDeployment.OrganizationId, currentDeployment.Id, platformCoreClient)
27✔
478
                if err != nil {
27✔
479
                        return deploymentInfo{}, err
×
480
                }
×
481
                var desiredDagTarballVersion string
27✔
482
                if coreDeployment.DesiredDagTarballVersion != nil {
45✔
483
                        desiredDagTarballVersion = *coreDeployment.DesiredDagTarballVersion
18✔
484
                } else {
27✔
485
                        desiredDagTarballVersion = ""
9✔
486
                }
9✔
487

488
                return deploymentInfo{
27✔
489
                        currentDeployment.Id,
27✔
490
                        currentDeployment.Namespace,
27✔
491
                        airflow.ImageName(currentDeployment.Namespace, "latest"),
27✔
492
                        currentDeployment.RuntimeVersion,
27✔
493
                        currentDeployment.OrganizationId,
27✔
494
                        currentDeployment.WorkspaceId,
27✔
495
                        currentDeployment.WebServerUrl,
27✔
496
                        string(*currentDeployment.Type),
27✔
497
                        desiredDagTarballVersion,
27✔
498
                        currentDeployment.IsDagDeployEnabled,
27✔
499
                        currentDeployment.IsCicdEnforced,
27✔
500
                        currentDeployment.Name,
27✔
501
                        deployment.IsRemoteExecutionEnabled(&currentDeployment),
27✔
502
                }, nil
27✔
503
        }
504
        c, err := config.GetCurrentContext()
16✔
505
        if err != nil {
16✔
506
                return deploymentInfo{}, err
×
507
        }
×
508
        deployInfo, err := fetchDeploymentDetails(deploymentID, c.Organization, platformCoreClient)
16✔
509
        if err != nil {
16✔
510
                return deploymentInfo{}, err
×
511
        }
×
512
        deployInfo.deploymentID = deploymentID
16✔
513
        return deployInfo, nil
16✔
514
}
515

516
func parseOrPytestDAG(pytest, runtimeVersion, envFile, deployImage, namespace, buildSecretString string) error {
19✔
517
        validDAGParseVersion := airflowversions.CompareRuntimeVersions(runtimeVersion, dagParseAllowedVersion) >= 0
19✔
518
        if !validDAGParseVersion {
19✔
519
                fmt.Println("\nruntime image is earlier than 4.1.0, this deploy will skip DAG parse...")
×
520
        }
×
521

522
        containerHandler, err := containerHandlerInit(config.WorkingPath, envFile, "Dockerfile", namespace)
19✔
523
        if err != nil {
19✔
524
                return err
×
525
        }
×
526

527
        switch {
19✔
528
        case pytest == parse && validDAGParseVersion:
7✔
529
                // parse dags
7✔
530
                fmt.Println("Testing image...")
7✔
531
                err := parseDAGs(deployImage, buildSecretString, containerHandler)
7✔
532
                if err != nil {
9✔
533
                        return err
2✔
534
                }
2✔
535
        case pytest != "" && pytest != parse && pytest != parseAndPytest:
6✔
536
                // check pytests
6✔
537
                fmt.Println("Testing image...")
6✔
538
                err := checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
539
                if err != nil {
7✔
540
                        return err
1✔
541
                }
1✔
542
        case pytest == parseAndPytest:
6✔
543
                // parse dags and check pytests
6✔
544
                fmt.Println("Testing image...")
6✔
545
                err := parseDAGs(deployImage, buildSecretString, containerHandler)
6✔
546
                if err != nil {
6✔
547
                        return err
×
548
                }
×
549

550
                err = checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
551
                if err != nil {
6✔
552
                        return err
×
553
                }
×
554
        }
555
        return nil
16✔
556
}
557

558
func parseDAGs(deployImage, buildSecretString string, containerHandler airflow.ContainerHandler) error {
13✔
559
        if !config.CFG.SkipParse.GetBool() && !util.CheckEnvBool(os.Getenv("ASTRONOMER_SKIP_PARSE")) {
26✔
560
                err := containerHandler.Parse("", deployImage, buildSecretString)
13✔
561
                if err != nil {
15✔
562
                        fmt.Println(err)
2✔
563
                        return errDagsParseFailed
2✔
564
                }
2✔
565
        } else {
×
566
                fmt.Println("Skipping parsing dags due to skip parse being set to true in either the config.yaml or local environment variables")
×
567
        }
×
568

569
        return nil
11✔
570
}
571

572
// Validate code with pytest
573
func checkPytest(pytest, deployImage, buildSecretString string, containerHandler airflow.ContainerHandler) error {
14✔
574
        if pytest != allTests && pytest != parseAndPytest {
18✔
575
                pytestFile = pytest
4✔
576
        }
4✔
577

578
        exitCode, err := containerHandler.Pytest(pytestFile, "", deployImage, "", buildSecretString)
14✔
579
        if err != nil {
17✔
580
                if strings.Contains(exitCode, "1") { // exit code is 1 meaning tests failed
4✔
581
                        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✔
582
                }
1✔
583
                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✔
584
        }
585

586
        fmt.Print("\nAll Pytests passed!\n")
11✔
587
        return err
11✔
588
}
589

590
func fetchDeploymentDetails(deploymentID, organizationID string, platformCoreClient astroplatformcore.CoreClient) (deploymentInfo, error) {
24✔
591
        resp, err := platformCoreClient.GetDeploymentWithResponse(httpContext.Background(), organizationID, deploymentID)
24✔
592
        if err != nil {
24✔
593
                return deploymentInfo{}, err
×
594
        }
×
595

596
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
24✔
597
        if err != nil {
25✔
598
                return deploymentInfo{}, err
1✔
599
        }
1✔
600

601
        currentVersion := resp.JSON200.RuntimeVersion
23✔
602
        namespace := resp.JSON200.Namespace
23✔
603
        workspaceID := resp.JSON200.WorkspaceId
23✔
604
        webserverURL := resp.JSON200.WebServerUrl
23✔
605
        dagDeployEnabled := resp.JSON200.IsDagDeployEnabled
23✔
606
        cicdEnforcement := resp.JSON200.IsCicdEnforced
23✔
607
        isRemoteExecutionEnabled := deployment.IsRemoteExecutionEnabled(resp.JSON200)
23✔
608
        var desiredDagTarballVersion string
23✔
609
        if resp.JSON200.DesiredDagTarballVersion != nil {
30✔
610
                desiredDagTarballVersion = *resp.JSON200.DesiredDagTarballVersion
7✔
611
        } else {
23✔
612
                desiredDagTarballVersion = ""
16✔
613
        }
16✔
614

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

23✔
618
        return deploymentInfo{
23✔
619
                namespace:                namespace,
23✔
620
                deployImage:              deployImage,
23✔
621
                currentVersion:           currentVersion,
23✔
622
                organizationID:           organizationID,
23✔
623
                workspaceID:              workspaceID,
23✔
624
                webserverURL:             webserverURL,
23✔
625
                dagDeployEnabled:         dagDeployEnabled,
23✔
626
                desiredDagTarballVersion: desiredDagTarballVersion,
23✔
627
                cicdEnforcement:          cicdEnforcement,
23✔
628
                isRemoteExecutionEnabled: isRemoteExecutionEnabled,
23✔
629
        }, nil
23✔
630
}
631

632
func buildImageWithoutDags(path, buildSecretString string, imageHandler airflow.ImageHandler) error {
29✔
633
        fullpath := filepath.Join(path, ".dockerignore")
29✔
634

29✔
635
        // Snapshot the original bytes so we can restore byte-for-byte after the build
29✔
636
        // (preserves CRLF, trailing whitespace, etc.).
29✔
637
        originalBytes, err := os.ReadFile(fullpath)
29✔
638
        originalExisted := err == nil
29✔
639
        if err != nil && !os.IsNotExist(err) {
29✔
640
                return err
×
641
        }
×
642

643
        defer func() {
58✔
644
                if originalExisted {
57✔
645
                        _ = os.WriteFile(fullpath, originalBytes, 0o644) //nolint:gosec,mnd
28✔
646
                } else {
29✔
647
                        _ = os.Remove(fullpath)
1✔
648
                }
1✔
649
        }()
650

651
        switch {
29✔
652
        case !originalExisted:
1✔
653
                if err := os.WriteFile(fullpath, []byte("dags/\n"), 0o644); err != nil { //nolint:gosec,mnd
1✔
654
                        return err
×
655
                }
×
656
        case !dockerignoreContainsDags(originalBytes):
26✔
657
                modified := append([]byte{}, originalBytes...)
26✔
658
                if len(modified) > 0 && modified[len(modified)-1] != '\n' {
28✔
659
                        modified = append(modified, '\n')
2✔
660
                }
2✔
661
                modified = append(modified, []byte("dags/\n")...)
26✔
662
                if err := os.WriteFile(fullpath, modified, 0o644); err != nil { //nolint:gosec,mnd
26✔
663
                        return err
×
664
                }
×
665
        }
666

667
        return imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
29✔
668
}
669

670
// dockerignoreContainsDags reports whether content has a line equal to "dags/".
671
// Uses bufio.Scanner so CRLF line endings are handled identically to LF.
672
func dockerignoreContainsDags(content []byte) bool {
28✔
673
        scanner := bufio.NewScanner(bytes.NewReader(content))
28✔
674
        for scanner.Scan() {
40✔
675
                if scanner.Text() == "dags/" {
14✔
676
                        return true
2✔
677
                }
2✔
678
        }
679
        return false
26✔
680
}
681

682
func buildImage(path, currentVersion, deployImage, imageName, organizationID, buildSecretString string, dagDeployEnabled, isRemoteExecutionEnabled bool, platformCoreClient astroplatformcore.CoreClient) (version string, err error) {
37✔
683
        imageHandler := airflowImageHandler(deployImage)
37✔
684

37✔
685
        if imageName == "" {
65✔
686
                // Build our image
28✔
687
                fmt.Println(composeImageBuildingPromptMsg)
28✔
688

28✔
689
                if dagDeployEnabled || isRemoteExecutionEnabled {
48✔
690
                        err := buildImageWithoutDags(path, buildSecretString, imageHandler)
20✔
691
                        if err != nil {
20✔
692
                                return "", err
×
693
                        }
×
694
                } else {
8✔
695
                        err := imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
8✔
696
                        if err != nil {
9✔
697
                                return "", err
1✔
698
                        }
1✔
699
                }
700
        } else {
9✔
701
                // skip build if an imageName is passed
9✔
702
                fmt.Println(composeSkipImageBuildingPromptMsg)
9✔
703

9✔
704
                err := imageHandler.TagLocalImage(imageName)
9✔
705
                if err != nil {
9✔
706
                        return "", err
×
707
                }
×
708
        }
709

710
        version, err = imageHandler.GetLabel("", runtimeImageLabel)
36✔
711
        if err != nil {
36✔
712
                fmt.Println("unable get runtime version from image")
×
713
        }
×
714

715
        if config.CFG.ShowWarnings.GetBool() && version == "" {
37✔
716
                if imageName != "" {
1✔
717
                        // Registry image names are arbitrary and do not convey base image
×
718
                        // information; reference the missing label in the warning instead.
×
719
                        fmt.Printf(warningInvalidPrebuiltImageNameMsg, imageName, runtimeImageLabel)
×
720
                } else {
1✔
721
                        // Parse the Dockerfile to include the FROM image in the warning,
1✔
722
                        // giving the user a concrete reference for what needs to change.
1✔
723
                        cmds, err := docker.ParseFile(filepath.Join(path, dockerfile))
1✔
724
                        if err != nil {
2✔
725
                                return "", errors.Wrapf(err, "failed to parse dockerfile: %s", filepath.Join(path, dockerfile))
1✔
726
                        }
1✔
727
                        fmt.Printf(warningInvalidImageNameMsg, docker.GetImageFromParsedFile(cmds))
×
728
                }
729
                fmt.Println("Canceling deploy...")
×
730
                os.Exit(1)
×
731
        }
732

733
        resp, err := platformCoreClient.GetDeploymentOptionsWithResponse(httpContext.Background(), organizationID, &astroplatformcore.GetDeploymentOptionsParams{})
35✔
734
        if err != nil {
36✔
735
                return "", err
1✔
736
        }
1✔
737
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
34✔
738
        if err != nil {
34✔
739
                return "", err
×
740
        }
×
741
        deploymentOptionsRuntimeVersions := []string{}
34✔
742
        for _, runtimeRelease := range resp.JSON200.RuntimeReleases {
238✔
743
                deploymentOptionsRuntimeVersions = append(deploymentOptionsRuntimeVersions, runtimeRelease.Version)
204✔
744
        }
204✔
745

746
        if !ValidRuntimeVersion(currentVersion, version, deploymentOptionsRuntimeVersions) {
34✔
747
                fmt.Println("Canceling deploy...")
×
748
                os.Exit(1)
×
749
        }
×
750

751
        WarnIfNonLatestVersion(version, httputil.NewHTTPClient())
34✔
752

34✔
753
        return version, nil
34✔
754
}
755

756
// finalize deploy
757
func finalizeDeploy(deployID, deploymentID, organizationID, dagTarballVersion string, dagDeploy bool, platformCoreClient astroplatformcore.CoreClient) error {
35✔
758
        finalizeDeployRequest := astroplatformcore.FinalizeDeployRequest{}
35✔
759
        if dagDeploy {
59✔
760
                finalizeDeployRequest.DagTarballVersion = &dagTarballVersion
24✔
761
        }
24✔
762
        resp, err := platformCoreClient.FinalizeDeployWithResponse(httpContext.Background(), organizationID, deploymentID, deployID, finalizeDeployRequest)
35✔
763
        if err != nil {
35✔
764
                return err
×
765
        }
×
766
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
35✔
767
        if err != nil {
35✔
768
                return err
×
769
        }
×
770
        if resp.JSON200.DagTarballVersion != nil {
70✔
771
                fmt.Println("Deployed DAG bundle: ", *resp.JSON200.DagTarballVersion)
35✔
772
        }
35✔
773
        if resp.JSON200.ImageTag != "" {
70✔
774
                fmt.Println("Deployed Image Tag: ", resp.JSON200.ImageTag)
35✔
775
        }
35✔
776
        return nil
35✔
777
}
778

779
func createDeploy(organizationID, deploymentID string, request astroplatformcore.CreateDeployRequest, platformCoreClient astroplatformcore.CoreClient) (*astroplatformcore.Deploy, error) {
41✔
780
        resp, err := platformCoreClient.CreateDeployWithResponse(httpContext.Background(), organizationID, deploymentID, request)
41✔
781
        if err != nil {
41✔
782
                return nil, err
×
783
        }
×
784
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
41✔
785
        if err != nil {
41✔
786
                return nil, err
×
787
        }
×
788
        return resp.JSON200, err
41✔
789
}
790

791
func ValidRuntimeVersion(currentVersion, tag string, deploymentOptionsRuntimeVersions []string) bool {
44✔
792
        // Allow old deployments which do not have runtimeVersion tag
44✔
793
        if currentVersion == "" {
45✔
794
                return true
1✔
795
        }
1✔
796

797
        // Check that the tag is not a downgrade
798
        if airflowversions.CompareRuntimeVersions(tag, currentVersion) < 0 {
46✔
799
                fmt.Printf("Cannot deploy a downgraded Astro Runtime version. Modify your Astro Runtime version to %s or higher in your Dockerfile\n", currentVersion)
3✔
800
                return false
3✔
801
        }
3✔
802

803
        // Check that the tag is supported by the deployment
804
        tagInDeploymentOptions := false
40✔
805
        for _, runtimeVersion := range deploymentOptionsRuntimeVersions {
111✔
806
                if airflowversions.CompareRuntimeVersions(tag, runtimeVersion) == 0 {
110✔
807
                        tagInDeploymentOptions = true
39✔
808
                        break
39✔
809
                }
810
        }
811
        if !tagInDeploymentOptions {
41✔
812
                fmt.Println("Cannot deploy an unsupported Astro Runtime version. Modify your Astro Runtime version to a supported version in your Dockerfile")
1✔
813
                fmt.Printf("Supported versions: %s\n", strings.Join(deploymentOptionsRuntimeVersions, ", "))
1✔
814
                return false
1✔
815
        }
1✔
816

817
        // If upgrading from Airflow 2 to Airflow 3, we require at least Runtime 12.0.0 (Airflow 2.10.0)
818
        currentVersionAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(currentVersion)
39✔
819
        tagAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(tag)
39✔
820
        if currentVersionAirflowMajorVersion == "2" && tagAirflowMajorVersion == "3" {
41✔
821
                if airflowversions.CompareRuntimeVersions(currentVersion, "12.0.0") < 0 {
3✔
822
                        fmt.Println("Can only upgrade deployment from Airflow 2 to Airflow 3 with deployment at Astro Runtime 12.0.0 or higher")
1✔
823
                        return false
1✔
824
                }
1✔
825
        }
826

827
        return true
38✔
828
}
829

830
func WarnIfNonLatestVersion(version string, httpClient *httputil.HTTPClient) {
37✔
831
        client := airflowversions.NewClient(httpClient, false, false)
37✔
832
        latestRuntimeVersion, err := airflowversions.GetDefaultImageTag(client, "", "", false)
37✔
833
        if err != nil {
39✔
834
                logger.Debugf("unable to get latest runtime version: %s", err)
2✔
835
                return
2✔
836
        }
2✔
837

838
        if airflowversions.CompareRuntimeVersions(version, latestRuntimeVersion) < 0 {
70✔
839
                fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
35✔
840
        }
35✔
841
}
842

843
// ClientBuildContext represents a prepared build context for client deployment
844
type ClientBuildContext struct {
845
        // TempDir is the temporary directory containing the build context
846
        TempDir string
847
        // CleanupFunc should be called to clean up the temporary directory
848
        CleanupFunc func()
849
}
850

851
// prepareClientBuildContext creates a temporary build context with client dependency files
852
// This avoids modifying the original project files, preventing race conditions with concurrent deployments.
853
func prepareClientBuildContext(sourcePath string) (*ClientBuildContext, error) {
8✔
854
        // Create a temporary directory for the build context
8✔
855
        tempBuildDir, err := os.MkdirTemp("", "astro-client-build-*")
8✔
856
        if err != nil {
8✔
857
                return nil, fmt.Errorf("failed to create temporary build directory: %w", err)
×
858
        }
×
859

860
        // Cleanup function to be called by the caller
861
        cleanup := func() {
16✔
862
                os.RemoveAll(tempBuildDir)
8✔
863
        }
8✔
864

865
        // Always return cleanup function if we created a temp directory, even on error
866
        buildContext := &ClientBuildContext{
8✔
867
                TempDir:     tempBuildDir,
8✔
868
                CleanupFunc: cleanup,
8✔
869
        }
8✔
870

8✔
871
        // Check if source directory exists first
8✔
872
        if exists, err := fileutil.Exists(sourcePath, nil); err != nil {
8✔
873
                return buildContext, fmt.Errorf("failed to check if source directory exists: %w", err)
×
874
        } else if !exists {
9✔
875
                return buildContext, fmt.Errorf("source directory does not exist: %s", sourcePath)
1✔
876
        }
1✔
877

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

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

890
        return buildContext, nil
5✔
891
}
892

893
// setupClientDependencyFiles processes client-specific dependency files in the build context
894
func setupClientDependencyFiles(buildDir string) error {
10✔
895
        // Define dependency file pairs (client file -> regular file)
10✔
896
        dependencyFiles := map[string]string{
10✔
897
                "requirements-client.txt": "requirements.txt",
10✔
898
                "packages-client.txt":     "packages.txt",
10✔
899
        }
10✔
900

10✔
901
        // Process client dependency files in the build directory
10✔
902
        for clientFile, regularFile := range dependencyFiles {
28✔
903
                clientPath := filepath.Join(buildDir, clientFile)
18✔
904
                regularPath := filepath.Join(buildDir, regularFile)
18✔
905

18✔
906
                // Copy client file content to the regular file location (requires client file to exist)
18✔
907
                if err := fileutil.CopyFile(clientPath, regularPath); err != nil {
21✔
908
                        return fmt.Errorf("failed to copy %s to %s in build context: %w", clientFile, regularFile, err)
3✔
909
                }
3✔
910
        }
911

912
        return nil
7✔
913
}
914

915
// DeployClientImage handles the client deploy functionality
916
func DeployClientImage(deployInput InputClientDeploy, platformCoreClient astroplatformcore.CoreClient) error { //nolint:gocritic
6✔
917
        c, err := config.GetCurrentContext()
6✔
918
        if err != nil {
6✔
919
                return errors.Wrap(err, "failed to get current context")
×
920
        }
×
921

922
        // Validate deployment runtime version if deployment ID is provided
923
        if err := validateClientImageRuntimeVersion(deployInput, platformCoreClient); err != nil {
6✔
924
                return err
×
925
        }
×
926

927
        // Get the remote client registry endpoint from config
928
        registryEndpoint := config.CFG.RemoteClientRegistry.GetString()
6✔
929
        if registryEndpoint == "" {
7✔
930
                fmt.Println("The Astro CLI is not configured to push client images to your private registry.")
1✔
931
                fmt.Println("For remote Deployments, client images must be stored in your private registry, not in Astronomer managed registries.")
1✔
932
                fmt.Println("Please provide your private registry information so the Astro CLI can push client images.")
1✔
933
                return errors.New("remote client registry is not configured. To configure it, run: 'astro config set remote.client_registry <endpoint>' and try again.")
1✔
934
        }
1✔
935

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

5✔
940
        // Build the full remote image name
5✔
941
        remoteImage := fmt.Sprintf("%s:%s", registryEndpoint, imageTag)
5✔
942

5✔
943
        // Create an image handler for building and pushing
5✔
944
        imageHandler := airflowImageHandler(remoteImage)
5✔
945

5✔
946
        if deployInput.ImageName != "" {
6✔
947
                // Use the provided local image (tag will be ignored, remote tag is always timestamp-based)
1✔
948
                fmt.Println("Using provided image:", deployInput.ImageName)
1✔
949
                err := imageHandler.TagLocalImage(deployInput.ImageName)
1✔
950
                if err != nil {
1✔
951
                        return fmt.Errorf("failed to tag local image: %w", err)
×
952
                }
×
953
        } else {
4✔
954
                // Authenticate with the base image registry before building
4✔
955
                // This is needed because Dockerfile.client uses base images from a private registry
4✔
956

4✔
957
                // Skip registry login if the base image registry is not from astronomer, check the content of the Dockerfile.client file
4✔
958
                dockerfileClientContent, err := fileutil.ReadFileToString(filepath.Join(deployInput.Path, "Dockerfile.client"))
4✔
959
                if util.IsAstronomerRegistry(dockerfileClientContent) || err != nil {
8✔
960
                        // login to the registry
4✔
961
                        if err != nil {
5✔
962
                                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✔
963
                        }
1✔
964
                        baseImageRegistry := config.CFG.RemoteBaseImageRegistry.GetString()
4✔
965
                        fmt.Printf("Authenticating with base image registry: %s\n", baseImageRegistry)
4✔
966
                        err := airflow.DockerLogin(baseImageRegistry, registryUsername, c.Token)
4✔
967
                        if err != nil {
5✔
968
                                fmt.Println("Failed to authenticate with Astronomer registry that contains the base agent image used in the Dockerfile.client file.")
1✔
969
                                fmt.Println("This could be because either your token has expired or you don't have permission to pull the base agent image.")
1✔
970
                                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✔
971
                                return fmt.Errorf("failed to authenticate with registry %s: %w", baseImageRegistry, err)
1✔
972
                        }
1✔
973
                }
974

975
                // Build the client image from the current directory
976
                // Determine target platforms for client deploy
977
                var targetPlatforms []string
3✔
978
                if deployInput.Platform != "" {
3✔
979
                        // Parse comma-separated platforms from --platform flag
×
980
                        targetPlatforms = strings.Split(deployInput.Platform, ",")
×
981
                        // Trim whitespace from each platform
×
982
                        for i, platform := range targetPlatforms {
×
983
                                targetPlatforms[i] = strings.TrimSpace(platform)
×
984
                        }
×
985
                        fmt.Printf("Building client image for platforms: %s\n", strings.Join(targetPlatforms, ", "))
×
986
                } else {
3✔
987
                        // Use empty slice to let Docker build for host platform by default
3✔
988
                        targetPlatforms = []string{}
3✔
989
                        fmt.Println("Building client image for host platform")
3✔
990
                }
3✔
991

992
                // Prepare build context with client dependency files
993
                buildContext, err := prepareClientBuildContext(deployInput.Path)
3✔
994
                if buildContext != nil && buildContext.CleanupFunc != nil {
6✔
995
                        defer buildContext.CleanupFunc()
3✔
996
                }
3✔
997
                if err != nil {
3✔
998
                        return fmt.Errorf("failed to prepare client build context: %w", err)
×
999
                }
×
1000

1001
                // Build the image from the prepared context
1002
                buildConfig := types.ImageBuildConfig{
3✔
1003
                        Path:            buildContext.TempDir,
3✔
1004
                        TargetPlatforms: targetPlatforms,
3✔
1005
                }
3✔
1006

3✔
1007
                err = imageHandler.Build("Dockerfile.client", deployInput.BuildSecretString, buildConfig)
3✔
1008
                if err != nil {
4✔
1009
                        return fmt.Errorf("failed to build client image: %w", err)
1✔
1010
                }
1✔
1011
        }
1012

1013
        // Push the image to the remote registry (assumes docker login was done externally)
1014
        fmt.Println("Pushing client image to configured remote registry")
3✔
1015
        _, err = imageHandler.Push(remoteImage, "", "", false)
3✔
1016
        if err != nil {
4✔
1017
                if errors.Is(err, airflow.ErrImagePush403) {
1✔
1018
                        fmt.Printf("\n--------------------------------\n")
×
1019
                        fmt.Printf("Failed to push client image to %s\n", registryEndpoint)
×
1020
                        fmt.Println("It could be due to either your registry token has expired or you don't have permission to push the client image")
×
1021
                        fmt.Printf("Please ensure that you have logged in to `%s` via `docker login` and try again\n\n", registryEndpoint)
×
1022
                }
×
1023
                return fmt.Errorf("failed to push client image: %w", err)
1✔
1024
        }
1025

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

2✔
1028
        fmt.Printf("\n--------------------------------\n")
2✔
1029
        fmt.Println("The client image has been pushed to your private registry.")
2✔
1030
        fmt.Println("Your next step would be to update the agent component to use the new client image.")
2✔
1031
        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✔
1032
        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✔
1033
        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✔
1034

2✔
1035
        return nil
2✔
1036
}
1037

1038
// validateClientImageRuntimeVersion validates that the client image runtime version
1039
// is not newer than the deployment runtime version
1040
func validateClientImageRuntimeVersion(deployInput InputClientDeploy, platformCoreClient astroplatformcore.CoreClient) error { //nolint:gocritic
16✔
1041
        // Skip validation if no deployment ID provided
16✔
1042
        if deployInput.DeploymentID == "" {
23✔
1043
                return nil
7✔
1044
        }
7✔
1045

1046
        // Get current context for organization info
1047
        c, err := config.GetCurrentContext()
9✔
1048
        if err != nil {
10✔
1049
                return errors.Wrap(err, "failed to get current context")
1✔
1050
        }
1✔
1051

1052
        // Get deployment information
1053
        deployInfo, err := fetchDeploymentDetails(deployInput.DeploymentID, c.Organization, platformCoreClient)
8✔
1054
        if err != nil {
9✔
1055
                return errors.Wrap(err, "failed to get deployment information")
1✔
1056
        }
1✔
1057

1058
        // Parse Dockerfile.client to get client image runtime version
1059
        dockerfileClientPath := filepath.Join(deployInput.Path, "Dockerfile.client")
7✔
1060
        if _, err := os.Stat(dockerfileClientPath); os.IsNotExist(err) {
8✔
1061
                return errors.New("Dockerfile.client is required for client image runtime version validation")
1✔
1062
        }
1✔
1063

1064
        cmds, err := docker.ParseFile(dockerfileClientPath)
6✔
1065
        if err != nil {
7✔
1066
                return errors.Wrapf(err, "failed to parse Dockerfile.client: %s", dockerfileClientPath)
1✔
1067
        }
1✔
1068

1069
        baseImage := docker.GetImageFromParsedFile(cmds)
5✔
1070
        if baseImage == "" {
6✔
1071
                return errors.New("failed to find base image in Dockerfile.client")
1✔
1072
        }
1✔
1073

1074
        // Extract runtime version from the base image tag
1075
        clientRuntimeVersion, err := extractRuntimeVersionFromImage(baseImage)
4✔
1076
        if err != nil {
5✔
1077
                return errors.Wrapf(err, "failed to extract runtime version from client image %s", baseImage)
1✔
1078
        }
1✔
1079

1080
        // Compare versions
1081
        if airflowversions.CompareRuntimeVersions(clientRuntimeVersion, deployInfo.currentVersion) > 0 {
4✔
1082
                return fmt.Errorf(`client image runtime version validation failed:
1✔
1083

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

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

1✔
1090
This validation ensures compatibility between your client image and the deployment environment`,
1✔
1091
                        clientRuntimeVersion, deployInfo.currentVersion, deployInfo.currentVersion, clientRuntimeVersion)
1✔
1092
        }
1✔
1093

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

2✔
1097
        return nil
2✔
1098
}
1099

1100
// extractRuntimeVersionFromImage extracts the runtime version from an image tag
1101
// Example: "images.astronomer.cloud/baseimages/astro-remote-execution-agent:3.1-1-python-3.12-astro-agent-1.1.0"
1102
// Returns: "3.1-1"
1103
func extractRuntimeVersionFromImage(imageName string) (string, error) {
9✔
1104
        // Split image name to get the tag part
9✔
1105
        parts := strings.Split(imageName, ":")
9✔
1106
        if len(parts) < 2 {
10✔
1107
                return "", errors.New("image name does not contain a tag")
1✔
1108
        }
1✔
1109

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

8✔
1112
        // Use the existing ParseImageTag function from airflow_versions package
8✔
1113
        tagInfo, err := airflowversions.ParseImageTag(imageTag)
8✔
1114
        if err != nil {
10✔
1115
                return "", errors.Wrapf(err, "failed to parse image tag: %s", imageTag)
2✔
1116
        }
2✔
1117

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