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

astronomer / astro-cli / c5fe8d76-4155-48e3-92e2-eb6dcecec39d

15 Dec 2025 07:48PM UTC coverage: 33.134% (+0.002%) from 33.132%
c5fe8d76-4155-48e3-92e2-eb6dcecec39d

Pull #1990

circleci

jlaneve
refactor: use constants for deploy types instead of string literals

Use astrocore.CreateDeployRequestType constants (DAG, IMAGE) instead of
raw strings for better type safety.

🤖 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.

20868 of 62981 relevant lines covered (33.13%)

8.51 hits per line

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

76.41
/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 for v1alpha1 API (uses IMAGE, DAG, BUNDLE)
243
        var deployTypeV1Alpha1 astrocore.CreateDeployRequestType
38✔
244
        switch {
38✔
245
        case deployInput.Dags:
18✔
246
                deployTypeV1Alpha1 = astrocore.CreateDeployRequestTypeDAG
18✔
247
        default:
20✔
248
                // v1alpha1 uses IMAGE for both image-only and image+dag deploys
20✔
249
                deployTypeV1Alpha1 = astrocore.CreateDeployRequestTypeIMAGE
20✔
250
        }
251

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

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

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

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

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

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

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

340
                        return err
×
341
                }
342

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

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

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

×
361
                        return nil
×
362
                }
363

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

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

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

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

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

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

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

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

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

446
                fmt.Println("Successfully pushed image to Astronomer registry. Navigate to the Astronomer UI for confirmation that your deploy was successful. To deploy dags only run astro deploy --dags." +
16✔
447
                        fmt.Sprintf(accessYourDeploymentFmt, ansi.Bold("https://"+deploymentURL), ansi.Bold("https://"+deployInfo.webserverURL)))
16✔
448
        }
449

450
        return nil
29✔
451
}
452

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

467
        if deploymentID != "" && deploymentName != "" {
48✔
468
                fmt.Printf("Both a Deployment ID and Deployment name have been supplied. The Deployment ID %s will be used for the Deploy\n", deploymentID)
8✔
469
        }
8✔
470

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

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

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

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

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

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

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

569
        return nil
11✔
570
}
571

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

578
        exitCode, err := containerHandler.Pytest(pytestFile, "", deployImage, "", buildSecretString)
14✔
579
        if err != nil {
17✔
580
                if strings.Contains(exitCode, "1") { // exit code is 1 meaning tests failed
4✔
581
                        return errors.New("at least 1 pytest in your tests directory failed. Fix the issues listed or rerun the command without the '--pytest' flag to deploy")
1✔
582
                }
1✔
583
                return errors.Wrap(err, "Something went wrong while Pytesting your DAGs,\nif the issue persists rerun the command without the '--pytest' flag to deploy")
2✔
584
        }
585

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

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

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

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

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

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

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

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

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

670
                defer f.Close()
19✔
671

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

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

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

691
        return nil
19✔
692
}
693

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

878
        return true
35✔
879
}
880

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

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

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

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

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

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

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

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

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

941
        return buildContext, nil
5✔
942
}
943

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

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

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

963
        return nil
7✔
964
}
965

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2✔
1086
        return nil
2✔
1087
}
1088

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

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

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

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

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

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

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

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

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

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

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

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

2✔
1148
        return nil
2✔
1149
}
1150

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

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

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

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