From 12b01ace717d96e6d0e3b522bb8482c058937478 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Tue, 2 Sep 2025 13:48:09 -0400 Subject: [PATCH] Show image differences in pre-deploy overview --- cli/app/deploy.go | 8 +-- pkg/client/configs.go | 7 +- pkg/deploy/utils.go | 152 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 137 insertions(+), 30 deletions(-) diff --git a/cli/app/deploy.go b/cli/app/deploy.go index b5bdd4dc..0da63311 100644 --- a/cli/app/deploy.go +++ b/cli/app/deploy.go @@ -3,7 +3,6 @@ package app import ( "context" "errors" - "fmt" "strings" "coopcloud.tech/abra/cli/internal" @@ -202,10 +201,9 @@ checkout as-is. Recipe commit hashes are also supported as values for } // Gather images - - var imageInfo []string - for _, service := range compose.Services { - imageInfo = append(imageInfo, fmt.Sprintf("%s: %s", service.Name, service.Image)) + imageInfo, err := deploy.GatherImagesForDeploy(cl, app, compose) + if err != nil { + log.Fatal(err) } // Show deploy overview diff --git a/pkg/client/configs.go b/pkg/client/configs.go index 591327b2..22c693c8 100644 --- a/pkg/client/configs.go +++ b/pkg/client/configs.go @@ -39,10 +39,11 @@ func RemoveConfigs(cl *client.Client, ctx context.Context, configNames []string, return nil } -func GetConfigNameAndVersion(fullName string, stackName string) (string, string) { +func GetConfigNameAndVersion(fullName string, stackName string) (string, string, error) { name := strings.TrimPrefix(fullName, stackName + "_") if lastUnderscore := strings.LastIndex(name, "_"); lastUnderscore != -1 { - return name[0:lastUnderscore], name[lastUnderscore+1:] + return name[0:lastUnderscore], name[lastUnderscore+1:], nil + } else { + return "", "", errors.New(i18n.G("can't parse version from config '%s'", fullName)) } - return name, "" } diff --git a/pkg/deploy/utils.go b/pkg/deploy/utils.go index 72692024..f447e4ec 100644 --- a/pkg/deploy/utils.go +++ b/pkg/deploy/utils.go @@ -2,7 +2,9 @@ package deploy import ( "context" + "errors" "fmt" + "regexp" "sort" "strings" @@ -16,8 +18,8 @@ import ( dockerClient "github.com/docker/docker/client" ) -// GetConfigNamesForStack retrieves all Docker configs attached to services in a given stack. -func GetConfigNamesForStack(cl *dockerClient.Client, app appPkg.App) ([]string, error) { +// GetConfigsForStack retrieves all Docker configs attached to services in a given stack. +func GetConfigsForStack(cl *dockerClient.Client, app appPkg.App) (map[string]string, error) { filters, err := app.Filters(false, false) if err != nil { return nil, err @@ -31,25 +33,92 @@ func GetConfigNamesForStack(cl *dockerClient.Client, app appPkg.App) ([]string, return nil, err } - // Collect unique config names from all services - configNames := make(map[string]bool) + // Collect unique config names with versions + configs := make(map[string]string) for _, service := range services { if service.Spec.TaskTemplate.ContainerSpec != nil { for _, configRef := range service.Spec.TaskTemplate.ContainerSpec.Configs { - if configRef.ConfigName != "" { - configNames[configRef.ConfigName] = true + configName := configRef.ConfigName + if configName == "" { + continue + } + configBaseName, configVersion, err := client.GetConfigNameAndVersion(configName, app.StackName()) + if err != nil { + log.Warn(err) + continue + } + + existingConfigVersion, ok := configs[configBaseName] + if !ok { + // First time seeing this, add to map + configs[configBaseName] = configVersion + } else { + // Just make sure the versions are the same.. + if existingConfigVersion != configVersion { + log.Warnf("different versions for config '%s', '%s' and %s'", configBaseName, existingConfigVersion, configVersion) + } } } } } - // Convert map to slice - result := make([]string, 0, len(configNames)) - for name := range configNames { - result = append(result, name) + return configs, nil +} + +func GetImageNameAndTag(imageName string) (string, string, error) { + imageParts := regexp.MustCompile("^([^:]*):([^@]*)@?").FindSubmatch([]byte(imageName)) + + if len(imageParts) == 0 { + return "", "", errors.New("can't determine image version for image '%s'") + } + + imageBaseName := string(imageParts[1]) + imageTag := string(imageParts[2]) + + return imageBaseName, imageTag, nil +} + +// GetImagesForStack retrieves all Docker images for services in a given stack. +func GetImagesForStack(cl *dockerClient.Client, app appPkg.App) (map[string]string, error) { + filters, err := app.Filters(false, false) + if err != nil { + return nil, err } - return result, nil + // List all services in the stack + services, err := cl.ServiceList(context.Background(), swarm.ServiceListOptions{ + Filters: filters, + }) + if err != nil { + return nil, err + } + + // Collect unique image names with versions + images := make(map[string]string) + for _, service := range services { + if service.Spec.TaskTemplate.ContainerSpec != nil { + imageName := service.Spec.TaskTemplate.ContainerSpec.Image + + imageBaseName, imageTag, err := GetImageNameAndTag(imageName) + if err != nil { + log.Warn(err) + continue + } + + existingImageVersion, ok := images[imageBaseName] + if !ok { + // First time seeing this, add to map + images[imageBaseName] = imageTag + } else { + // Just make sure the versions are the same.. + if existingImageVersion != imageTag { + log.Warnf("different versions for image '%s', '%s' and %s'", imageBaseName, existingImageVersion, imageTag) + } + } + } + } + + return images, nil } func GatherSecretsForDeploy(cl *dockerClient.Client, app appPkg.App) ([]string, error) { @@ -73,21 +142,12 @@ func GatherSecretsForDeploy(cl *dockerClient.Client, app appPkg.App) ([]string, func GatherConfigsForDeploy(cl *dockerClient.Client, app appPkg.App, compose *composetypes.Config, abraShEnv map[string]string) ([]string, error) { // Get current configs from existing deployment - currentConfigNames, err := GetConfigNamesForStack(cl, app) + currentConfigs, err := GetConfigsForStack(cl, app) if err != nil { return nil, err } - log.Infof("Config names: %v", currentConfigNames) - - // Create map of current config base names to versions - currentConfigs := make(map[string]string) - for _, configName := range currentConfigNames { - baseName, version := client.GetConfigNameAndVersion(configName, app.StackName()) - currentConfigs[baseName] = version - } - - log.Infof("Configs: %v", currentConfigs) + log.Debugf("Deployed config names: %v", currentConfigs) // Get new configs from the compose specification newConfigs := compose.Configs @@ -116,3 +176,51 @@ func GatherConfigsForDeploy(cl *dockerClient.Client, app appPkg.App, compose *co return configInfo, nil } + +func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *composetypes.Config) ([]string, error){ + + // Get current images from existing deployment + currentImages, err := GetImagesForStack(cl, app) + if err != nil { + return nil, err + } + + log.Infof("Deployed images: %v", currentImages) + + // Proposed new images from the compose files + newImages := make(map[string]string) + + for _, service := range compose.Services { + imageBaseName, imageTag, err := GetImageNameAndTag(service.Image) + if err != nil { + log.Warn(err) + continue + } + existingImageVersion, ok := newImages[imageBaseName] + if !ok { + // First time seeing this, add to map + newImages[imageBaseName] = imageTag + } else { + // Just make sure the versions are the same.. + if existingImageVersion != imageTag { + log.Warnf("different versions for image '%s', '%s' and %s'", imageBaseName, existingImageVersion, imageTag) + } + } + } + log.Infof("Proposed images: %v", newImages) + + var imageInfo []string + for newImageName, newImageVersion := range newImages { + if currentVersion, exists := currentImages[newImageName]; exists { + if currentVersion == newImageVersion { + imageInfo = append(imageInfo, fmt.Sprintf("%s: %s (unchanged)", newImageName, newImageVersion)) + } else { + imageInfo = append(imageInfo, fmt.Sprintf("%s: %s → %s", newImageName, currentVersion, newImageVersion)) + } + } else { + imageInfo = append(imageInfo, fmt.Sprintf("%s: %s (new)", newImageName, newImageVersion)) + } + } + + return imageInfo, nil +}