Unverified Commit f6292987 authored by Joaquim Rocha's avatar Joaquim Rocha Committed by GitHub
Browse files

Merge pull request #706 from kinvolk/config_from_env

backend: Add env support for config
Showing with 586 additions and 521 deletions
+586 -521
......@@ -30,8 +30,13 @@ jobs:
- name: Check out code
uses: actions/checkout@v2.3.3
- name: setup and run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.49.0
working-directory: backend
- name: Lint, Build & Check
run: |
cd $GITHUB_WORKSPACE
make backend-lint
make backend
......@@ -82,8 +82,9 @@ func (c *Cluster) getName() *string {
// pathRelativeToBase returns either an absolute path, or a relative path prefixed with base.
// Examples:
// pathRelativeToBase("/etc", "passwd") -> "/etc/passwd"
// pathRelativeToBase("/etc", "/etc/passwd") -> "/etc/passwd"
//
// pathRelativeToBase("/etc", "passwd") -> "/etc/passwd"
// pathRelativeToBase("/etc", "/etc/passwd") -> "/etc/passwd"
func pathRelativeToBase(base string, dest string) string {
if filepath.IsAbs(dest) {
return dest
......
......@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"io/ioutil"
"log"
"net/http"
......@@ -34,7 +35,7 @@ type HeadlampConfig struct {
devMode bool
insecure bool
kubeConfigPath string
port string
port uint
staticDir string
pluginDir string
oidcClientID string
......@@ -113,7 +114,8 @@ func fileExists(filename string) bool {
// copy a file, whilst doing some search/replace on the data.
func copyReplace(src string, dst string,
search []byte, replace []byte,
search2 []byte, replace2 []byte) {
search2 []byte, replace2 []byte,
) {
data, err := ioutil.ReadFile(src)
if err != nil {
log.Fatal(err)
......@@ -121,8 +123,9 @@ func copyReplace(src string, dst string,
data1 := bytes.ReplaceAll(data, search, replace)
data2 := bytes.ReplaceAll(data1, search2, replace2)
fileMode := 0600
err = ioutil.WriteFile(dst, data2, 0600)
err = ioutil.WriteFile(dst, data2, fs.FileMode(fileMode))
if err != nil {
log.Fatal(err)
}
......@@ -187,7 +190,7 @@ func serveWithNoCacheHeader(fs http.Handler) http.HandlerFunc {
}
}
// nolint:gocognit,funlen,gocyclo
//nolint:gocognit,funlen,gocyclo
func createHeadlampHandler(config *HeadlampConfig) http.Handler {
kubeConfigPath := config.kubeConfigPath
......@@ -264,7 +267,7 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
continue
}
fmt.Printf("\tlocalhost:%s%s%s/{api...} -> %s\n", config.port, config.baseURL, "/clusters/"+context.Name,
fmt.Printf("\tlocalhost:%d%s%s/{api...} -> %s\n", config.port, config.baseURL, "/clusters/"+context.Name,
*context.cluster.getServer())
config.contextProxies[context.Name] = contextProxy{
......@@ -316,7 +319,7 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
cluster := r.URL.Query().Get("cluster")
if config.insecure {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint:gosec
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
}
insecureClient := &http.Client{Transport: tr}
ctx = oidc.ClientContext(ctx, insecureClient)
......@@ -365,7 +368,7 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
http.Error(w, "invalid request state is empty", http.StatusBadRequest)
return
}
// nolint: nestif
//nolint:nestif
if oauthConfig, ok := oauthRequestMap[state]; ok {
oauth2Token, err := oauthConfig.Config.Exchange(oauthConfig.Ctx, r.URL.Query().Get("code"))
if err != nil {
......@@ -434,7 +437,7 @@ func StartHeadlampServer(config *HeadlampConfig) {
handler := createHeadlampHandler(config)
// Start server
log.Fatal(http.ListenAndServe(":"+config.port, handler))
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", config.port), handler)) //nolint:gosec
}
func (c *HeadlampConfig) handleClusterRequests(router *mux.Router) {
......@@ -519,7 +522,7 @@ func (c *HeadlampConfig) createProxyForContext(context Context) (*httputil.Rever
}
tls := &tls.Config{
InsecureSkipVerify: shouldVerifyTLS, // nolint:gosec
InsecureSkipVerify: shouldVerifyTLS, //nolint:gosec
RootCAs: rootCAs,
Certificates: certs,
}
......@@ -557,17 +560,6 @@ func proxyHandler(url *url.URL, proxy *httputil.ReverseProxy) func(http.Response
}
}
func GetDefaultKubeConfigPath() string {
user, err := user.Current()
if err != nil {
log.Fatalf(err.Error())
}
homeDirectory := user.HomeDir
return filepath.Join(homeDirectory, ".kube", "config")
}
func (c *HeadlampConfig) getConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
......@@ -630,7 +622,7 @@ func (c *HeadlampConfig) addClusterSetupRoute(r *mux.Router) {
} else {
fmt.Println("Created new cluster proxy:")
}
fmt.Printf("\tlocalhost:%s%s%s/{api...} -> %s\n", c.port, c.baseURL, "/clusters/"+context.Name, clusterReq.Server)
fmt.Printf("\tlocalhost:%d%s%s/{api...} -> %s\n", c.port, c.baseURL, "/clusters/"+context.Name, clusterReq.Server)
w.WriteHeader(http.StatusCreated)
c.getConfig(w, r)
......
package main //nolint:testpackage
package main
import (
"bytes"
......@@ -111,7 +111,7 @@ func getResponse(handler http.Handler, method, url string, body interface{}) (*h
return rr, nil
}
// nolint:gocognit,funlen
//nolint:gocognit,funlen
func TestDynamicClusters(t *testing.T) {
tests := []struct {
name string
......
......@@ -8,65 +8,12 @@ import (
"net/http"
"os"
"path/filepath"
"runtime"
"time"
"github.com/fsnotify/fsnotify"
"github.com/gorilla/mux"
)
// folderExists(path) returns true if the folder exists.
func folderExists(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return false
}
return info.IsDir()
}
// Gets the default plugins-dir depending on platform.
func defaultPluginDir() string {
// These are the folders we use for the default plugin-dir.
// - the passed in pluginDir if it's not empty.
// - "./.plugins" if it exists.
// - ~/.config/Headlamp/plugins exists or it can be made
// - "./.plugins" if the ~/.config/Headlamp/plugins can't be made.
// Windows: %APPDATA%\Headlamp\Config\plugins
// (for example, C:\Users\USERNAME\AppData\Roaming\Headlamp\Config\plugins)
pluginDirDefault := "./.plugins"
if folderExists(pluginDirDefault) {
return pluginDirDefault
}
// https://www.npmjs.com/package/env-paths
// https://pkg.go.dev/os#UserConfigDir
userConfigDir, err := os.UserConfigDir()
if err != nil {
log.Printf("error getting user config dir: %s\n", err)
return pluginDirDefault
}
pluginsConfigDir := filepath.Join(userConfigDir, "Headlamp", "plugins")
if runtime.GOOS == "windows" {
// golang is wrong for config folder on windows.
// This matches env-paths and headlamp-plugin.
pluginsConfigDir = filepath.Join(userConfigDir, "Headlamp", "Config", "plugins")
}
err = os.MkdirAll(pluginsConfigDir, 0755)
if err != nil {
log.Printf("error creating plugins directory: %s\n", err)
return pluginDirDefault
}
return pluginsConfigDir
}
var watcher *fsnotify.Watcher
var changeHappened = false
......
package main
import (
"flag"
"log"
"os"
"strings"
"github.com/kinvolk/headlamp/backend/pkg/config"
)
func main() {
kubeconfig := flag.String("kubeconfig", "", "Absolute path to the kubeconfig file")
inCluster := flag.Bool("in-cluster", false, "Set when running from a k8s cluster")
devMode := flag.Bool("dev", false, "Allow connections from other origins")
staticDir := flag.String("html-static-dir", "", "Static HTML directory to serve")
insecure := flag.Bool("insecure-ssl", false, "Accept/Ignore all server SSL certificates")
// @todo: Make this a uint and validate the values
port := flag.String("port", "4466", "Port to listen from")
pluginDir := flag.String("plugins-dir", defaultPluginDir(), "Specify the plugins directory to build the backend with")
// For inCluster config we need to get the oidc properties from the flags
oidcClientID := flag.String("oidc-client-id", "", "ClientID for OIDC")
oidcClientSecret := flag.String("oidc-client-secret", "", "ClientSecret for OIDC")
oidcIdpIssuerURL := flag.String("oidc-idp-issuer-url", "", "Identity provider issuer URL for OIDC")
// The profile and email scopes are default, but we keep them here for maximum control by the caller.
oidcScopes := flag.String("oidc-scopes", "profile,email",
"A comma separated list of scopes needed from the OIDC provider")
baseURL := flag.String("base-url", "", "Base URL path. eg. /headlamp")
proxyURLs := flag.String("proxy-urls", "", "Allow proxy requests to specified URLs")
flag.Parse()
if !*inCluster &&
(*oidcClientID != "" || *oidcClientSecret != "" || *oidcIdpIssuerURL != "") {
log.Fatal(`oidc-client-id, oidc-client-secret, oidc-idp-issuer-url
flags are only meant to be used in inCluster mode`)
}
if *baseURL != "" && !strings.HasPrefix(*baseURL, "/") {
log.Fatal("base-url needs to start with a '/' or be empty")
}
kubeConfigPath := ""
// If we don't have a specified kubeConfig path, and we are not running
// in-cluster, then use the default path.
if *kubeconfig != "" {
kubeConfigPath = *kubeconfig
} else if !*inCluster {
kubeConfigEnv := os.Getenv("KUBECONFIG")
if kubeConfigEnv != "" {
kubeConfigPath = kubeConfigEnv
} else {
kubeConfigPath = GetDefaultKubeConfigPath()
}
conf, err := config.Parse(os.Args)
if err != nil {
log.Fatalf("Error fetching config:%v", err)
}
StartHeadlampServer(&HeadlampConfig{
useInCluster: *inCluster,
kubeConfigPath: kubeConfigPath,
port: *port,
devMode: *devMode,
staticDir: *staticDir,
insecure: *insecure,
pluginDir: *pluginDir,
oidcClientID: *oidcClientID,
oidcClientSecret: *oidcClientSecret,
oidcIdpIssuerURL: *oidcIdpIssuerURL,
oidcScopes: strings.Split(*oidcScopes, ","),
baseURL: *baseURL,
proxyURLs: strings.Split(*proxyURLs, ","),
useInCluster: conf.InCluster,
kubeConfigPath: conf.KubeConfigPath,
port: conf.Port,
devMode: conf.DevMode,
staticDir: conf.StaticDir,
insecure: conf.InsecureSsl,
pluginDir: conf.PluginsDir,
oidcClientID: conf.OidcClientID,
oidcClientSecret: conf.OidcClientSecret,
oidcIdpIssuerURL: conf.OidcIdpIssuerURL,
oidcScopes: strings.Split(conf.OidcScopes, ","),
baseURL: conf.BaseURL,
proxyURLs: strings.Split(conf.ProxyURLs, ","),
})
}
......@@ -5,16 +5,19 @@ go 1.12
require (
github.com/coreos/go-oidc v2.2.1+incompatible
github.com/fsnotify/fsnotify v1.4.9
github.com/gobwas/glob v0.2.3 // indirect
github.com/golangci/golangci-lint v1.31.0 // indirect
github.com/gobwas/glob v0.2.3
github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.4
github.com/imdario/mergo v0.3.8 // indirect
github.com/pkg/errors v0.9.1
github.com/knadh/koanf v1.4.3
github.com/kr/text v0.2.0 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f // indirect
github.com/stretchr/testify v1.6.1
github.com/stretchr/testify v1.7.0
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect
google.golang.org/appengine v1.6.1 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/square/go-jose.v2 v2.5.1 // indirect
k8s.io/apimachinery v0.17.4 // indirect
k8s.io/client-go v0.17.0
......
This diff is collapsed.
package config
import (
"errors"
"flag"
"fmt"
"io/fs"
"log"
"os"
"os/user"
"path/filepath"
"runtime"
"strings"
"github.com/knadh/koanf"
"github.com/knadh/koanf/providers/basicflag"
"github.com/knadh/koanf/providers/env"
)
const defaultPort = 4466
type Config struct {
InCluster bool `koanf:"in-cluster"`
DevMode bool `koanf:"dev"`
InsecureSsl bool `koanf:"insecure-ssl"`
KubeConfigPath string `koanf:"kubeconfig"`
StaticDir string `koanf:"html-static-dir"`
PluginsDir string `koanf:"plugins-dir"`
BaseURL string `koanf:"base-url"`
Port uint `koanf:"port"`
ProxyURLs string `koanf:"proxy-urls"`
OidcClientID string `koanf:"oidc-client-id"`
OidcClientSecret string `koanf:"oidc-client-secret"`
OidcIdpIssuerURL string `koanf:"oidc-idp-issuer-url"`
OidcScopes string `koanf:"oidc-scopes"`
}
func (c *Config) Validate() error {
if !c.InCluster && (c.OidcClientID != "" || c.OidcClientSecret != "" || c.OidcIdpIssuerURL != "") {
return errors.New(`oidc-client-id, oidc-client-secret, oidc-idp-issuer-url flags
are only meant to be used in inCluster mode`)
}
if c.BaseURL != "" && !strings.HasPrefix(c.BaseURL, "/") {
return errors.New("base-url needs to start with a '/' or be empty")
}
return nil
}
// Parse Loads the config from flags and env.
// env vars should start with HEADLAMP_CONFIG_ and use _ as separator
// If a value is set both in flags and env then flag takes priority.
// eg:
// export HEADLAMP_CONFIG_PORT=2344
// go run ./cmd --port=3456
// the value of port will be 3456.
//nolint:funlen
func Parse(args []string) (*Config, error) {
var config Config
f := flagset()
k := koanf.New(".")
if args == nil {
args = []string{}
} else if len(args) > 0 {
args = args[1:]
}
// First Load default args from flags
if err := k.Load(basicflag.Provider(f, "."), nil); err != nil {
return nil, fmt.Errorf("error loading default config from flags: %w", err)
}
// Parse args
if err := f.Parse(args); err != nil {
return nil, fmt.Errorf("error parsing flags: %w", err)
}
// Load config from env
if err := k.Load(env.Provider("HEADLAMP_CONFIG_", ".", func(s string) string {
return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(s, "HEADLAMP_CONFIG_")), "_", "-")
}), nil); err != nil {
return nil, fmt.Errorf("error loading config from env: %w", err)
}
// Load only the flags that were set
if err := k.Load(basicflag.ProviderWithValue(f, ".", func(key string, value string) (string, interface{}) {
flagSet := false
f.Visit(func(f *flag.Flag) {
if f.Name == key {
flagSet = true
}
})
if flagSet {
return key, value
}
return "", nil
}), nil); err != nil {
return nil, fmt.Errorf("error loading config from flags: %w", err)
}
if err := k.Unmarshal("", &config); err != nil {
return nil, fmt.Errorf("error unmarshal config: %w", err)
}
// Validate parsed config
if err := config.Validate(); err != nil {
return nil, err
}
kubeConfigPath := ""
// If we don't have a specified kubeConfig path, and we are not running
// in-cluster, then use the default path.
if config.KubeConfigPath != "" {
kubeConfigPath = config.KubeConfigPath
} else if !config.InCluster {
kubeConfigEnv := os.Getenv("KUBECONFIG")
if kubeConfigEnv != "" {
kubeConfigPath = kubeConfigEnv
} else {
kubeConfigPath = GetDefaultKubeConfigPath()
}
}
config.KubeConfigPath = kubeConfigPath
return &config, nil
}
func flagset() *flag.FlagSet {
f := flag.NewFlagSet("config", flag.ContinueOnError)
f.Bool("in-cluster", false, "Set when running from a k8s cluster")
f.Bool("dev", false, "Allow connections from other origins")
f.Bool("insecure-ssl", false, "Accept/Ignore all server SSL certificates")
f.String("kubeconfig", "", "Absolute path to the kubeconfig file")
f.String("html-static-dir", "", "Static HTML directory to serve")
f.String("plugins-dir", defaultPluginDir(), "Specify the plugins directory to build the backend with")
f.String("base-url", "", "Base URL path. eg. /headlamp")
f.Uint("port", defaultPort, "Port to listen from")
f.String("proxy-urls", "", "Allow proxy requests to specified URLs")
f.String("oidc-client-id", "", "ClientID for OIDC")
f.String("oidc-client-secret", "", "ClientSecret for OIDC")
f.String("oidc-idp-issuer-url", "", "Identity provider issuer URL for OIDC")
f.String("oidc-scopes", "profile,email",
"A comma separated list of scopes needed from the OIDC provider")
return f
}
// Gets the default plugins-dir depending on platform.
func defaultPluginDir() string {
// These are the folders we use for the default plugin-dir.
// - the passed in pluginDir if it's not empty.
// - "./.plugins" if it exists.
// - ~/.config/Headlamp/plugins exists or it can be made
// - "./.plugins" if the ~/.config/Headlamp/plugins can't be made.
// Windows: %APPDATA%\Headlamp\Config\plugins
// (for example, C:\Users\USERNAME\AppData\Roaming\Headlamp\Config\plugins)
pluginDirDefault := "./.plugins"
if folderExists(pluginDirDefault) {
return pluginDirDefault
}
// https://www.npmjs.com/package/env-paths
// https://pkg.go.dev/os#UserConfigDir
userConfigDir, err := os.UserConfigDir()
if err != nil {
log.Printf("error getting user config dir: %s\n", err)
return pluginDirDefault
}
pluginsConfigDir := filepath.Join(userConfigDir, "Headlamp", "plugins")
if runtime.GOOS == "windows" {
// golang is wrong for config folder on windows.
// This matches env-paths and headlamp-plugin.
pluginsConfigDir = filepath.Join(userConfigDir, "Headlamp", "Config", "plugins")
}
fileMode := 0755
err = os.MkdirAll(pluginsConfigDir, fs.FileMode(fileMode))
if err != nil {
log.Printf("error creating plugins directory: %s\n", err)
return pluginDirDefault
}
return pluginsConfigDir
}
// folderExists(path) returns true if the folder exists.
func folderExists(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return false
}
return info.IsDir()
}
func GetDefaultKubeConfigPath() string {
user, err := user.Current()
if err != nil {
log.Fatalf(err.Error())
}
homeDirectory := user.HomeDir
return filepath.Join(homeDirectory, ".kube", "config")
}
package config_test
import (
"os"
"testing"
"github.com/kinvolk/headlamp/backend/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
//nolint:funlen
func TestParse(t *testing.T) {
t.Run("no_args_no_env", func(t *testing.T) {
conf, err := config.Parse(nil)
require.NoError(t, err)
require.NotNil(t, conf)
assert.Equal(t, false, conf.DevMode)
assert.Equal(t, uint(4466), conf.Port)
assert.Equal(t, "profile,email", conf.OidcScopes)
})
t.Run("with_args", func(t *testing.T) {
args := []string{
"go run ./cmd", "--port=3456",
}
conf, err := config.Parse(args)
require.NoError(t, err)
require.NotNil(t, conf)
assert.Equal(t, uint(3456), conf.Port)
})
t.Run("from_env", func(t *testing.T) {
os.Setenv("HEADLAMP_CONFIG_OIDC_CLIENT_SECRET", "superSecretBotsStayAwayPlease")
defer os.Unsetenv("HEADLAMP_CONFIG_OIDC_CLIENT_SECRET")
args := []string{
"go run ./cmd", "-in-cluster",
}
conf, err := config.Parse(args)
require.NoError(t, err)
require.NotNil(t, conf)
assert.Equal(t, "superSecretBotsStayAwayPlease", conf.OidcClientSecret)
})
t.Run("both_args_and_env", func(t *testing.T) {
os.Setenv("HEADLAMP_CONFIG_PORT", "1234")
defer os.Unsetenv("HEADLAMP_CONFIG_PORT")
args := []string{
"go run ./cmd", "--port=9876",
}
conf, err := config.Parse(args)
require.NoError(t, err)
require.NotNil(t, conf)
assert.NotEqual(t, uint(1234), conf.Port)
assert.Equal(t, uint(9876), conf.Port)
})
t.Run("oidc_settings_without_incluster", func(t *testing.T) {
args := []string{
"go run ./cmd", "-oidc-client-id=noClient",
}
conf, err := config.Parse(args)
require.Error(t, err)
require.Nil(t, conf)
assert.Contains(t, err.Error(), "are only meant to be used in inCluster mode")
})
t.Run("invalid_base_url", func(t *testing.T) {
args := []string{
"go run ./cmd", "--base-url=testingthis",
}
conf, err := config.Parse(args)
require.Error(t, err)
require.Nil(t, conf)
assert.Contains(t, err.Error(), "base-url")
})
t.Run("kubeconfig_from_default_env", func(t *testing.T) {
os.Setenv("KUBECONFIG", "~/.kube/test_config.yaml")
defer os.Unsetenv("KUBECONFIG")
args := []string{
"go run ./cmd",
}
conf, err := config.Parse(args)
require.NoError(t, err)
require.NotNil(t, conf)
assert.Equal(t, conf.KubeConfigPath, "~/.kube/test_config.yaml")
})
}
......@@ -10,10 +10,10 @@ Headlamp supports OIDC for cluster users to effortlessly log in using a "Sign in
For OIDC to be used, Headlamp needs to know how to configure it, so you have to provide the different OIDC-related arguments to Headlamp from your OIDC provider. Those are:
* the client ID: `-oidc-client-id`
* the client secret: `-oidc-client-secret`
* the issuer URL: `-oidc-idp-issuer-url`
* (optionally) the OpenId scopes: `-oidc-scopes`
* the client ID: `-oidc-client-id` or env var `HEADLAMP_CONFIG_OIDC_CLIENT_ID`
* the client secret: `-oidc-client-secret` or env var `HEADLAMP_CONFIG_OIDC_CLIENT_SECRET`
* the issuer URL: `-oidc-idp-issuer-url` or env var `HEADLAMP_CONFIG_OIDC_IDP_ISSUER_URL`
* (optionally) the OpenId scopes: `-oidc-scopes` or env var `HEADLAMP_CONFIG_OIDC_SCOPES`
and you have to tell the OIDC provider about the callback URL, which in Headlamp it is your URL + the `/oidc-callback` path, e.g.:
`https://YOUR_URL/oidc-callback`.
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment