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
47 changes: 47 additions & 0 deletions cmd/kosli/cli_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,55 @@ import (
cp "github.com/otiai10/copy"
"github.com/spf13/cobra"
"github.com/xeonx/timeago"
"golang.org/x/term"
)

// ANSI style codes for terminal output. Pass one or more of these to style().
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"

// standard foreground colors
ansiBlack = "\033[30m"
ansiRed = "\033[31m"
ansiGreen = "\033[32m"
ansiYellow = "\033[33m"
ansiBlue = "\033[34m"
ansiMagenta = "\033[35m"
ansiCyan = "\033[36m"
ansiWhite = "\033[37m"

// bright foreground colors
ansiBrightBlack = "\033[90m"
ansiBrightRed = "\033[91m"
ansiBrightGreen = "\033[92m"
ansiBrightYellow = "\033[93m"
ansiBrightBlue = "\033[94m"
ansiBrightMagenta = "\033[95m"
ansiBrightCyan = "\033[96m"
ansiBrightWhite = "\033[97m"
Comment thread
mbevc1 marked this conversation as resolved.
Comment thread
mbevc1 marked this conversation as resolved.
)
Comment thread
mbevc1 marked this conversation as resolved.
Comment thread
mbevc1 marked this conversation as resolved.
Comment thread
mbevc1 marked this conversation as resolved.

// styleEnabled reports whether ANSI styling should be applied to out:
// only when out is an interactive terminal and NO_COLOR is not set.
func styleEnabled(out io.Writer) bool {
if _, noColor := os.LookupEnv("NO_COLOR"); noColor {
return false
}
f, ok := out.(*os.File)
return ok && term.IsTerminal(int(f.Fd()))
}

// style wraps s in the given ANSI codes (bold, colors, ...) when styling is
// enabled for out, otherwise returns s unchanged. Multiple codes can be
// combined, e.g. style(out, s, ansiBold, ansiRed); a single reset closes them.
func style(out io.Writer, s string, codes ...string) string {
if len(codes) == 0 || !styleEnabled(out) {
return s
}
return strings.Join(codes, "") + s + ansiReset
}

const (
bitbucket = "Bitbucket"
github = "Github"
Expand Down
23 changes: 23 additions & 0 deletions cmd/kosli/cli_utils_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bytes"
"fmt"
"os"
"path/filepath"
Expand All @@ -22,6 +23,28 @@ type CliUtilsTestSuite struct {

// All methods that begin with "Test" are run as tests within a
// suite.
func (suite *CliUtilsTestSuite) TestStyle() {
// every defined ANSI code
allCodes := []string{
ansiReset, ansiBold,
ansiBlack, ansiRed, ansiGreen, ansiYellow, ansiBlue, ansiMagenta, ansiCyan, ansiWhite,
ansiBrightBlack, ansiBrightRed, ansiBrightGreen, ansiBrightYellow, ansiBrightBlue,
ansiBrightMagenta, ansiBrightCyan, ansiBrightWhite,
}

// A bytes.Buffer is not a terminal, so styling must never be applied,
// regardless of which/how many codes are passed.
buf := new(bytes.Buffer)
require.False(suite.T(), styleEnabled(buf), "a non-*os.File writer is never a terminal")
for _, code := range allCodes {
require.Equal(suite.T(), "text", style(buf, "text", code),
"non-terminal output should never be styled")
}
require.Equal(suite.T(), "text", style(buf, "text"), "no codes returns the input unchanged")
require.Equal(suite.T(), "text", style(buf, "text", ansiBold, ansiYellow),
"combined codes are still plain on a non-terminal")
}

func (suite *CliUtilsTestSuite) TestWhichCI() {
for _, t := range []struct {
name string
Expand Down
113 changes: 113 additions & 0 deletions cmd/kosli/createApiKey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package main

import (
"io"
"net/http"
"net/url"

"github.com/kosli-dev/cli/internal/output"
"github.com/kosli-dev/cli/internal/requests"
"github.com/spf13/cobra"
)

const createApiKeyShortDesc = `Create an API key for a service account.`

const createApiKeyLongDesc = createApiKeyShortDesc + `

The key value is only returned once, at creation time, so make sure to store it securely.`

const createApiKeyExample = `
# create an API key for a service account:
kosli service-account api-keys create \
--service-account yourServiceAccountName \
--description "key for CI" \
--api-token yourAPIToken \
--org yourOrgName

# create an API key that expires on a given date:
kosli service-account api-keys create \
--service-account yourServiceAccountName \
--description "key for CI" \
--expires-at 2026-12-31 \
--api-token yourAPIToken \
--org yourOrgName
`

type createApiKeyOptions struct {
serviceAccount string
expiresAt string
output string
payload createApiKeyPayload
}

type createApiKeyPayload struct {
Description string `json:"description"`
ExpiresAt *int64 `json:"expires_at,omitempty"`
}

func newCreateApiKeyCmd(out io.Writer) *cobra.Command {
o := new(createApiKeyOptions)
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"c", "cr"},
Short: createApiKeyShortDesc,
Long: createApiKeyLongDesc,
Example: createApiKeyExample,
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
return ErrorBeforePrintingUsage(cmd, err.Error())
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return o.run(out, args)
},
}

cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag)
cmd.Flags().StringVarP(&o.payload.Description, "description", "d", "", apiKeyDescriptionFlag)
cmd.Flags().StringVarP(&o.expiresAt, "expires-at", "e", "", apiKeyExpiresAtFlag)
cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag)
addDryRunFlag(cmd)

