Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Plugin Settings Detail View and Plugin Config Store #1678

Merged
merged 10 commits into from
Feb 29, 2024
18 changes: 18 additions & 0 deletions backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,24 @@ func defaultKubeConfigPersistenceFile() (string, error) {
// It serves plugin static files at “/plugins/” and “/static-plugins/”.
// It disables caching and reloads plugin list base paths if not in-cluster.
func addPluginRoutes(config *HeadlampConfig, r *mux.Router) {
// Delete plugin route
// This is only available when running locally.
if !config.useInCluster {
illume marked this conversation as resolved.
Show resolved Hide resolved
r.HandleFunc("/plugins/{name}", func(w http.ResponseWriter, r *http.Request) {
if err := checkHeadlampBackendToken(w, r); err != nil {
return
}
pluginName := mux.Vars(r)["name"]

err := plugins.Delete(config.pluginDir, pluginName)
if err != nil {
http.Error(w, "Error deleting plugin", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}).Methods("DELETE")
}

r.HandleFunc("/plugins", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pluginsList, err := config.cache.Get(context.Background(), plugins.PluginListKey)
Expand Down
40 changes: 40 additions & 0 deletions backend/cmd/headlamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,3 +511,43 @@ func TestDrainAndCordonNode(t *testing.T) {
}
}
}

func TestDeletePlugin(t *testing.T) {
// create temp dir for plugins
tempDir, err := os.MkdirTemp("", "plugins")
require.NoError(t, err)

defer os.RemoveAll(tempDir)

// create plugin
pluginDir := tempDir + "/test-plugin"
err = os.Mkdir(pluginDir, 0o755)
require.NoError(t, err)

// create plugin file
pluginFile := pluginDir + "/main.js"
_, err = os.Create(pluginFile)
require.NoError(t, err)

cache := cache.New[interface{}]()
kubeConfigStore := kubeconfig.NewContextStore()

c := HeadlampConfig{
useInCluster: false,
kubeConfigPath: config.GetDefaultKubeConfigPath(),
cache: cache,
kubeConfigStore: kubeConfigStore,
pluginDir: tempDir,
}

handler := createHeadlampHandler(&c)

rr, err := getResponseFromRestrictedEndpoint(handler, "DELETE", "/plugins/test-plugin", nil)
require.NoError(t, err)

assert.Equal(t, http.StatusOK, rr.Code)

// check if plugin was deleted
_, err = os.Stat(pluginDir)
assert.True(t, os.IsNotExist(err))
}
27 changes: 27 additions & 0 deletions backend/pkg/plugins/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"

"github.com/fsnotify/fsnotify"
Expand Down Expand Up @@ -212,3 +214,28 @@ func HandlePluginReload(cache cache.Cache[interface{}], w http.ResponseWriter) {
}
}
}

// Delete deletes the plugin from the plugin directory.
func Delete(pluginDir, filename string) error {
absPluginDir, err := filepath.Abs(pluginDir)
if err != nil {
return err
}

absPluginPath := path.Join(absPluginDir, filename)

if !isSubdirectory(absPluginDir, absPluginPath) {
yolossn marked this conversation as resolved.
Show resolved Hide resolved
return fmt.Errorf("plugin path '%s' is not a subdirectory of '%s'", absPluginPath, absPluginDir)
}

return os.RemoveAll(absPluginPath)
}

func isSubdirectory(parentDir, dirPath string) bool {
rel, err := filepath.Rel(parentDir, dirPath)
if err != nil {
return false
}

return !strings.HasPrefix(rel, "..") && !strings.HasPrefix(rel, ".")
}
39 changes: 39 additions & 0 deletions backend/pkg/plugins/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,42 @@ func TestPopulatePluginsCache(t *testing.T) {
require.True(t, ok)
require.Empty(t, pluginListArr)
}

// TestDelete checks the Delete function.
func TestDelete(t *testing.T) {
tempDir, err := os.MkdirTemp("", "testdelete")
require.NoError(t, err)

defer os.RemoveAll(tempDir) // clean up

// Create a temporary file
tempFile, err := os.CreateTemp(tempDir, "testfile")
require.NoError(t, err)
tempFile.Close() // Close the file

// Test cases
tests := []struct {
pluginDir string
pluginName string
expectErr bool
}{
{pluginDir: tempDir, pluginName: tempFile.Name(), expectErr: false}, // Existing file
{pluginDir: tempDir, pluginName: "non-existent-directory", expectErr: false}, // Non-existent file
{pluginDir: tempDir, pluginName: "../", expectErr: true}, // Directory traversal
yolossn marked this conversation as resolved.
Show resolved Hide resolved

}

for _, tt := range tests {
tt := tt
t.Run(tt.pluginName, func(t *testing.T) {
err := plugins.Delete(tt.pluginDir, tt.pluginName)
if tt.expectErr {
assert.Error(t, err, "Delete should return an error")
} else {
// check if the file exists
_, err := os.Stat(path.Join(tt.pluginDir, tt.pluginName))
assert.True(t, os.IsNotExist(err), "File should not exist")
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
title: "Interface: PluginSettingsDetailsProps"
linkTitle: "PluginSettingsDetailsProps"
slug: "plugin_registry.PluginSettingsDetailsProps"
---

[plugin/registry](../modules/plugin_registry.md).PluginSettingsDetailsProps

## Properties

### onDataChange

• `Optional` **onDataChange**: (`data`: { [`key`: `string`]: `any` }) => `void`
• `readonly` **data**: { [`key`: `string`]: `any` }

#### Defined in
[plugin/pluginsSlice.ts](https://github.com/headlamp-k8s/headlamp/blob/main/frontend/src/plugin/pluginsSlice.ts#L7)

54 changes: 54 additions & 0 deletions docs/development/api/modules/plugin_registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ ___

[components/DetailsViewSection/DetailsViewSection.tsx:9](https://github.com/headlamp-k8s/headlamp/blob/b0236780/frontend/src/components/DetailsViewSection/DetailsViewSection.tsx#L9)

___
### PluginSettingsComponentType

Ƭ **PluginSettingsComponentType**: `React.ComponentType`<[`PluginSettingsDetailsProps`](../interfaces/plugin_registry.PluginSettingsDetailsProps.md)\> \| `ReactElement` \| typeof `React.Component` \| ``null``

#### Defined in
[plugin/pluginsSlice.ts:24](https://github.com/headlamp-k8s/headlamp/blob/main/frontend/src/plugin/pluginsSlice.ts#L24)
___

### sectionFunc
Expand Down Expand Up @@ -661,3 +668,50 @@ registerSidebarEntryFilter(entry => (entry.name === 'workloads' ? null : entry))
#### Defined in

[plugin/registry.tsx:231](https://github.com/headlamp-k8s/headlamp/blob/b0236780/frontend/src/plugin/registry.tsx#L231)

___
### registerPluginSettings

▸ **registerPluginSettings**(`name`,`component`,`displaySaveButton`): `void`

**`example`**

```tsx
import { registerPluginSettings } from "@kinvolk/headlamp-plugin/lib";
import { TextField } from "@mui/material";

function MyPluginSettingsComponent(props: PluginSettingsDetailsProps) {
const { data, onDataChange } = props;

function onChange(value: string) {
if (onDataChange) {
onDataChange({ works: value });
}
}

return (
<TextField
value={data?.works || ""}
onChange={(e) => onChange(e.target.value)}
label="Normal Input"
variant="outlined"
fullWidth
/>
);
}
const displaySaveButton = true;
// Register a plugin settings component.
registerPluginSettings(
"my-plugin",
MyPluginSettingsComponent,
displaySaveButton
);
```

#### Parameters

| Name | Type | Description |
| :------ | :------ | :------ |
| `name` | `string` | The name of the plugin. |
| `component` | `PluginSettingsComponentType` | The component to be rendered in the plugin settings page. |
| `displaySaveButton` | `boolean` | Whether to display the save button. |
9 changes: 9 additions & 0 deletions docs/development/plugins/functionality.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,12 @@ React to Headlamp events with [registerHeadlampEventCallback](../api/modules/plu

- Example plugin shows [How to show snackbars for Headlamp events](https://github.com/kinvolk/headlamp/tree/main/plugins/examples/headlamp-events).
- API reference for [registerHeadlampEventCallback](../api/modules/plugin_registry.md#registerheadlampeventcallback)


### Plugin Settings

The plugins can have user configurable settings that can be used to change the behavior of the plugin. The plugin settings can be created using [registerPluginSettings](../api/modules/plugin_registry.md#registerpluginsettings).

- Example plugin shows [How to create plugin settings and use them](https://github.com/kinvolk/headlamp/tree/main/plugins/examples/change-logo)

![screenshot of the plugin settings](./images/plugin-settings.png)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading