Skip to content

Commit 16a5bb1

Browse files
add checking of running container on each exporter host
relies on labels jumpstarter.version and jumpstarter.revision on image, or standard OCI labels org.opencontainers.image.version and org.opencontainers.image.revision
1 parent 8f608da commit 16a5bb1

File tree

5 files changed

+268
-33
lines changed

5 files changed

+268
-33
lines changed

cmd/apply.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ var applyCmd = &cobra.Command{
6060
}
6161

6262
config_lint.Validate(cfg)
63+
6364
tapplier, err := templating.NewTemplateApplier(cfg, nil)
6465
if err != nil {
6566
return fmt.Errorf("error creating template applier %w", err)

internal/config/config.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import (
44
"os"
55
"path/filepath"
66

7+
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/container"
78
"gopkg.in/yaml.v3"
89
)
910

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

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

4547
cfg.Loaded, err = LoadAllResources(&cfg, vaultPassFile)
48+
if err != nil {
49+
return nil, err
50+
}
4651

47-
return &cfg, err
52+
return &cfg, nil
4853
}

internal/config/loader.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
jsApi "github.com/jumpstarter-dev/jumpstarter-controller/api/v1alpha1"
1111
api "github.com/jumpstarter-dev/jumpstarter-lab-config/api/v1alpha1"
12+
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/container"
1213
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/vars"
1314

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

309+
// Retrieve container versions for all unique container images found in exporters
310+
containerVersions := retrieveContainerVersionsFromExporters(loaded)
311+
312+
// Store the container versions in the config
313+
cfg.ContainerVersions = containerVersions
314+
308315
return loaded, nil
309316
}
310317

