diff --git a/api/util/release.go b/api/util/release.go new file mode 100644 index 0000000..445be11 --- /dev/null +++ b/api/util/release.go @@ -0,0 +1,26 @@ +package util + +import ( + "sort" + + apps "github.com/ninech/apis/apps/v1alpha1" +) + +// OrderReleaseList orders the given list of releases, moving the latest +// release to the beginning of the list +func OrderReleaseList(releaseList *apps.ReleaseList) { + if len(releaseList.Items) <= 1 { + return + } + + sort.Slice(releaseList.Items, func(i, j int) bool { + applicationNameI := releaseList.Items[i].ObjectMeta.Labels[ApplicationNameLabel] + applicationNameJ := releaseList.Items[j].ObjectMeta.Labels[ApplicationNameLabel] + + if applicationNameI != applicationNameJ { + return applicationNameI < applicationNameJ + } + + return releaseList.Items[i].CreationTimestampNano < releaseList.Items[j].CreationTimestampNano + }) +} diff --git a/exec/application.go b/exec/application.go new file mode 100644 index 0000000..2739ab4 --- /dev/null +++ b/exec/application.go @@ -0,0 +1,254 @@ +package exec + +import ( + "context" + "fmt" + "io" + + b64 "encoding/base64" + + dockerterm "github.com/moby/term" + apps "github.com/ninech/apis/apps/v1alpha1" + infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" + meta "github.com/ninech/apis/meta/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/api/util" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/term" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + appBuildTypeBuildpack appBuildType = "buildpack" + appBuildTypeDockerfile appBuildType = "dockerfile" + buildpackEntrypoint = "launcher" + defaultShellBuildpack = "/bin/bash" + defaultShellDockerfile = "/bin/sh" +) + +// appBuildType describes the way how the app was build (buildpack/dockerfile) +type appBuildType string + +type remoteCommandParameters struct { + replicaName string + replicaNamespace string + command []string + tty bool + enableStdin bool + stdin io.Reader + stdout io.Writer + stderr io.Writer + restConfig *rest.Config +} + +type applicationCmd struct { + resourceCmd + Stdin bool `name:"stdin" short:"i" help:"Pass stdin to the application" default:"true"` + Tty bool `name:"tty" short:"t" help:"Stdin is a TTY" default:"true"` + Command []string `arg:"" help:"command to execute" optional:""` +} + +// Help displays examples for the application exec command +func (ac applicationCmd) Help() string { + return `Examples: + # Open a shell in a buildpack/dockerfile built application. The dockerfile + # built application needs a valid "/bin/sh" shell to be installed. + nctl exec app myapp + + # Get output from running the 'date' command in an application replica. + nctl exec app myapp -- date + + # Use redirection to execute a comand. + echo date | nctl exec app myapp + + # In certain situations it might be needed to not redirect stdin. This can be + # achieved by using the "stdin" flag: + nctl exec app --stdin=false myapp -- + ` +} + +func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client, exec *Cmd) error { + replicaName, buildType, err := cmd.getReplica(ctx, client) + if err != nil { + return fmt.Errorf("error when searching for replica to connect: %w", err) + } + config, err := deploioRestConfig(ctx, client) + if err != nil { + return fmt.Errorf("can not create deplo.io cluster rest config: %w", err) + } + // use dockerterm to gather the std io streams (windows supported) + stdin, stdout, stderr := dockerterm.StdStreams() + return executeRemoteCommand( + ctx, + remoteCommandParameters{ + replicaName: replicaName, + replicaNamespace: client.Project, + command: replicaCommand(buildType, cmd.Command), + tty: cmd.Tty, + enableStdin: cmd.Stdin, + stdin: stdin, + stdout: stdout, + stderr: stderr, + restConfig: config, + }) +} + +func latestAvailableRelease(releases *apps.ReleaseList) *apps.Release { + util.OrderReleaseList(releases) + for _, release := range releases.Items { + if release.Status.AtProvider.ReleaseStatus == apps.ReleaseProcessStatusAvailable { + return &release + } + } + return nil +} + +// getReplica finds a replica of the latest available release +func (cmd *applicationCmd) getReplica(ctx context.Context, client *api.Client) (string, appBuildType, error) { + releases := &apps.ReleaseList{} + if err := client.List( + ctx, + releases, + runtimeclient.InNamespace(client.Project), + runtimeclient.MatchingLabels{util.ApplicationNameLabel: cmd.Name}, + ); err != nil { + return "", "", err + } + + if len(releases.Items) == 0 { + return "", "", fmt.Errorf("no releases found for application %s", cmd.Name) + } + latestAvailableRelease := latestAvailableRelease(releases) + if latestAvailableRelease == nil { + return "", "", fmt.Errorf("no ready release found for application %s", cmd.Name) + } + buildType := appBuildTypeBuildpack + if latestAvailableRelease.Spec.ForProvider.DockerfileBuild { + buildType = appBuildTypeDockerfile + } + if len(latestAvailableRelease.Status.AtProvider.ReplicaObservation) == 0 { + return "", buildType, fmt.Errorf("no replica information found for release %s", latestAvailableRelease.Name) + } + if replica := readyReplica(latestAvailableRelease.Status.AtProvider.ReplicaObservation); replica != "" { + return replica, buildType, nil + } + return "", buildType, fmt.Errorf("no ready replica found for release %s", latestAvailableRelease.Name) +} + +func readyReplica(replicaObs []apps.ReplicaObservation) string { + for _, obs := range replicaObs { + if obs.Status == apps.ReplicaStatusReady { + return obs.ReplicaName + } + } + return "" +} + +// setupTTY sets up a TTY for command execution +func setupTTY(params *remoteCommandParameters) term.TTY { + t := term.TTY{ + Out: params.stdout, + } + if !params.enableStdin { + return t + } + t.In = params.stdin + if !params.tty { + return t + } + if !t.IsTerminalIn() { + // if this is not a suitable TTY, we don't request one in the + // exec call and don't set the terminal into RAW mode either + params.tty = false + return t + } + // if we get to here, the user wants to attach stdin, wants a TTY, and + // os.Stdin is a terminal, so we can safely set t.Raw to true + t.Raw = true + return t +} + +func executeRemoteCommand(ctx context.Context, params remoteCommandParameters) error { + coreClient, err := kubernetes.NewForConfig(params.restConfig) + if err != nil { + return err + } + + tty := setupTTY(¶ms) + var sizeQueue remotecommand.TerminalSizeQueue + if tty.Raw { + // this call spawns a goroutine to monitor/update the terminal size + sizeQueue = tty.MonitorSize(tty.GetSize()) + + // unset stderr if it was previously set because both stdout + // and stderr go over params.stdout when tty is + // true + params.stderr = nil + } + fn := func() error { + request := coreClient.CoreV1().RESTClient(). + Post(). + Namespace(params.replicaNamespace). + Resource("pods"). + Name(params.replicaName). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Command: params.command, + Stdin: params.enableStdin, + Stdout: params.stdout != nil, + Stderr: params.stderr != nil, + TTY: params.tty, + }, scheme.ParameterCodec) + + exec, err := remotecommand.NewSPDYExecutor(params.restConfig, "POST", request.URL()) + if err != nil { + return err + } + return exec.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdin: tty.In, + Stdout: params.stdout, + Stderr: params.stderr, + Tty: params.tty, + TerminalSizeQueue: sizeQueue, + }) + + } + return tty.Safe(fn) +} + +func deploioRestConfig(ctx context.Context, client *api.Client) (*rest.Config, error) { + config := rest.CopyConfig(client.Config) + deploioClusterData := &infrastructure.ClusterData{} + if err := client.Get(ctx, types.NamespacedName{Name: meta.ClusterDataDeploioName}, deploioClusterData); err != nil { + return nil, fmt.Errorf("can not gather deplo.io cluster connection details: %w", err) + } + config.Host = deploioClusterData.Status.AtProvider.APIEndpoint + var err error + if config.CAData, err = b64.StdEncoding.DecodeString(deploioClusterData.Status.AtProvider.APICACert); err != nil { + return nil, fmt.Errorf("can not decode deplo.io cluster CA certificate: %w", err) + } + return config, nil +} + +func replicaCommand(buildType appBuildType, command []string) []string { + switch buildType { + case appBuildTypeBuildpack: + execute := append([]string{buildpackEntrypoint}, command...) + if len(command) == 0 { + execute = []string{buildpackEntrypoint, defaultShellBuildpack} + } + return execute + case appBuildTypeDockerfile: + if len(command) == 0 { + return []string{defaultShellDockerfile} + } + return command + default: + return command + } +} diff --git a/exec/application_test.go b/exec/application_test.go new file mode 100644 index 0000000..06b8cf7 --- /dev/null +++ b/exec/application_test.go @@ -0,0 +1,349 @@ +package exec + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + apps "github.com/ninech/apis/apps/v1alpha1" + meta "github.com/ninech/apis/meta/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/api/util" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const ( + project = "default" +) + +func TestApplicationReplicaSelection(t *testing.T) { + const ( + firstApp, secondApp = "first-app", "second-app" + ) + ctx := context.Background() + scheme, err := api.NewScheme() + require.NoError(t, err) + + for name, testCase := range map[string]struct { + application string + // releases will get an automatic timestamp added. The first + // release in the slice will be the oldest release. + releases []apps.Release + expectedReplica string + expectedBuildType appBuildType + expectError bool + }{ + "happy-path-single-release": { + application: firstApp, + releases: []apps.Release{ + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-1", + }, + }, + apps.ReleaseProcessStatusAvailable, + false, + ), + }, + expectedReplica: "test-replica-1", + expectedBuildType: appBuildTypeBuildpack, + }, + "happy-path-single-release-multiple-replicas": { + application: firstApp, + releases: []apps.Release{ + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-1", + }, + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-2", + }, + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-3", + }, + }, + apps.ReleaseProcessStatusAvailable, + false, + ), + }, + // we make sure that we always take the first replica + // even if multiple ready ones are available + expectedReplica: "test-replica-1", + expectedBuildType: appBuildTypeBuildpack, + }, + "happy-path-multiple-releases": { + application: firstApp, + releases: []apps.Release{ + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-1", + }, + }, + apps.ReleaseProcessStatusSuperseded, + false, + ), + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-2", + }, + }, + apps.ReleaseProcessStatusAvailable, + false, + ), + }, + expectedReplica: "test-replica-2", + expectedBuildType: appBuildTypeBuildpack, + }, + "happy-path-multiple-releases-with-failing-ones": { + application: firstApp, + releases: []apps.Release{ + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-1", + }, + }, + apps.ReleaseProcessStatusAvailable, + false, + ), + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusFailing, + ReplicaName: "test-replica-2", + }, + }, + apps.ReleaseProcessStatusFailure, + false, + ), + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusFailing, + ReplicaName: "test-replica-3", + }, + }, + apps.ReleaseProcessStatusFailure, + false, + ), + }, + expectedReplica: "test-replica-1", + expectedBuildType: appBuildTypeBuildpack, + }, + "happy-path-multiple-apps-and-releases": { + application: firstApp, + releases: []apps.Release{ + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-1", + }, + }, + apps.ReleaseProcessStatusSuperseded, + false, + ), + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-2", + }, + }, + apps.ReleaseProcessStatusAvailable, + false, + ), + newRelease( + secondApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-3", + }, + }, + apps.ReleaseProcessStatusAvailable, + false, + ), + }, + expectedReplica: "test-replica-2", + expectedBuildType: appBuildTypeBuildpack, + }, + "no-release-available": { + application: firstApp, + releases: []apps.Release{}, + expectError: true, + }, + "only-progressing-release-available": { + application: firstApp, + releases: []apps.Release{ + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusProgressing, + ReplicaName: "test-replica-1", + }, + }, + apps.ReleaseProcessStatusProgressing, + false, + ), + }, + expectError: true, + }, + "replica-is-suddenly-failing-in-available-release": { + application: firstApp, + releases: []apps.Release{ + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusFailing, + ReplicaName: "test-replica-1", + }, + }, + apps.ReleaseProcessStatusAvailable, + false, + ), + }, + expectError: true, + }, + "selecting-ready-replica-amongst-multiple-ones-works": { + application: firstApp, + releases: []apps.Release{ + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusFailing, + ReplicaName: "test-replica-1", + }, + { + Status: apps.ReplicaStatusProgressing, + ReplicaName: "test-replica-2", + }, + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-3", + }, + }, + apps.ReleaseProcessStatusAvailable, + false, + ), + }, + expectedReplica: "test-replica-3", + expectedBuildType: appBuildTypeBuildpack, + }, + "dockerfile-builds-get-detected": { + application: firstApp, + releases: []apps.Release{ + newRelease( + firstApp, + []apps.ReplicaObservation{ + { + Status: apps.ReplicaStatusReady, + ReplicaName: "test-replica-1", + }, + }, + apps.ReleaseProcessStatusAvailable, + true, + ), + }, + expectedReplica: "test-replica-1", + expectedBuildType: appBuildTypeDockerfile, + }, + } { + t.Run(name, func(t *testing.T) { + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithIndex(&apps.Release{}, "metadata.name", func(o client.Object) []string { + return []string{o.GetName()} + }). + WithLists( + &apps.ReleaseList{ + Items: addCreationTimestamp(testCase.releases), + }, + ).Build() + apiClient := &api.Client{WithWatch: client, Project: project} + cmd := applicationCmd{resourceCmd: resourceCmd{Name: testCase.application}} + replica, buildType, err := cmd.getReplica(ctx, apiClient) + if testCase.expectError { + require.Error(t, err) + return + } else { + require.NoError(t, err) + } + require.Equal(t, testCase.expectedReplica, replica) + require.Equal(t, testCase.expectedBuildType, buildType) + }) + } +} + +func newRelease( + appName string, + replicaObservation []apps.ReplicaObservation, + status apps.ReleaseProcessStatus, + isDockerfileBuild bool, +) apps.Release { + return apps.Release{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("test-%s", uuid.New().String()), + Namespace: project, + Labels: map[string]string{ + util.ApplicationNameLabel: appName, + }, + }, + Spec: apps.ReleaseSpec{ + ForProvider: apps.ReleaseParameters{ + DockerfileBuild: isDockerfileBuild, + Build: meta.LocalReference{Name: "test-build"}, + Image: meta.Image{ + Registry: "https://some.registry", + Repository: "some-repository", + Tag: "latest", + }, + }, + }, + Status: apps.ReleaseStatus{ + AtProvider: apps.ReleaseObservation{ + ReleaseStatus: status, + ReplicaObservation: replicaObservation, + }, + }, + } +} + +// addCreationTimestamp adds a creation timestamp to each release with 1 second +// difference between each release. The last release in the slice will be the +// most current +func addCreationTimestamp(releases []apps.Release) []apps.Release { + baseTime := time.Now().Add(-1 * time.Hour) + for i := range releases { + releases[i].CreationTimestampNano = baseTime.Add(time.Duration(i) * time.Second).UnixNano() + } + return releases +} diff --git a/exec/exec.go b/exec/exec.go new file mode 100644 index 0000000..d1ba589 --- /dev/null +++ b/exec/exec.go @@ -0,0 +1,9 @@ +package exec + +type Cmd struct { + Application applicationCmd `cmd:"" group:"deplo.io" aliases:"app" name:"application" help:"Execute a command or shell in a deplo.io application."` +} + +type resourceCmd struct { + Name string `arg:"" predictor:"resource_name" help:"Name of the application to exec command/shell in." required:""` +} diff --git a/get/releases.go b/get/releases.go index 4c8d371..878d373 100644 --- a/get/releases.go +++ b/get/releases.go @@ -4,7 +4,6 @@ import ( "context" "io" "os" - "sort" "strconv" "text/tabwriter" "time" @@ -39,7 +38,7 @@ func (cmd *releasesCmd) Run(ctx context.Context, client *api.Client, get *Cmd) e return nil } - orderReleaseList(releaseList) + util.OrderReleaseList(releaseList) switch get.Output { case full: @@ -53,23 +52,6 @@ func (cmd *releasesCmd) Run(ctx context.Context, client *api.Client, get *Cmd) e return nil } -func orderReleaseList(releaseList *apps.ReleaseList) { - if len(releaseList.Items) <= 1 { - return - } - - sort.Slice(releaseList.Items, func(i, j int) bool { - applicationNameI := releaseList.Items[i].ObjectMeta.Labels[util.ApplicationNameLabel] - applicationNameJ := releaseList.Items[j].ObjectMeta.Labels[util.ApplicationNameLabel] - - if applicationNameI != applicationNameJ { - return applicationNameI < applicationNameJ - } - - return releaseList.Items[i].CreationTimestampNano < releaseList.Items[j].CreationTimestampNano - }) -} - func printReleases(releases []apps.Release, get *Cmd, header bool) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) diff --git a/get/releases_test.go b/get/releases_test.go index 097d885..ada9537 100644 --- a/get/releases_test.go +++ b/get/releases_test.go @@ -217,7 +217,7 @@ func TestReleases(t *testing.T) { t.Fatal(err) } - orderReleaseList(releaseList) + util.OrderReleaseList(releaseList) releaseNames := []string{} for _, r := range releaseList.Items { releaseNames = append(releaseNames, r.ObjectMeta.Name) diff --git a/go.mod b/go.mod index ac601f2..59db27a 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/gobuffalo/flect v1.0.2 github.com/goccy/go-yaml v1.11.3 github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.1 github.com/grafana/dskit v0.0.0-20240403100540-1435abf0da58 github.com/grafana/loki v1.6.2-0.20240110103520-24fa648893d1 @@ -24,7 +25,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/moby/moby v26.0.0+incompatible github.com/moby/term v0.5.0 - github.com/ninech/apis v0.0.0-20240717081804-d2a5202fde72 + github.com/ninech/apis v0.0.0-20240717115641-af232c334dc6 github.com/posener/complete v1.2.3 github.com/prometheus/common v0.52.2 github.com/stretchr/testify v1.9.0 @@ -36,6 +37,7 @@ require ( k8s.io/api v0.30.1 k8s.io/apimachinery v0.30.1 k8s.io/client-go v0.30.1 + k8s.io/kubectl v0.29.0 k8s.io/utils v0.0.0-20240310230437-4693a0247e57 sigs.k8s.io/controller-runtime v0.18.4 ) @@ -115,7 +117,6 @@ require ( github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56 // indirect @@ -148,6 +149,7 @@ require ( github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/klauspost/compress v1.17.7 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -156,9 +158,11 @@ require ( github.com/miekg/dns v1.1.58 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/spdystream v0.2.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect @@ -168,6 +172,7 @@ require ( github.com/muesli/termenv v0.15.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect @@ -239,6 +244,7 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect helm.sh/helm/v3 v3.14.3 // indirect + k8s.io/cli-runtime v0.29.0 // indirect k8s.io/klog/v2 v2.120.1 // indirect k8s.io/kube-openapi v0.0.0-20240403164606-bc84c2ddaf99 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect diff --git a/go.sum b/go.sum index a4463ec..c3a03cf 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,8 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= @@ -376,6 +378,7 @@ github.com/gophercloud/gophercloud v1.8.0 h1:TM3Jawprb2NrdOnvcHhWJalmKmAmOGgfZEl github.com/gophercloud/gophercloud v1.8.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grafana/dskit v0.0.0-20240403100540-1435abf0da58 h1:ph674hL86kFIWcrqUCXW/D0RdSFu2ToIjqvzRnPAzPg= @@ -515,6 +518,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/linode/linodego v1.25.0 h1:zYMz0lTasD503jBu3tSRhzEmXHQN1zptCw5o71ibyyU= github.com/linode/linodego v1.25.0/go.mod h1:BMZI0pMM/YGjBis7pIXDPbcgYfCZLH0/UvzqtsGtG1c= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -554,6 +559,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -564,6 +571,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby v26.0.0+incompatible h1:2n9/cIWkxiEI1VsWgTGgXhxIWUbv42PyxEP9L+RReC0= github.com/moby/moby v26.0.0+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -588,11 +597,11 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/ninech/apis v0.0.0-20240604065453-1a4b503198d6 h1:pLpF8VsnBVqovY3wGxU8LQO8scEn32Lsc9zoqRY3jbs= -github.com/ninech/apis v0.0.0-20240604065453-1a4b503198d6/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= -github.com/ninech/apis v0.0.0-20240717081804-d2a5202fde72 h1:K0GFWZERwXe35L7SZpHRja8PmufQ3L121rmINRioZ+I= -github.com/ninech/apis v0.0.0-20240717081804-d2a5202fde72/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= +github.com/ninech/apis v0.0.0-20240717115641-af232c334dc6 h1:Hs62qefdgMZF+fytMYsYhm413XtjYsIkIBgpMA/2Ehs= +github.com/ninech/apis v0.0.0-20240717115641-af232c334dc6/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= @@ -1224,12 +1233,16 @@ k8s.io/apiextensions-apiserver v0.30.1 h1:4fAJZ9985BmpJG6PkoxVRpXv9vmPUOVzl614xa k8s.io/apiextensions-apiserver v0.30.1/go.mod h1:R4GuSrlhgq43oRY9sF2IToFh7PVlF1JjfWdoG3pixk4= k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U= k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/cli-runtime v0.29.0 h1:q2kC3cex4rOBLfPOnMSzV2BIrrQlx97gxHJs21KxKS4= +k8s.io/cli-runtime v0.29.0/go.mod h1:VKudXp3X7wR45L+nER85YUzOQIru28HQpXr0mTdeCrk= k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q= k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc= k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20240403164606-bc84c2ddaf99 h1:w6nThEmGo9zcL+xH1Tu6pjxJ3K1jXFW+V0u4peqN8ks= k8s.io/kube-openapi v0.0.0-20240403164606-bc84c2ddaf99/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/kubectl v0.29.0 h1:Oqi48gXjikDhrBF67AYuZRTcJV4lg2l42GmvsP7FmYI= +k8s.io/kubectl v0.29.0/go.mod h1:0jMjGWIcMIQzmUaMgAzhSELv5WtHo2a8pq67DtviAJs= k8s.io/utils v0.0.0-20240310230437-4693a0247e57 h1:gbqbevonBh57eILzModw6mrkbwM0gQBEuevE/AaBsHY= k8s.io/utils v0.0.0-20240310230437-4693a0247e57/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/main.go b/main.go index 169be7c..11a7ae7 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "github.com/ninech/nctl/auth" "github.com/ninech/nctl/create" "github.com/ninech/nctl/delete" + "github.com/ninech/nctl/exec" "github.com/ninech/nctl/get" "github.com/ninech/nctl/internal/format" "github.com/ninech/nctl/logs" @@ -44,6 +45,7 @@ type rootCommand struct { Delete delete.Cmd `cmd:"" help:"Delete resource."` Logs logs.Cmd `cmd:"" help:"Get logs of resource."` Update update.Cmd `cmd:"" help:"Update resource."` + Exec exec.Cmd `cmd:"" help:"Execute a command."` } var version = "dev"