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

astronomer / astro-cli / 55a53c96-6e1c-463c-b2bf-3f94080c6774

11 Feb 2026 02:42PM UTC coverage: 33.191% (+0.04%) from 33.152%
55a53c96-6e1c-463c-b2bf-3f94080c6774

Pull #2009

circleci

pre-commit-ci[bot]
[pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
Pull Request #2009: feat: add anonymous telemetry tracking

105 of 236 new or added lines in 5 files covered. (44.49%)

127 existing lines in 2 files now uncovered.

20969 of 63177 relevant lines covered (33.19%)

8.51 hits per line

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

79.79
/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✔
UNCOV
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✔
UNCOV
148
                                return err
×
149
                        }
×
150
                }
151
        }
152

153
        if err := scanner.Err(); err != nil {
58✔
UNCOV
154
                return err
×
155
        }
×
156
        err = os.WriteFile(fullpath, bytes.Trim(buf.Bytes(), "\n"), 0o666) //nolint:gosec, mnd
58✔
157
        if err != nil {
58✔
UNCOV
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✔
UNCOV
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✔
UNCOV
184
                return "", err
×
UNCOV
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✔
UNCOV
205
                return err
×
UNCOV
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✔
UNCOV
233
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
UNCOV
234
                }
×
235
        }
236

237
        deploymentURL, err := deployment.GetDeploymentURL(deployInfo.deploymentID, deployInfo.workspaceID)
38✔
238
        if err != nil {
38✔
UNCOV
239
                return err
×
UNCOV
240
        }
×
241

242
        // Check if git metadata is enabled (default: true)
243
        var deployGit *astrocore.DeployGit
38✔
244
        var commitMessage string
38✔
245
        if config.CFG.DeployGitMetadata.GetBool() {
76✔
246
                deployGit, commitMessage = retrieveLocalGitMetadata(deployInput.Path)
38✔
247
        }
38✔
248

249
        // Use commit message as description fallback
250
        description := deployInput.Description
38✔
251
        if description == "" {
76✔
252
                description = commitMessage
38✔
253
        }
38✔
254

255
        // Build the deploy request with git metadata
256
        createDeployRequest := astroplatformcore.CreateDeployRequest{
38✔
257
                Description: &description,
38✔
258
        }
38✔
259

38✔
260
        // Set deploy type
38✔
261
        switch {
38✔
262
        case deployInput.Dags:
18✔
263
                createDeployRequest.Type = astroplatformcore.CreateDeployRequestTypeDAGONLY
18✔
264
        case deployInput.Image:
1✔
265
                createDeployRequest.Type = astroplatformcore.CreateDeployRequestTypeIMAGEONLY
1✔
266
        default:
19✔
267
                createDeployRequest.Type = astroplatformcore.CreateDeployRequestTypeIMAGEANDDAG
19✔
268
        }
269

270
        // Add git metadata if available
271
        if deployGit != nil {
38✔
UNCOV
272
                createDeployRequest.Git = &astroplatformcore.CreateDeployGitRequest{
×
UNCOV
273
                        Provider:   astroplatformcore.CreateDeployGitRequestProvider(deployGit.Provider),
×
UNCOV
274
                        Account:    deployGit.Account,
×
UNCOV
275
                        Repo:       deployGit.Repo,
×
276
                        Path:       deployGit.Path,
×
277
                        Branch:     deployGit.Branch,
×
278
                        CommitSha:  deployGit.CommitSha,
×
279
                        CommitUrl:  deployGit.CommitUrl,
×
280
                        AuthorName: deployGit.AuthorName,
×
281
                }
×
282
        }
×
283

284
        deploy, err := createDeploy(deployInfo.organizationID, deployInfo.deploymentID, createDeployRequest, platformCoreClient)
38✔
285
        if err != nil {
38✔
286
                return err
×
UNCOV
287
        }
×
288
        deployID := deploy.Id
38✔
289
        if deploy.DagsUploadUrl != nil {
76✔
290
                dagsUploadURL = *deploy.DagsUploadUrl
38✔
291
        } else {
38✔
UNCOV
292
                dagsUploadURL = ""
×
UNCOV
293
        }
×
294
        if deploy.ImageTag != "" {
38✔
UNCOV
295
                nextTag = deploy.ImageTag
×
296
        } else {
38✔
297
                nextTag = ""
38✔
298
        }
38✔
299

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

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

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

321
                if !deployInfo.dagDeployEnabled {
16✔
322
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
1✔
323
                }
1✔
324

325
                fmt.Println("Initiating DAG deploy for: " + deployInfo.deploymentID)
14✔
326
                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astroplatformcore.DeploymentType(deployInfo.deploymentType))
