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

astronomer / astro-cli / ceda42fe-4ba9-49b2-88f3-673231834647

15 Dec 2025 07:31PM UTC coverage: 33.135% (+0.003%) from 33.132%
ceda42fe-4ba9-49b2-88f3-673231834647

Pull #1990

circleci

jlaneve
fix: add commit message fallback for v1beta1 path and fix tests

- Add commit message fallback for description even when git metadata is disabled
- Disable git metadata in deploy tests to preserve existing mock behavior

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

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

38 of 90 new or added lines in 1 file covered. (42.22%)

1 existing line in 1 file now uncovered.

20869 of 62982 relevant lines covered (33.13%)

8.51 hits per line

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

76.43
/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
        // Determine deploy type
243
        var deployType string
38✔
244
        switch {
38✔
245
        case deployInput.Dags:
18✔
246
                deployType = "DAG"
18✔
247
        case deployInput.Image:
1✔
248
                deployType = "IMAGE"
1✔
249
        default:
19✔
250
                deployType = "IMAGE_AND_DAG"
19✔
251
        }
252

253
        // Check if git metadata is enabled via config or environment variable
254
        useGitMetadata := config.CFG.DeployGitMetadata.GetBool() || util.CheckEnvBool(os.Getenv("ASTRO_DEPLOY_GIT_METADATA"))
38✔
255

38✔
256
        var deployID string
38✔
257
        var imageRepository string
38✔
258
        if useGitMetadata {
38✔
NEW
259
                // Use v1alpha1 API with git metadata support
×
NEW
260
                deploy, err := createDeployWithGit(deployInfo.organizationID, deployInfo.deploymentID, deployType, deployInput.Description, deployInput.Path, coreClient)
×
NEW
261
                if err != nil {
×
NEW
262
                        return err
×
NEW
263
                }
×
NEW
264
                deployID = deploy.Id
×
NEW
265
                imageRepository = deploy.ImageRepository
×
NEW
266
                if deploy.DagsUploadUrl != nil {
×
NEW
267
                        dagsUploadURL = *deploy.DagsUploadUrl
×
NEW
268
                } else {
×
NEW
269
                        dagsUploadURL = ""
×
NEW
270
                }
×
UNCOV
271
                nextTag = deploy.ImageTag
×
272
        } else {
38✔
273
                // Use v1beta1 API (default behavior)
38✔
274
                // Still try to use commit message as description fallback
38✔
275
                description := deployInput.Description
38✔
276
                if description == "" {
76✔
277
                        _, commitMessage := retrieveLocalGitMetadata(deployInput.Path)
38✔
278
                        description = commitMessage
38✔
279
                }
38✔
280
                createDeployRequest := astroplatformcore.CreateDeployRequest{
38✔
281
                        Description: &description,
38✔
282
                }
38✔
283
                switch {
38✔
284
                case deployInput.Dags:
18✔
285
                        createDeployRequest.Type = astroplatformcore.CreateDeployRequestTypeDAGONLY
18✔
286
                case deployInput.Image:
1✔
287
                        createDeployRequest.Type = astroplatformcore.CreateDeployRequestTypeIMAGEONLY
1✔
288
                default:
19✔
289
                        createDeployRequest.Type = astroplatformcore.CreateDeployRequestTypeIMAGEANDDAG
19✔
290
                }
291
                deploy, err := createDeploy(deployInfo.organizationID, deployInfo.deploymentID, createDeployRequest, platformCoreClient)
38✔
292
                if err != nil {
38✔
NEW
293
                        return err
×
NEW
294
                }
×
295
                deployID = deploy.Id
38✔
296
                imageRepository = deploy.ImageRepository
38✔
297
                if deploy.DagsUploadUrl != nil {
76✔
298
                        dagsUploadURL = *deploy.DagsUploadUrl
38✔
299
                } else {
38✔
NEW
300
                        dagsUploadURL = ""
×
NEW
301
                }
×
302
                if deploy.ImageTag != "" {
38✔
NEW
303
                        nextTag = deploy.ImageTag
×
304
                } else {
38✔
305
                        nextTag = ""
38✔
306
                }
38✔
307
        }
308

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

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

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

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

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

341
                        return err
×
342
                }
343

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

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

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

×
362
                        return nil
×
363
                }
364

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

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

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

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

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

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

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

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

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

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

451
        return nil
29✔
452
}
453

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

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

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

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

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

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

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

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

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

570
        return nil
11✔
571
}
572

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

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

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

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

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

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

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

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

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

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

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

671
                defer f.Close()
19✔
672

19✔
673
                if _, err := f.WriteString("\ndags/"); err != nil {
19✔
674
                        return err
×
675
                }
×
676

677
                dagsIgnoreSet = true
19✔
678
        }
679
        err = imageHandler.Build("", buildSecretString, types.ImageBuildConfig{Path: path, TargetPlatforms: deployImagePlatformSupport})
