o7k (pronounced oooo-sju-k πΈπͺ) is a terminal UI for OpenStack, heavily inspired by k9s β£οΈ
It provides a fast way to inspect and navigate OpenStack resources directly from the terminal.
Install o7k using the Homebrew tap:
brew install --cask akyriako/tap/o7kUpgrade to the latest release:
brew upgrade --cask o7kAdd the o7k repository signing key:
curl -fsSL https://akyriako.github.io/o7k-apt/o7k-archive-keyring.gpg \
| sudo tee /usr/share/keyrings/o7k-archive-keyring.gpg >/dev/nullAdd the APT repository:
echo "deb [signed-by=/usr/share/keyrings/o7k-archive-keyring.gpg] https://akyriako.github.io/o7k-apt/ stable main" \
| sudo tee /etc/apt/sources.list.d/o7k.listInstall o7k:
sudo apt update
sudo apt install o7kUpgrade to the latest release:
sudo apt update
sudo apt install --only-upgrade o7kImport the repository signing key:
sudo rpm --import https://akyriako.github.io/o7k-rpm/o7k-rpm-signing-key.ascAdd the o7k repository:
sudo tee /etc/yum.repos.d/o7k.repo >/dev/null <<'EOF'
[o7k]
name=o7k
baseurl=https://akyriako.github.io/o7k-rpm/
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://akyriako.github.io/o7k-rpm/o7k-rpm-signing-key.asc
EOFInstall o7k:
sudo dnf install o7kUpgrade to the latest release:
sudo dnf upgrade --refresh o7ko7k --versiono7k uses the standard OpenStack clouds.yaml configuration and automatically discovers the
configuration files from the following places:
/etc/openstack~/.config/openstack- the current working directory
Note
All the discovered files will be automatically loaded as context in o7k
| Key | Action |
|---|---|
: |
Switch resource |
Enter |
Execute the default action for the selected resource |
r |
Refresh the current resource |
c |
Copy the current detail-view as JSON or table-view as tab-delimited text |
Esc |
Dismiss / return to the previous view |
Ctrl+X |
Quit |
Tip
o7k will automatically refresh the current resource every 30s, you don't need to explicitely press r.
To switch between resources, press : and enter a resource name or alias (exactly as you are being used to in k9s)
e.g.:
:servers
:networks
:volumes
:images
Resource-specific commands are displayed in the header while a resource is active.
o7k supports navigation between related OpenStack resources.
Examples include:
Server -> Image
Network -> Subnets
Network -> Ports
Subnet -> Network
Port -> Network
Router -> Ports -> Networks -> Subnets
Router -> Floating IPs
Floating IP -> Router
Floating IP -> Port
Port -> Floating IPs
Volume -> Snapshots
Volume -> Backups
Snapshot -> Volume
Backup -> Volume
Security Group -> Security Group Rules
Security Group Rule -> Security Group
When navigating to a related collection, o7k filters the destination resource automatically.
Press Esc to return to the previous resource and selection.
| Service | Resources | Status |
|---|---|---|
| Local / Auth | contexts | Partial |
| Keystone / Identity | projects, users, groups, roles, domains | Partial |
| Keystone / Catalog | catalog, services, endpoints, regions | β |
| Cinder / Block Storage | volumes, snapshots, volume types, volume backups | β |
| Glance / Image | images | β |
| Swift / Object Storage | swift containers, swift objects | β |
| Nova / Compute | servers, flavors | β |
| Nova / Compute | keypairs, server groups, availability zones | β |
| Neutron / Network | networks, subnets, ports, routers, floating IPs, security groups | β |
| Neutron / Network | security group rules | β |
| Neutron / Network | router interfaces | β¬ |
| Designate / DNS | zones, recordsets | β |
| Heat / Orchestration | stacks, stack resources, stack events | β |
| Octavia / Load Balancing | load balancers, listeners, pools, members | β |
| Octavia / Load Balancing | health monitors, L7 policies, L7 rules | β |
| Barbican / Key Manager | secrets, secret containers, orders | β |
| Trove / Databases | instances | β¬ |
Caution
o7k is early beta and under active development. Expect bugs, incomplete features, and breaking changes. Behavior and configuration may change without notice.
Use against production OpenStack environments at your own risk.
| Service | Resource | Command | Aliases |
|---|---|---|---|
| Command Palette | Aliases | commands |
command, cmd, aliases, alias |
| Local / Auth | Contexts | contexts |
context, ctx, cloud, clouds |
| Keystone / Catalog | Catalog | catalog |
cat |
| Keystone / Catalog | Services | services |
service, svc |
| Keystone / Catalog | Endpoints | endpoints |
endpoint |
| Keystone / Catalog | Regions | regions |
region |
| Keystone / Identity | Projects | projects |
project |
| Keystone / Identity | Users | users |
user |
| Keystone / Identity | Groups | groups |
group |
| Keystone / Identity | Roles | roles |
role |
| Keystone / Identity | Domains | domains |
domain |
| Nova / Compute | Servers | servers |
server, srv |
| Nova / Compute | Flavors | flavors |
flavor |
| Nova / Compute | Keypairs | keypairs |
keypair, keys, key |
| Nova / Compute | Server Groups | servergroups |
servergroup, sgroups, sgroup |
| Nova / Compute | Availability Zones | availabilityzones |
availabilityzone, azs, az |
| Neutron / Network | Networks | networks |
network, net |
| Neutron / Network | Subnets | subnets |
subnet |
| Neutron / Network | Ports | ports |
port |
| Neutron / Network | Routers | routers |
router |
| Neutron / Network | Floating IPs | floatingips |
floatingip, fips, fip |
| Neutron / Network | Security Groups | securitygroups |
securitygroup, secgroups, secgroup, sg |
| Neutron / Network | Security Group Rules | securitygrouprules |
securitygrouprule, secrules, secrule, sgr |
| Cinder / Block Storage | Volumes | volumes |
volume, vol |
| Cinder / Block Storage | Snapshots | snapshots |
snapshot, snap |
| Cinder / Block Storage | Volume Types | volumetypes |
volumetype |
| Cinder / Block Storage | Volume Backups | backups |
backup, volume-backups, volume-backup |
| Glance / Image | Images | images |
image, img |
| Swift / Object Storage | Swift Containers | swift-containers |
swift-container |
| Swift / Object Storage | Swift Objects | swift-objects |
swift-object |
| Heat / Orchestration | Stacks | stacks |
stack |
| Heat / Orchestration | Stack Resources | stack-resources |
stack-resource |
| Heat / Orchestration | Stack Events | stack-events |
stack-event |
| Octavia / Load Balancing | Load Balancers | loadbalancers |
loadbalancer, lbs, lb |
| Octavia / Load Balancing | Listeners | listeners |
listener |
| Octavia / Load Balancing | Pools | pools |
pool |
| Octavia / Load Balancing | Members | members |
member |
| Octavia / Load Balancing | Health Monitors | healthmonitors |
healthmonitor, monitors, monitor |
| Octavia / Load Balancing | L7 Policies | l7policies |
l7policy, l7-policies, l7-policy |
| Octavia / Load Balancing | L7 Rules | l7rules |
l7rule, l7-rules, l7-rule |
| Barbican / Key Manager | Secrets | secrets |
secret |
| Barbican / Key Manager | Secret Containers | secret-containers |
secret-container |
| Barbican / Key Manager | Orders | orders |
order |
| Designate / DNS | Zones | zones |
zone, dns-zones, dns-zone |
| Designate / DNS | Recordsets | recordsets |
recordset, records, record |
o7k resources are organized by OpenStack service and implement a common resource interface. Adding support for a new resource normally involves four areas:
- Add or reuse the OpenStack service client in
openstack.Context. - Implement the resource under
internal/resources/<service>/. - Register the resource in
cmd/main.go. - Update the README support matrix.
The examples below use a fictional widgets resource belonging to an OpenStack service called example.
OpenStack API access is centralized through openstack.Context. Resources should use a service client provided by the context rather than creating their own authenticated provider.
Add a helper for the service if one does not already exist.
For example:
func (c *Context) ExampleV2() (*gophercloud.ServiceClient, error) {
return example.NewExampleV2(
c.Provider,
gophercloud.EndpointOpts{
Region: c.Region,
},
)
}Use the appropriate Gophercloud constructor and API version for the OpenStack service.
Important
If the service client already exists in openstack.Context, reuse it. Do not create another client specifically for the new resource.
A resource can then obtain its client with:
client, err := r.context.ExampleV2()
if err != nil {
return nil, fmt.Errorf("creating example client: %w", err)
}Resources live below the OpenStack service they belong to.
For example:
internal/resources/
βββ compute/
β βββ servers/
β βββ flavors/
β βββ keypairs/
β βββ servergroups/
βββ networking/
β βββ networks/
β βββ subnets/
β βββ ports/
β βββ routers/
βββ blockstorage/
β βββ volumes/
β βββ snapshots/
β βββ volumetypes/
β βββ backups/
βββ example/
βββ widgets/
βββ widgets.go
βββ commands.go
Important
Keep the basic resource definition and listing logic in the resource file. Put commands and command execution in commands.go when the resource has commands.
Every resource implements the common o7k resource contract.
A typical resource looks like:
package widgets
import (
"context"
"github.com/akyriako/o7k/internal/openstack"
"github.com/akyriako/o7k/internal/resource"
)
type Resource struct {
context *openstack.Context
}
func New(openstackContext *openstack.Context) *Resource {
return &Resource{
context: openstackContext,
}
}
func (r *Resource) Kind() string {
return "widgets"
}
func (r *Resource) Title() string {
return "Widgets"
}
func (r *Resource) Aliases() []string {
return []string{
"widget",
"wid",
}
}Kind()is the canonical resource name used by o7k.Aliases()provides alternative names accepted by the resource command:
:widgets
:widget
:wid
Important
Choose short aliases that do not conflict with existing resources.
Each resource declares the columns displayed in the table.
For example:
func (r *Resource) Columns() []resource.Column {
return []resource.Column{
{
Key: "id",
Title: "ID",
MinWidth: 40,
Flex: 0,
},
{
Key: "name",
Title: "NAME",
MinWidth: 20,
Flex: 1,
},
{
Key: "status",
Title: "STATUS",
MinWidth: 12,
Flex: 0,
},
}
}Important
UUID/GUID columns are advised to use a fixed width of 40 and Flex of 0:
{
Key: "id",
Title: "ID",
MinWidth: 40,
Flex: 0,
}MinWidthis the minimum width of the column.Flexcontrols how additional terminal width is distributed. UseFlex: 0for columns that should remain fixed and a positive value for columns that may expand.
Tip
Do not shrink UUID columns to make a resource fit into a small terminal. o7k supports horizontal table scrolling for tables wider than the available terminal.
List() retrieves the OpenStack objects and converts them into generic resource.Row values understood by the UI.
Start by obtaining the appropriate service client from openstack.Context:
func (r *Resource) List(ctx context.Context) ([]resource.Row, error) {
client, err := r.context.ExampleV2()
if err != nil {
return nil, err
}
// Use the appropriate Gophercloud List/Extract API here.
return rows, nil
}A typical conversion from an OpenStack object to an o7k row looks like:
rows := make([]resource.Row, 0, len(items))
for _, item := range items {
rows = append(rows, resource.Row{
ID: item.ID,
Fields: map[string]string{
"id": item.ID,
"name": item.Name,
"status": item.Status,
},
})
}
return rows, nilImportant
Every Fields key used by Columns() must be populated by List().
For example, this column:
{
Key: "status",
Title: "STATUS",
MinWidth: 12,
Flex: 0,
}expects:
Fields: map[string]string{
"status": item.Status,
}Warning
- The
IDfield onresource.Rowshould contain the canonical identifier used for commands, navigation and cursor restoration. - Avoid performing an additional API request for every row just to populate a table column. If an API's list operation does not provide a value, consider leaving that information for
Showrather than introducing a complex multi-service request pattern.
Commands are optional. Only expose commands that make sense for the resource.
For example:
func (r *Resource) Commands() []resource.Command {
return []resource.Command{
{
Key: "enter",
Description: "Show",
Default: true,
},
}
}Do not automatically add Show to every resource. Show support in o7k is opt-in.
Resources can also expose navigation commands to related resources. For example:
func (r *Resource) Commands() []resource.Command {
return []resource.Command{
{
Key: "enter",
Description: "Show",
Default: true,
},
{
Key: "shift-n",
Description: "Networks",
},
}
}Caution
Keep global o7k shortcuts in mind when selecting keys.
The following keys are reserved globally:
ctrl+x Quit
: Resource command
r Refresh
c Copy
esc Dismiss / navigate back
enter Default resource command
Resource-specific command handling belongs in the resource package.
A Show command normally retrieves the complete object and returns a generic DetailsMsg struct:
return func() tea.Msg {
item, err := widgets.Get(
context.Background(),
client,
row.ID,
).Extract()
if err != nil {
return resource.DetailsMsg{
ID: row.ID,
Err: err,
}
}
return resource.DetailsMsg{
ID: row.ID,
Content: item,
}
}The UI handles resource.DetailsMsg generically and renders its JSON content in the details view.
Relationships between OpenStack resources should use o7k's generic navigation messages rather than implementing navigation directly in the UI.
To navigate directly to another resource:
return func() tea.Msg {
return resource.NavigateMsg{
Resource: "images",
ID: imageID,
}
}Use this when the target object has a known ID and the destination table should select that object.
For one-to-many relationships, use filtered navigation:
return func() tea.Msg {
return resource.NavigateFilteredMsg{
Resource: "snapshots",
Field: "volume_id",
Value: row.ID,
}
}This opens the target resource while filtering rows by the specified field.
Filtered navigation currently performs exact scalar matching:
row.Fields[field] == value
The target resource therefore needs to expose the relationship in its row fields.
For example:
Fields: map[string]string{
"id": snapshot.ID,
"name": snapshot.Name,
"volume_id": snapshot.VolumeID,
}Tip
- Do not add special-case navigation logic to the UI for a particular OpenStack resource.
- Prefer
NavigateFilteredMsgoverNavigateMsg, as it's visually more intuitive for the users.
Some OpenStack APIs cannot list a child resource without information about its parent. In these cases, use scoped navigation instead of client-side filtered navigation.
For example, listing members of an Octavia pool requires the pool ID:
return func() tea.Msg {
return resource.NavigateScopedMsg{
Resource: "members",
Scope: map[string]string{
"pool_id": row.ID,
},
}
}The destination resource retrieves the scope from the context in List():
func (r *Resource) List(ctx context.Context) ([]resource.Row, error) {
scope := resource.Scope(ctx)
poolID := scope["pool_id"]
if poolID == "" {
return nil, fmt.Errorf("member requires pool_id")
}
}Scope can contain multiple values when required by the API. Heat stack resources, for example, require both the stack name and stack ID:
Scope: map[string]string{
"stack_name": row.Fields["name"],
"stack_id": row.ID,
}Scoped navigation is also appropriate when the OpenStack API supports an optional server-side filter. In that case the resource may still support normal unscoped listing:
scope := resource.Scope(ctx)
opts := listeners.ListOpts{
LoadbalancerID: scope["loadbalancer_id"],
}With no scope, the resource lists globally. When reached through scoped navigation, the relationship is filtered by the OpenStack API.
Important
Scope belongs to the navigation state. The UI preserves it across refreshes and restores the previous scope when navigating back with Esc.
Do not encode list-valued relationships into scalar fields merely to make NavigateFilteredMsg work. Use the navigation mechanism that matches the OpenStack API and relationship.
Creating the package is not enough. The resource must be registered so o7k knows it exists.
Add the resource package to the imports in cmd/main.go.
For example:
import (
"github.com/akyriako/o7k/internal/resources/example/widgets"
)Then register it with the resource registry alongside the existing resources:
registry.Register(
widgets.New(openstackContext),
)Registration makes the canonical resource name and its aliases available through the o7k resource command.
For example:
:widgets
:widget
:wid
Keep resource registration in a logical service/resource order rather than adding new resources at arbitrary positions.
Not every resource can be listed independently. Some OpenStack APIs require parent information before a child collection
can be queried. Examples include Octavia members, which require a pool ID, and L7 rules, which require an L7 policy ID.
These resources must still be registered so generic navigation can resolve them, but they should not be exposed as
standalone :<resource> commands.
Register them with:
registry.RegisterNavigationOnly(
members.New(openstackContext),
)A navigation-only resource:
- is registered in the resource registry
- can be resolved by generic navigation
- can have canonical names and aliases
- can receive scope through NavigateScopedMsg
- cannot be opened directly through
:<resource>
For instance in Octavia, the following is valid because the selected pool supplies pool_id:
:pools -> members
but accessing members directly is not valid because there is no parent pool from which to obtain the required scope.
Use normal registration, registry.Registry, when a resource can list independently:
registry.Register(
listeners.New(openstackContext),
)A resource may support both global listing and optional scoped navigation. Octavia listeners are an example: :listeners
can list all listeners, while Load Balancer β Listeners can pass loadbalancer_id for server-side filtering. Such resources
use normal Register and not RegisterNavigationOnly.
Warning
Use RegisterNavigationOnly only when the resource fundamentally requires parent context to perform its list operation.
Do not make a resource navigation-only merely because it participates in a parent/child relationship.
Finally, update the supported-resource table in this README.
For example:
| **Example Service** | widgets | β
|Use the existing status convention:
β
Supported
Partial Partially supported
β¬ Planned / not implemented
Warning
A resource should only be marked as supported once its basic listing and intended commands work against an actual OpenStack environment.
o7k can be extended with plugins that provide resources specific to an OpenStack-based provider without adding provider-specific code to the o7k codebase.
A plugin is a standalone executable that communicates with o7k through the public pluginsdk module. A single plugin can expose multiple resources across one or more OpenStack services.
Plugins run as separate processes and therefore do not share the authenticated OpenStack client used internally by o7k. Instead, a plugin receives information about the currently active o7k context and authenticates independently using the same clouds.yaml configuration.
The examples below use a fictional provider called example and a plugin named o7k-plugin-example.
Create a separate Go module/project for the plugin and follow the same structure as the example plugin: e.g.:
o7k-plugin-example/
βββ go.mod
βββ main.go
βββ internal/
βββ plugin/
βββ client.go
βββ plugin.go
βββ resources/
βββ compute/
β βββ commands.go
β βββ servers.go
βββ blockstorage/
βββ commands.go
βββ volumes.go
Initialize the module and add the o7k plugin SDK:
go mod init example.com/o7k-plugin-example
go get github.com/akyriako/o7k/pluginsdkTip
Add the OpenStack SDK used by the provider as a dependency as well.
The plugin is an independent Go application and must not import packages from github.com/akyriako/o7k/internal/xxx.
Communication between the plugin and o7k happens over gRPC, using HashiCorp's go-plugin,
to manage the plugin subprocess and RPC connection. The public github.com/akyriako/o7k/pluginsdk module defines the
interfaces and gRPC protocol shared by o7k and the plugin(s).
The examples in /examples/plugins/demo uses two T Cloud Public (formerly known as Open Telekom Cloud) resources:
Compute -> ecs-servers
Block Storage -> ecs-volumes
Create internal/plugin/plugin.go.
The plugin owns the host connection, the authenticated provider client and the resources it exposes:
package plugin
import (
"github.com/akyriako/o7k/pluginsdk"
golangsdk "github.com/opentelekomcloud/gophertelekomcloud"
)
type Plugin struct {
host pluginsdk.Host
provider *pluginsdk.ClientProvider[*golangsdk.ProviderClient]
resources []pluginsdk.Resource
}
func New() *Plugin {
return &Plugin{}
}
func (p *Plugin) SetHost(host pluginsdk.Host) {
p.host = host
p.provider = newClient(host)
}
func (p *Plugin) Register(resources ...pluginsdk.Resource) {
p.resources = append(p.resources, resources...)
}
func (p *Plugin) Metadata() pluginsdk.Metadata {
return pluginsdk.Metadata{
Name: "example",
Version: "0.1.0",
Color: "#E20074",
}
}
func (p *Plugin) Resources() []pluginsdk.Resource {
return p.resources
}
func (p *Plugin) Host() pluginsdk.Host {
return p.host
}
func (p *Plugin) Provider() *pluginsdk.ClientProvider[*golangsdk.ProviderClient] {
return p.provider
}Metadata() identifies the plugin to o7k:
Nameis the provider/plugin name displayed by o7k.Versionis the plugin version.Coloris used by o7k when rendering resources belonging to the plugin.
Register() collects the resources exposed by the plugin. A single provider plugin can register resources from multiple OpenStack services.
SetHost() is called by o7k when the plugin process is initialized. It stores the host connection and creates the provider client manager.
Important
- Fill in the
Metadata()and leave the rest intact. - Create the
pluginsdk.ClientProvider[T], where T corresponds to the target cloud provider.
Plugins run in a separate process from o7k and therefore cannot reuse the authenticated provider client owned by o7k. Instead, the plugin authenticates independently using the active context supplied by the host.
Create internal/plugin/client.go:
package plugin
import (
"context"
"fmt"
"github.com/akyriako/o7k/pluginsdk"
golangsdk "github.com/opentelekomcloud/gophertelekomcloud"
"github.com/opentelekomcloud/gophertelekomcloud/openstack"
)
func newClient(host pluginsdk.Host) *pluginsdk.ClientProvider[*golangsdk.ProviderClient] {
return pluginsdk.NewClientProvider(host, connectClient)
}
func connectClient(_ context.Context, current pluginsdk.Context) (*golangsdk.ProviderClient, error) {
data, err := os.ReadFile(current.CloudsPath)
if err != nil {
return nil, fmt.Errorf("reading clouds.yaml %q: %w", current.CloudsPath, err)
}
var config openstack.Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parsing clouds.yaml %q: %w", current.CloudsPath, err)
}
cloud, ok := config.Clouds[current.Cloud]
if !ok {
return nil, fmt.Errorf("cloud %q not found in %q", current.Cloud, current.CloudsPath)
}
if current.Region != "" {
cloud.RegionName = current.Region
}
provider, err := openstack.AuthenticatedClientFromCloud(&cloud)
if err != nil {
return nil, fmt.Errorf("authenticating cloud %q: %w", current.Cloud, err)
}
return provider, nil
}Important
The exact authentication implementation depends on the OpenStack Golang SDK used/developed by the target cloud provider. The example above
uses Open Telekom Cloud Golang SDK. A provider plugin using another SDK should implement connectClient using that SDK's clouds.yaml and authentication support.
Note
Always use both Cloud and CloudsPath. CloudsPath identifies the exact clouds.yaml file from which o7k loaded the active cloud.
pluginsdk.ClientProvider caches the authenticated provider client for the current context generation. When the user activates another o7k context, the generation changes and the provider client is recreated automatically.
Plugin resources should, as we will see later, obtain the provider through:
provider, err := r.plugin.Provider().Client(ctx)
if err != nil {
return nil, fmt.Errorf("getting provider: %w", err)
}rather than maintaining their own authenticated provider clients.
Resources should obtain service clients from the plugin rather than creating a new service client every time List() or a command is executed.
Add a helper for each OpenStack service used by the plugin in internal/plugin/plugin.go:
func (p *Plugin) ComputeV2(ctx context.Context) (*golangsdk.ServiceClient, error) {
return pluginsdk.GetServiceClient(ctx, p.provider, "compute", func(provider *golangsdk.ProviderClient, current pluginsdk.Context) (*golangsdk.ServiceClient, error) {
return openstack.NewComputeV2(provider, golangsdk.EndpointOpts{Region: current.Region})
})
}
func (p *Plugin) BlockStorageV3(ctx context.Context) (*golangsdk.ServiceClient, error) {
return pluginsdk.GetServiceClient(ctx, p.provider, "block-storage", func(provider *golangsdk.ProviderClient, current pluginsdk.Context) (*golangsdk.ServiceClient, error) {
return openstack.NewBlockStorageV3(provider, golangsdk.EndpointOpts{Region: current.Region})
})
}Note
Use the service constructor provided by the OpenStack SDK used by the provider. A resource can then simply request the client it needs:
client, err := r.plugin.ComputeV2(ctx)
if err != nil {
return nil, fmt.Errorf("getting compute client: %w", err)
}GetServiceClient keeps service clients associated with the active o7k context generation, just like ClientProvider does for the provider client.
Resources are better organized below internal/plugin/resources/ by service.
For example:
internal/plugin/resources/
βββ compute/
β βββ commands.go
β βββ servers.go
βββ blockstorage/
βββ commands.go
βββ volumes.go
Keep the resource definition and listing logic in the resource file. Put command implementations in commands.go.
For example, create internal/plugin/resources/compute/servers.go:
package compute
import (
"context"
"fmt"
"example.com/o7k-plugin-example/internal/plugin"
)
type Resource struct {
plugin *plugin.Plugin
}
func NewExampleServers(p *plugin.Plugin) *Resource {
return &Resource{
plugin: p,
}
}
func (r *Resource) Service() string {
return "example-compute"
}
func (r *Resource) Kind() string {
return "example-servers"
}
func (r *Resource) Title() string {
return "Example Servers"
}
func (r *Resource) Aliases() []string {
return nil
}Service() identifies the provider service the resource belongs to.
Kind() is the canonical resource name used by o7k:
:example-servers
Title() is the human-readable resource name shown in the UI.
Aliases() can provide additional names accepted by the resource command:
func (r *Resource) Aliases() []string {
return []string{
"example-server",
}
}Plugin resources use pluginsdk.Column to describe their table view:
func (r *Resource) Columns() []pluginsdk.Column {
return []pluginsdk.Column{
{Key: "id", Title: "ID", MinWidth: 36},
{Key: "name", Title: "NAME", MinWidth: 24, Flex: 1},
{Key: "status", Title: "STATUS", MinWidth: 12},
}
}Each column key maps to a field returned by List().
MinWidth specifies the minimum width of the column. Flex controls how additional terminal width is distributed.
Fields do not have to be visible columns. A resource may add additional values to Row.Fields for navigation or relationships between resources.
List() obtains the service client from the plugin, retrieves the provider resources and converts them to pluginsdk.Row values:
func (r *Resource) List(ctx context.Context) ([]pluginsdk.Row, error) {
client, err := r.plugin.ComputeV2(ctx)
if err != nil {
return nil, fmt.Errorf("getting compute client: %w", err)
}
pages, err := servers.List(client, servers.ListOpts{}).AllPages()
if err != nil {
return nil, fmt.Errorf("listing servers: %w", err)
}
allServers, err := servers.ExtractServers(pages)
if err != nil {
return nil, fmt.Errorf("extracting servers: %w", err)
}
rows := make([]pluginsdk.Row, 0, len(allServers))
for _, server := range allServers {
rows = append(rows, pluginsdk.Row{
ID: server.ID,
Fields: map[string]string{
"id": server.ID,
"name": server.Name,
"status": server.Status,
},
})
}
return rows, nil
}Row.ID must contain the canonical identifier used by commands and navigation.
Every field referenced by Columns() must be present in Fields.
Additional fields may be included without defining a column. For example, a volume resource can expose the attached server ID for navigation without displaying it:
Fields: map[string]string{
"id": volume.ID,
"name": volume.Name,
"status": volume.Status,
"size": strconv.Itoa(volume.Size),
"server_id": serverID,
}Commands are declared using pluginsdk.Command:
func (r *Resource) Commands() []pluginsdk.Command {
return []pluginsdk.Command{
{Key: "s", Description: "Show", Default: true},
}
}Default: true makes the command the default action for the resource, which is executed by pressing Enter.
Command execution is handled by Execute():
func (r *Resource) Execute(ctx context.Context, command pluginsdk.Command, row pluginsdk.Row) (pluginsdk.Result, error) {
switch command.Key {
case "s":
return r.show(ctx, row.ID)
}
return pluginsdk.Result{}, nil
}Keep the implementation of individual commands in commands.go.
A Show command retrieves the complete provider object, serializes it as JSON and returns pluginsdk.Details.
For example, in internal/plugin/resources/compute/commands.go:
package compute
import (
"context"
"encoding/json"
"fmt"
"github.com/akyriako/o7k/pluginsdk"
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers"
)
func (r *Resource) show(ctx context.Context, id string) (pluginsdk.Result, error) {
client, err := r.plugin.ComputeV2(ctx)
if err != nil {
return pluginsdk.Result{}, fmt.Errorf("getting compute client: %w", err)
}
server, err := servers.Get(ctx, client, id).Extract()
if err != nil {
return pluginsdk.Result{}, fmt.Errorf("getting server %q: %w", id, err)
}
content, err := json.Marshal(server)
if err != nil {
return pluginsdk.Result{}, fmt.Errorf("encoding server %q: %w", id, err)
}
return pluginsdk.Result{
Details: &pluginsdk.Details{
ID: server.ID,
Content: content,
},
}, nil
}The JSON is transported back to o7k and rendered by the normal details view. The plugin does not implement any UI-related functionality.
Plugin resources can use pluginsdk.Navigate to participate in normal o7k resource navigation.
For example, a compute server can expose a command that opens only the volumes attached to that server:
func (r *Resource) Commands() []pluginsdk.Command {
return []pluginsdk.Command{
{Key: "s", Description: "Show", Default: true},
{Key: "shift-v", Description: "Volumes"},
}
}Handle the command in Execute():
func (r *Resource) Execute(ctx context.Context, command pluginsdk.Command, row pluginsdk.Row) (pluginsdk.Result, error) {
switch command.Key {
case "s":
return r.show(ctx, row.ID)
case "shift-v":
return r.volumes(row)
}
return pluginsdk.Result{}, nil
}The navigation command can then return:
func (r *Resource) volumes(row pluginsdk.Row) (pluginsdk.Result, error) {
return pluginsdk.Result{
Navigate: &pluginsdk.Navigate{
Resource: "example-volumes",
Field: "server_id",
Value: row.ID,
},
}, nil
}The destination resource exposes server_id in its row fields:
Fields: map[string]string{
"id": volume.ID,
"name": volume.Name,
"status": volume.Status,
"server_id": serverID,
}o7k then opens example-volumes and filters the rows using:
row.Fields["server_id"] == selectedServer.ID
pluginsdk.Navigate also supports direct navigation by ID:
Navigate: &pluginsdk.Navigate{
Resource: "example-servers",
ID: serverID,
}and scoped navigation for APIs that require parent information:
Navigate: &pluginsdk.Navigate{
Resource: "example-resource",
Scope: map[string]string{
"parent_id": row.ID,
},
}Use the navigation form that matches the relationship exposed by the provider API.
Creating a resource does not automatically expose it to o7k.
Register the resources when constructing the plugin in main.go:
package main
import (
"example.com/o7k-plugin-example/internal/plugin"
"example.com/o7k-plugin-example/internal/plugin/resources/blockstorage"
"example.com/o7k-plugin-example/internal/plugin/resources/compute"
"github.com/akyriako/o7k/pluginsdk"
)
func main() {
p := plugin.New()
p.Register(
compute.NewServers(p),
blockstorage.NewVolumes(p),
)
pluginsdk.Serve(p)
}A single call to Register() may contain resources from multiple OpenStack services.
pluginsdk.Serve() starts the plugin process protocol used by o7k. No additional RPC or transport code is required
in the provider plugin.
Build the plugin as a normal Go executable:
go build -o o7k-plugin-example .Install the locally built executable:
o7k plugin install ./o7k-plugin-exampleStart o7k and open the built-in plugin resource:
:plugins
The plugin should be listed with its name, version and loading status.
Its registered resources are then available through the normal resource command:
:example-servers
:example-volumes
If the plugin fails to initialize, inspect its error through :plugins.
When rebuilding the plugin during development, install the new executable again and restart o7k.
Provider plugins are distributed independently of o7k.
The provider project is responsible for building and publishing the plugin executable for the platforms it supports. A release asset, public object-storage URL or any other directly downloadable HTTP(S) location can be used.
Users install the published executable directly:
o7k plugin install https://example.com/releases/o7k-plugin-exampleThe downloaded executable is copied into the o7k plugin directory and is loaded the next time o7k starts.
To remove an installed plugin:
o7k plugin remove o7k-plugin-exampleRestart o7k after removing the plugin.
Caution
Plugins are executable programs running on the user's machine. Only install plugins from sources you trust.