14✔
327
                if err != nil {
14✔
UNCOV
328
                        if strings.Contains(err.Error(), dagDeployDisabled) {
×
UNCOV
329
                                return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
×
UNCOV
330
                        }
×
331

332
                        return err
×
333
                }
334

335
                // finish deploy
336
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, platformCoreClient)
14✔
337
                if err != nil {
14✔
UNCOV
338
                        return err
×
UNCOV
339
                }
×
340

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

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

×
353
                        return nil
×
354
                }
355

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

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

×
UNCOV
383
                        if !i {
×
UNCOV
384
                                fmt.Println("Canceling deploy...")
×
385
                                return nil
×
386
                        }
×
387
                }
388

389
                // Build our image
390
                runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, platformCoreClient)
19✔
391
                if err != nil {
19✔
UNCOV
392
                        return err
×
UNCOV
393
                }
×
394

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

404
                repository := deploy.ImageRepository
18✔
405
                // TODO: Resolve the edge case where two people push the same nextTag at the same time
18✔
406
                remoteImage := fmt.Sprintf("%s:%s", repository, nextTag)
18✔
407

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

415
                if deployInfo.dagDeployEnabled && len(dagFiles) > 0 {
24✔
416
                        if !deployInput.Image {
12✔
417
                                dagTarballVersion, err = deployDags(deployInput.Path, dagsPath, dagsUploadURL, deployInfo.currentVersion, astroplatformcore.DeploymentType(deployInfo.deploymentType))
6✔
418
                                if err != nil {
6✔
UNCOV
419
                                        return err
×
UNCOV
420
                                }
×
UNCOV
421
                        } else {
×
UNCOV
422
                                fmt.Println("Image Deploy only. Skipping deploying DAG...")
×
423
                        }
×
424
                }
425
                // finish deploy
426
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, platformCoreClient)
18✔
427
                if err != nil {
18✔
UNCOV
428
                        return err
×
UNCOV
429
                }
×
430

431
                if deployInput.WaitForStatus {
20✔
432
                        err = deployment.HealthPoll(deployInfo.deploymentID, deployInfo.workspaceID, sleepTime, tickNum, int(deployInput.WaitTime.Seconds()), platformCoreClient)
2✔
433
                        if err != nil {
4✔
434
                                return err
2✔
435
                        }
2✔
436
                }
437

438
                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✔
439
                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold("https://"+deploymentURL), ansi.Bold("https://"+deployInfo.webserverURL)))
16✔
440
        }
441

442
        return nil
29✔
443
}
444

445
func getDeploymentInfo(
446
        deploymentID, wsID, deploymentName string,
447
        prompt bool,
448
        platformCoreClient astroplatformcore.CoreClient,
449
        coreClient astrocore.CoreClient,
450
) (deploymentInfo, error) {
40✔
451
        // Use config deployment if provided
40✔
452
        if deploymentID == "" {
54✔
453
                deploymentID = config.CFG.ProjectDeployment.GetProjectString()
14✔
454
                if deploymentID != "" {
14✔
UNCOV
455
                        fmt.Printf("Deployment ID found in the config file. This Deployment ID will be used for the deploy\n")
×
UNCOV
456
                }
×
457
        }
458

459
        if deploymentID != "" && deploymentName != "" {
48✔
460
                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✔
461
        }
8✔
462

463
        // check if deploymentID or if force prompt was requested was given by user
464
        if deploymentID == "" || prompt {
67✔
465
                currentDeployment, err := deployment.GetDeployment(wsID, deploymentID, deploymentName, false, nil, platformCoreClient, coreClient)
27✔
466
                if err != nil {
27✔
UNCOV
467
                        return deploymentInfo{}, err
×
UNCOV
468
                }
×
469
                coreDeployment, err := deployment.CoreGetDeployment(currentDeployment.OrganizationId, currentDeployment.Id, platformCoreClient)
27✔
470
                if err != nil {
27✔
471
                        return deploymentInfo{}, err
×
472
                }
×
473
                var desiredDagTarballVersion string
27✔
474
                if coreDeployment.DesiredDagTarballVersion != nil {
45✔
475
                        desiredDagTarballVersion = *coreDeployment.DesiredDagTarballVersion
18✔
476
                } else {
27✔
477
                        desiredDagTarballVersion = ""
9✔
478
                }
9✔
479

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

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

514
        containerHandler, err := containerHandlerInit(config.WorkingPath, envFile, "Dockerfile", namespace)
19✔
515
        if err != nil {
19✔
516
                return err
×
UNCOV
517
        }
×
518

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

542
                err = checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
543
                if err != nil {
6✔
544
                        return err
×
UNCOV
545
                }
×
546
        }
547
        return nil
16✔
548
}
549

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

561
        return nil
11✔
562
}
563

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

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