318+
// retrieveContainerVersionsFromExporters retrieves container versions for all unique container images found in exporters
319+
func retrieveContainerVersionsFromExporters(loaded *LoadedLabConfig) map[string]*container.ImageLabels {
320+
containerVersions := make(map[string]*container.ImageLabels)
321+
uniqueImages := make(map[string]bool)
322+
323+
// Collect all unique container images from exporter config templates
324+
for _, template := range loaded.ExporterConfigTemplates {
325+
if template.Spec.ContainerImage != "" {
326+
uniqueImages[template.Spec.ContainerImage] = true
327+
}
328+
}
329+
330+
// Retrieve version information for each unique image
331+
for imageURL := range uniqueImages {
332+
imageLabels, err := container.GetImageLabelsFromRegistry(imageURL)
333+
if err != nil {
334+
fmt.Printf("Latest container version of %s: unavailable (%v)\n", imageURL, err)
335+
containerVersions[imageURL] = &container.ImageLabels{} // Store empty labels
336+
} else if imageLabels.IsEmpty() {
337+
fmt.Printf("Latest container version of %s: no version info available\n", imageURL)
338+
containerVersions[imageURL] = imageLabels
339+
} else {
340+
fmt.Printf("Latest container version of %s: %s %s\n", imageURL, imageLabels.Version, imageLabels.Revision)
341+
containerVersions[imageURL] = imageLabels
342+
}
343+
}
344+
345+
return containerVersions
346+
}
347+
311348
func ReportLoading(cfg *Config) {
312349

313350
fmt.Println("Reading files from:")

internal/container/version.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package container
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
)
9+
10+
// ImageLabels represents the labels from a container image
11+
type ImageLabels struct {
12+
Version string
13+
Revision string
14+
}
15+
16+
// GetImageLabelsFromRegistry retrieves image labels from a registry using skopeo
17+
func GetImageLabelsFromRegistry(imageURL string) (*ImageLabels, error) {
18+
// Always add the docker:// prefix for skopeo
19+
imageURL = "docker://" + imageURL
20+
21+
cmd := exec.Command("skopeo", "inspect", "--override-os", "linux", "--override-arch", "amd64", imageURL)
22+
output, err := cmd.Output()
23+
if err != nil {
24+
return nil, fmt.Errorf("failed to inspect image %s with skopeo: %w", imageURL, err)
25+
}
26+
27+
var imageInfo struct {
28+
Labels map[string]string `json:"Labels"`
29+
}
30+
31+
if err := json.Unmarshal(output, &imageInfo); err != nil {
32+
return nil, fmt.Errorf("failed to parse skopeo output: %w", err)
33+
}
34+
35+
// Get both label sets
36+
jumpstarterVersion := imageInfo.Labels["jumpstarter.version"]
37+
jumpstarterRevision := imageInfo.Labels["jumpstarter.revision"]
38+
ociVersion := imageInfo.Labels["org.opencontainers.image.version"]
39+
ociRevision := imageInfo.Labels["org.opencontainers.image.revision"]
40+
41+
// Use jumpstarter labels if both exist, otherwise fall back to OCI labels
42+
var version, revision string
43+
if jumpstarterVersion != "" && jumpstarterRevision != "" {
44+
version = jumpstarterVersion
45+
revision = jumpstarterRevision
46+
} else {
47+
version = ociVersion
48+
revision = ociRevision
49+
}
50+
51+
// Only return empty labels if BOTH version and revision are completely missing
52+
// (neither jumpstarter nor OCI labels exist)
53+
return &ImageLabels{
54+
Version: version,
55+
Revision: revision,
56+
}, nil
57+
}
58+
59+
// GetRunningContainerLabels retrieves labels from a running container using podman inspect
60+
func GetRunningContainerLabels(serviceName string) (*ImageLabels, error) {
61+
cmd := exec.Command("podman", "inspect", "--format",
62+
"{{index .Config.Labels \"jumpstarter.version\"}} {{index .Config.Labels \"jumpstarter.revision\"}}",
63+
serviceName)
64+
output, err := cmd.Output()
65+
if err != nil {
66+
return nil, fmt.Errorf("failed to inspect container %s: %w", serviceName, err)
67+
}
68+
69+
parts := strings.Fields(strings.TrimSpace(string(output)))
70+
if len(parts) < 2 {
71+
return &ImageLabels{}, nil // Return empty labels if not found
72+
}
73+
74+
return &ImageLabels{
75+
Version: parts[0],
76+
Revision: parts[1],
77+
}, nil
78+
}
79+
80+
// CompareVersions compares two ImageLabels and returns true if they match
81+
func (il *ImageLabels) Matches(other *ImageLabels) bool {
82+
return il.Version == other.Version && il.Revision == other.Revision
83+
}
84+
85+
// IsEmpty returns true if both version and revision are empty
86+
func (il *ImageLabels) IsEmpty() bool {
87+
return il.Version == "" && il.Revision == ""
88+
}
89+
90+
// String returns a string representation of the image labels
91+
func (il *ImageLabels) String() string {
92+
if il.IsEmpty() {
93+
return "no version info"
94+
}
95+
return fmt.Sprintf("version=%s revision=%s", il.Version, il.Revision)
96+
}

internal/exporter/ssh/ssh.go

Lines changed: 124 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/google/go-cmp/cmp"
1616
"github.com/jumpstarter-dev/jumpstarter-lab-config/api/v1alpha1"
17+
"github.com/jumpstarter-dev/jumpstarter-lab-config/internal/container"
1718
"github.com/pkg/sftp"
1819
"golang.org/x/crypto/ssh"
1920
"golang.org/x/crypto/ssh/agent"
@@ -29,6 +30,10 @@ const (
2930
BOOTC_NOT_MANAGED
3031
)
3132

