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

astronomer / astro-cli / bc5f47c7-8d4b-4664-8417-ee806178463e

18 Dec 2025 06:34PM UTC coverage: 33.152% (+0.02%) from 33.132%
bc5f47c7-8d4b-4664-8417-ee806178463e

Pull #1990

circleci

jlaneve
chore: remove planning doc from PR

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Pull Request #1990: feat: add git metadata to deploys via v1alpha1 API

29 of 42 new or added lines in 3 files covered. (69.05%)

162 existing lines in 2 files now uncovered.

20868 of 62946 relevant lines covered (33.15%)

8.52 hits per line

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

79.67
/cloud/deploy/deploy.go
1
package deploy
2

3
import (
4
        "bufio"
5
        "bytes"
6
        httpContext "context"
7
        "fmt"
8
        "os"
9
        "path/filepath"
10
        "strings"
11
        "time"
12

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

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

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

43
        warningInvalidImageNameMsg = "WARNING! The image in your Dockerfile '%s' is not based on Astro Runtime and is not supported. Change your Dockerfile with an image that pulls from 'quay.io/astronomer/astro-runtime' to proceed.\n"
44

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

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

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

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

69
var (
70
        errDagsParseFailed = errors.New("your local DAGs did not parse. Fix the listed errors or use `astro deploy [deployment-id] -f` to force deploy") //nolint:revive
71
        envFileMissing     = errors.New("Env file path is incorrect: ")                                                                                  //nolint:revive
72
)
73

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

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

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

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

123
const accessYourDeploymentFmt = `
124

125
 Access your Deployment:
126

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

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

137
        defer f.Close()
58✔
138

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

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

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

161
        return nil
58✔
162
}
163

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

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

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

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

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

187
        return versionID, nil
20✔
188
}
189

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

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

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

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

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

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

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

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

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

242
        // Check if git metadata is enabled (default: true)
243
        var deployGit *astrocore.DeployGit
38✔
244
        var commitMessage string
38✔
245
        gitMetadataEnabled := config.CFG.DeployGitMetadata.GetBool()
38✔
246
        if envVal := os.Getenv("ASTRO_DEPLOY_GIT_METADATA"); envVal != "" {
38✔
NEW
247
                gitMetadataEnabled = util.CheckEnvBool(envVal)
×
NEW
248
        }
×
249
        if gitMetadataEnabled {
76✔
250
                deployGit, commitMessage = retrieveLocalGitMetadata(deployInput.Path)
38✔
251
        }
38✔
252

253
        // Use commit message as description fallback
254
        description := deployInput.Description
38✔
255
        if description == "" {
76✔
256
                description = commitMessage
38✔
257
        }
38✔
258

259
        // Build the deploy request with git metadata
260
        createDeployRequest := astroplatformcore.CreateDeployRequest{
38✔
261
                Description: &description,
38✔
262
        }
38✔
263

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

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

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

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

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

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

326
                if !deployInfo.dagDeployEnabled {
16✔
327
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
1✔
328
                }
1✔
329

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

UNCOV
337
                        return err
×
338
                }
339

340
                // finish deploy
341
                err = finalizeDeploy(deployID, deployInfo.deploymentID, deployInfo.organizationID, dagTarballVersion, deployInfo.dagDeployEnabled, platformCoreClient)
14✔
342
                if err != nil {
14✔
UNCOV
343
                        return err
×
UNCOV
344
                }
×
345

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

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

×
UNCOV
358
                        return nil
×
359
                }
360

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

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

×
UNCOV
388
                        if !i {
×
UNCOV
389
                                fmt.Println("Canceling deploy...")
×
UNCOV
390
                                return nil
×
UNCOV
391
                        }
×
392
                }
393

394
                // Build our image
395
                runtimeVersion, err := buildImage(deployInput.Path, deployInfo.currentVersion, deployInfo.deployImage, deployInput.ImageName, deployInfo.organizationID, deployInput.BuildSecretString, deployInfo.dagDeployEnabled, deployInfo.isRemoteExecutionEnabled, platformCoreClient)
19✔
396
                if err != nil {
19✔
UNCOV
397
                        return err
×
UNCOV
398
                }
×
399

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

409
                repository := imageRepository
18✔
410
                // TODO: Resolve the edge case where two people push the same nextTag at the same time
18✔
411
                remoteImage := fmt.Sprintf("%s:%s", repository, nextTag)
18✔
412

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

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

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

443
                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✔
444
                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold("https://"+deploymentURL), ansi.Bold("https://"+deployInfo.webserverURL)))
16✔
445
        }
446

447
        return nil
29✔
448
}
449

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

464
        if deploymentID != "" && deploymentName != "" {
48✔
465
                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✔
466
        }
8✔
467

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

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

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

519
        containerHandler, err := containerHandlerInit(config.WorkingPath, envFile, "Dockerfile", namespace)
19✔
520
        if err != nil {
19✔
UNCOV
521
                return err
×
UNCOV
522
        }
×
523

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

547
                err = checkPytest(pytest, deployImage, buildSecretString, containerHandler)
6✔
548
                if err != nil {
6✔
UNCOV
549
                        return err
×
UNCOV
550
                }
×
551
        }
552
        return nil
16✔
553
}
554

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

566
        return nil
11✔
567
}
568

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

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

583
        fmt.Print("\nAll Pytests passed!\n")
11✔
584
        return err
11✔
585
}
586

587
func fetchDeploymentDetails(deploymentID, organizationID string, platformCoreClient astroplatformcore.CoreClient) (deploymentInfo, error) {
21✔
588
        resp, err := platformCoreClient.GetDeploymentWithResponse(httpContext.Background(), organizationID, deploymentID)
21✔
589
        if err != nil {
21✔
UNCOV
590
                return deploymentInfo{}, err
×
UNCOV
591
        }
×
592

593
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
21✔
594
        if err != nil {
22✔
595
                return deploymentInfo{}, err
1✔
596
        }
1✔
597

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

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

20✔
615
        return deploymentInfo{
20✔
616
                namespace:                namespace,
20✔
617
                deployImage:              deployImage,
20✔
618
                currentVersion:           currentVersion,
20✔
619
                organizationID:           organizationID,
20✔
620
                workspaceID:              workspaceID,
20✔
621
                webserverURL:             webserverURL,
20✔
622
                dagDeployEnabled:         dagDeployEnabled,
20✔
623
                desiredDagTarballVersion: desiredDagTarballVersion,
20✔
624
                cicdEnforcement:          cicdEnforcement,
20✔
625
                isRemoteExecutionEnabled: isRemoteExecutionEnabled,
20✔
626
        }, nil
20✔
627
}
628

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

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

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

667
                defer f.Close()
19✔
668

19✔
669
                if _, err := f.WriteString("\ndags/"); err != nil {
19✔
UNCOV
670
                        return err
×
UNCOV
671
                }
×
672

673
                dagsIgnoreSet = true
19✔
674
        }
675
        err = imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
19✔
676
        if err != nil {
19✔
UNCOV
677
                return err
×
UNCOV
678
        }
×
679

680
        // remove dags from .dockerignore file if we set it
681
        if dagsIgnoreSet {
38✔
682
                err = removeDagsFromDockerIgnore(fullpath)
19✔
683
                if err != nil {
19✔
UNCOV
684
                        return err
×
UNCOV
685
                }
×
686
        }
687

688
        return nil
19✔
689
}
690

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

34✔
694
        if imageName == "" {
61✔
695
                // Build our image
27✔
696
                fmt.Println(composeImageBuildingPromptMsg)
27✔
697

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

7✔
713
                err := imageHandler.TagLocalImage(imageName)
7✔
714
                if err != nil {
7✔
UNCOV
715
                        return "", err
×
UNCOV
716
                }
×
717
        }
718

719
        // parse dockerfile
720
        cmds, err := docker.ParseFile(filepath.Join(path, dockerfile))
33✔
721
        if err != nil {
34✔
722
                return "", errors.Wrapf(err, "failed to parse dockerfile: %s", filepath.Join(path, dockerfile))
1✔
723
        }
1✔
724

725
        DockerfileImage := docker.GetImageFromParsedFile(cmds)
32✔
726

32✔
727
        version, err = imageHandler.GetLabel("", runtimeImageLabel)
32✔
728
        if err != nil {
32✔
729
                fmt.Println("unable get runtime version from image")
×
730
        }
×
731

732
        if config.CFG.ShowWarnings.GetBool() && version == "" {
32✔
UNCOV
733
                fmt.Printf(warningInvalidImageNameMsg, DockerfileImage)
×
UNCOV
734
                fmt.Println("Canceling deploy...")
×
UNCOV
735
                os.Exit(1)
×
UNCOV
736
        }
×
737

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

751
        if !ValidRuntimeVersion(currentVersion, version, deploymentOptionsRuntimeVersions) {
31✔
UNCOV
752
                fmt.Println("Canceling deploy...")
×
UNCOV
753
                os.Exit(1)
×
UNCOV
754
        }
×
755

756
        WarnIfNonLatestVersion(version, httputil.NewHTTPClient())
31✔
757

31✔
758
        return version, nil
31✔
759
}
760

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

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

796
func ValidRuntimeVersion(currentVersion, tag string, deploymentOptionsRuntimeVersions []string) bool {
41✔
797
        // Allow old deployments which do not have runtimeVersion tag
41✔
798
        if currentVersion == "" {
42✔
799
                return true
1✔
800
        }
1✔
801

802
        // Check that the tag is not a downgrade
803
        if airflowversions.CompareRuntimeVersions(tag, currentVersion) < 0 {
43✔
804
                fmt.Printf("Cannot deploy a downgraded Astro Runtime version. Modify your Astro Runtime version to %s or higher in your Dockerfile\n", currentVersion)
3✔
805
                return false
3✔
806
        }
3✔
807

808
        // Check that the tag is supported by the deployment
809
        tagInDeploymentOptions := false
37✔
810
        for _, runtimeVersion := range deploymentOptionsRuntimeVersions {
100✔
811
                if airflowversions.CompareRuntimeVersions(tag, runtimeVersion) == 0 {
99✔
812
                        tagInDeploymentOptions = true
36✔
813
                        break
36✔
814
                }
815
        }
816
        if !tagInDeploymentOptions {
38✔
817
                fmt.Println("Cannot deploy an unsupported Astro Runtime version. Modify your Astro Runtime version to a supported version in your Dockerfile")
1✔
818
                fmt.Printf("Supported versions: %s\n", strings.Join(deploymentOptionsRuntimeVersions, ", "))
1✔
819
                return false
1✔
820
        }
1✔
821

822
        // If upgrading from Airflow 2 to Airflow 3, we require at least Runtime 12.0.0 (Airflow 2.10.0)
823
        currentVersionAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(currentVersion)
36✔
824
        tagAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(tag)
36✔
825
        if currentVersionAirflowMajorVersion == "2" && tagAirflowMajorVersion == "3" {
38✔
826
                if airflowversions.CompareRuntimeVersions(currentVersion, "12.0.0") < 0 {
3✔
827
                        fmt.Println("Can only upgrade deployment from Airflow 2 to Airflow 3 with deployment at Astro Runtime 12.0.0 or higher")
1✔
828
                        return false
1✔
829
                }
1✔
830
        }
831

832
        return true
35✔
833
}
834

835
func WarnIfNonLatestVersion(version string, httpClient *httputil.HTTPClient) {
34✔
836
        client := airflowversions.NewClient(httpClient, false, false)
34✔
837
        latestRuntimeVersion, err := airflowversions.GetDefaultImageTag(client, "", "", false)
34✔
838
        if err != nil {
36✔
839
                logger.Debugf("unable to get latest runtime version: %s", err)
2✔
840
                return
2✔
841
        }
2✔
842

843
        if airflowversions.CompareRuntimeVersions(version, latestRuntimeVersion) < 0 {
64✔
844
                fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
32✔
845
        }
32✔
846
}
847

848
// ClientBuildContext represents a prepared build context for client deployment
849
type ClientBuildContext struct {
850
        // TempDir is the temporary directory containing the build context
851
        TempDir string
852
        // CleanupFunc should be called to clean up the temporary directory
853
        CleanupFunc func()
854
}
855

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

865
        // Cleanup function to be called by the caller
866
        cleanup := func() {
16✔
867
                os.RemoveAll(tempBuildDir)
8✔
868
        }
8✔
869

870
        // Always return cleanup function if we created a temp directory, even on error
871
        buildContext := &ClientBuildContext{
8✔
872
                TempDir:     tempBuildDir,
8✔
873
                CleanupFunc: cleanup,
8✔
874
        }
8✔
875

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

883
        // Copy all project files to the temporary directory
884
        err = fileutil.CopyDirectory(sourcePath, tempBuildDir)
7✔
885
        if err != nil {
7✔
UNCOV
886
                return buildContext, fmt.Errorf("failed to copy project files to temporary directory: %w", err)
×
UNCOV
887
        }
×
888

889
        // Process client dependency files
890
        err = setupClientDependencyFiles(tempBuildDir)
7✔
891
        if err != nil {
9✔
892
                return buildContext, fmt.Errorf("failed to setup client dependency files: %w", err)
2✔
893
        }
2✔
894

895
        return buildContext, nil
5✔
896
}
897

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

10✔
906
        // Process client dependency files in the build directory
10✔
907
        for clientFile, regularFile := range dependencyFiles {
28✔
908
                clientPath := filepath.Join(buildDir, clientFile)
18✔
909
                regularPath := filepath.Join(buildDir, regularFile)
18✔
910

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

917
        return nil
7✔
918
}
919

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

927
        // Validate deployment runtime version if deployment ID is provided
928
        if err := validateClientImageRuntimeVersion(deployInput, platformCoreClient); err != nil {
6✔
UNCOV
929
                return err
×
UNCOV
930
        }
×
931

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

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

5✔
945
        // Build the full remote image name
5✔
946
        remoteImage := fmt.Sprintf("%s:%s", registryEndpoint, imageTag)
5✔
947

5✔
948
        // Create an image handler for building and pushing
5✔
949
        imageHandler := airflowImageHandler(remoteImage)
5✔
950

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

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

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

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

1006
                // Build the image from the prepared context
1007
                buildConfig := types.ImageBuildConfig{
3✔
1008
                        Path:            buildContext.TempDir,
3✔
1009
                        TargetPlatforms: targetPlatforms,
3✔
1010
                }
3✔
1011

3✔
1012
                err = imageHandler.Build("Dockerfile.client", deployInput.BuildSecretString, buildConfig)
3✔
1013
                if err != nil {
4✔
1014
                        return fmt.Errorf("failed to build client image: %w", err)
1✔
1015
                }
1✔
1016
        }
1017

1018
        // Push the image to the remote registry (assumes docker login was done externally)
1019
        fmt.Println("Pushing client image to configured remote registry")
3✔
1020
        _, err = imageHandler.Push(remoteImage, "", "", false)
3✔
1021
        if err != nil {
4✔
1022
                if errors.Is(err, airflow.ErrImagePush403) {
1✔
UNCOV
1023
                        fmt.Printf("\n--------------------------------\n")
×
UNCOV
1024
                        fmt.Printf("Failed to push client image to %s\n", registryEndpoint)
×
UNCOV
1025
                        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
1026
                        fmt.Printf("Please ensure that you have logged in to `%s` via `docker login` and try again\n\n", registryEndpoint)
×
UNCOV
1027
                }
×
1028
                return fmt.Errorf("failed to push client image: %w", err)
1✔
1029
        }
1030

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

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

2✔
1040
        return nil
2✔
1041
}
1042

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

1051
        // Get current context for organization info
1052
        c, err := config.GetCurrentContext()
9✔
1053
        if err != nil {
10✔
1054
                return errors.Wrap(err, "failed to get current context")
1✔
1055
        }
1✔
1056

1057
        // Get deployment information
1058
        deployInfo, err := fetchDeploymentDetails(deployInput.DeploymentID, c.Organization, platformCoreClient)
8✔
1059
        if err != nil {
9✔
1060
                return errors.Wrap(err, "failed to get deployment information")
1✔
1061
        }
1✔
1062

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

1069
        cmds, err := docker.ParseFile(dockerfileClientPath)
6✔
1070
        if err != nil {
7✔
1071
                return errors.Wrapf(err, "failed to parse Dockerfile.client: %s", dockerfileClientPath)
1✔
1072
        }
1✔
1073

1074
        baseImage := docker.GetImageFromParsedFile(cmds)
5✔
1075
        if baseImage == "" {
6✔
1076
                return errors.New("failed to find base image in Dockerfile.client")
1✔
1077
        }
1✔
1078

1079
        // Extract runtime version from the base image tag
1080
        clientRuntimeVersion, err := extractRuntimeVersionFromImage(baseImage)
4✔
1081
        if err != nil {
5✔
1082
                return errors.Wrapf(err, "failed to extract runtime version from client image %s", baseImage)
1✔
1083
        }
1✔
1084

1085
        // Compare versions
1086
        if airflowversions.CompareRuntimeVersions(clientRuntimeVersion, deployInfo.currentVersion) > 0 {
4✔
1087
                return fmt.Errorf(`client image runtime version validation failed:
1✔
1088

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

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

1✔
1095
This validation ensures compatibility between your client image and the deployment environment`,
1✔
1096
                        clientRuntimeVersion, deployInfo.currentVersion, deployInfo.currentVersion, clientRuntimeVersion)
1✔
1097
        }
1✔
1098

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

2✔
1102
        return nil
2✔
1103
}
1104

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

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

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

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