578
        fmt.Print("\nAll Pytests passed!\n")
11✔
579
        return err
11✔
580
}
581

582
func fetchDeploymentDetails(deploymentID, organizationID string, platformCoreClient astroplatformcore.CoreClient) (deploymentInfo, error) {
21✔
583
        resp, err := platformCoreClient.GetDeploymentWithResponse(httpContext.Background(), organizationID, deploymentID)
21✔
584
        if err != nil {
21✔
UNCOV
585
                return deploymentInfo{}, err
×
UNCOV
586
        }
×
587

588
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
21✔
589
        if err != nil {
22✔
590
                return deploymentInfo{}, err
1✔
591
        }
1✔
592

593
        currentVersion := resp.JSON200.RuntimeVersion
20✔
594
        namespace := resp.JSON200.Namespace
20✔
595
        workspaceID := resp.JSON200.WorkspaceId
20✔
596
        webserverURL := resp.JSON200.WebServerUrl
20✔
597
        dagDeployEnabled := resp.JSON200.IsDagDeployEnabled
20✔
598
        cicdEnforcement := resp.JSON200.IsCicdEnforced
20✔
599
        isRemoteExecutionEnabled := deployment.IsRemoteExecutionEnabled(resp.JSON200)
20✔
600
        var desiredDagTarballVersion string
20✔
601
        if resp.JSON200.DesiredDagTarballVersion != nil {
25✔
602
                desiredDagTarballVersion = *resp.JSON200.DesiredDagTarballVersion
5✔
603
        } else {
20✔
604
                desiredDagTarballVersion = ""
15✔
605
        }
15✔
606

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

20✔
610
        return deploymentInfo{
20✔
611
                namespace:                namespace,
20✔
612
                deployImage:              deployImage,
20✔
613
                currentVersion:           currentVersion,
20✔
614
                organizationID:           organizationID,
20✔
615
                workspaceID:              workspaceID,
20✔
616
                webserverURL:             webserverURL,
20✔
617
                dagDeployEnabled:         dagDeployEnabled,
20✔
618
                desiredDagTarballVersion: desiredDagTarballVersion,
20✔
619
                cicdEnforcement:          cicdEnforcement,
20✔
620
                isRemoteExecutionEnabled: isRemoteExecutionEnabled,
20✔
621
        }, nil
20✔
622
}
623

624
func buildImageWithoutDags(path, buildSecretString string, imageHandler airflow.ImageHandler) error {
19✔
625
        // flag to determine if we are setting the dags folder in dockerignore
19✔
626
        dagsIgnoreSet := false
19✔
627
        // flag to determine if dockerignore file was created on runtime
19✔
628
        dockerIgnoreCreate := false
19✔
629
        fullpath := filepath.Join(path, ".dockerignore")
19✔
630

19✔
631
        defer func() {
38✔
632
                // remove dags from .dockerignore file if we set it
19✔
633
                if dagsIgnoreSet {
38✔
634
                        removeDagsFromDockerIgnore(fullpath) //nolint:errcheck
19✔
635
                }
19✔
636
                // remove created docker ignore file
637
                if dockerIgnoreCreate {
19✔
UNCOV
638
                        os.Remove(fullpath)
×
UNCOV
639
                }
×
640
        }()
641

642
        fileExist, _ := fileutil.Exists(fullpath, nil)
19✔
643
        if !fileExist {
19✔
UNCOV
644
                // Create a dockerignore file and add the dags folder entry
×
UNCOV
645
                err := fileutil.WriteStringToFile(fullpath, "dags/")
×
UNCOV
646
                if err != nil {
×
UNCOV
647
                        return err
×
648
                }
×
649
                dockerIgnoreCreate = true
×
650
        }
651
        lines, err := fileutil.Read(fullpath)
19✔
652
        if err != nil {
19✔
653
                return err
×
UNCOV
654
        }
×
655
        contains, _ := fileutil.Contains(lines, "dags/")
19✔
656
        if !contains {
38✔
657
                f, err := os.OpenFile(fullpath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) //nolint:mnd
19✔
658
                if err != nil {
19✔
UNCOV
659
                        return err
×
UNCOV
660
                }
×
661

662
                defer f.Close()
19✔
663

19✔
664
                if _, err := f.WriteString("\ndags/"); err != nil {
19✔
UNCOV
665
                        return err
×
UNCOV
666
                }
×
667

668
                dagsIgnoreSet = true
19✔
669
        }
670
        err = imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
19✔
671
        if err != nil {
19✔
UNCOV
672
                return err
×
UNCOV
673
        }
×
674

675
        // remove dags from .dockerignore file if we set it
676
        if dagsIgnoreSet {
38✔
677
                err = removeDagsFromDockerIgnore(fullpath)
19✔
678
                if err != nil {
19✔
UNCOV
679
                        return err
×
UNCOV
680
                }
×
681
        }
682

683
        return nil
19✔
684
}
685

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

34✔
689
        if imageName == "" {
61✔
690
                // Build our image
27✔
691
                fmt.Println(composeImageBuildingPromptMsg)
27✔
692

27✔
693
                if dagDeployEnabled || isRemoteExecutionEnabled {
46✔
694
                        err := buildImageWithoutDags(path, buildSecretString, imageHandler)
19✔
695
                        if err != nil {
19✔
UNCOV
696
                                return "", err
×
UNCOV
697
                        }
×
698
                } else {
8✔
699
                        err := imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
8✔
700
                        if err != nil {
9✔
701
                                return "", err
1✔
702
                        }
1✔
703
                }
704
        } else {
7✔
705
                // skip build if an imageName is passed
7✔
706
                fmt.Println(composeSkipImageBuildingPromptMsg)
7✔
707

7✔
708
                err := imageHandler.TagLocalImage(imageName)
7✔
709
                if err != nil {
7✔
UNCOV
710
                        return "", err
×
UNCOV
711
                }
×
712
        }
713

714
        // parse dockerfile
715
        cmds, err := docker.ParseFile(filepath.Join(path, dockerfile))
33✔
716
        if err != nil {
34✔
717
                return "", errors.Wrapf(err, "failed to parse dockerfile: %s", filepath.Join(path, dockerfile))
1✔
718
        }
1✔
719

720
        DockerfileImage := docker.GetImageFromParsedFile(cmds)
32✔
721

32✔
722
        version, err = imageHandler.GetLabel("", runtimeImageLabel)
32✔
723
        if err != nil {
32✔
UNCOV
724
                fmt.Println("unable get runtime version from image")
×
UNCOV
725
        }
×
726

727
        if config.CFG.ShowWarnings.GetBool() && version == "" {
32✔
728
                fmt.Printf(warningInvalidImageNameMsg, DockerfileImage)
×
729
                fmt.Println("Canceling deploy...")
×
UNCOV
730
                os.Exit(1)
×
UNCOV
731
        }
×
732

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

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

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

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

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

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

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

797
        // Check that the tag is not a downgrade
798
        if airflowversions.CompareRuntimeVersions(tag, currentVersion) < 0 {
43✔
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
37✔
805
        for _, runtimeVersion := range deploymentOptionsRuntimeVersions {
100✔
806
                if airflowversions.CompareRuntimeVersions(tag, runtimeVersion) == 0 {
99✔
807
                        tagInDeploymentOptions = true
36✔
808
                        break
36✔
809
                }
810
        }
811
        if !tagInDeploymentOptions {
38✔
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)
36✔
819
        tagAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(tag)
36✔
820
        if currentVersionAirflowMajorVersion == "2" && tagAirflowMajorVersion == "3" {
38✔
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
35✔
828
}
829

830
func WarnIfNonLatestVersion(version string, httpClient *httputil.HTTPClient) {
34✔
831
        client := airflowversions.NewClient(httpClient, false, false)
34✔
832
        latestRuntimeVersion, err := airflowversions.GetDefaultImageTag(client, "", "", false)
34✔
833
        if err != nil {
36✔
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 {
64✔
839
                fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
32✔
840
        }
32✔
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✔
UNCOV
857
                return nil, fmt.Errorf("failed to create temporary build directory: %w", err)
×
UNCOV
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✔
UNCOV
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✔
UNCOV
881
                return buildContext, fmt.Errorf("failed to copy project files to temporary directory: %w", err)
×
UNCOV
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✔
UNCOV
919
                return errors.Wrap(err, "failed to get current context")
×
UNCOV
920
        }
×
921

922
        // Validate deployment runtime version if deployment ID is provided
923
        if err := validateClientImageRuntimeVersion(deployInput, platformCoreClient); err != nil {
6✔
924
                return err
×
UNCOV
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✔
UNCOV
951
                        return fmt.Errorf("failed to tag local image: %w", err)
×
UNCOV
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✔
UNCOV
979
                        // Parse comma-separated platforms from --platform flag
×
UNCOV
980
                        targetPlatforms = strings.Split(deployInput.Platform, ",")
×
UNCOV
981
                        // Trim whitespace from each platform
×
UNCOV
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✔
UNCOV
998
                        return fmt.Errorf("failed to prepare client build context: %w", err)
×
UNCOV
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✔
UNCOV
1018
                        fmt.Printf("\n--------------------------------\n")
×
UNCOV
1019
                        fmt.Printf("Failed to push client image to %s\n", registryEndpoint)
×
UNCOV
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")
×
UNCOV
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