diff --git a/internal/cmd/skupper/common/flags.go b/internal/cmd/skupper/common/flags.go index f1929880c..464855880 100644 --- a/internal/cmd/skupper/common/flags.go +++ b/internal/cmd/skupper/common/flags.go @@ -92,6 +92,12 @@ for other Kubernetes flavors, loadbalancer is the default.` FlagNameReloadType = "reload-type" FlagDescReloadType = "Specify the type of reload to perform. Choices: manual, auto" + + FlagNameNetworkObserverUninstall = "uninstall" + FlagDescNetworkObserverUninstall = "Uninstall Network Observer" + + FlagNamePrometheusUninstall = "uninstall" + FlagDescPrometheusUninstall = "Uninstall Prometheus" ) type CommandSiteCreateFlags struct { @@ -245,6 +251,14 @@ type CommandListenerGenerateFlags struct { Output string } +type CommandNetworkObserverFlags struct { + Uninstall bool +} + +type CommandPrometheusFlags struct { + Uninstall bool +} + type CommandVersionFlags struct { Output string } diff --git a/internal/cmd/skupper/system/kube/system_network-observer.go b/internal/cmd/skupper/system/kube/system_network-observer.go new file mode 100644 index 000000000..c1e38a229 --- /dev/null +++ b/internal/cmd/skupper/system/kube/system_network-observer.go @@ -0,0 +1,39 @@ +package kube + +import ( + "fmt" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + "github.com/spf13/cobra" +) + +type CmdSystemNetworkObserver struct { + CobraCmd *cobra.Command + Flags *common.CommandNetworkObserverFlags + namespace string + user string + password string +} + +func NewCmdSystemNetworkObserver() *CmdSystemNetworkObserver { + return &CmdSystemNetworkObserver{} +} + +func (cmd *CmdSystemNetworkObserver) NewClient(cobraCommand *cobra.Command, args []string) {} + +func (cmd *CmdSystemNetworkObserver) ValidateInput(args []string) error { + return nil +} + +func (cmd *CmdSystemNetworkObserver) InputToOptions() { + +} + +func (cmd *CmdSystemNetworkObserver) Run() error { + fmt.Println("This command does not support kubernetes platforms.") + return nil +} + +func (cmd *CmdSystemNetworkObserver) WaitUntil() error { + return nil +} diff --git a/internal/cmd/skupper/system/kube/system_prometheus.go b/internal/cmd/skupper/system/kube/system_prometheus.go new file mode 100644 index 000000000..1bd88868b --- /dev/null +++ b/internal/cmd/skupper/system/kube/system_prometheus.go @@ -0,0 +1,34 @@ +package kube + +import ( + "fmt" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + "github.com/spf13/cobra" +) + +type CmdSystemPrometheus struct { + CobraCmd *cobra.Command + Flags *common.CommandPrometheusFlags +} + +func NewCmdSystemPrometheus() *CmdSystemPrometheus { + return &CmdSystemPrometheus{} +} + +func (cmd *CmdSystemPrometheus) NewClient(cobraCommand *cobra.Command, args []string) {} + +func (cmd *CmdSystemPrometheus) ValidateInput(args []string) error { + return nil +} + +func (cmd *CmdSystemPrometheus) InputToOptions() {} + +func (cmd *CmdSystemPrometheus) Run() error { + fmt.Println("This command does not support kubernetes platforms.") + return nil +} + +func (cmd *CmdSystemPrometheus) WaitUntil() error { + return nil +} diff --git a/internal/cmd/skupper/system/kube/system_prometheus_test.go b/internal/cmd/skupper/system/kube/system_prometheus_test.go new file mode 100644 index 000000000..379f788e7 --- /dev/null +++ b/internal/cmd/skupper/system/kube/system_prometheus_test.go @@ -0,0 +1,46 @@ +package kube + +import ( + "testing" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" +) + +func TestCmdSystemPrometheus_ValidateInput(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "no arguments"}, + {name: "arguments are accepted", args: []string{"something"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := &CmdSystemPrometheus{Flags: &common.CommandPrometheusFlags{}} + if err := cmd.ValidateInput(test.args); err != nil { + t.Fatalf("expected no error, got %q", err) + } + }) + } +} + +func TestCmdSystemPrometheus_Run(t *testing.T) { + // The kube Run prints a not-supported message and always returns nil. + cmd := &CmdSystemPrometheus{Flags: &common.CommandPrometheusFlags{}} + if err := cmd.Run(); err != nil { + t.Fatalf("expected no error, got %q", err) + } +} + +func TestCmdSystemPrometheus_WaitUntil(t *testing.T) { + cmd := &CmdSystemPrometheus{} + if err := cmd.WaitUntil(); err != nil { + t.Fatalf("expected no error, got %q", err) + } +} + +func TestCmdSystemPrometheus_InputToOptions(t *testing.T) { + cmd := &CmdSystemPrometheus{Flags: &common.CommandPrometheusFlags{}} + cmd.InputToOptions() +} diff --git a/internal/cmd/skupper/system/nonkube/system_network-observer.go b/internal/cmd/skupper/system/nonkube/system_network-observer.go new file mode 100644 index 000000000..2de97af89 --- /dev/null +++ b/internal/cmd/skupper/system/nonkube/system_network-observer.go @@ -0,0 +1,79 @@ +package nonkube + +import ( + "errors" + "fmt" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + networkobserver "github.com/skupperproject/skupper/internal/nonkube/network-observer" + "github.com/spf13/cobra" +) + +type CmdSystemNetworkObserver struct { + CobraCmd *cobra.Command + Flags *common.CommandNetworkObserverFlags + namespace string +} + +func NewCmdSystemNetworkObserver() *CmdSystemNetworkObserver { + return &CmdSystemNetworkObserver{} +} + +func (cmd *CmdSystemNetworkObserver) NewClient(cobraCommand *cobra.Command, args []string) { + if cmd.CobraCmd != nil && cmd.CobraCmd.Flag(common.FlagNameNamespace) != nil { + cmd.namespace = cmd.CobraCmd.Flag(common.FlagNameNamespace).Value.String() + } + if cmd.namespace == "" { + cmd.namespace = "default" + } +} + +func (cmd *CmdSystemNetworkObserver) ValidateInput(args []string) error { + var validationErrors []error + + if len(args) > 0 { + validationErrors = append(validationErrors, fmt.Errorf("this command does not accept arguments")) + } + + return errors.Join(validationErrors...) +} + +func (cmd *CmdSystemNetworkObserver) InputToOptions() {} + +func (cmd *CmdSystemNetworkObserver) Run() error { + installer, err := networkobserver.NewInstaller(cmd.namespace) + if err != nil { + return fmt.Errorf("failed to create installer: %w", err) + } + + if cmd.Flags.Uninstall { + + if err := installer.ValidatePrerequisitesForUninstall(); err != nil { + return err + } + + if err := installer.Uninstall(); err != nil { + return fmt.Errorf("uninstallation failed: %w", err) + } + + return nil + } + + if err := installer.ValidatePrerequisitesForInstall(); err != nil { + return fmt.Errorf("prerequisite validation failed: %w", err) + } + + result, err := installer.Install() + if err != nil { + return fmt.Errorf("installation failed: %w", err) + } + + fmt.Println("Network observer installed successfully!") + fmt.Printf("\nAccess URL: %s\n", result.URL) + + return nil +} + +func (cmd *CmdSystemNetworkObserver) WaitUntil() error { + return nil +} diff --git a/internal/cmd/skupper/system/nonkube/system_network-observer_test.go b/internal/cmd/skupper/system/nonkube/system_network-observer_test.go new file mode 100644 index 000000000..bcbbfa748 --- /dev/null +++ b/internal/cmd/skupper/system/nonkube/system_network-observer_test.go @@ -0,0 +1,102 @@ +package nonkube + +import ( + "strings" + "testing" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + "github.com/spf13/cobra" +) + +func TestCmdSystemNetworkObserverValidateInput(t *testing.T) { + tests := []struct { + name string + args []string + flags *common.CommandNetworkObserverFlags + expectedError []string + }{ + { + name: "rejects arguments", + args: []string{"extra"}, + expectedError: []string{"this command does not accept arguments"}, + }, + { + name: "accepts no arguments", + flags: &common.CommandNetworkObserverFlags{}, + }, + { + name: "accepts uninstall flag", + flags: &common.CommandNetworkObserverFlags{Uninstall: true}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := &CmdSystemNetworkObserver{Flags: test.flags} + + err := cmd.ValidateInput(test.args) + if len(test.expectedError) == 0 { + if err != nil { + t.Fatalf("expected no error, got %q", err.Error()) + } + return + } + + if err == nil { + t.Fatal("expected error") + } + for _, expected := range test.expectedError { + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected validation error %q, got %q", expected, err.Error()) + } + } + }) + } +} + +func TestCmdSystemNetworkObserverNewClient(t *testing.T) { + tests := []struct { + name string + flagNamespace string + initialNamespace string + expectedNamespace string + }{ + { + name: "uses namespace flag", + flagNamespace: "west", + expectedNamespace: "west", + }, + { + name: "defaults namespace", + expectedNamespace: "default", + }, + { + name: "defaults even when namespace was preset without flag", + initialNamespace: "east", + expectedNamespace: "default", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cobraCmd := &cobra.Command{} + cobraCmd.Flags().String(common.FlagNameNamespace, "", "") + if test.flagNamespace != "" { + if err := cobraCmd.Flags().Set(common.FlagNameNamespace, test.flagNamespace); err != nil { + t.Fatalf("failed to set namespace flag: %v", err) + } + } + + cmd := &CmdSystemNetworkObserver{ + CobraCmd: cobraCmd, + namespace: test.initialNamespace, + } + + cmd.NewClient(cobraCmd, nil) + + if cmd.namespace != test.expectedNamespace { + t.Fatalf("expected namespace %q, got %q", test.expectedNamespace, cmd.namespace) + } + }) + } +} diff --git a/internal/cmd/skupper/system/nonkube/system_prometheus.go b/internal/cmd/skupper/system/nonkube/system_prometheus.go new file mode 100644 index 000000000..4673dda9e --- /dev/null +++ b/internal/cmd/skupper/system/nonkube/system_prometheus.go @@ -0,0 +1,79 @@ +package nonkube + +import ( + "errors" + "fmt" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + networkobserver "github.com/skupperproject/skupper/internal/nonkube/network-observer" + "github.com/spf13/cobra" +) + +type CmdSystemPrometheus struct { + CobraCmd *cobra.Command + Flags *common.CommandPrometheusFlags + Install func() error + Uninstall func() error +} + +func NewCmdSystemPrometheus() *CmdSystemPrometheus { + return &CmdSystemPrometheus{} +} + +func (cmd *CmdSystemPrometheus) NewClient(cobraCommand *cobra.Command, args []string) { + installer, err := networkobserver.NewPrometheusInstaller() + if err != nil { + return + } + cmd.Install = func() error { + if err := installer.ValidatePrerequisitesForInstall(); err != nil { + return fmt.Errorf("prerequisite validation failed: %w", err) + } + return installer.Install() + } + cmd.Uninstall = func() error { + if err := installer.ValidatePrerequisitesForUninstall(); err != nil { + return err + } + return installer.Uninstall() + } +} + +func (cmd *CmdSystemPrometheus) ValidateInput(args []string) error { + var validationErrors []error + + if len(args) > 0 { + validationErrors = append(validationErrors, fmt.Errorf("this command does not accept arguments")) + } + + return errors.Join(validationErrors...) +} + +func (cmd *CmdSystemPrometheus) InputToOptions() {} + +func (cmd *CmdSystemPrometheus) Run() error { + if cmd.Flags.Uninstall { + if cmd.Uninstall == nil { + return fmt.Errorf("failed to create prometheus installer") + } + if err := cmd.Uninstall(); err != nil { + return fmt.Errorf("uninstallation failed: %w", err) + } + fmt.Println("Prometheus uninstalled successfully!") + return nil + } + + if cmd.Install == nil { + return fmt.Errorf("failed to create prometheus installer") + } + if err := cmd.Install(); err != nil { + return fmt.Errorf("installation failed: %w", err) + } + + fmt.Println("Prometheus installed successfully!") + return nil +} + +func (cmd *CmdSystemPrometheus) WaitUntil() error { + return nil +} diff --git a/internal/cmd/skupper/system/nonkube/system_prometheus_test.go b/internal/cmd/skupper/system/nonkube/system_prometheus_test.go new file mode 100644 index 000000000..a7d5b6533 --- /dev/null +++ b/internal/cmd/skupper/system/nonkube/system_prometheus_test.go @@ -0,0 +1,128 @@ +package nonkube + +import ( + "fmt" + "strings" + "testing" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" +) + +func TestCmdSystemPrometheus_ValidateInput(t *testing.T) { + tests := []struct { + name string + args []string + expectedError string + }{ + { + name: "no arguments accepted", + args: nil, + }, + { + name: "rejects arguments", + args: []string{"extra"}, + expectedError: "this command does not accept arguments", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := &CmdSystemPrometheus{Flags: &common.CommandPrometheusFlags{}} + + err := cmd.ValidateInput(test.args) + if test.expectedError == "" { + if err != nil { + t.Fatalf("expected no error, got %q", err) + } + return + } + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), test.expectedError) { + t.Fatalf("expected error %q, got %q", test.expectedError, err.Error()) + } + }) + } +} + +func TestCmdSystemPrometheus_Run(t *testing.T) { + tests := []struct { + name string + flags *common.CommandPrometheusFlags + install func() error + uninstall func() error + expectedError string + }{ + { + name: "install succeeds", + flags: &common.CommandPrometheusFlags{Uninstall: false}, + install: func() error { return nil }, + }, + { + name: "install fails", + flags: &common.CommandPrometheusFlags{Uninstall: false}, + install: func() error { return fmt.Errorf("disk full") }, + expectedError: "installation failed: disk full", + }, + { + name: "install with nil installer (NewClient failed)", + flags: &common.CommandPrometheusFlags{Uninstall: false}, + install: nil, + expectedError: "failed to create prometheus installer", + }, + { + name: "uninstall succeeds", + flags: &common.CommandPrometheusFlags{Uninstall: true}, + uninstall: func() error { return nil }, + }, + { + name: "uninstall fails", + flags: &common.CommandPrometheusFlags{Uninstall: true}, + uninstall: func() error { return fmt.Errorf("container not found") }, + expectedError: "uninstallation failed: container not found", + }, + { + name: "uninstall with nil installer (NewClient failed)", + flags: &common.CommandPrometheusFlags{Uninstall: true}, + uninstall: nil, + expectedError: "failed to create prometheus installer", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := &CmdSystemPrometheus{ + Flags: test.flags, + Install: test.install, + Uninstall: test.uninstall, + } + + err := cmd.Run() + if test.expectedError == "" { + if err != nil { + t.Fatalf("expected no error, got %q", err) + } + return + } + if err == nil { + t.Fatal("expected error, got nil") + } + if err.Error() != test.expectedError { + t.Fatalf("expected error %q, got %q", test.expectedError, err.Error()) + } + }) + } +} + +func TestCmdSystemPrometheus_WaitUntil(t *testing.T) { + cmd := &CmdSystemPrometheus{} + if err := cmd.WaitUntil(); err != nil { + t.Fatalf("expected no error, got %q", err) + } +} + +func TestCmdSystemPrometheus_InputToOptions(t *testing.T) { + cmd := &CmdSystemPrometheus{Flags: &common.CommandPrometheusFlags{}} + cmd.InputToOptions() +} diff --git a/internal/cmd/skupper/system/nonkube/system_uninstall.go b/internal/cmd/skupper/system/nonkube/system_uninstall.go index cd9f54e2e..6425c3f82 100644 --- a/internal/cmd/skupper/system/nonkube/system_uninstall.go +++ b/internal/cmd/skupper/system/nonkube/system_uninstall.go @@ -11,19 +11,22 @@ import ( "github.com/skupperproject/skupper/internal/cmd/skupper/common" "github.com/skupperproject/skupper/internal/config" "github.com/skupperproject/skupper/internal/nonkube/bootstrap" + networkobserver "github.com/skupperproject/skupper/internal/nonkube/network-observer" "github.com/skupperproject/skupper/internal/utils/validator" "github.com/skupperproject/skupper/pkg/nonkube/api" "github.com/spf13/cobra" ) type CmdSystemUninstall struct { - CobraCmd *cobra.Command - Namespace string - SystemUninstall func(string) error - CheckActiveSites func() (bool, error) - Flags *common.CommandSystemUninstallFlags - forceUninstall bool - TearDown func(namespace string) error + CobraCmd *cobra.Command + Namespace string + SystemUninstall func(string) error + CheckActiveSites func() (bool, error) + Flags *common.CommandSystemUninstallFlags + forceUninstall bool + TearDown func(namespace string) error + NetworkObserverUninstall func(namespace string) error + PrometheusUninstall func() error } func NewCmdSystemUninstall() *CmdSystemUninstall { @@ -38,6 +41,8 @@ func (cmd *CmdSystemUninstall) NewClient(cobraCommand *cobra.Command, args []str cmd.CheckActiveSites = bootstrap.CheckActiveSites cmd.Namespace = cobraCommand.Flag("namespace").Value.String() cmd.TearDown = bootstrap.Teardown + cmd.NetworkObserverUninstall = networkobserver.UninstallForNamespace + cmd.PrometheusUninstall = networkobserver.UninstallPrometheus } func (cmd *CmdSystemUninstall) ValidateInput(args []string) error { @@ -86,27 +91,39 @@ func (cmd *CmdSystemUninstall) Run() error { for _, entry := range entries { if entry.IsDir() { - runtimeDir := "namespaces/" + entry.Name() + "/runtime/" + namespace := entry.Name() + + if cmd.NetworkObserverUninstall != nil { + if err := cmd.NetworkObserverUninstall(namespace); err != nil { + return fmt.Errorf("failed to uninstall network observer for namespace %q: %s", namespace, err) + } + } + + runtimeDir := "namespaces/" + namespace + "/runtime/" _, err := os.ReadDir(path.Join(api.GetHostDataHome(), runtimeDir)) if err == nil { - fmt.Printf("Removing active site namespace \"%s\"\n", entry.Name()) - err := cmd.TearDown(entry.Name()) - if err != nil { - return fmt.Errorf("failed to remove site \"%s\": %s", entry.Name(), err) + fmt.Printf("Removing active site namespace %q\n", namespace) + if err := cmd.TearDown(namespace); err != nil { + return fmt.Errorf("failed to remove site %q: %s", namespace, err) } } else { // site not active so just remove directory - err := os.RemoveAll(api.GetHostNamespaceHome(entry.Name())) - if err == nil { - fmt.Printf("Namespace \"%s\" has been removed\n", entry.Name()) + if err := os.RemoveAll(api.GetHostNamespaceHome(namespace)); err == nil { + fmt.Printf("Namespace %q has been removed\n", namespace) } else { - return fmt.Errorf("failed to remove site \"%s\": %s", entry.Name(), err) + return fmt.Errorf("failed to remove site %q: %s", namespace, err) } } } } } + if cmd.PrometheusUninstall != nil { + if err := cmd.PrometheusUninstall(); err != nil { + return fmt.Errorf("failed to uninstall prometheus: %s", err) + } + } + err := cmd.SystemUninstall(string(config.GetPlatform())) if err != nil { diff --git a/internal/cmd/skupper/system/nonkube/system_uninstall_test.go b/internal/cmd/skupper/system/nonkube/system_uninstall_test.go index be1b21e61..4d9572964 100644 --- a/internal/cmd/skupper/system/nonkube/system_uninstall_test.go +++ b/internal/cmd/skupper/system/nonkube/system_uninstall_test.go @@ -131,10 +131,11 @@ func TestCmdSystemUninstall_InputToOptions(t *testing.T) { func TestCmdSystemUninstall_Run(t *testing.T) { type test struct { - name string - flags *common.CommandSystemUninstallFlags - disableSocketFails bool - errorMessage string + name string + flags *common.CommandSystemUninstallFlags + disableSocketFails bool + networkObserverFails bool + errorMessage string } testTable := []test{ @@ -150,6 +151,13 @@ func TestCmdSystemUninstall_Run(t *testing.T) { errorMessage: "Unable to uninstall.\nError: disable socket fails", flags: &common.CommandSystemUninstallFlags{Force: false}, }, + { + name: "network observer uninstall fails", + disableSocketFails: false, + networkObserverFails: true, + errorMessage: `failed to uninstall network observer for namespace "test": network observer uninstall failed`, + flags: &common.CommandSystemUninstallFlags{Force: true}, + }, } //Add a temp file so site exists for uninstall tests @@ -203,6 +211,9 @@ func TestCmdSystemUninstall_Run(t *testing.T) { for _, test := range testTable { command := newCmdSystemUninstallWithMocks(test.disableSocketFails) command.forceUninstall = test.flags.Force + if test.networkObserverFails { + command.NetworkObserverUninstall = mockNetworkObserverUninstallFails + } t.Run(test.name, func(t *testing.T) { @@ -229,9 +240,10 @@ func cleanup() { func newCmdSystemUninstallWithMocks(disableSocketFails bool) *CmdSystemUninstall { cmdMock := &CmdSystemUninstall{ - SystemUninstall: mockCmdSystemUninstall, - CheckActiveSites: mockCmdSystemUninstallNoActiveSites, - TearDown: mockCmdSystemTearDown, + SystemUninstall: mockCmdSystemUninstall, + CheckActiveSites: mockCmdSystemUninstallNoActiveSites, + TearDown: mockCmdSystemTearDown, + NetworkObserverUninstall: mockNetworkObserverUninstall, } if disableSocketFails { @@ -250,3 +262,7 @@ func mockCmdSystemUninstallThereAreStillSites() (bool, error) { return true, func mockCmdSystemUninstallCheckActiveSitesFails() (bool, error) { return false, fmt.Errorf("error") } func mockCmdSystemUninstallNoActiveSites() (bool, error) { return false, nil } func mockCmdSystemTearDown(string) error { return nil } +func mockNetworkObserverUninstall(string) error { return nil } +func mockNetworkObserverUninstallFails(string) error { + return fmt.Errorf("network observer uninstall failed") +} diff --git a/internal/cmd/skupper/system/system.go b/internal/cmd/skupper/system/system.go index c4ef0a0d8..9656cd23b 100644 --- a/internal/cmd/skupper/system/system.go +++ b/internal/cmd/skupper/system/system.go @@ -35,6 +35,8 @@ approach, which is based on the new set of Custom Resource Definitions (CRDs).`, cmd.AddCommand(CmdSystemGenerateBundleFactory(platform)) cmd.AddCommand(CmdSystemApplyFactory(platform)) cmd.AddCommand(CmdSystemDeleteFactory(platform)) + cmd.AddCommand(CmdSystemNetworkObserverFactory(platform)) + cmd.AddCommand(CmdSystemPrometheusFactory(platform)) return cmd } @@ -230,3 +232,59 @@ func CmdSystemDeleteFactory(configuredPlatform common.Platform) *cobra.Command { return cmd } + +func CmdSystemNetworkObserverFactory(configuredPlatform common.Platform) *cobra.Command { + + //This implementation will warn the user that the command is not available for Kubernetes environments. + kubeCommand := kube.NewCmdSystemNetworkObserver() + nonKubeCommand := nonkube.NewCmdSystemNetworkObserver() + + cmdDesc := common.SkupperCmdDescription{ + Use: "network-observer", + Short: "Install the network observer", + Long: `Install the Skupper network observer to collect and expose network metrics. +The network observer requires an existing Skupper site and will deploy two containers: +- network-observer: Collects metrics from the router +- prometheus: Stores metrics`, + Example: `skupper system network-observer --namespace west`, + } + + cmd := common.ConfigureCobraCommand(configuredPlatform, cmdDesc, kubeCommand, nonKubeCommand) + + cmdFlags := common.CommandNetworkObserverFlags{} + cmd.Flags().BoolVar(&cmdFlags.Uninstall, common.FlagNameNetworkObserverUninstall, false, common.FlagDescNetworkObserverUninstall) + + kubeCommand.CobraCmd = cmd + kubeCommand.Flags = &cmdFlags + nonKubeCommand.CobraCmd = cmd + nonKubeCommand.Flags = &cmdFlags + + return cmd +} + +func CmdSystemPrometheusFactory(configuredPlatform common.Platform) *cobra.Command { + + kubeCommand := kube.NewCmdSystemPrometheus() + nonKubeCommand := nonkube.NewCmdSystemPrometheus() + + cmdDesc := common.SkupperCmdDescription{ + Use: "prometheus", + Short: "Install the host-level Prometheus instance", + Long: `Install a single Prometheus container shared by all network observers on this host. +This must be run before installing any network observer.`, + Example: `skupper system prometheus +skupper system prometheus --uninstall`, + } + + cmd := common.ConfigureCobraCommand(configuredPlatform, cmdDesc, kubeCommand, nonKubeCommand) + + cmdFlags := common.CommandPrometheusFlags{} + cmd.Flags().BoolVar(&cmdFlags.Uninstall, common.FlagNamePrometheusUninstall, false, common.FlagDescPrometheusUninstall) + + kubeCommand.CobraCmd = cmd + kubeCommand.Flags = &cmdFlags + nonKubeCommand.CobraCmd = cmd + nonKubeCommand.Flags = &cmdFlags + + return cmd +} diff --git a/internal/cmd/skupper/system/system_test.go b/internal/cmd/skupper/system/system_test.go index 0db8bdac2..cba6d118b 100644 --- a/internal/cmd/skupper/system/system_test.go +++ b/internal/cmd/skupper/system/system_test.go @@ -69,6 +69,13 @@ func TestCmdSystemFactory(t *testing.T) { }, command: CmdSystemDeleteFactory(common.PlatformKubernetes), }, + { + name: "CmdSystemNetworkObserverFactory", + expectedFlagsWithDefaultValue: map[string]interface{}{ + common.FlagNameNetworkObserverUninstall: "false", + }, + command: CmdSystemNetworkObserverFactory(common.PlatformDocker), + }, } for _, test := range testTable { diff --git a/internal/images/image_utils.go b/internal/images/image_utils.go index 62f5b4308..ade1f44b5 100644 --- a/internal/images/image_utils.go +++ b/internal/images/image_utils.go @@ -26,12 +26,14 @@ const ( CliImageEnvKey string = "SKUPPER_CLI_IMAGE" SystemControllerImageEnvKey string = "SKUPPER_SYSTEM_CONTROLLER_IMAGE" PrometheusServerImageEnvKey string = "PROMETHEUS_SERVER_IMAGE" + NginxImageEnvKey string = "NGINX_IMAGE" OauthProxyImageEnvKey string = "OAUTH_PROXY_IMAGE" RouterPullPolicyEnvKey string = "SKUPPER_ROUTER_IMAGE_PULL_POLICY" KubeAdaptorPullPolicyEnvKey string = "SKUPPER_KUBE_ADAPTOR_IMAGE_PULL_POLICY" OauthProxyPullPolicyEnvKey string = "OAUTH_PROXY_IMAGE_PULL_POLICY" SkupperImageRegistryEnvKey string = "SKUPPER_IMAGE_REGISTRY" PrometheusImageRegistryEnvKey string = "PROMETHEUS_IMAGE_REGISTRY" + NginxImageRegistryEnvKey string = "NGINX_IMAGE_REGISTRY" OauthProxyRegistryEnvKey string = "OAUTH_PROXY_IMAGE_REGISTRY" ) @@ -139,6 +141,10 @@ func GetPrometheusServerImageName() string { } } +func GetPrometheusImageName() string { + return GetPrometheusServerImageName() +} + func GetSystemControllerImageName() string { image := os.Getenv(SystemControllerImageEnvKey) if image == "" { diff --git a/internal/images/images.go b/internal/images/images.go index 5f268d024..4afb3b43e 100644 --- a/internal/images/images.go +++ b/internal/images/images.go @@ -10,7 +10,7 @@ const ( SystemControllerImageName string = "system-controller:v2-dev" PrometheusImageRegistry string = "quay.io/prometheus" - PrometheusServerImageName string = "prometheus:v2.42.0" + PrometheusServerImageName string = "prometheus:v3.11.3" OauthProxyImageRegistry string = "quay.io/openshift" OauthProxyImageName string = "origin-oauth-proxy:4.14.0" ) diff --git a/internal/kube/certificates/mgr_test.go b/internal/kube/certificates/mgr_test.go index 6eb8c8027..81d4d4656 100644 --- a/internal/kube/certificates/mgr_test.go +++ b/internal/kube/certificates/mgr_test.go @@ -411,7 +411,7 @@ func secretWithOwnerRef(secret *corev1.Secret, ref metav1.OwnerReference) *corev return secret } -// managedWithOwnerHosts sets up a Certificiate with skupper controlled and owner hosts annotations +// managedWithOwnerHosts sets up a Certificate with skupper controlled and owner hosts annotations func managedWithOwnerHosts(t *testing.T, cert *skupperv2alpha1.Certificate, ref metav1.OwnerReference, hosts ...string) *skupperv2alpha1.Certificate { t.Helper() cert.ObjectMeta.OwnerReferences = append(cert.ObjectMeta.OwnerReferences, ref) diff --git a/internal/nonkube/network-observer/config_templates.go b/internal/nonkube/network-observer/config_templates.go new file mode 100644 index 000000000..640cdda5e --- /dev/null +++ b/internal/nonkube/network-observer/config_templates.go @@ -0,0 +1,23 @@ +package networkobserver + +import "fmt" + +func RenderPrometheusConfig(targetsDir string) string { + return fmt.Sprintf(`global: + scrape_interval: 15s + evaluation_interval: 15s +alerting: + alertmanagers: + - static_configs: + - targets: +scrape_configs: + - job_name: "skupper-network-observers" + scheme: http + follow_redirects: true + enable_http2: true + file_sd_configs: + - files: + - "%s/*.json" + refresh_interval: 15s +`, targetsDir) +} diff --git a/internal/nonkube/network-observer/containers.go b/internal/nonkube/network-observer/containers.go new file mode 100644 index 000000000..1c25524db --- /dev/null +++ b/internal/nonkube/network-observer/containers.go @@ -0,0 +1,77 @@ +package networkobserver + +import ( + "fmt" + "path/filepath" + + "github.com/skupperproject/skupper/internal/images" + "github.com/skupperproject/skupper/pkg/container" + "github.com/skupperproject/skupper/pkg/nonkube/api" +) + +func GetNetworkObserverContainer(namespace string, p ports) container.Container { + namespacePath := api.GetHostNamespaceHome(namespace) + clientCertsPath := filepath.Join(namespacePath, string(api.CertificatesPath), "skupper-local-client") + + return container.Container{ + Name: fmt.Sprintf("%s-skupper-network-observer", namespace), + Image: images.GetNetworkObserverImageName(), + Command: []string{ + fmt.Sprintf("-listen=127.0.0.1:%d", p.netobs), + fmt.Sprintf("-listen-metrics=127.0.0.1:%d", p.metrics), + fmt.Sprintf("-prometheus-api=http://127.0.0.1:%d", p.prometheus), + fmt.Sprintf("-router-endpoint=%s", p.router), + "-router-tls-ca=/etc/messaging/ca.crt", + "-router-tls-cert=/etc/messaging/tls.crt", + "-router-tls-key=/etc/messaging/tls.key", + }, + Env: map[string]string{}, + Labels: map[string]string{ + "application": "skupper-v2", + "skupper.io/v2-component": "network-observer", + }, + FileMounts: []container.FileMount{ + { + Source: clientCertsPath, + Destination: "/etc/messaging", + Options: []string{"z"}, + }, + }, + Networks: map[string]container.ContainerNetworkInfo{}, + RestartPolicy: "always", + } +} + +func GetHostPrometheusContainer(p prometheusInstallerPorts) container.Container { + prometheusHome := api.GetHostPrometheusHome() + dataPath := filepath.Join(prometheusHome, "data") + + return container.Container{ + Name: "skupper-prometheus", + Image: images.GetPrometheusImageName(), + Command: []string{ + "--config.file=/etc/prometheus/prometheus.yml", + "--storage.tsdb.path=/prometheus/", + fmt.Sprintf("--web.listen-address=127.0.0.1:%d", p.prometheus), + }, + Env: map[string]string{}, + Labels: map[string]string{ + "application": "skupper-v2", + "skupper.io/v2-component": "prometheus", + }, + FileMounts: []container.FileMount{ + { + Source: prometheusHome, + Destination: "/etc/prometheus", + Options: []string{"z"}, + }, + { + Source: dataPath, + Destination: "/prometheus", + Options: []string{"z"}, + }, + }, + Networks: map[string]container.ContainerNetworkInfo{}, + RestartPolicy: "always", + } +} diff --git a/internal/nonkube/network-observer/installer.go b/internal/nonkube/network-observer/installer.go new file mode 100644 index 000000000..d6136b9e8 --- /dev/null +++ b/internal/nonkube/network-observer/installer.go @@ -0,0 +1,344 @@ +package networkobserver + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "time" + + "github.com/skupperproject/skupper/api/types" + "github.com/skupperproject/skupper/internal/config" + "github.com/skupperproject/skupper/internal/nonkube/client/compat" + "github.com/skupperproject/skupper/internal/nonkube/client/fs" + "github.com/skupperproject/skupper/internal/nonkube/client/runtime" + "github.com/skupperproject/skupper/internal/nonkube/common" + "github.com/skupperproject/skupper/internal/utils" + "github.com/skupperproject/skupper/pkg/container" + "github.com/skupperproject/skupper/pkg/nonkube/api" +) + +type ports struct { + prometheus int + netobs int + metrics int + router string +} + +type Installer struct { + Namespace string + Platform string + ports ports + logger *slog.Logger + cli *compat.CompatClient + siteHandler *fs.SiteHandler +} + +type InstallResult struct { + URL string +} + +func NewInstaller(namespace string) (*Installer, error) { + selectedPlatform, err := detectPlatform() + if err != nil { + return nil, err + } + containerEndpoint, err := getContainerEndpoint(selectedPlatform) + if err != nil { + return nil, err + } + + compatClient, err := compat.NewCompatClient(containerEndpoint, "") + if err != nil { + return nil, fmt.Errorf("failed to create container client: %v", err) + } + + return &Installer{ + Namespace: namespace, + Platform: selectedPlatform, + logger: slog.Default().With("component", "network.observer.installer"), + siteHandler: fs.NewSiteHandler(namespace), + cli: compatClient, + }, nil +} + +func (i *Installer) ValidatePrerequisitesForInstall() error { + i.logger.Info("Validating prerequisites", slog.String("namespace", i.Namespace)) + namespacePath := api.GetHostNamespaceHome(i.Namespace) + + if _, err := os.Stat(namespacePath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("namespace %q not found", i.Namespace) + } + return err + } + + if !IsPrometheusInstalled() { + return fmt.Errorf("prometheus is not installed; run \"skupper system prometheus\" first") + } + + netobsContainer := fmt.Sprintf("%s-skupper-network-observer", i.Namespace) + if i.isContainerRunning(netobsContainer) { + return fmt.Errorf("container %q is already running in %s", netobsContainer, i.Platform) + } + + sites, err := i.siteHandler.List(fs.GetOptions{InputOnly: true}) + if err != nil { + return err + } else { + if len(sites) == 0 { + return fmt.Errorf("required site not found") + } + } + + clientCertsPath := filepath.Join(namespacePath, string(api.CertificatesPath), "skupper-local-client") + requiredCerts := []string{"ca.crt", "tls.crt", "tls.key"} + for _, cert := range requiredCerts { + certPath := filepath.Join(clientCertsPath, cert) + if _, err := os.Stat(certPath); os.IsNotExist(err) { + return fmt.Errorf("required certificate not found: %s", certPath) + } + } + + return nil +} + +func (i *Installer) Install() (*InstallResult, error) { + + i.logger.Info("Starting network observer installation", slog.String("namespace", i.Namespace)) + + if err := i.generateConfigurations(); err != nil { + return nil, fmt.Errorf("failed to generate configurations: %w", err) + } + + systemdGlobal, err := common.NewSystemdGlobal(i.Platform) + if err != nil { + return nil, err + } + + err = systemdGlobal.Enable() + if err != nil { + return nil, err + } + + err = i.installContainer(GetNetworkObserverContainer(i.Namespace, i.ports)) + if err != nil { + return nil, err + } + + if err := WriteTargetFile(i.Namespace, i.ports.metrics); err != nil { + return nil, fmt.Errorf("failed to write prometheus target file: %w", err) + } + + err = i.createNetObsSystemdService() + if err != nil { + return nil, fmt.Errorf("failed to create systemd services: %w", err) + } + + i.logger.Info("Network observer installation completed successfully") + + return &InstallResult{ + URL: fmt.Sprintf("http://localhost:%d", i.ports.netobs), + }, nil +} + +func (i *Installer) ValidatePrerequisitesForUninstall() error { + netobsContainer := fmt.Sprintf("%s-skupper-network-observer", i.Namespace) + if !i.isContainerRunning(netobsContainer) { + return fmt.Errorf("network observer is not running in namespace %q, there is nothing to uninstall", i.Namespace) + } + return nil +} + +func UninstallForNamespace(namespace string) error { + targetFile := filepath.Join(api.GetPrometheusTargetsDir(), namespace+".json") + if _, err := os.Stat(targetFile); os.IsNotExist(err) { + return nil + } + + installer, err := NewInstaller(namespace) + if err != nil { + return err + } + return installer.Uninstall() +} + +func (i *Installer) Uninstall() error { + i.logger.Info("Uninstalling network observer", slog.String("namespace", i.Namespace)) + + if err := RemoveTargetFile(i.Namespace); err != nil { + i.logger.Warn("Failed to remove prometheus target file", slog.Any("error", err)) + } + + manager := NewSystemdServiceManager(i.Namespace, i.Platform, ports{}) + if err := manager.RemoveNetworkObserverService(); err != nil { + i.logger.Warn("Failed to remove systemd services", slog.Any("error", err)) + } + + containerNames := []string{ + fmt.Sprintf("%s-skupper-network-observer", i.Namespace), + } + for _, name := range containerNames { + if i.isContainerRunning(name) { + if err := i.cli.ContainerStop(name); err != nil { + i.logger.Warn("Failed to stop container", slog.String("name", name), slog.String("error", err.Error())) + } + } + + if err := i.cli.ContainerRemove(name); err != nil { + i.logger.Warn("Failed to remove container", slog.String("name", name), slog.Any("error", err)) + } + } + + i.logger.Info("Network observer uninstalled successfully") + return nil +} + +func detectPlatform() (string, error) { + platform := config.GetPlatform() + + if platform != types.PlatformDocker && platform != types.PlatformPodman { + return "", fmt.Errorf("unsupported platform %q for network observer", platform) + } + + switch platform { + case "docker": + _, err := exec.LookPath("docker") + if err != nil { + return "", fmt.Errorf("docker not found") + } + + default: + _, err := exec.LookPath("podman") + if err != nil { + return "", fmt.Errorf("podman not found") + } + + } + + return string(platform), nil +} + +func getContainerEndpoint(platform string) (string, error) { + currentUser, err := user.Current() + if err != nil { + return "", fmt.Errorf("Failed to get current user: %v", err) + } + uid := currentUser.Uid + uidInt, _ := strconv.Atoi(uid) + + xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR") + if xdgRuntimeDir == "" { + xdgRuntimeDir = fmt.Sprintf("/run/user/%s", uid) + } + + containerEndpointDefault := os.Getenv("CONTAINER_ENDPOINT") + + if containerEndpointDefault == "" { + + if platform == "docker" { + containerEndpointDefault = "unix:///run/docker.sock" + } else { + + containerEndpointDefault = fmt.Sprintf("unix://%s/podman/podman.sock", xdgRuntimeDir) + + if uidInt == 0 { + if platform == "podman" { + containerEndpointDefault = "unix:///run/podman/podman.sock" + } + } + } + } + + return containerEndpointDefault, nil +} + +func (i *Installer) isContainerRunning(containerName string) bool { + + containers, err := i.cli.ContainerList() + if err != nil { + return false + } + + for _, c := range containers { + if c.Name == containerName { + return c.Running + } + } + + return false +} + +func (i *Installer) generateConfigurations() error { + prometheusPort, err := ReadPrometheusPort() + if err != nil { + return err + } + metricsPort, err := NextFreeMetricsPort(9000) + if err != nil { + return fmt.Errorf("failing to assign port to metrics: %s", err) + } + netobsPort, err := utils.TcpPortNextFree(8080) + if err != nil { + return fmt.Errorf("failing to assign port to network observer: %s", err) + } + + routerEndpoint, err := runtime.GetLocalRouterAddress(i.Namespace) + if err != nil { + return fmt.Errorf("failed to determine local router address: %w", err) + } + + i.ports = ports{ + prometheus: prometheusPort, + netobs: netobsPort, + metrics: metricsPort, + router: routerEndpoint, + } + + i.logger.Info("Assigned ports", + slog.Int("prometheus", prometheusPort), + slog.Int("netobs", netobsPort), + slog.Int("metrics", metricsPort), + slog.String("router", routerEndpoint), + ) + + return nil +} + +func (i *Installer) installContainer(newContainer container.Container) error { + ctx, cn := context.WithTimeout(context.Background(), time.Minute*10) + defer cn() + err := i.cli.ImagePull(ctx, newContainer.Image) + if err != nil { + return fmt.Errorf("failed to pull image: %v", err) + } + fmt.Printf("Pulled image: %s\n", newContainer.Image) + + err = i.cli.ContainerCreate(&newContainer) + if err != nil { + return fmt.Errorf("failed to create container %s: %v", newContainer.Name, err) + } + err = i.cli.ContainerStart(newContainer.Name) + if err != nil { + return fmt.Errorf("failed to start container %s: %v", newContainer.Name, err) + } + + return nil +} + +func (i *Installer) createNetObsSystemdService() error { + i.logger.Info("Creating systemd service for Network Observer", slog.String("namespace", i.Namespace)) + + manager := NewSystemdServiceManager(i.Namespace, i.Platform, i.ports) + err := manager.CreateNetworkObserverService() + if err != nil { + return fmt.Errorf("failed to create systemd service: %w", err) + } + + i.logger.Info("Systemd service created successfully") + return nil +} diff --git a/internal/nonkube/network-observer/prometheus_installer.go b/internal/nonkube/network-observer/prometheus_installer.go new file mode 100644 index 000000000..4f5a98d48 --- /dev/null +++ b/internal/nonkube/network-observer/prometheus_installer.go @@ -0,0 +1,185 @@ +package networkobserver + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/skupperproject/skupper/internal/nonkube/client/compat" + "github.com/skupperproject/skupper/internal/utils" + "github.com/skupperproject/skupper/pkg/container" + "github.com/skupperproject/skupper/pkg/nonkube/api" +) + +type prometheusInstallerPorts struct { + prometheus int +} + +type PrometheusInstaller struct { + Platform string + ports prometheusInstallerPorts + logger *slog.Logger + cli *compat.CompatClient +} + +func NewPrometheusInstaller() (*PrometheusInstaller, error) { + selectedPlatform, err := detectPlatform() + if err != nil { + return nil, err + } + containerEndpoint, err := getContainerEndpoint(selectedPlatform) + if err != nil { + return nil, err + } + compatClient, err := compat.NewCompatClient(containerEndpoint, "") + if err != nil { + return nil, fmt.Errorf("failed to create container client: %v", err) + } + return &PrometheusInstaller{ + Platform: selectedPlatform, + logger: slog.Default().With("component", "prometheus.installer"), + cli: compatClient, + }, nil +} + +func (p *PrometheusInstaller) isContainerRunning(name string) bool { + containers, err := p.cli.ContainerList() + if err != nil { + return false + } + for _, c := range containers { + if c.Name == name { + return c.Running + } + } + return false +} + +func (p *PrometheusInstaller) ValidatePrerequisitesForInstall() error { + if p.isContainerRunning("skupper-prometheus") { + return fmt.Errorf("container \"skupper-prometheus\" is already running in %s", p.Platform) + } + return nil +} + +func (p *PrometheusInstaller) Install() error { + p.logger.Info("Starting host-level prometheus installation") + + prometheusHome := api.GetHostPrometheusHome() + targetsDir := api.GetPrometheusTargetsDir() + dataDir := filepath.Join(prometheusHome, "data") + + for _, d := range []struct { + path string + perm os.FileMode + }{ + {prometheusHome, 0755}, + {targetsDir, 0755}, + {dataDir, 0750}, + } { + if err := os.MkdirAll(d.path, d.perm); err != nil { + return fmt.Errorf("failed to create directory %s: %w", d.path, err) + } + } + + prometheusPort, err := utils.TcpPortNextFree(9090) + if err != nil { + return fmt.Errorf("failed to assign port to prometheus: %w", err) + } + p.ports = prometheusInstallerPorts{prometheus: prometheusPort} + p.logger.Info("Assigned prometheus port", slog.Int("port", prometheusPort)) + + configPath := filepath.Join(prometheusHome, "prometheus.yml") + if err := os.WriteFile(configPath, []byte(RenderPrometheusConfig("/etc/prometheus/targets")), 0644); err != nil { + return fmt.Errorf("failed to write prometheus config: %w", err) + } + + if err := p.installContainer(GetHostPrometheusContainer(p.ports)); err != nil { + return err + } + + manager := &SystemdServiceManager{ + ContainerEngine: p.Platform, + ServiceDir: getSystemdServiceDir(), + } + if err := manager.CreatePrometheusService(); err != nil { + return fmt.Errorf("failed to create prometheus systemd service: %w", err) + } + + if err := WritePrometheusState(prometheusPort); err != nil { + return err + } + + p.logger.Info("Host-level prometheus installation completed", slog.Int("port", prometheusPort)) + return nil +} + +func (p *PrometheusInstaller) ValidatePrerequisitesForUninstall() error { + if !p.isContainerRunning("skupper-prometheus") { + return fmt.Errorf("container \"skupper-prometheus\" is not running in %s; nothing to uninstall", p.Platform) + } + if namespaces := installedNetworkObservers(); len(namespaces) > 0 { + return fmt.Errorf("network observers are still installed (%s); run \"skupper system network-observer --uninstall\" for each namespace first", joinStrings(namespaces)) + } + return nil +} + +func (p *PrometheusInstaller) Uninstall() error { + p.logger.Info("Uninstalling host-level prometheus") + + manager := &SystemdServiceManager{ + ContainerEngine: p.Platform, + ServiceDir: getSystemdServiceDir(), + } + if err := manager.RemovePrometheusService(); err != nil { + p.logger.Warn("Failed to remove prometheus systemd service", slog.Any("error", err)) + } + + const containerName = "skupper-prometheus" + if p.isContainerRunning(containerName) { + if err := p.cli.ContainerStop(containerName); err != nil { + p.logger.Warn("Failed to stop container", slog.String("name", containerName), slog.Any("error", err)) + } + } + if err := p.cli.ContainerRemove(containerName); err != nil { + p.logger.Warn("Failed to remove container", slog.String("name", containerName), slog.Any("error", err)) + } + + prometheusHome := api.GetHostPrometheusHome() + if err := os.RemoveAll(prometheusHome); err != nil { + p.logger.Warn("Failed to remove prometheus directory", slog.String("path", prometheusHome), slog.Any("error", err)) + } + + p.logger.Info("Host-level prometheus uninstalled successfully") + return nil +} + +func UninstallPrometheus() error { + if !IsPrometheusInstalled() { + return nil + } + installer, err := NewPrometheusInstaller() + if err != nil { + return err + } + return installer.Uninstall() +} + +func (p *PrometheusInstaller) installContainer(newContainer container.Container) error { + ctx, cn := context.WithTimeout(context.Background(), time.Minute*10) + defer cn() + if err := p.cli.ImagePull(ctx, newContainer.Image); err != nil { + return fmt.Errorf("failed to pull image: %v", err) + } + fmt.Printf("Pulled image: %s\n", newContainer.Image) + if err := p.cli.ContainerCreate(&newContainer); err != nil { + return fmt.Errorf("failed to create container %s: %v", newContainer.Name, err) + } + if err := p.cli.ContainerStart(newContainer.Name); err != nil { + return fmt.Errorf("failed to start container %s: %v", newContainer.Name, err) + } + return nil +} diff --git a/internal/nonkube/network-observer/prometheus_installer_test.go b/internal/nonkube/network-observer/prometheus_installer_test.go new file mode 100644 index 000000000..b7c42297a --- /dev/null +++ b/internal/nonkube/network-observer/prometheus_installer_test.go @@ -0,0 +1,144 @@ +package networkobserver + +import ( + "log/slog" + "os" + "testing" + + "github.com/skupperproject/skupper/internal/nonkube/client/compat" + "github.com/skupperproject/skupper/pkg/container" + "github.com/skupperproject/skupper/pkg/nonkube/api" + "gotest.tools/v3/assert" +) + +func newMockContainer(name string, running bool) *container.Container { + return &container.Container{ + Name: "/" + name, + Running: running, + Labels: map[string]string{"application": container.AppName}, + Image: "mock-image", + } +} + +func newPrometheusInstallerWithMock(containers []*container.Container) *PrometheusInstaller { + return &PrometheusInstaller{ + Platform: "podman", + logger: slog.Default(), + cli: compat.NewCompatClientMock(containers), + } +} + +func TestValidatePrerequisitesForInstall_AlreadyRunning(t *testing.T) { + installer := newPrometheusInstallerWithMock([]*container.Container{ + newMockContainer("skupper-prometheus", true), + }) + + err := installer.ValidatePrerequisitesForInstall() + assert.ErrorContains(t, err, `"skupper-prometheus" is already running`) +} + +func TestValidatePrerequisitesForInstall_NotRunning(t *testing.T) { + installer := newPrometheusInstallerWithMock([]*container.Container{}) + + assert.NilError(t, installer.ValidatePrerequisitesForInstall()) +} + +func TestValidatePrerequisitesForInstall_ContainerExistsButStopped(t *testing.T) { + installer := newPrometheusInstallerWithMock([]*container.Container{ + newMockContainer("skupper-prometheus", false), + }) + + assert.NilError(t, installer.ValidatePrerequisitesForInstall()) +} + +func TestValidatePrerequisitesForUninstall_NotRunning(t *testing.T) { + installer := newPrometheusInstallerWithMock([]*container.Container{}) + + err := installer.ValidatePrerequisitesForUninstall() + assert.ErrorContains(t, err, "is not running") + assert.ErrorContains(t, err, "nothing to uninstall") +} + +func TestValidatePrerequisitesForUninstall_NetworkObserversStillInstalled(t *testing.T) { + setTempPrometheusHome(t) + + assert.NilError(t, WriteTargetFile("west", 9001)) + + installer := newPrometheusInstallerWithMock([]*container.Container{ + newMockContainer("skupper-prometheus", true), + }) + + err := installer.ValidatePrerequisitesForUninstall() + assert.ErrorContains(t, err, "network observers are still installed") + assert.ErrorContains(t, err, "west") +} + +func TestValidatePrerequisitesForUninstall_OK(t *testing.T) { + setTempPrometheusHome(t) + + installer := newPrometheusInstallerWithMock([]*container.Container{ + newMockContainer("skupper-prometheus", true), + }) + + assert.NilError(t, installer.ValidatePrerequisitesForUninstall()) +} + +func TestPrometheusInstaller_isContainerRunning(t *testing.T) { + tests := []struct { + name string + containers []*container.Container + query string + expected bool + }{ + { + name: "running container found", + containers: []*container.Container{newMockContainer("skupper-prometheus", true)}, + query: "skupper-prometheus", + expected: true, + }, + { + name: "stopped container not considered running", + containers: []*container.Container{newMockContainer("skupper-prometheus", false)}, + query: "skupper-prometheus", + expected: false, + }, + { + name: "unknown container name returns false", + containers: []*container.Container{newMockContainer("skupper-prometheus", true)}, + query: "other-container", + expected: false, + }, + { + name: "empty container list returns false", + containers: []*container.Container{}, + query: "skupper-prometheus", + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + installer := newPrometheusInstallerWithMock(tc.containers) + assert.Equal(t, tc.expected, installer.isContainerRunning(tc.query)) + }) + } +} + +func TestPrometheusInstaller_Uninstall_RemovesStateAndDir(t *testing.T) { + setTempPrometheusHome(t) + + prometheusHome := api.GetHostPrometheusHome() + if err := os.MkdirAll(prometheusHome, 0755); err != nil { + t.Fatalf("setup: %v", err) + } + assert.NilError(t, WritePrometheusState(9090)) + + installer := newPrometheusInstallerWithMock([]*container.Container{ + newMockContainer("skupper-prometheus", true), + }) + + assert.NilError(t, installer.Uninstall()) + + _, err := os.Stat(prometheusHome) + assert.Assert(t, os.IsNotExist(err), "expected prometheus home to be removed") +} diff --git a/internal/nonkube/network-observer/prometheus_state.go b/internal/nonkube/network-observer/prometheus_state.go new file mode 100644 index 000000000..e691618ec --- /dev/null +++ b/internal/nonkube/network-observer/prometheus_state.go @@ -0,0 +1,160 @@ +package networkobserver + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + + "github.com/skupperproject/skupper/internal/utils" + "github.com/skupperproject/skupper/pkg/nonkube/api" +) + +type PrometheusState struct { + Port int `json:"port"` +} + +func prometheusStateFile() string { + return filepath.Join(api.GetHostPrometheusHome(), "prometheus.yml.state") +} + +func WritePrometheusState(port int) error { + data, err := json.Marshal(PrometheusState{Port: port}) + if err != nil { + return fmt.Errorf("failed to marshal prometheus state: %w", err) + } + if err := os.WriteFile(prometheusStateFile(), data, 0644); err != nil { + return fmt.Errorf("failed to write prometheus state file: %w", err) + } + return nil +} + +func ReadPrometheusPort() (int, error) { + data, err := os.ReadFile(prometheusStateFile()) + if err != nil { + if os.IsNotExist(err) { + return 0, fmt.Errorf("prometheus is not installed; run \"skupper system prometheus\" first") + } + return 0, fmt.Errorf("failed to read prometheus state file: %w", err) + } + var state PrometheusState + if err := json.Unmarshal(data, &state); err != nil { + return 0, fmt.Errorf("failed to parse prometheus state file: %w", err) + } + return state.Port, nil +} + +func IsPrometheusInstalled() bool { + _, err := ReadPrometheusPort() + return err == nil +} + +func WriteTargetFile(namespace string, metricsPort int) error { + type target struct { + Targets []string `json:"targets"` + Labels map[string]string `json:"labels"` + } + targets := []target{ + { + Targets: []string{fmt.Sprintf("localhost:%d", metricsPort)}, + Labels: map[string]string{"namespace": namespace}, + }, + } + data, err := json.MarshalIndent(targets, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal target file: %w", err) + } + targetsDir := api.GetPrometheusTargetsDir() + if err := os.MkdirAll(targetsDir, 0755); err != nil { + return fmt.Errorf("failed to create targets directory: %w", err) + } + targetFile := filepath.Join(targetsDir, namespace+".json") + if err := os.WriteFile(targetFile, data, 0644); err != nil { + return fmt.Errorf("failed to write target file %s: %w", targetFile, err) + } + return nil +} + +func RemoveTargetFile(namespace string) error { + targetFile := filepath.Join(api.GetPrometheusTargetsDir(), namespace+".json") + if err := os.Remove(targetFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove target file %s: %w", targetFile, err) + } + return nil +} + +func claimedMetricsPorts() map[int]bool { + claimed := map[int]bool{} + targetsDir := api.GetPrometheusTargetsDir() + entries, err := os.ReadDir(targetsDir) + if err != nil { + return claimed + } + type targetEntry struct { + Targets []string `json:"targets"` + } + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(targetsDir, e.Name())) + if err != nil { + continue + } + var targets []targetEntry + if err := json.Unmarshal(data, &targets); err != nil { + continue + } + for _, t := range targets { + for _, addr := range t.Targets { + if _, portStr, err := net.SplitHostPort(addr); err == nil { + if p, err := strconv.Atoi(portStr); err == nil { + claimed[p] = true + } + } + } + } + } + return claimed +} + +func installedNetworkObservers() []string { + targetsDir := api.GetPrometheusTargetsDir() + entries, err := os.ReadDir(targetsDir) + if err != nil { + return nil + } + var namespaces []string + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".json" { + namespaces = append(namespaces, e.Name()[:len(e.Name())-len(".json")]) + } + } + return namespaces +} + +func joinStrings(ss []string) string { + result := "" + for i, s := range ss { + if i > 0 { + result += ", " + } + result += s + } + return result +} + +func NextFreeMetricsPort(start int) (int, error) { + claimed := claimedMetricsPorts() + for port := start; port <= 65535; port++ { + if claimed[port] { + continue + } + if !utils.TcpPortInUse("", port) { + return port, nil + } + } + return 0, fmt.Errorf("no available metrics port found") +} diff --git a/internal/nonkube/network-observer/prometheus_state_test.go b/internal/nonkube/network-observer/prometheus_state_test.go new file mode 100644 index 000000000..f0a9b4d9b --- /dev/null +++ b/internal/nonkube/network-observer/prometheus_state_test.go @@ -0,0 +1,152 @@ +package networkobserver + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/skupperproject/skupper/pkg/nonkube/api" + "gotest.tools/v3/assert" +) + +func setTempPrometheusHome(t *testing.T) { + t.Helper() + if os.Getuid() == 0 { + api.DefaultRootDataHome = t.TempDir() + } else { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + } +} + +func TestWriteAndReadPrometheusState(t *testing.T) { + setTempPrometheusHome(t) + + if err := os.MkdirAll(api.GetHostPrometheusHome(), 0755); err != nil { + t.Fatalf("setup: %v", err) + } + + assert.NilError(t, WritePrometheusState(9090)) + + port, err := ReadPrometheusPort() + assert.NilError(t, err) + assert.Equal(t, 9090, port) +} + +func TestReadPrometheusPort_NotInstalled(t *testing.T) { + setTempPrometheusHome(t) + + _, err := ReadPrometheusPort() + assert.ErrorContains(t, err, "prometheus is not installed") +} + +func TestReadPrometheusPort_Corrupt(t *testing.T) { + setTempPrometheusHome(t) + + if err := os.MkdirAll(api.GetHostPrometheusHome(), 0755); err != nil { + t.Fatalf("setup: %v", err) + } + if err := os.WriteFile(prometheusStateFile(), []byte("not-json"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + + _, err := ReadPrometheusPort() + assert.ErrorContains(t, err, "failed to parse prometheus state file") +} + +func TestIsPrometheusInstalled(t *testing.T) { + setTempPrometheusHome(t) + + assert.Equal(t, false, IsPrometheusInstalled()) + + if err := os.MkdirAll(api.GetHostPrometheusHome(), 0755); err != nil { + t.Fatalf("setup: %v", err) + } + assert.NilError(t, WritePrometheusState(9091)) + assert.Equal(t, true, IsPrometheusInstalled()) +} + +func TestWriteAndRemoveTargetFile(t *testing.T) { + setTempPrometheusHome(t) + + assert.NilError(t, WriteTargetFile("west", 9001)) + + targetFile := filepath.Join(api.GetPrometheusTargetsDir(), "west.json") + data, err := os.ReadFile(targetFile) + assert.NilError(t, err) + + type entry struct { + Targets []string `json:"targets"` + Labels map[string]string `json:"labels"` + } + var entries []entry + assert.NilError(t, json.Unmarshal(data, &entries)) + assert.Equal(t, 1, len(entries)) + assert.Equal(t, "localhost:9001", entries[0].Targets[0]) + assert.Equal(t, "west", entries[0].Labels["namespace"]) + + assert.NilError(t, RemoveTargetFile("west")) + _, err = os.Stat(targetFile) + assert.Assert(t, os.IsNotExist(err)) + + assert.NilError(t, RemoveTargetFile("west")) +} + +func TestInstalledNetworkObservers(t *testing.T) { + setTempPrometheusHome(t) + + assert.Equal(t, 0, len(installedNetworkObservers())) + + assert.NilError(t, WriteTargetFile("west", 9001)) + assert.NilError(t, WriteTargetFile("east", 9002)) + + namespaces := installedNetworkObservers() + assert.Equal(t, 2, len(namespaces)) + + found := map[string]bool{} + for _, ns := range namespaces { + found[ns] = true + } + assert.Assert(t, found["west"]) + assert.Assert(t, found["east"]) +} + +func TestClaimedMetricsPorts(t *testing.T) { + setTempPrometheusHome(t) + + assert.Equal(t, 0, len(claimedMetricsPorts())) + + assert.NilError(t, WriteTargetFile("west", 9001)) + assert.NilError(t, WriteTargetFile("east", 9002)) + + claimed := claimedMetricsPorts() + assert.Assert(t, claimed[9001]) + assert.Assert(t, claimed[9002]) + assert.Assert(t, !claimed[9003]) +} + +func TestNextFreeMetricsPort(t *testing.T) { + setTempPrometheusHome(t) + + assert.NilError(t, WriteTargetFile("ns0", 9000)) + assert.NilError(t, WriteTargetFile("ns1", 9001)) + + port, err := NextFreeMetricsPort(9000) + assert.NilError(t, err) + assert.Assert(t, port >= 9002, "expected port >= 9002, got %d", port) +} + +func TestJoinStrings(t *testing.T) { + tests := []struct { + input []string + expected string + }{ + {nil, ""}, + {[]string{"a"}, "a"}, + {[]string{"a", "b", "c"}, "a, b, c"}, + } + for _, tc := range tests { + got := joinStrings(tc.input) + assert.Equal(t, tc.expected, got) + } +} diff --git a/internal/nonkube/network-observer/systemd.go b/internal/nonkube/network-observer/systemd.go new file mode 100644 index 000000000..c7ae66824 --- /dev/null +++ b/internal/nonkube/network-observer/systemd.go @@ -0,0 +1,221 @@ +package networkobserver + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const SystemdPrometheusHostServiceTemplate = `[Unit] +Description=Skupper Prometheus (host-level) +After=network.target + +[Service] +Type=simple +Restart=always +RestartSec=5 +ExecStart=%s start --attach skupper-prometheus +ExecStop=%s stop skupper-prometheus + +[Install] +WantedBy=default.target +` + +const SystemdNetworkObserverServiceTemplate = `[Unit] +Description=Skupper Network Observer - %s +After=network.target skupper-controller.service skupper-prometheus.service +Wants=skupper-controller.service skupper-prometheus.service + +[Service] +Type=simple +Restart=always +RestartSec=5 +ExecStart=%s start --attach %s-skupper-network-observer +ExecStop=%s stop %s-skupper-network-observer + +[Install] +WantedBy=default.target +` + +type SystemdServiceManager struct { + Namespace string + ContainerEngine string + ServiceDir string +} + +func NewSystemdServiceManager(namespace, containerEngine string, _ ports) *SystemdServiceManager { + serviceDir := getSystemdServiceDir() + return &SystemdServiceManager{ + Namespace: namespace, + ContainerEngine: containerEngine, + ServiceDir: serviceDir, + } +} + +func getSystemdServiceDir() string { + if os.Getuid() == 0 { + return "/etc/systemd/system" + } + home, err := os.UserHomeDir() + if err != nil { + home = fmt.Sprintf("/home/%s", os.Getenv("USER")) + } + return filepath.Join(home, ".config", "systemd", "user") +} + +func (s *SystemdServiceManager) CreateNetworkObserverService() error { + + if err := os.MkdirAll(s.ServiceDir, 0755); err != nil { + return fmt.Errorf("failed to create systemd service directory: %w", err) + } + + svcName := fmt.Sprintf("skupper-network-observer-%s.service", s.Namespace) + svcPath := filepath.Join(s.ServiceDir, svcName) + svcContent := fmt.Sprintf(SystemdNetworkObserverServiceTemplate, + s.Namespace, + s.ContainerEngine, s.Namespace, + s.ContainerEngine, s.Namespace) + if err := os.WriteFile(svcPath, []byte(svcContent), 0644); err != nil { + return fmt.Errorf("failed to write network observer service file: %w", err) + } + + if err := s.enableService(svcName); err != nil { + return fmt.Errorf("failed to enable service %s: %w", svcName, err) + } + + if err := s.startService(svcName); err != nil { + return fmt.Errorf("failed to start service %s: %w", svcName, err) + } + + return nil +} + +func (s *SystemdServiceManager) RemoveNetworkObserverService() error { + svcName := fmt.Sprintf("skupper-network-observer-%s.service", s.Namespace) + + if err := s.stopAndDisableService(svcName); err != nil { + fmt.Printf("Warning: failed to stop/disable service %s: %v\n", svcName, err) + } + + svcPath := filepath.Join(s.ServiceDir, svcName) + if err := os.Remove(svcPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove service file %s: %w", svcName, err) + } + + if err := s.reloadSystemd(); err != nil { + return fmt.Errorf("failed to reload systemd: %w", err) + } + + return nil +} + +func (s *SystemdServiceManager) CreatePrometheusService() error { + if err := os.MkdirAll(s.ServiceDir, 0755); err != nil { + return fmt.Errorf("failed to create systemd service directory: %w", err) + } + + svcName := "skupper-prometheus.service" + svcPath := filepath.Join(s.ServiceDir, svcName) + svcContent := fmt.Sprintf(SystemdPrometheusHostServiceTemplate, + s.ContainerEngine, + s.ContainerEngine) + if err := os.WriteFile(svcPath, []byte(svcContent), 0644); err != nil { + return fmt.Errorf("failed to write prometheus service file: %w", err) + } + + if err := s.enableServiceByPath(svcPath); err != nil { + return fmt.Errorf("failed to enable prometheus service: %w", err) + } + if err := s.startService(svcName); err != nil { + return fmt.Errorf("failed to start prometheus service: %w", err) + } + return nil +} + +func (s *SystemdServiceManager) RemovePrometheusService() error { + svcName := "skupper-prometheus.service" + if err := s.stopAndDisableService(svcName); err != nil { + fmt.Printf("Warning: failed to stop prometheus service: %v\n", err) + } + svcPath := filepath.Join(s.ServiceDir, svcName) + if err := os.Remove(svcPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove prometheus service file: %w", err) + } + if err := s.reloadSystemd(); err != nil { + return fmt.Errorf("failed to reload systemd: %w", err) + } + return nil +} + +func (s *SystemdServiceManager) reloadSystemd() error { + var cmd *exec.Cmd + if os.Getuid() == 0 { + cmd = exec.Command("systemctl", "daemon-reload") + } else { + cmd = exec.Command("systemctl", "--user", "daemon-reload") + } + return cmd.Run() +} + +func (s *SystemdServiceManager) enableService(serviceName string) error { + var cmd *exec.Cmd + if os.Getuid() == 0 { + cmd = exec.Command("systemctl", "enable", serviceName) + } else { + cmd = exec.Command("systemctl", "--user", "enable", serviceName) + } + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to enable %s: %w", serviceName, err) + } + return nil +} + +func (s *SystemdServiceManager) enableServiceByPath(servicePath string) error { + var cmd *exec.Cmd + if os.Getuid() == 0 { + cmd = exec.Command("systemctl", "enable", servicePath) + } else { + cmd = exec.Command("systemctl", "--user", "enable", servicePath) + } + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to enable %s: %w", servicePath, err) + } + return nil +} + +func (s *SystemdServiceManager) startService(serviceName string) error { + var cmd *exec.Cmd + if os.Getuid() == 0 { + cmd = exec.Command("systemctl", "start", serviceName) + } else { + cmd = exec.Command("systemctl", "--user", "start", serviceName) + } + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to start %s: %w", serviceName, err) + } + return nil +} + +func (s *SystemdServiceManager) stopAndDisableService(serviceName string) error { + var stopCmd, disableCmd *exec.Cmd + if os.Getuid() == 0 { + stopCmd = exec.Command("systemctl", "stop", serviceName) + disableCmd = exec.Command("systemctl", "disable", serviceName) + } else { + stopCmd = exec.Command("systemctl", "--user", "stop", serviceName) + disableCmd = exec.Command("systemctl", "--user", "disable", serviceName) + } + + err := stopCmd.Run() + if err != nil { + return err + } + + err = disableCmd.Run() + if err != nil { + return err + } + + return nil +} diff --git a/pkg/nonkube/api/environment.go b/pkg/nonkube/api/environment.go index 7f23aed2a..0124c2eda 100644 --- a/pkg/nonkube/api/environment.go +++ b/pkg/nonkube/api/environment.go @@ -195,3 +195,11 @@ func GetSystemControllerPath() string { dataHome := GetHostDataHome() return path.Join(dataHome, "system-controller") } + +func GetHostPrometheusHome() string { + return getHostPath("prometheus") +} + +func GetPrometheusTargetsDir() string { + return path.Join(GetHostPrometheusHome(), "targets") +}