19✔
680
        if err != nil {
19✔
681
                return err
×
682
        }
×
683

684
        // remove dags from .dockerignore file if we set it
685
        if dagsIgnoreSet {
38✔
686
                err = removeDagsFromDockerIgnore(fullpath)
19✔
687
                if err != nil {
19✔
688
                        return err
×
689
                }
×
690
        }
691

692
        return nil
19✔
693
}
694

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

34✔
698
        if imageName == "" {
61✔
699
                // Build our image
27✔
700
                fmt.Println(composeImageBuildingPromptMsg)
27✔
701

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

7✔
717
                err := imageHandler.TagLocalImage(imageName)
7✔
718
                if err != nil {
7✔
719
                        return "", err
×
720
                }
×
721
        }
722

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

729
        DockerfileImage := docker.GetImageFromParsedFile(cmds)
32✔
730

32✔
731
        version, err = imageHandler.GetLabel("", runtimeImageLabel)
32✔
732
        if err != nil {
32✔
733
                fmt.Println("unable get runtime version from image")
×
734
        }
×
735

736
        if config.CFG.ShowWarnings.GetBool() && version == "" {
32✔
737
                fmt.Printf(warningInvalidImageNameMsg, DockerfileImage)
×
738
                fmt.Println("Canceling deploy...")
×
739
                os.Exit(1)
×
740
        }
×
741

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

755
        if !ValidRuntimeVersion(currentVersion, version, deploymentOptionsRuntimeVersions) {
31✔
756
                fmt.Println("Canceling deploy...")
×
757
                os.Exit(1)
×
758
        }
×
759

760
        WarnIfNonLatestVersion(version, httputil.NewHTTPClient())
31✔
761

31✔
762
        return version, nil
31✔
763
}
764

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

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

800
// createDeployWithGit creates a deploy using the v1alpha1 API which supports git metadata.
801
// This function retrieves git metadata from the local repository and includes it in the deploy request.
NEW
802
func createDeployWithGit(organizationID, deploymentID, deployType, description, path string, coreClient astrocore.CoreClient) (*astrocore.Deploy, error) {
×
NEW
803
        // Retrieve git metadata from local repository
×
NEW
804
        deployGit, commitMessage := retrieveLocalGitMetadata(path)
×
NEW
805

×
NEW
806
        // Fall back to commit message if no description provided
×
NEW
807
        if description == "" && commitMessage != "" {
×
NEW
808
                description = commitMessage
×
NEW
809
        }
×
810

NEW
811
        request := astrocore.CreateDeployRequest{
×
NEW
812
                Type: astrocore.CreateDeployRequestType(deployType),
×
NEW
813
        }
×
NEW
814
        if description != "" {
×
NEW
815
                request.Description = &description
×
NEW
816
        }
×
817

818
        // Add git metadata if available
NEW
819
        if deployGit != nil {
×
NEW
820
                request.Git = &astrocore.CreateDeployGitRequest{
×
NEW
821
                        Provider:   astrocore.CreateDeployGitRequestProvider(deployGit.Provider),
×
NEW
822
                        Account:    deployGit.Account,
×
NEW
823
                        Repo:       deployGit.Repo,
×
NEW
824
                        Branch:     deployGit.Branch,
×
NEW
825
                        CommitSha:  deployGit.CommitSha,
×
NEW
826
                        CommitUrl:  deployGit.CommitUrl,
×
NEW
827
                        AuthorName: deployGit.AuthorName,
×
NEW
828
                        Path:       deployGit.Path,
×
NEW
829
                }
×
NEW
830
        }
×
831

NEW
832
        resp, err := coreClient.CreateDeployWithResponse(httpContext.Background(), organizationID, deploymentID, request)
×
NEW
833
        if err != nil {
×
NEW
834
                return nil, err
×
NEW
835
        }
×
NEW
836
        err = astrocore.NormalizeAPIError(resp.HTTPResponse, resp.Body)
×
NEW
837
        if err != nil {
×
NEW
838
                return nil, err
×
NEW
839
        }
×
NEW
840
        return resp.JSON200, err
×
841
}
842

