Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
402 changes: 402 additions & 0 deletions exec/cmd.go

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions exec/cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package exec

import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
"strings"
"time"

meta "github.com/ninech/apis/meta/v1alpha1"
"github.com/ninech/nctl/internal/format"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// capturingCmd records the exec.Cmd passed to runCommand.
type capturingCmd struct {
cmd *exec.Cmd
}

// testSecret creates a corev1.Secret with a single username→password entry.
func testSecret(name, namespace, user, password string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Data: map[string][]byte{
user: []byte(password),
},
}
}

// testDatabaseCmd returns a capturingCmd and a databaseCmd wired with no-op
// writer/reader and test-friendly function fields.
// When cidrs is non-nil those CIDRs are used; when nil the IP detection is
// triggered only for instance resources (which is safe to use in tests if the
// connector returns nil from AllowedCIDRs).
func testDatabaseCmd(name string, cidrs *[]meta.IPv4CIDR) (*capturingCmd, serviceCmd) {
return testDatabaseCmdConfirmed(name, cidrs, false)
}

// testDatabaseCmdConfirmed is like testDatabaseCmd but pre-seeds the reader
// with "y\n" so that confirmation prompts are auto-accepted.
func testDatabaseCmdConfirmed(name string, cidrs *[]meta.IPv4CIDR, confirmed bool) (*capturingCmd, serviceCmd) {
var reader io.Reader = &bytes.Buffer{}
if confirmed {
reader = strings.NewReader("y\n")
}
cap := &capturingCmd{}
cmd := serviceCmd{
resourceCmd: resourceCmd{Name: name},
Writer: format.NewWriter(&bytes.Buffer{}),
Reader: format.NewReader(reader),
AllowedCidrs: cidrs,
WaitTimeout: 0,
runCommand: func(c *exec.Cmd) error {
cap.cmd = c
return nil
},
lookPath: func(file string) (string, error) {
return "/usr/bin/" + file, nil
},
waitForConnectivity: func(_ context.Context, _ format.Writer, _ string, _ time.Duration) error {
return nil
},
openTTYForConfirm: func() (io.ReadCloser, error) {
return nil, fmt.Errorf("no tty in tests")
},
}
return cap, cmd
}
10 changes: 8 additions & 2 deletions exec/exec.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
// Package exec provides the implementation for the exec command.
package exec

// Cmd holds all exec sub-commands.
type Cmd struct {
Application applicationCmd `cmd:"" group:"deplo.io" aliases:"app,application" name:"application" help:"Execute a command or shell in a deplo.io application."`
Application applicationCmd `cmd:"" group:"deplo.io" aliases:"app,application" name:"application" help:"Execute a command or shell in a deplo.io application."`
Postgres postgresCmd `cmd:"" group:"storage.nine.ch" name:"postgres" help:"Connect to a PostgreSQL instance."`
PostgresDatabase postgresDatabaseCmd `cmd:"" group:"storage.nine.ch" name:"postgresdatabase" help:"Connect to a PostgreSQL database."`
MySQL mysqlCmd `cmd:"" group:"storage.nine.ch" name:"mysql" help:"Connect to a MySQL instance."`
MySQLDatabase mysqlDatabaseCmd `cmd:"" group:"storage.nine.ch" name:"mysqldatabase" help:"Connect to a MySQL database."`
KeyValueStore kvsCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Connect to a KeyValueStore instance."`
}

type resourceCmd struct {
Name string `arg:"" completion-predictor:"resource_name" help:"Name of the application to exec command/shell in." required:""`
Name string `arg:"" completion-predictor:"resource_name" help:"Name of the resource." required:""`
}
104 changes: 104 additions & 0 deletions exec/keyvaluestore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package exec

