Skip to content

Commit 549e631

Browse files
committed
overlord/changeslogger: add changes logger manager
1 parent c28008a commit 549e631

File tree

11 files changed

+454
-23
lines changed

11 files changed

+454
-23
lines changed

cmd/snap/cmd_changes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ var shortChangesHelp = i18n.G("List system changes")
3434
var shortTasksHelp = i18n.G("List a change's tasks")
3535
var longChangesHelp = i18n.G(`
3636
The changes command displays a summary of system changes performed recently.
37+
38+
For more details, see /var/log/snapd/changes.log
3739
`)
3840
var longTasksHelp = i18n.G(`
3941
The tasks command displays a summary of tasks associated with an individual

dirs/dirs.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ var (
7171

7272
SnapdMaintenanceFile string
7373

74+
SnapdLogDir string
75+
SnapdChangesLog string
76+
7477
SnapdStoreSSLCertsDir string
7578
SnapdPKIV1Dir string
7679
SystemCertsDir string
@@ -632,6 +635,9 @@ func SetRootDir(rootdir string) {
632635
SnapIconsPoolDir = filepath.Join(SnapCacheDir, "icons-pool")
633636
SnapIconsDir = filepath.Join(SnapCacheDir, "icons")
634637

638+
SnapdLogDir = filepath.Join(rootdir, "/var/log/snapd")
639+
SnapdChangesLog = filepath.Join(SnapdLogDir, "changes.log")
640+
635641
SnapSeedDir = SnapSeedDirUnder(rootdir)
636642
SnapDeviceDir = SnapDeviceDirUnder(rootdir)
637643

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// -*- Mode: Go; indent-tabs-mode: t -*-
2+
3+
/*
4+
* Copyright (C) 2026 Canonical Ltd
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License version 3 as
8+
* published by the Free Software Foundation.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*
18+
*/
19+
20+
package changeslogger
21+
22+
import (
23+
"fmt"
24+
"os"
25+
"path/filepath"
26+
"sync"
27+
"time"
28+
29+
"github.com/snapcore/snapd/dirs"
30+
"github.com/snapcore/snapd/logger"
31+
"github.com/snapcore/snapd/overlord/state"
32+
)
33+
34+
// ChangeInfo stores immutable information about a change for logging
35+
type ChangeInfo struct {
36+
ID string
37+
Kind string
38+
Summary string
39+
Status string
40+
SpawnTime time.Time
41+
ReadyTime time.Time
42+
Error string
43+
}
44+
45+
// ExtractChangeInfo extracts necessary information from a change (must be called with state locked)
46+
func ExtractChangeInfo(chg *state.Change) ChangeInfo {
47+
info := ChangeInfo{
48+
ID: chg.ID(),
49+
Kind: chg.Kind(),
50+
Summary: chg.Summary(),
51+
Status: chg.Status().String(),
52+
SpawnTime: chg.SpawnTime(),
53+
ReadyTime: chg.ReadyTime(),
54+
}
55+
if chg.Err() != nil {
56+
info.Error = chg.Err().Error()
57+
}
58+
return info
59+
}
60+
61+
// Manager logs changes to a file for audit/monitoring purposes
62+
type Manager struct {
63+
state *state.State
64+
mu sync.Mutex
65+
seenChanges map[string]ChangeInfo
66+
changeLogPath string
67+
retryCount map[string]int
68+
}
69+
70+
// New creates a new ChangesLogger manager
71+
func New(s *state.State) *Manager {
72+
return &Manager{
73+
state: s,
74+
seenChanges: make(map[string]ChangeInfo),
75+
changeLogPath: dirs.SnapdChangesLog,
76+
retryCount: make(map[string]int),
77+
}
78+
}
79+
80+
// Ensure checks for any state changes and logs them
81+
func (m *Manager) Ensure() error {
82+
m.mu.Lock()
83+
defer m.mu.Unlock()
84+
85+
m.state.Lock()
86+
changes := m.state.Changes()
87+
88+
// Extract info for all changes while holding lock
89+
changeInfos := make(map[string]ChangeInfo)
90+
for _, chg := range changes {
91+
changeInfos[chg.ID()] = ExtractChangeInfo(chg)
92+
}
93+
m.state.Unlock()
94+
95+
// Track which change IDs we've seen this pass
96+
currentChangeIDs := make(map[string]bool)
97+
98+
for id, info := range changeInfos {
99+
currentChangeIDs[id] = true
100+
101+
// Check if this is a new change or if it has changed status
102+
if oldInfo, seen := m.seenChanges[id]; !seen || oldInfo.Status != info.Status {
103+
// Log the change
104+
if err := m.logChange(info); err != nil {
105+
// Log the error but don't fail the ensure
106+
logger.Noticef("Failed to log change %s: %v", id, err)
107+
}
108+
// Store the info to compare next time
109+
m.seenChanges[id] = info
110+
}
111+
}
112+
113+
// Clean up old tracked changes that are no longer in state
114+
for id := range m.seenChanges {
115+
if !currentChangeIDs[id] {
116+
delete(m.seenChanges, id)
117+
delete(m.retryCount, id)
118+
}
119+
}
120+
121+
return nil
122+
}
123+
124+
// logChange writes a change entry to the log file in APT history.log format
125+
func (m *Manager) logChange(info ChangeInfo) error {
126+
// Ensure the directory exists
127+
logDir := filepath.Dir(m.changeLogPath)
128+
if err := os.MkdirAll(logDir, 0755); err != nil {
129+
return fmt.Errorf("cannot create log directory: %v", err)
130+
}
131+
132+
// Open the log file for appending
133+
f, err := os.OpenFile(m.changeLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
134+
if err != nil {
135+
return fmt.Errorf("cannot open log file: %v", err)
136+
}
137+
defer f.Close()
138+
139+
// Write the change entry as Key: Value pairs
140+
lines := []string{
141+
fmt.Sprintf("Timestamp: %s", time.Now().Format(time.RFC3339)),
142+
fmt.Sprintf("ID: %s", info.ID),
143+
fmt.Sprintf("Kind: %s", info.Kind),
144+
fmt.Sprintf("Summary: %s", info.Summary),
145+
fmt.Sprintf("Status: %s", info.Status),
146+
fmt.Sprintf("SpawnTime: %s", info.SpawnTime.Format(time.RFC3339)),
147+
}
148+
149+
if !info.ReadyTime.IsZero() {
150+
lines = append(lines, fmt.Sprintf("ReadyTime: %s", info.ReadyTime.Format(time.RFC3339)))
151+
}
152+
153+
retryCount := m.retryCount[info.ID]
154+
if retryCount > 0 {
155+
lines = append(lines, fmt.Sprintf("RetryCount: %d", retryCount))
156+
}
157+
158+
if info.Error != "" {
159+
lines = append(lines, fmt.Sprintf("Error: %s", info.Error))
160+
}
161+
162+
// Write all lines followed by a blank line separator
163+
for _, line := range lines {
164+
if _, err := f.WriteString(line + "\n"); err != nil {
165+
return fmt.Errorf("cannot write log entry: %v", err)
166+
}
167+
}
168+
169+
// Write blank line separator between entries
170+
if _, err := f.WriteString("\n"); err != nil {
171+
return fmt.Errorf("cannot write log separator: %v", err)
172+
}
173+
174+
return nil
175+
}
176+
177+
// StartUp performs any necessary initialization
178+
func (m *Manager) StartUp() error {
179+
// Ensure the log directory exists on startup
180+
logDir := filepath.Dir(m.changeLogPath)
181+
if err := os.MkdirAll(logDir, 0755); err != nil {
182+
return fmt.Errorf("cannot create changes log directory: %v", err)
183+
}
184+
185+
if stat, err := os.Stat(m.changeLogPath); err == nil {
186+
logger.Debugf("Changes log file already exists at %q (size: %d)", m.changeLogPath, stat.Size())
187+
}
188+
189+
return nil
190+
}

0 commit comments

Comments
 (0)