err := RequireFlags(cmd, []string{"service-account", "description"})
if err != nil {
logger.Error("failed to configure required flags: %v", err)
}

return cmd
}

func (o *createApiKeyOptions) run(out io.Writer, args []string) error {
url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys")
Comment thread
mbevc1 marked this conversation as resolved.
if err != nil {
return err
}

if o.expiresAt != "" {
expiresAt, err := parseExpiresAt(o.expiresAt)
if err != nil {
return err
}
o.payload.ExpiresAt = &expiresAt
}

reqParams := &requests.RequestParams{
Method: http.MethodPost,
URL: url,
Payload: o.payload,
DryRun: global.DryRun,
Token: global.ApiToken,
}
response, err := kosliClient.Do(reqParams)
if err != nil || global.DryRun {
return err
}

return output.FormattedPrint(response.Body, o.output, out, 0,
map[string]output.FormatOutputFunc{
"table": printApiKeyAsTable,
"json": output.PrintJson,
})
}
121 changes: 121 additions & 0 deletions cmd/kosli/listApiKeys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package main

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"

"github.com/kosli-dev/cli/internal/output"
"github.com/kosli-dev/cli/internal/requests"
"github.com/spf13/cobra"
)

const listApiKeysShortDesc = `List API keys for a service account.`

const listApiKeysLongDesc = listApiKeysShortDesc + `

Only the metadata of each active API key is returned; the key values themselves are never
listed (they are only shown once, at creation or rotation time).`

const listApiKeysExample = `
# list the API keys for a service account:
kosli service-account api-keys list \
--service-account yourServiceAccountName \
--api-token yourAPIToken \
--org yourOrgName
`

type listApiKeysOptions struct {
serviceAccount string
output string
}

func newListApiKeysCmd(out io.Writer) *cobra.Command {
o := new(listApiKeysOptions)
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l", "ls"},
Short: listApiKeysShortDesc,
Long: listApiKeysLongDesc,
Example: listApiKeysExample,
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
return ErrorBeforePrintingUsage(cmd, err.Error())
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return o.run(out, args)
},
}

cmd.Flags().StringVarP(&o.serviceAccount, "service-account", "s", "", serviceAccountNameFlag)
cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag)

err := RequireFlags(cmd, []string{"service-account"})
if err != nil {
logger.Error("failed to configure required flags: %v", err)
}

return cmd
}

func (o *listApiKeysOptions) run(out io.Writer, args []string) error {
url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, o.serviceAccount, "api-keys")
Comment thread
mbevc1 marked this conversation as resolved.
if err != nil {
return err
}

reqParams := &requests.RequestParams{
Method: http.MethodGet,
URL: url,
Token: global.ApiToken,
}
response, err := kosliClient.Do(reqParams)
if err != nil {
return err
}

return output.FormattedPrint(response.Body, o.output, out, 0,
map[string]output.FormatOutputFunc{
"table": printApiKeysListAsTable,
"json": output.PrintJson,
})
}

func printApiKeysListAsTable(raw string, out io.Writer, page int) error {
Comment thread
mbevc1 marked this conversation as resolved.
var keys []map[string]interface{}
if err := json.Unmarshal([]byte(raw), &keys); err != nil {
return err
}

if len(keys) == 0 {
logger.Info("No API keys were found.")
return nil
}

header := []string{"ID", "DESCRIPTION", "CREATED", "EXPIRES", "LAST USED"}
rows := []string{}
for _, key := range keys {
createdAt, err := formattedTimestamp(key["created_at"], false)
if err != nil {
return err
}
expiresAt, err := optionalTimestamp(key["expires_at"])
if err != nil {
return err
}
lastUsedAt, err := optionalTimestamp(key["last_used_at"])
if err != nil {
return err
}

Comment thread
mbevc1 marked this conversation as resolved.
row := fmt.Sprintf("%s\t%s\t%s\t%s\t%s", key["id"], key["description"], createdAt, expiresAt, lastUsedAt)
rows = append(rows, row)
}
tabFormattedPrint(out, header, rows)
return nil
}
Loading
Loading