Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions airflow/docker_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import (
)

const (
pushingImagePrompt = "Pushing image to Astronomer registry"
astroRunContainer = "astro-run"
pullingImagePrompt = "Pulling image from Astronomer registry"
prefix = "Bearer "
Expand Down Expand Up @@ -321,7 +320,7 @@ func (d *DockerImage) Push(remoteImage, username, token string, getImageRepoSha
}

// Push image to registry
fmt.Println(pushingImagePrompt)
// Note: Caller is responsible for printing appropriate message

configFile := cliConfig.LoadDefaultConfigFile(os.Stderr)

Expand Down
27 changes: 27 additions & 0 deletions airflow/docker_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"context"
"fmt"
"os"
"strings"

"github.com/astronomer/astro-cli/airflow/runtimes"
"github.com/astronomer/astro-cli/pkg/logger"
cliConfig "github.com/docker/cli/cli/config"
cliTypes "github.com/docker/cli/cli/config/types"
Expand All @@ -13,6 +15,9 @@ import (
"github.com/docker/docker/registry"
)

// DockerLogin is a testable variable that holds the docker login function
var DockerLogin = dockerLogin

type DockerRegistry struct {
registry string
cli DockerRegistryAPI
Expand Down Expand Up @@ -73,3 +78,25 @@ func (d *DockerRegistry) Login(username, token string) error {
}
return nil
}

// dockerLogin performs docker login using bash command instead of Docker API
// This is useful for OAuth-based registries that require special authentication flows
func dockerLogin(registryName, username, token string) error {
containerRuntime, err := runtimes.GetContainerRuntimeBinary()
if err != nil {
return err
}

if username != "" && token != "" {
// Remove Bearer prefix if present (consistent with pushWithBash)
const prefix = "Bearer "
pass := strings.TrimPrefix(token, prefix)
cmd := "echo \"" + pass + "\"" + " | " + containerRuntime + " login " + registryName + " -u " + username + " --password-stdin"
err = cmdExec("bash", nil, os.Stderr, "-c", cmd)
if err != nil {
return fmt.Errorf("docker login failed: %w", err)
}
}

return nil
}
104 changes: 104 additions & 0 deletions airflow/docker_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package airflow

import (
"context"
"errors"
"io"

"github.com/astronomer/astro-cli/airflow/mocks"
"github.com/astronomer/astro-cli/airflow/runtimes"
"github.com/docker/docker/api/types/registry"
"github.com/stretchr/testify/mock"
)
Expand Down Expand Up @@ -45,3 +48,104 @@ func (s *Suite) TestRegistryLogin() {
mockClient.AssertExpectations(s.T())
})
}

func (s *Suite) TestDockerLogin() {
// Store original cmdExec to restore after tests
originalCmdExec := cmdExec
defer func() {
cmdExec = originalCmdExec
}()

s.Run("success with credentials", func() {
var capturedCmd string
var capturedArgs []string
cmdExec = func(cmd string, stdout, stderr io.Writer, args ...string) error {
capturedCmd = cmd
capturedArgs = args
return nil
}

err := DockerLogin("test.registry.com", "testuser", "testtoken")
s.NoError(err)
s.Equal("bash", capturedCmd)
s.Equal([]string{"-c", "echo \"testtoken\" | docker login test.registry.com -u testuser --password-stdin"}, capturedArgs)
})

s.Run("success with bearer token", func() {
var capturedCmd string
var capturedArgs []string
cmdExec = func(cmd string, stdout, stderr io.Writer, args ...string) error {
capturedCmd = cmd
capturedArgs = args
return nil
}

err := DockerLogin("test.registry.com", "testuser", "Bearer testtoken123")
s.NoError(err)
s.Equal("bash", capturedCmd)
// Should strip Bearer prefix
s.Equal([]string{"-c", "echo \"testtoken123\" | docker login test.registry.com -u testuser --password-stdin"}, capturedArgs)
})

s.Run("no operation with empty credentials", func() {
cmdExecCalled := false
cmdExec = func(cmd string, stdout, stderr io.Writer, args ...string) error {
cmdExecCalled = true
return nil
}

err := DockerLogin("test.registry.com", "", "")
s.NoError(err)
s.False(cmdExecCalled, "cmdExec should not be called with empty credentials")
})

s.Run("no operation with empty username", func() {
cmdExecCalled := false
cmdExec = func(cmd string, stdout, stderr io.Writer, args ...string) error {
cmdExecCalled = true
return nil
}

err := DockerLogin("test.registry.com", "", "testtoken")
s.NoError(err)
s.False(cmdExecCalled, "cmdExec should not be called with empty username")
})

s.Run("no operation with empty token", func() {
cmdExecCalled := false
cmdExec = func(cmd string, stdout, stderr io.Writer, args ...string) error {
cmdExecCalled = true
return nil
}

err := DockerLogin("test.registry.com", "testuser", "")
s.NoError(err)
s.False(cmdExecCalled, "cmdExec should not be called with empty token")
})

s.Run("docker login command fails", func() {
expectedErr := errors.New("docker login failed")
cmdExec = func(cmd string, stdout, stderr io.Writer, args ...string) error {
return expectedErr
}

err := DockerLogin("test.registry.com", "testuser", "testtoken")
s.Error(err)
s.Contains(err.Error(), "docker login failed")
})

s.Run("container runtime not found", func() {
// Mock runtimes.GetContainerRuntimeBinary to return error
originalFunc := runtimes.GetContainerRuntimeBinary
runtimes.GetContainerRuntimeBinary = func() (string, error) {
return "", errors.New("container runtime not found")
}
defer func() {
runtimes.GetContainerRuntimeBinary = originalFunc
}()

err := DockerLogin("test.registry.com", "testuser", "testtoken")
s.Error(err)
s.Contains(err.Error(), "container runtime not found")
})
}
90 changes: 90 additions & 0 deletions cloud/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"time"

