Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
00495f8
feat: add --dry-run flag to prepare and restore commands
tonyandrewmeyer Jan 28, 2026
c503c9f
Address copilot review comments.
tonyandrewmeyer Jan 28, 2026
517e628
Initial plan
Copilot Jan 28, 2026
342f1a2
Address remaining review feedback on dry-run feature
Copilot Jan 28, 2026
e4732c9
Merge pull request #3 from tonyandrewmeyer/copilot/sub-pr-2
tonyandrewmeyer Jan 29, 2026
3cf2557
Simplify README addition.
tonyandrewmeyer Jan 29, 2026
66fdfc3
More minimal diff.
tonyandrewmeyer Jan 29, 2026
8a78a81
Tweak punctuation.
tonyandrewmeyer Jan 29, 2026
f0189dd
Avoid having to define past and present tense.
tonyandrewmeyer Jan 29, 2026
286e243
Update README.md
tonyandrewmeyer Jan 29, 2026
6de765e
Update README.md
tonyandrewmeyer Jan 29, 2026
b329c5d
Fix name clash.
tonyandrewmeyer Jan 31, 2026
3a9b8b6
Sacrifice more human readable descriptions in favour of fewer custom …
tonyandrewmeyer Jan 31, 2026
2e5e6c7
fix: Update spread tests to match auto-print output format
tonyandrewmeyer Jan 31, 2026
5d72d39
Update internal/packages/snap_handler.go
tonyandrewmeyer Jan 31, 2026
9ce55e3
Update README.md
tonyandrewmeyer Jan 31, 2026
fbf667a
fix: Address review comments for dry-run feature
tonyandrewmeyer Jan 31, 2026
13aae96
Update internal/packages/dryrun_test.go
tonyandrewmeyer Jan 31, 2026
a9bff3c
Update cmd/main.go
tonyandrewmeyer Jan 31, 2026
b5c3edd
Update tests/dry-run-prepare/task.yaml
tonyandrewmeyer Jan 31, 2026
f73da87
Update tests/dry-run-restore/task.yaml
tonyandrewmeyer Jan 31, 2026
8c62bd2
Update internal/system/dryrun.go
tonyandrewmeyer Jan 31, 2026
c15a7d2
fix: Improve TestDryRunWorkerDelegatesReadOperations test
tonyandrewmeyer Jan 31, 2026
bedc395
Merge branch 'main' into feat-dry-run
tonyandrewmeyer Jan 31, 2026
33993f8
Merge branch 'main' into feat-dry-run
tonyandrewmeyer Feb 2, 2026
c83b485
Make the dry run output copy and pastable into a shell.
tonyandrewmeyer Feb 2, 2026
62cead2
Remove the 'heading' dry run statements.
tonyandrewmeyer Feb 2, 2026
712439a
stdout doesn't need to be surrounded by locks, so we can just do dire…
tonyandrewmeyer Feb 2, 2026
026aab7
Rather than suppressing log messages entirely in dry-run mode, just b…
tonyandrewmeyer Feb 2, 2026
99a12f6
Remove pointless test.
tonyandrewmeyer Feb 2, 2026
753475f
Remove outdated comment.
tonyandrewmeyer Feb 2, 2026
e299673
Update cmd/main.go
tonyandrewmeyer Feb 2, 2026
1a4a857
Remove test that isn't really checking anything, particularly now tha…
tonyandrewmeyer Feb 2, 2026
b5d99b2
Adjust the non-command dry-run steps to also be shell-like.
tonyandrewmeyer Feb 2, 2026
a3a9760
Remove duplicate skipping of config recording in dry-run mode.
tonyandrewmeyer Feb 2, 2026
d5c69c1
Merge branch 'main' into feat-dry-run
tonyandrewmeyer Feb 3, 2026
61b01e0
Do exact changes, per review suggestion.
tonyandrewmeyer Feb 4, 2026
fe4888d
Simplify dry-run explanation.
tonyandrewmeyer Feb 4, 2026
4721a66
Dry run is a CLI arg, but isn't part of config.
tonyandrewmeyer Feb 4, 2026
1832787
Keep checking if a command exists in the path, but run and log just t…
tonyandrewmeyer Feb 4, 2026
bec5014
restore should behave the same way with and without --dry-run
tonyandrewmeyer Feb 4, 2026
3a0f694
Fix the way that dry-run is handled with restore.
tonyandrewmeyer Feb 5, 2026
1e79d0f
Add integration tests that verify the load-saved-config behaviour.
tonyandrewmeyer Feb 5, 2026
15af2b8
This test is really just testing the packages, and we have tests for …
tonyandrewmeyer Feb 7, 2026
1752da3
Adjust the dry run behaviour for bootstrap.
tonyandrewmeyer Feb 7, 2026
81e1c80
Update internal/concierge/manager.go
tonyandrewmeyer Feb 7, 2026
cc887bf
Change approach to run commands with an optional read-only flag.
tonyandrewmeyer Feb 7, 2026
e39051d
Use the readonly flag for which iptables as well.
tonyandrewmeyer Feb 9, 2026
6b11a49
Merge branch 'main' into feat-dry-run
tonyandrewmeyer Feb 17, 2026
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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,38 @@ export CONCIERGE_JUJU_CHANNEL=3.6/beta
sudo concierge prepare -p dev
```

### Dry Run Mode

Both `prepare` and `restore` commands support a `--dry-run` flag that shows what
operations would be performed without actually making any changes to the system.

```bash
# Preview prepare operations
sudo concierge prepare -p dev --dry-run

