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
8 changes: 8 additions & 0 deletions src/internal/app/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import (
)

func (m Model) connectToCloud(name string) tea.Cmd {
shared.Debugf("[app] connectToCloud: start cloud=%s", name)
return func() tea.Msg {
client, err := cloud.Connect(context.Background(), name)
if err != nil {
shared.Debugf("[app] connectToCloud: error: %v", err)
return shared.CloudConnectErrMsg{Err: err}
}
shared.Debugf("[app] connectToCloud: success cloud=%s", name)
return shared.CloudConnectedMsg{
ComputeClient: client.Compute,
ImageClient: client.Image,
Expand All @@ -30,6 +33,11 @@ func (m Model) connectToCloud(name string) tea.Cmd {

func (m Model) switchToCloudPicker() (Model, tea.Cmd) {
clouds, err := cloud.ListCloudNames()
if err != nil {
shared.Debugf("[app] switchToCloudPicker: error listing clouds: %v", err)
} else {
shared.Debugf("[app] switchToCloudPicker: found %d clouds", len(clouds))
}
m.cloudPicker = cloudpicker.New(clouds, err)
m.cloudPicker.SetSize(m.width, m.height)
m.view = viewCloudPicker
Expand Down
4 changes: 4 additions & 0 deletions src/internal/app/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ func (m Model) updateModal(msg tea.Msg) (Model, tea.Cmd) {
}

func (m Model) handleDetailNavigation(msg shared.NavigateToDetailMsg) (Model, tea.Cmd) {
shared.Debugf("[app] handleDetailNavigation: resource=%s id=%s", msg.Resource, msg.ID)
switch msg.Resource {
case "volume":
m.volumeDetail = volumedetail.New(m.client.BlockStorage, m.client.Compute, msg.ID)
Expand All @@ -191,6 +192,7 @@ func (m Model) handleDetailNavigation(msg shared.NavigateToDetailMsg) (Model, te
}

func (m Model) handleResourceNavigation(msg shared.NavigateToResourceMsg) (Model, tea.Cmd) {
shared.Debugf("[app] handleResourceNavigation: tab=%s", msg.Tab)
// Find the target tab index
tabIdx := -1
for i, td := range m.tabs {
Expand Down Expand Up @@ -220,6 +222,7 @@ func (m Model) handleResourceNavigation(msg shared.NavigateToResourceMsg) (Model
}

func (m Model) handleViewChange(msg shared.ViewChangeMsg) (Model, tea.Cmd) {
shared.Debugf("[app] handleViewChange: target=%s", msg.View)
switch msg.View {
case "serverlist":
// If returning from a cross-resource jump, go back to the originating view
Expand Down Expand Up @@ -342,6 +345,7 @@ func (m Model) handleViewChange(msg shared.ViewChangeMsg) (Model, tea.Cmd) {
}

func (m Model) forceRefreshActiveView() (Model, tea.Cmd) {
shared.Debugf("[app] forceRefreshActiveView: view=%d", m.view)
switch m.view {
case viewServerList:
return m, m.serverList.ForceRefresh()
Expand Down
23 changes: 23 additions & 0 deletions src/internal/cloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"crypto/tls"
"fmt"

"github.com/larkly/lazystack/internal/shared"

"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack"
"github.com/gophercloud/gophercloud/v2/openstack/config"
Expand All @@ -26,17 +28,21 @@ type Client struct {

// Connect authenticates to the given cloud and initializes service clients.
func Connect(ctx context.Context, cloudName string) (*Client, error) {
shared.Debugf("[cloud] Connect: starting, cloud=%s", cloudName)
ao, eo, tlsConfig, err := clouds.Parse(clouds.WithCloudName(cloudName), clouds.WithLocations(CloudsYamlPaths()...))
if err != nil {
shared.Debugf("[cloud] Connect: error parsing cloud config: %v", err)
return nil, fmt.Errorf("parsing cloud %q: %w", cloudName, err)
}
return connectWithOpts(ctx, ao, eo, tlsConfig, cloudName)
}

// ConnectWithProject authenticates scoped to a specific project.
func ConnectWithProject(ctx context.Context, cloudName, projectID string) (*Client, error) {
shared.Debugf("[cloud] ConnectWithProject: starting, cloud=%s projectID=%s", cloudName, projectID)
ao, eo, tlsConfig, err := clouds.Parse(clouds.WithCloudName(cloudName), clouds.WithLocations(CloudsYamlPaths()...))
if err != nil {
shared.Debugf("[cloud] ConnectWithProject: error parsing cloud config: %v", err)
return nil, fmt.Errorf("parsing cloud %q: %w", cloudName, err)
}
ao.TenantID = projectID
Expand All @@ -45,39 +51,56 @@ func ConnectWithProject(ctx context.Context, cloudName, projectID string) (*Clie
}

func connectWithOpts(ctx context.Context, ao gophercloud.AuthOptions, eo gophercloud.EndpointOpts, tlsConfig *tls.Config, cloudName string) (*Client, error) {
shared.Debugf("[cloud] connectWithOpts: authenticating to %s", cloudName)
providerClient, err := config.NewProviderClient(ctx, ao, config.WithTLSConfig(tlsConfig))
if err != nil {
shared.Debugf("[cloud] connectWithOpts: authentication error: %v", err)
return nil, fmt.Errorf("authenticating to %q: %w", cloudName, err)
}

shared.Debugf("[cloud] connectWithOpts: creating compute client")
compute, err := openstack.NewComputeV2(providerClient, eo)
if err != nil {
shared.Debugf("[cloud] connectWithOpts: compute client error: %v", err)
return nil, fmt.Errorf("compute client: %w", err)
}
compute.Microversion = "2.100"

shared.Debugf("[cloud] connectWithOpts: creating image client")
image, err := openstack.NewImageV2(providerClient, eo)
if err != nil {
shared.Debugf("[cloud] connectWithOpts: image client error: %v", err)
return nil, fmt.Errorf("image client: %w", err)
}

shared.Debugf("[cloud] connectWithOpts: creating network client")
network, err := openstack.NewNetworkV2(providerClient, eo)
if err != nil {
shared.Debugf("[cloud] connectWithOpts: network client error: %v", err)
return nil, fmt.Errorf("network client: %w", err)
}

// BlockStorage — try v3 first ("block-storage"), then v2, then v1 ("volume")
// Different clouds register Cinder under different service types
shared.Debugf("[cloud] connectWithOpts: creating block storage client")
blockStorage := tryBlockStorage(providerClient, eo)
if blockStorage == nil {
shared.Debugf("[cloud] connectWithOpts: block storage client unavailable")
}

// LoadBalancer (Octavia) — optional service
shared.Debugf("[cloud] connectWithOpts: creating load balancer client")
loadBalancer := tryLoadBalancer(providerClient, eo)
if loadBalancer == nil {
shared.Debugf("[cloud] connectWithOpts: load balancer client unavailable")
}

region := eo.Region
if region == "" {
region = "default"
}

shared.Debugf("[cloud] connectWithOpts: success, cloud=%s region=%s", cloudName, region)
return &Client{
CloudName: cloudName,
Region: region,
Expand Down
6 changes: 6 additions & 0 deletions src/internal/cloud/clouds.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"path/filepath"
"sort"

"github.com/larkly/lazystack/internal/shared"

"gopkg.in/yaml.v3"
)

Expand All @@ -16,6 +18,7 @@ type cloudsFile struct {

// ListCloudNames parses clouds.yaml and returns sorted cloud names.
func ListCloudNames() ([]string, error) {
shared.Debugf("[cloud] ListCloudNames: starting")
paths := CloudsYamlPaths()

for _, p := range paths {
Expand All @@ -26,6 +29,7 @@ func ListCloudNames() ([]string, error) {

var cf cloudsFile
if err := yaml.Unmarshal(data, &cf); err != nil {
shared.Debugf("[cloud] ListCloudNames: error parsing %s: %v", p, err)
return nil, fmt.Errorf("parsing %s: %w", p, err)
}

Expand All @@ -34,9 +38,11 @@ func ListCloudNames() ([]string, error) {
names = append(names, name)
}
sort.Strings(names)
shared.Debugf("[cloud] ListCloudNames: success, count=%d from=%s", len(names), p)
return names, nil
}

shared.Debugf("[cloud] ListCloudNames: no clouds.yaml found")
return nil, fmt.Errorf("no clouds.yaml found (searched: %v)", paths)
}

Expand Down
6 changes: 6 additions & 0 deletions src/internal/cloud/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"fmt"

"github.com/larkly/lazystack/internal/shared"

"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack"
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects"
Expand All @@ -18,8 +20,10 @@ type Project struct {

// ListAccessibleProjects returns projects the current user can access.
func ListAccessibleProjects(ctx context.Context, providerClient *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) ([]Project, error) {
shared.Debugf("[cloud] ListAccessibleProjects: starting")
identityClient, err := openstack.NewIdentityV3(providerClient, eo)
if err != nil {
shared.Debugf("[cloud] ListAccessibleProjects: identity client error: %v", err)
return nil, fmt.Errorf("identity client: %w", err)
}

Expand All @@ -37,7 +41,9 @@ func ListAccessibleProjects(ctx context.Context, providerClient *gophercloud.Pro
return true, nil
})
if err != nil {
shared.Debugf("[cloud] ListAccessibleProjects: error: %v", err)
return nil, fmt.Errorf("listing projects: %w", err)
}
shared.Debugf("[cloud] ListAccessibleProjects: success, count=%d", len(result))
return result, nil
}
4 changes: 4 additions & 0 deletions src/internal/compute/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/instanceactions"
"github.com/gophercloud/gophercloud/v2/pagination"
"github.com/larkly/lazystack/internal/shared"
)

// Action is a simplified instance action.
Expand All @@ -21,6 +22,7 @@ type Action struct {

// ListActions fetches instance actions for a server.
func ListActions(ctx context.Context, client *gophercloud.ServiceClient, serverID string) ([]Action, error) {
shared.Debugf("[compute] listing actions for server %s", serverID)
var result []Action
err := instanceactions.List(client, serverID, nil).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
extracted, err := instanceactions.ExtractInstanceActions(page)
Expand All @@ -39,7 +41,9 @@ func ListActions(ctx context.Context, client *gophercloud.ServiceClient, serverI
return true, nil
})
if err != nil {
shared.Debugf("[compute] list actions for server %s: %v", serverID, err)
return nil, fmt.Errorf("listing actions for %s: %w", serverID, err)
}
shared.Debugf("[compute] listed %d actions for server %s", len(result), serverID)
return result, nil
}
4 changes: 4 additions & 0 deletions src/internal/compute/flavors.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors"
"github.com/gophercloud/gophercloud/v2/pagination"
"github.com/larkly/lazystack/internal/shared"
)

// Flavor is a simplified representation of a Nova flavor.
Expand All @@ -20,6 +21,7 @@ type Flavor struct {

// ListFlavors fetches all available flavors.
func ListFlavors(ctx context.Context, client *gophercloud.ServiceClient) ([]Flavor, error) {
shared.Debugf("[compute] listing flavors")
opts := flavors.ListOpts{}

var result []Flavor
Expand All @@ -40,7 +42,9 @@ func ListFlavors(ctx context.Context, client *gophercloud.ServiceClient) ([]Flav
return true, nil
})
if err != nil {
shared.Debugf("[compute] list flavors: %v", err)
return nil, fmt.Errorf("listing flavors: %w", err)
}
shared.Debugf("[compute] listed %d flavors", len(result))
return result, nil
}
21 changes: 21 additions & 0 deletions src/internal/compute/keypairs.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/keypairs"
"github.com/gophercloud/gophercloud/v2/pagination"
"github.com/larkly/lazystack/internal/shared"
"golang.org/x/crypto/ssh"
)

Expand All @@ -23,6 +24,7 @@ type KeyPair struct {

// ListKeyPairs fetches all keypairs.
func ListKeyPairs(ctx context.Context, client *gophercloud.ServiceClient) ([]KeyPair, error) {
shared.Debugf("[compute] listing keypairs")
var result []KeyPair
err := keypairs.List(client, keypairs.ListOpts{}).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
extracted, err := keypairs.ExtractKeyPairs(page)
Expand All @@ -38,8 +40,10 @@ func ListKeyPairs(ctx context.Context, client *gophercloud.ServiceClient) ([]Key
return true, nil
})
if err != nil {
shared.Debugf("[compute] list keypairs: %v", err)
return nil, fmt.Errorf("listing keypairs: %w", err)
}
shared.Debugf("[compute] listed %d keypairs", len(result))
return result, nil
}

Expand All @@ -54,23 +58,27 @@ type KeyPairFull struct {
// GenerateAndImportKeyPair generates a keypair locally and imports the public key.
// algorithm is "rsa" or "ed25519". keySize is only used for RSA (e.g. 2048, 4096).
func GenerateAndImportKeyPair(ctx context.Context, client *gophercloud.ServiceClient, name, algorithm string, keySize int) (*KeyPairFull, error) {
shared.Debugf("[compute] generating and importing keypair %q (algorithm=%s)", name, algorithm)
var pubKeyBytes []byte
var privKeyPEM string

switch algorithm {
case "ed25519":
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
shared.Debugf("[compute] generate ed25519 key %q: %v", name, err)
return nil, fmt.Errorf("generating ed25519 key: %w", err)
}
sshPub, err := ssh.NewPublicKey(pub)
if err != nil {
shared.Debugf("[compute] convert ed25519 public key %q: %v", name, err)
return nil, fmt.Errorf("converting ed25519 public key: %w", err)
}
pubKeyBytes = ssh.MarshalAuthorizedKey(sshPub)

privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
shared.Debugf("[compute] marshal ed25519 private key %q: %v", name, err)
return nil, fmt.Errorf("marshaling ed25519 private key: %w", err)
}
privKeyPEM = string(pem.EncodeToMemory(&pem.Block{
Expand All @@ -84,10 +92,12 @@ func GenerateAndImportKeyPair(ctx context.Context, client *gophercloud.ServiceCl
}
privKey, err := rsa.GenerateKey(rand.Reader, keySize)
if err != nil {
shared.Debugf("[compute] generate rsa key %q (%d bits): %v", name, keySize, err)
return nil, fmt.Errorf("generating rsa key (%d bits): %w", keySize, err)
}
sshPub, err := ssh.NewPublicKey(&privKey.PublicKey)
if err != nil {
shared.Debugf("[compute] convert rsa public key %q: %v", name, err)
return nil, fmt.Errorf("converting rsa public key: %w", err)
}
pubKeyBytes = ssh.MarshalAuthorizedKey(sshPub)
Expand All @@ -103,22 +113,27 @@ func GenerateAndImportKeyPair(ctx context.Context, client *gophercloud.ServiceCl
// Import via Nova
kp, err := ImportKeyPair(ctx, client, name, pubKeyStr)
if err != nil {
shared.Debugf("[compute] generate and import keypair %q: %v", name, err)
return nil, err
}
shared.Debugf("[compute] generated and imported keypair %q", name)
kp.PrivateKey = privKeyPEM
return kp, nil
}

// ImportKeyPair imports an existing public key.
func ImportKeyPair(ctx context.Context, client *gophercloud.ServiceClient, name, publicKey string) (*KeyPairFull, error) {
shared.Debugf("[compute] importing keypair %q", name)
opts := keypairs.CreateOpts{
Name: name,
PublicKey: publicKey,
}
kp, err := keypairs.Create(ctx, client, opts).Extract()
if err != nil {
shared.Debugf("[compute] import keypair %q: %v", name, err)
return nil, fmt.Errorf("importing keypair %s: %w", name, err)
}
shared.Debugf("[compute] imported keypair %q", name)
return &KeyPairFull{
Name: kp.Name,
Type: kp.Type,
Expand All @@ -128,10 +143,13 @@ func ImportKeyPair(ctx context.Context, client *gophercloud.ServiceClient, name,

// GetKeyPair fetches a single keypair by name.
func GetKeyPair(ctx context.Context, client *gophercloud.ServiceClient, name string) (*KeyPairFull, error) {
shared.Debugf("[compute] getting keypair %q", name)
kp, err := keypairs.Get(ctx, client, name, keypairs.GetOpts{}).Extract()
if err != nil {
shared.Debugf("[compute] get keypair %q: %v", name, err)
return nil, fmt.Errorf("getting keypair %s: %w", name, err)
}
shared.Debugf("[compute] got keypair %q", name)
return &KeyPairFull{
Name: kp.Name,
Type: kp.Type,
Expand All @@ -141,9 +159,12 @@ func GetKeyPair(ctx context.Context, client *gophercloud.ServiceClient, name str

// DeleteKeyPair deletes a keypair by name.
func DeleteKeyPair(ctx context.Context, client *gophercloud.ServiceClient, name string) error {
shared.Debugf("[compute] deleting keypair %q", name)
r := keypairs.Delete(ctx, client, name, keypairs.DeleteOpts{})
if r.Err != nil {
shared.Debugf("[compute] delete keypair %q: %v", name, r.Err)
return fmt.Errorf("deleting keypair %s: %w", name, r.Err)
}
shared.Debugf("[compute] deleted keypair %q", name)
return nil
}
Loading
Loading