@@ -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+
3237type 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
282378func (m * SSHHostManager ) RunHostCommand (command string ) (* CommandResult , error ) {
283379 return m .runCommand (command )
0 commit comments