843
func ValidRuntimeVersion(currentVersion, tag string, deploymentOptionsRuntimeVersions []string) bool {
41✔
844
        // Allow old deployments which do not have runtimeVersion tag
41✔
845
        if currentVersion == "" {
42✔
846
                return true
1✔
847
        }
1✔
848

849
        // Check that the tag is not a downgrade
850
        if airflowversions.CompareRuntimeVersions(tag, currentVersion) < 0 {
43✔
851
                fmt.Printf("Cannot deploy a downgraded Astro Runtime version. Modify your Astro Runtime version to %s or higher in your Dockerfile\n", currentVersion)
3✔
852
                return false
3✔
853
        }
3✔
854

855
        // Check that the tag is supported by the deployment
856
        tagInDeploymentOptions := false
37✔
857
        for _, runtimeVersion := range deploymentOptionsRuntimeVersions {
100✔
858
                if airflowversions.CompareRuntimeVersions(tag, runtimeVersion) == 0 {
99✔
859
                        tagInDeploymentOptions = true
36✔
860
                        break
36✔
861
                }
862
        }
863
        if !tagInDeploymentOptions {
38✔
864
                fmt.Println("Cannot deploy an unsupported Astro Runtime version. Modify your Astro Runtime version to a supported version in your Dockerfile")
1✔
865
                fmt.Printf("Supported versions: %s\n", strings.Join(deploymentOptionsRuntimeVersions, ", "))
1✔
866
                return false
1✔
867
        }
1✔
868

869
        // If upgrading from Airflow 2 to Airflow 3, we require at least Runtime 12.0.0 (Airflow 2.10.0)
870
        currentVersionAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(currentVersion)
36✔
871
        tagAirflowMajorVersion := airflowversions.AirflowMajorVersionForRuntimeVersion(tag)
36✔
872
        if currentVersionAirflowMajorVersion == "2" && tagAirflowMajorVersion == "3" {
38✔
873
                if airflowversions.CompareRuntimeVersions(currentVersion, "12.0.0") < 0 {
3✔
874
                        fmt.Println("Can only upgrade deployment from Airflow 2 to Airflow 3 with deployment at Astro Runtime 12.0.0 or higher")
1✔
875
                        return false
1✔
876
                }
1✔
877
        }
878

879
        return true
35✔
880
}
881

882
func WarnIfNonLatestVersion(version string, httpClient *httputil.HTTPClient) {
34✔
883
        client := airflowversions.NewClient(httpClient, false, false)
34✔
884
        latestRuntimeVersion, err := airflowversions.GetDefaultImageTag(client, "", "", false)
34✔
885
        if err != nil {
36✔
886
                logger.Debugf("unable to get latest runtime version: %s", err)
2✔
887
                return
2✔
888
        }
2✔
889

890
        if airflowversions.CompareRuntimeVersions(version, latestRuntimeVersion) < 0 {
64✔
891
                fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
32✔
892
        }
32✔
893
}
894

895
// ClientBuildContext represents a prepared build context for client deployment
896
type ClientBuildContext struct {
897
        // TempDir is the temporary directory containing the build context
898
        TempDir string
899
        // CleanupFunc should be called to clean up the temporary directory
900
        CleanupFunc func()
901
}
902

903
// prepareClientBuildContext creates a temporary build context with client dependency files
904
// This avoids modifying the original project files, preventing race conditions with concurrent deployments.
905
func prepareClientBuildContext(sourcePath string) (*ClientBuildContext, error) {
8✔
906
        // Create a temporary directory for the build context
8✔
907
        tempBuildDir, err := os.MkdirTemp("", "astro-client-build-*")
8✔
908
        if err != nil {
8✔
909
                return nil, fmt.Errorf("failed to create temporary build directory: %w", err)
×
910
        }
×
911

912
        // Cleanup function to be called by the caller
913
        cleanup := func() {
16✔
914
                os.RemoveAll(tempBuildDir)
8✔
915
        }
8✔
916

917
        // Always return cleanup function if we created a temp directory, even on error
918
        buildContext := &ClientBuildContext{
8✔
919
                TempDir:     tempBuildDir,
8✔
920
                CleanupFunc: cleanup,
8✔
921
        }
8✔
922

8✔
923
        // Check if source directory exists first
8✔
924
        if exists, err := fileutil.Exists(sourcePath, nil); err != nil {
8✔
925
                return buildContext, fmt.Errorf("failed to check if source directory exists: %w", err)
×
926
        } else if !exists {
9✔
927
                return buildContext, fmt.Errorf("source directory does not exist: %s", sourcePath)
1✔
928
        }
1✔
929

930
        // Copy all project files to the temporary directory
931
        err = fileutil.CopyDirectory(sourcePath, tempBuildDir)
7✔
932
        if err != nil {
7✔
933
                return buildContext, fmt.Errorf("failed to copy project files to temporary directory: %w", err)
×
934
        }
×
935

936
        // Process client dependency files
937
        err = setupClientDependencyFiles(tempBuildDir)
7✔
938
        if err != nil {
9✔
939
                return buildContext, fmt.Errorf("failed to setup client dependency files: %w", err)
2✔
940
        }
2✔
941

942
        return buildContext, nil
5✔
943
}
944

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

10✔
953
        // Process client dependency files in the build directory
10✔
954
        for clientFile, regularFile := range dependencyFiles {
28✔
955
                clientPath := filepath.Join(buildDir, clientFile)
18✔
956
                regularPath := filepath.Join(buildDir, regularFile)
18✔
957

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

964
        return nil
7✔
965
}
966

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2✔
1087
        return nil
2✔
1088
}
1089

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

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

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

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

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

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

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

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

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

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

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

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

2✔
1149
        return nil
2✔
1150
}
1151

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

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

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

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