mirror of
https://github.com/helm/helm.git
synced 2026-05-28 04:35:48 -04:00
fix(helm): fix flag parsing once and for all
This commit is contained in:
parent
1261f71faf
commit
a29e610938
13 changed files with 219 additions and 97 deletions
|
|
@ -23,8 +23,6 @@ import (
|
|||
"testing"
|
||||
|
||||
"k8s.io/helm/pkg/chartutil"
|
||||
"k8s.io/helm/pkg/helm/environment"
|
||||
"k8s.io/helm/pkg/helm/helmpath"
|
||||
"k8s.io/helm/pkg/proto/hapi/chart"
|
||||
)
|
||||
|
||||
|
|
@ -87,7 +85,7 @@ func TestCreateStarterCmd(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := helmpath.Home(environment.DefaultHelmHome)
|
||||
old := settings.Home
|
||||
settings.Home = thome
|
||||
defer func() {
|
||||
settings.Home = old
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ import (
|
|||
"regexp"
|
||||
"testing"
|
||||
|
||||
"k8s.io/helm/pkg/helm/environment"
|
||||
"k8s.io/helm/pkg/helm/helmpath"
|
||||
"k8s.io/helm/pkg/repo/repotest"
|
||||
)
|
||||
|
||||
|
|
@ -34,7 +32,7 @@ func TestFetchCmd(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := helmpath.Home(environment.DefaultHelmHome)
|
||||
old := settings.Home
|
||||
settings.Home = hh
|
||||
defer func() {
|
||||
settings.Home = old
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import (
|
|||
helm_env "k8s.io/helm/pkg/helm/environment"
|
||||
"k8s.io/helm/pkg/helm/portforwarder"
|
||||
"k8s.io/helm/pkg/kube"
|
||||
tiller_env "k8s.io/helm/pkg/tiller/environment"
|
||||
"k8s.io/helm/pkg/tlsutil"
|
||||
)
|
||||
|
||||
|
|
@ -46,7 +45,6 @@ var (
|
|||
tlsVerify bool // enable TLS and verify remote certificates
|
||||
tlsEnable bool // enable TLS
|
||||
|
||||
kubeContext string
|
||||
tillerTunnel *kube.Tunnel
|
||||
settings helm_env.EnvSettings
|
||||
)
|
||||
|
|
@ -75,57 +73,25 @@ Environment:
|
|||
$KUBECONFIG set an alternative Kubernetes configuration file (default "~/.kube/config")
|
||||
`
|
||||
|
||||
func setFlagFromEnv(name, envar string, cmd *cobra.Command) {
|
||||
if cmd.Flags().Changed(name) {
|
||||
return
|
||||
}
|
||||
if v, ok := os.LookupEnv(envar); ok {
|
||||
cmd.Flags().Set(name, v)
|
||||
}
|
||||
}
|
||||
|
||||
func setFlagsFromEnv(flags map[string]string, cmd *cobra.Command) {
|
||||
for name, envar := range flags {
|
||||
setFlagFromEnv(name, envar, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func addRootFlags(cmd *cobra.Command) {
|
||||
pf := cmd.PersistentFlags()
|
||||
pf.StringVar((*string)(&settings.Home), "home", helm_env.DefaultHelmHome, "location of your Helm config. Overrides $HELM_HOME")
|
||||
pf.StringVar(&settings.TillerHost, "host", "", "address of Tiller. Overrides $HELM_HOST")
|
||||
pf.StringVar(&kubeContext, "kube-context", "", "name of the kubeconfig context to use")
|
||||
pf.BoolVar(&settings.Debug, "debug", false, "enable verbose output")
|
||||
pf.StringVar(&settings.TillerNamespace, "tiller-namespace", tiller_env.DefaultTillerNamespace, "namespace of Tiller")
|
||||
}
|
||||
|
||||
func initRootFlags(cmd *cobra.Command) {
|
||||
setFlagsFromEnv(map[string]string{
|
||||
"debug": helm_env.DebugEnvVar,
|
||||
"home": helm_env.HomeEnvVar,
|
||||
"host": helm_env.HostEnvVar,
|
||||
"tiller-namespace": tiller_env.TillerNamespaceEnvVar,
|
||||
}, cmd.Root())
|
||||
|
||||
tlsCaCertFile = os.ExpandEnv(tlsCaCertFile)
|
||||
tlsCertFile = os.ExpandEnv(tlsCertFile)
|
||||
tlsKeyFile = os.ExpandEnv(tlsKeyFile)
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
func newRootCmd(args []string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "helm",
|
||||
Short: "The Helm package manager for Kubernetes.",
|
||||
Long: globalUsage,
|
||||
SilenceUsage: true,
|
||||
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
|
||||
initRootFlags(cmd)
|
||||
PersistentPreRun: func(*cobra.Command, []string) {
|
||||
tlsCaCertFile = os.ExpandEnv(tlsCaCertFile)
|
||||
tlsCertFile = os.ExpandEnv(tlsCertFile)
|
||||
tlsKeyFile = os.ExpandEnv(tlsKeyFile)
|
||||
},
|
||||
PersistentPostRun: func(*cobra.Command, []string) {
|
||||
teardown()
|
||||
},
|
||||
}
|
||||
addRootFlags(cmd)
|
||||
flags := cmd.PersistentFlags()
|
||||
|
||||
settings.AddFlags(flags)
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
cmd.AddCommand(
|
||||
|
|
@ -167,6 +133,11 @@ func newRootCmd() *cobra.Command {
|
|||
markDeprecated(newRepoUpdateCmd(out), "use 'helm repo update'\n"),
|
||||
)
|
||||
|
||||
flags.Parse(args)
|
||||
|
||||
// set defaults from environment
|
||||
settings.Init(flags)
|
||||
|
||||
// Find and add plugins
|
||||
loadPlugins(cmd, out)
|
||||
|
||||
|
|
@ -179,7 +150,7 @@ func init() {
|
|||
}
|
||||
|
||||
func main() {
|
||||
cmd := newRootCmd()
|
||||
cmd := newRootCmd(os.Args[1:])
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -192,7 +163,7 @@ func markDeprecated(cmd *cobra.Command, notice string) *cobra.Command {
|
|||
|
||||
func setupConnection(c *cobra.Command, args []string) error {
|
||||
if settings.TillerHost == "" {
|
||||
config, client, err := getKubeClient(kubeContext)
|
||||
config, client, err := getKubeClient(settings.KubeContext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/protobuf/ptypes/timestamp"
|
||||
|
|
@ -232,8 +233,13 @@ func ensureTestHome(home helmpath.Home, t *testing.T) error {
|
|||
}
|
||||
|
||||
func TestRootCmd(t *testing.T) {
|
||||
oldhome := os.Getenv("HELM_HOME")
|
||||
defer os.Setenv("HELM_HOME", oldhome)
|
||||
// reset env
|
||||
defer func(origEnv []string) {
|
||||
for _, pair := range origEnv {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
os.Setenv(kv[0], kv[1])
|
||||
}
|
||||
}(os.Environ())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -287,7 +293,7 @@ func TestRootCmd(t *testing.T) {
|
|||
os.Setenv(k, v)
|
||||
}
|
||||
|
||||
cmd := newRootCmd()
|
||||
cmd := newRootCmd(tt.args)
|
||||
cmd.SetOutput(ioutil.Discard)
|
||||
cmd.SetArgs(tt.args)
|
||||
cmd.Run = func(*cobra.Command, []string) {}
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ func (i *initCmd) run() error {
|
|||
|
||||
if !i.clientOnly {
|
||||
if i.kubeClient == nil {
|
||||
_, c, err := getKubeClient(kubeContext)
|
||||
_, c, err := getKubeClient(settings.KubeContext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get kubernetes client: %s", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -434,7 +434,7 @@ func generateName(nameTemplate string) (string, error) {
|
|||
}
|
||||
|
||||
func defaultNamespace() string {
|
||||
if ns, _, err := kube.GetConfig(kubeContext).Namespace(); err == nil {
|
||||
if ns, _, err := kube.GetConfig(settings.KubeContext).Namespace(); err == nil {
|
||||
return ns
|
||||
}
|
||||
return "default"
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
helm_env "k8s.io/helm/pkg/helm/environment"
|
||||
"k8s.io/helm/pkg/plugin"
|
||||
)
|
||||
|
||||
|
|
@ -38,20 +36,11 @@ import (
|
|||
func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
|
||||
|
||||
// If HELM_NO_PLUGINS is set to 1, do not load plugins.
|
||||
if os.Getenv(helm_env.PluginDisableEnvVar) == "1" {
|
||||
if os.Getenv("HELM_NO_PLUGINS") == "1" {
|
||||
return
|
||||
}
|
||||
|
||||
// manually handel processing of HELM_HOME and --home
|
||||
helmHome := "$HOME/.helm"
|
||||
if h, ok := os.LookupEnv("HELM_HOME"); ok {
|
||||
helmHome = h
|
||||
}
|
||||
|
||||
fs := pflag.NewFlagSet("homer", pflag.ContinueOnError)
|
||||
fs.StringVar((*string)(&settings.Home), "home", helmHome, "location of your Helm config. Overrides $HELM_HOME")
|
||||
fs.Parse(os.Args)
|
||||
|
||||
// debug("HELM_PLUGIN_DIRS=%s", settings.PluginDirs())
|
||||
found, err := findPlugins(settings.PluginDirs())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to load plugins: %s", err)
|
||||
|
|
@ -63,7 +52,6 @@ func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
|
|||
if err := cmd.Parent().ParseFlags(k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initRootFlags(cmd)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
helm_env "k8s.io/helm/pkg/helm/environment"
|
||||
"k8s.io/helm/pkg/helm/helmpath"
|
||||
"k8s.io/helm/pkg/plugin"
|
||||
|
||||
|
|
@ -152,10 +151,10 @@ func TestLoadPlugins_HelmNoPlugins(t *testing.T) {
|
|||
// Set helm home to point to testdata
|
||||
old := settings.Home
|
||||
settings.Home = "testdata/helmhome"
|
||||
os.Setenv(helm_env.PluginDisableEnvVar, "1")
|
||||
cleanup := resetEnv("HELM_NO_PLUGINS", "1")
|
||||
defer func() {
|
||||
settings.Home = old
|
||||
os.Unsetenv(helm_env.PluginDisableEnvVar)
|
||||
cleanup()
|
||||
}()
|
||||
|
||||
out := bytes.NewBuffer(nil)
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ func newResetCmd(client helm.Interface, out io.Writer) *cobra.Command {
|
|||
// runReset uninstalls tiller from Kubernetes Cluster and deletes local config
|
||||
func (d *resetCmd) run() error {
|
||||
if d.kubeClient == nil {
|
||||
_, c, err := getInternalKubeClient(kubeContext)
|
||||
_, c, err := getInternalKubeClient(settings.KubeContext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get kubernetes client: %s", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,20 +26,9 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"k8s.io/helm/pkg/helm/helmpath"
|
||||
)
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
const (
|
||||
// HomeEnvVar is the HELM_HOME environment variable key.
|
||||
HomeEnvVar = "HELM_HOME"
|
||||
// PluginEnvVar is the HELM_PLUGIN environment variable key.
|
||||
PluginEnvVar = "HELM_PLUGIN"
|
||||
// PluginDisableEnvVar is the HELM_NO_PLUGINS environment variable key.
|
||||
PluginDisableEnvVar = "HELM_NO_PLUGINS"
|
||||
// HostEnvVar is the HELM_HOST environment variable key.
|
||||
HostEnvVar = "HELM_HOST"
|
||||
// DebugEnvVar is the HELM_DEBUG environment variable key.
|
||||
DebugEnvVar = "HELM_DEBUG"
|
||||
"k8s.io/helm/pkg/helm/helmpath"
|
||||
)
|
||||
|
||||
// DefaultHelmHome is the default HELM_HOME.
|
||||
|
|
@ -55,12 +44,56 @@ type EnvSettings struct {
|
|||
Home helmpath.Home
|
||||
// Debug indicates whether or not Helm is running in Debug mode.
|
||||
Debug bool
|
||||
// KubeContext is the name of the kubeconfig context.
|
||||
KubeContext string
|
||||
}
|
||||
|
||||
// AddFlags binds flags to the given flagset.
|
||||
func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) {
|
||||
fs.StringVar((*string)(&s.Home), "home", DefaultHelmHome, "location of your Helm config. Overrides $HELM_HOME")
|
||||
fs.StringVar(&s.TillerHost, "host", "", "address of Tiller. Overrides $HELM_HOST")
|
||||
fs.StringVar(&s.KubeContext, "kube-context", "", "name of the kubeconfig context to use")
|
||||
fs.BoolVar(&s.Debug, "debug", false, "enable verbose output")
|
||||
fs.StringVar(&s.TillerNamespace, "tiller-namespace", "kube-system", "namespace of Tiller")
|
||||
}
|
||||
|
||||
// Init sets values from the environment.
|
||||
func (s *EnvSettings) Init(fs *pflag.FlagSet) {
|
||||
for name, envar := range envMap {
|
||||
setFlagFromEnv(name, envar, fs)
|
||||
}
|
||||
}
|
||||
|
||||
// PluginDirs is the path to the plugin directories.
|
||||
func (s EnvSettings) PluginDirs() string {
|
||||
if d := os.Getenv(PluginEnvVar); d != "" {
|
||||
if d, ok := os.LookupEnv("HELM_PLUGIN"); ok {
|
||||
return d
|
||||
}
|
||||
return s.Home.Plugins()
|
||||
}
|
||||
|
||||
// envMap maps flag names to envvars
|
||||
var envMap = map[string]string{
|
||||
"debug": "HELM_DEBUG",
|
||||
"home": "HELM_HOME",
|
||||
"host": "HELM_HOST",
|
||||
"tiller-namespace": "TILLER_NAMESPACE",
|
||||
}
|
||||
|
||||
func setFlagFromEnv(name, envar string, fs *pflag.FlagSet) {
|
||||
if fs.Changed(name) {
|
||||
return
|
||||
}
|
||||
if v, ok := os.LookupEnv(envar); ok {
|
||||
fs.Set(name, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated
|
||||
const (
|
||||
HomeEnvVar = "HELM_HOME"
|
||||
PluginEnvVar = "HELM_PLUGIN"
|
||||
PluginDisableEnvVar = "HELM_NO_PLUGINS"
|
||||
HostEnvVar = "HELM_HOST"
|
||||
DebugEnvVar = "HELM_DEBUG"
|
||||
)
|
||||
|
|
|
|||
134
pkg/helm/environment/environment_test.go
Normal file
134
pkg/helm/environment/environment_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
Copyright 2016 The Kubernetes Authors All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package environment
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"k8s.io/helm/pkg/helm/helmpath"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestEnvSettings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
// input
|
||||
args []string
|
||||
envars map[string]string
|
||||
|
||||
// expected values
|
||||
home, host, ns, kcontext, plugins string
|
||||
debug bool
|
||||
}{
|
||||
{
|
||||
name: "defaults",
|
||||
args: []string{},
|
||||
home: DefaultHelmHome,
|
||||
plugins: helmpath.Home(DefaultHelmHome).Plugins(),
|
||||
ns: "kube-system",
|
||||
},
|
||||
{
|
||||
name: "with flags set",
|
||||
args: []string{"--home", "/foo", "--host=here", "--debug", "--tiller-namespace=myns"},
|
||||
home: "/foo",
|
||||
plugins: helmpath.Home("/foo").Plugins(),
|
||||
host: "here",
|
||||
ns: "myns",
|
||||
debug: true,
|
||||
},
|
||||
{
|
||||
name: "with envvars set",
|
||||
args: []string{},
|
||||
envars: map[string]string{"HELM_HOME": "/bar", "HELM_HOST": "there", "HELM_DEBUG": "1", "TILLER_NAMESPACE": "yourns"},
|
||||
home: "/bar",
|
||||
plugins: helmpath.Home("/bar").Plugins(),
|
||||
host: "there",
|
||||
ns: "yourns",
|
||||
debug: true,
|
||||
},
|
||||
{
|
||||
name: "with flags and envvars set",
|
||||
args: []string{"--home", "/foo", "--host=here", "--debug", "--tiller-namespace=myns"},
|
||||
envars: map[string]string{"HELM_HOME": "/bar", "HELM_HOST": "there", "HELM_DEBUG": "1", "TILLER_NAMESPACE": "yourns", "HELM_PLUGIN": "glade"},
|
||||
home: "/foo",
|
||||
plugins: "glade",
|
||||
host: "here",
|
||||
ns: "myns",
|
||||
debug: true,
|
||||
},
|
||||
}
|
||||
|
||||
cleanup := resetEnv()
|
||||
defer cleanup()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for k, v := range tt.envars {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
|
||||
flags := pflag.NewFlagSet("testing", pflag.ContinueOnError)
|
||||
|
||||
settings := &EnvSettings{}
|
||||
settings.AddFlags(flags)
|
||||
flags.Parse(tt.args)
|
||||
|
||||
settings.Init(flags)
|
||||
|
||||
if settings.Home != helmpath.Home(tt.home) {
|
||||
t.Errorf("expected home %q, got %q", tt.home, settings.Home)
|
||||
}
|
||||
if settings.PluginDirs() != tt.plugins {
|
||||
t.Errorf("expected plugins %q, got %q", tt.plugins, settings.PluginDirs())
|
||||
}
|
||||
if settings.TillerHost != tt.host {
|
||||
t.Errorf("expected host %q, got %q", tt.host, settings.TillerHost)
|
||||
}
|
||||
if settings.Debug != tt.debug {
|
||||
t.Errorf("expected debug %t, got %t", tt.debug, settings.Debug)
|
||||
}
|
||||
if settings.TillerNamespace != tt.ns {
|
||||
t.Errorf("expected tiller-namespace %q, got %q", tt.ns, settings.TillerNamespace)
|
||||
}
|
||||
if settings.KubeContext != tt.kcontext {
|
||||
t.Errorf("expected kube-context %q, got %q", tt.kcontext, settings.KubeContext)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func resetEnv() func() {
|
||||
origEnv := os.Environ()
|
||||
|
||||
// ensure any local envvars do not hose us
|
||||
for _, e := range envMap {
|
||||
os.Unsetenv(e)
|
||||
}
|
||||
|
||||
return func() {
|
||||
for _, pair := range origEnv {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
os.Setenv(kv[0], kv[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@ import (
|
|||
"strings"
|
||||
|
||||
helm_env "k8s.io/helm/pkg/helm/environment"
|
||||
tiller_env "k8s.io/helm/pkg/tiller/environment"
|
||||
|
||||
"github.com/ghodss/yaml"
|
||||
)
|
||||
|
|
@ -179,8 +178,8 @@ func SetupPluginEnv(settings helm_env.EnvSettings,
|
|||
|
||||
// Set vars that may not have been set, and save client the
|
||||
// trouble of re-parsing.
|
||||
helm_env.PluginEnvVar: settings.PluginDirs(),
|
||||
helm_env.HomeEnvVar: settings.Home.String(),
|
||||
"HELM_PLUGIN": settings.PluginDirs(),
|
||||
"HELM_HOME": settings.Home.String(),
|
||||
|
||||
// Set vars that convey common information.
|
||||
"HELM_PATH_REPOSITORY": settings.Home.Repository(),
|
||||
|
|
@ -189,8 +188,8 @@ func SetupPluginEnv(settings helm_env.EnvSettings,
|
|||
"HELM_PATH_LOCAL_REPOSITORY": settings.Home.LocalRepository(),
|
||||
"HELM_PATH_STARTER": settings.Home.Starters(),
|
||||
|
||||
"TILLER_HOST": settings.TillerHost,
|
||||
tiller_env.TillerNamespaceEnvVar: settings.TillerNamespace,
|
||||
"TILLER_HOST": settings.TillerHost,
|
||||
"TILLER_NAMESPACE": settings.TillerNamespace,
|
||||
} {
|
||||
os.Setenv(key, val)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,6 @@ import (
|
|||
"k8s.io/helm/pkg/storage/driver"
|
||||
)
|
||||
|
||||
// TillerNamespaceEnvVar is the environment variable name for the Tiller
|
||||
// namespace in the kubernetes cluster.
|
||||
const TillerNamespaceEnvVar = "TILLER_NAMESPACE"
|
||||
|
||||
// DefaultTillerNamespace is the default namespace for Tiller.
|
||||
const DefaultTillerNamespace = "kube-system"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue