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

astronomer / astro-cli / 8444e6c8-e8dd-4f2b-85ba-bf81639b4625

15 Dec 2025 09:14PM UTC coverage: 33.143% (+0.01%) from 33.132%
8444e6c8-e8dd-4f2b-85ba-bf81639b4625

Pull #1990

circleci

jlaneve
feat: use v1beta1 API with git metadata for all deploys

Remove the config flag approach and update the deploy implementation
to use v1beta1 API exclusively with git metadata support. This requires
the backend changes from astro PR #35907 which adds git metadata support
to the v1beta1 CreateDeploy endpoint.

Changes:
- Regenerate astro-client-platform-core with git metadata types
- Update Deploy() to use v1beta1 CreateDeployWithResponse with git metadata
- Remove v1alpha1 code path and createDeployWithGit function
- Remove DeployGitMetadata config flag
- Fix IsHibernating pointer type change from client regeneration

🤖 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

14 of 25 new or added lines in 2 files covered. (56.0%)

185 existing lines in 4 files now uncovered.

20858 of 62934 relevant lines covered (33.14%)

8.51 hits per line

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

79.77
/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
        // Retrieve git metadata from local repository
243
        deployGit, commitMessage := retrieveLocalGitMetadata(deployInput.Path)
38✔
244

38✔
245
        // Use commit message as description fallback
38✔
246
        description := deployInput.Description
38✔
247
        if description == "" {
76✔
248
                description = commitMessage
38✔
249
        }
38✔
250

251
        // Build the deploy request with git metadata
252
        createDeployRequest := astroplatformcore.CreateDeployRequest{
38✔
253
                Description: &description,
38✔
254
        }
38✔
255

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

266
        // Add git metadata if available
267
        if deployGit != nil {
38✔
NEW
UNCOV
268
                createDeployRequest.Git = &astroplatformcore.CreateDeployGitRequest{
×
NEW
UNCOV
269
                        Provider:   astroplatformcore.GITHUB,
×
NEW
UNCOV
270
                        Account:    deployGit.Account,
×
NEW
UNCOV
271
                        Repo:       deployGit.Repo,
×
NEW
UNCOV
272
                        Branch:     deployGit.Branch,
×
NEW
273
                        CommitSha:  deployGit.CommitSha,
×
NEW
274
                        CommitUrl:  deployGit.CommitUrl,
×
NEW
275
                        AuthorName: deployGit.AuthorName,
×
NEW
276
                        Path:       deployGit.Path,
×
NEW
277
                }
×
NEW
278
        }
×
279

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

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

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

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

318
                if !deployInfo.dagDeployEnabled {
16✔
319
                        return fmt.Errorf(enableDagDeployMsg, deployInfo.deploymentID) //nolint
1✔
320
                }
1✔
321

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

UNCOV
329
                        return err
×
330
                }
331

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

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

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

×
UNCOV
350
                        return nil
×
351
                }
352

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

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

×
UNCOV
380
                        if !i {
×
UNCOV
381
                                fmt.Println("Canceling deploy...")
×
UNCOV
382
                                return nil
×
UNCOV
383
                        }
×
384
                }
385

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

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

401
                repository := imageRepository
18✔
402
                // TODO: Resolve the edge case where two people push the same nextTag at the same time
18✔
403
                remoteImage := fmt.Sprintf("%s:%s", repository, nextTag)
18✔
404

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

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

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

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

439
        return nil
29✔
440
}
441

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

456
        if deploymentID != "" && deploymentName != "" {
48✔
457
                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✔
458
        }
8✔
459

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

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

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

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

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

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

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

558
        return nil
11✔
559
}
560

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

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

575
        fmt.Print("\nAll Pytests passed!\n")
11✔
576
        return err
11✔
577
}
578

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

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

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

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

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

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

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

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

659
                defer f.Close()
19✔
660

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

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

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

680
        return nil
19✔
681
}
682

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

34✔
686
        if imageName == "" {
61✔
687
                // Build our image
27✔
688
                fmt.Println(composeImageBuildingPromptMsg)
27✔
689

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

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

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

717
        DockerfileImage := docker.GetImageFromParsedFile(cmds)
32✔
718

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

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

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

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

748
        WarnIfNonLatestVersion(version, httputil.NewHTTPClient())
31✔
749

31✔
750
        return version, nil
31✔
751
}
752

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

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

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

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

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

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

824
        return true
35✔
825
}
826

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

835
        if airflowversions.CompareRuntimeVersions(version, latestRuntimeVersion) < 0 {
64✔
836
                fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
32✔
837
        }
32✔
838
}
839

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

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

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

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

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

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

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

887
        return buildContext, nil
5✔
888
}
889

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

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

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

909
        return nil
7✔
910
}
911

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

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

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

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

5✔
937
        // Build the full remote image name
5✔
938
        remoteImage := fmt.Sprintf("%s:%s", registryEndpoint, imageTag)
5✔
939

5✔
940
        // Create an image handler for building and pushing
5✔
941
        imageHandler := airflowImageHandler(remoteImage)
5✔
942

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

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

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

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

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

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

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

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

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

2✔
1032
        return nil
2✔
1033
}
1034

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

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

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

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

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

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

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

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

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

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

1✔
1087
This validation ensures compatibility between your client image and the deployment environment`,
1✔
1088
                        clientRuntimeVersion, deployInfo.currentVersion, deployInfo.currentVersion, clientRuntimeVersion)
1✔
1089
        }
1✔
1090

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

2✔
1094
        return nil
2✔
1095
}
1096

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

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

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

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