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
188 changes: 188 additions & 0 deletions internal/api/assets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package api

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

// AssetsClient talks to the Jira Service Management Assets (CMDB) REST API.
//
// Assets lives on a different base URL than the Jira site API
// (https://api.atlassian.com/jsm/assets/workspace/{workspaceId}/v1) and the
// granular OAuth scopes the rest of atl uses do not cover CMDB objects, so this
// client authenticates with Basic auth (account email + API token) instead of
// the shared OAuth client. The token is read from the environment so it never
// lands in the on-disk config.
type AssetsClient struct {
httpClient *http.Client
email string
token string
workspaceID string
siteBase string // https://<hostname>, used only for workspace discovery
}

const assetsAPIBase = "https://api.atlassian.com/jsm/assets/workspace"

// NewAssetsClient builds an Assets client. email and token are required;
// workspaceID may be empty, in which case it is discovered from the site.
func NewAssetsClient(siteBase, email, token, workspaceID string) *AssetsClient {
return &AssetsClient{
httpClient: &http.Client{Timeout: 60 * time.Second},
email: email,
token: token,
workspaceID: workspaceID,
siteBase: strings.TrimRight(siteBase, "/"),
}
}

func (c *AssetsClient) do(ctx context.Context, method, fullURL string, body []byte, out interface{}) error {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, fullURL, rdr)
if err != nil {
return err
}
req.SetBasicAuth(c.email, c.token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
dec := json.NewDecoder(resp.Body)
var apiErr struct {
ErrorMessages []string `json:"errorMessages"`
}
_ = dec.Decode(&apiErr)
if len(apiErr.ErrorMessages) > 0 {
return fmt.Errorf("assets API %s: %d: %s", method, resp.StatusCode, strings.Join(apiErr.ErrorMessages, "; "))
}
return fmt.Errorf("assets API %s: unexpected status %d", method, resp.StatusCode)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}

// WorkspaceID returns the resolved workspace id, discovering it from the site if
// it was not supplied.
func (c *AssetsClient) WorkspaceID(ctx context.Context) (string, error) {
if c.workspaceID != "" {
return c.workspaceID, nil
}
if c.siteBase == "" {
return "", fmt.Errorf("workspace id not set and no site to discover it from (pass --workspace or set ATLASSIAN_ASSETS_WORKSPACE)")
}
var out struct {
Values []struct {
WorkspaceID string `json:"workspaceId"`
} `json:"values"`
}
if err := c.do(ctx, http.MethodGet, c.siteBase+"/rest/servicedeskapi/assets/workspace", nil, &out); err != nil {
return "", fmt.Errorf("discovering assets workspace: %w", err)
}
if len(out.Values) == 0 {
return "", fmt.Errorf("no assets workspace found for site %s", c.siteBase)
}
c.workspaceID = out.Values[0].WorkspaceID
return c.workspaceID, nil
}

func (c *AssetsClient) v1(ctx context.Context) (string, error) {
ws, err := c.WorkspaceID(ctx)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/%s/v1", assetsAPIBase, ws), nil
}

// AssetSchema is one object schema with its current object count.
type AssetSchema struct {
ID string `json:"id"`
Name string `json:"name"`
ObjectSchemaKey string `json:"objectSchemaKey"`
ObjectCount int `json:"objectCount"`
ObjectTypeCount int `json:"objectTypeCount"`
}

// Schemas returns all object schemas in the workspace.
func (c *AssetsClient) Schemas(ctx context.Context) ([]AssetSchema, error) {
base, err := c.v1(ctx)
if err != nil {
return nil, err
}
var out struct {
Values []AssetSchema `json:"values"`
}
if err := c.do(ctx, http.MethodGet, base+"/objectschema/list", nil, &out); err != nil {
return nil, err
}
return out.Values, nil
}

// AssetObject is a single Assets object (trimmed to the useful fields).
type AssetObject struct {
ID string `json:"id"`
ObjectKey string `json:"objectKey"`
Label string `json:"label"`
Created string `json:"created"`
Updated string `json:"updated"`
ObjectType struct {
Name string `json:"name"`
} `json:"objectType"`
}

type aqlPage struct {
Values []AssetObject `json:"values"`
IsLast bool `json:"isLast"`
}

// AQLPage runs an AQL query and returns one page of objects.
func (c *AssetsClient) AQLPage(ctx context.Context, ql string, startAt, maxResults int) ([]AssetObject, bool, error) {
base, err := c.v1(ctx)
if err != nil {
return nil, false, err
}
q := url.Values{}
q.Set("startAt", fmt.Sprint(startAt))
q.Set("maxResults", fmt.Sprint(maxResults))
q.Set("includeAttributes", "false")
body, _ := json.Marshal(map[string]string{"qlQuery": ql})
var page aqlPage
if err := c.do(ctx, http.MethodPost, base+"/object/aql?"+q.Encode(), body, &page); err != nil {
return nil, false, err
}
return page.Values, page.IsLast, nil
}

// AQLCount returns the exact number of objects matching an AQL query by
// paginating through every page. The object/aql endpoint caps its reported
// `total` at 1000, so it cannot be trusted for counting — this walks instead.
func (c *AssetsClient) AQLCount(ctx context.Context, ql string) (int, error) {
total, start := 0, 0
for {
vals, isLast, err := c.AQLPage(ctx, ql, start, 500)
if err != nil {
return 0, err
}
total += len(vals)
if isLast || len(vals) == 0 {
return total, nil
}
start += len(vals)
}
}
74 changes: 74 additions & 0 deletions internal/cmd/assets/aql.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package assets