import (
"context"
"fmt"
"os/exec"

meta "github.com/ninech/apis/meta/v1alpha1"
storage "github.com/ninech/apis/storage/v1alpha1"
"github.com/ninech/nctl/api"
"github.com/ninech/nctl/internal/cli"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

const kvsPort = "6379"

type kvsCmd struct {
serviceCmd
}

// Help displays usage examples for the keyvaluestore exec command.
func (cmd kvsCmd) Help() string {
return `Examples:
# Connect to a KeyValueStore instance interactively
nctl exec keyvaluestore mykvs

# Pass extra flags to redis-cli (after --)
nctl exec keyvaluestore mykvs -- --no-auth-warning
`
}

func (cmd *kvsCmd) Run(ctx context.Context, client *api.Client) error {
kvs := &storage.KeyValueStore{
ObjectMeta: metav1.ObjectMeta{
Name: cmd.Name,
Namespace: client.Project,
},
}
if err := client.Get(ctx, client.Name(cmd.Name), kvs); err != nil {
return fmt.Errorf("getting keyvaluestore %q: %w", cmd.Name, err)
}
return connectAndExec(ctx, client, kvs, kvsConnector{}, cmd.serviceCmd)
}

// kvsConnector implements ServiceConnector for storage.KeyValueStore instances.
type kvsConnector struct{}

func (kvsConnector) Command() string { return "redis-cli" }

func (kvsConnector) Endpoint(kvs *storage.KeyValueStore) string {
if kvs.Status.AtProvider.FQDN == "" {
return ""
}
return kvs.Status.AtProvider.FQDN + ":" + kvsPort
}

func (kvsConnector) AllowedCIDRs(kvs *storage.KeyValueStore) []meta.IPv4CIDR {
return kvs.Spec.ForProvider.AllowedCIDRs
}

func (kvsConnector) Update(ctx context.Context, client *api.Client, kvs *storage.KeyValueStore, cidrs []meta.IPv4CIDR) error {
current := &storage.KeyValueStore{}
if err := client.Get(ctx, api.ObjectName(kvs), current); err != nil {
return err
}

if current.Spec.ForProvider.PublicNetworkingEnabled != nil && !*current.Spec.ForProvider.PublicNetworkingEnabled {
return cli.ErrorWithContext(fmt.Errorf("public networking is disabled for keyvaluestore %q", kvs.GetName())).
WithSuggestions(
fmt.Sprintf("Enable it with: %s update keyvaluestore %s --public-networking", cli.Name, kvs.GetName()),
)
}

current.Spec.ForProvider.AllowedCIDRs = cidrs
return client.Update(ctx, current)
}

// NewCmd builds the redis-cli command. The auth token is passed via REDISCLI_AUTH
// rather than -a so it does not appear in the process argument list.
func (kvsConnector) NewCmd(ctx context.Context, kvs *storage.KeyValueStore, _ string, pw string) (*exec.Cmd, func(), error) {
dir, cleanup, err := createTempDir()
if err != nil {
return nil, func() {}, err
}

caPath, err := writeCACert(dir, kvs.Status.AtProvider.CACert)
if err != nil {
cleanup()
return nil, func() {}, err
}

args := []string{
"-h", kvs.Status.AtProvider.FQDN,
"-p", kvsPort,
"--tls",
}
if caPath != "" {
args = append(args, "--cacert", caPath)
}

cmd := exec.CommandContext(ctx, "redis-cli", args...)
cmd.Env = []string{"REDISCLI_AUTH=" + pw}
return cmd, cleanup, nil
}
148 changes: 148 additions & 0 deletions exec/keyvaluestore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package exec

import (
"context"
"os/exec"
"strings"
"testing"

meta "github.com/ninech/apis/meta/v1alpha1"
storage "github.com/ninech/apis/storage/v1alpha1"
"github.com/ninech/nctl/api"
"github.com/ninech/nctl/internal/test"
runtimeclient "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
)

func TestKVSCmd(t *testing.T) {
t.Parallel()

const (
kvsName = "mykvs"
kvsFQDN = "mykvs.example.com"
kvsToken = "supersecrettoken"
)

cidr := []meta.IPv4CIDR{"203.0.113.5/32"}
pubNet := true

ready := test.KeyValueStore(kvsName, test.DefaultProject, "nine-es34")
ready.Status.AtProvider.FQDN = kvsFQDN
ready.Spec.ForProvider.AllowedCIDRs = []meta.IPv4CIDR{"10.0.0.1/32"}
ready.Spec.ForProvider.PublicNetworkingEnabled = &pubNet

pubNetFalse := false
pubNetDisabled := test.KeyValueStore("no-public", test.DefaultProject, "nine-es34")
pubNetDisabled.Status.AtProvider.FQDN = "no-public.example.com"
pubNetDisabled.Spec.ForProvider.PublicNetworkingEnabled = &pubNetFalse
pubNetDisabled.Spec.ForProvider.AllowedCIDRs = []meta.IPv4CIDR{}

notReady := test.KeyValueStore("notready", test.DefaultProject, "nine-es34")

// KVS secret: single key with auth token as value.
secret := testSecret(kvsName, test.DefaultProject, "token", kvsToken)

_, notFoundCmd := testDatabaseCmd("doesnotexist", &cidr)
_, notReadyCmd := testDatabaseCmd("notready", &cidr)
alreadyCap, alreadyPresentCmd := testDatabaseCmd(kvsName, &[]meta.IPv4CIDR{"10.0.0.1/32"})
_, newCidrCmd := testDatabaseCmdConfirmed(kvsName, &cidr, true)
_, pubNetDisabledCmd := testDatabaseCmdConfirmed("no-public", &cidr, true)
tokenCap, tokenCmd := testDatabaseCmd(kvsName, &[]meta.IPv4CIDR{"10.0.0.1/32"})

tests := []struct {
name string
cmd kvsCmd
cap *capturingCmd
wantErr bool
errContains string
wantUpdate bool
check func(t *testing.T, cmd *exec.Cmd)
}{
{
name: "resource not found",
cmd: kvsCmd{serviceCmd: notFoundCmd},
wantErr: true,
},
{
name: "resource not ready",
cmd: kvsCmd{serviceCmd: notReadyCmd},
wantErr: true,
errContains: "not ready",
},
{
name: "cidr already present skips update",
cmd: kvsCmd{serviceCmd: alreadyPresentCmd},
cap: alreadyCap,
check: func(t *testing.T, cmd *exec.Cmd) {
t.Helper()
if !strings.Contains(strings.Join(cmd.Args, " "), kvsFQDN) {
t.Errorf("expected FQDN %q in args %v", kvsFQDN, cmd.Args)
}
},
},
{
name: "new cidr triggers update",
cmd: kvsCmd{serviceCmd: newCidrCmd},
wantUpdate: true,
},
{
name: "public networking disabled returns error",
cmd: kvsCmd{serviceCmd: pubNetDisabledCmd},
wantErr: true,
errContains: "networking is disabled",
},
{
name: "token passed securely via env",
cmd: kvsCmd{serviceCmd: tokenCmd},
cap: tokenCap,
check: func(t *testing.T, cmd *exec.Cmd) {
t.Helper()
if strings.Contains(strings.Join(cmd.Args, " "), kvsToken) {
t.Errorf("token must not appear in args %v", cmd.Args)
}
if !containsEnv(cmd.Env, "REDISCLI_AUTH="+kvsToken) {
t.Errorf("expected REDISCLI_AUTH env var, got %v", cmd.Env)
}
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
updateCalled := false
apiClient := test.SetupClient(t,
test.WithObjects(ready, notReady, pubNetDisabled, secret),
test.WithInterceptorFuncs(interceptor.Funcs{
Update: func(ctx context.Context, c runtimeclient.WithWatch, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error {
updateCalled = true
return c.Update(ctx, obj, opts...)
},
}),
)

err := tc.cmd.Run(t.Context(), apiClient)

if (err != nil) != tc.wantErr {
t.Fatalf("Run() error = %v, wantErr %v", err, tc.wantErr)
}
if tc.errContains != "" && (err == nil || !strings.Contains(err.Error(), tc.errContains)) {
t.Errorf("expected error containing %q, got %v", tc.errContains, err)
}
if tc.wantUpdate && !updateCalled {
t.Error("expected Update to be called for CIDR addition")
}
if !tc.wantErr && tc.check != nil {
tc.check(t, tc.cap.cmd)
}
if tc.wantUpdate {
kvs := &storage.KeyValueStore{}
if err := apiClient.Get(t.Context(), api.ObjectName(ready), kvs); err != nil {
t.Fatalf("getting kvs: %v", err)
}
if !cidrsPresent(kvs.Spec.ForProvider.AllowedCIDRs, cidr) {
t.Errorf("expected CIDR %v to be added, got %v", cidr, kvs.Spec.ForProvider.AllowedCIDRs)
}
}
})
}
}
Loading
Loading