Skip to content

Commit

Permalink
refactor: Improve image synchronization and container management
Browse files Browse the repository at this point in the history
  • Loading branch information
yarlson committed Dec 3, 2024
1 parent b4ff9b7 commit cc7aa68
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 164 deletions.
25 changes: 13 additions & 12 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,19 @@ type Server struct {
}

type Service struct {
Name string `yaml:"name" validate:"required"`
Image string `yaml:"image"`
Port int `yaml:"port" validate:"required,min=1,max=65535"`
Path string `yaml:"path"`
HealthCheck *HealthCheck `yaml:"health_check"`
Routes []Route `yaml:"routes" validate:"required,dive"`
Volumes []string `yaml:"volumes" validate:"dive,volume_reference"`
Command string `yaml:"command"`
Entrypoint []string `yaml:"entrypoint"`
Env []string `yaml:"env"`
Forwards []string `yaml:"forwards"`
Recreate bool `yaml:"recreate"`
Name string `yaml:"name" validate:"required"`
Image string `yaml:"image"`
ImageUpdated bool
Port int `yaml:"port" validate:"required,min=1,max=65535"`
Path string `yaml:"path"`
HealthCheck *HealthCheck `yaml:"health_check"`
Routes []Route `yaml:"routes" validate:"required,dive"`
Volumes []string `yaml:"volumes" validate:"dive,volume_reference"`
Command string `yaml:"command"`
Entrypoint []string `yaml:"entrypoint"`
Env []string `yaml:"env"`
Forwards []string `yaml:"forwards"`
Recreate bool `yaml:"recreate"`
}