33+
const (
34+
noValuePlaceholder = "<no value>"
35+
)
36+
3237
type HostManager interface {
3338
Status() (string, error)
3439
NeedsUpdate() (bool, error)
@@ -186,12 +191,12 @@ func (m *SSHHostManager) Apply(exporterConfig *v1alpha1.ExporterConfigTemplate,
186191
if !dryRun {
187192
_, enableErr := m.runCommand("systemctl restart " + fmt.Sprintf("%q", serviceName))
188193
if enableErr != nil {
189-
fmt.Printf(" ❌ Failed to start service %s: %v\n", serviceName, enableErr)
194+
fmt.Printf(" ❌ Failed to start service %s: %v\n", serviceName, enableErr)
190195
} else {
191-
fmt.Printf(" ✅ Service %s started\n", serviceName)
196+
fmt.Printf(" ✅ Service %s started\n", serviceName)
192197
}
193198
} else {
194-
fmt.Printf(" 📄 Would restart service %s\n", serviceName)
199+
fmt.Printf(" 📄 Would restart service %s\n", serviceName)
195200
}
196201
}
197202

@@ -217,9 +222,9 @@ func (m *SSHHostManager) Apply(exporterConfig *v1alpha1.ExporterConfigTemplate,
217222

218223
if m.GetBootcStatus() == BOOTC_UPDATING {
219224
if dryRun {
220-
fmt.Printf(" 📄 Bootc upgrade in progress, would skip exporter service restarts/container updates\n")
225+
fmt.Printf(" 📄 Bootc upgrade in progress, would skip exporter service restarts/container updates\n")
221226
} else {
222-
fmt.Printf(" ⚠️ Bootc upgrade in progress, skipping exporter service restarts/container updates\n")
227+
fmt.Printf(" ⚠️ Bootc upgrade in progress, skipping exporter service restarts/container updates\n")
223228
return nil
224229
}
225230
}
@@ -245,39 +250,130 @@ func (m *SSHHostManager) Apply(exporterConfig *v1alpha1.ExporterConfigTemplate,
245250
} else {
246251
// Check if service is running and start if needed
247252
statusResult, err := m.runCommand("systemctl is-active " + fmt.Sprintf("%q", svcName))
248-
if err != nil || statusResult.Stdout != "active\n" {
249-
fmt.Printf(" ⚠️ Service %s is not running...\n", svcName)
253+
serviceRunning := err == nil && statusResult.Stdout == "active\n"
254+
255+
if !serviceRunning {
256+
fmt.Printf(" ⚠️ Service %s is not running...\n", svcName)
250257
restartService(svcName, dryRun)
258+
} else {
259+
// Only check container version if service is running
260+
err = m.checkContainerVersion(exporterConfig, svcName, dryRun, restartService)
261+
if err != nil {
262+
return fmt.Errorf("container version check failed: %w", err)
263+
}
251264
}
265+
}
252266

253-
// Check if container needs updating using podman auto-update (if podman exists)
254-
autoUpdateResult, err := m.runCommand(fmt.Sprintf("command -v podman >/dev/null 2>&1 && podman auto-update --dry-run --format json | jq -r '.[] | select(.ContainerName == %q) | .Updated'", svcName))
255-
if err != nil {
256-
fmt.Printf(" ℹ️ Podman not available, skipping auto-update check\n")
267+
return nil
268+
}
269+
270+
// checkContainerVersion checks if container needs updating using detailed version comparison
271+
func (m *SSHHostManager) checkContainerVersion(exporterConfig *v1alpha1.ExporterConfigTemplate, svcName string, dryRun bool, restartService func(string, bool)) error {
272+
// Only check version for container-based exporters
273+
if exporterConfig.Spec.SystemdContainerTemplate == "" {
274+
return nil
275+
}
276+
277+
// Check detailed version comparison (if we have container image info)
278+
if exporterConfig.Spec.ContainerImage != "" {
279+
return m.checkDetailedContainerVersion(exporterConfig.Spec.ContainerImage, svcName, dryRun, restartService)
280+
}
281+
282+
// No container image specified, nothing to check
283+
return nil
284+
}
285+
286+
// checkDetailedContainerVersion performs detailed version comparison using skopeo and podman inspect
287+
func (m *SSHHostManager) checkDetailedContainerVersion(containerImage, svcName string, dryRun bool, restartService func(string, bool)) error {
288+
// Get expected version from registry
289+
expectedLabels, err := container.GetImageLabelsFromRegistry(containerImage)
290+
if err != nil {
291+
fmt.Printf(" ⚠️ Could not check container version: %v\n", err)
292+
return nil // Don't fail the entire operation, just skip version check
293+
}
294+
295+
if expectedLabels.IsEmpty() {
296+
fmt.Printf(" ℹ️ No version info available for image %s\n", containerImage)
297+
return nil // Don't fail, just skip version check
298+
}
299+
300+
// Get running container version
301+
runningLabels, err := m.getRunningContainerLabels(svcName)
302+
if err != nil {
303+
fmt.Printf(" ⚠️ Could not check running container version: %v\n", err)
304+
return nil // Container might not be running yet, which is fine
305+
}
306+
307+
// Compare versions
308+
if expectedLabels.Matches(runningLabels) {
309+
if dryRun {
310+
fmt.Printf(" ✅ Exporter container image running latest version\n")
311+
}
312+
// In non-dry-run mode, print nothing for matching versions as requested
313+
} else {
314+
if dryRun {
315+
fmt.Printf(" 🔄 Would restart service for container update (running: %s, latest: %s)\n",
316+
runningLabels.String(), expectedLabels.String())
257317
} else {
258-
updatedOutput := strings.TrimSpace(autoUpdateResult.Stdout)
259-
switch updatedOutput {
260-
case "":
261-
fmt.Printf(" ⚠️ Container %s not found in auto-update check\n", svcName)
262-
case "false":
263-
if dryRun {
264-
fmt.Printf(" ✅ Exporter container image is up to date\n")
265-
}
266-
case "pending":
267-
if dryRun {
268-
fmt.Printf(" 📄 Would update container %s\n", svcName)
269-
} else {
270-
restartService(svcName, dryRun)
271-
}
272-
default:
273-
return fmt.Errorf("unexpected auto-update result: %s", updatedOutput)
274-
}
318+
fmt.Printf(" 🔄 Restarting service for container update (running: %s, latest: %s)\n",
319+
runningLabels.String(), expectedLabels.String())
320+
restartService(svcName, dryRun)
275321
}
276322
}
277323

278324
return nil
279325
}
280326

327+
// getRunningContainerLabels gets container labels from running container
328+
func (m *SSHHostManager) getRunningContainerLabels(serviceName string) (*container.ImageLabels, error) {
329+
// Try jumpstarter labels first, then fall back to OCI standard labels
330+
result, err := m.runCommand(fmt.Sprintf("podman inspect --format '{{index .Config.Labels \"jumpstarter.version\"}} {{index .Config.Labels \"jumpstarter.revision\"}} {{index .Config.Labels \"org.opencontainers.image.version\"}} {{index .Config.Labels \"org.opencontainers.image.revision\"}}' %s", serviceName))
331+
if err != nil {
332+
return nil, fmt.Errorf("failed to inspect container %s: %w", serviceName, err)
333+
}
334+
335+
parts := strings.Fields(strings.TrimSpace(result.Stdout))
336+
// Pad with empty strings if we got fewer parts
337+
for len(parts) < 4 {
338+
parts = append(parts, "")
339+
}
340+
341+
jumpstarterVersion := parts[0] // jumpstarter.version
342+
jumpstarterRevision := parts[1] // jumpstarter.revision
343+
ociVersion := parts[2] // org.opencontainers.image.version
344+
ociRevision := parts[3] // org.opencontainers.image.revision
345+
346+
// Clean up "<no value>" to empty string
347+
if jumpstarterVersion == noValuePlaceholder {
348+
jumpstarterVersion = ""
349+
}
350+
if jumpstarterRevision == noValuePlaceholder {
351+
jumpstarterRevision = ""
352+
}
353+
if ociVersion == noValuePlaceholder {
354+
ociVersion = ""
355+
}
356+
if ociRevision == noValuePlaceholder {
357+
ociRevision = ""
358+
}
359+
360+
// Use jumpstarter labels if both exist, otherwise fall back to OCI labels
361+
var version, revision string
362+
if jumpstarterVersion != "" && jumpstarterRevision != "" {
363+
version = jumpstarterVersion
364+
revision = jumpstarterRevision
365+
} else {
366+
version = ociVersion
367+
revision = ociRevision
368+
}
369+
370+
// Return labels even if only partially available (version OR revision can be empty)
371+
return &container.ImageLabels{
372+
Version: version,
373+
Revision: revision,
374+
}, nil
375+
}
376+
281377
// RunHostCommand implements the HostManager interface by exposing runCommand
282378
func (m *SSHHostManager) RunHostCommand(command string) (*CommandResult, error) {
283379
return m.runCommand(command)

0 commit comments

Comments
 (0)