import (
"fmt"
"strings"

"github.com/spf13/cobra"

"github.com/enthus-appdev/atl-cli/internal/iostreams"
"github.com/enthus-appdev/atl-cli/internal/output"
)

func newCmdAQL(ios *iostreams.IOStreams, common *commonOptions) *cobra.Command {
var (
countOnly bool
limit int
jsonOut bool
)

cmd := &cobra.Command{
Use: "aql <query>",
Short: "Run an AQL query against the Assets workspace",
Long: `Run an Assets Query Language (AQL) query.

With --count the result is the exact number of matching objects (the command
paginates, because the Assets endpoint caps its reported total at 1000).
Otherwise the first matching objects are listed.`,
Args: cobra.ExactArgs(1),
Example: ` # Exact count of every object in the workspace
atl assets aql 'objectId > 0' --count

# Newest objects of one object type
atl assets aql 'objectTypeId = 36 ORDER BY created DESC' --limit 20`,
RunE: func(cmd *cobra.Command, args []string) error {
ql := args[0]
client, err := common.client()
if err != nil {
return err
}
ctx := cmd.Context()

if countOnly {
n, err := client.AQLCount(ctx, ql)
if err != nil {
return err
}
if jsonOut {
return output.JSON(ios.Out, map[string]int{"count": n})
}
fmt.Fprintln(ios.Out, n)
return nil
}

objs, _, err := client.AQLPage(ctx, ql, 0, limit)
if err != nil {
return err
}
if jsonOut {
return output.JSON(ios.Out, objs)
}
rows := make([][]string, 0, len(objs))
for _, o := range objs {
rows = append(rows, []string{o.ObjectKey, o.ObjectType.Name, o.Created, o.Updated, strings.TrimSpace(o.Label)})
}
output.SimpleTable(ios.Out, []string{"KEY", "TYPE", "CREATED", "UPDATED", "LABEL"}, rows)
return nil
},
}

cmd.Flags().BoolVar(&countOnly, "count", false, "Print only the exact total match count")
cmd.Flags().IntVar(&limit, "limit", 25, "Max objects to list when not counting")
cmd.Flags().BoolVarP(&jsonOut, "json", "j", false, "Output as JSON")
return cmd
}
87 changes: 87 additions & 0 deletions internal/cmd/assets/assets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package assets

import (
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/enthus-appdev/atl-cli/internal/api"
"github.com/enthus-appdev/atl-cli/internal/config"
"github.com/enthus-appdev/atl-cli/internal/iostreams"
)

// commonOptions holds the auth/target flags shared by every assets subcommand.
type commonOptions struct {
Email string
Workspace string
}

func (o *commonOptions) addFlags(cmd *cobra.Command) {
cmd.PersistentFlags().StringVar(&o.Email, "email", "", "Atlassian account email (default: $ATLASSIAN_EMAIL or current host user)")
cmd.PersistentFlags().StringVar(&o.Workspace, "workspace", "", "Assets workspace id (default: $ATLASSIAN_ASSETS_WORKSPACE or auto-discovered)")
}

// client builds a Basic-auth Assets client from flags, environment, and the
// current atl host. The API token is only ever read from the environment.
func (o *commonOptions) client() (*api.AssetsClient, error) {
token := os.Getenv("ATLASSIAN_API_TOKEN")
if token == "" {
return nil, fmt.Errorf("ATLASSIAN_API_TOKEN is not set — Assets uses Basic auth, not atl's OAuth login; create a token at https://id.atlassian.com/manage-profile/security/api-tokens")
}

email := o.Email
if email == "" {
email = os.Getenv("ATLASSIAN_EMAIL")
}
workspace := o.Workspace
if workspace == "" {
workspace = os.Getenv("ATLASSIAN_ASSETS_WORKSPACE")
}

cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("loading atl config: %w", err)
}
siteBase := ""
if hc := cfg.CurrentHostConfig(); hc != nil {
if email == "" {
email = hc.User
}
proto := hc.Protocol
if proto == "" {
proto = "https"
}
if hc.Hostname != "" {
siteBase = proto + "://" + hc.Hostname
}
}

if email == "" {
return nil, fmt.Errorf("no account email — pass --email, set $ATLASSIAN_EMAIL, or log in to a host that records a user")
}

return api.NewAssetsClient(siteBase, email, token, workspace), nil
}

// NewCmdAssets creates the assets command group.
func NewCmdAssets(ios *iostreams.IOStreams) *cobra.Command {
opts := &commonOptions{}

cmd := &cobra.Command{
Use: "assets",
Short: "Work with Jira Assets (CMDB)",
Long: `Query the Jira Service Management Assets (CMDB) workspace.

Assets has its own API and uses Basic auth rather than atl's OAuth login
(the granular OAuth scopes do not cover CMDB objects). Set ATLASSIAN_API_TOKEN;
the account email and site default to your current atl host, and the workspace
id is auto-discovered if not supplied.`,
}

opts.addFlags(cmd)
cmd.AddCommand(newCmdCount(ios, opts))
cmd.AddCommand(newCmdAQL(ios, opts))

return cmd
}
Loading
Loading