diff --git a/internal/concierge/plan.go b/internal/concierge/plan.go index 358931d..2c69d58 100644 --- a/internal/concierge/plan.go +++ b/internal/concierge/plan.go @@ -14,7 +14,7 @@ import ( // Plan represents a set of packages and providers that are to be prepared/restored. type Plan struct { Providers []providers.Provider - Snaps []packages.SnapPackage + Snaps []*runner.Snap Debs []*packages.Deb config *config.Config @@ -26,12 +26,12 @@ func NewPlan(config *config.Config, runner runner.CommandRunner) *Plan { plan := &Plan{config: config, runner: runner} for _, s := range append(config.Host.Snaps, config.Overrides.ExtraSnaps...) { - snap := packages.NewSnapFromString(s) + snap := runner.NewSnapFromString(s) // Check if the channel has been overridden by a CLI argument/env var - channelOverride := getSnapChannelOverride(config, snap.Name()) + channelOverride := getSnapChannelOverride(config, snap.Name) if channelOverride != "" { - snap.SetChannel(channelOverride) + snap.Channel = channelOverride } plan.Snaps = append(plan.Snaps, snap) diff --git a/internal/concierge/plan_validators_test.go b/internal/concierge/plan_validators_test.go index 6ec71f5..ae1d981 100644 --- a/internal/concierge/plan_validators_test.go +++ b/internal/concierge/plan_validators_test.go @@ -4,11 +4,11 @@ import ( "testing" "github.com/jnsgruk/concierge/internal/config" - "github.com/jnsgruk/concierge/internal/runnertest" + "github.com/jnsgruk/concierge/internal/runner" ) func TestSingleK8sValidator(t *testing.T) { - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() twoK8s := &config.Config{} twoK8s.Providers.K8s.Enable = true diff --git a/internal/juju/juju.go b/internal/juju/juju.go index 0efd59f..6262633 100644 --- a/internal/juju/juju.go +++ b/internal/juju/juju.go @@ -17,7 +17,7 @@ import ( ) // NewJujuHandler constructs a new JujuHandler instance. -func NewJujuHandler(config *config.Config, runner runner.CommandRunner, providers []providers.Provider) *JujuHandler { +func NewJujuHandler(config *config.Config, r runner.CommandRunner, providers []providers.Provider) *JujuHandler { var channel string if config.Overrides.JujuChannel != "" { channel = config.Overrides.JujuChannel @@ -30,8 +30,8 @@ func NewJujuHandler(config *config.Config, runner runner.CommandRunner, provider bootstrapConstraints: config.Juju.BootstrapConstraints, modelDefaults: config.Juju.ModelDefaults, providers: providers, - runner: runner, - snaps: []packages.SnapPackage{packages.NewSnap("juju", channel)}, + runner: r, + snaps: []*runner.Snap{{Name: "juju", Channel: channel}}, } } @@ -42,7 +42,7 @@ type JujuHandler struct { modelDefaults map[string]string providers []providers.Provider runner runner.CommandRunner - snaps []packages.SnapPackage + snaps []*runner.Snap } // Prepare bootstraps Juju on the configured providers. diff --git a/internal/juju/juju_test.go b/internal/juju/juju_test.go index 46633db..dfc3cfe 100644 --- a/internal/juju/juju_test.go +++ b/internal/juju/juju_test.go @@ -2,14 +2,12 @@ package juju import ( "fmt" - "os" "reflect" "testing" "github.com/jnsgruk/concierge/internal/config" - "github.com/jnsgruk/concierge/internal/packages" "github.com/jnsgruk/concierge/internal/providers" - "github.com/jnsgruk/concierge/internal/runnertest" + "github.com/jnsgruk/concierge/internal/runner" ) var fakeGoogleCreds = []byte(`auth-type: oauth2 @@ -22,12 +20,12 @@ private-key: | project-id: concierge `) -func setupHandlerWithPreset(preset string) (*runnertest.MockRunner, *JujuHandler, error) { +func setupHandlerWithPreset(preset string) (*runner.MockRunner, *JujuHandler, error) { var err error var cfg *config.Config var provider providers.Provider - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() runner.MockCommandReturn("sudo -u test-user juju show-controller concierge-lxd", []byte("not found"), fmt.Errorf("Test error")) runner.MockCommandReturn("sudo -u test-user juju show-controller concierge-microk8s", []byte("not found"), fmt.Errorf("Test error")) runner.MockCommandReturn("sudo -u test-user juju show-controller concierge-k8s", []byte("not found"), fmt.Errorf("Test error")) @@ -47,17 +45,17 @@ func setupHandlerWithPreset(preset string) (*runnertest.MockRunner, *JujuHandler } handler := NewJujuHandler(cfg, runner, []providers.Provider{provider}) - handler.snaps = []packages.SnapPackage{runnertest.NewTestSnap("juju", "", false, false)} + return runner, handler, nil } -func setupHandlerWithGoogleProvider() (*runnertest.MockRunner, *JujuHandler, error) { +func setupHandlerWithGoogleProvider() (*runner.MockRunner, *JujuHandler, error) { cfg := &config.Config{} cfg.Providers.Google.Enable = true cfg.Providers.Google.Bootstrap = true cfg.Providers.Google.CredentialsFile = "google.yaml" - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() runner.MockFile("google.yaml", fakeGoogleCreds) provider := providers.NewProvider("google", runner, cfg) @@ -68,15 +66,9 @@ func setupHandlerWithGoogleProvider() (*runnertest.MockRunner, *JujuHandler, err } handler := NewJujuHandler(cfg, runner, []providers.Provider{provider}) - handler.snaps = []packages.SnapPackage{runnertest.NewTestSnap("juju", "", false, false)} return runner, handler, nil } func TestJujuHandlerCommandsPresets(t *testing.T) { - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - os.Setenv("PATH", "") - defer os.Setenv("PATH", path) - type test struct { preset string expectedCommands []string @@ -171,11 +163,6 @@ func TestJujuHandlerWithCredentialedProvider(t *testing.T) { } func TestJujuRestoreNoKillController(t *testing.T) { - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - os.Setenv("PATH", "") - defer os.Setenv("PATH", path) - runner, handler, err := setupHandlerWithPreset("machine") if err != nil { t.Fatal(err.Error()) @@ -196,11 +183,6 @@ func TestJujuRestoreNoKillController(t *testing.T) { } func TestJujuRestoreKillController(t *testing.T) { - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - os.Setenv("PATH", "") - defer os.Setenv("PATH", path) - runner, handler, err := setupHandlerWithGoogleProvider() if err != nil { t.Fatal(err.Error()) diff --git a/internal/packages/apt.go b/internal/packages/apt.go deleted file mode 100644 index 0bcdecf..0000000 --- a/internal/packages/apt.go +++ /dev/null @@ -1,11 +0,0 @@ -package packages - -// NewDeb constructs a new Deb instance. -func NewDeb(name string) *Deb { - return &Deb{Name: name} -} - -// Deb is a simple representation of a package installed from the Ubuntu archive. -type Deb struct { - Name string -} diff --git a/internal/packages/deb_handler.go b/internal/packages/deb_handler.go index 7579cdb..c8b087f 100644 --- a/internal/packages/deb_handler.go +++ b/internal/packages/deb_handler.go @@ -7,6 +7,16 @@ import ( "github.com/jnsgruk/concierge/internal/runner" ) +// NewDeb constructs a new Deb instance. +func NewDeb(name string) *Deb { + return &Deb{Name: name} +} + +// Deb is a simple representation of a package installed from the Ubuntu archive. +type Deb struct { + Name string +} + // NewDebHandler constructs a new instance of a DebHandler. func NewDebHandler(runner runner.CommandRunner, debs []*Deb) *DebHandler { return &DebHandler{ diff --git a/internal/packages/deb_handler_test.go b/internal/packages/deb_handler_test.go index 1417b35..62d0136 100644 --- a/internal/packages/deb_handler_test.go +++ b/internal/packages/deb_handler_test.go @@ -1,11 +1,10 @@ package packages import ( - "os" "reflect" "testing" - "github.com/jnsgruk/concierge/internal/runnertest" + "github.com/jnsgruk/concierge/internal/runner" ) func TestDebHandlerCommands(t *testing.T) { @@ -14,11 +13,6 @@ func TestDebHandlerCommands(t *testing.T) { expected []string } - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - defer os.Setenv("PATH", path) - os.Setenv("PATH", "") - tests := []test{ { func(d *DebHandler) { d.Prepare() }, @@ -44,7 +38,7 @@ func TestDebHandlerCommands(t *testing.T) { } for _, tc := range tests { - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() tc.testFunc(NewDebHandler(runner, debs)) if !reflect.DeepEqual(tc.expected, runner.ExecutedCommands) { diff --git a/internal/packages/snap.go b/internal/packages/snap.go deleted file mode 100644 index 425c243..0000000 --- a/internal/packages/snap.go +++ /dev/null @@ -1,135 +0,0 @@ -package packages - -import ( - "context" - "fmt" - "log/slog" - "strings" - "time" - - retry "github.com/sethvargo/go-retry" - "github.com/snapcore/snapd/client" -) - -// SnapPackage is an interface with methods that can describe a snap package -type SnapPackage interface { - // Name reports the name of the snap. - Name() string - // Installed reports if the snap is currently Installed. - Installed() bool - // Classic reports whether or not the snap at the tip of the specified channel uses - // Classic confinement or not. - Classic() (bool, error) - // tracking reports which channel an installed snap is tracking. - Tracking() (string, error) - // Channel reports the snap channel. - Channel() string - // SetChannel sets the snap channel. - SetChannel(c string) -} - -// NewSnapFromString returns a constructed snap instance, where the snap is -// specified in shorthand form, i.e. `charmcraft/latest/edge`. -func NewSnapFromString(snap string) *Snap { - before, after, found := strings.Cut(snap, "/") - if found { - return NewSnap(before, after) - } else { - return NewSnap(before, "") - } -} - -// NewSnap constructs a new Snap instance. -func NewSnap(name string, channel string) *Snap { - return &Snap{name: name, channel: channel, client: client.New(nil)} -} - -// Snap represents a snap package on the system. -type Snap struct { - name string - channel string - - client *client.Client -} - -// Name reports the name of the snap. -func (s *Snap) Name() string { return s.name } - -// Channel reports the snap channel. -func (s *Snap) Channel() string { return s.channel } - -// SetChannel sets the snap channel. -func (s *Snap) SetChannel(c string) { s.channel = c } - -// Installed is a helper that reports if the snap is currently Installed. -func (s *Snap) Installed() bool { - slog.Debug("Querying snap install status", "snap", s.name) - - snap, err := s.withRetry(func(ctx context.Context) (*client.Snap, error) { - snap, _, err := s.client.Snap(s.name) - if err != nil && strings.Contains(err.Error(), "snap not installed") { - return snap, nil - } else if err != nil { - return nil, retry.RetryableError(err) - } - return snap, nil - }) - if err != nil || snap == nil { - return false - } - - return snap.Status == client.StatusActive -} - -// Classic reports whether or not the snap at the tip of the specified channel uses -// Classic confinement or not. -func (s *Snap) Classic() (bool, error) { - slog.Debug("Querying snap confinement", "snap", s.name) - - snap, err := s.withRetry(func(ctx context.Context) (*client.Snap, error) { - snap, _, err := s.client.FindOne(s.name) - if err != nil { - return nil, retry.RetryableError(err) - } - return snap, nil - }) - if err != nil { - return false, fmt.Errorf("failed to find snap: %w", err) - } - - channel, ok := snap.Channels[s.channel] - if ok { - return channel.Confinement == "classic", nil - } - - return snap.Confinement == "classic", nil -} - -// Tracking reports which channel an installed snap is tracking. -func (s *Snap) Tracking() (string, error) { - slog.Debug("Querying snap channel tracking", "snap", s.name) - - snap, err := s.withRetry(func(ctx context.Context) (*client.Snap, error) { - snap, _, err := s.client.Snap(s.name) - if err != nil { - return nil, retry.RetryableError(err) - } - return snap, nil - }) - if err != nil { - return "", fmt.Errorf("failed to find snap: %w", err) - } - - if snap.Status == client.StatusActive { - return snap.TrackingChannel, nil - } else { - return "", fmt.Errorf("snap '%s' is not installed", s.name) - } -} - -func (s *Snap) withRetry(f func(ctx context.Context) (*client.Snap, error)) (*client.Snap, error) { - backoff := retry.NewExponential(1 * time.Second) - backoff = retry.WithMaxRetries(10, backoff) - ctx := context.Background() - return retry.DoValue(ctx, backoff, f) -} diff --git a/internal/packages/snap_handler.go b/internal/packages/snap_handler.go index c6dce80..ab01c95 100644 --- a/internal/packages/snap_handler.go +++ b/internal/packages/snap_handler.go @@ -8,7 +8,7 @@ import ( ) // NewSnapHandler constructs a new instance of a SnapHandler. -func NewSnapHandler(runner runner.CommandRunner, snaps []SnapPackage) *SnapHandler { +func NewSnapHandler(runner runner.CommandRunner, snaps []*runner.Snap) *SnapHandler { return &SnapHandler{ Snaps: snaps, runner: runner, @@ -17,7 +17,7 @@ func NewSnapHandler(runner runner.CommandRunner, snaps []SnapPackage) *SnapHandl // SnapHandler can install or remove a set of snaps. type SnapHandler struct { - Snaps []SnapPackage + Snaps []*runner.Snap runner runner.CommandRunner } @@ -45,9 +45,16 @@ func (h *SnapHandler) Restore() error { // installSnap ensures that the specified snap is installed at the specified channel. // If already installed, but on the wrong channel, the snap is refreshed. -func (h *SnapHandler) installSnap(s SnapPackage) error { +func (h *SnapHandler) installSnap(s *runner.Snap) error { + slog.Debug("Installing snap", "snap", s.Name) var action, logAction string - if s.Installed() { + + snapInfo, err := h.runner.SnapInfo(s.Name, s.Channel) + if err != nil { + return fmt.Errorf("failed to lookup snap details: %w", err) + } + + if snapInfo.Installed { action = "refresh" logAction = "Refreshed" } else { @@ -55,18 +62,13 @@ func (h *SnapHandler) installSnap(s SnapPackage) error { logAction = "Installed" } - args := []string{action, s.Name()} - - if s.Channel() != "" { - args = append(args, "--channel", s.Channel()) - } + args := []string{action, s.Name} - classic, err := s.Classic() - if err != nil { - return fmt.Errorf("failed to determine if snap '%s' is classic: %w", s.Name(), err) + if s.Channel != "" { + args = append(args, "--channel", s.Channel) } - if classic { + if snapInfo.Classic { args = append(args, "--classic") } @@ -76,25 +78,21 @@ func (h *SnapHandler) installSnap(s SnapPackage) error { return fmt.Errorf("command failed: %w", err) } - trackingChannel, err := s.Tracking() - if err != nil { - return fmt.Errorf("failed to resolve which channel the '%s' snap is tracking: %w", s.Name(), err) - } - - slog.Info(fmt.Sprintf("%s snap", logAction), "snap", s.Name(), "channel", trackingChannel) + slog.Info(fmt.Sprintf("%s snap", logAction), "snap", s.Name) return nil } // Remove uninstalls the specified snap from the system, optionally purging its data. -func (h *SnapHandler) removeSnap(s SnapPackage) error { - args := []string{"remove", s.Name(), "--purge"} +func (h *SnapHandler) removeSnap(s *runner.Snap) error { + slog.Debug("Removing snap", "snap", s.Name) + args := []string{"remove", s.Name, "--purge"} cmd := runner.NewCommand("snap", args) _, err := h.runner.RunExclusive(cmd) if err != nil { - return fmt.Errorf("failed to remove snap '%s': %w", s.Name(), err) + return fmt.Errorf("failed to remove snap '%s': %w", s.Name, err) } - slog.Info("Removed snap", "snap", s.Name()) + slog.Info("Removed snap", "snap", s.Name) return nil } diff --git a/internal/packages/snap_handler_test.go b/internal/packages/snap_handler_test.go index 9f5d91c..01d0c37 100644 --- a/internal/packages/snap_handler_test.go +++ b/internal/packages/snap_handler_test.go @@ -1,11 +1,10 @@ package packages import ( - "os" "reflect" "testing" - "github.com/jnsgruk/concierge/internal/runnertest" + "github.com/jnsgruk/concierge/internal/runner" ) func TestSnapHandlerCommands(t *testing.T) { @@ -14,12 +13,6 @@ func TestSnapHandlerCommands(t *testing.T) { expected []string } - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - defer os.Setenv("PATH", path) - os.Setenv("PATH", "") - // Reset the PATH variable - tests := []test{ { func(s *SnapHandler) { s.Prepare() }, @@ -39,18 +32,20 @@ func TestSnapHandlerCommands(t *testing.T) { }, } - snaps := []SnapPackage{ - runnertest.NewTestSnap("charmcraft", "latest/stable", true, true), - runnertest.NewTestSnap("jq", "latest/stable", false, false), - runnertest.NewTestSnap("microk8s", "1.30-strict/stable", false, false), - } - for _, tc := range tests { - runner := runnertest.NewMockRunner() - tc.testFunc(NewSnapHandler(runner, snaps)) + r := runner.NewMockRunner() + r.MockSnapStoreLookup("charmcraft", "latest/stable", true, true) + + snaps := []*runner.Snap{ + r.NewSnap("charmcraft", "latest/stable"), + r.NewSnap("jq", "latest/stable"), + r.NewSnap("microk8s", "1.30-strict/stable"), + } + + tc.testFunc(NewSnapHandler(r, snaps)) - if !reflect.DeepEqual(tc.expected, runner.ExecutedCommands) { - t.Fatalf("expected: %v, got: %v", tc.expected, runner.ExecutedCommands) + if !reflect.DeepEqual(tc.expected, r.ExecutedCommands) { + t.Fatalf("expected: %v, got: %v", tc.expected, r.ExecutedCommands) } } diff --git a/internal/providers/google_test.go b/internal/providers/google_test.go index d760ca9..402572c 100644 --- a/internal/providers/google_test.go +++ b/internal/providers/google_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/jnsgruk/concierge/internal/config" - "github.com/jnsgruk/concierge/internal/runnertest" + "github.com/jnsgruk/concierge/internal/runner" "gopkg.in/yaml.v3" ) @@ -23,7 +23,7 @@ func TestNewGoogle(t *testing.T) { overrides := &config.Config{} overrides.Overrides.GoogleCredentialFile = "/home/ubuntu/alternate-credentials.yaml" - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() tests := []test{ { @@ -63,7 +63,7 @@ func TestGooglePrepareCommands(t *testing.T) { config := &config.Config{} config.Providers.Google.CredentialsFile = "/home/ubuntu/credentials.yaml" - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() uk8s := NewGoogle(runner, config) uk8s.Prepare() @@ -80,7 +80,7 @@ func TestGoogleReadCredentials(t *testing.T) { config := &config.Config{} config.Providers.Google.CredentialsFile = "credentials.yaml" - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() creds := []byte(`auth-type: oauth2 client-email: juju-gce-1-sa@concierge.iam.gserviceaccount.com diff --git a/internal/providers/k8s.go b/internal/providers/k8s.go index 304d516..12361fb 100644 --- a/internal/providers/k8s.go +++ b/internal/providers/k8s.go @@ -15,7 +15,7 @@ import ( const defaultK8sChannel = "1.31/candidate" // NewK8s constructs a new K8s provider instance. -func NewK8s(runner runner.CommandRunner, config *config.Config) *K8s { +func NewK8s(r runner.CommandRunner, config *config.Config) *K8s { var channel string if config.Overrides.K8sChannel != "" { @@ -30,10 +30,10 @@ func NewK8s(runner runner.CommandRunner, config *config.Config) *K8s { Channel: channel, Features: config.Providers.K8s.Features, bootstrap: config.Providers.K8s.Bootstrap, - runner: runner, - snaps: []packages.SnapPackage{ - packages.NewSnap("k8s", channel), - packages.NewSnap("kubectl", "stable"), + runner: r, + snaps: []*runner.Snap{ + {Name: "k8s", Channel: channel}, + {Name: "kubectl", Channel: "stable"}, }, } } @@ -45,7 +45,7 @@ type K8s struct { bootstrap bool runner runner.CommandRunner - snaps []packages.SnapPackage + snaps []*runner.Snap } // Prepare installs and configures K8s such that it can work in testing environments. diff --git a/internal/providers/k8s_test.go b/internal/providers/k8s_test.go index 15901ca..162c49a 100644 --- a/internal/providers/k8s_test.go +++ b/internal/providers/k8s_test.go @@ -1,14 +1,12 @@ package providers import ( - "os" "reflect" "slices" "testing" "github.com/jnsgruk/concierge/internal/config" - "github.com/jnsgruk/concierge/internal/packages" - "github.com/jnsgruk/concierge/internal/runnertest" + "github.com/jnsgruk/concierge/internal/runner" ) var defaultFeatureConfig = map[string]map[string]string{ @@ -34,7 +32,7 @@ func TestNewK8s(t *testing.T) { overrides.Overrides.K8sChannel = "1.32/edge" overrides.Providers.K8s.Features = defaultFeatureConfig - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() tests := []test{ { @@ -55,8 +53,8 @@ func TestNewK8s(t *testing.T) { ck8s := NewK8s(runner, tc.config) // Check the constructed snaps are correct - if ck8s.snaps[0].Channel() != tc.expected.Channel { - t.Fatalf("expected: %v, got: %v", ck8s.snaps[0].Channel(), tc.expected.Channel) + if ck8s.snaps[0].Channel != tc.expected.Channel { + t.Fatalf("expected: %v, got: %v", ck8s.snaps[0].Channel, tc.expected.Channel) } // Remove the snaps so the rest of the object can be compared @@ -68,17 +66,13 @@ func TestNewK8s(t *testing.T) { } func TestK8sPrepareCommands(t *testing.T) { - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - defer os.Setenv("PATH", path) - os.Setenv("PATH", "") - config := &config.Config{} config.Providers.K8s.Channel = "" config.Providers.K8s.Features = defaultFeatureConfig expectedCommands := []string{ - "snap install k8s", + "snap install k8s --channel 1.31/candidate", + "snap install kubectl --channel stable", "k8s bootstrap", "k8s status --wait-ready", "k8s set load-balancer.l2-mode=true", @@ -92,14 +86,8 @@ func TestK8sPrepareCommands(t *testing.T) { ".kube/config": "", } - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() ck8s := NewK8s(runner, config) - - // Override the snaps with fake ones that don't call the snapd socket. - ck8s.snaps = []packages.SnapPackage{ - runnertest.NewTestSnap("k8s", "", false, false), - } - ck8s.Prepare() slices.Sort(expectedCommands) @@ -115,16 +103,11 @@ func TestK8sPrepareCommands(t *testing.T) { } func TestK8sRestore(t *testing.T) { - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - defer os.Setenv("PATH", path) - os.Setenv("PATH", "") - config := &config.Config{} config.Providers.K8s.Channel = "" config.Providers.K8s.Features = defaultFeatureConfig - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() ck8s := NewK8s(runner, config) ck8s.Restore() diff --git a/internal/providers/lxd.go b/internal/providers/lxd.go index 2509835..3e72be0 100644 --- a/internal/providers/lxd.go +++ b/internal/providers/lxd.go @@ -10,7 +10,7 @@ import ( ) // NewLXD constructs a new LXD provider instance. -func NewLXD(runner runner.CommandRunner, config *config.Config) *LXD { +func NewLXD(r runner.CommandRunner, config *config.Config) *LXD { var channel string if config.Overrides.LXDChannel != "" { channel = config.Overrides.LXDChannel @@ -20,9 +20,9 @@ func NewLXD(runner runner.CommandRunner, config *config.Config) *LXD { return &LXD{ Channel: channel, - runner: runner, + runner: r, bootstrap: config.Providers.LXD.Bootstrap, - snaps: []packages.SnapPackage{packages.NewSnap("lxd", channel)}, + snaps: []*runner.Snap{{Name: "lxd", Channel: channel}}, } } @@ -32,7 +32,7 @@ type LXD struct { bootstrap bool runner runner.CommandRunner - snaps []packages.SnapPackage + snaps []*runner.Snap } // Prepare installs and configures LXD such that it can work in testing environments. diff --git a/internal/providers/lxd_test.go b/internal/providers/lxd_test.go index def04ec..7532676 100644 --- a/internal/providers/lxd_test.go +++ b/internal/providers/lxd_test.go @@ -1,13 +1,11 @@ package providers import ( - "os" "reflect" "testing" "github.com/jnsgruk/concierge/internal/config" - "github.com/jnsgruk/concierge/internal/packages" - "github.com/jnsgruk/concierge/internal/runnertest" + "github.com/jnsgruk/concierge/internal/runner" ) func TestNewLXD(t *testing.T) { @@ -24,7 +22,7 @@ func TestNewLXD(t *testing.T) { overrides := &config.Config{} overrides.Overrides.LXDChannel = "5.20/stable" - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() tests := []test{ {config: noOverrides, expected: &LXD{Channel: "", runner: runner}}, @@ -36,8 +34,8 @@ func TestNewLXD(t *testing.T) { lxd := NewLXD(runner, tc.config) // Check the constructed snaps are correct - if lxd.snaps[0].Channel() != tc.expected.Channel { - t.Fatalf("expected: %v, got: %v", lxd.snaps[0].Channel(), tc.expected.Channel) + if lxd.snaps[0].Channel != tc.expected.Channel { + t.Fatalf("expected: %v, got: %v", lxd.snaps[0].Channel, tc.expected.Channel) } // Remove the snaps so the rest of the object can be compared @@ -49,15 +47,10 @@ func TestNewLXD(t *testing.T) { } func TestLXDPrepareCommands(t *testing.T) { - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - defer os.Setenv("PATH", path) - os.Setenv("PATH", "") - config := &config.Config{} expected := []string{ - "snap install lxd --channel latest/stable", + "snap install lxd", "lxd waitready", "lxd init --minimal", "lxc network set lxdbr0 ipv6.address none", @@ -67,14 +60,8 @@ func TestLXDPrepareCommands(t *testing.T) { "iptables -P FORWARD ACCEPT", } - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() lxd := NewLXD(runner, config) - - // Override the snaps with fake ones that don't call the snapd socket. - lxd.snaps = []packages.SnapPackage{ - runnertest.NewTestSnap("lxd", "latest/stable", false, false), - } - lxd.Prepare() if !reflect.DeepEqual(expected, runner.ExecutedCommands) { @@ -83,14 +70,9 @@ func TestLXDPrepareCommands(t *testing.T) { } func TestLXDRestore(t *testing.T) { - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - defer os.Setenv("PATH", path) - os.Setenv("PATH", "") - config := &config.Config{} - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() lxd := NewLXD(runner, config) lxd.Restore() diff --git a/internal/providers/microk8s.go b/internal/providers/microk8s.go index 4c7603d..d450d40 100644 --- a/internal/providers/microk8s.go +++ b/internal/providers/microk8s.go @@ -21,7 +21,7 @@ import ( const defaultMicroK8sChannel = "1.31-strict/stable" // NewMicroK8s constructs a new MicroK8s provider instance. -func NewMicroK8s(runner runner.CommandRunner, config *config.Config) *MicroK8s { +func NewMicroK8s(r runner.CommandRunner, config *config.Config) *MicroK8s { var channel string if config.Overrides.MicroK8sChannel != "" { @@ -36,10 +36,10 @@ func NewMicroK8s(runner runner.CommandRunner, config *config.Config) *MicroK8s { Channel: channel, Addons: config.Providers.MicroK8s.Addons, bootstrap: config.Providers.MicroK8s.Bootstrap, - runner: runner, - snaps: []packages.SnapPackage{ - packages.NewSnap("microk8s", channel), - packages.NewSnap("kubectl", "stable"), + runner: r, + snaps: []*runner.Snap{ + {Name: "microk8s", Channel: channel}, + {Name: "kubectl", Channel: "stable"}, }, } } @@ -51,7 +51,7 @@ type MicroK8s struct { bootstrap bool runner runner.CommandRunner - snaps []packages.SnapPackage + snaps []*runner.Snap } // Prepare installs and configures MicroK8s such that it can work in testing environments. diff --git a/internal/providers/microk8s_test.go b/internal/providers/microk8s_test.go index 507424b..ca22597 100644 --- a/internal/providers/microk8s_test.go +++ b/internal/providers/microk8s_test.go @@ -1,13 +1,11 @@ package providers import ( - "os" "reflect" "testing" "github.com/jnsgruk/concierge/internal/config" - "github.com/jnsgruk/concierge/internal/packages" - "github.com/jnsgruk/concierge/internal/runnertest" + "github.com/jnsgruk/concierge/internal/runner" ) var defaultAddons []string = []string{ @@ -32,7 +30,7 @@ func TestNewMicroK8s(t *testing.T) { overrides.Overrides.MicroK8sChannel = "1.30/edge" overrides.Providers.MicroK8s.Addons = defaultAddons - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() tests := []test{ { @@ -53,8 +51,8 @@ func TestNewMicroK8s(t *testing.T) { uk8s := NewMicroK8s(runner, tc.config) // Check the constructed snaps are correct - if uk8s.snaps[0].Channel() != tc.expected.Channel { - t.Fatalf("expected: %v, got: %v", uk8s.snaps[0].Channel(), tc.expected.Channel) + if uk8s.snaps[0].Channel != tc.expected.Channel { + t.Fatalf("expected: %v, got: %v", uk8s.snaps[0].Channel, tc.expected.Channel) } // Remove the snaps so the rest of the object can be compared @@ -79,7 +77,7 @@ func TestMicroK8sGroupName(t *testing.T) { for _, tc := range tests { config := &config.Config{} config.Providers.MicroK8s.Channel = tc.channel - uk8s := NewMicroK8s(runnertest.NewMockRunner(), config) + uk8s := NewMicroK8s(runner.NewMockRunner(), config) if !reflect.DeepEqual(tc.expected, uk8s.GroupName()) { t.Fatalf("expected: %v, got: %v", tc.expected, uk8s.GroupName()) @@ -88,17 +86,13 @@ func TestMicroK8sGroupName(t *testing.T) { } func TestMicroK8sPrepareCommands(t *testing.T) { - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - defer os.Setenv("PATH", path) - os.Setenv("PATH", "") - config := &config.Config{} config.Providers.MicroK8s.Channel = "1.31-strict/stable" config.Providers.MicroK8s.Addons = defaultAddons expectedCommands := []string{ "snap install microk8s --channel 1.31-strict/stable", + "snap install kubectl --channel stable", "microk8s status --wait-ready", "microk8s enable hostpath-storage", "microk8s enable dns", @@ -112,14 +106,8 @@ func TestMicroK8sPrepareCommands(t *testing.T) { ".kube/config": "", } - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() uk8s := NewMicroK8s(runner, config) - - // Override the snaps with fake ones that don't call the snapd socket. - uk8s.snaps = []packages.SnapPackage{ - runnertest.NewTestSnap("microk8s", "1.31-strict/stable", false, false), - } - uk8s.Prepare() if !reflect.DeepEqual(expectedCommands, runner.ExecutedCommands) { @@ -132,16 +120,11 @@ func TestMicroK8sPrepareCommands(t *testing.T) { } func TestMicroK8sRestore(t *testing.T) { - // Prevent the path of the test machine interfering with the test results. - path := os.Getenv("PATH") - defer os.Setenv("PATH", path) - os.Setenv("PATH", "") - config := &config.Config{} config.Providers.MicroK8s.Channel = "1.31-strict/stable" config.Providers.MicroK8s.Addons = defaultAddons - runner := runnertest.NewMockRunner() + runner := runner.NewMockRunner() uk8s := NewMicroK8s(runner, config) uk8s.Restore() diff --git a/internal/runner/interface.go b/internal/runner/interface.go index 4e8a485..f539ce0 100644 --- a/internal/runner/interface.go +++ b/internal/runner/interface.go @@ -33,4 +33,12 @@ type CommandRunner interface { ReadHomeDirFile(filepath string) ([]byte, error) // ReadFile reads a file with an arbitrary path from the system. ReadFile(filePath string) ([]byte, error) + // SnapInfo returns information about a given snap, looking up details in the snap + // store using the snapd client API where necessary. + SnapInfo(snap string, channel string) (*SnapInfo, error) + // NewSnap returns a new Snap package. + NewSnap(snap, channel string) *Snap + // NewSnapFromString returns a constructed snap instance, where the snap is + // specified in shorthand form, i.e. `charmcraft/latest/edge`. + NewSnapFromString(snap string) *Snap } diff --git a/internal/runnertest/mock_runner.go b/internal/runner/mock_runner.go similarity index 64% rename from internal/runnertest/mock_runner.go rename to internal/runner/mock_runner.go index 6c77585..bce6957 100644 --- a/internal/runnertest/mock_runner.go +++ b/internal/runner/mock_runner.go @@ -1,20 +1,20 @@ -package runnertest +package runner import ( "fmt" "os" "os/user" + "strings" "time" - - "github.com/jnsgruk/concierge/internal/runner" ) -// NewMockRunner constructs a new mock command runner. +// NewMockRunner constructs a new mock command func NewMockRunner() *MockRunner { return &MockRunner{ CreatedFiles: map[string]string{}, mockReturns: map[string]MockCommandReturn{}, mockFiles: map[string][]byte{}, + mockSnapInfo: map[string]*SnapInfo{}, } } @@ -31,8 +31,9 @@ type MockRunner struct { CreatedDirectories []string Deleted []string - mockReturns map[string]MockCommandReturn - mockFiles map[string][]byte + mockFiles map[string][]byte + mockReturns map[string]MockCommandReturn + mockSnapInfo map[string]*SnapInfo } // MockCommandReturn sets a static return value representing command combined output, @@ -46,6 +47,15 @@ func (r *MockRunner) MockFile(filePath string, contents []byte) { r.mockFiles[filePath] = contents } +// MockSnapStoreLookup gets a new test snap and adds a mock snap into the mock test +func (r *MockRunner) MockSnapStoreLookup(name, channel string, classic, installed bool) *Snap { + r.mockSnapInfo[name] = &SnapInfo{ + Installed: installed, + Classic: classic, + } + return &Snap{Name: name, Channel: channel} +} + // User returns the user the runner executes commands on behalf of. func (r *MockRunner) User() *user.User { return &user.User{ @@ -57,7 +67,12 @@ func (r *MockRunner) User() *user.User { } // Run executes the command, returning the stdout/stderr where appropriate. -func (r *MockRunner) Run(c *runner.Command) ([]byte, error) { +func (r *MockRunner) Run(c *Command) ([]byte, error) { + // Prevent the path of the test machine interfering with the test results. + path := os.Getenv("PATH") + defer os.Setenv("PATH", path) + os.Setenv("PATH", "") + cmd := c.CommandString() r.ExecutedCommands = append(r.ExecutedCommands, cmd) @@ -71,13 +86,13 @@ func (r *MockRunner) Run(c *runner.Command) ([]byte, error) { // RunWithRetries executes the command, retrying utilising an exponential backoff pattern, // which starts at 1 second. Retries will be attempted up to the specified maximum duration. -func (r *MockRunner) RunWithRetries(c *runner.Command, maxDuration time.Duration) ([]byte, error) { +func (r *MockRunner) RunWithRetries(c *Command, maxDuration time.Duration) ([]byte, error) { return r.Run(c) } // RunMany takes a variadic number of Command's, and runs them in a loop, returning // and error if any command fails. -func (r *MockRunner) RunMany(commands ...*runner.Command) error { +func (r *MockRunner) RunMany(commands ...*Command) error { for _, cmd := range commands { _, err := r.Run(cmd) if err != nil { @@ -89,7 +104,7 @@ func (r *MockRunner) RunMany(commands ...*runner.Command) error { // RunExclusive is a wrapper around Run that uses a mutex to ensure that only one of that // particular command can be run at a time. -func (r *MockRunner) RunExclusive(c *runner.Command) ([]byte, error) { +func (r *MockRunner) RunExclusive(c *Command) ([]byte, error) { return r.Run(c) } @@ -131,3 +146,33 @@ func (r *MockRunner) RemoveAllHome(filePath string) error { r.Deleted = append(r.Deleted, filePath) return nil } + +// SnapInfo returns information about a given snap, looking up details in the snap +// store using the snapd client API where necessary. +func (r *MockRunner) SnapInfo(snap string, channel string) (*SnapInfo, error) { + snapInfo, ok := r.mockSnapInfo[snap] + if ok { + return snapInfo, nil + } + + return &SnapInfo{ + Installed: false, + Classic: false, + }, nil +} + +// NewSnap returns a Snap object with details populated from the snap store and local system. +func (r *MockRunner) NewSnap(name, channel string) *Snap { + return &Snap{Name: name, Channel: channel} +} + +// NewSnapFromString returns a constructed snap instance, where the snap is +// specified in shorthand form, i.e. `charmcraft/latest/edge`. +func (r *MockRunner) NewSnapFromString(snap string) *Snap { + before, after, found := strings.Cut(snap, "/") + if found { + return r.NewSnap(before, after) + } else { + return r.NewSnap(before, "") + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 1ceb7d3..1c0524e 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -16,8 +16,8 @@ import ( "sync" "time" - "github.com/fatih/color" retry "github.com/sethvargo/go-retry" + client "github.com/snapcore/snapd/client" ) // NewRunner constructs a new command runner. @@ -30,6 +30,7 @@ func NewRunner(trace bool) (*Runner, error) { trace: trace, user: realUser, cmdMutexes: map[string]*sync.Mutex{}, + snapd: *client.New(nil), }, nil } @@ -37,6 +38,7 @@ func NewRunner(trace bool) (*Runner, error) { type Runner struct { trace bool user *user.User + snapd client.Client // Map of mutexes to prevent the concurrent execution of certain commands. cmdMutexes map[string]*sync.Mutex } @@ -217,54 +219,3 @@ func (r *Runner) chownRecursively(path string, user *user.User) error { slog.Debug("Filesystem ownership changed", "user", user.Username, "group", user.Gid, "path", path) return err } - -// generateTraceMessage creates a formatted string that is written to stdout, representing -// a command and it's output when concierge is run with `--trace`. -func generateTraceMessage(cmd string, output []byte) string { - green := color.New(color.FgGreen, color.Bold, color.Underline) - bold := color.New(color.Bold) - - result := fmt.Sprintf("%s %s\n", green.Sprintf("Command:"), bold.Sprintf(cmd)) - if len(output) > 0 { - result = fmt.Sprintf("%s%s\n%s", result, green.Sprintf("Output:"), string(output)) - } - return result -} - -// getShellPath tries to find the path to the user's preferred shell, as per the `SHELL“ -// environment variable. If that cannot be found, it looks for a path to "bash", and to -// "sh" in that order. If no shell can be found, then an error is returned. -func getShellPath() (string, error) { - // If the `SHELL` var is set, return that. - shellVar := os.Getenv("SHELL") - if len(shellVar) > 0 { - return shellVar, nil - } - - // Try both the command name (to lookup in PATH), and common default paths. - for _, shell := range []string{"bash", "/bin/bash", "sh", "/bin/sh"} { - // Check if the shell path exists - if _, err := os.Stat(shell); errors.Is(err, os.ErrNotExist) { - // If the path doesn't exist, the lookup the value in the `PATH` variable - path, err := exec.LookPath(shell) - if err != nil { - continue - } - return path, nil - } - return shell, nil - } - - return "", fmt.Errorf("could not find path to a shell") -} - -// realUser returns a user struct containing details of the "real" user, which -// may differ from the current user when concierge is executed with `sudo`. -func realUser() (*user.User, error) { - realUser := os.Getenv("SUDO_USER") - if len(realUser) == 0 { - return user.Lookup("root") - } - - return user.Lookup(realUser) -} diff --git a/internal/runner/snap.go b/internal/runner/snap.go new file mode 100644 index 0000000..453c00b --- /dev/null +++ b/internal/runner/snap.go @@ -0,0 +1,101 @@ +package runner + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + retry "github.com/sethvargo/go-retry" + client "github.com/snapcore/snapd/client" +) + +// SnapInfo represents information about a snap fetched from the snapd API. +type SnapInfo struct { + Installed bool + Classic bool +} + +// Snap represents a given snap on a given channel. +type Snap struct { + Name string + Channel string +} + +// NewSnap returns a new Snap package. +func (r *Runner) NewSnap(name, channel string) *Snap { + return &Snap{Name: name, Channel: channel} +} + +// NewSnapFromString returns a constructed snap instance, where the snap is +// specified in shorthand form, i.e. `charmcraft/latest/edge`. +func (r *Runner) NewSnapFromString(snap string) *Snap { + before, after, found := strings.Cut(snap, "/") + if found { + return r.NewSnap(before, after) + } else { + return r.NewSnap(before, "") + } +} + +// SnapInfo returns information about a given snap, looking up details in the snap +// store using the snapd client API where necessary. +func (r *Runner) SnapInfo(snap string, channel string) (*SnapInfo, error) { + classic, err := r.snapIsClassic(snap, channel) + if err != nil { + return nil, err + } + + installed := r.snapInstalled(snap) + + slog.Debug("Queried snapd API", "snap", snap, "installed", installed, "classic", classic) + return &SnapInfo{Installed: installed, Classic: classic}, nil +} + +// snapInstalled is a helper that reports if the snap is currently Installed. +func (r *Runner) snapInstalled(name string) bool { + s, err := r.withRetry(func(ctx context.Context) (*client.Snap, error) { + snap, _, err := r.snapd.Snap(name) + if err != nil && strings.Contains(err.Error(), "snap not installed") { + return snap, nil + } else if err != nil { + return nil, retry.RetryableError(err) + } + return snap, nil + }) + if err != nil || s == nil { + return false + } + + return s.Status == client.StatusActive +} + +// snapIsClassic reports whether or not the snap at the tip of the specified channel uses +// Classic confinement or not. +func (r *Runner) snapIsClassic(name, channel string) (bool, error) { + snap, err := r.withRetry(func(ctx context.Context) (*client.Snap, error) { + snap, _, err := r.snapd.FindOne(name) + if err != nil { + return nil, retry.RetryableError(err) + } + return snap, nil + }) + if err != nil { + return false, fmt.Errorf("failed to find snap: %w", err) + } + + c, ok := snap.Channels[channel] + if ok { + return c.Confinement == "classic", nil + } + + return snap.Confinement == "classic", nil +} + +func (r *Runner) withRetry(f func(ctx context.Context) (*client.Snap, error)) (*client.Snap, error) { + backoff := retry.NewExponential(1 * time.Second) + backoff = retry.WithMaxRetries(10, backoff) + ctx := context.Background() + return retry.DoValue(ctx, backoff, f) +} diff --git a/internal/packages/snap_test.go b/internal/runner/snap_test.go similarity index 52% rename from internal/packages/snap_test.go rename to internal/runner/snap_test.go index 5017085..cce72ed 100644 --- a/internal/packages/snap_test.go +++ b/internal/runner/snap_test.go @@ -1,4 +1,4 @@ -package packages +package runner import ( "testing" @@ -11,17 +11,19 @@ func TestNewSnapFromString(t *testing.T) { } tests := []test{ - {input: "juju", expected: &Snap{name: "juju"}}, - {input: "juju/latest/edge", expected: &Snap{name: "juju", channel: "latest/edge"}}, - {input: "juju/stable", expected: &Snap{name: "juju", channel: "stable"}}, + {input: "juju", expected: &Snap{Name: "juju"}}, + {input: "juju/latest/edge", expected: &Snap{Name: "juju", Channel: "latest/edge"}}, + {input: "juju/stable", expected: &Snap{Name: "juju", Channel: "stable"}}, } for _, tc := range tests { - snap := NewSnapFromString(tc.input) - if tc.expected.channel != snap.channel { + runner := NewMockRunner() + snap := runner.NewSnapFromString(tc.input) + + if tc.expected.Channel != snap.Channel { t.Fatalf("incorrect snap channel; expected: %v, got: %v", tc.expected, snap) } - if tc.expected.name != snap.name { + if tc.expected.Name != snap.Name { t.Fatalf("incorrect snap name; expected: %v, got: %v", tc.expected, snap) } } diff --git a/internal/runner/util.go b/internal/runner/util.go new file mode 100644 index 0000000..65c5c4e --- /dev/null +++ b/internal/runner/util.go @@ -0,0 +1,62 @@ +package runner + +import ( + "errors" + "fmt" + "os" + "os/exec" + "os/user" + + "github.com/fatih/color" +) + +// generateTraceMessage creates a formatted string that is written to stdout, representing +// a command and it's output when concierge is run with `--trace`. +func generateTraceMessage(cmd string, output []byte) string { + green := color.New(color.FgGreen, color.Bold, color.Underline) + bold := color.New(color.Bold) + + result := fmt.Sprintf("%s %s\n", green.Sprintf("Command:"), bold.Sprintf(cmd)) + if len(output) > 0 { + result = fmt.Sprintf("%s%s\n%s", result, green.Sprintf("Output:"), string(output)) + } + return result +} + +// getShellPath tries to find the path to the user's preferred shell, as per the `SHELL“ +// environment variable. If that cannot be found, it looks for a path to "bash", and to +// "sh" in that order. If no shell can be found, then an error is returned. +func getShellPath() (string, error) { + // If the `SHELL` var is set, return that. + shellVar := os.Getenv("SHELL") + if len(shellVar) > 0 { + return shellVar, nil + } + + // Try both the command name (to lookup in PATH), and common default paths. + for _, shell := range []string{"bash", "/bin/bash", "sh", "/bin/sh"} { + // Check if the shell path exists + if _, err := os.Stat(shell); errors.Is(err, os.ErrNotExist) { + // If the path doesn't exist, the lookup the value in the `PATH` variable + path, err := exec.LookPath(shell) + if err != nil { + continue + } + return path, nil + } + return shell, nil + } + + return "", fmt.Errorf("could not find path to a shell") +} + +// realUser returns a user struct containing details of the "real" user, which +// may differ from the current user when concierge is executed with `sudo`. +func realUser() (*user.User, error) { + realUser := os.Getenv("SUDO_USER") + if len(realUser) == 0 { + return user.Lookup("root") + } + + return user.Lookup(realUser) +} diff --git a/internal/runnertest/mock_snap.go b/internal/runnertest/mock_snap.go deleted file mode 100644 index cba99b7..0000000 --- a/internal/runnertest/mock_snap.go +++ /dev/null @@ -1,19 +0,0 @@ -package runnertest - -func NewTestSnap(name, channel string, classic bool, installed bool) *TestSnap { - return &TestSnap{name: name, channel: channel, classic: classic, installed: installed} -} - -type TestSnap struct { - name string - channel string - classic bool - installed bool -} - -func (ts *TestSnap) Name() string { return ts.name } -func (ts *TestSnap) Classic() (bool, error) { return ts.classic, nil } -func (ts *TestSnap) Installed() bool { return ts.installed } -func (ts *TestSnap) Tracking() (string, error) { return ts.channel, nil } -func (ts *TestSnap) Channel() string { return ts.channel } -func (ts *TestSnap) SetChannel(c string) { ts.channel = c }