# Preview restore operations
sudo concierge restore --dry-run
```

In dry-run mode:
- Commands are printed to stdout in a format that can be copied and pasted into a shell
- Log level defaults to error (use `--trace` or `--verbose` to see more details)
- No packages are installed or removed
- No files are created or modified
- No Juju controllers are bootstrapped or destroyed
- System state is read for accurate conditional logic (for example, checking if snaps are
already installed, reading configuration files)

This is useful for verifying what `concierge` will do before running it, or for
understanding what a particular preset or configuration file includes. Note that
Concierge will still inspect the system (for example, to see what snaps are already
installed), so dry-run mode runs read-only commands but never executes operations that
modify system state.

**Note:** Dry-run mode reads actual system state to provide accurate output. If your
configuration references files that don't exist (e.g., Google Cloud credentials), the
dry-run will fail with the same error that would occur during actual execution.

## Configuration

### Presets
Expand Down
8 changes: 4 additions & 4 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,20 @@ func Execute() {
func parseLoggingFlags(flags *pflag.FlagSet) {
verbose, _ := flags.GetBool("verbose")
trace, _ := flags.GetBool("trace")
dryRun, _ := flags.GetBool("dry-run")

logLevel := new(slog.LevelVar)

// Set the default log level to "DEBUG" if verbose is specified.
// Determine log level: --verbose/--trace take precedence, then --dry-run defaults to error
level := slog.LevelInfo
if verbose || trace {
level = slog.LevelDebug
} else if dryRun {
level = slog.LevelError
}

// Setup the TextHandler and ensure our configured logger is the default.
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})
logger := slog.New(h)
slog.SetDefault(logger)
logLevel.Set(level)
}

func checkUser() error {
Expand Down
2 changes: 2 additions & 0 deletions cmd/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,7 @@ More information at https://github.com/canonical/concierge.
"comma-separated list of extra debs to install. E.g. 'make,python3-tox'",
)

flags.Bool("dry-run", false, "show what would be done without making changes")

return cmd
}
7 changes: 6 additions & 1 deletion cmd/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

// restoreCmd constructs the `restore` subcommand
func restoreCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "restore",
Short: "Run the reverse of `concierge prepare`.",
Long: `Run the reverse of 'concierge prepare'.
Expand Down Expand Up @@ -42,4 +42,9 @@ files or configuration that would normally be created during 'prepare' will be r
return mgr.Restore()
},
}

flags := cmd.Flags()
flags.Bool("dry-run", false, "show what would be done without making changes")

