Skip to content

Commit f97fac9

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 f97fac9

File tree

4 files changed

+174
-33
lines changed

4 files changed

+174
-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: 39 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,47 @@ 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+
fmt.Printf("🔍 Checking container version for %s...\n", imageURL)
333+
334+
imageLabels, err := container.GetImageLabelsFromRegistry(imageURL)
335+
if err != nil {
336+
fmt.Printf("Latest container version of %s: unavailable (%v)\n", imageURL, err)
337+
containerVersions[imageURL] = &container.ImageLabels{} // Store empty labels
338+
} else if imageLabels.IsEmpty() {
339+
fmt.Printf("Latest container version of %s: no version info available\n", imageURL)
340+
containerVersions[imageURL] = imageLabels
341+
} else {
342+
fmt.Printf("Latest container version of %s: %s %s\n", imageURL, imageLabels.Version, imageLabels.Revision)
343+
containerVersions[imageURL] = imageLabels
344+
}
345+
}
346+
347+
return containerVersions
348+
}
349+
311350
func ReportLoading(cfg *Config) {
312351

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

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)