type HealthCheck struct {
Expand Down
155 changes: 91 additions & 64 deletions pkg/deployment/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ const (

type Runner interface {
CopyFile(ctx context.Context, from, to string) error
GetHost() string
Host() string
RunCommand(ctx context.Context, command string, args ...string) (io.ReadCloser, error)
}

type ImageSyncer interface {
Sync(ctx context.Context, image string) error
Sync(ctx context.Context, image string) (bool, error)
CompareImages(ctx context.Context, image string) (bool, error)
}

type Deployment struct {
Expand All @@ -42,7 +43,7 @@ func NewDeployment(runner Runner, syncer ImageSyncer, sm *console.SpinnerManager
}

func (d *Deployment) Deploy(ctx context.Context, project string, cfg *config.Config) error {
hostname := d.runner.GetHost()
hostname := d.runner.Host()

// Create project network
spinner := d.sm.AddSpinner("network", fmt.Sprintf("[%s] Creating network...", hostname))
Expand Down Expand Up @@ -76,7 +77,7 @@ func (d *Deployment) Deploy(ctx context.Context, project string, cfg *config.Con
}

func (d *Deployment) createVolumes(ctx context.Context, project string, volumes []string) error {
hostname := d.runner.GetHost()
hostname := d.runner.Host()

for _, volume := range volumes {
spinner := d.sm.AddSpinner("volume", fmt.Sprintf("[%s] Creating volume %s", hostname, volume))
Expand All @@ -93,7 +94,7 @@ func (d *Deployment) createVolumes(ctx context.Context, project string, volumes
}

func (d *Deployment) deployDependencies(ctx context.Context, project string, dependencies []config.Dependency) error {
hostname := d.runner.GetHost()
hostname := d.runner.Host()
var wg sync.WaitGroup
errChan := make(chan error, len(dependencies))

Expand Down Expand Up @@ -130,7 +131,7 @@ func (d *Deployment) deployDependencies(ctx context.Context, project string, dep
}

func (d *Deployment) deployServices(ctx context.Context, project string, services []config.Service) error {
hostname := d.runner.GetHost()
hostname := d.runner.Host()
var wg sync.WaitGroup
errChan := make(chan error, len(services))

Expand Down Expand Up @@ -167,7 +168,7 @@ func (d *Deployment) deployServices(ctx context.Context, project string, service
}

func (d *Deployment) startProxy(ctx context.Context, project string, cfg *config.Config) error {
hostname := d.runner.GetHost()
hostname := d.runner.Host()

// Prepare project folder
projectPath, err := d.prepareProjectFolder(project)
Expand Down Expand Up @@ -237,10 +238,6 @@ func (d *Deployment) startProxy(ctx context.Context, project string, cfg *config
}

func (d *Deployment) startDependency(project string, dependency *config.Dependency) error {
if _, err := d.pullImage(dependency.Image); err != nil {
return fmt.Errorf("failed to pull image for %s: %v", dependency.Image, err)
}

service := &config.Service{
Name: dependency.Name,
Image: dependency.Image,
Expand All @@ -255,16 +252,6 @@ func (d *Deployment) startDependency(project string, dependency *config.Dependen
}

func (d *Deployment) installService(project string, service *config.Service) error {
if service.Image != "" {
if _, err := d.pullImage(service.Image); err != nil {
return fmt.Errorf("failed to pull image for %s: %v", service.Image, err)
}
} else {
if err := d.syncer.Sync(context.Background(), fmt.Sprintf("%s-%s", project, service.Name)); err != nil {
return fmt.Errorf("failed to sync service %s for %s: %v", service.Name, service.Image, err)
}
}

if err := d.createContainer(project, service, ""); err != nil {
return fmt.Errorf("failed to start container for %s: %v", service.Image, err)
}
Expand All @@ -281,16 +268,6 @@ func (d *Deployment) installService(project string, service *config.Service) err
func (d *Deployment) updateService(project string, service *config.Service) error {
svcName := service.Name

if service.Image != "" {
if _, err := d.pullImage(service.Image); err != nil {
return fmt.Errorf("failed to pull new image for %s: %v", svcName, err)
}
} else {
if err := d.syncer.Sync(context.Background(), fmt.Sprintf("%s-%s", project, service.Name)); err != nil {
return fmt.Errorf("failed to sync service %s for %s: %v", service.Name, service.Image, err)
}
}

if service.Recreate {
if err := d.recreateService(project, service); err != nil {
return fmt.Errorf("failed to recreate service %s: %w", service.Name, err)
Expand Down Expand Up @@ -369,15 +346,15 @@ type containerInfo struct {
}

func (d *Deployment) getContainerID(project, service string) (string, error) {
info, err := d.getContainerInfo(service, project)
info, err := d.getContainerInfo(project, service)
if err != nil {
return "", err
}

return info.ID, err
}

func (d *Deployment) getContainerInfo(service, network string) (*containerInfo, error) {
func (d *Deployment) getContainerInfo(network, service string) (*containerInfo, error) {
output, err := d.runCommand(context.Background(), "docker", "ps", "-aq", "--filter", fmt.Sprintf("network=%s", network))
if err != nil {
return nil, fmt.Errorf("failed to get container IDs: %w", err)
Expand Down Expand Up @@ -637,69 +614,119 @@ func (d *Deployment) prepareNginxConfig(cfg *config.Config, projectPath string)
return configPath, d.runner.CopyFile(context.Background(), tmpFile.Name(), filepath.Join(configPath, "default.conf"))
}

func (d *Deployment) serviceChanged(project string, service *config.Service) (bool, error) {
containerInfo, err := d.getContainerInfo(service.Name, project)
func (d *Deployment) deployService(project string, service *config.Service) error {
err := d.updateImage(project, service)
if err != nil {
return false, fmt.Errorf("failed to get container info: %w", err)
return err
}

hash, err := service.Hash()
containerStatus, err := d.getContainerStatus(project, service.Name)
if err != nil {
return false, fmt.Errorf("failed to generate config hash: %w", err)
return err
}

return containerInfo.Config.Labels["ftl.config-hash"] != hash, nil
}
if containerStatus == ContainerStatusNotFound {
if err := d.installService(project, service); err != nil {
return fmt.Errorf("failed to install service %s: %w", service.Name, err)
}

func (d *Deployment) deployService(project string, service *config.Service) error {
imageName := service.Image
if imageName == "" {
imageName = fmt.Sprintf("%s-%s", project, service.Name)
return nil
}

hash, err := d.getImageHash(imageName)
containerShouldBeUpdated, err := d.containerShouldBeUpdated(project, service)
if err != nil {
return fmt.Errorf("failed to pull image for %s: %w", service.Name, err)
return err
}

containerInfo, err := d.getContainerInfo(service.Name, project)
if err != nil {
if err := d.installService(project, service); err != nil {
return fmt.Errorf("failed to install service %s: %w", service.Name, err)
if containerShouldBeUpdated {
if err := d.updateService(project, service); err != nil {
return fmt.Errorf("failed to update service %s due to image change: %w", service.Name, err)
}

return nil
}

if hash != containerInfo.Image {
if err := d.updateService(project, service); err != nil {
return fmt.Errorf("failed to update service %s due to image change: %w", service.Name, err)
if containerStatus == ContainerStatusStopped {
if err := d.startContainer(service); err != nil {
return fmt.Errorf("failed to start container %s: %w", service.Name, err)
}

return nil
}

changed, err := d.serviceChanged(project, service)
if err != nil {
return fmt.Errorf("failed to check if service %s has changed: %w", service.Name, err)
}
return nil
}

if changed {
if err := d.updateService(project, service); err != nil {
return fmt.Errorf("failed to update service %s due to config change: %w", service.Name, err)
type ContainerStatusType int

const (
ContainerStatusRunning ContainerStatusType = iota
ContainerStatusStopped
ContainerStatusNotFound
ContainerStatusError
)

func (d *Deployment) getContainerStatus(project, service string) (ContainerStatusType, error) {
getContainerInfo, err := d.getContainerInfo(project, service)
if err != nil {
if strings.Contains(err.Error(), "no container found") {
return ContainerStatusNotFound, nil
}

return nil
return ContainerStatusError, fmt.Errorf("failed to get container info: %w", err)
}

if containerInfo.State.Status != "running" {
if err := d.startContainer(service); err != nil {
return fmt.Errorf("failed to start container %s: %w", service.Name, err)
if getContainerInfo.State.Status != "running" {
return ContainerStatusStopped, nil
}

return ContainerStatusRunning, nil
}

func (d *Deployment) updateImage(project string, service *config.Service) error {
if service.Image == "" {
updated, err := d.syncer.Sync(context.Background(), fmt.Sprintf("%s-%s", project, service.Name))
if err != nil {
return err
}
service.ImageUpdated = updated
}

_, err := d.pullImage(service.Image)
if err != nil {
return err
}

return nil
}

func (d *Deployment) containerShouldBeUpdated(project string, service *config.Service) (bool, error) {
containerInfo, err := d.getContainerInfo(project, service.Name)
if err != nil {
return false, fmt.Errorf("failed to get container info: %w", err)
}

imageHash, err := d.getImageHash(service.Image)
if err != nil {
return false, fmt.Errorf("failed to get image hash: %w", err)
}

if service.Image == "" && service.ImageUpdated {
return true, nil
}

if service.Image != "" && containerInfo.Image != imageHash {
return true, nil
}

hash, err := service.Hash()
if err != nil {
return false, fmt.Errorf("failed to generate config hash: %w", err)
}

return containerInfo.Config.Labels["ftl.config-hash"] != hash, nil
}

func (d *Deployment) networkExists(network string) (bool, error) {
output, err := d.runCommand(context.Background(), "docker", "network", "ls", "--format", "{{.Name}}")
if err != nil {
Expand Down
27 changes: 14 additions & 13 deletions pkg/imagesync/imagesync.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,41 +45,41 @@ func NewImageSync(cfg Config, runner *remote.Runner) *ImageSync {
}

// Sync performs the Docker image synchronization process.
func (s *ImageSync) Sync(ctx context.Context, image string) error {
needsSync, err := s.compareImages(ctx, image)
func (s *ImageSync) Sync(ctx context.Context, image string) (bool, error) {
needsSync, err := s.CompareImages(ctx, image)
if err != nil {
return fmt.Errorf("failed to compare images: %w", err)
return false, fmt.Errorf("failed to compare images: %w", err)
}

if !needsSync {
return nil // Images are identical
return false, nil // Images are identical
}

if err := s.prepareDirectories(ctx); err != nil {
return fmt.Errorf("failed to prepare directories: %w", err)
return false, fmt.Errorf("failed to prepare directories: %w", err)
}

if err := s.exportAndExtractImage(ctx, image); err != nil {
return fmt.Errorf("failed to export and extract image: %w", err)
return false, fmt.Errorf("failed to export and extract image: %w", err)
}

if err := s.transferMetadata(ctx, image); err != nil {
return fmt.Errorf("failed to transfer metadata: %w", err)
return false, fmt.Errorf("failed to transfer metadata: %w", err)
}

if err := s.syncBlobs(ctx, image); err != nil {
return fmt.Errorf("failed to sync blobs: %w", err)
return false, fmt.Errorf("failed to sync blobs: %w", err)
}

if err := s.loadRemoteImage(ctx, image); err != nil {
return fmt.Errorf("failed to load remote image: %w", err)
return false, fmt.Errorf("failed to load remote image: %w", err)
}

return nil
return true, nil
}

// compareImages checks if the image needs to be synced by comparing local and remote versions.
func (s *ImageSync) compareImages(ctx context.Context, image string) (bool, error) {
// CompareImages checks if the image needs to be synced by comparing local and remote versions.
func (s *ImageSync) CompareImages(ctx context.Context, image string) (bool, error) {
localInspect, err := s.inspectLocalImage(image)
if err != nil {
return false, fmt.Errorf("failed to inspect local image: %w", err)
Expand All @@ -91,7 +91,8 @@ func (s *ImageSync) compareImages(ctx context.Context, image string) (bool, erro
}

// Compare normalized JSON data
return !compareImageData(localInspect, remoteInspect), nil
imagesEqual := compareImageData(localInspect, remoteInspect)
return !imagesEqual, nil
}

// ImageData represents Docker image metadata.
Expand Down
6 changes: 3 additions & 3 deletions pkg/imagesync/imagesync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func TestImageSync(t *testing.T) {
// Run sync
t.Log("Running sync...")
ctx := context.Background()
err = sync.Sync(ctx, testImage)
_, err = sync.Sync(ctx, testImage)
require.NoError(t, err)

// Verify image exists on remote
Expand All @@ -98,12 +98,12 @@ func TestImageSync(t *testing.T) {

// Test image comparison
t.Log("Comparing images...")
needsSync, err := sync.compareImages(ctx, testImage)
needsSync, err := sync.CompareImages(ctx, testImage)
require.NoError(t, err)
require.False(t, needsSync, "Images should be identical after sync")

// Test re-sync with no changes
t.Log("Re-syncing...")
err = sync.Sync(ctx, testImage)
_, err = sync.Sync(ctx, testImage)
require.NoError(t, err)
}
Loading

0 comments on commit cc7aa68

Please sign in to comment.