"github.com/astronomer/astro-cli/airflow"
"github.com/astronomer/astro-cli/airflow/types"
Expand Down Expand Up @@ -111,6 +112,14 @@ type InputDeploy struct {
ForceUpgradeToAF3 bool
}

// InputClientDeploy contains inputs for client image deployments
type InputClientDeploy struct {
Path string
ImageName string
Platform string
BuildSecretString string
}

const accessYourDeploymentFmt = `

Access your Deployment:
Expand Down Expand Up @@ -365,6 +374,7 @@ func Deploy(deployInput InputDeploy, platformCoreClient astroplatformcore.CoreCl
remoteImage := fmt.Sprintf("%s:%s", repository, nextTag)

imageHandler := airflowImageHandler(deployInfo.deployImage)
fmt.Println("Pushing image to Astronomer registry")
_, err = imageHandler.Push(remoteImage, registryUsername, c.Token, false)
if err != nil {
return err
Expand Down Expand Up @@ -801,3 +811,83 @@ func WarnIfNonLatestVersion(version string, httpClient *httputil.HTTPClient) {
fmt.Printf("WARNING! You are currently running Astro Runtime Version %s\nConsider upgrading to the latest version, Astro Runtime %s\n", version, latestRuntimeVersion)
}
}

// DeployClientImage handles the client deploy functionality
func DeployClientImage(deployInput InputClientDeploy) error { //nolint:gocritic
c, err := config.GetCurrentContext()
if err != nil {
return errors.Wrap(err, "failed to get current context")
}

fmt.Printf(deploymentHeaderMsg, "Astro")

// Get the remote client registry endpoint from config
registryEndpoint := config.CFG.RemoteClientRegistry.GetString()
if registryEndpoint == "" {
return errors.New("remote client registry is not configured. Please run 'astro config set remote.client_registry <endpoint>' to configure the registry")
}

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

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

// Create an image handler for building and pushing
imageHandler := airflowImageHandler(remoteImage)

if deployInput.ImageName != "" {
// Use the provided local image (tag will be ignored, remote tag is always timestamp-based)
fmt.Println("Using provided image:", deployInput.ImageName)
err := imageHandler.TagLocalImage(deployInput.ImageName)
if err != nil {
return fmt.Errorf("failed to tag local image: %w", err)
}
} else {
// Authenticate with the base image registry before building
// This is needed because Dockerfile.client uses base images from a private registry
baseImageRegistry := config.CFG.RemoteBaseImageRegistry.GetString()
err := airflow.DockerLogin(baseImageRegistry, registryUsername, c.Token)
if err != nil {
return fmt.Errorf("failed to authenticate with registry %s: %w", baseImageRegistry, err)
}

// Build the client image from the current directory
// Determine target platforms for client deploy
var targetPlatforms []string
if deployInput.Platform != "" {
// Parse comma-separated platforms from --platform flag
targetPlatforms = strings.Split(deployInput.Platform, ",")
// Trim whitespace from each platform
for i, platform := range targetPlatforms {
targetPlatforms[i] = strings.TrimSpace(platform)
}
fmt.Printf("Building client image for platforms: %s\n", strings.Join(targetPlatforms, ", "))
} else {
// Use empty slice to let Docker build for host platform by default
targetPlatforms = []string{}
fmt.Println("Building client image for host platform")
}

buildConfig := types.ImageBuildConfig{
Path: deployInput.Path,
TargetPlatforms: targetPlatforms,
}

err = imageHandler.Build("Dockerfile.client", deployInput.BuildSecretString, buildConfig)
if err != nil {
return fmt.Errorf("failed to build client image: %w", err)
}
}

// Push the image to the remote registry (assumes docker login was done externally)
fmt.Println("Pushing client image to configured remote registry")
_, err = imageHandler.Push(remoteImage, "", "", false)
if err != nil {
return fmt.Errorf("failed to push client image: %w", err)
}

fmt.Printf("Successfully pushed client image to %s\n", ansi.Bold(remoteImage))
return nil
}
Loading