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
4 changes: 4 additions & 0 deletions cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ var applyCmd = &cobra.Command{
}

config_lint.Validate(cfg)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
config_lint.Validate(cfg)

Should we remove this line since we have the other?

if err := config_lint.ValidateWithError(cfg); err != nil {
return fmt.Errorf("config validation failed: %w", err)
}

tapplier, err := templating.NewTemplateApplier(cfg, nil)
if err != nil {
return fmt.Errorf("error creating template applier %w", err)
Expand Down
15 changes: 10 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import (
"os"
"path/filepath"

"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/container"
"gopkg.in/yaml.v3"
)

// Config represents the structure of the jumpstarter-lab.yaml file.
type Config struct {
Sources Sources `yaml:"sources"`
Variables []string `yaml:"variables"`
BaseDir string `yaml:"-"` // Not serialized, set programmatically
Loaded *LoadedLabConfig `yaml:"-"` // Not serialized, used internally
Sources Sources `yaml:"sources"`
Variables []string `yaml:"variables"`
BaseDir string `yaml:"-"` // Not serialized, set programmatically
Loaded *LoadedLabConfig `yaml:"-"` // Not serialized, used internally
ContainerVersions map[string]*container.ImageLabels `yaml:"-"` // Not serialized, container versions by image URL
}

// Sources defines the paths for various configuration files.
Expand Down Expand Up @@ -43,6 +45,9 @@ func LoadConfig(filePath string, vaultPassFile string) (*Config, error) {
cfg.BaseDir = filepath.Dir(filePath)

cfg.Loaded, err = LoadAllResources(&cfg, vaultPassFile)
if err != nil {
return nil, err
}

return &cfg, err
return &cfg, nil
}
37 changes: 37 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

jsApi "github.com/jumpstarter-dev/jumpstarter-controller/api/v1alpha1"
api "github.com/jumpstarter-dev/jumpstarter-lab-config/api/v1alpha1"
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/container"
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/vars"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -305,9 +306,45 @@ func LoadAllResources(cfg *Config, vaultPassFile string) (*LoadedLabConfig, erro
}
}

// Retrieve container versions for all unique container images found in exporters
containerVersions := retrieveContainerVersionsFromExporters(loaded)

// Store the container versions in the config
cfg.ContainerVersions = containerVersions

return loaded, nil
}

// retrieveContainerVersionsFromExporters retrieves container versions for all unique container images found in exporters
func retrieveContainerVersionsFromExporters(loaded *LoadedLabConfig) map[string]*container.ImageLabels {
containerVersions := make(map[string]*container.ImageLabels)
uniqueImages := make(map[string]bool)

// Collect all unique container images from exporter config templates
for _, template := range loaded.ExporterConfigTemplates {
if template.Spec.ContainerImage != "" {
uniqueImages[template.Spec.ContainerImage] = true
}
}

// Retrieve version information for each unique image
for imageURL := range uniqueImages {
imageLabels, err := container.GetImageLabelsFromRegistry(imageURL)
if err != nil {
fmt.Printf("Latest container version of %s: unavailable (%v)\n", imageURL, err)
containerVersions[imageURL] = &container.ImageLabels{} // Store empty labels
} else if imageLabels.IsEmpty() {
fmt.Printf("Latest container version of %s: no version info available\n", imageURL)
containerVersions[imageURL] = imageLabels
} else {
fmt.Printf("Latest container version of %s: %s %s\n", imageURL, imageLabels.Version, imageLabels.Revision)
containerVersions[imageURL] = imageLabels
}
}

return containerVersions
}

func ReportLoading(cfg *Config) {

fmt.Println("Reading files from:")
Expand Down
96 changes: 96 additions & 0 deletions internal/container/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package container

import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)

// ImageLabels represents the labels from a container image
type ImageLabels struct {
Version string
Revision string
}

// GetImageLabelsFromRegistry retrieves image labels from a registry using skopeo
func GetImageLabelsFromRegistry(imageURL string) (*ImageLabels, error) {
// Always add the docker:// prefix for skopeo
imageURL = "docker://" + imageURL

cmd := exec.Command("skopeo", "inspect", "--override-os", "linux", "--override-arch", "amd64", imageURL)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to inspect image %s with skopeo: %w", imageURL, err)
}

var imageInfo struct {
Labels map[string]string `json:"Labels"`
}

if err := json.Unmarshal(output, &imageInfo); err != nil {
return nil, fmt.Errorf("failed to parse skopeo output: %w", err)
}

// Get both label sets
jumpstarterVersion := imageInfo.Labels["jumpstarter.version"]
jumpstarterRevision := imageInfo.Labels["jumpstarter.revision"]
ociVersion := imageInfo.Labels["org.opencontainers.image.version"]
ociRevision := imageInfo.Labels["org.opencontainers.image.revision"]

// Use jumpstarter labels if both exist, otherwise fall back to OCI labels
var version, revision string
if jumpstarterVersion != "" && jumpstarterRevision != "" {
version = jumpstarterVersion
revision = jumpstarterRevision
} else {
version = ociVersion
revision = ociRevision
}

// Only return empty labels if BOTH version and revision are completely missing
// (neither jumpstarter nor OCI labels exist)
return &ImageLabels{
Version: version,
Revision: revision,
}, nil
}

// GetRunningContainerLabels retrieves labels from a running container using podman inspect
func GetRunningContainerLabels(serviceName string) (*ImageLabels, error) {
cmd := exec.Command("podman", "inspect", "--format",
"{{index .Config.Labels \"jumpstarter.version\"}} {{index .Config.Labels \"jumpstarter.revision\"}}",
serviceName)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to inspect container %s: %w", serviceName, err)
}

parts := strings.Fields(strings.TrimSpace(string(output)))
if len(parts) < 2 {
return &ImageLabels{}, nil // Return empty labels if not found
}

return &ImageLabels{
Version: parts[0],
Revision: parts[1],
}, nil
}

// CompareVersions compares two ImageLabels and returns true if they match
func (il *ImageLabels) Matches(other *ImageLabels) bool {
return il.Version == other.Version && il.Revision == other.Revision
}

// IsEmpty returns true if both version and revision are empty
func (il *ImageLabels) IsEmpty() bool {
return il.Version == "" && il.Revision == ""
}

// String returns a string representation of the image labels
func (il *ImageLabels) String() string {
if il.IsEmpty() {
return "no version info"
}
return fmt.Sprintf("version=%s revision=%s", il.Version, il.Revision)
}
Loading