return cmd
}
22 changes: 19 additions & 3 deletions internal/concierge/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@ import (

// NewManager constructs a new instance of the concierge manager.
func NewManager(config *config.Config) (*Manager, error) {
system, err := system.NewSystem(config.Trace)
sys, err := system.NewSystem(config.Trace)
if err != nil {
return nil, fmt.Errorf("failed to initialise system: %w", err)
}

var worker system.Worker = sys
if config.DryRun {
worker = system.NewDryRunWorker(sys)
}

return &Manager{
config: config,
system: system,
system: worker,
}, nil
}

Expand Down Expand Up @@ -65,9 +70,16 @@ func (m *Manager) execute(action string) error {
return fmt.Errorf("failed to record config file: %w", err)
}
case RestoreAction:
// Try to load the cached runtime config for accurate restore information.
// In dry-run mode, if no prepare has been run, fall back to current config.
err := m.loadRuntimeConfig()
if err != nil {
return fmt.Errorf("failed to load previous runtime configuration: %w", err)
if m.config.DryRun {
// In dry-run mode, use current config if no cached config exists
slog.Debug("No cached runtime config found, using current config for dry-run")
} else {
return fmt.Errorf("failed to load previous runtime configuration: %w", err)
}
}
default:
return fmt.Errorf("unknown handler action: %s", action)
Expand All @@ -80,7 +92,11 @@ func (m *Manager) execute(action string) error {

// recordRuntimeConfig dumps the current manager config into a file in the user's home
// directory, such that it can be read later and used to restore the machine.
// In dry-run mode, this is a no-op.
func (m *Manager) recordRuntimeConfig(status config.Status) error {
if m.config.DryRun {
return nil
}
m.config.Status = status
configYaml, err := yaml.Marshal(m.config)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@ func NewConfig(cmd *cobra.Command, flags *pflag.FlagSet) (*Config, error) {
}
}

dryRun, _ := flags.GetBool("dry-run")

conf.Overrides = getOverrides(flags)
conf.Verbose = verbose
conf.Trace = trace
conf.DryRun = dryRun

return conf, nil
}
Expand Down
1 change: 1 addition & 0 deletions internal/config/config_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Config struct {
Status Status `mapstructure:"status"`
Verbose bool `mapstructure:"verbose"`
Trace bool `mapstructure:"trace"`
DryRun bool `mapstructure:"dry-run"`
}

// Status represents the status of concierge on a given machine.
Expand Down
201 changes: 201 additions & 0 deletions internal/packages/dryrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package packages

import (
"fmt"
"os"
"os/user"
"strings"
"sync"
"testing"
"time"

"github.com/canonical/concierge/internal/system"
)

// testDryRunWorker is a test implementation that tracks commands
// for verification without actually executing them
type testDryRunWorker struct {
mu sync.Mutex
executedCmds []string
mockSnapInfo map[string]*system.SnapInfo
}

func newTestDryRunWorker() *testDryRunWorker {
return &testDryRunWorker{
executedCmds: []string{},
mockSnapInfo: map[string]*system.SnapInfo{},
}
}

func (t *testDryRunWorker) User() *user.User {
return &user.User{Username: "test", Uid: "1000", Gid: "1000", HomeDir: "/tmp"}
}

func (t *testDryRunWorker) Run(c *system.Command) ([]byte, error) {
// This mock returns success without actually executing the command.
// We track the command strings for potential test assertions.
t.mu.Lock()
t.executedCmds = append(t.executedCmds, c.CommandString())
t.mu.Unlock()
return []byte{}, nil
}

func (t *testDryRunWorker) RunMany(commands ...*system.Command) error {
for _, c := range commands {
t.Run(c)
}
return nil
}

func (t *testDryRunWorker) RunExclusive(c *system.Command) ([]byte, error) {
return t.Run(c)
}

func (t *testDryRunWorker) RunWithRetries(c *system.Command, maxDuration time.Duration) ([]byte, error) {
return t.Run(c)
}

func (t *testDryRunWorker) WriteHomeDirFile(filepath string, contents []byte) error {
return nil
}

func (t *testDryRunWorker) ReadHomeDirFile(filepath string) ([]byte, error) {
return nil, fmt.Errorf("file not found")
}

func (t *testDryRunWorker) ReadFile(filePath string) ([]byte, error) {
return nil, fmt.Errorf("file not found")
}

func (t *testDryRunWorker) SnapInfo(snap string, channel string) (*system.SnapInfo, error) {
if info, ok := t.mockSnapInfo[snap]; ok {
return info, nil
}
return &system.SnapInfo{Installed: false, Classic: false}, nil
}

func (t *testDryRunWorker) SnapChannels(snap string) ([]string, error) {
return []string{"stable", "edge"}, nil
}

func (t *testDryRunWorker) RemovePath(path string) error {
return nil
}

func (t *testDryRunWorker) MkdirAll(path string, perm os.FileMode) error {
return nil
}

func (t *testDryRunWorker) ChownAll(path string, user *user.User) error {
return nil
}

func (t *testDryRunWorker) MockSnapInfo(name string, installed, classic bool) {
t.mockSnapInfo[name] = &system.SnapInfo{
Installed: installed,
Classic: classic,
}
}

// Verify testDryRunWorker implements system.Worker
var _ system.Worker = (*testDryRunWorker)(nil)

func TestSnapHandlerExecutesCorrectCommands(t *testing.T) {
drw := newTestDryRunWorker()
drw.MockSnapInfo("existing-snap", true, false)

snaps := []*system.Snap{
system.NewSnap("new-snap", "stable", []string{}),
system.NewSnap("existing-snap", "stable", []string{}),
}

handler := NewSnapHandler(drw, snaps)
err := handler.Prepare()
if err != nil {
t.Fatalf("Prepare should not fail: %v", err)
}

// Verify the correct commands were issued
foundInstall := false
foundRefresh := false
for _, cmd := range drw.executedCmds {
if strings.Contains(cmd, "snap install new-snap") {
foundInstall = true
}
if strings.Contains(cmd, "snap refresh existing-snap") {
foundRefresh = true
}
}

if !foundInstall {
t.Errorf("expected 'snap install new-snap' command, got: %v", drw.executedCmds)
}
if !foundRefresh {
t.Errorf("expected 'snap refresh existing-snap' command, got: %v", drw.executedCmds)
}
}

func TestSnapHandlerRestoreExecutesCorrectCommands(t *testing.T) {
drw := newTestDryRunWorker()

snaps := []*system.Snap{
system.NewSnap("snap-to-remove", "stable", []string{}),
}

handler := NewSnapHandler(drw, snaps)
err := handler.Restore()
if err != nil {
t.Fatalf("Restore should not fail: %v", err)
}

foundRemove := false
for _, cmd := range drw.executedCmds {
if strings.Contains(cmd, "snap remove snap-to-remove") {
foundRemove = true
}
}

if !foundRemove {
t.Errorf("expected 'snap remove snap-to-remove' command, got: %v", drw.executedCmds)
}
}

func TestDebHandlerExecutesCorrectCommands(t *testing.T) {
drw := newTestDryRunWorker()

debs := []*Deb{
{Name: "make"},
{Name: "python3"},
}

handler := NewDebHandler(drw, debs)
err := handler.Prepare()
if err != nil {
t.Fatalf("Prepare should not fail: %v", err)
}

foundUpdate := false
foundMake := false
foundPython := false
for _, cmd := range drw.executedCmds {
if strings.Contains(cmd, "apt-get update") {
foundUpdate = true
}
if strings.Contains(cmd, "apt-get install") && strings.Contains(cmd, "make") {
foundMake = true
}
if strings.Contains(cmd, "apt-get install") && strings.Contains(cmd, "python3") {
foundPython = true
}
}

if !foundUpdate {
t.Errorf("expected 'apt-get update' command, got: %v", drw.executedCmds)
}
if !foundMake {
t.Errorf("expected 'apt-get install make' command, got: %v", drw.executedCmds)
}
if !foundPython {
t.Errorf("expected 'apt-get install python3' command, got: %v", drw.executedCmds)
}
}
Loading