Skip to content
Open
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
7 changes: 7 additions & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func NewCommand() (*cobra.Command, error) {
command.AddCommand(NewContextCommand(&clientOpts))
command.AddCommand(NewLoginCommand(&clientOpts))
command.AddCommand(NewLogoutCommand(&clientOpts))
command.AddCommand(newDeleteAliasCommand(&clientOpts))

defaultLocalConfigPath, err := config.DefaultLocalConfigPath()
if err != nil {
Expand All @@ -81,3 +82,9 @@ func NewCommand() (*cobra.Command, error) {

return command, nil
}

func newDeleteAliasCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command {
cmd := newServiceDeleteCommand(globalClientOpts)
cmd.Hidden = true
return cmd
}
6 changes: 4 additions & 2 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ microcks login http://localhost:8080 --sso --sso-launch-browser=false
if keycloakUrl == "null" {
localConfig.UpsertServer(config.Server{
Server: server,
InsecureTLS: true,
InsecureTLS: globalClientOpts.InsecureTLS,
TLSExplicit: true,
KeycloakEnable: false,
})
fmt.Print("No login required...\n")
Expand Down Expand Up @@ -178,7 +179,8 @@ microcks login http://localhost:8080 --sso --sso-launch-browser=false

localConfig.UpsertServer(config.Server{
Server: server,
InsecureTLS: true,
InsecureTLS: globalClientOpts.InsecureTLS,
TLSExplicit: true,
KeycloakEnable: true,
})
}
Expand Down
57 changes: 56 additions & 1 deletion cmd/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
package cmd

import (
"bufio"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
Expand All @@ -26,16 +28,25 @@ import (
"github.com/microcks/microcks-cli/pkg/errors"
"github.com/microcks/microcks-cli/pkg/output"
"github.com/spf13/cobra"
"golang.org/x/term"
)

var (
isTerminal = func() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
stdinReader io.Reader = os.Stdin
)

func NewServiceCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command {
serviceCmd := &cobra.Command{
Use: "service",
Short: "List and inspect Microcks services",
Short: "List, inspect, and delete Microcks services",
}

serviceCmd.AddCommand(newServiceListCommand(globalClientOpts))
serviceCmd.AddCommand(newServiceGetCommand(globalClientOpts))
serviceCmd.AddCommand(newServiceDeleteCommand(globalClientOpts))

return serviceCmd
}
Expand Down Expand Up @@ -150,3 +161,47 @@ func printServiceDetail(detail *connectors.ServiceDetail) error {
}
return nil
}

func newServiceDeleteCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command {
var force bool

cmd := &cobra.Command{
Use: "delete <id-or-name-version>",
Short: "Delete a Microcks service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
serviceRef := args[0]

if !force {
if !isTerminal() {
return errors.Wrapf(errors.KindUsage, "--force flag is required for non-interactive deletion")
}
fmt.Printf("Are you sure you want to delete service '%s'? [y/N]: ", serviceRef)
reader := bufio.NewReader(stdinReader)
input, err := reader.ReadString('\n')
if err != nil {
return errors.Wrap(errors.KindGeneric, err)
}
input = strings.TrimSpace(strings.ToLower(input))
if input != "y" && input != "yes" {
fmt.Println("Service deletion aborted")
return nil
}
}

mc, _, err := newCommandClient(globalClientOpts)
if err != nil {
return err
}

if err := mc.DeleteService(serviceRef); err != nil {
return err
}

fmt.Printf("Service '%s' deleted successfully\n", serviceRef)
return nil
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force deletion without confirmation prompt")
return cmd
}
105 changes: 105 additions & 0 deletions cmd/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,108 @@ func executeCLIForTest(t *testing.T, args ...string) (string, error) {
}
return string(out), execErr
}

func TestServiceDeleteCommandRequiresForceInNonTerminal(t *testing.T) {
oldIsTerminal := isTerminal
isTerminal = func() bool { return false }
defer func() { isTerminal = oldIsTerminal }()

_, err := executeCLIForTest(t, "service", "delete", "svc-1", "--microcksURL", "http://localhost:8080")
if err == nil {
t.Fatal("expected error when running in non-terminal without --force, got nil")
}
if !strings.Contains(err.Error(), "--force flag is required") {
t.Fatalf("unexpected error message: %v", err)
}
}

func TestServiceDeleteCommandWithForce(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/services/svc-1" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodDelete {
t.Fatalf("unexpected method: %s", r.Method)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

out, err := executeCLIForTest(t, "service", "delete", "svc-1", "--force", "--microcksURL", server.URL)
if err != nil {
t.Fatalf("command returned unexpected error: %v", err)
}
if !strings.Contains(out, "Service 'svc-1' deleted successfully") {
t.Fatalf("unexpected output: %s", out)
}
}

func TestServiceDeleteCommandWithConfirmationAccepted(t *testing.T) {
oldIsTerminal := isTerminal
oldStdinReader := stdinReader
isTerminal = func() bool { return true }
stdinReader = strings.NewReader("y\n")
defer func() {
isTerminal = oldIsTerminal
stdinReader = oldStdinReader
}()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/services/svc-1" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodDelete {
t.Fatalf("unexpected method: %s", r.Method)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

out, err := executeCLIForTest(t, "service", "delete", "svc-1", "--microcksURL", server.URL)
if err != nil {
t.Fatalf("command returned unexpected error: %v", err)
}
if !strings.Contains(out, "Service 'svc-1' deleted successfully") {
t.Fatalf("unexpected output: %s", out)
}
}

func TestServiceDeleteCommandWithConfirmationAborted(t *testing.T) {
oldIsTerminal := isTerminal
oldStdinReader := stdinReader
isTerminal = func() bool { return true }
stdinReader = strings.NewReader("n\n")
defer func() {
isTerminal = oldIsTerminal
stdinReader = oldStdinReader
}()

out, err := executeCLIForTest(t, "service", "delete", "svc-1", "--microcksURL", "http://localhost:8080")
if err != nil {
t.Fatalf("command returned unexpected error: %v", err)
}
if !strings.Contains(out, "Service deletion aborted") {
t.Fatalf("unexpected output: %s", out)
}
}

func TestRootDeleteAliasCommandWithForce(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/services/svc-1" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodDelete {
t.Fatalf("unexpected method: %s", r.Method)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

out, err := executeCLIForTest(t, "delete", "svc-1", "--force", "--microcksURL", server.URL)
if err != nil {
t.Fatalf("command returned unexpected error: %v", err)
}
if !strings.Contains(out, "Service 'svc-1' deleted successfully") {
t.Fatalf("unexpected output: %s", out)
}
}
1 change: 1 addition & 0 deletions pkg/config/localconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type Server struct {
Name string `yaml:"name"`
Server string `yaml:"server"`
InsecureTLS bool `yaml:"insecureTLS"`
TLSExplicit bool `yaml:"tlsExplicit,omitempty"`
KeycloakEnable bool `yaml:"keycloakEnable"`
}

Expand Down
90 changes: 86 additions & 4 deletions pkg/connectors/microcks_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type MicrocksClient interface {
SetOAuthToken(oauthToken string)
ListServices(page int, size int) ([]Service, error)
GetService(ref string) (*ServiceDetail, error)
DeleteService(ref string) error
ListTestResults(serviceID string, page int, size int) ([]TestResultSummary, error)
CreateTestResult(serviceID string, testEndpoint string, runnerType string, secretName string, timeout int64, filteredOperations string, operationsHeaders string, oAuth2Context string) (string, error)
GetTestResult(testResultID string) (*TestResultSummary, error)
Expand Down Expand Up @@ -200,7 +201,17 @@ func NewClient(opts ClientOptions) (MicrocksClient, error) {
}
c.ServerAddr = configCtx.Server.Server

c.InsecureTLS = configCtx.Server.InsecureTLS
// Honor the persisted insecureTLS only when it was written by the new
// login (TLSExplicit == true). Old login always wrote insecureTLS: true
// even without --insecure-tls, so we must not blindly trust it.
if configCtx.Server.TLSExplicit {
c.InsecureTLS = configCtx.Server.InsecureTLS
} else if configCtx.Server.InsecureTLS {
// Legacy config: insecureTLS was set unconditionally by older login.
// Warn the user and keep TLS verification enabled for safety.
log.Printf("Warning: saved context %q has insecureTLS: true but was written by an older microcks-cli that always persisted that value. TLS certificate verification will be enforced. Re-run 'microcks login' with --insecure-tls if needed.\n", configCtx.Name)
c.InsecureTLS = false
}
c.AuthToken = configCtx.User.AuthToken
c.RefreshToken = configCtx.User.RefreshToken

Expand All @@ -226,8 +237,17 @@ func NewClient(opts ClientOptions) (MicrocksClient, error) {
c.Verbose = opts.Verbose
}

if config.InsecureTLS || len(config.CaCertPaths) > 0 {
// Build the HTTP transport. config.InsecureTLS is set by the --insecure-tls
// CLI flag and always takes precedence. c.InsecureTLS comes from the saved
// context and is honoured only when it was written by the new login code
// (TLSExplicit == true in the stored Server entry).
effectiveInsecure := config.InsecureTLS || c.InsecureTLS
if effectiveInsecure || len(config.CaCertPaths) > 0 {
// Temporarily set the global flag so CreateTLSConfig picks it up.
prevInsecure := config.InsecureTLS
config.InsecureTLS = effectiveInsecure
tlsConfig := config.CreateTLSConfig()
config.InsecureTLS = prevInsecure
tr := &http.Transport{
TLSClientConfig: tlsConfig,
}
Expand Down Expand Up @@ -395,7 +415,11 @@ func (c *microcksClient) redeemRefreshToken(auth config.Auth) (string, string, e
if err != nil {
return "", "", err
}
// Apply effective insecure setting for Keycloak client initialization.
prevInsecure := config.InsecureTLS
config.InsecureTLS = config.InsecureTLS || c.InsecureTLS
kc, err := NewKeycloakClientWithContext(c.ctx, keyCloakUrl, "", "")
config.InsecureTLS = prevInsecure
if err != nil {
return "", "", err
}
Expand Down Expand Up @@ -453,6 +477,53 @@ func (c *microcksClient) GetService(ref string) (*ServiceDetail, error) {
return &detail, nil
}

// DeleteService deletes a service by ID or name:version reference.
func (c *microcksClient) DeleteService(ref string) error {
id := ref
if strings.Contains(ref, ":") {
serviceID, err := c.resolveServiceID(ref)
if err != nil {
return err
}
id = serviceID
}

rel := &url.URL{Path: "services/" + id}
u := c.APIURL.ResolveReference(rel)

req, err := http.NewRequestWithContext(c.ctx, "DELETE", u.String(), nil)
if err != nil {
return errors.Wrap(errors.KindGeneric, fmt.Errorf("creating delete service request: %w", err))
}

req.Header.Set("Authorization", "Bearer "+c.AuthToken)

config.DumpRequestIfRequired("Microcks for deleting service", req, false)

resp, err := c.httpClient.Do(req)
if err != nil {
return errors.Wrap(errors.KindConnection, err)
}
defer resp.Body.Close()

config.DumpResponseIfRequired("Microcks for deleting service", resp, true)

body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(errors.KindConnection, fmt.Errorf("reading Microcks response: %w", err))
}

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
kind := errors.KindAPI
if resp.StatusCode == http.StatusNotFound {
kind = errors.KindNotFound
}
return errors.Wrapf(kind, "Microcks returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}

return nil
}

func (c *microcksClient) ListTestResults(serviceID string, page int, size int) ([]TestResultSummary, error) {
values := url.Values{}
values.Set("page", strconv.Itoa(page))
Expand All @@ -473,21 +544,32 @@ func (c *microcksClient) resolveServiceID(ref string) (string, error) {
if !ok || name == "" || version == "" {
return "", errors.Wrapf(errors.KindUsage, "service reference %q must be <name>:<version>", ref)
}
var matches []Service
for page := 0; ; page++ {
services, err := c.ListServices(page, serviceLookupPageSize)
if err != nil {
return "", err
}
for _, service := range services {
if service.Name == name && service.Version == version {
return service.ID, nil
matches = append(matches, service)
}
}
if len(services) < serviceLookupPageSize {
break
}
}
return "", errors.Wrapf(errors.KindNotFound, "service %q does not exist", ref)
if len(matches) == 0 {
return "", errors.Wrapf(errors.KindNotFound, "service %q does not exist", ref)
}
if len(matches) > 1 {
var ids []string
for _, m := range matches {
ids = append(ids, fmt.Sprintf("%s (%s)", m.ID, m.Type))
}
return "", errors.Wrapf(errors.KindUsage, "multiple services matched %q: %s; please specify by ID", ref, strings.Join(ids, ", "))
}
return matches[0].ID, nil
}

func (c *microcksClient) getJSON(path string, query url.Values, out any, dumpLabel string) error {
Expand Down
Loading
Loading