This section of the docs is designed to help people new to the code understand
how azbrowse
is written, where stuff is and what we're proud of vs regret!
As it's core azbrowse
is a tree
which the user walks. The root
node uses the az cli
to discover all
the subscriptions a user has access too. From this point the user can walk down the tree to resources
, subresources
and actions
.
Everything (or nearly everything) is represented as expander
's which return ExpanderResult
's.
An ExpanderResult
contains the content to display in the itemView
and also treeNode
's each of which represents and item in the list of the left panel.
Everything gets started in cmd/azbrowse/cmd.go
- Lots of strings are composed in the code. The
+
concat style is preferred throughout the codebase overfmt
and%s
as it was my (Lawrence) preference as I believe it's more readable when things started out withgo 1.13
. - The
Status
notifications are an overly complexpub/sub
system that I'd not use if I had a do-over. I'll cover this later in the doc.
... Probably more.
For connecting to Azure API's in azbrowse
you'll use the armclient
helpers.
Early on we made a choice not to use the Golang SDK for Azure.
Instead, we talk directly to the API's of
the individual resourceProviders
using the code in armclient
.
Using the REST methods directly via the helpers in armclient
allows us to do the following:
- Meta-programming: We use the Azure API Specs to generate the
tree
the user is going to walk - Flexibility: We're able to use endpoints which may not be represented correctly or at all in the Go SDK for Azure
The armclient
provides some helpers to make this easier.
- Requests the latest
api-versions
for eachresourceProvider
and appends this to requests - Handles throttling to ensure we don't make too many requests to the ARM API and get the user throttled
- Allows async requests using Golang
channels
- Helpers for querying the
azureResourceGraph
this is used to get status fromresources
in Azure - Handle mocking and testing allowing authors of
expanders
to create mock responses to test their code
Sometimes calls to Azure are expensive (take a while and return lots of data), when working with these calls we cache results with a TTL
using ``internal/pkg/storage/store.go`.
PopulateResourceAPILookup
in armclient
is an example of this caching approach.
These represent items in the list panel, mapping to Azure Resources, Subresources or Actions.
The items have has evolved over a year, it has some duplication and fields which are used differently by different expanders.
// TreeNode is an item in the ListWidget
type TreeNode struct {
Parentid string // The ID of the parent resource
Parent *TreeNode // Reference to the parent node
ID string // The ID of the resource in ARM
Name string // The name of the object returned by the API
Display string // The Text used to draw the object in the list
ExpandURL string // The URL to call to expand the item
ItemType string // The type of item either subscription, resourcegroup, resource, deployment or action
ExpandReturnType string // The type of the items returned by the expandURL
DeleteURL string // The URL to call to delete the current resource
Namespace string // The ARM Namespace of the item eg StorageAccount
ArmType string // The ARM type of the item eg Microsoft.Storage/StorageAccount
Metadata map[string]string // Metadata is used to pass arbritray data between `Expander`'s
SubscriptionID string // The SubId of this item
StatusIndicator string // Displays the resources status
SwaggerResourceType *swagger.ResourceType // caches the swagger ResourceType to avoid repeated lookups
Expander Expander // The Expander that created the node (set automatically by the list)
SuppressSwaggerExpand bool // Prevent the SwaggerResourceExpander attempting to expand the node
SuppressGenericExpand bool // Prevent the DefaultExpander (aka GenericExpander) attempting to expand the node
TimeoutOverrideSeconds *int // Override the default expand timeout for a node
ExpandInPlace bool // Indicates that the node is a "More..." node. Must be the last in the list and will be removed and replaced with the expanded nodes
}
Simple expanders may use a little as the following to construct a treeNode
instance:
&TreeNode{
Display: sub.DisplayName,
Name: sub.DisplayName,
ID: sub.ID,
ExpandURL: sub.ID + "/resourceGroups?api-version=2018-05-01",
ItemType: SubscriptionType,
SubscriptionID: sub.SubscriptionID,
}
A more complex example would be the metrics
expander. This constructs a complex ExpandURL
while also suppressing other expanders for Swagger
and DefaultExpander
.
Another interesting case here is the use of Metadata
map, this allows expanders to store arbitrary data which they, or another expander, can reuse when expanding the item (see internal/pkg/expanders/metrics.go
for full code).
&TreeNode{
Name: metric.Name.Value,
Display: metric.Name.Value + "\n " + style.Subtle("Unit: "+metric.Unit),
ID: currentItem.Metadata["ResourceID"] + "/providers/microsoft.Insights/metrics",
Parentid: currentItem.ID,
ExpandURL: currentItem.Metadata["ResourceID"] + "/providers/microsoft.Insights/metrics?timespan=" +
time.Now().UTC().Add(-4*time.Hour).Format("2006-01-02T15:04:05.000Z") + "/" +
time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + "&interval=PT1M&metricnames=" +
url.QueryEscape(metric.Name.Value) + "&aggregation=" +
url.QueryEscape(metric.PrimaryAggregationType) +
"&metricNamespace=" + url.QueryEscape(metric.Namespace) +
"&autoadjusttimegrain=true&validatedimensions=false&api-version=2018-01-01",
ItemType: "metrics.graph",
SubscriptionID: currentItem.SubscriptionID,
SuppressSwaggerExpand: true,
SuppressGenericExpand: true,
Metadata: map[string]string{
"AggregationType": strings.ToLower(metric.PrimaryAggregationType),
"Units": strings.ToLower(metric.Unit),
},
In this case the metadata is used to create a caption when rendering the graph for the item when expanded:
caption := style.Title(currentItem.Name) +
style.Subtle(" (Aggregate: '"+currentItem.Metadata["AggregationType"]+"' Unit: '"+
currentItem.Metadata["Units"]+"')")
The hierarchical drill-down from Subscription -> Resource Group -> Resource -> ... is driven by Expanders. These are registered in registerExpanders.go
and when the list widget expands a node it calls each expander asking if they have any nodes to provide. Multiple expanders can return nodes for any given parent node, but only one expander should mark the response as the primary response.
Note: Remember to update
InitializeExpanders
ininternal/pkg/expanders/registerExpanders.go
with your new expander if you add one otherwise it won't get called!
Each node has an ID and IDs should be unique (to support the --navigate
command), and typically are the resource ID for the resource in Azure (this allows the open in portal
action to function)
Expanders meet an interface defined in internal/pkg/expanders/types.go
which is roughly as follows:
// Expander is used to open/expand items in the left list panel
// a single item can be expanded by 1 or more expanders
// each Expander provides two methods.
// `DoesExpand` should return true if this expander can expand the resource
// `Expand` returns the list of sub items from the resource
type Expander interface {
DoesExpand(ctx context.Context, currentNode *TreeNode) (bool, error)
Expand(ctx context.Context, currentNode *TreeNode) ExpanderResult
Name() string // Returns the name of this expander for logging (ie. TenantExpander)
Delete(context context.Context, item *TreeNode) (bool, error)
HasActions(ctx context.Context, currentNode *TreeNode) (bool, error)
ListActions(ctx context.Context, currentNode *TreeNode) ListActionsResult
ExecuteAction(ctx context.Context, currentNode *TreeNode) ExpanderResult
CanUpdate(context context.Context, item *TreeNode) (bool, error)
Update(context context.Context, item *TreeNode, updatedContent string) error
// Used for testing the expanders
testCases() (bool, *[]expanderTestCase)
setClient(c *armclient.Client)
}
A good example of a simple expander is the TenantExpander
at internal/pkg/expanders/tentant.go
. It uses armclient
to request
a list of subscriptions for to show the user via the /subscriptions
Azure REST call. The response is deserialized into a
go struct for ease and then the list items to show (one for each subscription) are created as TreeNode
's. The response
content is set to the API response as JSON or the error received from the call.
The SwaggerResourceExpander
is used to drill down within resources. It works against SwaggerAPISet
s which provide the swagger metadata as well as encapsulating access to the endpoints identified in the metadata.
The default API Set is SwaggerAPISetARMResources
which is based on code generated at build time via make swagger-codegen
. The swagger codegen process loads all the management plane swagger documents published on GitHub and builds a hierarchy based on the URLs. This is then distilled down into a slightly simpler format based around the ResourceType
struct. Access to the endpoints in SwaggerAPISetARMResources
is performed by the armclient
which piggy-backs on the authentication from the Azure CLI.
Other API Sets can be registered and currently containerService and search are two examples. The Azure Search API Set also uses a ResourceType
hierarchy generated at build time, but it is dynamically registered with the SwaggerResourceExpander
when the user expands the "Search Service" node (added by the AzureSearchServiceExpander
). The API Set instance that is registered at that point has the credentials for authenticating to that specific instance of the Azure Search Service.
The pattern for the container Service API Set is similar: a Kubernetes API node is added by the AzureKubernetesServiceExpander
and when that is expanded the credentials to the Kubernetes cluster are retrieved and passed to an instance of the API Set. One difference is that the ResourceType
s for the container service API Set are generated at runtime by querying the Kubernetes API (this allows the node expansion to accurately represent the cluster version as well as any other endpoints that are specific to the cluster)
Issuing PUT
/DELETE
requests requires the same authentication as GET
requests so the SwaggerResourceExpander
also forwards these to the relevant API Set. (The metadata for the node contains the name of the API Set that returned it)
ExpandItemAllowDefaultExpander
in internal/pkg/expanders/expander.go
handles calling the expanders.
It does the following:
- Use
getRegisteredExpanders
to find all expanders - Check which expanders are relevant for the
TreeNode
provided usingDoesExpand
on each expander - Starts a go routine to call
Expand
asynchronously on each expander which indicated it could expand the item. Results are sent to a go channel on completion as anExpanderResult
seeinternal/pkg/expanders/types.go
- A timeout is also started to ensure we don't block on a single expander while others have responded
- Responses are collected from all expanders, by pulling from the go channel and returned
- If
AllowDefaultExpander: true
theDefaultExpander
is used which attempts to make aGET
request to theTreeNodes
's ID. Seeinternal/pkg/expanders/default.go
Key bindings are initialized in the setupViewsAndKeybindings
function in main.go
. Each binding is registered via the keybindings.AddHandler
function and subsequently bound through the keybindings.Bind
function.
Handlers implement the KeyHandler
interface which specifies an ID, an implementation to invoke, the widget that the binding is scoped to, and the default key.
The ID
and DefaultKey
functions are both provided by KeyHandlerBase
. ID
simply returns the id
property, and DefaultKey
performs a lookup in DefaultKeys
using the ID.
GoCUI draws the terminal interface using views
(or widgets
). All the views live in the internal/pkg/views.
This view provides the left-hand list view.
It is responsible for displaying items which can be opened (TreeNodes
which opened by expanders
).
This is one of the more complex views in the system, it:
- Handles navigation back/forward in the tree.
- Keeps a
navStack
which is a first-in first-out history used to go back to previous pages without reloading all items (it tracks thecontent
andTreeNode[]
of the previous items) - Allows
more ...
style behaviour to incrementally load more items to the list. This is used thestorage
data-plane for when acontainer
holds lots of items - Handles
filtering
items in the list - Tracks the currently selected item and adds the
>
indicator to that item asup/down
are pressed.
This view provides the main right-hand output panel displaying the json
/yaml
/xml
/hcl
content.
It has methods for GetContent
and SetContent
for example.
One of its responsibilities is to add formatting and highlighting to the content.
The content is provided to the views from the ExpanderResult
which is produced by the expanders
we discussed in the sections above.
It also keeps track of a pointer to the TreeNode
which generated the content. This enables the GetNode
method
to return a reference to the currently displayed TreeNode
or CurrentItem
.
This view is the overlaid command panel (inspired by the CTRL+P panel in VSCode) which allows for more complex interactions with the UI.
For example, typing /
opens the panel and then the user can type text and the listView
will filter to only
show items that contain the text.
Alternatively a user can press CTRL+P
and a small list will show possible options/actions the user can then
select to invoke.
This view handles the optional top-right notifications.
It is used to display pending deletes (resources queued for delete but not yet actioned) alongside
toast
style notifications, for example, deletes that have been actioned, and we're tracking their
async completion.
The delete functionality has taken over this view, the aim was to be generalized, but it's more specific currently. Methods
for AddPendingDelete
and ConfirmDelete
handling logic for deleting items.
Generalized toast
notifications are driven by events sent via internal/pkg/eventing/eventing.go
package IsToast: true
.
Currently, I'm not aware of this being used anywhere, so it may not function correctly.
This view shows status messages along the bottom of the view.
It listens to a pub/sub
style bus of messages published by the internal/pkg/eventing/eventing.go
package.
Each message has a ttl
, status
etc. It translates these to displayed text colour, icons and ensures
they're displayed for the correct amount of time.
This is the simplest view it shows the help for which keys do what.
There is an overly complex pub/sub
system for handling StatusEvents
and other events in the system.
// StatusEvent is used to show status information
// in the statusbar
type StatusEvent struct {
Message string
Timeout time.Duration
createdAt time.Time
InProgress bool
IsToast bool
Failure bool
id uuid.UUID
}
This is used by the statusbar
view, notifications
view. These events are published, updated and completed as follows:
statusEvent, done := eventing.SendStatusEvent(&eventing.StatusEvent{
Message: "Opening: thing",
InProgress: true,
})
defer done() // This will mark the StatusEvent's InProgress field to false
// ... Do things here
event.Failure = true
event.InProgress = false
event.Message = "Failed to delete doing thing with error:" + err.Error()
event.Update()
Not great right? There are some helpers hanging off eventing
which could be updated to make this nicer or the system could be refactored to simplify it.
The same bus is used to push messages when navigation
occurs, this is used to drive a number of features such as the --navigate
CLI arg, make fuzz
fuzzer and --resume
cli command.
You'll see calls to publish to the bus in the internal/pkg/views/list.go
ListView like this:
eventing.Publish("list.prenavigate", "GOBACK")
eventing.Publish("list.navigated", ListNavigatedEventState{Success: false})
These events power functionality of --navigate
via the code in internal/pkg/automation/navigateTo.go
which listens to these events and drives to UI until it reaches the expected item.
The events are also used in RegisterGuiAndStartHistoryTracking
part of the recovery
package internal/pkg/errorhandling/recovery.go
. In this package the events are used to track the path taken up until a crash/panic occurs to allow it to be easily reproduced.
All go routines started in the solution have the following first line.
// recover from panic, if one occurrs, and leave terminal usable
defer errorhandling.RecoveryWithCleanup()
This does 2 things.
- Ensure we don't leave the terminal session unusable, closing out
gocui
and tidying up - Outputting useful information for the user to report the crash like the path they took before the crash, stack etc
Inside cmd/azbrowse/cmd.go
the command structure of the CLI is setup using cobra
.
As part of this autocomplete functions are added which do the following:
subscriptionAutocompletion
allows--subscription stuff<TAB>
-->--subscription stuffAndThingsSub
navigateAutocompletion
allows--navigate /subscription/GUID/resourceg<TAB>
to autocomplete to a resource
To ensure that autocomplete is quick results are cached using the internal/pkg/storage/store.go
key vault store which also provides a rough TTL
based expirey (it's a hack - go look ... it works, but I'm not proud of it).
AzBrowse is built using the scripts/ci_release.sh
script which runs locally or as part of the Github Action .github/workflows/build.yaml
.
Under the covers this uses goreleaser
to build for multiple platforms and packaging systems (brew, docker, deb etc). It is configured in .goreleaser.yml
and pushes output to Github releases.
The cmd/azbrowse/selfupdate.go
handles checking for new releases when the CLI started and offering to the user to update. It uses the selfupdate
package from rhysd.