mirror of
https://github.com/ahmetb/kubectx.git
synced 2026-05-05 12:41:44 +00:00
Extract common shell logic into shellSession
Deduplicates code between ShellOp and ReadonlyShellOp by extracting shared logic (context validation, kubeconfig extraction, temp file management, shell spawning) into a shellSession struct. Callers configure behavior via: - printEntry/printExit callbacks for banner messages - extraEnv for additional environment variables - transformKubeconfig hook for kubeconfig transformation (e.g. proxy) Also extracts fzfPickContext for shared interactive mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
|
||||
"github.com/ahmetb/kubectx/internal/cmdutil"
|
||||
"github.com/ahmetb/kubectx/internal/env"
|
||||
"github.com/ahmetb/kubectx/internal/kubeconfig"
|
||||
"github.com/ahmetb/kubectx/internal/printer"
|
||||
"github.com/ahmetb/kubectx/internal/proxy"
|
||||
)
|
||||
@@ -31,154 +25,69 @@ type ReadonlyShellOp struct {
|
||||
}
|
||||
|
||||
func (op InteractiveReadonlyShellOp) Run(_, stderr io.Writer) error {
|
||||
if err := checkIsolatedMode(); err != nil {
|
||||
choice, err := fzfPickContext(op.SelfCmd, stderr)
|
||||
if err != nil || choice == "" {
|
||||
return err
|
||||
}
|
||||
|
||||
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
|
||||
defer kc.Close()
|
||||
if err := kc.Parse(); err != nil {
|
||||
if cmdutil.IsNotFoundErr(err) {
|
||||
printer.Warning(stderr, "kubeconfig file not found")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("kubeconfig error: %w", err)
|
||||
}
|
||||
|
||||
ctxNames, err := kc.ContextNames()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get context names: %w", err)
|
||||
}
|
||||
if len(ctxNames) == 0 {
|
||||
return errors.New("no contexts found in the kubeconfig file")
|
||||
}
|
||||
|
||||
cmd := exec.Command("fzf", "--ansi", "--no-preview")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stderr = stderr
|
||||
cmd.Stdout = &out
|
||||
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("FZF_DEFAULT_COMMAND=%s", op.SelfCmd),
|
||||
fmt.Sprintf("%s=1", env.EnvForceColor))
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
choice := strings.TrimSpace(out.String())
|
||||
if choice == "" {
|
||||
return errors.New("you did not choose any of the options")
|
||||
}
|
||||
return ReadonlyShellOp{Target: choice}.Run(nil, stderr)
|
||||
}
|
||||
|
||||
func (op ReadonlyShellOp) Run(_, stderr io.Writer) error {
|
||||
if err := checkIsolatedMode(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kubectlPath, err := resolveKubectl()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify context exists and get current context for exit message.
|
||||
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
|
||||
defer kc.Close()
|
||||
if err := kc.Parse(); err != nil {
|
||||
return fmt.Errorf("kubeconfig error: %w", err)
|
||||
}
|
||||
exists, err := kc.ContextExists(op.Target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check context: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("no context exists with the name: %q", op.Target)
|
||||
}
|
||||
previousCtx, err := kc.GetCurrentContext()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current context: %w", err)
|
||||
}
|
||||
|
||||
// Extract minimal kubeconfig for the target context.
|
||||
data, err := extractMinimalKubeconfig(kubectlPath, op.Target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract kubeconfig for context: %w", err)
|
||||
}
|
||||
|
||||
// Write original minified kubeconfig to temp file (used by proxy for TLS/auth).
|
||||
origFile, err := os.CreateTemp("", "kubectx-readonly-orig-*.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp kubeconfig file: %w", err)
|
||||
}
|
||||
origPath := origFile.Name()
|
||||
defer os.Remove(origPath)
|
||||
|
||||
if _, err := origFile.Write(data); err != nil {
|
||||
origFile.Close()
|
||||
return fmt.Errorf("failed to write temp kubeconfig: %w", err)
|
||||
}
|
||||
origFile.Close()
|
||||
|
||||
// Start the readonly proxy.
|
||||
p, err := proxy.Start(proxy.Config{
|
||||
KubeconfigPath: origPath,
|
||||
ContextName: op.Target,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start readonly proxy: %w", err)
|
||||
}
|
||||
defer p.Shutdown(context.Background())
|
||||
|
||||
// Rewrite kubeconfig to point to the proxy.
|
||||
rewritten, err := proxy.RewriteKubeconfig(data, p.Addr())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to rewrite kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
// Write rewritten kubeconfig to a second temp file for the shell.
|
||||
shellFile, err := os.CreateTemp("", "kubectx-readonly-shell-*.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp kubeconfig file: %w", err)
|
||||
}
|
||||
shellPath := shellFile.Name()
|
||||
defer os.Remove(shellPath)
|
||||
|
||||
if _, err := shellFile.Write(rewritten); err != nil {
|
||||
shellFile.Close()
|
||||
return fmt.Errorf("failed to write rewritten kubeconfig: %w", err)
|
||||
}
|
||||
shellFile.Close()
|
||||
|
||||
// Give the proxy a moment to be ready.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Print entry message.
|
||||
badgeColor := color.New(color.BgYellow, color.FgBlack, color.Bold)
|
||||
printer.EnableOrDisableColor(badgeColor)
|
||||
fmt.Fprintf(stderr, "%s kubectl context is %s in READ-ONLY mode — type 'exit' to leave.\n",
|
||||
badgeColor.Sprint("[READONLY SHELL]"), printer.WarningColor.Sprint(op.Target))
|
||||
|
||||
// Detect and start shell.
|
||||
shellBin := detectShell()
|
||||
cmd := exec.Command(shellBin)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = append(os.Environ(),
|
||||
"KUBECONFIG="+shellPath,
|
||||
env.EnvIsolatedShell+"=1",
|
||||
env.EnvReadonlyShell+"=1",
|
||||
)
|
||||
s := &shellSession{
|
||||
target: op.Target,
|
||||
extraEnv: []string{env.EnvReadonlyShell + "=1"},
|
||||
printEntry: func(w io.Writer, ctxName string) {
|
||||
fmt.Fprintf(w, "%s kubectl context is %s in READ-ONLY mode — type 'exit' to leave.\n",
|
||||
badgeColor.Sprint("[READONLY SHELL]"), printer.WarningColor.Sprint(ctxName))
|
||||
},
|
||||
printExit: func(w io.Writer, prevCtx string) {
|
||||
fmt.Fprintf(w, "%s kubectl context is now %s.\n",
|
||||
badgeColor.Sprint("[READONLY SHELL EXITED]"), printer.WarningColor.Sprint(prevCtx))
|
||||
},
|
||||
transformKubeconfig: func(data []byte) ([]byte, func(), error) {
|
||||
// Write original kubeconfig to temp file for the proxy to load TLS/auth.
|
||||
origFile, err := os.CreateTemp("", "kubectx-readonly-orig-*.yaml")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create temp kubeconfig file: %w", err)
|
||||
}
|
||||
origPath := origFile.Name()
|
||||
|
||||
_ = cmd.Run()
|
||||
if _, err := origFile.Write(data); err != nil {
|
||||
origFile.Close()
|
||||
os.Remove(origPath)
|
||||
return nil, nil, fmt.Errorf("failed to write temp kubeconfig: %w", err)
|
||||
}
|
||||
origFile.Close()
|
||||
|
||||
// Print exit message.
|
||||
fmt.Fprintf(stderr, "%s kubectl context is now %s.\n",
|
||||
badgeColor.Sprint("[READONLY SHELL EXITED]"), printer.WarningColor.Sprint(previousCtx))
|
||||
// Start the readonly proxy.
|
||||
p, err := proxy.Start(proxy.Config{
|
||||
KubeconfigPath: origPath,
|
||||
ContextName: op.Target,
|
||||
})
|
||||
if err != nil {
|
||||
os.Remove(origPath)
|
||||
return nil, nil, fmt.Errorf("failed to start readonly proxy: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
// Rewrite kubeconfig to point to the proxy.
|
||||
rewritten, err := proxy.RewriteKubeconfig(data, p.Addr())
|
||||
if err != nil {
|
||||
p.Shutdown(context.Background())
|
||||
os.Remove(origPath)
|
||||
return nil, nil, fmt.Errorf("failed to rewrite kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
cleanup := func() {
|
||||
p.Shutdown(context.Background())
|
||||
os.Remove(origPath)
|
||||
}
|
||||
return rewritten, cleanup, nil
|
||||
},
|
||||
}
|
||||
return s.run(stderr)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
|
||||
"github.com/ahmetb/kubectx/internal/cmdutil"
|
||||
"github.com/ahmetb/kubectx/internal/env"
|
||||
"github.com/ahmetb/kubectx/internal/kubeconfig"
|
||||
"github.com/ahmetb/kubectx/internal/printer"
|
||||
)
|
||||
|
||||
@@ -29,122 +23,29 @@ type ShellOp struct {
|
||||
}
|
||||
|
||||
func (op InteractiveShellOp) Run(_, stderr io.Writer) error {
|
||||
if err := checkIsolatedMode(); err != nil {
|
||||
choice, err := fzfPickContext(op.SelfCmd, stderr)
|
||||
if err != nil || choice == "" {
|
||||
return err
|
||||
}
|
||||
|
||||
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
|
||||
defer kc.Close()
|
||||
if err := kc.Parse(); err != nil {
|
||||
if cmdutil.IsNotFoundErr(err) {
|
||||
printer.Warning(stderr, "kubeconfig file not found")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("kubeconfig error: %w", err)
|
||||
}
|
||||
|
||||
ctxNames, err := kc.ContextNames()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get context names: %w", err)
|
||||
}
|
||||
if len(ctxNames) == 0 {
|
||||
return errors.New("no contexts found in the kubeconfig file")
|
||||
}
|
||||
|
||||
cmd := exec.Command("fzf", "--ansi", "--no-preview")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stderr = stderr
|
||||
cmd.Stdout = &out
|
||||
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("FZF_DEFAULT_COMMAND=%s", op.SelfCmd),
|
||||
fmt.Sprintf("%s=1", env.EnvForceColor))
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
choice := strings.TrimSpace(out.String())
|
||||
if choice == "" {
|
||||
return errors.New("you did not choose any of the options")
|
||||
}
|
||||
return ShellOp{Target: choice}.Run(nil, stderr)
|
||||
}
|
||||
|
||||
func (op ShellOp) Run(_, stderr io.Writer) error {
|
||||
if err := checkIsolatedMode(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kubectlPath, err := resolveKubectl()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify context exists and get current context for exit message
|
||||
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
|
||||
defer kc.Close()
|
||||
if err := kc.Parse(); err != nil {
|
||||
return fmt.Errorf("kubeconfig error: %w", err)
|
||||
}
|
||||
exists, err := kc.ContextExists(op.Target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check context: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("no context exists with the name: \"%s\"", op.Target)
|
||||
}
|
||||
previousCtx, err := kc.GetCurrentContext()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current context: %w", err)
|
||||
}
|
||||
|
||||
// Extract minimal kubeconfig using kubectl
|
||||
data, err := extractMinimalKubeconfig(kubectlPath, op.Target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract kubeconfig for context: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
tmpFile, err := os.CreateTemp("", "kubectx-shell-*.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp kubeconfig file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to write temp kubeconfig: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Print entry message
|
||||
badgeColor := color.New(color.BgRed, color.FgWhite, color.Bold)
|
||||
printer.EnableOrDisableColor(badgeColor)
|
||||
fmt.Fprintf(stderr, "%s kubectl context is %s in this shell — type 'exit' to leave.\n",
|
||||
badgeColor.Sprint("[ISOLATED SHELL]"), printer.WarningColor.Sprint(op.Target))
|
||||
|
||||
// Detect and start shell
|
||||
shellBin := detectShell()
|
||||
cmd := exec.Command(shellBin)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = append(os.Environ(),
|
||||
"KUBECONFIG="+tmpPath,
|
||||
env.EnvIsolatedShell+"=1",
|
||||
)
|
||||
|
||||
_ = cmd.Run()
|
||||
|
||||
// Print exit message
|
||||
fmt.Fprintf(stderr, "%s kubectl context is now %s.\n",
|
||||
badgeColor.Sprint("[ISOLATED SHELL EXITED]"), printer.WarningColor.Sprint(previousCtx))
|
||||
|
||||
return nil
|
||||
s := &shellSession{
|
||||
target: op.Target,
|
||||
printEntry: func(w io.Writer, ctxName string) {
|
||||
fmt.Fprintf(w, "%s kubectl context is %s in this shell — type 'exit' to leave.\n",
|
||||
badgeColor.Sprint("[ISOLATED SHELL]"), printer.WarningColor.Sprint(ctxName))
|
||||
},
|
||||
printExit: func(w io.Writer, prevCtx string) {
|
||||
fmt.Fprintf(w, "%s kubectl context is now %s.\n",
|
||||
badgeColor.Sprint("[ISOLATED SHELL EXITED]"), printer.WarningColor.Sprint(prevCtx))
|
||||
},
|
||||
}
|
||||
return s.run(stderr)
|
||||
}
|
||||
|
||||
func resolveKubectl() (string, error) {
|
||||
|
||||
161
cmd/kubectx/shell_session.go
Normal file
161
cmd/kubectx/shell_session.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/ahmetb/kubectx/internal/cmdutil"
|
||||
"github.com/ahmetb/kubectx/internal/env"
|
||||
"github.com/ahmetb/kubectx/internal/kubeconfig"
|
||||
"github.com/ahmetb/kubectx/internal/printer"
|
||||
)
|
||||
|
||||
// shellSession holds the configuration for spawning an isolated sub-shell.
|
||||
type shellSession struct {
|
||||
target string
|
||||
extraEnv []string // additional env vars beyond KUBECONFIG + KUBECTX_ISOLATED_SHELL
|
||||
|
||||
printEntry func(stderr io.Writer, ctxName string)
|
||||
printExit func(stderr io.Writer, prevCtx string)
|
||||
|
||||
// transformKubeconfig optionally transforms the minified kubeconfig bytes
|
||||
// before writing them to the shell's temp file. The returned cleanup func
|
||||
// is called after the shell exits (e.g. to shut down a proxy).
|
||||
// If nil, the kubeconfig is used as-is.
|
||||
transformKubeconfig func(data []byte) (newData []byte, cleanup func(), err error)
|
||||
}
|
||||
|
||||
func (s *shellSession) run(stderr io.Writer) error {
|
||||
if err := checkIsolatedMode(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kubectlPath, err := resolveKubectl()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify context exists and get current context for exit message.
|
||||
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
|
||||
defer kc.Close()
|
||||
if err := kc.Parse(); err != nil {
|
||||
return fmt.Errorf("kubeconfig error: %w", err)
|
||||
}
|
||||
exists, err := kc.ContextExists(s.target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check context: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("no context exists with the name: %q", s.target)
|
||||
}
|
||||
previousCtx, err := kc.GetCurrentContext()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current context: %w", err)
|
||||
}
|
||||
|
||||
// Extract minimal kubeconfig for the target context.
|
||||
data, err := extractMinimalKubeconfig(kubectlPath, s.target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract kubeconfig for context: %w", err)
|
||||
}
|
||||
|
||||
// Optionally transform the kubeconfig (e.g. rewrite for readonly proxy).
|
||||
var cleanup func()
|
||||
if s.transformKubeconfig != nil {
|
||||
data, cleanup, err = s.transformKubeconfig(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// Write kubeconfig to temp file.
|
||||
tmpFile, err := os.CreateTemp("", "kubectx-shell-*.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp kubeconfig file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to write temp kubeconfig: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Print entry message.
|
||||
s.printEntry(stderr, s.target)
|
||||
|
||||
// Detect and start shell.
|
||||
shellBin := detectShell()
|
||||
cmd := exec.Command(shellBin)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = append(os.Environ(),
|
||||
append([]string{
|
||||
"KUBECONFIG=" + tmpPath,
|
||||
env.EnvIsolatedShell + "=1",
|
||||
}, s.extraEnv...)...,
|
||||
)
|
||||
|
||||
_ = cmd.Run()
|
||||
|
||||
// Print exit message.
|
||||
s.printExit(stderr, previousCtx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fzfPickContext launches fzf for interactive context selection.
|
||||
func fzfPickContext(selfCmd string, stderr io.Writer) (string, error) {
|
||||
if err := checkIsolatedMode(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
kc := new(kubeconfig.Kubeconfig).WithLoader(kubeconfig.DefaultLoader)
|
||||
defer kc.Close()
|
||||
if err := kc.Parse(); err != nil {
|
||||
if cmdutil.IsNotFoundErr(err) {
|
||||
printer.Warning(stderr, "kubeconfig file not found")
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("kubeconfig error: %w", err)
|
||||
}
|
||||
|
||||
ctxNames, err := kc.ContextNames()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get context names: %w", err)
|
||||
}
|
||||
if len(ctxNames) == 0 {
|
||||
return "", errors.New("no contexts found in the kubeconfig file")
|
||||
}
|
||||
|
||||
cmd := exec.Command("fzf", "--ansi", "--no-preview")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stderr = stderr
|
||||
cmd.Stdout = &out
|
||||
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("FZF_DEFAULT_COMMAND=%s", selfCmd),
|
||||
fmt.Sprintf("%s=1", env.EnvForceColor))
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
choice := strings.TrimSpace(out.String())
|
||||
if choice == "" {
|
||||
return "", errors.New("you did not choose any of the options")
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
Reference in New Issue
Block a user