package image
import (
imageFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/compute/image"
"github.com/spf13/cobra"
)
// Dedicated documentation for the get command
var getLong = `
Get images in the specified compartment with pagination support.
This command retrieves available images in the current compartment.
By default, it shows basic image information such as name, ID, operating system, and launch mode.
The output is paginated, with a default limit of 20 images per page. You can navigate
through pages using the --page flag and control the number of images per page with
the --limit flag.
Additional Information:
- Use --json (-j) to output the results in JSON format
- The command shows all available images in the compartment
`
var getExamples = `
# Get images with default pagination (20 per page)
ocloud compute image get
# Get images with custom pagination (10 per page, page 2)
ocloud compute image get --limit 10 --page 2
# Get images and output in JSON format
ocloud compute image get --json
# Get images with custom pagination and JSON output
ocloud compute image get --limit 5 --page 3 --json
`
// NewGetCmd creates a new command for listing images
func NewGetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Paginated Image Results",
Long: getLong,
Example: getExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runGetCommand(cmd, appCtx)
},
}
imageFlags.LimitFlag.Add(cmd)
imageFlags.PageFlag.Add(cmd)
return cmd
}
// runGetCommand handles the execution of the list command
func runGetCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, imageFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, imageFlags.FlagDefaultPage)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running image list command in", "compartment", appCtx.CompartmentName, "limit", limit, "page", page, "json", useJSON)
return image.GetImages(appCtx, limit, page, useJSON)
}
package image
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/compute/image"
"github.com/spf13/cobra"
)
// Dedicated documentation for the list command (separate from get)
var listLong = `
Interactively browse and search images in the specified compartment using a TUI.
This command launches terminal UI that loads available images and lets you:
- Search/filter image as you type
- Navigate the list
- Select a single image to view its details
After you pick an image, the tool prints detailed information about the selected image default table view or JSON format if specified with --json.
`
var listExamples = `
# Launch the interactive images browser
ocloud compute image list
ocloud compute image list --json
`
// NewListCmd creates a new command for listing images
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List all images",
Aliases: []string{"l"},
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runListCommand(cmd, appCtx)
},
}
return cmd
}
// runListCommand executes the interactive TUI image lister
func runListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
ctx := cmd.Context()
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running image list (TUI) command in", "compartment", appCtx.CompartmentName)
return image.ListImages(ctx, appCtx, useJSON)
}
package image
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewImageCmd creates a new command for image-related operations
func NewImageCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "image",
Aliases: []string{"img"},
Short: "Manage OCI Compute images — list, get, and search",
Long: "List OCI Compute images in a compartment. Supports paging through large result sets and fuzzy search",
Example: " ocloud compute image get\n ocloud compute image list\n ocloud compute image search <value>",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewSearchCmd(appCtx))
return cmd
}
package image
import (
"github.com/cnopslabs/ocloud/internal/app"
cfgflags "github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/compute/image"
"github.com/spf13/cobra"
)
var searchLong = `
Search for images in the specified compartment that match the given pattern.
The search uses a fuzzy, prefix, and substring matching algorithm across many indexed fields.
You can search using any of the following fields (partial matches are supported):
Searchable fields:
- OCID: Image OCID
- Name: Display name of the image
- OSVersion: Operating system version
- LaunchMode: Launch mode of the image
- OperatingSystem: Operating system of the image
The search pattern is case-insensitive. For very specific inputs (like full OCID),
the search first tries exact and substring matches; otherwise it falls back to broader fuzzy search.
`
var searchExamples = `
# Search by display name (substring)
ocloud compute image search ubuntu
# Search by OS
ocloud compute image search "Oracle-Linux"
# Search by OS version
ocloud compute image search 8.10
# Show more details in the output
ocloud compute image search api --all
# Output in JSON format
ocloud compute image search server --json
`
// NewSearchCmd creates a new command for finding images by name pattern
func NewSearchCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "search [pattern]",
Aliases: []string{"s"},
Short: "Fuzzy search for Images",
Long: searchLong,
Example: searchExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSearchCommand(cmd, args, appCtx)
},
}
return cmd
}
// runSearchCommand handles the execution of the search command
func runSearchCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
namePattern := args[0]
useJSON := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running image search command", "pattern", namePattern, "in compartment", appCtx.CompartmentName, "json", useJSON)
return image.SearchImages(appCtx, namePattern, useJSON)
}
package instance
import (
instaceFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/compute/instance"
"github.com/spf13/cobra"
)
var getLong = `
Get all instances in the specified compartment with pagination support.
This command displays information about running instances in the current compartment.
By default, it shows basic instance information such as name, ID, IP address, and shape.
The output is paginated, with a default limit of 20 instances per page. You can navigate
through pages using the --page flag and control the number of instances per page with
the --limit flag.
Additional Information:
- Use --all (-A) to include detailed information about the instance
- Use --json (-j) to output the results in JSON format
- The command only shows running instances by default
`
var getExamples = `
# Get all instances with default pagination (20 per page)
ocloud compute instance get
# Get instances with custom pagination (10 per page, page 2)
ocloud compute instance get --limit 10 --page 2
# Get instances and include instance details
ocloud compute instance get --all
# Get instances with instance details (using shorthand flag)
ocloud compute instance get -A
# Get instances and output in JSON format
ocloud compute instance get --json
# Get instances with both instance details and JSON output
ocloud compute instance get --all --json
`
// NewGetCmd creates a new command for listing instances
func NewGetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Paginated Instance Results",
Long: getLong,
Example: getExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runGetCommand(cmd, appCtx)
},
}
instaceFlags.LimitFlag.Add(cmd)
instaceFlags.PageFlag.Add(cmd)
instaceFlags.AllInfoFlag.Add(cmd)
return cmd
}
// runGetCommand handles the execution of the list command
func runGetCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, instaceFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, instaceFlags.FlagDefaultPage)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
imageDetails := flags.GetBoolFlag(cmd, flags.FlagNameAll, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running instance get command in", "compartment", appCtx.CompartmentName, "limit", limit, "page", page, "json", useJSON, "imageDetails", imageDetails)
return instance.GetInstances(appCtx, useJSON, limit, page, imageDetails)
}
package instance
import (
instaceFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/compute/instance"
"github.com/spf13/cobra"
)
// Dedicated documentation for the list command (separate from get)
var listLong = `
Interactively browse and search instances in the specified compartment using a TUI.
This command launches terminal UI that loads available instances and lets you:
- Search/filter instance as you type
- Navigate the list
- Select a single instance to view its details
After you pick an instance, the tool prints detailed information about the selected instance default table view or JSON format if specified with --json.
`
var listExamples = `
# Launch the interactive instance browser
ocloud compute instance list
ocloud compute instance list --json
`
// NewListCmd creates a new command for listing instances
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "List all Instances",
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return RunListCommand(cmd, appCtx)
},
}
instaceFlags.AllInfoFlag.Add(cmd)
return cmd
}
// RunListCommand handles the execution of the list command
func RunListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running instance list command in", "compartment", appCtx.CompartmentName, useJSON)
return instance.ListInstances(appCtx, useJSON)
}
package instance
import (
"github.com/spf13/cobra"
"github.com/cnopslabs/ocloud/internal/app"
)
// NewInstanceCmd creates a new command for instance-related operations
func NewInstanceCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "instance",
Aliases: []string{"inst"},
Short: "Manage OCI Compute instances — list, get, and search.",
Long: "List OCI Compute instances in a compartment. Supports paging through large result sets and fuzzy search",
Example: " ocloud compute instance get\n ocloud compute instance list\n ocloud compute instance search <value>",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewSearchCmd(appCtx))
cmd.AddCommand(NewListCmd(appCtx))
return cmd
}
package instance
import (
instaceFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/compute/instance"
"github.com/spf13/cobra"
)
var searchLong = `
Search for instances in the specified compartment that match the given pattern.
The search uses a fuzzy, prefix, and substring matching algorithm across many indexed fields.
You can search using any of the following fields (partial matches are supported):
Searchable fields:
- Name: Display name of the instance
- Hostname: Instance hostname
- ImageName: Name of the image used by the instance
- ImageOS: Operating system of the image
- Shape: Instance shape
- PrimaryIP: Primary private IP address
- OCID: Instance OCID
- VcnName: Name of the VCN the instance is attached to
- SubnetName: Name of the subnet the instance is attached to
- FD: Fault domain
- AD: Availability domain
- TagsKV: All tags in key=value form, flattened
- TagsVal: Only tag values (e.g., "8.10")
The search pattern is case-insensitive. For very specific inputs (like full OCID, IP, or exact hostname),
the search first tries exact and substring matches; otherwise it falls back to broader fuzzy search.
`
var searchExamples = `
# Search by display name (substring)
ocloud compute instance search web
# Search by hostname (substring)
ocloud compute instance search host123
# Search by image name or OS
ocloud compute instance search oracle
ocloud compute instance search "Oracle-Linux"
# Search by shape
ocloud compute instance search VM.Standard3
# Search by primary IP (exact or partial)
ocloud compute instance search 10.0.1.15
ocloud compute instance search 10.0.1.
# Search by OCID (exact)
ocloud compute instance search ocid1.instance.oc1..aaaa...
# Search by VCN or Subnet names
ocloud compute instance search my-vcn
ocloud compute instance search app-subnet
# Search by Availability/Fault Domain
ocloud compute instance search AD-1
ocloud compute instance search FD-2
# Search by tag value only (TagsVal)
ocloud compute instance search 8.10
# Show more details in the output
ocloud compute instance search api --all
# Output in JSON format
ocloud compute instance search server --json
`
// NewSearchCmd creates a new command for finding instances by name pattern
func NewSearchCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "search [pattern]",
Aliases: []string{"s"},
Short: "Fuzzy Search for Instances",
Long: searchLong,
Example: searchExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSearchCommand(cmd, args, appCtx)
},
}
instaceFlags.AllInfoFlag.Add(cmd)
return cmd
}
// RunSearchCommand handles the execution of the find command
func runSearchCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
search := args[0]
showDetails := flags.GetBoolFlag(cmd, flags.FlagNameAll, false)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running instance search command", "search", search, "in compartment", appCtx.CompartmentName, "json", useJSON)
return instance.SearchInstances(appCtx, search, useJSON, showDetails)
}
package oke
import (
paginationFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/compute/oke"
"github.com/spf13/cobra"
)
var getLong = `
Get all Oracle Kubernetes Engine (OKE) clusters in the specified compartment.
This command displays information about all OKE clusters in the current compartment,
including their names, Kubernetes versions, endpoints, and associated node pools.
By default, it shows basic cluster information in a tabular format.
Additional Information:
- Use --json (-j) to output the results in JSON format
- Use --limit (-m) to control the number of results per page
- Use --page (-p) to navigate between pages of results
`
var getExamples = `
# Get all OKE clusters in the current compartment
ocloud compute oke get
# Get all OKE clusters and output in JSON format
ocloud compute oke get --json
# Get OKE clusters with pagination (10 per page, page 2)
ocloud compute oke get --limit 10 --page 2
`
// NewGetCmd creates a new cobra.Command for listing all OKE clusters in a specified compartment.
// The command supports pagination through the --limit and --page flags for controlling list size and navigation.
// The command supports JSON output through the --JSON flag.
func NewGetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Get all Oracle Kubernetes Engine (OKE) clusters",
Long: getLong,
Example: getExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return RunGetCommand(cmd, appCtx)
},
}
paginationFlags.LimitFlag.Add(cmd)
paginationFlags.PageFlag.Add(cmd)
return cmd
}
// RunGetCommand handles the execution of the list command
func RunGetCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, paginationFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, paginationFlags.FlagDefaultPage)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running oke get command in", "compartment", appCtx.CompartmentName, "json", useJSON)
return oke.GetClusters(appCtx, useJSON, limit, page)
}
package oke
import (
paginationFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/compute/oke"
"github.com/spf13/cobra"
)
// Dedicated documentation for the list command (separate from get)
var listLong = `
Interactively browse and search oke cluster in the specified compartment using a TUI.
This command launches terminal UI that loads available oke cluster and lets you:
- Search/filter oke cluster as you type
- Navigate the list
- Select a single oke cluster to view its details
After you pick an oke cluster, the tool prints detailed information about the selected oke cluster default table view or JSON format if specified with --json.
`
var listExamples = `
# Launch the interactive oke cluster browser
ocloud compute oke list
ocloud compute oke list --json
`
// NewListCmd creates a new command for listing OKE clusters
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "List all Oracle Kubernetes Engine (OKE) clusters",
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return RunListCommand(cmd, appCtx)
},
}
paginationFlags.LimitFlag.Add(cmd)
paginationFlags.PageFlag.Add(cmd)
return cmd
}
// RunListCommand handles the execution of the list command
func RunListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running oke list command in", "compartment", appCtx.CompartmentName, "json", useJSON)
return oke.ListClusters(appCtx, useJSON)
}
package oke
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewOKECmd creates a new command for OKE-related operations
func NewOKECmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "oke",
Short: "Manage OCI Kubernetes Engine (OKE)",
Long: "Manage Oracle Cloud Infrastructure Kubernetes Engine (OKE) clusters and node pools.\nThis command allows you to list all clusters in a compartment or search specific clusters by search pattern. For each cluster, you can view detailed information including Kubernetes version, endpoint, and associated node pools.",
Example: " ocloud compute oke list\n ocloud compute oke list --json\n ocloud compute oke get\n ocloud compute oke get --json\n ocloud compute oke search myoke\n ocloud compute oke search myoke --json",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewSearchCmd(appCtx))
cmd.AddCommand(NewListCmd(appCtx))
return cmd
}
package oke
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/compute/oke"
"github.com/spf13/cobra"
)
var searchLong = `
FuzzySearch Oracle Kubernetes Engine (OKE) clusters in the specified compartment that match the given pattern.
This command searches across both cluster-level and node-pool attributes. It matches on:
- Cluster: name, OCID, Kubernetes version, state, VCN OCID, private/public endpoints, and tags
- Node pools: display names and node shapes (aggregated per cluster)
By default, it shows detailed cluster information such as name, ID, Kubernetes version,
endpoints, and associated node pools for all matching clusters.
The search is performed using fuzzy matching, which means it will find clusters
even if the pattern is only partially matched. The search is case-insensitive.
Additional Information:
- Use --json (-j) to output the results in JSON format
- The command searches across all available clusters in the compartment
`
var searchExamples = `
# Fuzzy search clusters with names containing "prod"
ocloud compute oke search prod
# Fuzzy search clusters with names containing "dev" and output in JSON format
ocloud compute oke search dev --json
# Fuzzy search clusters by node pool shape
ocloud compute oke search "VM.Standard3"
# Fuzzy search clusters by OCID fragment or exact OCID
ocloud compute oke search ocid1.clusters
ocloud compute oke search ocid1.cluster.oc1..exampleexactocid
# Fuzzy search clusters with tags (key or value)
ocloud compute oke search team:platform
ocloud compute oke search platform
`
// NewSearchCmd creates a new command for searching OKE clusters.
func NewSearchCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "search [pattern]",
Aliases: []string{"s"},
Short: "Fuzzy Search for OKE clusters",
Long: searchLong,
Example: searchExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSearchCommand(cmd, args, appCtx)
},
}
return cmd
}
// RunFindCommand handles the execution of the search command
func runSearchCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
search := args[0]
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running oke search command", "search", search, "in compartment", appCtx.CompartmentName, "json", useJSON)
return oke.SearchOKEClusters(appCtx, search, useJSON)
}
package compute
import (
"github.com/cnopslabs/ocloud/cmd/compute/image"
"github.com/cnopslabs/ocloud/cmd/compute/instance"
"github.com/cnopslabs/ocloud/cmd/compute/oke"
"github.com/spf13/cobra"
"github.com/cnopslabs/ocloud/internal/app"
)
// NewComputeCmd creates a new command for compute-related operations
func NewComputeCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "compute",
Aliases: []string{"comp"},
Short: "Manage OCI compute services",
Long: "Manage Oracle Cloud Infrastructure Compute services such as instances, images, and oke.",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(instance.NewInstanceCmd(appCtx))
cmd.AddCommand(image.NewImageCmd(appCtx))
cmd.AddCommand(oke.NewOKECmd(appCtx))
return cmd
}
package auth
import (
configurationFlags "github.com/cnopslabs/ocloud/cmd/configuration/flags"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/configuration/auth"
"github.com/spf13/cobra"
)
// Short description for the authenticate command
var authenticateShort = "Authenticate with OCI and refresh session tokens"
// Long description for the authenticate command
var authenticateLong = `Interactively guides you through the authentication process with OCI.
Allows you to select your desired profile and region.
You can use --filter to filter regions by prefix and --realm to filter by realm.
If a tenancy-mapping file is present, the --realm flag will also filter tenancy mappings by the specified realm.`
// Examples for the authenticate command
var authenticateExamples = ` ocloud config session authenticate
ocloud config session authenticate --filter us
ocloud config session authenticate --realm OC1
ocloud config session auth -f us -r OC2`
// NewAuthenticateCmd creates a new cobra.Command for authenticating with OCI.
func NewAuthenticateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "authenticate",
Aliases: []string{"auth", "a"},
Short: authenticateShort,
Long: authenticateLong,
Example: authenticateExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runAuthenticateCommand(cmd)
},
}
configurationFlags.FilterFlag.Add(cmd)
configurationFlags.RealmFlag.Add(cmd)
return cmd
}
func runAuthenticateCommand(cmd *cobra.Command) error {
filter := flags.GetStringFlag(cmd, flags.FlagNameFilter, "")
realm := flags.GetStringFlag(cmd, flags.FlagNameRealm, "")
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running authenticate command", "filter", filter, "realm", realm)
return auth.AuthenticateWithOCI(filter, realm)
}
package auth
import (
"github.com/spf13/cobra"
)
// Short description for the session command
var sessionShort = "Authenticate with OCI and refresh session tokens"
// Long description for the session command
var sessionLong = `Provides commands for authenticating with Oracle Cloud Infrastructure (OCI).
This command group includes subcommands for authenticating with OCI and refreshing session tokens.
It allows you to interactively select your desired profile and region for authentication.`
// Examples for the session command
var sessionExamples = ` ocloud config session authenticate
ocloud config s authenticate --filter us
ocloud config s auth --realm OC1
ocloud config s a -f us -r OC2`
// NewSessionCmd creates a new cobra.Command for the session command group.
func NewSessionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "session",
Aliases: []string{"s"},
Short: sessionShort,
Long: sessionLong,
Example: sessionExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
// If no subcommand is specified, run the authenticate command
return NewAuthenticateCmd().RunE(cmd, args)
},
}
cmd.AddCommand(NewAuthenticateCmd())
return cmd
}
package info
import (
"github.com/spf13/cobra"
)
// Short description for the info command
var infoShort = "View information about ocloud environment configuration"
// Long description for the info command
var infoLong = `View information about ocloud environment configuration, such as tenancy mappings and other configuration details.
This command provides access to information about your ocloud environment, including tenancy mappings,
which allow you to associate tenancy names with their OCIDs and other metadata.`
// Examples for the info command
var infoExamples = ` ocloud config info map-file
ocloud config i map-file --json
ocloud config i map-file --realm OC1`
// NewInfoCmd creates a new cobra.Command for viewing information about ocloud environment configuration.
// It provides subcommands for viewing tenancy mapping information and other configuration details.
func NewInfoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "info",
Aliases: []string{"i"},
Short: infoShort,
Long: infoLong,
Example: infoExamples,
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(ViewMappingFile())
return cmd
}
package info
import (
configurationFlags "github.com/cnopslabs/ocloud/cmd/configuration/flags"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/configuration/info"
"github.com/spf13/cobra"
)
// Long description for the map-file command
var mapFileLong = `
View the tenancy mapping information from the tenancy-map.yaml file.
This command displays information about the tenancy mappings defined in the tenancy-map.yaml file.
It shows details such as environment, tenancy, tenancy ID, realm, compartments, and regions.
Additional Information:
- Use --json (-j) to output the results in JSON format
- Use --realm (-r) to filter the mappings by realm (e.g., OC1, OC2, etc.)
- The command reads the tenancy-map.yaml file from the default location or from the path specified by the OCI_TENANCY_MAP_PATH environment variable
`
// Examples for the map-file command
var mapFileExamples = `
# View the tenancy mapping information
ocloud config info map-file
# View the tenancy mapping information in JSON format
ocloud config info map-file --json
# Filter tenancy mappings by realm
ocloud config info map-file --realm OC1
# Filter tenancy mappings by realm and output in JSON format
ocloud config info map-file --realm OC1 --json
`
// ViewMappingFile creates a new command for viewing the tenancy mapping file
func ViewMappingFile() *cobra.Command {
cmd := &cobra.Command{
Use: "map-file",
Aliases: []string{"mf", "tf"},
Short: "View tenancy mapping information",
Long: mapFileLong,
Example: mapFileExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runViewFileMappingCommand(cmd)
},
}
flags.JSONFlag.Add(cmd)
configurationFlags.RealmFlag.Add(cmd)
return cmd
}
// runViewFileMappingCommand handles the execution of the map-file command
func runViewFileMappingCommand(cmd *cobra.Command) error {
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
realm := flags.GetStringFlag(cmd, flags.FlagNameRealm, "")
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running map-file command", "json", useJSON, "realm", realm)
return info.ViewConfiguration(useJSON, realm)
}
package configuration
import (
"github.com/cnopslabs/ocloud/cmd/configuration/auth"
"github.com/cnopslabs/ocloud/cmd/configuration/info"
"github.com/cnopslabs/ocloud/cmd/configuration/setup"
"github.com/spf13/cobra"
)
// NewConfigCmd creates the `configuration` command for managing ocloud CLI configurations, authentication with OCI,
// and viewing configuration information such as tenancy mappings.
func NewConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Aliases: []string{"conf"},
Short: "Manage ocloud CLI configurations file and authentication",
Long: "Manage ocloud CLI configurations file and authentication with Oracle Cloud Infrastructure (OCI).\n\nThis command group provides functionality for:\n- Authenticating with OCI and refreshing session tokens\n- Viewing configuration information such as tenancy mappings\n- Setting up and managing tenancy mapping files",
Example: " ocloud config session\n ocloud config info\n ocloud config setup",
SilenceUsage: true,
SilenceErrors: true,
}
// Add subcommands
cmd.AddCommand(info.NewInfoCmd())
cmd.AddCommand(auth.NewSessionCmd())
cmd.AddCommand(setup.SetupMappingFile())
return cmd
}
package setup
import (
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/configuration/setup"
"github.com/spf13/cobra"
)
// Long description for the setup command
var setupLong = `
Create or update the tenancy mapping file used by ocloud CLI.
This command guides you through an interactive process to create a new tenancy mapping file
or add records to an existing one. The mapping file allows ocloud to associate tenancy names
with their OCIDs and other metadata such as compartments and regions.
The tenancy mapping file is stored at ~/.oci/.ocloud/tenancy-map.yaml by default, but this
location can be overridden using the OCI_TENANCY_MAP_PATH environment variable.
Each record in the mapping file includes:
- Environment: A descriptive name for the environment (e.g., Prod, Dev, Test)
- Tenancy Name: The name of the tenancy
- Tenancy OCID: The Oracle Cloud ID of the tenancy
- Realm: The OCI realm (e.g., OC1, OC2)
- Compartments: A list of compartments in the tenancy
- Regions: A list of regions used by the tenancy
`
// Examples for the setup command
var setupExamples = `
# Create or update the tenancy mapping file
ocloud config setup
# After running the command, you'll be guided through an interactive process
# to enter information about your tenancy environments
`
// SetupMappingFile creates a new command for setting up or updating the tenancy mapping file.
func SetupMappingFile() *cobra.Command {
cmd := &cobra.Command{
Use: "setup",
Short: "Create tenancy mapping file or add a record",
Long: setupLong,
Example: setupExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSetupFileMappingCommand(cmd)
},
}
return cmd
}
// runSetupFileMappingCommand handles the execution of the setup command
func runSetupFileMappingCommand(cmd *cobra.Command) error {
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running setup command")
return setup.SetupTenancyMapping()
}
package cmd
import (
"context"
"fmt"
"os"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/spf13/cobra"
)
// InitializeAppContext checks for help-related flags and initializes the ApplicationContext accordingly.
// It returns an error instead of exiting directly.
func InitializeAppContext(ctx context.Context, tempRoot *cobra.Command) (*app.ApplicationContext, error) {
isHelpRequested := HasHelpFlag(os.Args)
var appCtx *app.ApplicationContext
var err error
if isHelpRequested {
appCtx = &app.ApplicationContext{
Logger: logger.CmdLogger,
CompartmentName: flags.FlagValueHelpMode, // Set a dummy value to avoid nil pointer issues.
}
} else {
// One-shot bootstrap of ApplicationContext
appCtx, err = app.InitApp(ctx, tempRoot)
if err != nil {
return nil, fmt.Errorf("initializing application: %w", err)
}
}
return appCtx, nil
}
// HasHelpFlag checks if any help-related flags are present in the arguments.
func HasHelpFlag(args []string) bool {
for _, arg := range args {
if arg == flags.FlagPrefixShortHelp || arg == flags.FlagPrefixLongHelp || arg == flags.FlagNameHelp {
return true
}
}
return false
}
package autonomousdb
import (
databaseFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/database/autonomousdb"
"github.com/spf13/cobra"
)
// Long description for the list command
var getLong = `
Fetch Autonomous Databases in the specified compartment with pagination support.
This command displays information about available Autonomous Databases in the current compartment.
By default, it shows basic database information such as name, ID, state, and workload type.
The output is paginated, with a default limit of 20 databases per page. You can navigate
through pages using the --page flag and control the number of databases per page with
the --limit flag.
Additional Information:
- Use --json (-j) to output the results in JSON format
- The command shows all available Autonomous Databases in the compartment
`
// Examples for the list command
var getExamples = `
# Get all Autonomous Databases with default pagination (20 per page)
ocloud database autonomous get
# Get Autonomous Databases with custom pagination (10 per page, page 2)
ocloud database autonomous get --limit 10 --page 2
# Get Autonomous Databases and output in JSON format
ocloud database autonomous get --json
# Get Autonomous Databases with custom pagination and JSON output
ocloud database autonomous get --limit 5 --page 3 --json
`
// NewGetCmd creates a "list" subcommand for listing all databases in the specified compartment with pagination support.
func NewGetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Get all Databases",
Long: getLong,
Example: getExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runGetCommand(cmd, appCtx)
},
}
databaseFlags.LimitFlag.Add(cmd)
databaseFlags.PageFlag.Add(cmd)
databaseFlags.AllInfoFlag.Add(cmd)
return cmd
}
// runGetCommand handles the execution of the list command
func runGetCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running autonomous database Get command")
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, databaseFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, databaseFlags.FlagDefaultPage)
showAll := flags.GetBoolFlag(cmd, flags.FlagNameAll, false)
return autonomousdb.GetAutonomousDatabase(appCtx, useJSON, limit, page, showAll)
}
package autonomousdb
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/database/autonomousdb"
"github.com/spf13/cobra"
)
var listLong = `
Interactively browse and search Autonomous Databases in the specified compartment using a TUI.
This command launches terminal UI that loads available Autonomous Databases and lets you:
- Search/filter Autonomous Database as you type
- Navigate the list
- Select a single Autonomous Databases to view its details
After you pick an Autonomous Database, the tool prints detailed information about the selected Autonomous Database default table view or JSON format if specified with --json.
`
var listExamples = `
# Launch the interactive images browser
ocloud database autonomous list
ocloud database autonomous list --json
`
// NewListCmd creates a new command for listing Autonomous Databases
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "List all Autonomous Databases",
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runListCommand(cmd, appCtx)
},
}
return cmd
}
// runListCommand handles the execution of the list command
func runListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running autonomous database list command")
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
return autonomousdb.ListAutonomousDatabases(appCtx, useJSON)
}
package autonomousdb
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewAutonomousDatabaseCmd creates a new command for database-related operations
func NewAutonomousDatabaseCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "autonomous",
Aliases: []string{"adb"},
Short: "Manage OCI Databases.",
Long: "Manage Oracle Cloud Infrastructure databases: list, get, and search",
Example: " ocloud database autonomous list \n ocloud database autonomous get \n ocloud database autonomous search <value>",
SilenceUsage: true,
SilenceErrors: true,
}
// Add subcommands
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewSearchCmd(appCtx))
return cmd
}
package autonomousdb
import (
databaseFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/database/autonomousdb"
"github.com/spf13/cobra"
)
// Long description for the search command
var searchLong = `
FuzzySearch Autonomous Databases in the specified compartment that match the given pattern.
This command searches across a broad set of Autonomous Database attributes. It matches on:
- Identity & lifecycle: Name, OCID, State, DB version, Workload, License model, Compute model
- Capacity: OCPU/ECPU count, CPU core count, Storage size (GB/TB)
- Networking: VCN ID/Name, Subnet ID/Name, Private endpoint, Private endpoint IP/label,
Whitelisted IPs, NSG IDs/Names
- Tags: Flattened key:value pairs (TagsKV) and tag values (TagsVal)
The search is case-insensitive and uses a fuzzy/prefix/wildcard strategy, similar to instance and OKE search.
Additional Information:
- Use --json (-j) to output results in JSON format
- Works with partial fragments (e.g., OCID parts, hostnames, IPs, tag values)
`
// Examples for the search command
var searchExamples = `
# Fuzzy search ADBs with names containing "prod"
ocloud database autonomous search prod
# Output results in JSON
ocloud database autonomous search dev --json
# Search by OCID fragment or exact OCID
ocloud database autonomous search ocid1.autonomousdatabase
ocloud database autonomous search ocid1.autonomousdatabase.oc1..exampleexactocid
# Search by DB version, workload, license, or compute model
ocloud database autonomous search 19c
ocloud database autonomous search OLTP
ocloud database autonomous search LICENSE_INCLUDED
ocloud database autonomous search ECPU
# Search by networking attributes
ocloud database autonomous search my-vcn
ocloud database autonomous search app-subnet
ocloud database autonomous search adb-priv.endpoint.oraclecloud.com
ocloud database autonomous search 10.0.1.25
# Search by NSG name or whitelisted IP
ocloud database autonomous search nsg-apps
ocloud database autonomous search 203.0.113.42
# Search by tags (key:value or value only)
ocloud database autonomous search team:platform
ocloud database autonomous search prod
`
// NewSearchCmd creates a new command for searching Autonomous Databases.
func NewSearchCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "search [pattern]",
Aliases: []string{"s"},
Short: "Fuzzy Search for Autonomous Databases",
Long: searchLong,
Example: searchExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSearchCommand(cmd, args, appCtx)
},
}
databaseFlags.AllInfoFlag.Add(cmd)
return cmd
}
// runFindCommand handles the execution of the search command
func runSearchCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
namePattern := args[0]
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running search command", "searchPattern", namePattern, "json", useJSON)
showAll := flags.GetBoolFlag(cmd, flags.FlagNameAll, false)
return autonomousdb.SearchAutonomousDatabases(appCtx, namePattern, useJSON, showAll)
}
package database
import (
"github.com/cnopslabs/ocloud/cmd/database/autonomousdb"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewDatabaseCmd creates a new cobra.Command to manage Oracle Cloud Infrastructure database services.
// It provides functionality for managing Autonomous Databases, HeatWave MySQL, and other database types.
func NewDatabaseCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "database",
Aliases: []string{"db"},
Short: "Manage OCI Database services",
Long: "Manage Oracle Cloud Infrastructure database services such as Autonomous Database, HeatWave MySql and more.",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(autonomousdb.NewAutonomousDatabaseCmd(appCtx))
return cmd
}
// Package bastion Command wiring and orchestration for "bastion creates".
package bastion
import (
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
bastionSvc "github.com/cnopslabs/ocloud/internal/services/identity/bastion"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/spf13/cobra"
)
// NewCreateCmd returns "bastion create".
func NewCreateCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"c"},
Short: "Create a Bastion or a Session",
Long: "Interactively create a session on a selected bastion and target (Instance, OKE, Database).",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return runCreateCommand(cmd, appCtx)
},
}
return cmd
}
// runCreateCommand orchestrates the full flow. It calls TUI for selections.
func runCreateCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
ctx := cmd.Context()
svc, err := bastionSvc.NewService(appCtx)
if err != nil {
return fmt.Errorf("create bastion service: %w", err)
}
choice, err := SelectBastionType(ctx)
if err != nil {
return err
}
if choice == "" {
return ErrAborted
}
if choice == TypeBastion {
util.ShowConstructionAnimation()
return nil
}
b, err := SelectBastion(ctx, svc, choice)
if err != nil {
return err
}
if b.ID == "" {
return ErrAborted
}
tType, err := SelectTargetType(ctx, b.ID)
if err != nil {
return err
}
sType, err := SelectSessionType(ctx, b.ID)
if err != nil {
return err
}
if sType == "" {
return ErrAborted
}
if tType == "" {
return ErrAborted
}
return ConnectTarget(ctx, appCtx, svc, b, sType, tType)
}
// Package bastion Flows (orchestrators) that stitch together services, TUI, and side effects.
// These are thin and testable: they take ctx and collaborators; no globals.
package bastion
import (
"context"
"fmt"
"slices"
tea "github.com/charmbracelet/bubbletea"
"github.com/cnopslabs/ocloud/internal/app"
bastionSvc "github.com/cnopslabs/ocloud/internal/services/identity/bastion"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/oracle/oci-go-sdk/v65/bastion"
)
// SelectBastionType runs a simple TUI to choose between Bastion mgmt or Session.
func SelectBastionType(ctx context.Context) (BastionType, error) {
m := NewTypeSelectionModel()
p := tea.NewProgram(m, tea.WithContext(ctx))
res, err := p.Run()
if err != nil {
return "", fmt.Errorf("type selection TUI: %w", err)
}
out, ok := res.(TypeSelectionModel)
if !ok || out.Choice == "" {
return "", ErrAborted
}
return out.Choice, nil
}
// SelectBastion lists and filters ACTIVE bastions, then runs a picker TUI.
func SelectBastion(ctx context.Context, svc *bastionSvc.Service, t BastionType) (bastionSvc.Bastion, error) {
if t != TypeSession {
return bastionSvc.Bastion{}, nil
}
list, err := svc.List(ctx)
if err != nil {
return bastionSvc.Bastion{}, fmt.Errorf("list bastions: %w", err)
}
list = slices.DeleteFunc(list, func(b bastionSvc.Bastion) bool {
return b.LifecycleState != bastion.BastionLifecycleStateActive
})
m := NewBastionModel(list)
p := tea.NewProgram(m, tea.WithContext(ctx))
res, err := p.Run()
if err != nil {
return bastionSvc.Bastion{}, fmt.Errorf("bastion selection TUI: %w", err)
}
out, ok := res.(BastionModel)
if !ok || out.Choice == "" {
return bastionSvc.Bastion{}, ErrAborted
}
for _, b := range list {
if b.ID == out.Choice {
return b, nil
}
}
return bastionSvc.Bastion{}, fmt.Errorf("selected bastion not found")
}
// SelectTargetType provides a TUI to select a target type associated with the given bastion ID and returns the selection.
func SelectTargetType(ctx context.Context, bastionID string) (TargetType, error) {
m := NewTargetTypeModel(bastionID)
p := tea.NewProgram(m, tea.WithContext(ctx))
res, err := p.Run()
if err != nil {
return "", fmt.Errorf("target type TUI: %w", err)
}
out, ok := res.(TargetTypeModel)
if !ok || out.Choice == "" {
return "", ErrAborted
}
return out.Choice, nil
}
// SelectSessionType chooses a session type for the selected bastion.
func SelectSessionType(ctx context.Context, bastionID string) (SessionType, error) {
m := NewSessionTypeModel(bastionID)
p := tea.NewProgram(m, tea.WithContext(ctx))
res, err := p.Run()
if err != nil {
return "", fmt.Errorf("session type TUI: %w", err)
}
out, ok := res.(SessionTypeModel)
if !ok || out.Choice == "" {
return "", ErrAborted
}
return out.Choice, nil
}
// ConnectTarget switches to the correct flow for the chosen target.
func ConnectTarget(ctx context.Context, appCtx *app.ApplicationContext, svc *bastionSvc.Service,
b bastionSvc.Bastion, sType SessionType, tType TargetType) error {
switch tType {
case TargetInstance:
return connectInstance(ctx, appCtx, svc, b, sType)
case TargetDatabase:
util.ShowConstructionAnimation()
//return connectDatabase(ctx, appCtx, svc, b, sType)
return nil
case TargetOKE:
return connectOKE(ctx, appCtx, svc, b, sType)
default:
fmt.Printf("Prepared %s session on %s (%s) -> %s\n", sType, b.Name, b.ID, tType)
return nil
}
}
package bastion
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
ociadb "github.com/cnopslabs/ocloud/internal/oci/database/autonomousdb"
adbSvc "github.com/cnopslabs/ocloud/internal/services/database/autonomousdb"
bastionSvc "github.com/cnopslabs/ocloud/internal/services/identity/bastion"
)
// connectDatabase runs the DB target flow. We can’t always auto-verify reachability,
// so we surface that limitation to the user.
func connectDatabase(ctx context.Context, appCtx *app.ApplicationContext, svc *bastionSvc.Service,
b bastionSvc.Bastion, sType SessionType) error {
adapter, err := ociadb.NewAdapter(appCtx.Provider)
if err != nil {
return fmt.Errorf("error creating database adapter: %w", err)
}
dbService := adbSvc.NewService(adapter, appCtx)
dbs, _, _, err := dbService.FetchPaginatedAutonomousDb(ctx, 1000, 0)
if err != nil {
return fmt.Errorf("list databases: %w", err)
}
if len(dbs) == 0 {
logger.Logger.Info("No Autonomous Databases found.")
return nil
}
// Port 1521 or 1522 is the default ports for Oracle Database
dm := NewDBListModelFancy(dbs)
dp := tea.NewProgram(dm, tea.WithContext(ctx))
dres, err := dp.Run()
if err != nil {
return fmt.Errorf("DB selection TUI: %w", err)
}
chosen, ok := dres.(ResourceListModel)
if !ok || chosen.Choice() == "" {
return ErrAborted
}
var db adbSvc.AutonomousDatabase
for _, d := range dbs {
if d.ID == chosen.Choice() {
db = d
break
}
}
_, reason := svc.CanReach(ctx, b, "", "")
logger.Logger.Info("Reachability to DB cannot be automatically verified", "reason", reason)
logger.Logger.Info("Selected database", "name", db.Name, "id", db.ID)
logger.Logger.Info("Prepared session on Bastion to database", "session_type", sType, "bastion_name", b.Name, "bastion_id", b.ID, "database_name", db.Name)
return nil
}
package bastion
import (
"context"
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
ociInst "github.com/cnopslabs/ocloud/internal/oci/compute/instance"
instSvc "github.com/cnopslabs/ocloud/internal/services/compute/instance"
bastionSvc "github.com/cnopslabs/ocloud/internal/services/identity/bastion"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// connectInstance runs the flow for an Instance target.
func connectInstance(ctx context.Context, appCtx *app.ApplicationContext, svc *bastionSvc.Service,
b bastionSvc.Bastion, sType SessionType) error {
computeClient, err := oci.NewComputeClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating compute client: %w", err)
}
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
instanceAdapter := ociInst.NewAdapter(computeClient, networkClient)
instService := instSvc.NewService(instanceAdapter, appCtx.Logger, appCtx.CompartmentID)
instances, _, _, err := instService.FetchPaginatedInstances(ctx, 300, 0)
if err != nil {
return fmt.Errorf("list instances: %w", err)
}
if len(instances) == 0 {
logger.Logger.Info("No instances found.")
return nil
}
// TUI selection
im := NewInstanceListModelFancy(instances)
ip := tea.NewProgram(im, tea.WithContext(ctx))
ires, err := ip.Run()
if err != nil {
return fmt.Errorf("instance selection TUI: %w", err)
}
chosen, ok := ires.(ResourceListModel)
if !ok || chosen.Choice() == "" {
return ErrAborted
}
pubKey, privKey, err := SelectSSHKeyPair(ctx)
if err != nil {
return err
}
var inst instSvc.Instance
for _, it := range instances {
if it.OCID == chosen.Choice() {
inst = it
break
}
}
if ok, reason := svc.CanReach(ctx, b, inst.VcnID, inst.SubnetID); !ok {
logger.Logger.Info("Bastion cannot reach selected instance", "reason", reason)
return nil
}
logger.Logger.Info("Validated session on Bastion to Instance", "session_type", sType, "bastion_name", b.Name, "bastion_id", b.ID, "instance_name", inst.DisplayName)
region, regErr := appCtx.Provider.Region()
if regErr != nil {
return fmt.Errorf("get region: %w", regErr)
}
switch sType {
case TypeManagedSSH:
sshUser, err := util.PromptString("Enter SSH username", "opc")
if err != nil {
return fmt.Errorf("read ssh username: %w", err)
}
sessID, err := svc.EnsureManagedSSHSession(ctx, b.ID, inst.OCID, inst.PrimaryIP, sshUser, 22, pubKey, 0)
if err != nil {
return fmt.Errorf("ensure managed SSH: %w", err)
}
sshCmd := bastionSvc.BuildManagedSSHCommand(privKey, sessID, region, inst.PrimaryIP, sshUser)
logger.Logger.Info("Executing", "command", sshCmd)
return bastionSvc.RunShell(ctx, appCtx.Stdout, appCtx.Stderr, sshCmd)
case TypePortForwarding:
defaultPort := 5901
port, err := util.PromptPort("Enter port to forward (local:target)", defaultPort)
if err != nil {
return fmt.Errorf("read port: %w", err)
}
sessID, err := svc.EnsurePortForwardSession(ctx, b.ID, inst.PrimaryIP, port, pubKey)
if err != nil {
return fmt.Errorf("ensure port forward: %w", err)
}
logFile := fmt.Sprintf("~/.oci/.ocloud/ssh-tunnel-%d.log", port)
sshTunnelArgs, err := bastionSvc.BuildPortForwardArgs(privKey, sessID, region, inst.PrimaryIP, port, port)
if err != nil {
return fmt.Errorf("build args: %w", err)
}
logger.Logger.Info("Starting background tunnel", "args", sshTunnelArgs)
pid, err := bastionSvc.SpawnDetached(sshTunnelArgs, "/tmp/ssh-tunnel.log")
if err != nil {
return fmt.Errorf("spawn detached: %w", err)
}
logger.Logger.V(logger.Debug).Info("spawned tunnel", "pid", pid)
if err := bastionSvc.WaitForListen(defaultPort, 5*time.Second); err != nil {
logger.Logger.Error(err, "warning")
}
logger.Logger.Info("Starting background tunnel", "args", sshTunnelArgs)
logger.Logger.Info("SSH tunnel started in background", "logs", logFile)
return nil
default:
return fmt.Errorf("unsupported session type: %s", sType)
}
}
package bastion
import (
"context"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
ociInst "github.com/cnopslabs/ocloud/internal/oci/compute/instance"
ociOke "github.com/cnopslabs/ocloud/internal/oci/compute/oke"
instSvc "github.com/cnopslabs/ocloud/internal/services/compute/instance"
okeSvc "github.com/cnopslabs/ocloud/internal/services/compute/oke"
bastionSvc "github.com/cnopslabs/ocloud/internal/services/identity/bastion"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// connectOKE runs the OKE target flow.
func connectOKE(ctx context.Context, appCtx *app.ApplicationContext, svc *bastionSvc.Service,
b bastionSvc.Bastion, sType SessionType) error {
containerEngineClient, err := oci.NewContainerEngineClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating container engine client: %w", err)
}
okeAdapter := ociOke.NewAdapter(containerEngineClient)
okeService := okeSvc.NewService(okeAdapter, appCtx.Logger, appCtx.CompartmentID)
clusters, _, _, err := okeService.FetchPaginatedClusters(ctx, 1000, 0)
if err != nil {
return fmt.Errorf("list OKE clusters: %w", err)
}
if len(clusters) == 0 {
logger.Logger.Info("No OKE clusters found.")
return nil
}
cm := NewOKEListModelFancy(clusters)
cp := tea.NewProgram(cm, tea.WithContext(ctx))
userSelection, err := cp.Run()
if err != nil {
return fmt.Errorf("OKE selection TUI: %w", err)
}
chosen, ok := userSelection.(ResourceListModel)
if !ok || chosen.Choice() == "" {
return ErrAborted
}
var cluster okeSvc.Cluster
for _, c := range clusters {
if c.OCID == chosen.Choice() {
cluster = c
break
}
}
if ok, reason := svc.CanReach(ctx, b, cluster.VcnOCID, ""); !ok {
logger.Logger.Info("Bastion cannot reach selected OKE cluster", "reason", reason)
return nil
}
logger.Logger.Info("Validated session on Bastion to OKE cluster", "session_type", sType, "bastion_name", b.Name, "bastion_id", b.ID, "cluster_name", cluster.DisplayName)
switch sType {
case TypeManagedSSH:
computeClient, err := oci.NewComputeClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating compute client: %w", err)
}
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
instanceAdapter := ociInst.NewAdapter(computeClient, networkClient)
instService := instSvc.NewService(instanceAdapter, appCtx.Logger, appCtx.CompartmentID)
instances, _, _, err := instService.FetchPaginatedInstances(ctx, 300, 0)
if err != nil {
return fmt.Errorf("list instances: %w", err)
}
filtered := make([]instSvc.Instance, 0, len(instances))
for _, it := range instances {
if strings.HasPrefix(strings.ToLower(it.DisplayName), "oke") {
filtered = append(filtered, it)
}
}
if len(filtered) == 0 {
logger.Logger.Info("No instances with name starting with 'oke' found.")
return nil
}
im := NewInstanceListModelFancy(filtered)
ip := tea.NewProgram(im, tea.WithContext(ctx))
ires, err := ip.Run()
if err != nil {
return fmt.Errorf("instance selection TUI: %w", err)
}
chosenInstRes, ok := ires.(ResourceListModel)
if !ok || chosenInstRes.Choice() == "" {
return ErrAborted
}
var inst instSvc.Instance
for _, it := range filtered {
if it.OCID == chosenInstRes.Choice() {
inst = it
break
}
}
pubKey, privKey, err := SelectSSHKeyPair(ctx)
if err != nil {
return err
}
if ok, reason := svc.CanReach(ctx, b, inst.VcnID, inst.SubnetID); !ok {
logger.Logger.Info("Bastion cannot reach selected instance", "reason", reason)
return nil
}
region, regErr := appCtx.Provider.Region()
if regErr != nil {
return fmt.Errorf("get region: %w", regErr)
}
sshUser, err := util.PromptString("Enter SSH username", "opc")
if err != nil {
return fmt.Errorf("read ssh username: %w", err)
}
sessID, err := svc.EnsureManagedSSHSession(ctx, b.ID, inst.OCID, inst.PrimaryIP, sshUser, 22, pubKey, 0)
if err != nil {
return fmt.Errorf("ensure managed SSH: %w", err)
}
sshCmd := bastionSvc.BuildManagedSSHCommand(privKey, sessID, region, inst.PrimaryIP, sshUser)
logger.Logger.Info("Executing", "command", sshCmd)
return bastionSvc.RunShell(ctx, appCtx.Stdout, appCtx.Stderr, sshCmd)
case TypePortForwarding:
candidates := []string{}
if h := util.ExtractHostname(cluster.PrivateEndpoint); h != "" {
candidates = append(candidates, h)
}
if h := util.ExtractHostname(cluster.PublicEndpoint); h != "" {
candidates = append(candidates, h)
}
if len(candidates) == 0 {
return fmt.Errorf("could not determine OKE API host from endpoints: kube=%q private=%q",
cluster.PublicEndpoint, cluster.PrivateEndpoint)
}
var targetIP string
var lastErr error
for _, host := range candidates {
ip, err := util.ResolveHostToIP(ctx, host)
if err == nil {
targetIP = ip
break
}
lastErr = err
}
if targetIP == "" {
return fmt.Errorf("resolve OKE API endpoint to private IP: %v", lastErr)
}
pubKey, privKey, err := SelectSSHKeyPair(ctx)
if err != nil {
return err
}
okeTargetPort := 6443
sessID, err := svc.EnsurePortForwardSession(ctx, b.ID, targetIP, okeTargetPort, pubKey)
if err != nil {
return fmt.Errorf("ensure port forward: %w", err)
}
region, regErr := appCtx.Provider.Region()
if regErr != nil {
return fmt.Errorf("get region: %w", regErr)
}
port, err := util.PromptPort("Enter port to forward (local:target)", okeTargetPort)
if err != nil {
return fmt.Errorf("read port: %w", err)
}
if util.IsLocalTCPPortInUse(port) {
return fmt.Errorf("local port %d is already in use on 127.0.0.1; choose another port", port)
}
exists, err := okeSvc.KubeconfigExistsForOKE(cluster, region)
if err != nil {
return fmt.Errorf("check kubeconfig: %w", err)
}
if !exists {
question := "Kubeconfig for this OKE cluster was not found in ~/.kube/config. Create and merge it now?"
if util.PromptYesNo(question) {
if err := okeSvc.EnsureKubeconfigForOKE(cluster, region, port); err != nil {
return fmt.Errorf("ensure kubeconfig: %w", err)
}
} else {
logger.Logger.Info("Skipping kubeconfig creation for this OKE cluster.")
}
}
localPort := port
logFile := fmt.Sprintf("~/.oci/.ocloud/ssh-tunnel-%d.log", localPort)
sshTunnelArgs, err := bastionSvc.BuildPortForwardArgs(privKey, sessID, region, targetIP, localPort, okeTargetPort)
if err != nil {
return fmt.Errorf("build args: %w", err)
}
pid, err := bastionSvc.SpawnDetached(sshTunnelArgs, "/tmp/ssh-tunnel.log")
if err != nil {
return fmt.Errorf("spawn detached: %w", err)
}
logger.Logger.V(logger.Debug).Info("spawned tunnel", "pid", pid)
if err := bastionSvc.WaitForListen(okeTargetPort, 5*time.Second); err != nil {
logger.Logger.Error(err, "warning")
}
logger.Logger.Info("Starting background OKE API tunnel", "args", sshTunnelArgs)
logger.Logger.Info("SSH tunnel to OKE API started", "access", fmt.Sprintf("https://127.0.0.1:%d (kube-apiserver)", localPort), "logs", logFile)
return nil
default:
return fmt.Errorf("unsupported session type: %s", sType)
}
}
package bastion
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/identity/bastion"
"github.com/spf13/cobra"
)
// NewListCmd returns "bastion list".
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "FetchPaginatedClusters all bastions",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return runListCommand(cmd, appCtx)
},
}
return cmd
}
// RunListCommand handles the execution of the list command
func runListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
ctx := cmd.Context()
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running list command")
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
return bastion.ListBastions(ctx, appCtx, useJSON)
}
package bastion
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewBastionCmd returns the "bastion" command group.
func NewBastionCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "bastion",
Aliases: []string{"b"},
Short: "Manage OCI Bastion",
Long: "Manage Oracle Cloud Infrastructure Bastions: list existing bastions or create bastion and sessions connection.",
Example: " ocloud identity bastion list\n ocloud identity bastion create",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewCreateCmd(appCtx))
return cmd
}
package bastion
import (
"bytes"
"context"
"crypto"
xecdsa "crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/cnopslabs/ocloud/internal/logger"
"golang.org/x/crypto/ssh"
)
// SelectSSHKeyPair opens two TUIs to select a matching SSH public and private key.
// It returns ErrAborted if the user cancels either selection.
func SelectSSHKeyPair(ctx context.Context) (pubKey, privKey string, err error) {
home := os.Getenv("HOME")
if home == "" {
if h, err := os.UserHomeDir(); err == nil {
home = h
}
}
startDir := filepath.Join(home, ".ssh")
pk := NewSSHKeysModelBrowser("Choose Public Key", startDir, true)
pProg := tea.NewProgram(pk, tea.WithContext(ctx))
pRes, err := pProg.Run()
if err != nil {
return "", "", fmt.Errorf("public key selection TUI: %w", err)
}
pPick, ok := pRes.(SHHFilesModel)
if !ok || pPick.Choice() == "" {
return "", "", ErrAborted
}
pubKey = pPick.Choice()
logger.CmdLogger.Info("selected public ssh key", "name", filepath.Base(pubKey), "path", pubKey)
sk := NewSSHKeysModelBrowser("Choose Private Key", startDir, false)
sProg := tea.NewProgram(sk, tea.WithContext(ctx))
sRes, err := sProg.Run()
if err != nil {
return "", "", fmt.Errorf("private key selection TUI: %w", err)
}
sPick, ok := sRes.(SHHFilesModel)
if !ok || sPick.Choice() == "" {
return "", "", ErrAborted
}
privKey = sPick.Choice()
logger.CmdLogger.Info("selected private ssh key", "name", filepath.Base(privKey), "path", privKey)
expected := strings.TrimSuffix(pubKey, ".pub")
if filepath.Base(privKey) != filepath.Base(expected) {
return "", "", fmt.Errorf("selected private key %s does not match public key %s (expected private: %s)", privKey, pubKey, expected)
}
if err := validateSSHKeyPair(pubKey, privKey); err != nil {
return "", "", err
}
return pubKey, privKey, nil
}
// validateSSHKeyPair ensures that:
// 1) the public key type is ssh-rsa, ssh-ed25519, or ecdsa-sha2-nistp{256,384,521},
// 2) the private key is RSA, ED25519, or ECDSA,
// 3) the derived public key from the private key matches the selected public key.
func validateSSHKeyPair(pubPath, privPath string) error {
pubBytes, err := os.ReadFile(pubPath)
if err != nil {
return fmt.Errorf("read public key: %w", err)
}
pubKey, pubComment, _, _, pubErr := ssh.ParseAuthorizedKey(pubBytes)
if pubErr != nil {
return fmt.Errorf("parse public key: %w", pubErr)
}
pubType := pubKey.Type()
if pubType != ssh.KeyAlgoRSA && pubType != ssh.KeyAlgoED25519 && pubType != ssh.KeyAlgoECDSA256 && pubType != ssh.KeyAlgoECDSA384 && pubType != ssh.KeyAlgoECDSA521 {
return fmt.Errorf("unsupported public key type %s (allowed: ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256/384/521)%s", pubType, formatComment(pubComment))
}
privBytes, err := os.ReadFile(privPath)
if err != nil {
return fmt.Errorf("read private key: %w", err)
}
rawPriv, err := ssh.ParseRawPrivateKey(privBytes)
if err != nil {
if strings.Contains(err.Error(), "encrypted") {
return errors.New("the selected private key is encrypted. please use an unencrypted key for this workflow or decrypt it temporarily")
}
return fmt.Errorf("parse private key: %w", err)
}
derived, err := deriveSSHPublicKeyFromPrivate(rawPriv)
if err != nil {
return fmt.Errorf("derive public key from private: %w", err)
}
if isKeyAlgorithmMismatch(pubType, derived.Type()) {
return fmt.Errorf("mismatch between public (%s) and private (%s) key algorithms", pubType, derived.Type())
}
if !bytes.Equal(pubKey.Marshal(), derived.Marshal()) {
return fmt.Errorf("selected private key does not match the selected public key")
}
return nil
}
// isKeyAlgorithmMismatch returns true if the public and private key types do not match.
func isKeyAlgorithmMismatch(pubType, derivedType string) bool {
return pubType != derivedType
}
// formatComment returns a comment string with a colon prefix if the comment is not empty.
func formatComment(c string) string {
if c == "" {
return ""
}
return ": " + c
}
// deriveSSHPublicKeyFromPrivate attempts to derive an ssh.PublicKey from a parsed private key.
// It supports RSA, ED25519, and ECDSA. If the concrete type is not directly matched,
// it falls back to using crypto.Signer to obtain the corresponding public key.
// Returns an error if the private key type is unsupported.
func deriveSSHPublicKeyFromPrivate(rawPriv any) (ssh.PublicKey, error) {
switch k := rawPriv.(type) {
case *rsa.PrivateKey:
return ssh.NewPublicKey(&k.PublicKey)
case ed25519.PrivateKey:
return ssh.NewPublicKey(k.Public())
case *xecdsa.PrivateKey:
return ssh.NewPublicKey(&k.PublicKey)
default:
if s, ok := rawPriv.(crypto.Signer); ok {
switch pk := s.Public().(type) {
case ed25519.PublicKey:
return ssh.NewPublicKey(pk)
case *xecdsa.PublicKey:
return ssh.NewPublicKey(pk)
case *rsa.PublicKey:
return ssh.NewPublicKey(pk)
}
}
return nil, fmt.Errorf("unsupported private key type (allowed: RSA, ED25519, ECDSA)")
}
}
package bastion
import (
"fmt"
"os"
"path"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
instSvc "github.com/cnopslabs/ocloud/internal/services/compute/instance"
okeSvc "github.com/cnopslabs/ocloud/internal/services/compute/oke"
adbSvc "github.com/cnopslabs/ocloud/internal/services/database/autonomousdb"
bastionSvc "github.com/cnopslabs/ocloud/internal/services/identity/bastion"
)
// BastionType identifies the top-level action.
type BastionType string
const (
TypeBastion BastionType = "Bastion"
TypeSession BastionType = "Session"
)
// TargetType identifies what the session connects to.
type TargetType string
const (
TargetOKE TargetType = "OKE"
TargetDatabase TargetType = "Database"
TargetInstance TargetType = "Instance"
)
// SessionType identifies how the bastion session behaves.
type SessionType string
const (
TypeManagedSSH SessionType = "Managed SSH"
TypePortForwarding SessionType = "Port-Forwarding"
)
//-----------------------------------Bastion/Session Creation Selection-------------------------------------------------
// TypeSelectionModel defines a TUI model for selecting a BastionType from a list of available types.
type TypeSelectionModel struct {
Cursor int
Choice BastionType
Types []BastionType
}
// NewTypeSelectionModel creates a new TypeSelectionModel.
func NewTypeSelectionModel() TypeSelectionModel {
return TypeSelectionModel{
Types: []BastionType{TypeBastion, TypeSession},
Cursor: 0,
}
}
// Init initializes the TypeSelectionModel and returns a command.
func (m TypeSelectionModel) Init() tea.Cmd { return nil }
// Update processes input messages to update the model's state and returns the updated model and an optional command.
func (m TypeSelectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "enter":
if m.Cursor >= 0 && m.Cursor < len(m.Types) {
m.Choice = m.Types[m.Cursor]
}
return m, tea.Quit
case "down", "j":
m.Cursor = (m.Cursor + 1) % len(m.Types)
case "up", "k":
m.Cursor--
if m.Cursor < 0 {
m.Cursor = len(m.Types) - 1
}
}
}
return m, nil
}
// View returns a string representation of the model's state.
func (m TypeSelectionModel) View() string {
var b strings.Builder
b.WriteString("Select a type:\n\n")
for i, t := range m.Types {
mark := "( ) "
if m.Cursor == i {
mark = "(•) "
}
b.WriteString(mark + string(t) + "\n")
}
b.WriteString("\n(press q to quit)\n")
return b.String()
}
//--------------------------------------------------Bastion Selection---------------------------------------------------
// BastionModel Bastion Selection
type BastionModel struct {
Cursor int
Choice string
Bastions []bastionSvc.Bastion
}
// NewBastionModel creates a BastionModel instance with the provided list of bastions and initializes the cursor to 0.
func NewBastionModel(bastions []bastionSvc.Bastion) BastionModel {
return BastionModel{Bastions: bastions, Cursor: 0}
}
// Init initializes the BastionModel and returns an optional command to execute.
func (m BastionModel) Init() tea.Cmd { return nil }
// Update processes incoming messages, updates the model's state, and determines the next command to execute.
func (m BastionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "enter":
if m.Cursor >= 0 && m.Cursor < len(m.Bastions) {
m.Choice = m.Bastions[m.Cursor].ID
}
return m, tea.Quit
case "down", "j":
m.Cursor = (m.Cursor + 1) % len(m.Bastions)
case "up", "k":
m.Cursor--
if m.Cursor < 0 {
m.Cursor = len(m.Bastions) - 1
}
}
}
return m, nil
}
// View renders the string representation of the BastionModel, displaying the list of bastion hosts and current selection.
func (m BastionModel) View() string {
var b strings.Builder
b.WriteString("Select a bastion host:\n\n")
for i, ba := range m.Bastions {
mark := "( ) "
if m.Cursor == i {
mark = "(•) "
}
b.WriteString(mark + ba.Name + "\n")
}
b.WriteString("\n(press q to quit)\n")
return b.String()
}
//------------------------------------------Session Type Selection------------------------------------------------------
// SessionTypeModel Session Type Selection
type SessionTypeModel struct {
Cursor int
Choice SessionType
Types []SessionType
BastionID string
}
// NewSessionTypeModel creates a SessionTypeModel instance with the provided list of bastions and initializes the cursor to 0.
func NewSessionTypeModel(bastionID string) SessionTypeModel {
return SessionTypeModel{
Types: []SessionType{TypeManagedSSH, TypePortForwarding},
Cursor: 0,
BastionID: bastionID,
}
}
// Init initializes the SessionTypeModel and returns an optional command to execute.
func (m SessionTypeModel) Init() tea.Cmd { return nil }
// Update processes incoming messages, updates the model's state, and determines the next command to execute.
func (m SessionTypeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "enter":
if m.Cursor >= 0 && m.Cursor < len(m.Types) {
m.Choice = m.Types[m.Cursor]
}
return m, tea.Quit
case "down", "j":
m.Cursor = (m.Cursor + 1) % len(m.Types)
case "up", "k":
m.Cursor--
if m.Cursor < 0 {
m.Cursor = len(m.Types) - 1
}
}
}
return m, nil
}
// View returns a string representation of the model's state.
func (m SessionTypeModel) View() string {
var b strings.Builder
b.WriteString("Select a session type:\n\n")
for i, t := range m.Types {
mark := "( ) "
if m.Cursor == i {
mark = "(•) "
}
b.WriteString(mark + string(t) + "\n")
}
b.WriteString("\n(press q to quit)\n")
return b.String()
}
//------------------------------------------Target Type Selection-------------------------------------------------------
// TargetTypeModel Target Type Selection
type TargetTypeModel struct {
Cursor int
Choice TargetType
Types []TargetType
BastionID string
}
// NewTargetTypeModel creates a TargetTypeModel instance with the provided list of bastions and initializes the cursor to 0.
func NewTargetTypeModel(bastionID string) TargetTypeModel {
var types []TargetType
types = []TargetType{TargetOKE, TargetDatabase, TargetInstance}
return TargetTypeModel{Types: types, Cursor: 0, BastionID: bastionID}
}
// Init initializes the TargetTypeModel and returns an optional command to execute.
func (m TargetTypeModel) Init() tea.Cmd { return nil }
// Update processes incoming messages, updates the model's state, and determines the next command to execute.
func (m TargetTypeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "enter":
if m.Cursor >= 0 && m.Cursor < len(m.Types) {
m.Choice = m.Types[m.Cursor]
}
return m, tea.Quit
case "down", "j":
m.Cursor = (m.Cursor + 1) % len(m.Types)
case "up", "k":
m.Cursor--
if m.Cursor < 0 {
m.Cursor = len(m.Types) - 1
}
}
}
return m, nil
}
// View returns a string representation of the model's state.
func (m TargetTypeModel) View() string {
var b strings.Builder
b.WriteString("Select a target type:\n\n")
for i, t := range m.Types {
mark := "( ) "
if m.Cursor == i {
mark = "(•) "
}
b.WriteString(mark + string(t) + "\n")
}
b.WriteString("\n(press q to quit)\n")
return b.String()
}
// ----------------------------------Fancy searchable list (Instances / OKE / DB)--------------------------------------
// resourceItem defines a resource item for a list.
type resourceItem struct {
id, title, description string
}
func (i resourceItem) Title() string { return i.title }
func (i resourceItem) Description() string { return i.description }
func (i resourceItem) FilterValue() string { return i.title + " " + i.description }
// ResourceListModel defines a TUI model for displaying a list of resources.
type ResourceListModel struct {
list list.Model
choice string
keys struct {
confirm key.Binding
quit key.Binding
}
}
func (m ResourceListModel) Init() tea.Cmd { return nil }
func (m ResourceListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2)
case tea.KeyMsg:
if key.Matches(msg, m.keys.quit) {
return m, tea.Quit
}
if key.Matches(msg, m.keys.confirm) {
if it, ok := m.list.SelectedItem().(resourceItem); ok {
m.choice = it.id
}
return m, tea.Quit
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m ResourceListModel) View() string { return m.list.View() }
func (m ResourceListModel) Choice() string { return m.choice }
func newResourceList(title string, items []list.Item) ResourceListModel {
delegate := list.NewDefaultDelegate()
l := list.New(items, delegate, 0, 0)
l.Title = title
l.SetShowTitle(true)
l.SetShowHelp(true)
l.SetFilteringEnabled(true)
l.SetShowFilter(true)
rm := ResourceListModel{list: l}
rm.keys.confirm = key.NewBinding(key.WithKeys("enter"))
rm.keys.quit = key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"))
return rm
}
// NewInstanceListModelFancy creates a ResourceListModel to display instances in a searchable and interactive list.
func NewInstanceListModelFancy(instances []instSvc.Instance) ResourceListModel {
items := make([]list.Item, 0, len(instances))
for _, inst := range instances {
name := inst.DisplayName
if name == "" {
name = inst.OCID
}
desc := fmt.Sprintf("IP: %s", inst.PrimaryIP)
if inst.VcnName != "" {
desc = inst.VcnName
if inst.SubnetName != "" {
desc += " · " + inst.SubnetName
}
}
items = append(items, resourceItem{id: inst.OCID, title: name, description: desc})
}
return newResourceList("Instances", items)
}
// NewOKEListModelFancy creates a ResourceListModel to display OKE clusters in a searchable and interactive list.
func NewOKEListModelFancy(clusters []okeSvc.Cluster) ResourceListModel {
items := make([]list.Item, 0, len(clusters))
for _, c := range clusters {
desc := c.KubernetesVersion
if c.PrivateEndpoint != "" {
desc += " · PE"
}
items = append(items, resourceItem{id: c.OCID, title: c.DisplayName, description: desc})
}
return newResourceList("OKE Clusters", items)
}
// NewDBListModelFancy creates a ResourceListModel populated with a list of autonomous databases for TUI display.
func NewDBListModelFancy(dbs []adbSvc.AutonomousDatabase) ResourceListModel {
items := make([]list.Item, 0, len(dbs))
for _, d := range dbs {
desc := d.PrivateEndpoint
items = append(items, resourceItem{id: d.ID, title: d.Name, description: desc})
}
return newResourceList("Autonomous Databases", items)
}
//---------------------------------------SSH Keys----------------------------------------------------------------------
// SSHFileItem is a list item representing a file system entry (file or directory).
type SSHFileItem struct {
path string
title string
permission string
isDir bool
}
// Title returns the display title (implements list.Item).
func (i SSHFileItem) Title() string { return i.title }
// Description returns permissions or metadata (implements list.Item).
func (i SSHFileItem) Description() string { return i.permission }
func (i SSHFileItem) FilterValue() string { return i.title + " " + i.permission }
// SSHFilesModel is the canonical model name for SSH file selection/browsing.
type SSHFilesModel struct {
list list.Model
choice string
currentDir string
showPublic bool
browsing bool
keys struct {
confirm key.Binding
quit key.Binding
upDir key.Binding
}
}
// SHHFilesModel is an alias for SSHFilesModel used in contexts requiring SSH file selection and interaction.
type SHHFilesModel = SSHFilesModel
func (m SSHFilesModel) Init() tea.Cmd { return nil }
func (m SSHFilesModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2)
case tea.KeyMsg:
if key.Matches(msg, m.keys.quit) {
return m, tea.Quit
}
if m.browsing && key.Matches(msg, m.keys.upDir) {
if m.currentDir != "" {
parent := path.Dir(m.currentDir)
if parent != m.currentDir {
m.currentDir = parent
m.NewSSHFilesModelFancyList()
}
}
return m, nil
}
if key.Matches(msg, m.keys.confirm) {
if it, ok := m.list.SelectedItem().(SSHFileItem); ok {
if m.browsing && it.isDir {
if it.path == ".." {
parent := path.Dir(m.currentDir)
if parent != m.currentDir {
m.currentDir = parent
m.NewSSHFilesModelFancyList()
}
} else {
m.currentDir = it.path
m.NewSSHFilesModelFancyList()
}
return m, nil
}
m.choice = it.path
}
return m, tea.Quit
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m SSHFilesModel) View() string { return m.list.View() }
func (m SSHFilesModel) Choice() string { return m.choice }
func newSSHList(title string, items []list.Item) SSHFilesModel {
delegate := list.NewDefaultDelegate()
l := list.New(items, delegate, 0, 0)
l.Title = title
l.SetShowTitle(true)
l.SetShowHelp(true)
l.SetFilteringEnabled(true)
l.SetShowFilter(true)
rm := SSHFilesModel{list: l}
rm.keys.confirm = key.NewBinding(key.WithKeys("enter"))
rm.keys.quit = key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"))
rm.keys.upDir = key.NewBinding(key.WithKeys("backspace", "left"))
return rm
}
// filePermString returns the file's unix permission bits as a short octal string (e.g., "600").
func filePermString(path string) string {
info, err := os.Stat(path)
if err != nil {
return "n/a"
}
perm := info.Mode().Perm()
return fmt.Sprintf("%o", perm)
}
// NewSSHFilesModelFancyList populates the list items based on currentDir and filtering rules.
func (m *SSHFilesModel) NewSSHFilesModelFancyList() {
if !m.browsing || m.currentDir == "" {
return
}
entries, err := os.ReadDir(m.currentDir)
if err != nil {
return
}
items := make([]list.Item, 0, len(entries)+1)
if parent := path.Dir(m.currentDir); parent != m.currentDir {
items = append(items, SSHFileItem{path: "..", title: "..", permission: "", isDir: true})
}
for _, e := range entries {
if e.IsDir() {
p := path.Join(m.currentDir, e.Name())
items = append(items, SSHFileItem{path: p, title: e.Name() + string(os.PathSeparator), permission: "dir", isDir: true})
}
}
for _, e := range entries {
if !e.IsDir() {
name := e.Name()
if m.showPublic && !strings.HasSuffix(name, ".pub") {
continue
}
if !m.showPublic && strings.HasSuffix(name, ".pub") {
continue
}
p := path.Join(m.currentDir, name)
items = append(items, SSHFileItem{path: p, title: name, permission: filePermString(p), isDir: false})
}
}
m.list.SetItems(items)
}
// NewSSHKeysModelBrowser creates a navigable SSHFilesModel starting from startDir.
func NewSSHKeysModelBrowser(title, startDir string, showPublic bool) SHHFilesModel {
m := newSSHList(title, nil)
m.browsing = true
m.currentDir = startDir
m.showPublic = showPublic
m.NewSSHFilesModelFancyList()
return m
}
package compartment
import (
scopeFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
scopeUtil "github.com/cnopslabs/ocloud/cmd/shared/scope"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/identity/compartment"
"github.com/spf13/cobra"
)
// Long description for the get command
var getLong = `
Get all Compartments in the specified tenancy or compartment with pagination support.
This command displays information about compartments in the current tenancy or within
a specific parent compartment (depending on scope). By default, it shows basic
compartment information such as name, ID, and description.
Pagination:
- The output is paginated, with a default limit of 20 compartments per page.
- Navigate through pages using the --page flag and control items per page with --limit.
Output formats:
- Use --json (-j) to output the results in JSON format; otherwise a table is shown.
Scope control:
- Use --scope to choose where to list from: "compartment" (default) or "tenancy".
- Use -T/--tenancy-scope as a shortcut to force tenancy-level listing; it overrides --scope.
- When scope is tenancy, the command lists all compartments in the tenancy (including subtree).
- When scope is compartment, the command lists only the direct children of the configured compartment.`
// Examples for the get command
var getExamples = `
# Get all compartments with default pagination (20 per page)
ocloud identity compartment get
# List at tenancy level (equivalent ways)
ocloud identity compartment get -T
ocloud identity compartment get --scope tenancy
# List direct children of the configured compartment (explicit)
ocloud identity compartment get --scope compartment
# Get compartments with custom pagination (10 per page, page 2)
ocloud identity compartment get --limit 10 --page 2
# Get compartments and output in JSON format
ocloud identity compartment get --json
# Get compartments with custom pagination and JSON output
ocloud identity compartment get --limit 5 --page 3 --json
`
// NewGetCmd creates a new Cobra command for getting compartments in a specified tenancy or compartment.
// It supports pagination and optional JSON output.
func NewGetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Get all Compartments in the specified tenancy or compartment",
Long: getLong,
Example: getExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runGetCommand(cmd, appCtx)
},
}
scopeFlags.LimitFlag.Add(cmd)
scopeFlags.PageFlag.Add(cmd)
scopeFlags.ScopeFlag.Add(cmd)
scopeFlags.TenancyScopeFlag.Add(cmd)
return cmd
}
// runGetCommand handles the execution of the get command
func runGetCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, scopeFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, scopeFlags.FlagDefaultPage)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
scope := scopeUtil.ResolveScope(cmd)
parentID := scopeUtil.ResolveParentID(scope, appCtx)
logger.LogWithLevel(
logger.CmdLogger, logger.Debug, "Running compartment get",
"scope", scope, "parentID", parentID, "json", useJSON,
)
return compartment.GetCompartments(appCtx, useJSON, limit, page, parentID)
}
package compartment
import (
scopeFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
scopeUtil "github.com/cnopslabs/ocloud/cmd/shared/scope"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/identity/compartment"
"github.com/spf13/cobra"
)
var listLong = `
Interactively browse and search Compartments in the specified tenancy or parent compartment using a TUI.
This command launches a terminal UI that loads available Compartments and lets you:
- Search/filter Compartments as you type
- Navigate the list
- Select a single Compartment to view its details
After you pick a Compartment, the tool prints detailed information about the selected Compartment in a default table view or JSON format if specified with --json.
Scope control:
- Use --scope to choose where to list from: "compartment" (default) or "tenancy".
- Use -T/--tenancy-scope as a shortcut to force tenancy-level listing; it overrides --scope.
- When scope is tenancy, the TUI lists all compartments in the tenancy (including subtree).
- When scope is compartment, the TUI lists only the direct children of the configured compartment.
`
var listExamples = `
# Launch the interactive compartments browser (default scope: compartment)
ocloud identity compartment list
# List at tenancy level (equivalent ways)
ocloud identity compartment list -T
ocloud identity compartment list --scope tenancy
# List only direct children of the configured compartment (explicit)
ocloud identity compartment list --scope compartment
# Output selection in JSON format
ocloud identity compartment list --json
`
// NewListCmd creates a new Cobra command for getting compartments in a specified tenancy or compartment.
// It supports pagination and optional JSON output.
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "List all Compartments in the specified tenancy or compartment",
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runListCommand(cmd, appCtx)
},
}
scopeFlags.ScopeFlag.Add(cmd)
scopeFlags.TenancyScopeFlag.Add(cmd)
return cmd
}
// runListCommand handles the execution of the get command
func runListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
scope := scopeUtil.ResolveScope(cmd)
parentID := scopeUtil.ResolveParentID(scope, appCtx)
logger.LogWithLevel(
logger.CmdLogger, logger.Debug, "Running compartment list",
"scope", scope, "parentID", parentID, "json", useJSON,
)
return compartment.ListCompartments(appCtx, parentID, useJSON)
}
package compartment
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewCompartmentCmd creates a new command for compartment-related operations
func NewCompartmentCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "compartment",
Aliases: []string{"compart", "comp", "cmp", "c"},
Short: "Manage OCI Compartments",
Long: "Manage Oracle Cloud Infrastructure Compartments: list, get and search",
Example: " ocloud identity compartment get \n ocloud identity compartment list \n ocloud identity compartment search <value>",
SilenceUsage: true,
SilenceErrors: true,
}
// Add subcommands
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewSearchCmd(appCtx))
return cmd
}
package compartment
import (
scopeFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
scopeUtil "github.com/cnopslabs/ocloud/cmd/shared/scope"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/identity/compartment"
"github.com/spf13/cobra"
)
// Long description for the search command
var searchLong = `
Search for compartments in the specified scope that match the given pattern.
The search uses a fuzzy, prefix, and substring matching algorithm across multiple indexed fields.
You can search using any of the following fields (partial matches are supported):
Searchable fields:
- Name: Compartment display name
- Description: Compartment description
- OCID: Compartment OCID
- State: Lifecycle state (e.g., ACTIVE)
- TagsKV: All tags in key=value form, flattened
- TagsVal: Only tag values (e.g., "prod")
The search pattern is case-insensitive. For very specific inputs (like full OCID),
the search first tries exact and substring matches; otherwise it falls back to broader fuzzy search.
Scope control:
- Use --scope to choose where to search: "compartment" (default) or "tenancy".
- Use -T/--tenancy-scope to force tenancy-level search (overrides --scope).
- When scope is tenancy, the command searches across all compartments in the tenancy (including subtree).
- When scope is compartment, the command searches only the direct children of the configured compartment.
Output control:
- Use --json (-j) to output the results in JSON format
`
// Examples for the search command
var searchExamples = `
# Search by display name (substring)
ocloud identity compartment search prod
# Search across the entire tenancy
ocloud identity compartment search prod -T
ocloud identity compartment search prod --scope tenancy
# Search only direct children of the configured compartment
ocloud identity compartment search dev --scope compartment
# Search by OCID (exact)
ocloud identity compartment search ocid1.compartment.oc1..aaaa...
# Search by tag value only (TagsVal)
ocloud identity compartment search prod
# Output in JSON format
ocloud identity compartment search finance --json
`
// NewSearchCmd creates a new command for searching compartments
func NewSearchCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "search [pattern]",
Aliases: []string{"s"},
Short: "Fuzzy Search for Compartments",
Long: searchLong,
Example: searchExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSearchCommand(cmd, args, appCtx)
},
}
scopeFlags.ScopeFlag.Add(cmd)
scopeFlags.TenancyScopeFlag.Add(cmd)
return cmd
}
// RunFindCommand handles the execution of the find command
func runSearchCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
namePattern := args[0]
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
scope := scopeUtil.ResolveScope(cmd)
parentID := scopeUtil.ResolveParentID(scope, appCtx)
logger.LogWithLevel(
logger.CmdLogger, logger.Debug, "Running compartment search",
"scope", scope, "parentID", parentID, "json", useJSON,
)
return compartment.SearchCompartments(appCtx, namePattern, useJSON, parentID)
}
package policy
import (
paginationFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
scopeFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
scopeUtil "github.com/cnopslabs/ocloud/cmd/shared/scope"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/identity/policy"
"github.com/spf13/cobra"
)
var getLong = `
Get all Policies in the specified tenancy or compartment with pagination support.
This command displays information about policies either in the current tenancy or within
a specific parent compartment (depending on scope). By default, it shows basic policy
information such as name, ID, and description.
Pagination:
- The output is paginated, with a default limit of 20 policies per page.
- Navigate through pages using the --page flag and control items per page with --limit.
Output formats:
- Use --json (-j) to output the results in JSON format; otherwise a table is shown.
Scope control:
- Use --scope to choose where to list from: "compartment" (default) or "tenancy".
- Use -T/--tenancy-scope as a shortcut to force tenancy-level listing; it overrides --scope.
- When scope is tenancy, the command lists all policies in the tenancy (including subtree, if applicable).
- When scope is compartment, the command lists only the direct children of the configured compartment.`
var getExamples = `
# Get all policies with default pagination (20 per page)
ocloud identity policy get
# List at tenancy level (equivalent ways)
ocloud identity policy get -T
ocloud identity policy get --scope tenancy
# List direct children of the configured compartment (explicit)
ocloud identity policy get --scope compartment
# Get policies with custom pagination (10 per page, page 2)
ocloud identity policy get --limit 10 --page 2
# Get policies and output in JSON format
ocloud identity policy get --json
# Get policies with custom pagination and JSON output
ocloud identity policy get --limit 5 --page 3 --json
`
// NewGetCmd creates a new cobra.Command for get all policies in a specified tenancy or compartment.
// The command supports pagination through the --limit and --page flags for controlling get size and navigation.
// It also provides optional JSON output for formatted results using the --JSON flag.
func NewGetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Aliases: []string{"l"},
Short: "Get all Policies in the specified tenancy or compartment",
Long: getLong,
Example: getExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runGetCommand(cmd, appCtx)
},
}
paginationFlags.LimitFlag.Add(cmd)
paginationFlags.PageFlag.Add(cmd)
scopeFlags.ScopeFlag.Add(cmd)
scopeFlags.TenancyScopeFlag.Add(cmd)
return cmd
}
// runGetCommand handles the execution of the get command
func runGetCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, paginationFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, paginationFlags.FlagDefaultPage)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
scope := scopeUtil.ResolveScope(cmd)
parentID := scopeUtil.ResolveParentID(scope, appCtx)
logger.LogWithLevel(
logger.CmdLogger, logger.Debug, "Running policy get",
"scope", scope, "parentID", parentID, "json", useJSON,
)
return policy.GetPolicies(appCtx, useJSON, limit, page, parentID)
}
package policy
import (
scopeFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
scopeUtil "github.com/cnopslabs/ocloud/cmd/shared/scope"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/identity/policy"
"github.com/spf13/cobra"
)
var listLong = `
Interactively browse and search Policies in the specified tenancy or parent compartment using a TUI.
This command launches a terminal UI that loads available policies and lets you:
- Search/filter policies as you type
- Navigate the list
- Select a single policy to view its details
After you pick a policy, the tool prints detailed information about the selected policy in a default table view or JSON format if specified with --json.
Scope control:
- Use --scope to choose where to list from: "compartment" (default) or "tenancy".
- Use -T/--tenancy-scope as a shortcut to force tenancy-level listing; it overrides --scope.
- When scope is tenancy, the TUI lists all policies in the tenancy (including subtree).
- When scope is compartment, the TUI lists only the direct children of the configured compartment.
`
var listExamples = `
# Launch the interactive policies browser (default scope: compartment)
ocloud identity policy list
# List at tenancy level (equivalent ways)
ocloud identity policy list -T
ocloud identity policy list --scope tenancy
# List only direct children of the configured compartment (explicit)
ocloud identity policy list --scope compartment
# Output selection in JSON format
ocloud identity policy list --json
`
// NewListCmd returns "policy list".
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "List all Policies in the specified tenancy or compartment",
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runListCommand(cmd, appCtx)
},
}
scopeFlags.ScopeFlag.Add(cmd)
scopeFlags.TenancyScopeFlag.Add(cmd)
return cmd
}
// runListCommand handles the execution of the list command
func runListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
scope := scopeUtil.ResolveScope(cmd)
parentID := scopeUtil.ResolveParentID(scope, appCtx)
logger.LogWithLevel(
logger.CmdLogger, logger.Debug, "Running policy list",
"scope", scope, "parentID", parentID, "json", useJSON,
)
return policy.ListPolicies(appCtx, useJSON, parentID)
}
package policy
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewPolicyCmd creates a new command for policy-related operations
func NewPolicyCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "policy",
Aliases: []string{"pol"},
Short: "Manage OCI Policies",
Long: "Manage Oracle Cloud Infrastructure Policies: list, get, and search",
Example: " ocloud identity policy get \n ocloud identity policy list \n ocloud identity policy search <value>",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewSearchCmd(appCtx))
return cmd
}
package policy
import (
scopeFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
scopeUtil "github.com/cnopslabs/ocloud/cmd/shared/scope"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/identity/policy"
"github.com/spf13/cobra"
)
// Long description for the search command
var searchLong = `
Fuzzy search Policies in the specified tenancy or parent compartment that match the given pattern.
This command searches for policies whose fields match the specified pattern within
the chosen scope. By default, it searches within the configured parent compartment.
Search behavior:
- Uses a combination of fuzzy, prefix, and substring matching; partial and case-insensitive matches are supported.
- You can search using any of the following fields (partial matches are supported):
Searchable fields:
- Name: Policy name
- Description: Policy description
- OCID: Policy OCID
- Statements: Policy statements (combined)
- TagsKV: Tags in key:value or namespace.key:value format
- TagsVal: Tag values only (without keys)
Scope control:
- Use --scope to choose where to search: "compartment" (default) or "tenancy".
- Use -T/--tenancy-scope as a shortcut to force tenancy-level search; it overrides --scope.
- When scope is tenancy, the command searches across all compartments in the tenancy (including subtree).
- When scope is compartment, the command searches only the direct children of the configured compartment.
Additional Information:
- Use --json (-j) to output the results in JSON format
`
// Examples for the search command
var searchExamples = `
# FuzzySearch policies with names containing "admin" (default scope: compartment)
ocloud identity policy search admin
# Search at tenancy level (equivalent ways)
ocloud identity policy search admin -T
ocloud identity policy search admin --scope tenancy
# Search only direct children of the configured compartment (explicit)
ocloud identity policy search admin --scope compartment
# FuzzySearch policies with names containing "network" and output in JSON format
ocloud identity policy search network --json
`
// NewSearchCmd creates a new command for finding policies by name pattern
func NewSearchCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "search [pattern]",
Aliases: []string{"s"},
Short: "Fuzzy Search for Policies",
Long: searchLong,
Example: searchExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSearchCommand(cmd, args, appCtx)
},
}
scopeFlags.ScopeFlag.Add(cmd)
scopeFlags.TenancyScopeFlag.Add(cmd)
return cmd
}
// RunFindCommand handles the execution of the find command
func runSearchCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
search := args[0]
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
scope := scopeUtil.ResolveScope(cmd)
parentID := scopeUtil.ResolveParentID(scope, appCtx)
logger.LogWithLevel(
logger.CmdLogger, logger.Debug, "Running policy search",
"scope", scope, "parentID", parentID, "json", useJSON,
)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running policy search command", "search", search, "json", useJSON)
return policy.SearchPolicies(appCtx, search, useJSON, parentID)
}
package identity
import (
"github.com/cnopslabs/ocloud/cmd/identity/bastion"
"github.com/cnopslabs/ocloud/cmd/identity/compartment"
"github.com/cnopslabs/ocloud/cmd/identity/policy"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewIdentityCmd creates a new cobra.Command for managing OCI identity services such as compartments, polices and bastions.
func NewIdentityCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "identity",
Aliases: []string{"ident", "idt"},
Short: "Manage OCI identity services",
Long: "Manage Oracle Cloud Infrastructure Identity services such as compartments, policies, bastion sessions",
SilenceUsage: true,
SilenceErrors: true,
}
// Add subcommands, passing in the ApplicationContext
cmd.AddCommand(bastion.NewBastionCmd(appCtx))
cmd.AddCommand(compartment.NewCompartmentCmd(appCtx))
cmd.AddCommand(policy.NewPolicyCmd(appCtx))
return cmd
}
package loadbalancer
import (
lbFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
configflags "github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
lbservice "github.com/cnopslabs/ocloud/internal/services/network/loadbalancer"
"github.com/spf13/cobra"
)
var getLong = `Get all load balancers in the specified compartment with pagination support.
This command displays information about load balancers in the current compartment.
By default, it shows a concise table with key fields. Use flags to control pagination
and detail level.`
var getExamples = ` # Get all load balancers with default pagination (20 per page)
ocloud network loadbalancer get
# Get load balancers with custom pagination (10 per page, page 2)
ocloud network loadbalancer get --limit 10 --page 2
# Get load balancers and include extra details in the table
ocloud network loadbalancer get --all
# Output in JSON format
ocloud net lb get --json`
func NewGetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Get Load Balancer Paginated Results",
Long: getLong,
Example: getExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runGetCommand(cmd, appCtx)
},
}
lbFlags.LimitFlag.Add(cmd)
lbFlags.PageFlag.Add(cmd)
lbFlags.AllInfoFlag.Add(cmd)
return cmd
}
func runGetCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
limit := configflags.GetIntFlag(cmd, configflags.FlagNameLimit, lbFlags.FlagDefaultLimit)
page := configflags.GetIntFlag(cmd, configflags.FlagNamePage, lbFlags.FlagDefaultPage)
useJSON := configflags.GetBoolFlag(cmd, configflags.FlagNameJSON, false)
showAll := configflags.GetBoolFlag(cmd, configflags.FlagNameAll, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running load balancer get command", "compartment", appCtx.CompartmentName, "limit", limit, "page", page, "json", useJSON, "all", showAll)
return lbservice.GetLoadBalancers(appCtx, useJSON, limit, page, showAll)
}
package loadbalancer
import (
instaceFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
configflags "github.com/cnopslabs/ocloud/internal/config/flags"
lbdomain "github.com/cnopslabs/ocloud/internal/services/network/loadbalancer"
"github.com/spf13/cobra"
)
var listLong = `
Interactively browse and search Load Balancers in the specified compartment using a TUI.
This command launches a terminal UI that loads available Load Balancers and lets you:
- Search/filter Load Balancers as you type
- Navigate the list
- Select a single Load Balancer to view its details
After you pick a Load Balancer, the tool prints detailed information about the selected Load Balancer in the default table view or JSON format if specified with --json (-j).
You can also toggle inclusion of extra columns via --all (-A).
`
var listExamples = `
# Launch the interactive Load Balancer browser
ocloud network loadbalancer list
# Include extra columns in the table output
ocloud network loadbalancer list --all
# Output in JSON
ocloud network loadbalancer list --json
# Using short aliases
ocloud net lb list -A -j
`
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "Lists Load Balancers in a compartment",
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runListCommand(cmd, appCtx)
},
}
instaceFlags.AllInfoFlag.Add(cmd)
return cmd
}
func runListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
useJSON := configflags.GetBoolFlag(cmd, configflags.FlagNameJSON, false)
showAll := configflags.GetBoolFlag(cmd, configflags.FlagNameAll, false)
return lbdomain.ListLoadBalancers(appCtx, useJSON, showAll)
}
package loadbalancer
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewLoadBalancerCmd creates a new command group for Load Balancer operations
func NewLoadBalancerCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "load-balancer",
Aliases: []string{"loadbalancer", "lb", "lbr"},
Short: "Manage OCI Network Load Balancers",
Long: "Manage Oracle Cloud Infrastructure Network Load Balancers such as LBs, listeners, backend sets, and more",
Example: " ocloud network load-balancer get \n ocloud network load-balancer list \n ocloud network load-balancer search <value>",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewSearchCmd(appCtx))
return cmd
}
package loadbalancer
import (
lbFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
configflags "github.com/cnopslabs/ocloud/internal/config/flags"
lbservice "github.com/cnopslabs/ocloud/internal/services/network/loadbalancer"
"github.com/spf13/cobra"
)
var searchLong = `
Search for Load Balancers in the specified compartment that match the given pattern.
The search uses a combination of fuzzy, prefix, token, and substring matching across indexed fields.
You can search using any of the following fields (partial matches are supported):
Searchable fields:
- Name: Display name
- OCID: Load Balancer OCID
- Type: Public or private
- State: Lifecycle state
- VcnName: Name of the VCN
- Shape: Load balancer shape
- IPAddresses: All assigned IP addresses
- Hostnames: Associated hostnames
- SSLCertificates: Attached SSL certificate names
- Subnets: Subnet names/ids
Additional information:
- Use --all (-A) to include extra details in the output table
- Use --json (-j) to output the results in JSON format
- The search is case-insensitive. For highly specific inputs (like full OCIDs), exact and substring
matches are attempted before broader fuzzy search.
`
var searchExamples = `
# Search load balancers whose name contains "prod"
ocloud network loadbalancer search prod
# Search by hostname
ocloud network loadbalancer search example.com
# Include extra details in the table
ocloud network loadbalancer search prod --all
# Use JSON output
ocloud network loadbalancer search prod --json
# Short aliases
ocloud net lb s prod -A -j
`
func NewSearchCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "search <pattern>",
Aliases: []string{"s"},
Short: "Fuzzy search for Load Balancers",
Long: searchLong,
Example: searchExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSearchCommand(cmd, args, appCtx)
},
}
lbFlags.AllInfoFlag.Add(cmd)
return cmd
}
func runSearchCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
namePattern := args[0]
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
showAll := configflags.GetBoolFlag(cmd, configflags.FlagNameAll, false)
return lbservice.SearchLoadBalancer(appCtx, namePattern, useJSON, showAll)
}
package network
import (
lbcmd "github.com/cnopslabs/ocloud/cmd/network/loadbalancer"
"github.com/cnopslabs/ocloud/cmd/network/subnet"
vcncmd "github.com/cnopslabs/ocloud/cmd/network/vcn"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewNetworkCmd creates a new cobra.Command for managing OCI network services such as vcn, subnets, load balancers and more
func NewNetworkCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "network",
Aliases: []string{"net"},
Short: "Manage OCI network services",
Long: "Manage Oracle Cloud Infrastructure Networking services such as vcn, subnets and more.",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(subnet.NewSubnetCmd(appCtx))
cmd.AddCommand(vcncmd.NewVcnCmd(appCtx))
cmd.AddCommand(lbcmd.NewLoadBalancerCmd(appCtx))
return cmd
}
package subnet
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/network/subnet"
"github.com/spf13/cobra"
)
// Long description for the find command
var findLong = `
FuzzySearch Subnets in the specified tenancy or compartment that match the given pattern.
This command searches for subnets whose names match the specified pattern.
By default, it shows detailed subnet information such as name, ID, CIDR block,
and whether public IP addresses are allowed for all matching subnets.
The search is performed using fuzzy matching, which means it will find subnets
even if the pattern is only partially matched. The search is case-insensitive.
Additional Information:
- Use --json (-j) to output the results in JSON format
- The command searches across all available subnets in the compartment
`
// Examples for the find command
var findExamples = `
# FuzzySearch subnets with names containing "prod"
ocloud network subnet find prod
# FuzzySearch subnets with names containing "dev" and output in JSON format
ocloud network subnet find dev --json
# FuzzySearch subnets with names containing "test" (case-insensitive)
ocloud network subnet find test
`
// NewFindCmd creates a new command for finding subnets by name pattern
func NewFindCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "find [pattern]",
Aliases: []string{"f"},
Short: "FuzzySearch Subnets by name pattern",
Long: findLong,
Example: findExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return RunFindCommand(cmd, args, appCtx)
},
}
return cmd
}
// RunFindCommand handles the execution of the find command
func RunFindCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
namePattern := args[0]
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running subnet find command", "pattern", namePattern, "json", useJSON)
return subnet.FindSubnets(appCtx, namePattern, useJSON)
}
package subnet
import (
paginationFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/network/subnet"
"github.com/spf13/cobra"
)
// Long description for the list command
var listLong = `
FetchPaginatedClusters all Subnets in the specified tenancy or compartment.
This command displays information about all subnets in the current compartment,
including their names, CIDR blocks, and whether they allow public IP addresses.
By default, it shows basic subnet information in a tabular format.
Additional Information:
- Use --json (-j) to output the results in JSON format
- Use --limit (-m) to control the number of results per page
- Use --page (-p) to navigate between pages of results
- Use --sort (-s) to sort results by name or CIDR
`
// Examples for the list command
var listExamples = `
# FetchPaginatedClusters all subnets in the current compartment
ocloud network subnet list
# FetchPaginatedClusters all subnets and output in JSON format
ocloud network subnet list --json
# FetchPaginatedClusters subnets with pagination (10 per page, page 2)
ocloud network subnet list --limit 10 --page 2
# FetchPaginatedClusters subnets sorted by name
ocloud network subnet list --sort name
# FetchPaginatedClusters subnets sorted by CIDR block
ocloud network subnet list --sort cidr
`
// NewListCmd creates a new "list" command for listing all subnets in a specified tenancy or compartment.
// Accepts an ApplicationContext for accessing configuration and dependencies.
// Adds pagination flags for controlling the number of results returned and the page to retrieve.
// Executes the RunListCommand function when invoked.
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "FetchPaginatedClusters all Subnets in the specified tenancy or compartment",
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return RunListCommand(cmd, appCtx)
},
}
paginationFlags.LimitFlag.Add(cmd)
paginationFlags.PageFlag.Add(cmd)
paginationFlags.SortFlag.Add(cmd)
return cmd
}
// RunListCommand handles the execution of the list command
func RunListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, paginationFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, paginationFlags.FlagDefaultPage)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
sortBy := flags.GetStringFlag(cmd, flags.FlagNameSort, "")
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running subnet list command in", "compartment", appCtx.CompartmentName, "json", useJSON, "sort", sortBy)
return subnet.ListSubnets(appCtx, useJSON, limit, page, sortBy)
}
package subnet
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewSubnetCmd creates a new command for subnet-related operations
func NewSubnetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "subnet",
Aliases: []string{"sub"},
Short: "Manage OCI Subnets",
Long: "Manage Oracle Cloud Infrastructure Subnets - list all subnets or find subnet by pattern.",
Example: " ocloud network subnet list \n ocloud network subnet find mysubnet",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewFindCmd(appCtx))
return cmd
}
package vcn
import (
networkFlags "github.com/cnopslabs/ocloud/cmd/network/flags"
vcnFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
netvcn "github.com/cnopslabs/ocloud/internal/services/network/vcn"
"github.com/spf13/cobra"
)
// Long description for the get command
var getLong = `
Fetch VCNs in the specified compartment with pagination support.
This command retrieves Virtual Cloud Networks (VCNs) in the current compartment.
By default, it shows basic information such as name, OCID, state, compartment, and CIDR blocks.
The output is paginated. Control the number of VCNs per page with --limit (-m) and
navigate pages using --page (-p).
Additional Information:
- Use --json (-j) to output the results in JSON format
- Use flags to include related resources: gateways, subnets, NSGs, route tables, security lists
`
// Examples for the get command
var getExamples = `
# Get VCNs with default pagination
ocloud network vcn get
# Get VCNs with custom pagination (10 per page, page 2)
ocloud network vcn get --limit 10 --page 2
# Include related resources
ocloud network vcn get --gateway --subnet --nsg --route-table --security-list
# Include all related resources at once
ocloud network vcn get --all
# JSON output with short aliases
ocloud network vcn get -m 5 -p 3 -A -j
`
// NewGetCmd returns "vcn get" command.
func NewGetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Get VCNs",
Long: getLong,
Example: getExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runGetCommand(cmd, appCtx)
},
}
networkFlags.Gateway.Add(cmd)
networkFlags.Subnet.Add(cmd)
networkFlags.Nsg.Add(cmd)
networkFlags.RouteTable.Add(cmd)
networkFlags.SecurityList.Add(cmd)
vcnFlags.AllInfoFlag.Add(cmd)
vcnFlags.LimitFlag.Add(cmd)
vcnFlags.PageFlag.Add(cmd)
return cmd
}
// RunGetCommand executes the get logic
func runGetCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, vcnFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, vcnFlags.FlagDefaultPage)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
gateways := flags.GetBoolFlag(cmd, flags.FlagNameGateway, false)
subnets := flags.GetBoolFlag(cmd, flags.FlagNameSubnet, false)
nsgs := flags.GetBoolFlag(cmd, flags.FlagNameNsg, false)
routes := flags.GetBoolFlag(cmd, flags.FlagNameRoute, false)
securityLists := flags.GetBoolFlag(cmd, flags.FlagNameSecurity, false)
showAll := flags.GetBoolFlag(cmd, flags.FlagNameAll, false)
if showAll {
gateways, subnets, nsgs, routes, securityLists = true, true, true, true, true
}
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running network vcn get", "json", useJSON, "all", showAll)
return netvcn.GetVCNs(appCtx, limit, page, useJSON, gateways, subnets, nsgs, routes, securityLists)
}
package vcn
import (
networkFlags "github.com/cnopslabs/ocloud/cmd/network/flags"
vcnFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
cfgflags "github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
netvcn "github.com/cnopslabs/ocloud/internal/services/network/vcn"
"github.com/spf13/cobra"
)
var listLong = `
Interactively browse and search VCNs in the specified compartment using a TUI.
This command launches a terminal UI that loads available Virtual Cloud Networks (VCNs) and lets you:
- Search/filter VCNs as you type
- Navigate the list
- Select a single VCN to view its details
After you pick a VCN, the tool prints detailed information about the selected VCN in the default table view or JSON format if specified with --json (-j).
You can also toggle inclusion of related networking resources via flags.
`
var listExamples = `
# Launch the interactive VCN browser
ocloud network vcn list
# Launch and include related network resources
ocloud network vcn list --gateway --subnet --nsg --route-table --security-list
# Include all related resources at once
ocloud network vcn list --all
# Output in JSON
ocloud network vcn list --json
# Using short aliases
ocloud network vcn list -A -j
`
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "Lists VCNs in a compartment",
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runListCommand(cmd, appCtx)
},
}
networkFlags.Gateway.Add(cmd)
networkFlags.Subnet.Add(cmd)
networkFlags.Nsg.Add(cmd)
networkFlags.RouteTable.Add(cmd)
networkFlags.SecurityList.Add(cmd)
vcnFlags.AllInfoFlag.Add(cmd)
return cmd
}
func runListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
useJSON := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameJSON, false)
gateways := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameGateway, false)
subnets := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameSubnet, false)
nsgs := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameNsg, false)
routes := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameRoute, false)
securityLists := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameSecurity, false)
showAll := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameAll, false)
if showAll {
gateways, subnets, nsgs, routes, securityLists = true, true, true, true, true
}
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running network vcn list", "json", useJSON, "all", showAll)
return netvcn.ListVCNs(appCtx, useJSON, gateways, subnets, nsgs, routes, securityLists)
}
package vcn
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
// NewVcnCmd creates a new command group for VCN-related operations
func NewVcnCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "vcn",
Short: "Manage OCI Virtual Cloud Networks (VCNs)",
Long: " ocloud network vcn list \n ocloud network vcn get \n ocloud network vcn search <value>",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewSearchCmd(appCtx))
return cmd
}
package vcn
import (
networkFlags "github.com/cnopslabs/ocloud/cmd/network/flags"
vcnFlags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
cfgflags "github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
netvcn "github.com/cnopslabs/ocloud/internal/services/network/vcn"
"github.com/spf13/cobra"
)
var searchLong = `
Search for Virtual Cloud Networks (VCNs) in the specified compartment that match the given pattern.
The search uses a combination of fuzzy, prefix, token, and substring matching across indexed fields.
You can search using any of the following fields (partial matches are supported):
Searchable fields:
- Name: Display name
- OCID: VCN OCID
- State: Lifecycle state
- CIDRs: IPv4/IPv6 CIDR blocks
- DnsLabel: DNS label
- DomainName: VCN domain name
- TagsKV/TagsVal: Flattened tag keys and values
- Gateways/Subnets/NSGs/RouteTables/SecLists: Related resource names
Additional information:
- Use --all (-A) to include related resources in the output (gateways, subnets, NSGs, route tables, security lists)
- Use --json (-j) to output the results in JSON format
- The search is case-insensitive. For highly specific inputs (like full OCIDs), exact and substring
matches are attempted before broader fuzzy search.
`
var searchExamples = `
# Search VCNs whose name contains "prod"
ocloud network vcn search prod
# Search by DNS label or domain name
ocloud network vcn search corp
# Include related resources in the output table
ocloud network vcn search prod --all
# Use JSON output
ocloud network vcn search prod --json
# Short aliases
ocloud net vcn s prod -A -j
`
func NewSearchCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "search <pattern>",
Aliases: []string{"s"},
Short: "Fuzzy search for VCNs",
Long: searchLong,
Example: searchExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSearchCommand(cmd, args, appCtx)
},
}
networkFlags.Gateway.Add(cmd)
networkFlags.Subnet.Add(cmd)
networkFlags.Nsg.Add(cmd)
networkFlags.RouteTable.Add(cmd)
networkFlags.SecurityList.Add(cmd)
vcnFlags.AllInfoFlag.Add(cmd)
return cmd
}
func runSearchCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
pattern := args[0]
useJSON := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameJSON, false)
gateways := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameGateway, false)
subnets := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameSubnet, false)
nsgs := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameNsg, false)
routes := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameRoute, false)
securityLists := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameSecurity, false)
showAll := cfgflags.GetBoolFlag(cmd, cfgflags.FlagNameAll, false)
if showAll {
gateways, subnets, nsgs, routes, securityLists = true, true, true, true, true
}
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running network vcn search", "pattern", pattern, "json", useJSON, "all", showAll)
return netvcn.SearchVCNs(appCtx, pattern, useJSON, gateways, subnets, nsgs, routes, securityLists)
}
package cmd
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/cmd/shared/cmdcreate"
"github.com/cnopslabs/ocloud/cmd/shared/cmdutil"
"github.com/cnopslabs/ocloud/cmd/shared/display"
cmdlogger "github.com/cnopslabs/ocloud/cmd/shared/logger"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/spf13/cobra"
)
// NewRootCmd creates a new root command with all subcommands attached
func NewRootCmd(appCtx *app.ApplicationContext) *cobra.Command {
return cmdcreate.CreateRootCmd(appCtx)
}
// Execute runs the root command with the given context.
// It now returns an error instead of exiting directly.
func Execute(ctx context.Context) error {
tempRoot := &cobra.Command{
Use: "ocloud",
Short: "Interact with Oracle Cloud Infrastructure",
Long: "",
SilenceUsage: true,
SilenceErrors: true,
}
flags.AddGlobalFlags(tempRoot)
if err := cmdlogger.SetLogLevel(tempRoot); err != nil {
return fmt.Errorf("setting log level: %w", err)
}
if cmdutil.IsNoContextCommand() {
root := cmdcreate.CreateRootCmdWithoutContext()
if cmdutil.IsRootCommandWithoutSubcommands() {
display.PrintOCIConfiguration()
}
if err := root.ExecuteContext(ctx); err != nil {
return fmt.Errorf("failed to execute root command: %w", err)
}
return nil
}
appCtx, err := InitializeAppContext(ctx, tempRoot)
if err != nil {
return fmt.Errorf("initializing app context: %w", err)
}
if cmdutil.IsRootCommandWithoutSubcommands() {
display.PrintOCIConfiguration()
}
root := cmdcreate.CreateRootCmd(appCtx)
root.RunE = func(cmd *cobra.Command, args []string) error {
return cmd.Help()
}
if err := root.ExecuteContext(ctx); err != nil {
return fmt.Errorf("failed to execute root command: %w", err)
}
return nil
}
package cmdcreate
import (
"fmt"
"os"
"github.com/cnopslabs/ocloud/cmd/compute"
"github.com/cnopslabs/ocloud/cmd/configuration"
"github.com/cnopslabs/ocloud/cmd/database"
"github.com/cnopslabs/ocloud/cmd/identity"
"github.com/cnopslabs/ocloud/cmd/network"
"github.com/cnopslabs/ocloud/cmd/storage"
"github.com/cnopslabs/ocloud/cmd/version"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/spf13/cobra"
)
// CreateRootCmd creates a root command with or without application context
// If appCtx is nil, only commands that don't need context are added
// If appCtx is not nil, all commands are added
func CreateRootCmd(appCtx *app.ApplicationContext) *cobra.Command {
rootCmd := &cobra.Command{
Use: "ocloud",
Short: "Interact with Oracle Cloud Infrastructure",
Long: "",
SilenceUsage: true,
}
// Initialize global flags
flags.AddGlobalFlags(rootCmd)
// Add commands that don't need context
rootCmd.AddCommand(version.NewVersionCommand())
version.AddVersionFlag(rootCmd, os.Stdout)
rootCmd.AddCommand(configuration.NewConfigCmd())
// If appCtx is not nil, add commands that need context
if appCtx != nil {
rootCmd.AddCommand(compute.NewComputeCmd(appCtx))
rootCmd.AddCommand(identity.NewIdentityCmd(appCtx))
rootCmd.AddCommand(database.NewDatabaseCmd(appCtx))
rootCmd.AddCommand(network.NewNetworkCmd(appCtx))
rootCmd.AddCommand(storage.NewStorageCmd(appCtx))
}
return rootCmd
}
// CreateRootCmdWithoutContext creates a root command without application context
// This is used for commands that don't need a full context
func CreateRootCmdWithoutContext() *cobra.Command {
rootCmd := CreateRootCmd(nil)
addPlaceholderCommands(rootCmd)
rootCmd.RunE = func(cmd *cobra.Command, args []string) error {
return cmd.Help()
}
return rootCmd
}
// addPlaceholderCommands adds placeholder commands that will be displayed in help
// but will show a message about needing to initialize if they're actually run
func addPlaceholderCommands(rootCmd *cobra.Command) {
commandTypes := []struct {
use string
short string
}{
{"compute", "Manage OCI compute services"},
{"identity", "Manage OCI identity services"},
{"database", "Manage OCI Database services"},
{"network", "Manage OCI network services"},
{"storage", "Manage OCI Storage services"},
}
for _, cmdType := range commandTypes {
cmd := &cobra.Command{
Use: cmdType.use,
Short: cmdType.short,
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("this command requires application initialization")
},
}
rootCmd.AddCommand(cmd)
}
}
package cmdutil
import (
"os"
)
// IsNoContextCommand checks if a command doesn't need a full application context
func IsNoContextCommand() bool {
args := os.Args
if len(args) < 2 {
return true
}
// Commands that don't need context
noContextCommands := map[string]bool{
"version": true,
"config": true,
}
// Flags that don't need context
noContextFlags := map[string]bool{
"--version": true,
"-v": true,
}
if noContextCommands[args[1]] {
return true
}
for _, arg := range args[1:] {
if noContextFlags[arg] {
return true
}
}
return false
}
// IsRootCommandWithoutSubcommands checks if the command being executed is the root command without any subcommands or flags
func IsRootCommandWithoutSubcommands() bool {
args := os.Args
return len(args) == 1
}
package display
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/cnopslabs/ocloud/buildinfo"
"github.com/cnopslabs/ocloud/internal/config"
"github.com/fatih/color"
"github.com/cnopslabs/ocloud/internal/config/flags"
)
var (
boldStyle = color.New(color.Bold)
redStyle = color.New(color.FgRed)
greenStyle = color.New(color.FgGreen)
yellowStyle = color.New(color.FgYellow)
regularStyle = color.New(color.FgWhite)
)
var validRe = regexp.MustCompile(`(?i)^Session is valid until\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s*$`)
var expiredRe = regexp.MustCompile(`(?i)^Session has expired\s*$`)
// CheckOCISessionValidity checks the validity of the OCI session
func CheckOCISessionValidity(profile string) string {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "oci", "session", "validate", "--profile", profile)
out, err := cmd.CombinedOutput()
raw := strings.TrimSpace(string(out))
if matches := validRe.FindStringSubmatch(raw); len(matches) > 1 {
return greenStyle.Sprintf("Valid until %s", matches[1])
} else if expiredRe.MatchString(raw) {
return redStyle.Sprint("Session Expired")
} else {
if err != nil {
return redStyle.Sprintf("Error checking session: %v", err)
} else {
return yellowStyle.Sprintf("Unknown status: %s", raw)
}
}
}
// RefresherStatus represents the status of the OCI auth refresher
type RefresherStatus struct {
IsRunning bool
PID string
Display string
}
// CheckOCIAuthRefresherStatus checks if the OCI auth refresher script is running for the current profile
func CheckOCIAuthRefresherStatus() RefresherStatus {
profile := os.Getenv(flags.EnvKeyProfile)
if profile == "" {
return RefresherStatus{
IsRunning: false,
PID: "",
Display: redStyle.Sprint("OFF"),
}
}
homeDir, err := os.UserHomeDir()
if err != nil {
return RefresherStatus{
IsRunning: false,
PID: "",
Display: redStyle.Sprint("OFF"),
}
}
pidFilePath := filepath.Join(homeDir, flags.OCIConfigDirName, flags.OCISessionsDirName, profile, flags.OCIRefresherPIDFile)
pidBytes, err := os.ReadFile(pidFilePath)
if err != nil {
return RefresherStatus{
IsRunning: false,
PID: "",
Display: redStyle.Sprint("OFF"),
}
}
pidStr := strings.TrimSpace(string(pidBytes))
// Check if the process with this PID is running
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Check if the process exists
cmd := exec.CommandContext(ctx, "ps", "-p", pidStr, "-o", "pid=")
if err := cmd.Run(); err != nil {
_ = os.Remove(pidFilePath)
return RefresherStatus{
IsRunning: false,
PID: "",
Display: redStyle.Sprint("OFF"),
}
}
// Then check if it's actually the refresher script for this profile
cmd = exec.CommandContext(ctx, "pgrep", "-af", fmt.Sprintf("oci_auth_refresher.sh.*%s", profile))
out, err := cmd.CombinedOutput()
outStr := strings.TrimSpace(string(out))
if err == nil && len(outStr) > 0 && strings.Contains(outStr, pidStr) {
return RefresherStatus{
IsRunning: true,
PID: pidStr,
Display: greenStyle.Sprintf("ON [%s]", pidStr),
}
}
// Process exists, but it's not the refresher script for this profile
// Remove the PID file as it's stale
_ = os.Remove(pidFilePath)
return RefresherStatus{
IsRunning: false,
PID: "",
Display: redStyle.Sprint("OFF"),
}
}
// PrintOCIConfiguration displays the current configuration details
func PrintOCIConfiguration() {
displayBanner()
profile := os.Getenv(flags.EnvKeyProfile)
// Handle session status and profile display together to avoid redundancy
var sessionStatus string
if profile == "" {
sessionStatus = redStyle.Sprint("Not set - Please set profile")
fmt.Printf("%s %s\n", boldStyle.Sprint("Configuration Details:"), sessionStatus)
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyProfile), sessionStatus)
} else {
sessionStatus = CheckOCISessionValidity(profile)
fmt.Printf("%s %s\n", boldStyle.Sprint("Configuration Details:"), sessionStatus)
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyProfile), profile)
}
region, err := config.LoadOCIConfig().Region()
if profile == "" {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyRegion), redStyle.Sprint("Not set - Please set profile first"))
} else if err != nil {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyRegion), redStyle.Sprintf("Error loading region: %v", err))
} else {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyRegion), region)
}
tenancyName := os.Getenv(flags.EnvKeyTenancyName)
if tenancyName == "" {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyTenancyName), redStyle.Sprint("Not set - Please set tenancy"))
} else {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyTenancyName), tenancyName)
}
compartment := os.Getenv(flags.EnvKeyCompartment)
if compartment == "" {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyCompartment), redStyle.Sprint("Not set - Please set compartment name"))
} else {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyCompartment), compartment)
}
refresherStatus := CheckOCIAuthRefresherStatus()
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyAutoRefresher), refresherStatus.Display)
path := config.TenancyMapPath()
_, err = os.Stat(path)
if os.IsNotExist(err) {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyTenancyMapPath), redStyle.Sprint("Not set (file not found)"))
} else if err != nil {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyTenancyMapPath), redStyle.Sprintf("Error checking file: %v", err))
} else {
fmt.Printf(" %s: %s\n", yellowStyle.Sprint(flags.EnvKeyTenancyMapPath), path)
}
fmt.Println()
}
// displayBanner displays the OCloud ASCII art banner
func displayBanner() {
fmt.Println()
fmt.Println(" ██████╗ ██████╗██╗ ██████╗ ██╗ ██╗██████╗ ")
fmt.Println("██╔═══██╗██╔════╝██║ ██╔═══██╗██║ ██║██╔══██╗")
fmt.Println("██║ ██║██║ ██║ ██║ ██║██║ ██║██║ ██║")
fmt.Println("██║ ██║██║ ██║ ██║ ██║██║ ██║██║ ██║")
fmt.Println("╚██████╔╝╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝")
fmt.Println(" ╚═════╝ ╚═════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝")
fmt.Println()
fmt.Printf("\t %s: %s\n", regularStyle.Sprint("Version"), regularStyle.Sprint(buildinfo.Version))
fmt.Println()
}
package logger
import (
"fmt"
"os"
"github.com/cnopslabs/ocloud/cmd/version"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/spf13/cobra"
)
// SetLogLevel sets the logging level and colored output based on command-line flags or default values.
func SetLogLevel(tempRoot *cobra.Command) error {
for _, arg := range os.Args {
if arg == flags.FlagPrefixVersion || arg == flags.FlagPrefixShortVersion {
version.PrintVersion()
os.Exit(0)
}
}
tempRoot.ParseFlags(os.Args)
// Parse the flags to get the log level Should be approach, but for some reason it prevents parsing flags and give an error
//if err: = tempRoot.ParseFlags(os.Args); err != nil {
// return fmt.Errorf("parsing flags: %w", err)
//}
// Check for a debug flag first - it takes precedence over log-level
debugFlag := tempRoot.PersistentFlags().Lookup(flags.FlagNameDebug)
if debugFlag != nil && debugFlag.Value.String() == flags.FlagValueTrue {
// If a debug flag is set, set the log level to debug
logger.LogLevel = flags.FlagNameDebug
} else {
// Otherwise, use the log-level flag
logLevelFlag := tempRoot.PersistentFlags().Lookup(flags.FlagNameLogLevel)
if logLevelFlag != nil {
// Use the value from the parsed flag
logger.LogLevel = logLevelFlag.Value.String()
if logger.LogLevel == "" {
// If not set, use the default value
logger.LogLevel = flags.FlagValueInfo
}
}
}
// This is a Hack!
// Check if -d or --debug flag is explicitly set in the command line arguments
// This ensures that debug mode is set correctly regardless of whether
// the full command or shorthand flags are used
for _, arg := range os.Args {
if arg == flags.FlagPrefixDebug || arg == flags.FlagPrefixShortDebug {
logger.LogLevel = flags.FlagNameDebug
break
}
}
// Set the colored output from the flag value
colorFlag := tempRoot.PersistentFlags().Lookup(flags.FlagNameColor)
if colorFlag != nil {
// Use the value from the parsed flag
colorValue := colorFlag.Value.String()
logger.ColoredOutput = colorValue == flags.FlagValueTrue
}
// This is a Hack!
// Check if --color flag is explicitly set in the command line arguments
// This ensures that the color setting is set correctly regardless of whether
// the full command or shorthand flags are used
for _, arg := range os.Args {
if arg == flags.FlagPrefixColor {
logger.ColoredOutput = true
break
}
}
// Initialize logger
if err := logger.SetLogger(); err != nil {
return fmt.Errorf("initializing logger: %w", err)
}
logger.InitLogger(logger.CmdLogger)
return nil
}
package scope
import (
"strings"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/spf13/cobra"
)
const (
Compartment = "compartment"
Tenancy = "tenancy"
)
// ResolveScope returns the final scope respecting precedence:
func ResolveScope(cmd *cobra.Command) string {
if flags.GetBoolFlag(cmd, flags.FlagNameTenancyScope, false) {
return Tenancy
}
scope := strings.ToLower(flags.GetStringFlag(cmd, flags.FlagNameScope, Compartment))
switch scope {
case Tenancy:
return Tenancy
case Compartment, "":
return Compartment
default:
return Compartment
}
}
// ResolveParentID maps scope parent OCID
func ResolveParentID(scope string, appCtx *app.ApplicationContext) string {
if scope == Tenancy {
return appCtx.TenancyID
}
if appCtx.CompartmentID != "" {
return appCtx.CompartmentID
}
return appCtx.TenancyID
}
package objectstorage
import (
osflags "github.com/cnopslabs/ocloud/cmd/shared/flags"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
osSvc "github.com/cnopslabs/ocloud/internal/services/storage/objectstorage"
"github.com/spf13/cobra"
)
var getLong = `Get Object Storage buckets in the specified compartment with pagination support.
This command lists Object Storage buckets in the current compartment. By default, it shows a concise table
with key fields (name, namespace, created). Use --all (-A) to include extended bucket details (tier, access,
versioning, encryption, counts) and --json (-j) for machine-readable output.`
var getExamples = ` # Get buckets with default pagination (20 per page)
ocloud storage object-storage get
# Get buckets with custom pagination (10 per page, page 2)
ocloud storage object-storage get --limit 10 --page 2
# Get buckets and include extended details in the table
ocloud storage object-storage get --all
# JSON output with short aliases
ocloud storage os get -A -j`
func NewGetCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Paginated Object Storage Result",
Long: getLong,
Example: getExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runGetCommand(cmd, appCtx)
},
}
osflags.LimitFlag.Add(cmd)
osflags.PageFlag.Add(cmd)
return cmd
}
func runGetCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, osflags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, osflags.FlagDefaultPage)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running object storage get command", "compartment", appCtx.CompartmentName, "limit", limit, "page", page, "json", useJSON)
return osSvc.GetBuckets(appCtx, limit, page, useJSON)
}
package objectstorage
import (
"github.com/cnopslabs/ocloud/internal/app"
configflags "github.com/cnopslabs/ocloud/internal/config/flags"
osSvc "github.com/cnopslabs/ocloud/internal/services/storage/objectstorage"
"github.com/spf13/cobra"
)
var listLong = `
Interactively browse and search Object Storage Buckets in the specified compartment using a TUI.
This command launches a terminal UI that loads available Buckets and lets you:
- Search/filter Buckets as you type
- Navigate the list
- Select a single Bucket to view its details
After you pick a Bucket, the tool prints detailed information about the selected Bucket in the default table view or JSON format if specified with --json (-j).
`
var listExamples = `
# Launch the interactive Bucket browser
ocloud storage object-storage list
# Output in JSON
ocloud storage object-storage list --json
# Using short aliases
ocloud stg os list -j
`
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "Lists Object Storage Buckets in a compartment",
Long: listLong,
Example: listExamples,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runListCommand(cmd, appCtx)
},
}
return cmd
}
func runListCommand(cmd *cobra.Command, appCtx *app.ApplicationContext) error {
useJSON := configflags.GetBoolFlag(cmd, configflags.FlagNameJSON, false)
return osSvc.ListBuckets(appCtx, useJSON)
}
package objectstorage
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
func NewObjectStorageCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "object-storage",
Aliases: []string{"objectstorage", "os"},
Short: "Manage OCI Object Storage: list, get, and search",
Long: "Manage Oracle Cloud Infrastructure Object Storage: list, get, and search\",",
Example: " ocloud storage object-storage list\n ocloud storage object-storage list --json\n ocloud storage object-storage get\n ocloud storage object-storage get --json\n ocloud storage object-storage search <value>\n ocloud storage object-storage search <value> --json",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewSearchCmd(appCtx))
return cmd
}
package objectstorage
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
objsvc "github.com/cnopslabs/ocloud/internal/services/storage/objectstorage"
"github.com/spf13/cobra"
)
var searchLong = `
Search for Object Storage Buckets in the specified compartment that match the given pattern.
The search uses a combination of fuzzy, prefix, token, and substring matching across indexed fields.
You can search using any of the following fields (partial matches are supported):
Searchable fields:
- Name: Bucket name
- OCID: Bucket OCID
- Namespace: Object storage namespace
- StorageTier: Standard/Archive, etc.
- Visibility: Public/Private
- Encryption: Encryption algorithm/provider
- Versioning: Versioning status
- TagsKV/TagsVal: Flattened tag keys and values
- ReplicationEnabled/IsReadOnly: Boolean flags
Additional information:
- Use --json (-j) to output the results in JSON format
- The search is case-insensitive. For highly specific inputs (like full OCIDs), exact and substring
matches are attempted before broader fuzzy search.
`
var searchExamples = `
# Buckets whose name contains "prod"
ocloud storage objectstorage search prod
# Search by namespace
ocloud storage objectstorage search myns
# Use JSON output
ocloud storage objectstorage search prod --json
# Short alias
ocloud st os s prod -j
`
// NewSearchCmd creates a new command for searching buckets
func NewSearchCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "search <pattern>",
Aliases: []string{"s"},
Short: "Fuzzy search for Buckets",
Long: searchLong,
Example: searchExamples,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runSearchCommand(cmd, args, appCtx)
},
}
return cmd
}
// RunSearchCommand handles the execution of the search command
func runSearchCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
pattern := args[0]
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running object storage bucket search", "pattern", pattern, "json", useJSON)
return objsvc.SearchBuckets(appCtx, pattern, useJSON)
}
package storage
import (
"github.com/cnopslabs/ocloud/cmd/storage/objectstorage"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/spf13/cobra"
)
func NewStorageCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "storage",
Aliases: []string{"stg"},
Short: "Manage OCI Storage Resources",
Long: "Manage Oracle Cloud Infrastructure Storage Resources: list, get, and search by name or pattern.",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(objectstorage.NewObjectStorageCmd(appCtx))
return cmd
}
package version
import (
"fmt"
"io"
"os"
"github.com/cnopslabs/ocloud/buildinfo"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/spf13/cobra"
)
// VersionInfo encapsulates the version command functionality
// It wraps a cobra.Command and provides methods to handle version information display
type VersionInfo struct {
cmd *cobra.Command
writer io.Writer
}
// NewVersionCommand creates and configures a new version command
// Returns a *cobra.Command that can be added to the root command
// This function was refactored to return *cobra.Command directly instead of *VersionInfo
// to fix an issue with adding the command to the root command
func NewVersionCommand() *cobra.Command {
var writer io.Writer = os.Stdout
vc := &VersionInfo{
writer: writer,
}
vc.cmd = &cobra.Command{
Use: "version",
Short: "Print the version information",
Long: "Print the version, build time, and git commit hash information",
RunE: vc.runCommand,
}
return vc.cmd
}
// runCommand handles the main command execution
func (vc *VersionInfo) runCommand(cmd *cobra.Command, args []string) error {
return vc.printVersionInfo()
}
// printVersionInfo displays the version information
func (vc *VersionInfo) printVersionInfo() error {
PrintVersionInfo(vc.writer)
return nil
}
// PrintVersionInfo prints complete version information to the specified writer
// This function was updated to print all version information (version, commit hash, and build time)
// to ensure consistency between the version command and the version flag
func PrintVersionInfo(w io.Writer) {
fmt.Fprintf(w, "Version: %s\n", buildinfo.Version)
fmt.Fprintf(w, "Commit: %s\n", buildinfo.CommitHash)
fmt.Fprintf(w, "Built: %s\n", buildinfo.BuildTime)
}
// PrintVersion prints version information to stdout
// This function is used by the root command when the --version flag is specified
// It was added to fix an issue where cmd/root.go was calling version.PrintVersion()
// which didn't exist in the version package
func PrintVersion() {
PrintVersionInfo(os.Stdout)
}
// AddVersionFlag adds a version flag to the root command
// This function adds a global persistent flag to support the --version/-v flag
// and sets up a PersistentPreRunE hook to check for the flag and print version information
func AddVersionFlag(rootCmd *cobra.Command, writer io.Writer) {
// Register a global persistent flag to support short form (e.g., `ocloud -v`)
rootCmd.PersistentFlags().BoolP(flags.FlagNameVersion, flags.FlagShortVersion, false, flags.FlagDescVersion)
// Store the original PersistentPreRunE function
originalPreRun := rootCmd.PersistentPreRunE
// Override the persistent pre-run hook to check for the `-v` flag
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
if versionFlag := flags.GetBoolFlag(cmd, flags.FlagNameVersion, false); versionFlag {
PrintVersionInfo(writer)
return nil
}
// Call the original PersistentPreRunE if it exists
if originalPreRun != nil {
return originalPreRun(cmd, args)
}
return nil
}
}
package app
import (
"context"
"fmt"
"io"
"os"
"github.com/cnopslabs/ocloud/internal/oci"
"github.com/go-logr/logr"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/identity"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/cnopslabs/ocloud/internal/config"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
)
// ApplicationContext represents the application with all its clients, configuration, and resolved IDs.
// It holds all the components needed for command execution.
type ApplicationContext struct {
Provider common.ConfigurationProvider
IdentityClient identity.IdentityClient
TenancyID string
TenancyName string
CompartmentName string
CompartmentID string
Logger logr.Logger
Stdout io.Writer
Stderr io.Writer
}
// InitApp initializes the application context, setting up configuration, clients, logging, and determineConcurrencyStatus settings.
// Returns an ApplicationContext instance and an error if initialization fails.
func InitApp(ctx context.Context, cmd *cobra.Command) (*ApplicationContext, error) {
logger.CmdLogger.V(logger.Debug).Info("Initializing application context...")
provider := config.LoadOCIConfig()
identityClient, err := oci.NewIdentityClient(provider)
if err != nil {
return nil, fmt.Errorf("creating identity client: %w", err)
}
configureClientRegion(identityClient)
appCtx := &ApplicationContext{
Provider: provider,
IdentityClient: identityClient,
CompartmentName: viper.GetString(flags.FlagNameCompartment),
Logger: logger.CmdLogger,
}
// Set the standard writers for the application's lifetime.
appCtx.Stdout = os.Stdout
appCtx.Stderr = os.Stderr
if err := resolveTenancyAndCompartment(ctx, cmd, appCtx); err != nil {
return nil, fmt.Errorf("resolving tenancy and compartment: %w", err)
}
logger.CmdLogger.V(logger.Debug).Info("Application context initialized successfully.")
return appCtx, nil
}
// configureClientRegion checks the `OCI_REGION` environment variable and overrides the client's region if it is set.
func configureClientRegion(client identity.IdentityClient) {
if region, ok := os.LookupEnv(flags.EnvKeyRegion); ok {
client.SetRegion(region)
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "overriding region from env", "region", region)
}
}
// resolveTenancyAndCompartment resolves the tenancy ID, tenancy name, and compartment ID for the application context.
// It uses various sources such as CLI flags, environment variables, mapping files, and OCI configuration.
// Updates the provided ApplicationContext with the resolved IDs and names. Returns an error if resolution fails.
func resolveTenancyAndCompartment(ctx context.Context, cmd *cobra.Command, appCtx *ApplicationContext) error {
tenancyID, err := resolveTenancyID(cmd)
if err != nil {
return fmt.Errorf("could not resolve tenancy ID: %w", err)
}
appCtx.TenancyID = tenancyID
logger.CmdLogger.V(logger.Debug).Info("Tenancy ID resolved", "tenancyID", tenancyID)
if name := resolveTenancyName(cmd, appCtx.TenancyID); name != "" {
appCtx.TenancyName = name
logger.CmdLogger.V(logger.Debug).Info("Tenancy name resolved", "tenancyName", appCtx.TenancyName)
} else {
logger.CmdLogger.V(logger.Debug).Info("Tenancy name not resolved, using Tenancy ID as name.")
}
compID, err := resolveCompartmentID(ctx, appCtx)
if err != nil {
return fmt.Errorf("could not resolve compartment ID: %w", err)
}
appCtx.CompartmentID = compID
logger.CmdLogger.V(logger.Debug).Info("Compartment ID resolved", "compartmentID", compID)
return nil
}
// resolveTenancyID resolves the tenancy OCID from various sources in order of precedence:
// 1. Command line flag
// 2. Environment variable
// 3. Tenancy name lookup (if tenancy name is provided)
// 4. OCI config file
// Returns the tenancy ID or an error if it cannot be resolved.
func resolveTenancyID(cmd *cobra.Command) (string, error) {
// Check if tenancy ID is provided as a flag
if cmd.Flags().Changed(flags.FlagNameTenancyID) {
tenancyID := viper.GetString(flags.FlagNameTenancyID)
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "using tenancy OCID from flag", "tenancyID", tenancyID)
return tenancyID, nil
}
// Check if tenancy ID is provided as an environment variable
if envTenancy := os.Getenv(flags.EnvKeyCLITenancy); envTenancy != "" {
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "using tenancy OCID from env", "tenancyID", envTenancy)
viper.Set(flags.FlagNameTenancyID, envTenancy)
return envTenancy, nil
}
// Check if the tenancy name is provided as an environment variable
if envTenancyName := os.Getenv(flags.EnvKeyTenancyName); envTenancyName != "" {
lookupID, err := config.LookupTenancyID(envTenancyName)
if err != nil {
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "could not look up tenancy ID for tenancy name, continuing with other methods", "tenancyName", envTenancyName, "error", err)
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "To set up tenancy mapping, create a YAML file at ~/.oci/tenancy-map.yaml or set the OCI_TENANCY_MAP_PATH environment variable. The file should contain entries mapping tenancy names to OCIDs. Example:\n- environment: prod\n tenancy: mytenancy\n tenancy_id: ocid1.tenancy.oc1..aaaaaaaabcdefghijklmnopqrstuvwxyz\n realm: oc1\n compartments: mycompartment\n regions: us-ashburn-1")
} else {
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "using tenancy OCID for name", "tenancyName", envTenancyName, "tenancyID", lookupID)
viper.Set(flags.FlagNameTenancyID, lookupID)
return lookupID, nil
}
}
// Load from an OCI config file as a last resort
tenancyID, err := config.GetTenancyOCID()
if err != nil {
return "", fmt.Errorf("could not load tenancy OCID: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "using tenancy OCID from config file", "tenancyID", tenancyID)
viper.Set(flags.FlagNameTenancyID, tenancyID)
return tenancyID, nil
}
// resolveTenancyName resolves the tenancy name from various sources in order of precedence:
// 1. Command line flag
// 2. Environment variable
// 3. Tenancy mapping file lookup (using tenancy ID)
// Returns the tenancy name or an empty string if it cannot be resolved.
func resolveTenancyName(cmd *cobra.Command, tenancyID string) string {
// Check if the tenancy name is provided as a flag
if cmd.Flags().Changed(flags.FlagNameTenancyName) {
tenancyName := viper.GetString(flags.FlagNameTenancyName)
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "using tenancy name from flag", "tenancyName", tenancyName)
return tenancyName
}
// Check if the tenancy name is provided as an environment variable
if envTenancyName := os.Getenv(flags.EnvKeyTenancyName); envTenancyName != "" {
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "using tenancy name from env", "tenancyName", envTenancyName)
viper.Set(flags.FlagNameTenancyName, envTenancyName)
return envTenancyName
}
// Try to find a tenancy name from a mapping file if available
tenancies, err := config.LoadTenancyMap()
if err == nil {
for _, env := range tenancies {
if env.TenancyID == tenancyID {
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "found tenancy name from mapping file", "tenancyName", env.Tenancy)
viper.Set(flags.FlagNameTenancyName, env.Tenancy)
return env.Tenancy
}
}
}
return ""
}
// resolveCompartmentID returns the OCID of the compartment whose name matches
// `compartmentName` under the given tenancy. It searches all active compartments
// in the tenancy subtree.
func resolveCompartmentID(ctx context.Context, appCtx *ApplicationContext) (string, error) {
compartmentName := appCtx.CompartmentName
idClient := appCtx.IdentityClient
tenancyOCID := appCtx.TenancyID
// If the compartment name is not set, use tenancy ID as fallback
if compartmentName == "" {
logger.LogWithLevel(logger.CmdLogger, logger.Trace, "compartment name not set, using tenancy ID as fallback", "tenancyID", tenancyOCID)
return tenancyOCID, nil
}
req := identity.ListCompartmentsRequest{
CompartmentId: &tenancyOCID,
AccessLevel: identity.ListCompartmentsAccessLevelAccessible,
LifecycleState: identity.CompartmentLifecycleStateActive,
CompartmentIdInSubtree: common.Bool(true),
}
pageToken := ""
for {
if pageToken != "" {
req.Page = common.String(pageToken)
}
resp, err := idClient.ListCompartments(ctx, req)
if err != nil {
return "", fmt.Errorf("listing compartments: %w", err)
}
for _, comp := range resp.Items {
if comp.Name != nil && *comp.Name == compartmentName {
return *comp.Id, nil
}
}
if resp.OpcNextPage == nil {
break
}
pageToken = *resp.OpcNextPage
}
return "", fmt.Errorf("compartment %q not found under tenancy %s", compartmentName, tenancyOCID)
}
// Package flags define flag types and domain-specific flag collections for the CLI.
package flags
import (
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// Global flags
var (
LogLevelFlag = StringFlag{
Name: FlagNameLogLevel,
Default: FlagValueInfo,
Usage: FlagDescLogLevel,
}
DebugFlag = BoolFlag{
Name: FlagNameDebug,
Shorthand: FlagShortDebug,
Default: false,
Usage: FlagDescDebug,
}
ColorFlag = BoolFlag{
Name: FlagNameColor,
Default: false,
Usage: logger.ColoredOutputMsg,
}
TenancyIDFlag = StringFlag{
Name: FlagNameTenancyID,
Shorthand: FlagShortTenancyID,
Default: "",
Usage: FlagDescTenancyID,
}
TenancyNameFlag = StringFlag{
Name: FlagNameTenancyName,
Default: "",
Usage: FlagDescTenancyName,
}
CompartmentFlag = StringFlag{
Name: FlagNameCompartment,
Shorthand: FlagShortCompartment,
Default: "",
Usage: FlagDescCompartment,
}
HelpFlag = BoolFlag{
Name: FlagNameHelp,
Shorthand: FlagShortHelp,
Default: false,
Usage: FlagDescHelp,
}
JSONFlag = BoolFlag{
Name: FlagNameJSON,
Shorthand: FlagShortJSON,
Default: false,
Usage: FlagDescJSON,
}
)
// globalFlags is a slice of all global flags for batch registration
var globalFlags = []Flag{
LogLevelFlag,
DebugFlag,
ColorFlag,
TenancyIDFlag,
TenancyNameFlag,
CompartmentFlag,
HelpFlag,
JSONFlag,
}
// AddGlobalFlags adds all global flags to the given command
func AddGlobalFlags(cmd *cobra.Command) {
// Add global flags as persistent flags
for _, f := range globalFlags {
f.Apply(cmd.PersistentFlags())
}
// Set annotation for a help flag
_ = cmd.PersistentFlags().SetAnnotation(FlagNameHelp, CobraAnnotationKey, []string{FlagValueTrue})
// Bind flags to viper for configuration
_ = viper.BindPFlag(FlagNameTenancyID, cmd.PersistentFlags().Lookup(FlagNameTenancyID))
_ = viper.BindPFlag(FlagNameTenancyName, cmd.PersistentFlags().Lookup(FlagNameTenancyName))
_ = viper.BindPFlag(FlagNameCompartment, cmd.PersistentFlags().Lookup(FlagNameCompartment))
// allow ENV overrides, e.g., OCI_CLI_TENANCY, OCI_TENANCY_NAME, OCI_COMPARTMENT
viper.SetEnvPrefix("OCI")
viper.AutomaticEnv()
}
// Package flags provides a type-safe and reusable way to define and manage command-line flags
// for CLI applications using cobra and pflag libraries. It offers structured flag types for
// boolean, string, and integer values, along with consistent interfaces for adding these flags
// to commands and flag sets.
package flags
import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// BoolFlag represents a boolean command flag configuration with a name, optional shorthand,
// default value, and usage description. It implements the Flag interface for boolean flags.
type BoolFlag struct {
Name string
Shorthand string
Default bool
Usage string
}
// Add adds the boolean flag to the command
func (f BoolFlag) Add(cmd *cobra.Command) {
cmd.Flags().BoolP(f.Name, f.Shorthand, f.Default, f.Usage)
}
// Apply adds the boolean flag to the given flag set
func (f BoolFlag) Apply(flags *pflag.FlagSet) {
flags.BoolP(f.Name, f.Shorthand, f.Default, f.Usage)
}
// StringFlag represents a string command flag configuration with a name, optional shorthand,
// default value, and usage description. It implements the Flag interface for string flags.
type StringFlag struct {
Name string
Shorthand string
Default string
Usage string
}
// Add adds the string flag to the command
func (f StringFlag) Add(cmd *cobra.Command) {
cmd.Flags().StringP(f.Name, f.Shorthand, f.Default, f.Usage)
}
// Apply adds the string flag to the given flag set
func (f StringFlag) Apply(flags *pflag.FlagSet) {
flags.StringP(f.Name, f.Shorthand, f.Default, f.Usage)
}
// IntFlag represents an integer command flag configuration with a name, optional shorthand,
// default value, and usage description. It implements the Flag interface for integer flags.
type IntFlag struct {
Name string
Shorthand string
Default int
Usage string
}
// Add adds the integer flag to the command
func (f IntFlag) Add(cmd *cobra.Command) {
cmd.Flags().IntP(f.Name, f.Shorthand, f.Default, f.Usage)
}
// Apply adds the integer flag to the given flag set
func (f IntFlag) Apply(flags *pflag.FlagSet) {
flags.IntP(f.Name, f.Shorthand, f.Default, f.Usage)
}
// Flag defines the interface that all flag types must implement to be used within the CLI.
// It provides methods for adding flags to both cobras.Command and pflag.FlagSet, allowing
// flexible flag registration across different command contexts.
type Flag interface {
// Add registers the flag with the provided cobra.Command
Add(*cobra.Command)
// Apply registers the flag with the provided pflag.FlagSet
Apply(*pflag.FlagSet)
}
// Package flags define flag types and domain-specific flag collections for the CLI.
// It provides utility functions for safely retrieving flag values from cobra commands
// with default fallbacks. This package is used throughout the application to handle
// command-line flags in a consistent manner.
//
// Usage:
// - Import this package in your command handlers
// - Use the FetchPaginatedImages*Flag functions to safely retrieve flag values
// - Each function handles potential errors and returns a default value if the flag is not found
//
// Example:
// debug := flags.GetBoolFlag(cmd, "debug", false)
// name: = flags.GetStringFlag(cmd, "name", "default-name")
package flags
import (
"github.com/spf13/cobra"
)
// GetBoolFlag retrieves a boolean flag value from the cobra.Command.
// It provides a safe way to access boolean flags with automatic error handling.
// If the flag is not found or there's an error reading it, it returns the provided default value.
//
// Parameters:
// - cmd: The cobra.Command instance containing the flags
// - flagName: The name of the flag to retrieve
// - defaultValue: The value to return if the flag is not found or has an error
//
// Returns:
// - The boolean value of the flag or the default value if not found/error
func GetBoolFlag(cmd *cobra.Command, flagName string, defaultValue bool) bool {
value, err := cmd.Flags().GetBool(flagName)
if err != nil {
return defaultValue
}
return value
}
// GetStringFlag gets a string flag value from the command
// If the flag is not found or there's an error, it returns the default value
func GetStringFlag(cmd *cobra.Command, flagName string, defaultValue string) string {
value, err := cmd.Flags().GetString(flagName)
if err != nil {
return defaultValue
}
return value
}
// GetIntFlag gets an integer flag value from the command
// If the flag is not found or there's an error, it returns the default value
func GetIntFlag(cmd *cobra.Command, flagName string, defaultValue int) int {
value, err := cmd.Flags().GetInt(flagName)
if err != nil {
return defaultValue
}
return value
}
// GetStringSliceFlag gets a string slice flag value from the command
// If the flag is not found or there's an error, it returns the default value
func GetStringSliceFlag(cmd *cobra.Command, flagName string, defaultValue []string) []string {
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
return defaultValue
}
return value
}
// GetFloat64Flag gets a float64 flag value from the command
// If the flag is not found or there's an error, it returns the default value
func GetFloat64Flag(cmd *cobra.Command, flagName string, defaultValue float64) float64 {
value, err := cmd.Flags().GetFloat64(flagName)
if err != nil {
return defaultValue
}
return value
}
// GetDurationFlag gets a duration flag value from the command
// If the flag is not found or there's an error, it returns the default value
func GetDurationFlag(cmd *cobra.Command, flagName string, defaultValue int64) int64 {
value, err := cmd.Flags().GetInt64(flagName)
if err != nil {
return defaultValue
}
return value
}
package config
import (
"fmt"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"path/filepath"
"gopkg.in/yaml.v3"
"os"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/pkg/errors"
)
// For testing purposes
var (
// MockGetTenancyOCID allows tests to override the GetTenancyOCID function
MockGetTenancyOCID func() (string, error)
// MockLookupTenancyID allows tests to override the LookupTenancyID function
MockLookupTenancyID func(tenancyName string) (string, error)
)
// DefaultTenancyMapPath defines the default file path for the OCI tenancy map configuration in the user's home directory.
// If the home directory cannot be determined, it falls back to an empty string.
var DefaultTenancyMapPath = func() string {
dir, err := GetUserHomeDir()
if err != nil {
logger.Logger.V(logger.Debug).Info("failed to get user home directory for tenancy map path", "error", err)
return ""
}
return filepath.Join(dir, flags.OCIConfigDirName, flags.OCloudDefaultDirName, flags.TenancyMapFileName)
}()
// LoadOCIConfig picks the profile from env or default, and logs at debug level.
// If there's an error getting the home directory, it falls back to the default provider.
func LoadOCIConfig() common.ConfigurationProvider {
logger.Logger.V(logger.Debug).Info("Loading OCI configuration...")
profile := GetOCIProfile()
if profile == flags.DefaultProfileName {
logger.LogWithLevel(logger.Logger, logger.Trace, "using default profile")
return common.DefaultConfigProvider()
}
logger.LogWithLevel(logger.Logger, logger.Trace, "using profile", "profile", profile)
homeDir, err := GetUserHomeDir()
if err != nil {
logger.Logger.Error(err, "failed to get user home directory for config path, falling back to default provider")
return common.DefaultConfigProvider()
}
path := filepath.Join(homeDir, flags.OCIConfigDirName, flags.OCIConfigFileName)
return common.CustomProfileConfigProvider(path, profile)
}
// GetOCIProfile returns OCI_CLI_PROFILE or "DEFAULT".
func GetOCIProfile() string {
if p := os.Getenv(flags.EnvKeyProfile); p != "" {
return p
}
return flags.DefaultProfileName
}
// GetTenancyOCID fetches the tenancy OCID (error on failure).
func GetTenancyOCID() (string, error) {
logger.Logger.V(logger.Debug).Info("Attempting to get tenancy OCID.")
// Use mock function if set (for testing)
if MockGetTenancyOCID != nil {
return MockGetTenancyOCID()
}
id, err := LoadOCIConfig().TenancyOCID()
if err != nil {
return "", errors.Wrap(err, "failed to retrieve tenancy OCID from OCI config")
}
logger.Logger.V(logger.Debug).Info("Successfully retrieved tenancy OCID.", "tenancyID", id)
return id, nil
}
// LookupTenancyID locates the OCID for a given tenancy name.
// It returns an error if the map cannot be loaded or if the name isn't found.
func LookupTenancyID(tenancyName string) (string, error) {
logger.Logger.V(logger.Debug).Info("Attempting to lookup tenancy ID", "tenancyName", tenancyName)
// Use mock function if set (for testing)
if MockLookupTenancyID != nil {
return MockLookupTenancyID(tenancyName)
}
path := TenancyMapPath()
logger.LogWithLevel(logger.Logger, logger.Trace, "looking up tenancy in map", "tenancy", tenancyName, "path", path)
tenancies, err := LoadTenancyMap()
if err != nil {
return "", err
}
for _, env := range tenancies {
if env.Tenancy == tenancyName {
logger.LogWithLevel(logger.Logger, logger.Trace, "found tenancy", "tenancy", tenancyName, "tenancyID", env.TenancyID)
logger.Logger.V(logger.Debug).Info("Successfully looked up tenancy ID.", "tenancyName", tenancyName, "tenancyID", env.TenancyID)
return env.TenancyID, nil
}
}
lookupErr := fmt.Errorf("tenancy %q not found in %s", tenancyName, path)
logger.Logger.Info("tenancy lookup failed", "error", lookupErr)
return "", errors.Wrap(lookupErr, "tenancy lookup failed - please check that the tenancy name is correct and exists in the mapping file")
}
// LoadTenancyMap loads the tenancy mapping from the disk at TenancyMapPath.
// It logs debug information and returns a slice of OciTenancyEnvironment.
func LoadTenancyMap() ([]MappingsFile, error) {
logger.Logger.V(logger.Debug).Info("Attempting to load tenancy map.")
path := TenancyMapPath()
logger.LogWithLevel(logger.Logger, logger.Trace, "loading tenancy map", "path", path)
if err := ensureFile(path); err != nil {
logger.Logger.Info("tenancy mapping file not found", "error", err)
return nil, errors.Wrapf(err, "tenancy mapping file not found (%s) - this is normal if you're not using tenancy name lookup. To set up the mapping file, create a YAML file at %s or set the %s environment variable to point to your mapping file. The file should contain entries mapping tenancy names to OCIDs. Example:\n- environment: OcluodOps\n tenancy: cncloudops\n tenancy_id: ocid1.tenancy.oc1..aaaaaaaasrwe3nsfsidfxzxyzct\n realm: OC1\n compartments:\n - sandbox\n - uat\n - prod\n regions:\n - us-chicago-1\n - us-ashburn-1\n", path, DefaultTenancyMapPath, flags.EnvKeyTenancyMapPath)
}
data, err := os.ReadFile(path)
if err != nil {
logger.Logger.Error(err, "failed to read tenancy mapping file", "path", path)
return nil, errors.Wrapf(err, "failed to read tenancy mapping file (%s)", path)
}
var tenancies []MappingsFile
if err := yaml.Unmarshal(data, &tenancies); err != nil {
logger.Logger.Error(err, "failed to parse tenancy mapping file", "path", path)
return nil, errors.Wrapf(err, "failed to parse tenancy mapping file (%s) - please check that the file is valid YAML", path)
}
logger.LogWithLevel(logger.Logger, logger.Trace, "loaded tenancy mapping entries", "count", len(tenancies))
logger.Logger.V(logger.Debug).Info("Successfully loaded tenancy map.", "count", len(tenancies))
return tenancies, nil
}
// ensureFile verifies the given path exists and is not a directory.
func ensureFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.IsDir() {
return fmt.Errorf("path %s is a directory, expected a file", path)
}
return nil
}
// GetUserHomeDir returns the path to the current user's home directory or an error if unable to determine it.
func GetUserHomeDir() (string, error) {
logger.Logger.V(logger.Debug).Info("Attempting to get user home directory.")
dir, err := os.UserHomeDir()
if err != nil {
logger.Logger.Error(err, "failed to get user home directory")
return "", fmt.Errorf("getting user home directory: %w", err)
}
logger.Logger.V(logger.Debug).Info("Successfully retrieved user home directory.", "directory", dir)
return dir, nil
}
// TenancyMapPath returns either the overridden path or the default.
func TenancyMapPath() string {
if p := os.Getenv(flags.EnvKeyTenancyMapPath); p != "" {
logger.LogWithLevel(logger.Logger, logger.Trace, "using tenancy map from env", "path", p)
return p
}
return DefaultTenancyMapPath
}
package domain
import "errors"
import "fmt"
var (
// ErrNotFound is returned when a resource is not found.
ErrNotFound = errors.New("not found")
)
// NewNotFoundError creates a new error indicating that a resource was not found.
func NewNotFoundError(resourceType, resourceName string) error {
return fmt.Errorf("%s '%s': %w", resourceType, resourceName, ErrNotFound)
}
package logger
import (
"context"
"fmt"
"io"
"log/slog"
"runtime"
"slices"
"sync"
"time"
)
const (
Reset = "\033[0m"
White = "\033[37m"
WhiteDim = "\033[37;2m"
Green = "\033[32m"
GreenDimUnderlined = "\033[32;2;4m"
Magenta = "\033[35m"
BrightRed = "\033[91m"
BrightYellow = "\033[93m"
Cyan = "\033[36m"
CyanDim = "\033[36;2m"
// this mirrors the limit value from the shared slog package
maxBufferSize = 16384
dateFormat = time.Stamp
)
var bufPool = sync.Pool{
New: func() any {
b := make([]byte, 0, 2048)
return &b
},
}
type Options struct {
AddSource bool
Colored bool
Level slog.Leveler
TimeFormat string
}
// Handler is very similar to slog's commonHandler
type Handler struct {
opts Options
json bool
preformattedAttrs []byte
groupPrefix string
groups []string
unopenedGroups []string
nOpenGroups int
mu *sync.Mutex
w io.Writer
}
func NewHandler(out io.Writer, opts Options) *Handler {
return &Handler{
opts: opts,
preformattedAttrs: make([]byte, 0),
unopenedGroups: make([]string, 0),
nOpenGroups: 0,
mu: &sync.Mutex{},
w: out,
}
}
func (h *Handler) clone() *Handler {
return &Handler{
opts: h.opts,
json: h.json,
preformattedAttrs: slices.Clip(h.preformattedAttrs),
groupPrefix: h.groupPrefix,
groups: slices.Clip(h.groups),
nOpenGroups: h.nOpenGroups,
w: h.w,
mu: h.mu,
}
}
func (h *Handler) Enabled(_ context.Context, level slog.Level) bool {
minLevel := slog.LevelInfo
if h.opts.Level != nil {
minLevel = h.opts.Level.Level()
}
return level >= minLevel
}
func (h *Handler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
h2 := h.clone()
h2.unopenedGroups = make([]string, len(h.unopenedGroups)+1)
copy(h2.unopenedGroups, h.unopenedGroups)
h2.unopenedGroups[len(h2.unopenedGroups)-1] = name
return h2
}
func (h *Handler) WithAttrs(as []slog.Attr) slog.Handler {
if len(as) == 0 {
return h
}
h2 := h.clone()
h2.preformattedAttrs = h2.appendUnopenedGroups(h2.preformattedAttrs)
h2.unopenedGroups = nil
for _, a := range as {
h2.preformattedAttrs = h2.appendAttr(h2.preformattedAttrs, a)
}
return h2
}
func (h *Handler) appendUnopenedGroups(buf []byte) []byte {
for _, g := range h.unopenedGroups {
buf = fmt.Appendf(buf, "%s ", g)
}
return buf
}
func (h *Handler) appendAttr(buf []byte, a slog.Attr) []byte {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return buf
}
switch a.Value.Kind() {
case slog.KindGroup:
attrs := a.Value.Group()
if len(attrs) == 0 {
return buf
}
if a.Key != "" {
for _, ga := range attrs {
buf = h.appendAttr(buf, ga)
}
}
default:
if a.Key == "" || a.Value.String() == "" {
return buf
}
buf = h.appendKeyValuePair(buf, a)
}
return buf
}
func (h *Handler) Handle(ctx context.Context, record slog.Record) error {
bufp := bufPool.Get().(*[]byte)
buf := *bufp
defer func() {
*bufp = buf
free(bufp)
}()
// append time, level, then message.
if h.opts.Colored {
buf = fmt.Appendf(buf, WhiteDim)
buf = slog.Time(slog.TimeKey, record.Time).Value.Time().AppendFormat(buf, fmt.Sprintf("%s%s ", dateFormat, Reset))
var color string
switch record.Level {
case slog.LevelDebug:
color = Magenta
case slog.LevelInfo:
color = Green
case slog.LevelWarn:
color = BrightYellow
case slog.LevelError:
color = BrightRed
default:
color = Magenta
}
buf = fmt.Appendf(buf, "%s%s%s ", color, record.Level.String(), Reset)
buf = fmt.Appendf(buf, "%s%s%s ", Cyan, record.Message, Reset)
} else {
buf = slog.Time(slog.TimeKey, record.Time).Value.Time().AppendFormat(buf, fmt.Sprintf("%s ", dateFormat))
buf = fmt.Appendf(buf, "%s ", record.Level)
buf = fmt.Appendf(buf, "%s ", record.Message)
}
if h.opts.AddSource {
buf = h.appendAttr(buf, slog.Any(slog.SourceKey, source(record)))
}
buf = append(buf, h.preformattedAttrs...)
if record.NumAttrs() > 0 {
buf = h.appendUnopenedGroups(buf)
record.Attrs(func(a slog.Attr) bool {
buf = h.appendAttr(buf, a)
return true
})
}
buf = append(buf, "\n"...)
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.w.Write(buf)
return err
}
func (h *Handler) appendKeyValuePair(buf []byte, a slog.Attr) []byte {
if h.opts.Colored {
if a.Key == "err" {
return fmt.Appendf(buf, "%s%s=%v%s ", BrightRed, a.Key, a.Value.String(), Reset)
}
return fmt.Appendf(buf, "%s%s=%s%s%s%s ", WhiteDim, a.Key, Reset, White, a.Value.String(), Reset)
}
return fmt.Appendf(buf, "%s=%v ", a.Key, a.Value.String())
}
func free(b *[]byte) {
if cap(*b) <= maxBufferSize {
*b = (*b)[:0]
bufPool.Put(b)
}
}
func source(r slog.Record) *slog.Source {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
return &slog.Source{
Function: f.Function,
File: f.File,
Line: f.Line,
}
}
package logger
import (
"fmt"
"log/slog"
"os"
"strings"
"github.com/go-logr/logr"
"k8s.io/klog/v2"
ctrl "sigs.k8s.io/controller-runtime"
)
var (
// Logger is the package-level logger
// It should be initialized using InitLogger before use
Logger logr.Logger
// LogLevel sets the verbosity level for logging
LogLevel string
// CmdLogger is the logger used by command-line operations
CmdLogger logr.Logger
// ColoredOutput determines whether log output should be colored
ColoredOutput bool
// ColoredOutputMsg provides help text for the color flag
ColoredOutputMsg = "Enable colored log messages."
// GLOBAL_VERBOSITY controls the verbosity level for V(n) calls
// Higher values show more verbose logs
GLOBAL_VERBOSITY int
)
// SetLogger initializes the loggers based on the current LogLevel and ColoredOutput settings
func SetLogger() error {
l, err := getSlogLevel(LogLevel)
if err != nil {
return err
}
// Set the global verbosity level based on the log level
switch strings.ToLower(LogLevel) {
case "debug":
// Turn on all verbose levels 0..10 to ensure all debug logs are shown
GLOBAL_VERBOSITY = 10
default:
GLOBAL_VERBOSITY = 0
}
slogger := slog.New(NewHandler(os.Stderr, Options{Level: l, Colored: ColoredOutput}))
kslogger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: getKlogLevel(l)}))
baseLogger := logr.FromSlogHandler(slogger.Handler())
klogger := logr.FromSlogHandler(kslogger.Handler())
klog.SetLogger(klogger)
ctrl.SetLogger(baseLogger)
CmdLogger = baseLogger
return nil
}
// InitLogger initializes the package-level logger
// If no logger is provided, it creates a default one
func InitLogger(logger logr.Logger) {
Logger = logger
if Logger.GetSink() == nil {
// If no logger is provided, or it has a nil sink, create a default one
slogger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
Logger = logr.FromSlogHandler(slogger.Handler())
}
}
// LogWithLevel logs a message at the specified verbosity level.
// It logs the message using the logger's V(level).Info() method.
// The logr library handles verbosity filtering based on the logger's configured level.
func LogWithLevel(logger logr.Logger, level int, msg string, keysAndValues ...interface{}) {
logger.V(level).Info(msg, keysAndValues...)
}
// getSlogLevel converts a string log level to a slog.Level
func getSlogLevel(s string) (slog.Level, error) {
switch strings.ToLower(s) {
case "debug":
// Set to a lower level than LevelDebug to ensure all debug logs are shown
return slog.LevelDebug - 10, nil
case "info":
return slog.LevelInfo, nil
case "warn":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return slog.LevelDebug, fmt.Errorf("%s is not a valid log level", s)
}
}
// For end users, klog messages are mostly useless. I set it to the error level unless debug logging is enabled.
func getKlogLevel(l slog.Level) slog.Level {
if l < slog.LevelInfo {
return l
}
return slog.LevelError
}
package logger
import (
"io"
"log/slog"
"github.com/go-logr/logr"
)
// NewTestLogger creates a logger suitable for testing that doesn't produce output.
// It uses a discard handler to ensure no logs are written to stdout/stderr.
func NewTestLogger() logr.Logger {
// Create a slog.Logger with a handler that discards all output
handler := slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug})
slogger := slog.New(handler)
// Convert the slog.Logger to a logr.Logger
return logr.FromSlogHandler(slogger.Handler())
}
package mapping
import (
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/database"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/database"
)
type AutonomousDatabaseAttributes struct {
ID *string
Name *string
CompartmentOCID *string
LifecycleState string
DbVersion *string
DbWorkload string
LicenseModel string
IsFreeTier *bool
WhitelistedIps []string
PrivateEndpoint *string
PrivateEndpointIp *string
PrivateEndpointLabel *string
SubnetId *string
NsgIds []string
IsMtlsRequired *bool
ComputeModel string
EcpuCount *float32
OcpuCount *float32
CpuCoreCount *int
DataStorageSizeInTBs *int
DataStorageSizeInGBs *int
IsAutoScalingEnabled *bool
IsStorageAutoScalingEnabled *bool
OperationsInsightsStatus string
DatabaseManagementStatus string
DataSafeStatus string
IsDataGuardEnabled *bool
Role *string
PeerAutonomousDbIds []string
BackupRetentionDays *int
LastBackupTime *time.Time
LatestRestoreTime *time.Time
PatchModel *string
NextMaintenanceRunId *string
MaintenanceScheduleType *string
ConnectionStrings map[string]string
Profiles []database.DatabaseConnectionStringProfile
ConnectionUrls *database.AutonomousDatabaseConnectionUrls
FreeformTags map[string]string
DefinedTags map[string]map[string]interface{}
TimeCreated *common.SDKTime
}
func NewAutonomousDatabaseAttributesFromOCIAutonomousDatabase(db database.AutonomousDatabase) *AutonomousDatabaseAttributes {
return &AutonomousDatabaseAttributes{
ID: db.Id,
Name: db.DbName,
CompartmentOCID: db.CompartmentId,
LifecycleState: string(db.LifecycleState),
DbVersion: db.DbVersion,
DbWorkload: string(db.DbWorkload),
LicenseModel: string(db.LicenseModel),
IsFreeTier: db.IsFreeTier,
WhitelistedIps: db.WhitelistedIps,
PrivateEndpoint: db.PrivateEndpoint,
PrivateEndpointIp: db.PrivateEndpointIp,
PrivateEndpointLabel: db.PrivateEndpointLabel,
SubnetId: db.SubnetId,
NsgIds: db.NsgIds,
IsMtlsRequired: db.IsMtlsConnectionRequired,
ComputeModel: string(db.ComputeModel),
EcpuCount: db.ComputeCount,
OcpuCount: db.OcpuCount,
CpuCoreCount: db.CpuCoreCount,
DataStorageSizeInTBs: db.DataStorageSizeInTBs,
DataStorageSizeInGBs: db.DataStorageSizeInGBs,
IsAutoScalingEnabled: db.IsAutoScalingEnabled,
IsStorageAutoScalingEnabled: db.IsAutoScalingForStorageEnabled,
OperationsInsightsStatus: string(db.OperationsInsightsStatus),
DatabaseManagementStatus: string(db.DatabaseManagementStatus),
DataSafeStatus: string(db.DataSafeStatus),
IsDataGuardEnabled: db.IsDataGuardEnabled,
Role: (*string)(&db.Role),
PeerAutonomousDbIds: db.PeerDbIds,
ConnectionStrings: db.ConnectionStrings.AllConnectionStrings,
Profiles: db.ConnectionStrings.Profiles,
ConnectionUrls: db.ConnectionUrls,
FreeformTags: db.FreeformTags,
DefinedTags: db.DefinedTags,
TimeCreated: db.TimeCreated,
}
}
func NewAutonomousDatabaseAttributesFromOCIAutonomousDatabaseSummary(db database.AutonomousDatabaseSummary) *AutonomousDatabaseAttributes {
return &AutonomousDatabaseAttributes{
ID: db.Id,
Name: db.DbName,
CompartmentOCID: db.CompartmentId,
LifecycleState: string(db.LifecycleState),
DbVersion: db.DbVersion,
DbWorkload: string(db.DbWorkload),
LicenseModel: string(db.LicenseModel),
IsFreeTier: db.IsFreeTier,
WhitelistedIps: db.WhitelistedIps,
PrivateEndpoint: db.PrivateEndpoint,
PrivateEndpointIp: db.PrivateEndpointIp,
PrivateEndpointLabel: db.PrivateEndpointLabel,
SubnetId: db.SubnetId,
NsgIds: db.NsgIds,
IsMtlsRequired: db.IsMtlsConnectionRequired,
ComputeModel: string(db.ComputeModel),
EcpuCount: db.ComputeCount,
OcpuCount: db.OcpuCount,
CpuCoreCount: db.CpuCoreCount,
DataStorageSizeInTBs: db.DataStorageSizeInTBs,
DataStorageSizeInGBs: db.DataStorageSizeInGBs,
IsAutoScalingEnabled: db.IsAutoScalingEnabled,
IsStorageAutoScalingEnabled: db.IsAutoScalingForStorageEnabled,
OperationsInsightsStatus: string(db.OperationsInsightsStatus),
DatabaseManagementStatus: string(db.DatabaseManagementStatus),
DataSafeStatus: string(db.DataSafeStatus),
IsDataGuardEnabled: db.IsDataGuardEnabled,
Role: (*string)(&db.Role),
PeerAutonomousDbIds: db.PeerDbIds,
ConnectionStrings: db.ConnectionStrings.AllConnectionStrings,
Profiles: db.ConnectionStrings.Profiles,
ConnectionUrls: db.ConnectionUrls,
FreeformTags: db.FreeformTags,
DefinedTags: db.DefinedTags,
TimeCreated: db.TimeCreated,
}
}
func NewDomainAutonomousDatabaseFromAttrs(attrs *AutonomousDatabaseAttributes) *domain.AutonomousDatabase {
// helper to safely dereference string pointers
val := func(p *string) string {
if p == nil {
return ""
}
return *p
}
var timeCreated *time.Time
if attrs.TimeCreated != nil {
t := attrs.TimeCreated.Time
timeCreated = &t
}
return &domain.AutonomousDatabase{
ID: val(attrs.ID),
Name: val(attrs.Name),
CompartmentOCID: val(attrs.CompartmentOCID),
LifecycleState: attrs.LifecycleState,
DbVersion: val(attrs.DbVersion),
DbWorkload: attrs.DbWorkload,
LicenseModel: attrs.LicenseModel,
IsFreeTier: attrs.IsFreeTier,
WhitelistedIps: attrs.WhitelistedIps,
PrivateEndpoint: val(attrs.PrivateEndpoint),
PrivateEndpointIp: val(attrs.PrivateEndpointIp),
PrivateEndpointLabel: val(attrs.PrivateEndpointLabel),
SubnetId: val(attrs.SubnetId),
NsgIds: attrs.NsgIds,
IsMtlsRequired: attrs.IsMtlsRequired,
ComputeModel: attrs.ComputeModel,
EcpuCount: attrs.EcpuCount,
OcpuCount: attrs.OcpuCount,
CpuCoreCount: attrs.CpuCoreCount,
DataStorageSizeInTBs: attrs.DataStorageSizeInTBs,
DataStorageSizeInGBs: attrs.DataStorageSizeInGBs,
IsAutoScalingEnabled: attrs.IsAutoScalingEnabled,
IsStorageAutoScalingEnabled: attrs.IsStorageAutoScalingEnabled,
OperationsInsightsStatus: attrs.OperationsInsightsStatus,
DatabaseManagementStatus: attrs.DatabaseManagementStatus,
DataSafeStatus: attrs.DataSafeStatus,
IsDataGuardEnabled: attrs.IsDataGuardEnabled,
Role: val(attrs.Role),
PeerAutonomousDbIds: attrs.PeerAutonomousDbIds,
BackupRetentionDays: attrs.BackupRetentionDays,
LastBackupTime: attrs.LastBackupTime,
LatestRestoreTime: attrs.LatestRestoreTime,
PatchModel: val(attrs.PatchModel),
NextMaintenanceRunId: val(attrs.NextMaintenanceRunId),
MaintenanceScheduleType: val(attrs.MaintenanceScheduleType),
ConnectionStrings: attrs.ConnectionStrings,
Profiles: attrs.Profiles,
ConnectionUrls: attrs.ConnectionUrls,
FreeformTags: attrs.FreeformTags,
DefinedTags: attrs.DefinedTags,
TimeCreated: timeCreated,
}
}
package mapping
import (
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/storage/objectstorage"
"github.com/oracle/oci-go-sdk/v65/objectstorage"
)
// BucketAttributes is a generic, intermediate representation of a bucket's data.
// Each adapter is responsible for populating this struct.
type BucketAttributes struct {
Name *string
ID *string
Namespace *string
TimeCreated *time.Time
StorageTier string
PublicAccessType string
KmsKeyID *string
Versioning string
ReplicationEnabled *bool
IsReadOnly *bool
ApproximateCount *int64
ApproximateSize *int64
FreeformTags map[string]string
DefinedTags map[string]map[string]interface{}
}
func NewBucketAttributesFromOCIBucket(bucket objectstorage.Bucket) *BucketAttributes {
var tc *time.Time
if bucket.TimeCreated != nil {
t := bucket.TimeCreated.Time
tc = &t
}
return &BucketAttributes{
Name: bucket.Name,
ID: bucket.Id,
Namespace: bucket.Namespace,
TimeCreated: tc,
StorageTier: string(bucket.StorageTier),
PublicAccessType: string(bucket.PublicAccessType),
KmsKeyID: bucket.KmsKeyId,
Versioning: string(bucket.Versioning),
ReplicationEnabled: bucket.ReplicationEnabled,
IsReadOnly: bucket.IsReadOnly,
ApproximateCount: bucket.ApproximateCount,
ApproximateSize: bucket.ApproximateSize,
FreeformTags: bucket.FreeformTags,
DefinedTags: bucket.DefinedTags,
}
}
func NewBucketAttributesFromOCIBucketSummary(bucket objectstorage.BucketSummary) *BucketAttributes {
var tc *time.Time
if bucket.TimeCreated != nil {
t := bucket.TimeCreated.Time
tc = &t
}
return &BucketAttributes{
Name: bucket.Name,
Namespace: bucket.Namespace,
TimeCreated: tc,
}
}
// NewDomainBucketFromAttrs builds a domain.Bucket from provider-agnostic attributes.
func NewDomainBucketFromAttrs(attrs BucketAttributes) domain.Bucket {
encryption := "Oracle-managed"
if kmsKeyID := stringValue(attrs.KmsKeyID); kmsKeyID != "" {
encryption = "Customer-managed (KMS)"
}
db := domain.Bucket{
Name: stringValue(attrs.Name),
OCID: stringValue(attrs.ID),
Namespace: stringValue(attrs.Namespace),
StorageTier: attrs.StorageTier,
Visibility: attrs.PublicAccessType,
Versioning: attrs.Versioning,
Encryption: encryption,
ReplicationEnabled: boolValue(attrs.ReplicationEnabled),
IsReadOnly: boolValue(attrs.IsReadOnly),
ApproximateCount: intValueFromInt64(attrs.ApproximateCount),
ApproximateSize: int64Value(attrs.ApproximateSize),
FreeformTags: attrs.FreeformTags,
DefinedTags: attrs.DefinedTags,
}
if attrs.TimeCreated != nil {
db.TimeCreated = *attrs.TimeCreated
}
return db
}
// Helper to dereference a *string safely.
func stringValue(s *string) string {
if s == nil {
return ""
}
return *s
}
// Helper to dereference a *bool safely.
func boolValue(b *bool) bool {
if b == nil {
return false
}
return *b
}
// Helper to convert *int64 to int safely.
func intValueFromInt64(v *int64) int {
if v == nil {
return 0
}
return int(*v)
}
// Helper to dereference *int64 safely.
func int64Value(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
package mapping
import (
domain "github.com/cnopslabs/ocloud/internal/domain/identity"
"github.com/oracle/oci-go-sdk/v65/identity"
)
type CompartmentAttributes struct {
OCID *string
Name *string
Description *string
LifecycleState identity.CompartmentLifecycleStateEnum
FreeformTags map[string]string
DefinedTags map[string]map[string]interface{}
}
func NewCompartmentAttributesFromOCICompartment(c identity.Compartment) *CompartmentAttributes {
return &CompartmentAttributes{
OCID: c.Id,
Name: c.Name,
Description: c.Description,
LifecycleState: c.LifecycleState,
FreeformTags: c.FreeformTags,
DefinedTags: c.DefinedTags,
}
}
func NewDomainCompartmentFromAttrs(c *CompartmentAttributes) *domain.Compartment {
var ocid, name, description, lifecycleState string
if c.OCID != nil {
ocid = *c.OCID
}
if c.Name != nil {
name = *c.Name
}
if c.Description != nil {
description = *c.Description
}
if c.LifecycleState != "" {
lifecycleState = string(c.LifecycleState)
}
return &domain.Compartment{
OCID: ocid,
DisplayName: name,
Description: description,
LifecycleState: lifecycleState,
FreeformTags: c.FreeformTags,
DefinedTags: c.DefinedTags,
}
}
package mapping
import (
compute "github.com/cnopslabs/ocloud/internal/domain/compute"
)
// NewDomainImageFromAttrs builds a domain Image from provider-agnostic attributes.
func NewDomainImageFromAttrs(attrs ImageAttributes) compute.Image {
img := compute.Image{
OCID: stringValue(attrs.ID),
DisplayName: stringValue(attrs.DisplayName),
OperatingSystem: stringValue(attrs.OperatingSystem),
OperatingSystemVersion: stringValue(attrs.OperatingSystemVersion),
LaunchMode: attrs.LaunchMode,
}
if attrs.TimeCreated != nil {
img.TimeCreated = *attrs.TimeCreated
}
return img
}
package mapping
import (
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
type InstanceAttributes struct {
OCID *string
DisplayName *string
State core.InstanceLifecycleStateEnum
Shape *string
ImageId *string
TimeCreated *common.SDKTime
Region *string
AvailabilityDomain *string
FaultDomain *string
Vcpus *float32
MemoryInGBs *float32
FreeformTags map[string]string
DefinedTags map[string]map[string]interface{}
}
func NewInstanceAttributesFromOCIInstance(i core.Instance) *InstanceAttributes {
return &InstanceAttributes{
OCID: i.Id,
DisplayName: i.DisplayName,
State: i.LifecycleState,
Shape: i.Shape,
ImageId: i.ImageId,
TimeCreated: i.TimeCreated,
Region: i.Region,
AvailabilityDomain: i.AvailabilityDomain,
FaultDomain: i.FaultDomain,
Vcpus: i.ShapeConfig.Ocpus,
MemoryInGBs: i.ShapeConfig.MemoryInGBs,
FreeformTags: i.FreeformTags,
DefinedTags: i.DefinedTags,
}
}
func NewDomainInstanceFromAttrs(i *InstanceAttributes) *domain.Instance {
var ocid, displayName, state, shape, imageId, region, availabilityDomain, faultDomain string
var timeCreated time.Time
var vcpus int
var memoryGB float32
if i.OCID != nil {
ocid = *i.OCID
}
if i.DisplayName != nil {
displayName = *i.DisplayName
}
if i.State != "" {
state = string(i.State)
}
if i.Shape != nil {
shape = *i.Shape
}
if i.ImageId != nil {
imageId = *i.ImageId
}
if i.TimeCreated != nil {
timeCreated = i.TimeCreated.Time
}
if i.Region != nil {
region = *i.Region
}
if i.AvailabilityDomain != nil {
availabilityDomain = *i.AvailabilityDomain
}
if i.FaultDomain != nil {
faultDomain = *i.FaultDomain
}
if i.Vcpus != nil {
vcpus = int(*i.Vcpus)
}
if i.MemoryInGBs != nil {
memoryGB = *i.MemoryInGBs
}
return &domain.Instance{
OCID: ocid,
DisplayName: displayName,
State: state,
Shape: shape,
ImageID: imageId,
TimeCreated: timeCreated,
Region: region,
AvailabilityDomain: availabilityDomain,
FaultDomain: faultDomain,
VCPUs: vcpus,
MemoryGB: memoryGB,
FreeformTags: i.FreeformTags,
DefinedTags: i.DefinedTags,
}
}
type VnicAttributes struct {
PrivateIp *string
SubnetId *string
HostnameLabel *string
SkipSourceDestCheck *bool
}
func NewVnicAttributesFromOCIVnic(v core.Vnic) *VnicAttributes {
return &VnicAttributes{
PrivateIp: v.PrivateIp,
SubnetId: v.SubnetId,
HostnameLabel: v.HostnameLabel,
SkipSourceDestCheck: v.SkipSourceDestCheck,
}
}
package mapping
import (
"sort"
"strconv"
"strings"
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/network/loadbalancer"
"github.com/oracle/oci-go-sdk/v65/loadbalancer"
)
type LoadBalancerAttributes struct {
ID *string
DisplayName *string
LifecycleState loadbalancer.LoadBalancerLifecycleStateEnum
IsPrivate *bool
ShapeName *string
TimeCreated *time.Time
IpAddresses []loadbalancer.IpAddress
Listeners map[string]loadbalancer.Listener
RoutingPolicies map[string]loadbalancer.RoutingPolicy
BackendSets map[string]loadbalancer.BackendSet
SubnetIds []string
NetworkSecurityGroupIds []string
Certificates map[string]loadbalancer.Certificate
Hostnames map[string]loadbalancer.Hostname
}
func NewLoadBalancerAttributesFromOCILoadBalancer(lb loadbalancer.LoadBalancer) *LoadBalancerAttributes {
return &LoadBalancerAttributes{
ID: lb.Id,
DisplayName: lb.DisplayName,
LifecycleState: lb.LifecycleState,
IsPrivate: lb.IsPrivate,
ShapeName: lb.ShapeName,
TimeCreated: &lb.TimeCreated.Time,
IpAddresses: lb.IpAddresses,
Listeners: lb.Listeners,
RoutingPolicies: lb.RoutingPolicies,
BackendSets: lb.BackendSets,
SubnetIds: lb.SubnetIds,
NetworkSecurityGroupIds: lb.NetworkSecurityGroupIds,
Certificates: lb.Certificates,
Hostnames: lb.Hostnames,
}
}
func NewDomainLoadBalancerFromAttrs(lb *LoadBalancerAttributes) *domain.LoadBalancer {
deref := func(p *string) string {
if p == nil {
return ""
}
return *p
}
derefInt := func(p *int) int {
if p == nil {
return 0
}
return *p
}
id := deref(lb.ID)
name := deref(lb.DisplayName)
shape := deref(lb.ShapeName)
// Type
typeStr := "Public"
if lb.IsPrivate != nil && *lb.IsPrivate {
typeStr = "Private"
}
var createdTime *time.Time
if lb.TimeCreated != nil {
t := *lb.TimeCreated
createdTime = &t
}
ips := make([]string, 0, len(lb.IpAddresses))
for i := range lb.IpAddresses {
ip := lb.IpAddresses[i]
addr := deref(ip.IpAddress)
if addr == "" {
continue
}
if ip.IsPublic != nil {
if *ip.IsPublic {
addr += " (public)"
} else {
addr += " (private)"
}
}
ips = append(ips, addr)
}
// Listeners (name -> "proto:port -> backendset")
listeners := make(map[string]string, len(lb.Listeners))
useSSL := false
// routing policies referenced by listeners; fallback to LB map if none seen
routingPolicySet := make(map[string]struct{}, len(lb.RoutingPolicies))
for lname, l := range lb.Listeners {
port := derefInt(l.Port)
backend := deref(l.DefaultBackendSetName)
if l.SslConfiguration != nil {
useSSL = true
}
if rp := deref(l.RoutingPolicyName); rp != "" {
routingPolicySet[rp] = struct{}{}
}
// Cheap protocol detection:
// If SSL config exists or typical HTTPS ports → https
// Else if the protocol string is "TCP" (case-insensitive) → tcp
// Else common HTTP ports → http
proto := "http"
if l.SslConfiguration != nil || port == 443 || port == 8443 {
proto = "https"
} else if l.Protocol != nil {
p := *l.Protocol
if p == "TCP" || p == "tcp" || p == "Tcp" {
proto = "tcp"
} else if port == 80 {
proto = "http"
}
} else if port == 80 {
proto = "http"
}
// Build "proto:port → backend" without fmt
// (fmt is fine, but concat avoids an allocation)
val := proto + ":" + strconv.Itoa(port) + " → " + backend
listeners[lname] = val
}
if len(routingPolicySet) == 0 {
for rpName := range lb.RoutingPolicies {
if rpName != "" {
routingPolicySet[rpName] = struct{}{}
}
}
}
routingPolicies := make([]string, 0, len(routingPolicySet))
for rp := range routingPolicySet {
routingPolicies = append(routingPolicies, rp)
}
sort.Strings(routingPolicies)
// Backend sets (policy + health-check summary)
backendSets := make(map[string]domain.BackendSet, len(lb.BackendSets))
for bsName, bs := range lb.BackendSets {
policy := deref(bs.Policy)
hc := ""
if bs.HealthChecker != nil {
// Derive health-check label: PROTO:PORT (prefer explicit; normalize common ports)
var p string
if bs.HealthChecker.Protocol != nil {
// normalize once; no need for ToUpper on every path
switch *bs.HealthChecker.Protocol {
case "https", "HTTPS", "Https":
p = "HTTPS"
case "http", "HTTP", "Http":
p = "HTTP"
case "tcp", "TCP", "Tcp":
p = "TCP"
default:
p = strings.ToUpper(*bs.HealthChecker.Protocol)
}
}
port := 0
if bs.HealthChecker.Port != nil {
port = int(*bs.HealthChecker.Port)
}
// If port hints common protocols, prefer those (matches your earlier behavior)
switch port {
case 443, 8443:
p = "HTTPS"
case 80:
if p == "" {
p = "HTTP"
}
}
if p != "" {
hc = p + ":" + strconv.Itoa(port)
}
}
backendSets[bsName] = domain.BackendSet{
Policy: policy,
Health: hc,
Backends: []domain.Backend{}, // filled during enrichment
}
}
// Subnets/NSGs – copy IDs (pre-size)
subnets := make([]string, len(lb.SubnetIds))
copy(subnets, lb.SubnetIds)
nsgs := make([]string, len(lb.NetworkSecurityGroupIds))
copy(nsgs, lb.NetworkSecurityGroupIds)
// Certificates: collect names only (pre-size)
certs := make([]string, 0, len(lb.Certificates))
for cname := range lb.Certificates {
certs = append(certs, cname)
}
// Hostnames (prefer value, fallback to key) + sort
hostnames := make([]string, 0, len(lb.Hostnames))
for key, h := range lb.Hostnames {
if h.Hostname != nil && *h.Hostname != "" {
hostnames = append(hostnames, *h.Hostname)
} else if s := strings.TrimSpace(key); s != "" {
hostnames = append(hostnames, s)
}
}
sort.Strings(hostnames)
return &domain.LoadBalancer{
ID: id,
OCID: id,
Name: name,
State: string(lb.LifecycleState),
Type: typeStr,
IPAddresses: ips,
Shape: shape,
Listeners: listeners,
BackendHealth: make(map[string]string),
Subnets: subnets,
NSGs: nsgs,
Created: createdTime,
BackendSets: backendSets,
SSLCertificates: certs,
RoutingPolicies: routingPolicies,
UseSSL: useSSL,
Hostnames: hostnames,
}
}
package mapping
import (
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/oracle/oci-go-sdk/v65/containerengine"
)
type ClusterAttributes struct {
OCID *string
DisplayName *string
KubernetesVersion *string
VcnOCID *string
State string
PrivateEndpoint *string
PublicEndpoint *string
TimeCreated *time.Time
FreeformTags map[string]string
DefinedTags map[string]map[string]interface{}
}
func NewClusterAttributesFromOCICluster(c containerengine.Cluster) *ClusterAttributes {
var privateEndpoint, publicEndpoint *string
if c.Endpoints != nil {
privateEndpoint = c.Endpoints.PrivateEndpoint
publicEndpoint = c.Endpoints.Kubernetes
}
var timeCreated *time.Time
if c.Metadata != nil && c.Metadata.TimeCreated != nil {
t := c.Metadata.TimeCreated.Time
timeCreated = &t
}
return &ClusterAttributes{
OCID: c.Id,
DisplayName: c.Name,
KubernetesVersion: c.KubernetesVersion,
VcnOCID: c.VcnId,
State: string(c.LifecycleState),
PrivateEndpoint: privateEndpoint,
PublicEndpoint: publicEndpoint,
TimeCreated: timeCreated,
FreeformTags: c.FreeformTags,
DefinedTags: c.DefinedTags,
}
}
func NewClusterAttributesFromOCIClusterSummary(c containerengine.ClusterSummary) *ClusterAttributes {
var privateEndpoint, publicEndpoint *string
if c.Endpoints != nil {
privateEndpoint = c.Endpoints.PrivateEndpoint
publicEndpoint = c.Endpoints.Kubernetes
}
var timeCreated *time.Time
if c.Metadata != nil && c.Metadata.TimeCreated != nil {
t := c.Metadata.TimeCreated.Time
timeCreated = &t
}
return &ClusterAttributes{
OCID: c.Id,
DisplayName: c.Name,
KubernetesVersion: c.KubernetesVersion,
VcnOCID: c.VcnId,
State: string(c.LifecycleState),
PrivateEndpoint: privateEndpoint,
PublicEndpoint: publicEndpoint,
TimeCreated: timeCreated,
FreeformTags: c.FreeformTags,
DefinedTags: c.DefinedTags,
}
}
func NewDomainClusterFromAttrs(c *ClusterAttributes) *domain.Cluster {
var ocid, displayName, kubernetesVersion, vcnOCID, state, privateEndpoint, publicEndpoint string
var timeCreated time.Time
if c.OCID != nil {
ocid = *c.OCID
}
if c.DisplayName != nil {
displayName = *c.DisplayName
}
if c.KubernetesVersion != nil {
kubernetesVersion = *c.KubernetesVersion
}
if c.VcnOCID != nil {
vcnOCID = *c.VcnOCID
}
if c.State != "" {
state = c.State
}
if c.PrivateEndpoint != nil {
privateEndpoint = *c.PrivateEndpoint
}
if c.PublicEndpoint != nil {
publicEndpoint = *c.PublicEndpoint
}
if c.TimeCreated != nil {
timeCreated = *c.TimeCreated
}
return &domain.Cluster{
OCID: ocid,
DisplayName: displayName,
KubernetesVersion: kubernetesVersion,
VcnOCID: vcnOCID,
State: state,
PrivateEndpoint: privateEndpoint,
PublicEndpoint: publicEndpoint,
TimeCreated: timeCreated,
FreeformTags: c.FreeformTags,
DefinedTags: c.DefinedTags,
}
}
type NodePoolAttributes struct {
OCID *string
DisplayName *string
KubernetesVersion *string
NodeShape *string
NodeCount *int
FreeformTags map[string]string
DefinedTags map[string]map[string]interface{}
}
func NewNodePoolAttributesFromOCINodePool(np containerengine.NodePool) *NodePoolAttributes {
var nodeCount *int
if np.NodeConfigDetails != nil {
nodeCount = np.NodeConfigDetails.Size
}
return &NodePoolAttributes{
OCID: np.Id,
DisplayName: np.Name,
KubernetesVersion: np.KubernetesVersion,
NodeShape: np.NodeShape,
NodeCount: nodeCount,
FreeformTags: np.FreeformTags,
DefinedTags: np.DefinedTags,
}
}
func NewNodePoolAttributesFromOCINodePoolSummary(np containerengine.NodePoolSummary) *NodePoolAttributes {
var nodeCount *int
if np.NodeConfigDetails != nil {
nodeCount = np.NodeConfigDetails.Size
}
return &NodePoolAttributes{
OCID: np.Id,
DisplayName: np.Name,
KubernetesVersion: np.KubernetesVersion,
NodeShape: np.NodeShape,
NodeCount: nodeCount,
FreeformTags: np.FreeformTags,
DefinedTags: np.DefinedTags,
}
}
func NewDomainNodePoolFromAttrs(np *NodePoolAttributes) *domain.NodePool {
var ocid, displayName, kubernetesVersion, nodeShape string
var nodeCount int
if np.OCID != nil {
ocid = *np.OCID
}
if np.DisplayName != nil {
displayName = *np.DisplayName
}
if np.KubernetesVersion != nil {
kubernetesVersion = *np.KubernetesVersion
}
if np.NodeShape != nil {
nodeShape = *np.NodeShape
}
if np.NodeCount != nil {
nodeCount = *np.NodeCount
}
return &domain.NodePool{
OCID: ocid,
DisplayName: displayName,
KubernetesVersion: kubernetesVersion,
NodeShape: nodeShape,
NodeCount: nodeCount,
FreeformTags: np.FreeformTags,
DefinedTags: np.DefinedTags,
}
}
package mapping
import (
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/identity"
"github.com/oracle/oci-go-sdk/v65/identity"
)
type PolicyAttributes struct {
Name *string
ID *string
Statement []string
Description *string
TimeCreated *time.Time
FreeformTags map[string]string
DefinedTags map[string]map[string]interface{}
}
func NewPolicyAttributesFromOCIPolicy(p identity.Policy) *PolicyAttributes {
return &PolicyAttributes{
Name: p.Name,
ID: p.Id,
Statement: p.Statements,
Description: p.Description,
TimeCreated: &p.TimeCreated.Time,
FreeformTags: p.FreeformTags,
DefinedTags: p.DefinedTags,
}
}
func NewDomainPolicyFromAttrs(p *PolicyAttributes) *domain.Policy {
var name, id, description string
var timeCreated time.Time
if p.Name != nil {
name = *p.Name
}
if p.ID != nil {
id = *p.ID
}
if p.Description != nil {
description = *p.Description
}
if p.TimeCreated != nil {
timeCreated = *p.TimeCreated
}
return &domain.Policy{
Name: name,
ID: id,
Statement: p.Statement,
Description: description,
TimeCreated: timeCreated,
FreeformTags: p.FreeformTags,
DefinedTags: p.DefinedTags,
}
}
package mapping
import (
"time"
domain_vcn "github.com/cnopslabs/ocloud/internal/domain/network/vcn"
"github.com/oracle/oci-go-sdk/v65/core"
)
// ImageAttributes is a provider-agnostic representation of an image.
// Adapters should populate this from their SDK types and pass to the builder.
type ImageAttributes struct {
ID *string
DisplayName *string
OperatingSystem *string
OperatingSystemVersion *string
LaunchMode string
TimeCreated *time.Time
}
func NewImageAttributesFromOCIImage(i core.Image) *ImageAttributes {
return &ImageAttributes{
ID: i.Id,
DisplayName: i.DisplayName,
OperatingSystem: i.OperatingSystem,
OperatingSystemVersion: i.OperatingSystemVersion,
LaunchMode: string(i.LaunchMode),
TimeCreated: &i.TimeCreated.Time,
}
}
type SubnetAttributes struct {
OCID *string
DisplayName *string
LifecycleState core.SubnetLifecycleStateEnum
CidrBlock *string
RouteTableId *string
ProhibitPublicIpOnVnic *bool
SecurityListIds []string
VcnId *string
}
func NewSubnetAttributesFromOCISubnet(s core.Subnet) *SubnetAttributes {
return &SubnetAttributes{
OCID: s.Id,
DisplayName: s.DisplayName,
LifecycleState: s.LifecycleState,
CidrBlock: s.CidrBlock,
RouteTableId: s.RouteTableId,
ProhibitPublicIpOnVnic: s.ProhibitPublicIpOnVnic,
SecurityListIds: s.SecurityListIds,
VcnId: s.VcnId,
}
}
func NewDomainSubnetFromAttrs(s *SubnetAttributes) *domain_vcn.Subnet {
var ocid, displayName, lifecycleState, cidrBlock, routeTableId string
var public bool
if s.OCID != nil {
ocid = *s.OCID
}
if s.DisplayName != nil {
displayName = *s.DisplayName
}
if s.LifecycleState != "" {
lifecycleState = string(s.LifecycleState)
}
if s.CidrBlock != nil {
cidrBlock = *s.CidrBlock
}
if s.RouteTableId != nil {
routeTableId = *s.RouteTableId
}
if s.ProhibitPublicIpOnVnic != nil {
public = !*s.ProhibitPublicIpOnVnic
}
return &domain_vcn.Subnet{
OCID: ocid,
DisplayName: displayName,
LifecycleState: lifecycleState,
CidrBlock: cidrBlock,
Public: public,
RouteTableID: routeTableId,
SecurityListIDs: s.SecurityListIds,
}
}
type RouteTableAttributes struct {
OCID *string
DisplayName *string
LifecycleState core.RouteTableLifecycleStateEnum
}
func NewRouteTableAttributesFromOCIRouteTable(rt core.RouteTable) *RouteTableAttributes {
return &RouteTableAttributes{
OCID: rt.Id,
DisplayName: rt.DisplayName,
LifecycleState: rt.LifecycleState,
}
}
func NewDomainRouteTableFromAttrs(rt *RouteTableAttributes) *domain_vcn.RouteTable {
var ocid, displayName, lifecycleState string
if rt.OCID != nil {
ocid = *rt.OCID
}
if rt.DisplayName != nil {
displayName = *rt.DisplayName
}
if rt.LifecycleState != "" {
lifecycleState = string(rt.LifecycleState)
}
return &domain_vcn.RouteTable{
OCID: ocid,
DisplayName: displayName,
LifecycleState: lifecycleState,
}
}
type VcnAttributes struct {
DisplayName *string
}
func NewVcnAttributesFromOCIVcn(v core.Vcn) *VcnAttributes {
return &VcnAttributes{
DisplayName: v.DisplayName,
}
}
package mapping
import (
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/network/vcn"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
type VCNAttributes struct {
OCID *string
DisplayName *string
LifecycleState core.VcnLifecycleStateEnum
CompartmentID *string
CidrBlocks []string
Ipv6CidrBlocks []string
DnsLabel *string
VcnDomainName *string
DefaultDhcpOptionsId *string
TimeCreated *common.SDKTime
FreeformTags map[string]string
DefinedTags map[string]map[string]interface{}
}
func NewVCNAttributesFromOCIVCN(v core.Vcn) *VCNAttributes {
return &VCNAttributes{
OCID: v.Id,
DisplayName: v.DisplayName,
LifecycleState: v.LifecycleState,
CompartmentID: v.CompartmentId,
CidrBlocks: v.CidrBlocks,
Ipv6CidrBlocks: v.Ipv6CidrBlocks,
DnsLabel: v.DnsLabel,
VcnDomainName: v.VcnDomainName,
DefaultDhcpOptionsId: v.DefaultDhcpOptionsId,
TimeCreated: v.TimeCreated,
FreeformTags: v.FreeformTags,
DefinedTags: v.DefinedTags,
}
}
func NewDomainVCNFromAttrs(v *VCNAttributes) *domain.VCN {
var ocid, displayName, lifecycleState, compartmentID, dnsLabel, domainName, dhcpOptionsID string
var timeCreated time.Time
if v.OCID != nil {
ocid = *v.OCID
}
if v.DisplayName != nil {
displayName = *v.DisplayName
}
if v.LifecycleState != "" {
lifecycleState = string(v.LifecycleState)
}
if v.CompartmentID != nil {
compartmentID = *v.CompartmentID
}
if v.DnsLabel != nil {
dnsLabel = *v.DnsLabel
}
if v.VcnDomainName != nil {
domainName = *v.VcnDomainName
}
if v.DefaultDhcpOptionsId != nil {
dhcpOptionsID = *v.DefaultDhcpOptionsId
}
if v.TimeCreated != nil {
timeCreated = v.TimeCreated.Time
}
return &domain.VCN{
OCID: ocid,
DisplayName: displayName,
LifecycleState: lifecycleState,
CompartmentID: compartmentID,
CidrBlocks: v.CidrBlocks,
Ipv6Enabled: len(v.Ipv6CidrBlocks) > 0,
DnsLabel: dnsLabel,
DomainName: domainName,
DhcpOptionsID: dhcpOptionsID,
TimeCreated: timeCreated,
FreeformTags: v.FreeformTags,
DefinedTags: v.DefinedTags,
}
}
type GatewayAttributes struct {
OCID *string
DisplayName *string
LifecycleState string
Type string
}
func NewGatewayAttributesFromOCIInternetGateway(ig core.InternetGateway) *GatewayAttributes {
return &GatewayAttributes{
OCID: ig.Id,
DisplayName: ig.DisplayName,
LifecycleState: string(ig.LifecycleState),
Type: "Internet",
}
}
func NewGatewayAttributesFromOCINatGateway(ng core.NatGateway) *GatewayAttributes {
return &GatewayAttributes{
OCID: ng.Id,
DisplayName: ng.DisplayName,
LifecycleState: string(ng.LifecycleState),
Type: "NAT",
}
}
func NewGatewayAttributesFromOCIServiceGateway(sg core.ServiceGateway) *GatewayAttributes {
return &GatewayAttributes{
OCID: sg.Id,
DisplayName: sg.DisplayName,
LifecycleState: string(sg.LifecycleState),
Type: "Service",
}
}
func NewGatewayAttributesFromOCILocalPeeringGateway(lpg core.LocalPeeringGateway) *GatewayAttributes {
return &GatewayAttributes{
OCID: lpg.Id,
DisplayName: lpg.DisplayName,
LifecycleState: string(lpg.LifecycleState),
Type: "Local Peering",
}
}
func NewGatewayAttributesFromOCIDrgAttachment(drg core.DrgAttachment) *GatewayAttributes {
return &GatewayAttributes{
OCID: drg.Id,
DisplayName: drg.DisplayName,
LifecycleState: string(drg.LifecycleState),
Type: "DRG",
}
}
func NewDomainGatewayFromAttrs(g *GatewayAttributes) *domain.Gateway {
var ocid, displayName, lifecycleState, typeName string
if g.OCID != nil {
ocid = *g.OCID
}
if g.DisplayName != nil {
displayName = *g.DisplayName
}
if g.LifecycleState != "" {
lifecycleState = g.LifecycleState
}
if g.Type != "" {
typeName = g.Type
}
return &domain.Gateway{
OCID: ocid,
DisplayName: displayName,
LifecycleState: lifecycleState,
Type: typeName,
}
}
type SecurityListAttributes struct {
OCID *string
DisplayName *string
LifecycleState core.SecurityListLifecycleStateEnum
}
func NewSecurityListAttributesFromOCISecurityList(sl core.SecurityList) *SecurityListAttributes {
return &SecurityListAttributes{
OCID: sl.Id,
DisplayName: sl.DisplayName,
LifecycleState: sl.LifecycleState,
}
}
func NewDomainSecurityListFromAttrs(sl *SecurityListAttributes) *domain.SecurityList {
var ocid, displayName, lifecycleState string
if sl.OCID != nil {
ocid = *sl.OCID
}
if sl.DisplayName != nil {
displayName = *sl.DisplayName
}
if sl.LifecycleState != "" {
lifecycleState = string(sl.LifecycleState)
}
return &domain.SecurityList{
OCID: ocid,
DisplayName: displayName,
LifecycleState: lifecycleState,
}
}
type NSGAttributes struct {
OCID *string
DisplayName *string
LifecycleState core.NetworkSecurityGroupLifecycleStateEnum
}
func NewNSGAttributesFromOCINSG(nsg core.NetworkSecurityGroup) *NSGAttributes {
return &NSGAttributes{
OCID: nsg.Id,
DisplayName: nsg.DisplayName,
LifecycleState: nsg.LifecycleState,
}
}
func NewDomainNSGFromAttrs(nsg *NSGAttributes) *domain.NSG {
var ocid, displayName, lifecycleState string
if nsg.OCID != nil {
ocid = *nsg.OCID
}
if nsg.DisplayName != nil {
displayName = *nsg.DisplayName
}
if nsg.LifecycleState != "" {
lifecycleState = string(nsg.LifecycleState)
}
return &domain.NSG{
OCID: ocid,
DisplayName: displayName,
LifecycleState: lifecycleState,
}
}
type DhcpOptionsAttributes struct {
OCID *string
DisplayName *string
LifecycleState core.DhcpOptionsLifecycleStateEnum
DomainNameType string
}
func NewDhcpOptionsAttributesFromOCIDhcpOptions(do core.DhcpOptions) *DhcpOptionsAttributes {
return &DhcpOptionsAttributes{
OCID: do.Id,
DisplayName: do.DisplayName,
LifecycleState: do.LifecycleState,
}
}
func NewDomainDhcpOptionsFromAttrs(do *DhcpOptionsAttributes) *domain.DhcpOptions {
var ocid, displayName, lifecycleState string
if do.OCID != nil {
ocid = *do.OCID
}
if do.DisplayName != nil {
displayName = *do.DisplayName
}
if do.LifecycleState != "" {
lifecycleState = string(do.LifecycleState)
}
return &domain.DhcpOptions{
OCID: ocid,
DisplayName: displayName,
LifecycleState: lifecycleState,
DomainNameType: do.DomainNameType,
}
}
package image
import (
"context"
"fmt"
domain "github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/mapping"
"github.com/oracle/oci-go-sdk/v65/core"
)
// Adapter is an infrastructure-layer adapter that implements the domain.ImageRepository interface.
type Adapter struct {
client core.ComputeClient
}
// NewAdapter creates a new adapter for interacting with OCI images.
func NewAdapter(client core.ComputeClient) *Adapter {
return &Adapter{client: client}
}
// GetImage retrieves a single image by its OCID.
func (a *Adapter) GetImage(ctx context.Context, ocid string) (*domain.Image, error) {
resp, err := a.client.GetImage(ctx, core.GetImageRequest{
ImageId: &ocid,
})
if err != nil {
return nil, fmt.Errorf("getting image from OCI: %w", err)
}
img := mapping.NewDomainImageFromAttrs(*mapping.NewImageAttributesFromOCIImage(resp.Image))
return &img, nil
}
// ListImages retrieves all images in a given compartment.
func (a *Adapter) ListImages(ctx context.Context, compartmentID string) ([]domain.Image, error) {
var images []domain.Image
page := ""
for {
resp, err := a.client.ListImages(ctx, core.ListImagesRequest{
CompartmentId: &compartmentID,
Page: &page,
})
if err != nil {
return nil, fmt.Errorf("listing images from OCI: %w", err)
}
for _, item := range resp.Items {
images = append(images, mapping.NewDomainImageFromAttrs(*mapping.NewImageAttributesFromOCIImage(item)))
}
if resp.OpcNextPage == nil {
break
}
page = *resp.OpcNextPage
}
return images, nil
}
package image
import (
"fmt"
domain "github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/tui"
)
// NewImageListModel builds a TUI list for images.
func NewImageListModel(images []domain.Image) tui.Model {
return tui.NewModel("Images", images, func(image domain.Image) tui.ResourceItemData {
return tui.ResourceItemData{
ID: image.OCID,
Title: image.DisplayName,
Description: fmt.Sprintf("OS: %s • Version: %s", image.OperatingSystem, image.OperatingSystemVersion),
}
})
}
package instance
import (
"context"
"fmt"
"net/http"
"sync"
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/mapping"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
const (
defaultMaxRetries = 5
defaultInitialBackoff = 1 * time.Second
defaultMaxBackoff = 32 * time.Second
)
// Adapter is an infrastructure-layer adapter for compute instances.
// It implements the domain.InstanceRepository interface.
type Adapter struct {
computeClient core.ComputeClient
networkClient core.VirtualNetworkClient
}
// NewAdapter creates a new instance adapter.
func NewAdapter(computeClient core.ComputeClient, networkClient core.VirtualNetworkClient) *Adapter {
return &Adapter{
computeClient: computeClient,
networkClient: networkClient,
}
}
// GetEnrichedInstance fetches a single instance by OCID and enriches it with network and image details.
func (a *Adapter) GetEnrichedInstance(ctx context.Context, instanceID string) (*domain.Instance, error) {
resp, err := a.computeClient.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
if err != nil {
return nil, fmt.Errorf("getting instance from OCI: %w", err)
}
dm, err := a.enrichAndMapInstance(ctx, resp.Instance)
if err != nil {
return nil, err
}
return dm, nil
}
// ListInstances fetches all running instances in a compartment.
func (a *Adapter) ListInstances(ctx context.Context, compartmentID string) ([]domain.Instance, error) {
var allInstances []domain.Instance
var page *string
for {
resp, err := a.computeClient.ListInstances(ctx, core.ListInstancesRequest{
CompartmentId: &compartmentID,
LifecycleState: core.InstanceLifecycleStateRunning,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("listing instances from OCI: %w", err)
}
for _, item := range resp.Items {
allInstances = append(allInstances, *mapping.NewDomainInstanceFromAttrs(mapping.NewInstanceAttributesFromOCIInstance(item)))
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return allInstances, nil
}
// ListEnrichedInstances fetches all running instances in a compartment and enriches them with network and image details.
func (a *Adapter) ListEnrichedInstances(ctx context.Context, compartmentID string) ([]domain.Instance, error) {
var allInstances []core.Instance
var page *string
for {
resp, err := a.computeClient.ListInstances(ctx, core.ListInstancesRequest{
CompartmentId: &compartmentID,
LifecycleState: core.InstanceLifecycleStateRunning,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("listing instances from OCI: %w", err)
}
allInstances = append(allInstances, resp.Items...)
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return a.enrichAndMapInstances(ctx, allInstances)
}
// enrichAndMapInstances converts OCI instances to domain models and enriches them with details.
func (a *Adapter) enrichAndMapInstances(ctx context.Context, ociInstances []core.Instance) ([]domain.Instance, error) {
domainInstances := make([]domain.Instance, len(ociInstances))
var wg sync.WaitGroup
errChan := make(chan error, len(ociInstances))
for i, ociInstance := range ociInstances {
wg.Add(1)
go func(i int, ociInstance core.Instance) {
defer wg.Done()
dm := mapping.NewDomainInstanceFromAttrs(mapping.NewInstanceAttributesFromOCIInstance(ociInstance))
if err := a.enrichDomainInstance(ctx, dm, ociInstance); err != nil {
errChan <- err
return
}
domainInstances[i] = *dm
}(i, ociInstance)
}
wg.Wait()
close(errChan)
for err := range errChan {
return nil, err
}
return domainInstances, nil
}
// enrichAndMapInstance converts a single OCI instance to a domain model and enriches it with details.
func (a *Adapter) enrichAndMapInstance(ctx context.Context, ociInstance core.Instance) (*domain.Instance, error) {
dm := mapping.NewDomainInstanceFromAttrs(mapping.NewInstanceAttributesFromOCIInstance(ociInstance))
if err := a.enrichDomainInstance(ctx, dm, ociInstance); err != nil {
return dm, err
}
return dm, nil
}
// enrichDomainInstance enriches the given domain instance with network, subnet, VCN, route table and image details.
func (a *Adapter) enrichDomainInstance(ctx context.Context, dm *domain.Instance, ociInstance core.Instance) error {
vnic, err := a.getPrimaryVnic(ctx, *ociInstance.Id, *ociInstance.CompartmentId)
if err != nil {
return fmt.Errorf("enriching instance %s with network: %w", dm.OCID, err)
}
if vnic != nil {
vnicAttrs := mapping.NewVnicAttributesFromOCIVnic(*vnic)
dm.PrimaryIP = *vnicAttrs.PrivateIp
dm.SubnetID = *vnicAttrs.SubnetId
if vnicAttrs.HostnameLabel != nil {
dm.Hostname = *vnicAttrs.HostnameLabel
}
dm.PrivateDNSEnabled = vnicAttrs.SkipSourceDestCheck == nil || !*vnicAttrs.SkipSourceDestCheck
subnet, err := a.getSubnet(ctx, *vnicAttrs.SubnetId)
if err != nil {
return fmt.Errorf("enriching instance %s with subnet: %w", dm.OCID, err)
}
if subnet != nil {
subnetAttrs := mapping.NewSubnetAttributesFromOCISubnet(*subnet)
dm.SubnetName = *subnetAttrs.DisplayName
dm.VcnID = *subnet.VcnId
if subnetAttrs.RouteTableId != nil {
dm.RouteTableID = *subnetAttrs.RouteTableId
rt, err := a.getRouteTable(ctx, *subnetAttrs.RouteTableId)
if err != nil {
return fmt.Errorf("enriching instance %s with route table: %w", dm.OCID, err)
}
if rt != nil {
rtAttrs := mapping.NewRouteTableAttributesFromOCIRouteTable(*rt)
dm.RouteTableName = *rtAttrs.DisplayName
}
}
vcn, err := a.getVcn(ctx, *subnet.VcnId)
if err != nil {
return fmt.Errorf("enriching instance %s with vcn: %w", dm.OCID, err)
}
if vcn != nil {
vcnAttrs := mapping.NewVcnAttributesFromOCIVcn(*vcn)
dm.VcnName = *vcnAttrs.DisplayName
}
}
}
image, err := a.getImage(ctx, *ociInstance.ImageId)
if err != nil {
return fmt.Errorf("enriching instance %s with image: %w", dm.OCID, err)
}
if image != nil {
imageAttrs := mapping.NewImageAttributesFromOCIImage(*image)
dm.ImageName = *imageAttrs.DisplayName
dm.ImageOS = *imageAttrs.OperatingSystem
}
return nil
}
// getPrimaryVnic finds the primary VNIC for a given instance.
func (a *Adapter) getPrimaryVnic(ctx context.Context, instanceID, compartmentID string) (*core.Vnic, error) {
var attachments core.ListVnicAttachmentsResponse
var err error
err = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
attachments, e = a.computeClient.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{
CompartmentId: &compartmentID,
InstanceId: &instanceID,
})
return e
})
if err != nil {
return nil, err
}
for _, attach := range attachments.Items {
if attach.VnicId != nil {
var resp core.GetVnicResponse
var vnicErr error
vnicErr = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.networkClient.GetVnic(ctx, core.GetVnicRequest{VnicId: attach.VnicId})
return e
})
if vnicErr == nil {
if resp.Vnic.IsPrimary != nil && *resp.Vnic.IsPrimary {
return &resp.Vnic, nil
}
}
}
}
return nil, nil
}
// getSubnet fetches subnet details.
func (a *Adapter) getSubnet(ctx context.Context, subnetID string) (*core.Subnet, error) {
var resp core.GetSubnetResponse
var err error
err = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.networkClient.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &subnetID})
return e
})
if err != nil {
return nil, err
}
return &resp.Subnet, nil
}
// getVcn fetches VCN details.
func (a *Adapter) getVcn(ctx context.Context, vcnID string) (*core.Vcn, error) {
var resp core.GetVcnResponse
var err error
err = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.networkClient.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
return e
})
if err != nil {
return nil, err
}
return &resp.Vcn, nil
}
// getImage fetches image details.
func (a *Adapter) getImage(ctx context.Context, imageID string) (*core.Image, error) {
var resp core.GetImageResponse
var err error
err = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.computeClient.GetImage(ctx, core.GetImageRequest{ImageId: &imageID})
return e
})
if err != nil {
return nil, err
}
return &resp.Image, nil
}
// getRouteTable fetches route table details.
func (a *Adapter) getRouteTable(ctx context.Context, rtID string) (*core.RouteTable, error) {
var resp core.GetRouteTableResponse
var err error
err = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.networkClient.GetRouteTable(ctx, core.GetRouteTableRequest{RtId: &rtID})
return e
})
if err != nil {
return nil, err
}
return &resp.RouteTable, nil
}
// retryOnRateLimit retries the provided operation when OCI responds with HTTP 429 rate limited.
// It applies exponential backoff between retries and preserves the original behavior and error messages.
func retryOnRateLimit(ctx context.Context, maxRetries int, initialBackoff, maxBackoff time.Duration, op func() error) error {
backoff := initialBackoff
for attempt := 0; attempt < maxRetries; attempt++ {
err := op()
if err == nil {
return nil
}
if serviceErr, ok := common.IsServiceError(err); ok && serviceErr.GetHTTPStatusCode() == http.StatusTooManyRequests {
if attempt == maxRetries-1 {
return fmt.Errorf("rate limit exceeded after %d retries: %w", maxRetries, err)
}
time.Sleep(backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
return err
}
return nil
}
package instance
import (
"fmt"
"strings"
domain "github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/tui"
)
// NewImageListModel builds a TUI list for images.
func NewImageListModel(instances []domain.Instance) tui.Model {
return tui.NewModel("Instances", instances, func(inst domain.Instance) tui.ResourceItemData {
return tui.ResourceItemData{
ID: inst.OCID,
Title: inst.DisplayName,
Description: description(inst),
}
})
}
func description(inst domain.Instance) string {
cpuMem := ""
if inst.VCPUs > 0 || inst.MemoryGB > 0 {
mem := fmt.Sprintf("%.1f", inst.MemoryGB)
mem = strings.TrimSuffix(mem, ".0")
cpuMem = fmt.Sprintf(" %dvCPU/%sGB", inst.VCPUs, mem)
}
spec := strings.TrimSpace(inst.Shape + cpuMem)
fd := inst.FaultDomain
if strings.HasPrefix(fd, "FAULT-DOMAIN-") {
fd = "FD-" + strings.TrimPrefix(fd, "FAULT-DOMAIN-")
}
var locParts []string
if inst.Region != "" {
locParts = append(locParts, inst.Region)
}
if inst.AvailabilityDomain != "" && fd != "" {
locParts = append(locParts, inst.AvailabilityDomain+"/"+fd)
} else if inst.AvailabilityDomain != "" {
locParts = append(locParts, inst.AvailabilityDomain)
} else if fd != "" {
locParts = append(locParts, fd)
}
loc := strings.Join(locParts, " ")
date := ""
if !inst.TimeCreated.IsZero() {
date = inst.TimeCreated.Format("2006-01-02")
}
parts := make([]string, 0, 4)
if inst.State != "" {
parts = append(parts, inst.State)
}
if spec != "" {
parts = append(parts, spec)
}
if loc != "" {
parts = append(parts, loc)
}
if date != "" {
parts = append(parts, date)
}
return strings.Join(parts, " • ")
}
package oke
import (
"context"
"fmt"
domain "github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/mapping"
"github.com/oracle/oci-go-sdk/v65/containerengine"
)
// Adapter is an infrastructure-layer adapter for OKE clusters.
type Adapter struct {
client containerengine.ContainerEngineClient
}
// NewAdapter creates a new OKE adapter.
func NewAdapter(client containerengine.ContainerEngineClient) *Adapter {
return &Adapter{client: client}
}
// GetCluster retrieves a single cluster by its OCID and enriches it with node pools.
func (a *Adapter) GetCluster(ctx context.Context, clusterOCID string) (*domain.Cluster, error) {
resp, err := a.client.GetCluster(ctx, containerengine.GetClusterRequest{
ClusterId: &clusterOCID,
})
if err != nil {
return nil, fmt.Errorf("getting cluster from OCI: %w", err)
}
dc, err := a.enrichAndMapCluster(ctx, resp.Cluster)
if err != nil {
return nil, err
}
return dc, nil
}
// ListClusters fetches all clusters in a compartment and enriches them with node pools.
func (a *Adapter) ListClusters(ctx context.Context, compartmentID string) ([]domain.Cluster, error) {
var ociClusters []containerengine.ClusterSummary
var page *string
for {
resp, err := a.client.ListClusters(ctx, containerengine.ListClustersRequest{
CompartmentId: &compartmentID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("listing OKE clusters from OCI: %w", err)
}
ociClusters = append(ociClusters, resp.Items...)
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return a.mapAndEnrichClusters(ctx, ociClusters)
}
// mapAndEnrichClusters maps OCI clusters (summaries) to domain models and enriches them with node pools.
func (a *Adapter) mapAndEnrichClusters(ctx context.Context, ociClusters []containerengine.ClusterSummary) ([]domain.Cluster, error) {
var domainClusters []domain.Cluster
for _, ociCluster := range ociClusters {
dc := mapping.NewDomainClusterFromAttrs(mapping.NewClusterAttributesFromOCIClusterSummary(ociCluster))
if ociCluster.CompartmentId == nil || ociCluster.Id == nil {
domainClusters = append(domainClusters, *dc)
continue
}
nodePools, err := a.listNodePools(ctx, *ociCluster.CompartmentId, *ociCluster.Id)
if err != nil {
return nil, fmt.Errorf("enriching cluster %s with node pools: %w", dc.OCID, err)
}
dc.NodePools = nodePools
domainClusters = append(domainClusters, *dc)
}
return domainClusters, nil
}
// enrichAndMapCluster maps a single full OCI cluster object to a domain model and enriches it with node pools.
func (a *Adapter) enrichAndMapCluster(ctx context.Context, c containerengine.Cluster) (*domain.Cluster, error) {
dc := mapping.NewDomainClusterFromAttrs(mapping.NewClusterAttributesFromOCICluster(c))
if c.CompartmentId != nil && c.Id != nil {
nps, err := a.listNodePools(ctx, *c.CompartmentId, *c.Id)
if err != nil {
return dc, fmt.Errorf("enriching cluster %s with node pools: %w", dc.OCID, err)
}
dc.NodePools = nps
}
return dc, nil
}
// listNodePools fetches all node pools in a cluster.
func (a *Adapter) listNodePools(ctx context.Context, compartmentID, clusterID string) ([]domain.NodePool, error) {
var domainNodePools []domain.NodePool
var page *string
for {
resp, err := a.client.ListNodePools(ctx, containerengine.ListNodePoolsRequest{
CompartmentId: &compartmentID,
ClusterId: &clusterID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("listing node pools from OCI: %w", err)
}
for _, ociNodePool := range resp.Items {
domainNodePools = append(domainNodePools, *mapping.NewDomainNodePoolFromAttrs(mapping.NewNodePoolAttributesFromOCINodePoolSummary(ociNodePool)))
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return domainNodePools, nil
}
package oke
import (
"fmt"
"strings"
domain "github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/tui"
)
// NewImageListModel builds a TUI list for images.
func NewImageListModel(cluster []domain.Cluster) tui.Model {
return tui.NewModel("Oracle Kubernetes Engine", cluster, func(c domain.Cluster) tui.ResourceItemData {
return tui.ResourceItemData{
ID: c.OCID,
Title: c.DisplayName,
Description: description(c),
}
})
}
func description(c domain.Cluster) string {
parts := make([]string, 0, 4)
if c.State != "" {
parts = append(parts, c.State)
}
if v := strings.TrimSpace(c.KubernetesVersion); v != "" {
parts = append(parts, v)
}
np := len(c.NodePools)
parts = append(parts, fmt.Sprintf("%d node pool%s", np, plural(np)))
if !c.TimeCreated.IsZero() {
parts = append(parts, c.TimeCreated.Format("2006-01-02"))
}
return strings.Join(parts, " • ")
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
package autonomousdb
import (
"context"
"fmt"
domain "github.com/cnopslabs/ocloud/internal/domain/database"
"github.com/cnopslabs/ocloud/internal/mapping"
"github.com/cnopslabs/ocloud/internal/oci"
"github.com/oracle/oci-go-sdk/v65/core"
"github.com/oracle/oci-go-sdk/v65/database"
)
// Adapter implements the domain.AutonomousDatabaseRepository interface for OCI.
type Adapter struct {
dbClient database.DatabaseClient
networkClient core.VirtualNetworkClient
subnetCache map[string]*core.Subnet
vcnCache map[string]*core.Vcn
nsgCache map[string]*core.NetworkSecurityGroup
}
// NewAdapter creates a new Adapter instance.
func NewAdapter(provider oci.ClientProvider) (*Adapter, error) {
dbClient, err := oci.NewDatabaseClient(provider)
if err != nil {
return nil, fmt.Errorf("failed to create database client: %w", err)
}
netClient, err := core.NewVirtualNetworkClientWithConfigurationProvider(provider)
if err != nil {
return nil, fmt.Errorf("failed to create virtual network client: %w", err)
}
return &Adapter{
dbClient: dbClient,
networkClient: netClient,
subnetCache: make(map[string]*core.Subnet),
vcnCache: make(map[string]*core.Vcn),
nsgCache: make(map[string]*core.NetworkSecurityGroup),
}, nil
}
// GetAutonomousDatabase retrieves a single Autonomous Database and maps it to the domain model.
func (a *Adapter) GetAutonomousDatabase(ctx context.Context, ocid string) (*domain.AutonomousDatabase, error) {
response, err := a.dbClient.GetAutonomousDatabase(ctx, database.GetAutonomousDatabaseRequest{
AutonomousDatabaseId: &ocid,
})
if err != nil {
return nil, fmt.Errorf("failed to get autonomous database: %w", err)
}
db, err := a.enrichAndMapAutonomousDatabase(ctx, response.AutonomousDatabase)
if err != nil {
return nil, err
}
return db, nil
}
// ListAutonomousDatabases retrieves a list of autonomous databases from OCI.
func (a *Adapter) ListAutonomousDatabases(ctx context.Context, compartmentID string) ([]domain.AutonomousDatabase, error) {
var allDatabases []domain.AutonomousDatabase
var page *string
for {
resp, err := a.dbClient.ListAutonomousDatabases(ctx, database.ListAutonomousDatabasesRequest{
CompartmentId: &compartmentID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("failed to list autonomous databases: %w", err)
}
for _, item := range resp.Items {
allDatabases = append(allDatabases, *mapping.NewDomainAutonomousDatabaseFromAttrs(mapping.NewAutonomousDatabaseAttributesFromOCIAutonomousDatabaseSummary(item)))
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return allDatabases, nil
}
// ListEnrichedAutonomousDatabase retrieves a list of autonomous databases from OCI and enriches them.
func (a *Adapter) ListEnrichedAutonomousDatabase(ctx context.Context, compartmentID string) ([]domain.AutonomousDatabase, error) {
var results []domain.AutonomousDatabase
var page *string
for {
resp, err := a.dbClient.ListAutonomousDatabases(ctx, database.ListAutonomousDatabasesRequest{
CompartmentId: &compartmentID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("failed to list autonomous databases: %w", err)
}
// Map summaries then enrich (network names). Keep it lightweight like instances' batch enrichment.
batch, err := a.enrichAndMapAutonomousDatabasesFromSummaries(ctx, resp.Items)
if err != nil {
return nil, err
}
results = append(results, batch...)
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return results, nil
}
// enrichNetworkNames resolves display names for subnet, VCN, and NSGs.
func (a *Adapter) enrichNetworkNames(ctx context.Context, d *domain.AutonomousDatabase) error {
if d.SubnetId != "" {
if sub, err := a.getSubnet(ctx, d.SubnetId); err == nil && sub != nil {
if sub.DisplayName != nil {
d.SubnetName = *sub.DisplayName
}
if sub.VcnId != nil {
d.VcnID = *sub.VcnId
if vcn, err := a.getVcn(ctx, *sub.VcnId); err == nil && vcn != nil && vcn.DisplayName != nil {
d.VcnName = *vcn.DisplayName
}
}
}
}
if len(d.NsgIds) > 0 {
var names []string
for _, id := range d.NsgIds {
if nsg, err := a.getNsg(ctx, id); err == nil && nsg != nil && nsg.DisplayName != nil {
names = append(names, *nsg.DisplayName)
}
}
d.NsgNames = names
}
return nil
}
// getSubnet retrieves a subnet by its ID, utilizing a local cache for improved performance.
func (a *Adapter) getSubnet(ctx context.Context, id string) (*core.Subnet, error) {
if s, ok := a.subnetCache[id]; ok {
return s, nil
}
resp, err := a.networkClient.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &id})
if err != nil {
return nil, err
}
a.subnetCache[id] = &resp.Subnet
return &resp.Subnet, nil
}
// getVcn retrieves a VCN by its ID, utilizing a local cache for improved performance.
func (a *Adapter) getVcn(ctx context.Context, id string) (*core.Vcn, error) {
if v, ok := a.vcnCache[id]; ok {
return v, nil
}
resp, err := a.networkClient.GetVcn(ctx, core.GetVcnRequest{VcnId: &id})
if err != nil {
return nil, err
}
a.vcnCache[id] = &resp.Vcn
return &resp.Vcn, nil
}
// getNsg retrieves a NSG by its ID, utilizing a local cache for improved performance.
func (a *Adapter) getNsg(ctx context.Context, id string) (*core.NetworkSecurityGroup, error) {
if n, ok := a.nsgCache[id]; ok {
return n, nil
}
resp, err := a.networkClient.GetNetworkSecurityGroup(ctx, core.GetNetworkSecurityGroupRequest{NetworkSecurityGroupId: &id})
if err != nil {
return nil, err
}
a.nsgCache[id] = &resp.NetworkSecurityGroup
return &resp.NetworkSecurityGroup, nil
}
// enrichDomainAutonomousDB applies additional lookups (e.g., network names) to the mapped domain model.
func (a *Adapter) enrichDomainAutonomousDB(ctx context.Context, d *domain.AutonomousDatabase) error {
return a.enrichNetworkNames(ctx, d)
}
// enrichAndMapAutonomousDatabase maps a full OCI AutonomousDatabase and enriches it.
func (a *Adapter) enrichAndMapAutonomousDatabase(ctx context.Context, full database.AutonomousDatabase) (*domain.AutonomousDatabase, error) {
d := mapping.NewDomainAutonomousDatabaseFromAttrs(mapping.NewAutonomousDatabaseAttributesFromOCIAutonomousDatabase(full))
if err := a.enrichDomainAutonomousDB(ctx, d); err != nil {
return d, fmt.Errorf("enriching autonomous database %s: %w", d.ID, err)
}
return d, nil
}
// enrichAndMapAutonomousDatabasesFromSummaries maps summaries and enriches them (best-effort names).
func (a *Adapter) enrichAndMapAutonomousDatabasesFromSummaries(ctx context.Context, items []database.AutonomousDatabaseSummary) ([]domain.AutonomousDatabase, error) {
res := make([]domain.AutonomousDatabase, 0, len(items))
for _, it := range items {
d := mapping.NewDomainAutonomousDatabaseFromAttrs(mapping.NewAutonomousDatabaseAttributesFromOCIAutonomousDatabaseSummary(it))
if err := a.enrichDomainAutonomousDB(ctx, d); err != nil {
return nil, fmt.Errorf("enriching autonomous database %s: %w", d.ID, err)
}
res = append(res, *d)
}
return res, nil
}
package autonomousdb
import (
"fmt"
"strings"
domain "github.com/cnopslabs/ocloud/internal/domain/database"
"github.com/cnopslabs/ocloud/internal/tui"
)
// NewDatabaseListModel builds a TUI list for ADBs.
func NewDatabaseListModel(adbs []domain.AutonomousDatabase) tui.Model {
return tui.NewModel("Autonomous Databases", adbs, func(adb domain.AutonomousDatabase) tui.ResourceItemData {
return tui.ResourceItemData{
ID: adb.ID,
Title: adb.Name,
Description: describeAutonomousDatabase(adb),
}
})
}
func describeAutonomousDatabase(adb domain.AutonomousDatabase) string {
wv := strings.TrimSpace(strings.Join(
filterNonEmpty(adb.DbWorkload, adb.DbVersion),
" ",
))
cpu := ""
if adb.EcpuCount != nil && *adb.EcpuCount > 0 {
cpu = fmt.Sprintf("%s eCPU", trimFloat(*adb.EcpuCount))
} else if adb.OcpuCount != nil && *adb.OcpuCount > 0 {
cpu = fmt.Sprintf("%s OCPU", trimFloat(*adb.OcpuCount))
} else if adb.CpuCoreCount != nil && *adb.CpuCoreCount > 0 {
cpu = fmt.Sprintf("%d CPU", *adb.CpuCoreCount)
}
storage := ""
switch {
case adb.DataStorageSizeInTBs != nil && *adb.DataStorageSizeInTBs > 0:
storage = fmt.Sprintf("%dTB", *adb.DataStorageSizeInTBs)
case adb.DataStorageSizeInGBs != nil && *adb.DataStorageSizeInGBs > 0:
storage = fmt.Sprintf("%dGB", *adb.DataStorageSizeInGBs)
}
spec := strings.TrimSpace(strings.Join(filterNonEmpty(cpu, storage), "/"))
access := ""
if adb.PrivateEndpointLabel != "" {
access = "Private " + adb.PrivateEndpointLabel
} else if adb.SubnetName != "" {
access = "Private " + adb.SubnetName
} else {
access = "Private"
}
license := ""
switch strings.ToUpper(adb.LicenseModel) {
case "BRING_YOUR_OWN_LICENSE":
license = "BYOL"
case "LICENSE_INCLUDED":
license = "LI"
}
auto := ""
autoFlags := []string{}
if isTrue(adb.IsAutoScalingEnabled) {
autoFlags = append(autoFlags, "CPU-auto")
}
if isTrue(adb.IsStorageAutoScalingEnabled) {
autoFlags = append(autoFlags, "Storage-auto")
}
if len(autoFlags) > 0 {
auto = strings.Join(autoFlags, ",")
}
dg := ""
if isTrue(adb.IsDataGuardEnabled) && adb.Role != "" {
dg = "DG " + strings.ToUpper(adb.Role)
}
date := ""
if adb.TimeCreated != nil && !adb.TimeCreated.IsZero() {
date = adb.TimeCreated.Format("2006-01-02")
}
parts := []string{}
if adb.LifecycleState != "" {
parts = append(parts, adb.LifecycleState)
}
if wv != "" {
parts = append(parts, wv)
}
if spec != "" {
parts = append(parts, spec)
}
if access != "" {
parts = append(parts, access)
}
if license != "" {
parts = append(parts, license)
}
if dg != "" {
parts = append(parts, dg)
}
if auto != "" {
parts = append(parts, auto)
}
if date != "" {
parts = append(parts, date)
}
return strings.Join(parts, " • ")
}
// --- helpers ---
func isTrue(b *bool) bool { return b != nil && *b }
func filterNonEmpty(vals ...string) []string {
out := make([]string, 0, len(vals))
for _, v := range vals {
if strings.TrimSpace(v) != "" {
out = append(out, v)
}
}
return out
}
// trimFloat prints 1 decimal max and trims trailing zeros
func trimFloat(f float32) string {
s := fmt.Sprintf("%.1f", f)
s = strings.TrimRight(s, "0")
return strings.TrimRight(s, ".")
}
package compartment
import (
"context"
"fmt"
"strings"
domain "github.com/cnopslabs/ocloud/internal/domain/identity"
"github.com/cnopslabs/ocloud/internal/mapping"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/identity"
)
// Adapter is an infrastructure-layer adapter that implements the domain.CompartmentRepository interface.
type Adapter struct {
client identity.IdentityClient
compartmentOCID string
}
// NewCompartmentAdapter creates a new adapter for interacting with OCI compartments.
func NewCompartmentAdapter(client identity.IdentityClient, ocid string) *Adapter {
return &Adapter{
client: client,
compartmentOCID: ocid,
}
}
// GetCompartment retrieves a single compartment by its OCID.
func (a *Adapter) GetCompartment(ctx context.Context, ocid string) (*domain.Compartment, error) {
resp, err := a.client.GetCompartment(ctx, identity.GetCompartmentRequest{
CompartmentId: &ocid,
})
if err != nil {
return nil, fmt.Errorf("getting compartment from OCI: %w", err)
}
return mapping.NewDomainCompartmentFromAttrs(mapping.NewCompartmentAttributesFromOCICompartment(resp.Compartment)), nil
}
// ListCompartments retrieves all active compartments under a given parent compartment.
func (a *Adapter) ListCompartments(ctx context.Context, ocid string) ([]domain.Compartment, error) {
var compartments []domain.Compartment
page := ""
for {
includeSubtree := strings.HasPrefix(ocid, "ocid1.tenancy.")
resp, err := a.client.ListCompartments(ctx, identity.ListCompartmentsRequest{
CompartmentId: &ocid,
Page: &page,
AccessLevel: identity.ListCompartmentsAccessLevelAccessible,
LifecycleState: identity.CompartmentLifecycleStateActive,
CompartmentIdInSubtree: common.Bool(includeSubtree),
})
if err != nil {
return nil, fmt.Errorf("listing compartments from OCI: %w", err)
}
for _, item := range resp.Items {
compartments = append(compartments, *mapping.NewDomainCompartmentFromAttrs(mapping.NewCompartmentAttributesFromOCICompartment(item)))
}
if resp.OpcNextPage == nil {
break
}
page = *resp.OpcNextPage
}
return compartments, nil
}
package compartment
import (
"fmt"
domain "github.com/cnopslabs/ocloud/internal/domain/identity"
"github.com/cnopslabs/ocloud/internal/tui"
)
// NewPoliciesListModel builds a TUI list for policies.
func NewPoliciesListModel(c []domain.Compartment) tui.Model {
return tui.NewModel("Compartments", c, func(c domain.Compartment) tui.ResourceItemData {
return tui.ResourceItemData{
ID: c.OCID,
Title: c.DisplayName,
Description: fmt.Sprint(c.LifecycleState, " • ", c.Description),
}
})
}
package policy
import (
"context"
"fmt"
domain "github.com/cnopslabs/ocloud/internal/domain/identity"
"github.com/cnopslabs/ocloud/internal/mapping"
"github.com/oracle/oci-go-sdk/v65/identity"
)
type Adapter struct {
identityClient identity.IdentityClient
}
// NewAdapter creates a new adapter instance.
func NewAdapter(identityClient identity.IdentityClient) *Adapter {
return &Adapter{identityClient: identityClient}
}
// GetPolicy retrieves a single policy by its OCID.
func (a *Adapter) GetPolicy(ctx context.Context, ocid string) (*domain.Policy, error) {
resp, err := a.identityClient.GetPolicy(ctx, identity.GetPolicyRequest{
PolicyId: &ocid,
})
if err != nil {
return nil, fmt.Errorf("failed to get policy: %w", err)
}
return mapping.NewDomainPolicyFromAttrs(mapping.NewPolicyAttributesFromOCIPolicy(resp.Policy)), nil
}
// ListPolicies retrieves all policies in a given compartment.
func (a *Adapter) ListPolicies(ctx context.Context, compartmentID string) ([]domain.Policy, error) {
var policies []domain.Policy
page := ""
for {
resp, err := a.identityClient.ListPolicies(ctx, identity.ListPoliciesRequest{
CompartmentId: &compartmentID,
Page: &page,
})
if err != nil {
return nil, fmt.Errorf("failed to list policies: %w", err)
}
for _, item := range resp.Items {
policies = append(policies, *mapping.NewDomainPolicyFromAttrs(mapping.NewPolicyAttributesFromOCIPolicy(item)))
}
if resp.OpcNextPage == nil {
break
}
page = *resp.OpcNextPage
}
return policies, nil
}
package policy
import (
"fmt"
domain "github.com/cnopslabs/ocloud/internal/domain/identity"
"github.com/cnopslabs/ocloud/internal/tui"
)
// NewPoliciesListModel builds a TUI list for policies.
func NewPoliciesListModel(p []domain.Policy) tui.Model {
return tui.NewModel("Policies", p, func(p domain.Policy) tui.ResourceItemData {
return tui.ResourceItemData{
ID: p.ID,
Title: p.Name,
Description: fmt.Sprint(p.Description, " • ", p.TimeCreated.Format("2006-01-02")),
}
})
}
package oci
import (
"crypto/rsa"
"fmt"
"github.com/oracle/oci-go-sdk/v65/common"
)
// MockConfigurationProvider is a mock implementation of common.ConfigurationProvider
// for testing purposes. It returns predefined values for all methods.
type MockConfigurationProvider struct {
tenancyID string
userID string
keyFingerprint string
region string
passphrase string
}
// NewMockConfigurationProvider creates a new MockConfigurationProvider with default test values
func NewMockConfigurationProvider() common.ConfigurationProvider {
return &MockConfigurationProvider{
tenancyID: "ocid1.tenancy.oc1..mock-tenancy-id",
userID: "ocid1.user.oc1..mock-user-id",
keyFingerprint: "mock-key-fingerprint",
region: "us-ashburn-1",
passphrase: "",
}
}
// TenancyOCID returns the mock tenancy OCID
func (p *MockConfigurationProvider) TenancyOCID() (string, error) {
return p.tenancyID, nil
}
// UserOCID returns the mock user OCID
func (p *MockConfigurationProvider) UserOCID() (string, error) {
return p.userID, nil
}
// KeyFingerprint returns the mock key fingerprint
func (p *MockConfigurationProvider) KeyFingerprint() (string, error) {
return p.keyFingerprint, nil
}
// Region returns the mock region
func (p *MockConfigurationProvider) Region() (string, error) {
return p.region, nil
}
// KeyID returns a formatted key ID using the mock values
func (p *MockConfigurationProvider) KeyID() (string, error) {
tenancy, err := p.TenancyOCID()
if err != nil {
return "", err
}
user, err := p.UserOCID()
if err != nil {
return "", err
}
fingerprint, err := p.KeyFingerprint()
if err != nil {
return "", err
}
return fmt.Sprintf("%s/%s/%s", tenancy, user, fingerprint), nil
}
// PrivateRSAKey returns a mock private key
func (p *MockConfigurationProvider) PrivateRSAKey() (key *rsa.PrivateKey, err error) {
// For testing purposes, we don't need a real private key
// This is just a placeholder that returns nil
return nil, nil
}
// Passphrase returns the mock passphrase
func (p *MockConfigurationProvider) Passphrase() (string, error) {
return p.passphrase, nil
}
// AuthType returns the auth type and configurations
func (p *MockConfigurationProvider) AuthType() (common.AuthConfig, error) {
return common.AuthConfig{
AuthType: common.UserPrincipal,
IsFromConfigFile: false,
}, nil
}
package gateway
import (
"context"
"fmt"
"strings"
"sync"
"github.com/cnopslabs/ocloud/internal/domain/network/vcn"
"github.com/oracle/oci-go-sdk/v65/core"
"golang.org/x/sync/errgroup"
)
type Adapter struct {
client core.VirtualNetworkClient
}
func NewAdapter(client core.VirtualNetworkClient) *Adapter {
return &Adapter{client: client}
}
func (a *Adapter) GatewaysSummary(ctx context.Context, compartmentID, vcnID string) (vcn.Gateways, error) {
var out vcn.Gateways
// Service ID -> Name cache for SGW services
svcNames := make(map[string]string)
eg, ctx := errgroup.WithContext(ctx)
var mu sync.Mutex
// 1)--------------------------------------------- Internet Gateway ------------------------------------------------
eg.Go(func() error {
req := core.ListInternetGatewaysRequest{
CompartmentId: &compartmentID,
VcnId: &vcnID,
}
resp, err := a.client.ListInternetGateways(ctx, req)
if err != nil {
return fmt.Errorf("list IGWs: %w", err)
}
name := "—"
for _, igw := range resp.Items {
// show the first enabled gateway name
if igw.IsEnabled != nil && *igw.IsEnabled {
if igw.DisplayName != nil && *igw.DisplayName != "" {
name = fmt.Sprintf("%s (present)", *igw.DisplayName)
} else {
name = "present"
}
break
}
}
mu.Lock()
out.InternetGateway = name
mu.Unlock()
return nil
})
// 2)-------------------------------------------- NAT Gateway ------------------------------------------------------
eg.Go(func() error {
req := core.ListNatGatewaysRequest{
CompartmentId: &compartmentID,
VcnId: &vcnID,
}
resp, err := a.client.ListNatGateways(ctx, req)
if err != nil {
return fmt.Errorf("list NATs: %w", err)
}
name := "—"
for _, nat := range resp.Items {
if nat.DisplayName != nil && *nat.DisplayName != "" {
name = fmt.Sprintf("%s (present)", *nat.DisplayName)
} else {
name = "present"
}
break
}
mu.Lock()
out.NatGateway = name
mu.Unlock()
return nil
})
// 3)----------------------------- Service Gateway (+attached services pretty names) -------------------------------
eg.Go(func() error {
// first, cache regional services so we can map IDs -> names
sr, err := a.client.ListServices(ctx, core.ListServicesRequest{})
if err != nil {
return fmt.Errorf("list services: %w", err)
}
for _, s := range sr.Items {
if s.Id != nil {
label := "-"
if s.Description != nil && *s.Description != "" {
label = *s.Description
}
svcNames[*s.Id] = label
}
}
// now list SGWs on this VCN
req := core.ListServiceGatewaysRequest{
CompartmentId: &compartmentID,
VcnId: &vcnID,
}
resp, err := a.client.ListServiceGateways(ctx, req)
if err != nil {
return fmt.Errorf("list SGWs: %w", err)
}
if len(resp.Items) == 0 {
mu.Lock()
out.ServiceGateway = "—"
mu.Unlock()
return nil
}
sgw := resp.Items[0]
name := "-"
if sgw.DisplayName != nil && *sgw.DisplayName != "" {
name = *sgw.DisplayName
}
var attached []string
for _, e := range sgw.Services {
if e.ServiceId != nil {
if n, ok := svcNames[*e.ServiceId]; ok && n != "" {
attached = append(attached, n)
}
}
}
s := name
if len(attached) > 0 {
s = fmt.Sprintf("%s (%s)", name, strings.Join(attached, ", "))
}
mu.Lock()
out.ServiceGateway = s
mu.Unlock()
return nil
})
// 4)------------------------------------------ DRG attachment -----------------------------------------------------
eg.Go(func() error {
req := core.ListDrgAttachmentsRequest{
VcnId: &vcnID,
}
resp, err := a.client.ListDrgAttachments(ctx, req)
if err != nil {
return fmt.Errorf("list DRG attachments: %w", err)
}
if len(resp.Items) == 0 {
mu.Lock()
out.Drg = "—"
mu.Unlock()
return nil
}
att := resp.Items[0]
status := "attached"
if att.LifecycleState != "" {
status = strings.ToLower(string(att.LifecycleState))
}
name := "drg"
if att.DrgId != nil {
drg, err := a.client.GetDrg(ctx, core.GetDrgRequest{DrgId: att.DrgId})
if err == nil && drg.DisplayName != nil && *drg.DisplayName != "" {
name = *drg.DisplayName
}
}
mu.Lock()
out.Drg = fmt.Sprintf("%s (%s)", name, status)
mu.Unlock()
return nil
})
// 5)------------------------------------ LPG peers: lpg-name → peer-vcn-name --------------------------------------
eg.Go(func() error {
req := core.ListLocalPeeringGatewaysRequest{
CompartmentId: &compartmentID,
VcnId: &vcnID,
}
resp, err := a.client.ListLocalPeeringGateways(ctx, req)
if err != nil {
return fmt.Errorf("list LPGs: %w", err)
}
var peers []string
for _, lpg := range resp.Items {
lpgName := "-"
if lpg.DisplayName != nil {
lpgName = *lpg.DisplayName
}
// if we have a peer LPG, fetch its VCN name
if lpg.PeerId != nil {
peer, err := a.client.GetLocalPeeringGateway(ctx, core.GetLocalPeeringGatewayRequest{LocalPeeringGatewayId: lpg.PeerId})
if err == nil && peer.LocalPeeringGateway.VcnId != nil {
vcn, err := a.client.GetVcn(ctx, core.GetVcnRequest{VcnId: peer.LocalPeeringGateway.VcnId})
if err == nil && vcn.DisplayName != nil {
peers = append(peers, fmt.Sprintf("%s → %s", lpgName, *vcn.DisplayName))
continue
}
}
peers = append(peers, fmt.Sprintf("%s → <peer>", lpgName))
}
}
mu.Lock()
out.LocalPeeringPeers = peers
mu.Unlock()
return nil
})
if err := eg.Wait(); err != nil {
return vcn.Gateways{}, err
}
return out, nil
}
package loadbalancer
import (
"context"
"fmt"
"sync"
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/network/loadbalancer"
lbLogger "github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/mapping"
"github.com/oracle/oci-go-sdk/v65/certificatesmanagement"
"github.com/oracle/oci-go-sdk/v65/core"
"github.com/oracle/oci-go-sdk/v65/loadbalancer"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate"
)
// Adapter implements the domain.LoadBalancerRepository interface for OCI.
type Adapter struct {
lbClient loadbalancer.LoadBalancerClient
nwClient core.VirtualNetworkClient
certsClient certificatesmanagement.CertificatesManagementClient
limiter *rate.Limiter
sf singleflight.Group
workerCount int
// caches to reduce repeated OCI calls within a command run
subnetCache map[string]core.GetSubnetResponse
vcnCache map[string]core.GetVcnResponse
nsgCache map[string]core.GetNetworkSecurityGroupResponse
certListCache map[string][]loadbalancer.Certificate // keyed by LB ID
muSubnets sync.RWMutex
muVcns sync.RWMutex
muNsgs sync.RWMutex
muCertLists sync.RWMutex
}
// NewAdapter creates a new Adapter instance using pre-created OCI clients.
func NewAdapter(lbClient loadbalancer.LoadBalancerClient, nwClient core.VirtualNetworkClient, certsClient certificatesmanagement.CertificatesManagementClient) *Adapter {
ad := &Adapter{
lbClient: lbClient,
nwClient: nwClient,
certsClient: certsClient,
workerCount: defaultWorkerCount,
limiter: rate.NewLimiter(rate.Limit(defaultRatePerSec), defaultRateBurst),
subnetCache: make(map[string]core.GetSubnetResponse),
vcnCache: make(map[string]core.GetVcnResponse),
nsgCache: make(map[string]core.GetNetworkSecurityGroupResponse),
certListCache: make(map[string][]loadbalancer.Certificate),
}
return ad
}
// GetLoadBalancer retrieves a single Load Balancer and maps it to the basic domain model, adding backend health for usability.
func (a *Adapter) GetLoadBalancer(ctx context.Context, ocid string) (*domain.LoadBalancer, error) {
response, err := a.lbClient.GetLoadBalancer(ctx, loadbalancer.GetLoadBalancerRequest{
LoadBalancerId: &ocid,
})
if err != nil {
return nil, fmt.Errorf("failed to get load balancer: %w", err)
}
dm := mapping.NewDomainLoadBalancerFromAttrs(mapping.NewLoadBalancerAttributesFromOCILoadBalancer(response.LoadBalancer))
_ = a.enrichBackendHealth(ctx, response.LoadBalancer, dm, false)
_ = a.resolveSubnets(ctx, dm)
return dm, nil
}
// GetEnrichedLoadBalancer retrieves a single Load Balancer and returns the enriched domain model.
func (a *Adapter) GetEnrichedLoadBalancer(ctx context.Context, ocid string) (*domain.LoadBalancer, error) {
response, err := a.lbClient.GetLoadBalancer(ctx, loadbalancer.GetLoadBalancerRequest{
LoadBalancerId: &ocid,
})
if err != nil {
return nil, fmt.Errorf("failed to get load balancer: %w", err)
}
dm, err := a.enrichAndMapLoadBalancer(ctx, response.LoadBalancer)
if err != nil {
return nil, err
}
return dm, nil
}
// ListLoadBalancers returns all load balancers in the compartment (paginated) mapped to a base domain model,
func (a *Adapter) ListLoadBalancers(ctx context.Context, compartmentID string) ([]domain.LoadBalancer, error) {
result := make([]domain.LoadBalancer, 0)
var page *string
for {
resp, err := a.lbClient.ListLoadBalancers(ctx, loadbalancer.ListLoadBalancersRequest{
CompartmentId: &compartmentID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("listing load balancers: %w", err)
}
pageItems := resp.Items
mapped := make([]domain.LoadBalancer, len(pageItems))
var wg sync.WaitGroup
for i := range pageItems {
wg.Add(1)
idx := i
go func() {
defer wg.Done()
lb := pageItems[idx]
dm := mapping.NewDomainLoadBalancerFromAttrs(mapping.NewLoadBalancerAttributesFromOCILoadBalancer(lb))
_ = a.enrichBackendHealth(ctx, lb, dm, false)
_ = a.resolveSubnets(ctx, dm)
mapped[idx] = *dm
}()
}
wg.Wait()
result = append(result, mapped...)
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return result, nil
}
// ListEnrichedLoadBalancers returns all load balancers in the compartment (paginated) with enrichment
func (a *Adapter) ListEnrichedLoadBalancers(ctx context.Context, compartmentID string) ([]domain.LoadBalancer, error) {
result := make([]domain.LoadBalancer, 0)
var page *string
for {
resp, err := a.lbClient.ListLoadBalancers(ctx, loadbalancer.ListLoadBalancersRequest{
CompartmentId: &compartmentID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("listing load balancers: %w", err)
}
mapped, err := a.enrichAndMapLoadBalancers(ctx, resp.Items)
if err != nil {
return nil, err
}
result = append(result, mapped...)
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return result, nil
}
// enrichAndMapLoadBalancers converts a slice of OCI LBs to domain models with enrichment using concurrency
func (a *Adapter) enrichAndMapLoadBalancers(ctx context.Context, items []loadbalancer.LoadBalancer) ([]domain.LoadBalancer, error) {
// Page-level prefetch: collect unique Subnet and NSG IDs across items and resolve them once using the worker pool.
uniqSubnets := make(map[string]struct{})
uniqNSGs := make(map[string]struct{})
for _, lb := range items {
for _, sid := range lb.SubnetIds {
if sid != "" {
uniqSubnets[sid] = struct{}{}
}
}
for _, nid := range lb.NetworkSecurityGroupIds {
if nid != "" {
uniqNSGs[nid] = struct{}{}
}
}
}
// Prefetch subnets (and related VCNs)
if len(uniqSubnets) > 0 {
jobs := make(chan Work, len(uniqSubnets))
for sid := range uniqSubnets {
id := sid
jobs <- func() error {
var sResp core.GetSubnetResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
return a.do(ctx, func() error {
var e error
sResp, e = a.nwClient.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &id})
return e
})
})
if err != nil {
return err
}
a.muSubnets.Lock()
a.subnetCache[id] = sResp
a.muSubnets.Unlock()
// Prefetch VCN by ID if not in cache
if sResp.Subnet.VcnId != nil {
vcnID := *sResp.Subnet.VcnId
a.muVcns.RLock()
_, ok := a.vcnCache[vcnID]
a.muVcns.RUnlock()
if !ok {
var vcnResp core.GetVcnResponse
_ = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
return a.do(ctx, func() error {
var e error
vcnResp, e = a.nwClient.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
return e
})
})
if vcnResp.RawResponse != nil {
a.muVcns.Lock()
a.vcnCache[vcnID] = vcnResp
a.muVcns.Unlock()
}
}
}
return nil
}
}
close(jobs)
_ = runWithWorkers(ctx, a.workerCount, jobs)
}
if len(uniqNSGs) > 0 {
jobs := make(chan Work, len(uniqNSGs))
for nid := range uniqNSGs {
id := nid
jobs <- func() error {
var nResp core.GetNetworkSecurityGroupResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
return a.do(ctx, func() error {
var e error
nResp, e = a.nwClient.GetNetworkSecurityGroup(ctx, core.GetNetworkSecurityGroupRequest{NetworkSecurityGroupId: &id})
return e
})
})
if err != nil {
return err
}
a.muNsgs.Lock()
a.nsgCache[id] = nResp
a.muNsgs.Unlock()
return nil
}
}
close(jobs)
_ = runWithWorkers(ctx, a.workerCount, jobs)
}
// Process each LB using a bounded worker pool as well
out := make([]domain.LoadBalancer, len(items))
jobs := make(chan Work, len(items))
var mu sync.Mutex
for i := range items {
idx := i
jobs <- func() error {
mapped, err := a.enrichAndMapLoadBalancer(ctx, items[idx])
if err != nil {
return err
}
mu.Lock()
out[idx] = *mapped
mu.Unlock()
return nil
}
}
close(jobs)
if err := runWithWorkers(ctx, a.workerCount, jobs); err != nil {
return nil, err
}
return out, nil
}
// enrichAndMapLoadBalancer builds the domain model and enriches it with names, health, members and SSL certificate info
func (a *Adapter) enrichAndMapLoadBalancer(ctx context.Context, lb loadbalancer.LoadBalancer) (*domain.LoadBalancer, error) {
startTotal := time.Now()
id := ""
name := ""
if lb.Id != nil {
id = *lb.Id
}
if lb.DisplayName != nil {
name = *lb.DisplayName
}
// Start log for this LB enrichment
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.start", "id", id, "name", name)
defer func() {
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.total", "id", id, "name", name, "duration_ms", time.Since(startTotal).Milliseconds())
}()
dm := mapping.NewDomainLoadBalancerFromAttrs(mapping.NewLoadBalancerAttributesFromOCILoadBalancer(lb))
var (
wg sync.WaitGroup
errCh = make(chan error, 5)
dResolveSubnets int64
dResolveNSGs int64
dHealth int64
dMembers int64
dCerts int64
mu sync.Mutex
)
wg.Add(1)
go func() {
defer wg.Done()
s := time.Now()
if err := a.resolveSubnets(ctx, dm); err != nil {
errCh <- err
return
}
mu.Lock()
dResolveSubnets = time.Since(s).Milliseconds()
mu.Unlock()
}()
wg.Add(1)
go func() {
defer wg.Done()
s := time.Now()
if err := a.resolveNSGs(ctx, dm); err != nil {
errCh <- err
return
}
mu.Lock()
dResolveNSGs = time.Since(s).Milliseconds()
mu.Unlock()
}()
wg.Add(1)
go func() {
defer wg.Done()
s := time.Now()
if err := a.enrichBackendHealth(ctx, lb, dm, true); err != nil {
errCh <- err
return
}
mu.Lock()
dHealth = time.Since(s).Milliseconds()
mu.Unlock()
}()
wg.Add(1)
go func() {
defer wg.Done()
s := time.Now()
if err := a.enrichBackendMembers(ctx, lb, dm, true); err != nil {
errCh <- err
return
}
mu.Lock()
dMembers = time.Since(s).Milliseconds()
mu.Unlock()
}()
wg.Add(1)
go func() {
defer wg.Done()
s := time.Now()
if err := a.enrichCertificates(ctx, lb, dm); err != nil {
errCh <- err
return
}
mu.Lock()
dCerts = time.Since(s).Milliseconds()
mu.Unlock()
}()
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return dm, err
}
}
// Final summary with per-step durations
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.summary", "id", id, "name", name,
"duration_total_ms", time.Since(startTotal).Milliseconds(),
"resolve_subnets_ms", dResolveSubnets,
"resolve_nsgs_ms", dResolveNSGs,
"backend_health_ms", dHealth,
"backend_members_ms", dMembers,
"certificates_ms", dCerts,
)
return dm, nil
}
package loadbalancer
import (
x509std "crypto/x509"
pemenc "encoding/pem"
"time"
)
// parseCertNotAfter attempts to parse the first certificate in a PEM bundle and returns NotAfter
func parseCertNotAfter(pemData string) (time.Time, bool) {
data := []byte(pemData)
for {
var block *pemenc.Block
block, data = pemenc.Decode(data)
if block == nil {
break
}
if block.Type == "CERTIFICATE" {
c, err := x509std.ParseCertificate(block.Bytes)
if err == nil {
return c.NotAfter, true
}
}
}
return time.Time{}, false
}
package loadbalancer
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/network/loadbalancer"
lbLogger "github.com/cnopslabs/ocloud/internal/logger"
"github.com/oracle/oci-go-sdk/v65/certificatesmanagement"
"github.com/oracle/oci-go-sdk/v65/loadbalancer"
)
// enrichBackendHealth fetches overall status per backend set and fills dm.BackendHealth.
// When deep is false, it only fetches per-set health; per-backend health is handled in enrichBackendMembers when deep.
func (a *Adapter) enrichBackendHealth(ctx context.Context, lb loadbalancer.LoadBalancer, dm *domain.LoadBalancer, deep bool) error {
start := time.Now()
lbID, lbName := "", ""
if lb.Id != nil {
lbID = *lb.Id
}
if lb.DisplayName != nil {
lbName = *lb.DisplayName
}
defer func() {
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.backend_health", "id", lbID, "name", lbName, "backend_sets", len(lb.BackendSets), "duration_ms", time.Since(start).Milliseconds())
}()
if lb.Id == nil {
return nil
}
healthLocal := make(map[string]string)
jobs := make(chan Work, len(lb.BackendSets))
var mu sync.Mutex
for bsName := range lb.BackendSets {
name := bsName
jobs <- func() error {
var hResp loadbalancer.GetBackendSetHealthResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
return a.do(ctx, func() error {
var e error
hResp, e = a.lbClient.GetBackendSetHealth(ctx, loadbalancer.GetBackendSetHealthRequest{LoadBalancerId: lb.Id, BackendSetName: &name})
return e
})
})
if err != nil {
return err
}
status := strings.ToUpper(string(hResp.BackendSetHealth.Status))
mu.Lock()
healthLocal[name] = status
mu.Unlock()
return nil
}
}
close(jobs)
_ = runWithWorkers(ctx, a.workerCount, jobs)
if dm.BackendHealth == nil {
dm.BackendHealth = map[string]string{}
}
for k, v := range healthLocal {
dm.BackendHealth[k] = v
}
return nil
}
// enrichBackendMembers fetches backend members per backend set and fills dm.BackendSets[...].Backends.
// It avoids per-backend GetBackendHealth unless deep is true or the set is unhealthy.
func (a *Adapter) enrichBackendMembers(ctx context.Context, lb loadbalancer.LoadBalancer, dm *domain.LoadBalancer, deep bool) error {
start := time.Now()
lbID, lbName := "", ""
if lb.Id != nil {
lbID = *lb.Id
}
if lb.DisplayName != nil {
lbName = *lb.DisplayName
}
defer func() {
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.backend_members", "id", lbID, "name", lbName, "backend_sets", len(lb.BackendSets), "duration_ms", time.Since(start).Milliseconds())
}()
if lb.Id == nil {
return nil
}
jobs := make(chan Work, len(lb.BackendSets))
var mu sync.Mutex
for bsName := range lb.BackendSets {
name := bsName
jobs <- func() error {
var bsResp loadbalancer.GetBackendSetResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
return a.do(ctx, func() error {
var e error
bsResp, e = a.lbClient.GetBackendSet(ctx, loadbalancer.GetBackendSetRequest{LoadBalancerId: lb.Id, BackendSetName: &name})
return e
})
})
if err != nil {
return err
}
backends := make([]domain.Backend, len(bsResp.BackendSet.Backends))
setStatus := strings.ToUpper(dm.BackendHealth[name])
needDeep := deep || (setStatus != "" && setStatus != "OK")
for i, b := range bsResp.BackendSet.Backends {
ip := ""
if b.IpAddress != nil {
ip = *b.IpAddress
}
port := 0
if b.Port != nil {
port = int(*b.Port)
}
status := "UNKNOWN"
if needDeep && ip != "" && port > 0 {
backendName := fmt.Sprintf("%s:%d", ip, port)
var bhResp loadbalancer.GetBackendHealthResponse
_ = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
return a.do(ctx, func() error {
var e error
bhResp, e = a.lbClient.GetBackendHealth(ctx, loadbalancer.GetBackendHealthRequest{LoadBalancerId: lb.Id, BackendSetName: &name, BackendName: &backendName})
return e
})
})
if bhResp.RawResponse != nil {
status = strings.ToUpper(string(bhResp.BackendHealth.Status))
}
}
backends[i] = domain.Backend{Name: ip, Port: port, Status: status}
}
mu.Lock()
bs := dm.BackendSets[name]
bs.Backends = backends
dm.BackendSets[name] = bs
mu.Unlock()
return nil
}
}
close(jobs)
_ = runWithWorkers(ctx, a.workerCount, jobs)
return nil
}
// enrichCertificates gathers certificate names/ids and resolves expiry where possible, storing formatted strings in dm.SSLCertificates
func (a *Adapter) enrichCertificates(ctx context.Context, lb loadbalancer.LoadBalancer, dm *domain.LoadBalancer) error {
start := time.Now()
lbID, lbName := "", ""
if lb.Id != nil {
lbID = *lb.Id
}
if lb.DisplayName != nil {
lbName = *lb.DisplayName
}
defer func() {
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.certificates", "id", lbID, "name", lbName, "certs_count", len(dm.SSLCertificates), "duration_ms", time.Since(start).Milliseconds())
}()
out := make([]string, 0)
if lb.Id == nil {
dm.SSLCertificates = out
return nil
}
nameSet := make(map[string]struct{})
idSet := make(map[string]struct{})
for _, l := range lb.Listeners {
if l.SslConfiguration != nil {
if l.SslConfiguration.CertificateName != nil {
if n := strings.TrimSpace(*l.SslConfiguration.CertificateName); n != "" {
nameSet[n] = struct{}{}
}
}
for _, cid := range l.SslConfiguration.CertificateIds {
if c := strings.TrimSpace(cid); c != "" {
idSet[c] = struct{}{}
}
}
}
}
certsByName := make(map[string]loadbalancer.Certificate)
var listResp loadbalancer.ListCertificatesResponse
var listItems []loadbalancer.Certificate
cacheHit := false
if lbID != "" {
a.muCertLists.RLock()
if cached, ok := a.certListCache[lbID]; ok {
listItems = cached
cacheHit = true
}
a.muCertLists.RUnlock()
}
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.certificates.list_cache", "id", lbID, "name", lbName, "cache_hit", cacheHit)
if listItems == nil {
_ = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
return a.do(ctx, func() error {
var e error
listResp, e = a.lbClient.ListCertificates(ctx, loadbalancer.ListCertificatesRequest{LoadBalancerId: lb.Id})
return e
})
})
listItems = listResp.Items
if lbID != "" {
a.muCertLists.Lock()
a.certListCache[lbID] = listItems
a.muCertLists.Unlock()
}
}
if len(listItems) > 0 {
for _, c := range listItems {
if c.CertificateName != nil {
certsByName[*c.CertificateName] = c
nameSet[*c.CertificateName] = struct{}{}
}
}
} else {
for n, c := range lb.Certificates {
certsByName[n] = c
nameSet[n] = struct{}{}
}
}
if len(nameSet) == 0 {
var getResp loadbalancer.GetLoadBalancerResponse
if err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
getResp, e = a.lbClient.GetLoadBalancer(ctx, loadbalancer.GetLoadBalancerRequest{LoadBalancerId: lb.Id})
return e
}); err == nil {
for n, c := range getResp.LoadBalancer.Certificates {
certsByName[n] = c
nameSet[n] = struct{}{}
}
for _, l := range getResp.LoadBalancer.Listeners {
if l.SslConfiguration != nil && l.SslConfiguration.CertificateName != nil {
if n := strings.TrimSpace(*l.SslConfiguration.CertificateName); n != "" {
nameSet[n] = struct{}{}
}
}
}
}
}
jobs := make(chan Work, len(nameSet)+len(idSet))
var mu sync.Mutex
for n := range nameSet {
name := n
jobs <- func() error {
expires := ""
if c, ok := certsByName[name]; ok {
if c.PublicCertificate != nil && *c.PublicCertificate != "" {
if t, ok := parseCertNotAfter(*c.PublicCertificate); ok {
expires = t.Format("2006-01-02")
}
}
}
display := name
if expires != "" {
display = fmt.Sprintf("%s (Expires: %s)", name, expires)
}
mu.Lock()
out = append(out, display)
mu.Unlock()
return nil
}
}
for cid := range idSet {
id := cid
jobs <- func() error {
var certResp certificatesmanagement.GetCertificateResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
certResp, e = a.certsClient.GetCertificate(ctx, certificatesmanagement.GetCertificateRequest{CertificateId: &id})
return e
})
if err != nil {
mu.Lock()
out = append(out, id)
mu.Unlock()
return nil
}
name := id
if certResp.Certificate.Name != nil && *certResp.Certificate.Name != "" {
name = *certResp.Certificate.Name
}
var expiresStr string
if certResp.Certificate.CurrentVersion != nil && certResp.Certificate.CurrentVersion.VersionNumber != nil {
ver := *certResp.Certificate.CurrentVersion.VersionNumber
var verResp certificatesmanagement.GetCertificateVersionResponse
_ = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
verResp, e = a.certsClient.GetCertificateVersion(ctx, certificatesmanagement.GetCertificateVersionRequest{CertificateId: &id, CertificateVersionNumber: &ver})
return e
})
if verResp.CertificateVersion.Validity != nil && verResp.CertificateVersion.Validity.TimeOfValidityNotAfter != nil {
expiresStr = verResp.CertificateVersion.Validity.TimeOfValidityNotAfter.Time.Format("2006-01-02")
}
}
display := name
if expiresStr != "" {
display = fmt.Sprintf("%s (Expires: %s)", name, expiresStr)
}
mu.Lock()
out = append(out, display)
mu.Unlock()
return nil
}
}
close(jobs)
_ = runWithWorkers(ctx, a.workerCount, jobs)
sort.Strings(out)
dm.SSLCertificates = out
return nil
}
package loadbalancer
import (
"context"
"fmt"
"sync"
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/network/loadbalancer"
lbLogger "github.com/cnopslabs/ocloud/internal/logger"
"github.com/oracle/oci-go-sdk/v65/core"
)
// cachedFetch centralizes cache lookup, rate-limited fetch via Adapter.do, and cache population.
// It returns the response and whether it was served from a cache.
func cachedFetch[T any](ctx context.Context, a *Adapter, id string, mu *sync.RWMutex, cache map[string]T, fetch func(context.Context, string) (T, error)) (T, bool) {
var resp T
fromCache := false
// Fast path: read lock and check cache
mu.RLock()
if cached, ok := cache[id]; ok {
resp = cached
fromCache = true
}
mu.RUnlock()
if fromCache {
return resp, true
}
// Miss: perform fetch with retry and rate-limited do wrapper
_ = retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
return a.do(ctx, func() error {
var e error
resp, e = fetch(ctx, id)
return e
})
})
// Store in cache
mu.Lock()
cache[id] = resp
mu.Unlock()
return resp, false
}
// resolveSubnets resolves subnet IDs on the domain model to "Name (CIDR)" and captures the VCN context
func (a *Adapter) resolveSubnets(ctx context.Context, dm *domain.LoadBalancer) error {
start := time.Now()
origCount := len(dm.Subnets)
cacheHits := 0
resolved := make([]string, 0, len(dm.Subnets))
var capturedVcnID string
for _, sid := range dm.Subnets {
id := sid
if id == "" {
continue
}
resp, fromCache := cachedFetch(ctx, a, id, &a.muSubnets, a.subnetCache, func(ctx context.Context, id string) (core.GetSubnetResponse, error) {
return a.nwClient.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &id})
})
if fromCache {
cacheHits++
}
if resp.Subnet.Id != nil {
if capturedVcnID == "" && resp.Subnet.VcnId != nil {
capturedVcnID = *resp.Subnet.VcnId
}
name := ""
if resp.Subnet.DisplayName != nil {
name = *resp.Subnet.DisplayName
}
cidr := ""
if resp.Subnet.CidrBlock != nil {
cidr = *resp.Subnet.CidrBlock
}
if name != "" && cidr != "" {
resolved = append(resolved, fmt.Sprintf("%s (%s)", name, cidr))
continue
}
}
resolved = append(resolved, sid)
}
dm.Subnets = resolved
if capturedVcnID != "" {
vcnStart := time.Now()
vcnResp, vcnFromCache := cachedFetch(ctx, a, capturedVcnID, &a.muVcns, a.vcnCache, func(ctx context.Context, id string) (core.GetVcnResponse, error) {
return a.nwClient.GetVcn(ctx, core.GetVcnRequest{VcnId: &id})
})
dm.VcnID = capturedVcnID
if vcnResp.Vcn.DisplayName != nil {
dm.VcnName = *vcnResp.Vcn.DisplayName
}
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.resolve_vcn", "lb_id", dm.OCID, "lb_name", dm.Name, "vcn_id", dm.VcnID, "from_cache", vcnFromCache, "duration_ms", time.Since(vcnStart).Milliseconds())
}
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.resolve_subnets", "lb_id", dm.OCID, "lb_name", dm.Name, "subnets", origCount, "cache_hits", cacheHits, "cache_misses", origCount-cacheHits, "duration_ms", time.Since(start).Milliseconds())
return nil
}
// resolveNSGs resolves NSG IDs on the domain model to display names best-effort
func (a *Adapter) resolveNSGs(ctx context.Context, dm *domain.LoadBalancer) error {
start := time.Now()
origCount := len(dm.NSGs)
cacheHits := 0
resolved := make([]string, 0, len(dm.NSGs))
for _, nid := range dm.NSGs {
id := nid
if id == "" {
continue
}
resp, fromCache := cachedFetch(ctx, a, id, &a.muNsgs, a.nsgCache, func(ctx context.Context, id string) (core.GetNetworkSecurityGroupResponse, error) {
return a.nwClient.GetNetworkSecurityGroup(ctx, core.GetNetworkSecurityGroupRequest{NetworkSecurityGroupId: &id})
})
if fromCache {
cacheHits++
}
if resp.NetworkSecurityGroup.DisplayName != nil && *resp.NetworkSecurityGroup.DisplayName != "" {
resolved = append(resolved, *resp.NetworkSecurityGroup.DisplayName)
continue
}
resolved = append(resolved, nid)
}
dm.NSGs = resolved
lbLogger.LogWithLevel(lbLogger.CmdLogger, lbLogger.Debug, "lb.enrich.resolve_nsgs", "lb_id", dm.OCID, "lb_name", dm.Name, "nsgs", origCount, "cache_hits", cacheHits, "cache_misses", origCount-cacheHits, "duration_ms", time.Since(start).Milliseconds())
return nil
}
package loadbalancer
import (
"context"
"fmt"
"net/http"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
)
const (
defaultMaxRetries = 5
defaultInitialBackoff = 1 * time.Second
defaultMaxBackoff = 32 * time.Second
defaultRatePerSec = 10
defaultRateBurst = 5
)
// do apply a central rate limit before performing the given operation.
func (a *Adapter) do(ctx context.Context, op func() error) error {
if a.limiter != nil {
if err := a.limiter.Wait(ctx); err != nil {
return err
}
}
return op()
}
// retryOnRateLimit retries the provided operation when OCI responds with HTTP 429 rate limited.
// It applies exponential backoff between retries and preserves the original behavior and error messages.
func retryOnRateLimit(ctx context.Context, maxRetries int, initialBackoff, maxBackoff time.Duration, op func() error) error {
backoff := initialBackoff
for attempt := 0; attempt < maxRetries; attempt++ {
err := op()
if err == nil {
return nil
}
if serviceErr, ok := common.IsServiceError(err); ok && serviceErr.GetHTTPStatusCode() == http.StatusTooManyRequests {
if attempt == maxRetries-1 {
return fmt.Errorf("rate limit exceeded after %d retries: %w", maxRetries, err)
}
var sleepDur = backoff
jitter := time.Duration(time.Now().UnixNano() % int64(backoff/4))
sleepDur = backoff + jitter
t := time.NewTimer(sleepDur)
select {
case <-t.C:
case <-ctx.Done():
t.Stop()
return ctx.Err()
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
return err
}
return nil
}
package loadbalancer
import (
"fmt"
"strings"
domain "github.com/cnopslabs/ocloud/internal/domain/network/loadbalancer"
"github.com/cnopslabs/ocloud/internal/tui"
)
// NewLoadBalancerListModel builds a TUI list for load balancers.
func NewLoadBalancerListModel(lbs []domain.LoadBalancer) tui.Model {
return tui.NewModel("Load Balancers", lbs, func(lb domain.LoadBalancer) tui.ResourceItemData {
return tui.ResourceItemData{
ID: lb.OCID,
Title: lb.Name,
Description: description(lb),
}
})
}
func description(lb domain.LoadBalancer) string {
ip := first(lb.IPAddresses)
hs := healthSummary(lb.BackendHealth)
line2 := joinNonEmpty(" • ",
hs,
firstNonEmpty(lb.VcnName, lb.VcnID),
)
return joinNonEmpty(" • ", ip, line2)
}
// --- helpers ---
func first(ss []string) string {
if len(ss) > 0 {
return ss[0]
}
return ""
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func joinNonEmpty(sep string, parts ...string) string {
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return strings.Join(out, sep)
}
func healthSummary(health map[string]string) string {
if len(health) == 0 {
return "Health N/A"
}
total := len(health)
ok := 0
unknown := 0
for _, s := range health {
switch strings.ToUpper(s) {
case "OK":
ok++
case "UNKNOWN":
unknown++
}
}
// consider any non-OK/non-UNKNOWN as unhealthy (CRITICAL, WARNING, etc.)
unhealthy := total - ok - unknown
switch {
case unhealthy > 0:
return fmt.Sprintf("UNHEALTHY (%d/%d)", unhealthy, total)
case ok == total:
return fmt.Sprintf("Health OK (%d/%d)", ok, total)
default:
return fmt.Sprintf("Health %d OK, %d UNKNOWN", ok, unknown)
}
}
package loadbalancer
import (
"context"
"golang.org/x/sync/errgroup"
)
const (
defaultWorkerCount = 12
)
// Work represents a unit of work executed by the worker pool
// It returns an error to allow early cancellation via errgroup.
type Work func() error
// runWithWorkers executes jobs from the channel using n workers and stops on the first error or context cancel.
func runWithWorkers(ctx context.Context, n int, jobs <-chan Work) error {
g, ctx := errgroup.WithContext(ctx)
for i := 0; i < n; i++ {
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case w, ok := <-jobs:
if !ok {
return nil
}
if err := w(); err != nil {
return err
}
}
}
})
}
return g.Wait()
}
package network
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/domain/network/subnet"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
// Client is a minimal legacy wrapper retained for backward compatibility with older code paths.
// Newer code should prefer the adapter in internal/oci/network/subnet/adapter.go.
// This struct is only here to keep the package compiling during tests/builds.
type Client struct {
vnClient core.VirtualNetworkClient
}
// SubnetRepository implements the domain SubnetRepository interface.
func (c *Client) GetSubnet(ctx context.Context, ocid string) (*subnet.Subnet, error) {
resp, err := c.vnClient.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &ocid})
if err != nil {
return nil, fmt.Errorf("getting subnet: %w", err)
}
s := &subnet.Subnet{
OCID: *resp.Id,
DisplayName: *resp.DisplayName,
LifecycleState: string(resp.LifecycleState),
CidrBlock: *resp.CidrBlock,
Public: !*resp.ProhibitPublicIpOnVnic,
RouteTableID: *resp.RouteTableId,
}
return s, nil
}
func (c *Client) ListSubnets(ctx context.Context, compartmentID string) ([]subnet.Subnet, error) {
var subnets []subnet.Subnet
var page *string
for {
req := core.ListSubnetsRequest{
CompartmentId: &compartmentID,
Limit: common.Int(100),
Page: page,
}
resp, err := c.vnClient.ListSubnets(ctx, req)
if err != nil {
return nil, fmt.Errorf("listing subnets: %w", err)
}
for _, s := range resp.Items {
subnets = append(subnets, subnet.Subnet{
OCID: *s.Id,
DisplayName: *s.DisplayName,
LifecycleState: string(s.LifecycleState),
CidrBlock: *s.CidrBlock,
Public: !*s.ProhibitPublicIpOnVnic,
RouteTableID: *s.RouteTableId,
})
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return subnets, nil
}
package subnet
import (
"context"
"fmt"
domainsubnet "github.com/cnopslabs/ocloud/internal/domain/network/subnet"
"github.com/oracle/oci-go-sdk/v65/core"
)
// Adapter is an infrastructure-layer adapter for network subnets.
type Adapter struct {
client core.VirtualNetworkClient
}
// NewAdapter creates a new subnet adapter.
func NewAdapter(client core.VirtualNetworkClient) *Adapter {
return &Adapter{client: client}
}
// GetSubnet retrieves a single subnet by its OCID.
func (a *Adapter) GetSubnet(ctx context.Context, ocid string) (*domainsubnet.Subnet, error) {
resp, err := a.client.GetSubnet(ctx, core.GetSubnetRequest{
SubnetId: &ocid,
})
if err != nil {
return nil, fmt.Errorf("getting subnet from OCI: %w", err)
}
sub := a.toDomainModel(resp.Subnet)
return &sub,
nil
}
// ListSubnets fetches all subnets in a compartment.
func (a *Adapter) ListSubnets(ctx context.Context, compartmentID string) ([]domainsubnet.Subnet, error) {
var subnets []domainsubnet.Subnet
var page *string
for {
resp, err := a.client.ListSubnets(ctx, core.ListSubnetsRequest{
CompartmentId: &compartmentID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("listing subnets from OCI: %w", err)
}
for _, item := range resp.Items {
subnets = append(subnets, a.toDomainModel(item))
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return subnets, nil
}
// toDomainModel converts an OCI SDK subnet object to our application domain model.
func (a *Adapter) toDomainModel(s core.Subnet) domainsubnet.Subnet {
var routeTableID string
if s.RouteTableId != nil {
routeTableID = *s.RouteTableId
}
var cidr string
if s.CidrBlock != nil {
cidr = *s.CidrBlock
}
var displayName string
if s.DisplayName != nil {
displayName = *s.DisplayName
}
var ocid string
if s.Id != nil {
ocid = *s.Id
}
// Public is the inverse of ProhibitPublicIpOnVnic
public := s.ProhibitPublicIpOnVnic == nil || !*s.ProhibitPublicIpOnVnic
return domainsubnet.Subnet{
OCID: ocid,
DisplayName: displayName,
LifecycleState: string(s.LifecycleState),
CidrBlock: cidr,
Public: public,
RouteTableID: routeTableID,
SecurityListIDs: s.SecurityListIds,
NSGIDs: nil,
}
}
package vcn
import (
"context"
"fmt"
"net/http"
"sync"
"time"
domain "github.com/cnopslabs/ocloud/internal/domain/network/vcn"
"github.com/cnopslabs/ocloud/internal/mapping"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
const (
defaultMaxRetries = 5
defaultInitialBackoff = 1 * time.Second
defaultMaxBackoff = 32 * time.Second
)
// Adapter provides access to VCN-related OCI APIs.
// It is infra-layer and should be used by the service layer.
type Adapter struct {
client core.VirtualNetworkClient
}
// NewAdapter creates a new adapter instance.
func NewAdapter(client core.VirtualNetworkClient) *Adapter {
return &Adapter{client: client}
}
func (a *Adapter) GetEnrichedVcn(ctx context.Context, vcnID string) (domain.VCN, error) {
var resp core.GetVcnResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
return e
})
if err != nil {
return domain.VCN{}, fmt.Errorf("getting VCN from OCI: %w", err)
}
m := mapping.NewDomainVCNFromAttrs(mapping.NewVCNAttributesFromOCIVCN(resp.Vcn))
if e := a.enrichVCN(ctx, m); e != nil {
return domain.VCN{}, e
}
return *m, nil
}
// ListVcns lists all VCNs in a given compartment.
func (a *Adapter) ListVcns(ctx context.Context, compartmentID string) ([]domain.VCN, error) {
req := core.ListVcnsRequest{CompartmentId: &compartmentID}
var out []domain.VCN
for {
var resp core.ListVcnsResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListVcns(ctx, req)
return e
})
if err != nil {
return nil, fmt.Errorf("listing VCNs from OCI: %w", err)
}
for _, v := range resp.Items {
out = append(out, *mapping.NewDomainVCNFromAttrs(mapping.NewVCNAttributesFromOCIVCN(v)))
}
if resp.OpcNextPage == nil {
break
}
req.Page = resp.OpcNextPage
}
return out, nil
}
// ListEnrichedVcns lists VCNs and enriches them with all related resources in parallel.
func (a *Adapter) ListEnrichedVcns(ctx context.Context, compartmentID string) ([]domain.VCN, error) {
vcns, err := a.ListVcns(ctx, compartmentID)
if err != nil {
return nil, err
}
var wg sync.WaitGroup
errCh := make(chan error, len(vcns))
for i := range vcns {
wg.Add(1)
go func(i int) {
defer wg.Done()
if err := a.enrichVCN(ctx, &vcns[i]); err != nil {
errCh <- err
}
}(i)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return nil, err
}
}
return vcns, nil
}
func (a *Adapter) enrichVCN(ctx context.Context, vcn *domain.VCN) error {
var wg sync.WaitGroup
errCh := make(chan error, 10)
var mutex sync.Mutex
wg.Add(10)
go func() {
defer wg.Done()
gateways, err := a.listInternetGateways(ctx, vcn.CompartmentID, vcn.OCID)
if err != nil {
errCh <- err
return
}
mutex.Lock()
vcn.Gateways = append(vcn.Gateways, gateways...)
mutex.Unlock()
}()
go func() {
defer wg.Done()
nats, err := a.listNatGateways(ctx, vcn.CompartmentID, vcn.OCID)
if err != nil {
errCh <- err
return
}
mutex.Lock()
vcn.Gateways = append(vcn.Gateways, nats...)
mutex.Unlock()
}()
go func() {
defer wg.Done()
sgws, err := a.listServiceGateways(ctx, vcn.CompartmentID, vcn.OCID)
if err != nil {
errCh <- err
return
}
mutex.Lock()
vcn.Gateways = append(vcn.Gateways, sgws...)
mutex.Unlock()
}()
go func() {
defer wg.Done()
lpgs, err := a.listLocalPeeringGateways(ctx, vcn.CompartmentID, vcn.OCID)
if err != nil {
errCh <- err
return
}
mutex.Lock()
vcn.Gateways = append(vcn.Gateways, lpgs...)
mutex.Unlock()
}()
go func() {
defer wg.Done()
drg, err := a.listDrgAttachments(ctx, vcn.CompartmentID, vcn.OCID)
if err != nil {
errCh <- err
return
}
mutex.Lock()
vcn.Gateways = append(vcn.Gateways, drg...)
mutex.Unlock()
}()
go func() {
defer wg.Done()
var err error
vcn.Subnets, err = a.listSubnets(ctx, vcn.CompartmentID, vcn.OCID)
if err != nil {
errCh <- err
}
}()
go func() {
defer wg.Done()
var err error
vcn.RouteTables, err = a.listRouteTables(ctx, vcn.CompartmentID, vcn.OCID)
if err != nil {
errCh <- err
}
}()
go func() {
defer wg.Done()
var err error
vcn.SecurityLists, err = a.listSecurityLists(ctx, vcn.CompartmentID, vcn.OCID)
if err != nil {
errCh <- err
}
}()
go func() {
defer wg.Done()
var err error
vcn.NSGs, err = a.listNetworkSecurityGroups(ctx, vcn.CompartmentID, vcn.OCID)
if err != nil {
errCh <- err
}
}()
go func() {
defer wg.Done()
if vcn.DhcpOptionsID != "" {
dhcp, err := a.GetDhcpOptions(ctx, vcn.DhcpOptionsID)
if err != nil {
errCh <- err
} else {
vcn.DhcpOptions = dhcp
}
}
}()
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return err
}
}
return nil
}
func (a *Adapter) listInternetGateways(ctx context.Context, compartmentID, vcnID string) ([]domain.Gateway, error) {
req := core.ListInternetGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID}
var resp core.ListInternetGatewaysResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListInternetGateways(ctx, req)
return e
})
if err != nil {
return nil, err
}
var gateways []domain.Gateway
for _, item := range resp.Items {
gateways = append(gateways, *mapping.NewDomainGatewayFromAttrs(mapping.NewGatewayAttributesFromOCIInternetGateway(item)))
}
return gateways, nil
}
func (a *Adapter) listNatGateways(ctx context.Context, compartmentID, vcnID string) ([]domain.Gateway, error) {
req := core.ListNatGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID}
var resp core.ListNatGatewaysResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListNatGateways(ctx, req)
return e
})
if err != nil {
return nil, err
}
var gateways []domain.Gateway
for _, item := range resp.Items {
gateways = append(gateways, *mapping.NewDomainGatewayFromAttrs(mapping.NewGatewayAttributesFromOCINatGateway(item)))
}
return gateways, nil
}
func (a *Adapter) listServiceGateways(ctx context.Context, compartmentID, vcnID string) ([]domain.Gateway, error) {
req := core.ListServiceGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID}
var resp core.ListServiceGatewaysResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListServiceGateways(ctx, req)
return e
})
if err != nil {
return nil, err
}
var gateways []domain.Gateway
for _, item := range resp.Items {
gateways = append(gateways, *mapping.NewDomainGatewayFromAttrs(mapping.NewGatewayAttributesFromOCIServiceGateway(item)))
}
return gateways, nil
}
func (a *Adapter) listLocalPeeringGateways(ctx context.Context, compartmentID, vcnID string) ([]domain.Gateway, error) {
req := core.ListLocalPeeringGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID}
var resp core.ListLocalPeeringGatewaysResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListLocalPeeringGateways(ctx, req)
return e
})
if err != nil {
return nil, err
}
var gateways []domain.Gateway
for _, item := range resp.Items {
gateways = append(gateways, *mapping.NewDomainGatewayFromAttrs(mapping.NewGatewayAttributesFromOCILocalPeeringGateway(item)))
}
return gateways, nil
}
func (a *Adapter) listDrgAttachments(ctx context.Context, compartmentID, vcnID string) ([]domain.Gateway, error) {
req := core.ListDrgAttachmentsRequest{CompartmentId: &compartmentID, VcnId: &vcnID}
var resp core.ListDrgAttachmentsResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListDrgAttachments(ctx, req)
return e
})
if err != nil {
return nil, err
}
var gateways []domain.Gateway
for _, item := range resp.Items {
gateways = append(gateways, *mapping.NewDomainGatewayFromAttrs(mapping.NewGatewayAttributesFromOCIDrgAttachment(item)))
}
return gateways, nil
}
func (a *Adapter) listRouteTables(ctx context.Context, compartmentID, vcnID string) ([]domain.RouteTable, error) {
req := core.ListRouteTablesRequest{CompartmentId: &compartmentID, VcnId: &vcnID}
var resp core.ListRouteTablesResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListRouteTables(ctx, req)
return e
})
if err != nil {
return nil, err
}
var rts []domain.RouteTable
for _, item := range resp.Items {
rts = append(rts, *mapping.NewDomainRouteTableFromAttrs(mapping.NewRouteTableAttributesFromOCIRouteTable(item)))
}
return rts, nil
}
func (a *Adapter) listSecurityLists(ctx context.Context, compartmentID, vcnID string) ([]domain.SecurityList, error) {
req := core.ListSecurityListsRequest{CompartmentId: &compartmentID, VcnId: &vcnID}
var resp core.ListSecurityListsResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListSecurityLists(ctx, req)
return e
})
if err != nil {
return nil, err
}
var sls []domain.SecurityList
for _, item := range resp.Items {
sls = append(sls, *mapping.NewDomainSecurityListFromAttrs(mapping.NewSecurityListAttributesFromOCISecurityList(item)))
}
return sls, nil
}
func (a *Adapter) listNetworkSecurityGroups(ctx context.Context, compartmentID, vcnID string) ([]domain.NSG, error) {
req := core.ListNetworkSecurityGroupsRequest{CompartmentId: &compartmentID, VcnId: &vcnID}
var resp core.ListNetworkSecurityGroupsResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListNetworkSecurityGroups(ctx, req)
return e
})
if err != nil {
return nil, err
}
var nsgs []domain.NSG
for _, item := range resp.Items {
nsgs = append(nsgs, *mapping.NewDomainNSGFromAttrs(mapping.NewNSGAttributesFromOCINSG(item)))
}
return nsgs, nil
}
func (a *Adapter) GetDhcpOptions(ctx context.Context, dhcpID string) (domain.DhcpOptions, error) {
var resp core.GetDhcpOptionsResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.GetDhcpOptions(ctx, core.GetDhcpOptionsRequest{DhcpId: &dhcpID})
return e
})
if err != nil {
return domain.DhcpOptions{}, err
}
return *mapping.NewDomainDhcpOptionsFromAttrs(mapping.NewDhcpOptionsAttributesFromOCIDhcpOptions(resp.DhcpOptions)), nil
}
func (a *Adapter) listSubnets(ctx context.Context, compartmentID, vcnID string) ([]domain.Subnet, error) {
req := core.ListSubnetsRequest{CompartmentId: &compartmentID, VcnId: &vcnID}
var resp core.ListSubnetsResponse
err := retryOnRateLimit(ctx, defaultMaxRetries, defaultInitialBackoff, defaultMaxBackoff, func() error {
var e error
resp, e = a.client.ListSubnets(ctx, req)
return e
})
if err != nil {
return nil, err
}
var subnets []domain.Subnet
for _, item := range resp.Items {
subnets = append(subnets, *mapping.NewDomainSubnetFromAttrs(mapping.NewSubnetAttributesFromOCISubnet(item)))
}
return subnets, nil
}
// retryOnRateLimit retries the provided operation when OCI responds with HTTP 429 rate limited.
// It applies exponential backoff between retries and preserves the original behavior and error messages.
func retryOnRateLimit(ctx context.Context, maxRetries int, initialBackoff, maxBackoff time.Duration, op func() error) error {
backoff := initialBackoff
for attempt := 0; attempt < maxRetries; attempt++ {
err := op()
if err == nil {
return nil
}
if serviceErr, ok := common.IsServiceError(err); ok && serviceErr.GetHTTPStatusCode() == http.StatusTooManyRequests {
if attempt == maxRetries-1 {
return fmt.Errorf("rate limit exceeded after %d retries: %w", maxRetries, err)
}
time.Sleep(backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
return err
}
return nil
}
package vcn
import (
"fmt"
"strings"
domain "github.com/cnopslabs/ocloud/internal/domain/network/vcn"
"github.com/cnopslabs/ocloud/internal/tui"
)
// NewVCNListModel builds a TUI list for VCNs.
func NewVCNListModel(v []domain.VCN) tui.Model {
return tui.NewModel("VCNs", v, func(v domain.VCN) tui.ResourceItemData {
return tui.ResourceItemData{
ID: v.OCID,
Title: v.DisplayName,
Description: describeVCN(v),
}
})
}
// describeVCN constructs a concise description of a VCN, including CIDR blocks, domain name, subnets, gateways, and creation date.
func describeVCN(v domain.VCN) string {
parts := []string{}
if len(v.CidrBlocks) > 0 {
parts = append(parts, fmt.Sprintf("%d CIDRs", len(v.CidrBlocks)))
}
if v.DomainName != "" {
parts = append(parts, v.DomainName)
}
if len(v.Subnets) > 0 {
parts = append(parts, fmt.Sprintf("%d subnets", len(v.Subnets)))
}
if len(v.Gateways) > 0 {
parts = append(parts, fmt.Sprintf("%d gateways", len(v.Gateways)))
}
if !v.TimeCreated.IsZero() {
parts = append(parts, v.TimeCreated.Format("2006-01-02"))
}
return strings.Join(parts, " • ")
}
package oci
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/oracle/oci-go-sdk/v65/bastion"
"github.com/oracle/oci-go-sdk/v65/certificatesmanagement"
"github.com/oracle/oci-go-sdk/v65/identity"
"github.com/oracle/oci-go-sdk/v65/loadbalancer"
"github.com/oracle/oci-go-sdk/v65/objectstorage"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/containerengine"
"github.com/oracle/oci-go-sdk/v65/core"
"github.com/oracle/oci-go-sdk/v65/database"
)
var (
sharedTransportOnce sync.Once
sharedTransport http.RoundTripper
)
// getSharedTransport returns the shared transport.
func getSharedTransport() http.RoundTripper {
sharedTransportOnce.Do(func() {
sharedTransport = &http.Transport{
MaxIdleConns: 256,
MaxIdleConnsPerHost: 64,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
})
return sharedTransport
}
// applySharedTransport applies the shared transport to the provided BaseClient.
func applySharedTransport(base *common.BaseClient) {
if base != nil {
base.HTTPClient = &http.Client{Transport: getSharedTransport()}
}
}
// NewIdentityClient creates and returns a new instance of IdentityClient using the provided configuration provider.
func NewIdentityClient(provider common.ConfigurationProvider) (identity.IdentityClient, error) {
client, err := identity.NewIdentityClientWithConfigurationProvider(provider)
if err != nil {
return client, fmt.Errorf("creating identity client: %w", err)
}
applySharedTransport(&client.BaseClient)
return client, nil
}
// NewComputeClient creates a new OCI compute client using the provided configuration provider.
func NewComputeClient(provider common.ConfigurationProvider) (core.ComputeClient, error) {
client, err := core.NewComputeClientWithConfigurationProvider(provider)
if err != nil {
return client, fmt.Errorf("creating compute client: %w", err)
}
return client, nil
}
// NewNetworkClient creates a new OCI virtual network client using the provided configuration provider.
func NewNetworkClient(provider common.ConfigurationProvider) (core.VirtualNetworkClient, error) {
client, err := core.NewVirtualNetworkClientWithConfigurationProvider(provider)
if err != nil {
return client, fmt.Errorf("creating virtual network client: %w", err)
}
return client, nil
}
// NewContainerEngineClient creates a new instance of ContainerEngineClient using the provided configuration provider.
func NewContainerEngineClient(provider common.ConfigurationProvider) (containerengine.ContainerEngineClient, error) {
client, err := containerengine.NewContainerEngineClientWithConfigurationProvider(provider)
if err != nil {
return client, fmt.Errorf("creating container engine client: %w", err)
}
return client, nil
}
// NewDatabaseClient creates and returns a new DatabaseClient using the provided configuration.
func NewDatabaseClient(provider common.ConfigurationProvider) (database.DatabaseClient, error) {
client, err := database.NewDatabaseClientWithConfigurationProvider(provider)
if err != nil {
return client, fmt.Errorf("creating database client: %w", err)
}
return client, nil
}
// NewBastionClient creates and returns a new BastionClient using the specified ConfigurationProvider.
func NewBastionClient(provider common.ConfigurationProvider) (bastion.BastionClient, error) {
client, err := bastion.NewBastionClientWithConfigurationProvider(provider)
if err != nil {
return client, fmt.Errorf("creating bastion client: %w", err)
}
return client, nil
}
// NewLoadBalancerClient creates and returns a new LoadBalancerClient using the provided configuration provider.
func NewLoadBalancerClient(provider common.ConfigurationProvider) (loadbalancer.LoadBalancerClient, error) {
client, err := loadbalancer.NewLoadBalancerClientWithConfigurationProvider(provider)
if err != nil {
return client, fmt.Errorf("creating load balancer client: %w", err)
}
return client, nil
}
// NewCertificatesManagementClient creates and returns a new CertificatesManagementClient.
func NewCertificatesManagementClient(provider common.ConfigurationProvider) (certificatesmanagement.CertificatesManagementClient, error) {
client, err := certificatesmanagement.NewCertificatesManagementClientWithConfigurationProvider(provider)
if err != nil {
return client, fmt.Errorf("creating certificates management client: %w", err)
}
return client, nil
}
// NewObjectStorageClient creates and returns a new ObjectStorageClient.
func NewObjectStorageClient(provider common.ConfigurationProvider) (objectstorage.ObjectStorageClient, error) {
client, err := objectstorage.NewObjectStorageClientWithConfigurationProvider(provider)
if err != nil {
return client, fmt.Errorf("creating object storage client: %w", err)
}
return client, nil
}
package objectstorage
import (
"context"
"fmt"
domain "github.com/cnopslabs/ocloud/internal/domain/storage/objectstorage"
"github.com/cnopslabs/ocloud/internal/mapping"
"github.com/oracle/oci-go-sdk/v65/objectstorage"
)
type Adapter struct {
client objectstorage.ObjectStorageClient
}
// NewAdapter builds a new adapter.
func NewAdapter(client objectstorage.ObjectStorageClient) *Adapter {
return &Adapter{client: client}
}
// GetBucketNameByOCID retrieves the OCID of a bucket by its name.
func (a *Adapter) GetBucketNameByOCID(ctx context.Context, compartmentID, bucketOCID string) (string, error) {
nsResp, err := a.client.GetNamespace(ctx, objectstorage.GetNamespaceRequest{
CompartmentId: &compartmentID,
})
if err != nil {
return "", fmt.Errorf("failed to get namespace: %w", err)
}
namespace := *nsResp.Value
var page *string
for {
listResp, err := a.client.ListBuckets(ctx, objectstorage.ListBucketsRequest{
NamespaceName: &namespace,
CompartmentId: &compartmentID,
Page: page,
})
if err != nil {
return "", fmt.Errorf("list buckets: %w", err)
}
for _, sum := range listResp.Items {
if sum.Name == nil || *sum.Name == "" {
continue
}
name := *sum.Name
getResp, err := a.client.GetBucket(ctx, objectstorage.GetBucketRequest{
NamespaceName: &namespace,
BucketName: &name,
})
if err != nil || getResp.Bucket.Id == nil {
continue
}
if *getResp.Bucket.Id == bucketOCID {
return name, nil
}
}
if listResp.OpcNextPage == nil {
break
}
page = listResp.OpcNextPage
}
return "", fmt.Errorf("bucket with OCID %q not found in compartment %q", bucketOCID, compartmentID)
}
// GetBucketByName retrieves a single bucket by its name (interprets input string as bucket name).
func (a *Adapter) GetBucketByName(ctx context.Context, compartmentID, bucketName string) (*domain.Bucket, error) {
nsResp, err := a.client.GetNamespace(ctx, objectstorage.GetNamespaceRequest{
CompartmentId: &compartmentID,
})
if err != nil {
return nil, fmt.Errorf("failed to get namespace: %w", err)
}
resp, err := a.client.GetBucket(ctx, objectstorage.GetBucketRequest{
NamespaceName: nsResp.Value,
BucketName: &bucketName,
Fields: []objectstorage.GetBucketFieldsEnum{
objectstorage.GetBucketFieldsApproximatecount,
objectstorage.GetBucketFieldsApproximatesize,
},
})
if err != nil {
return nil, err
}
bkt := mapping.NewDomainBucketFromAttrs(*mapping.NewBucketAttributesFromOCIBucket(resp.Bucket))
return &bkt, nil
}
// ListBuckets retrieves all buckets in a given compartment.
func (a *Adapter) ListBuckets(ctx context.Context, ocid string) (buckets []domain.Bucket, err error) {
var allBuckets []domain.Bucket
var page *string
nsResp, err := a.client.GetNamespace(context.Background(), objectstorage.GetNamespaceRequest{
CompartmentId: &ocid,
})
if err != nil {
return nil, fmt.Errorf("failed to get namespace: %w", err)
}
for {
resp, err := a.client.ListBuckets(ctx, objectstorage.ListBucketsRequest{
NamespaceName: nsResp.Value,
CompartmentId: &ocid,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("listing buckets: %w", err)
}
for _, item := range resp.Items {
allBuckets = append(allBuckets, mapping.NewDomainBucketFromAttrs(*mapping.NewBucketAttributesFromOCIBucketSummary(item)))
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return allBuckets, nil
}
package objectstorage
import (
"fmt"
"strings"
domain "github.com/cnopslabs/ocloud/internal/domain/storage/objectstorage"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/cnopslabs/ocloud/internal/tui"
)
// NewBucketListModel builds a TUI list for Buckets.
func NewBucketListModel(b []domain.Bucket) tui.Model {
return tui.NewModel("Buckets", b, func(b domain.Bucket) tui.ResourceItemData {
return tui.ResourceItemData{
ID: b.OCID,
Title: b.Name,
Description: describeBucket(b),
}
})
}
func describeBucket(b domain.Bucket) string {
size := ""
if b.ApproximateSize > 0 {
size = util.HumanizeBytesIEC(b.ApproximateSize)
}
count := ""
if b.ApproximateCount > 0 {
count = humanCount(b.ApproximateCount) + " objs"
}
sizePart := strings.TrimSpace(strings.Join(filterNonEmpty([]string{size, count}), " / "))
line1 := join(" • ",
firstNonEmpty(b.Visibility, "Private"),
firstNonEmpty(b.StorageTier, "Standard"),
sizePart,
)
// Line 2: Protections • Enc • Created
prot := fmt.Sprintf("Ver:%s Rep:%s RO:%s",
onOff(b.Versioning == "Enabled"),
onOff(b.ReplicationEnabled),
onOff(b.IsReadOnly),
)
enc := ""
if b.Encryption != "" {
switch strings.ToLower(b.Encryption) {
case "kms", "customer-managed", "cmk":
enc = "Enc:KMS"
case "oracle", "oracle-managed":
enc = "Enc:Oracle"
default:
enc = "Enc:" + b.Encryption
}
}
created := ""
if !b.TimeCreated.IsZero() {
created = b.TimeCreated.Format("2006-01-02")
}
line2 := join(" • ",
prot, enc, created,
)
return join(" • ", line1, line2)
}
// ---- helpers ----
func onOff(b bool) string {
if b {
return "on"
}
return "off"
}
func humanCount(n int) string {
// 0–999 as-is; then K/M/B with one decimal (trim .0)
if n < 1000 {
return fmt.Sprintf("%d", n)
}
val := float64(n)
suffix := []string{"", "K", "M", "B", "T"}
i := 0
for val >= 1000 && i < len(suffix)-1 {
val /= 1000.0
i++
}
s := fmt.Sprintf("%.1f", val)
s = strings.TrimSuffix(s, ".0")
return s + suffix[i]
}
func filterNonEmpty(parts []string) []string {
out := make([]string, 0, len(parts))
for _, p := range parts {
if strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return out
}
func join(sep string, parts ...string) string {
return strings.Join(filterNonEmpty(parts), sep)
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
package printer
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"golang.org/x/term"
)
// Printer handles formatting and writing output to a designated writer.
type Printer struct {
out io.Writer
}
// New creates a new Printer that writes to the provided io.Writer.
// For console output, use os.Stdout. For testing, use bytes.Buffer.
func New(out io.Writer) *Printer {
return &Printer{out: out}
}
// -----------------------------------------------------------------------------
// Utility helpers
// -----------------------------------------------------------------------------
// getTerminalWidth returns the current terminal width. If the writer is not a
// file descriptor (e.g., in tests) or the call fails, it falls back to 80 cols.
func (p *Printer) getTerminalWidth() int {
if f, ok := p.out.(*os.File); ok {
if w, _, err := term.GetSize(int(f.Fd())); err == nil {
return w
}
}
return 80 // sensible default
}
// truncate shortens a string to max runes, appending an ellipsis when needed.
func truncate(s string, max int) string {
if utf8.RuneCountInString(s) <= max {
return s
}
r := []rune(s)
if max <= 3 {
return string(r[:max])
}
return string(r[:max-3]) + "..."
}
// -----------------------------------------------------------------------------
// JSON output helpers
// -----------------------------------------------------------------------------
// MarshalToJSON marshals data to JSON and writes it to the printer's output.
func (p *Printer) MarshalToJSON(data interface{}) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal data to JSON: %w", err)
}
_, err = fmt.Fprintln(p.out, string(jsonData))
return err
}
// -----------------------------------------------------------------------------
// Key/Value table
// -----------------------------------------------------------------------------
// PrintKeyValues renders a table from a map, with ordered keys, a title, and
// colored values.
func (p *Printer) PrintKeyValues(title string, data map[string]string, keys []string) {
termWidth := p.getTerminalWidth()
maxKeyWidth := 20
maxValWidth := termWidth - maxKeyWidth - 10 // Padding/border allowance
if maxValWidth < 20 {
maxValWidth = 20
}
t := table.NewWriter()
t.SetOutputMirror(p.out)
t.SetStyle(table.StyleRounded)
t.Style().Title.Align = text.AlignCenter
t.SetTitle(title)
t.AppendHeader(table.Row{"KEY", "VALUE"})
t.SetColumnConfigs([]table.ColumnConfig{
{
Number: 1,
WidthMax: maxKeyWidth,
Transformer: func(val interface{}) string {
return truncate(fmt.Sprint(val), maxKeyWidth)
},
},
{
Number: 2,
WidthMax: maxValWidth,
Transformer: func(val interface{}) string {
return truncate(fmt.Sprint(val), maxValWidth)
},
},
})
for i, key := range keys {
if value, ok := data[key]; ok {
if i > 0 {
t.AppendSeparator()
}
coloredValue := text.Colors{text.FgYellow}.Sprint(value)
t.AppendRow(table.Row{key, coloredValue})
}
}
t.Render()
}
// PrintKeyValuesNoTruncate renders a key/value table without truncating values.
// Useful when full content must remain visible (e.g., SSL certificates lists).
func (p *Printer) PrintKeyValuesNoTruncate(title string, data map[string]string, keys []string) {
t := table.NewWriter()
t.SetOutputMirror(p.out)
t.SetStyle(table.StyleRounded)
t.Style().Title.Align = text.AlignCenter
t.SetTitle(title)
t.AppendHeader(table.Row{"KEY", "VALUE"})
// No WidthMax and no Transformer -> no truncation
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1},
{Number: 2},
})
for i, key := range keys {
if value, ok := data[key]; ok {
if i > 0 {
t.AppendSeparator()
}
coloredValue := text.Colors{text.FgYellow}.Sprint(value)
t.AppendRow(table.Row{key, coloredValue})
}
}
t.Render()
}
// -----------------------------------------------------------------------------
// Responsive multi‑column table
// -----------------------------------------------------------------------------
// PrintTable renders a table with the given headers and rows, automatically
// adapting column widths based on the current terminal size.
func (p *Printer) PrintTable(title string, headers []string, rows [][]string) {
termWidth := p.getTerminalWidth()
// Calculate a reasonable max width per column.
// Rough formula: subtract borders/padding (≈3 chars per col), then divide.
pad := (len(headers) + 1) * 3
maxPerCol := (termWidth - pad) / len(headers)
if maxPerCol < 10 {
maxPerCol = 10 // never let columns get absurdly narrow
}
// Set up the table writer
t := table.NewWriter()
t.SetOutputMirror(p.out)
t.SetStyle(table.StyleRounded)
t.Style().Title.Align = text.AlignCenter
t.SetTitle(title)
// Build header row and column configs simultaneously
headerRow := make(table.Row, len(headers))
colConfigs := make([]table.ColumnConfig, len(headers))
for i, h := range headers {
headerRow[i] = text.Colors{text.FgHiYellow}.Sprint(h)
idx := i
colConfigs[i] = table.ColumnConfig{
Number: i + 1,
WidthMax: maxPerCol,
Transformer: func(val interface{}) string {
return truncate(fmt.Sprint(val), maxPerCol)
},
}
// Special case: align CIDR and IP columns to the center for readability
if h == "CIDR" || strings.Contains(strings.ToLower(h), "ip") {
colConfigs[idx].Align = text.AlignCenter
}
}
t.AppendHeader(headerRow)
t.SetColumnConfigs(colConfigs)
// Add rows
for _, row := range rows {
tblRow := make(table.Row, len(row))
for i, cell := range row {
tblRow[i] = cell
}
t.AppendRow(tblRow)
}
t.Render()
}
// -----------------------------------------------------------------------------
// Create end result table
// -----------------------------------------------------------------------------
// ResultTable renders a table with export variables centered in the terminal.
func (p *Printer) ResultTable(title string, message string, exportVars map[string]string) {
t := table.NewWriter()
t.SetStyle(table.StyleRounded)
t.Style().Title.Align = text.AlignCenter
t.SetTitle(text.Colors{text.FgMagenta}.Sprint(title))
if message != "" {
t.AppendHeader(table.Row{text.Colors{text.FgGreen}.Sprint(message)})
}
t.AppendRow(table.Row{""})
// Combine all export variables into a single line
var exportCommands []string
for varName, varValue := range exportVars {
exportCmd := text.Colors{text.FgYellow}.Sprint("export "+varName+"=") + "\"" + varValue + "\""
exportCommands = append(exportCommands, exportCmd)
}
// Join all export commands with spaces and add as a single row
t.AppendRow(table.Row{strings.Join(exportCommands, " ")})
t.AppendRow(table.Row{""})
// Render the table
tableStr := t.Render()
indentation := "\t"
// Add indentation to each line
lines := strings.Split(tableStr, "\n")
for i, line := range lines {
if line != "" {
lines[i] = indentation + line
}
}
fmt.Fprintln(p.out, strings.Join(lines, "\n"))
}
// PrintTableNoTruncate renders a table without truncating cell values.
// Useful for tests or outputs where full content must be visible regardless of terminal width.
func (p *Printer) PrintTableNoTruncate(title string, headers []string, rows [][]string) {
// Set up the table writer
t := table.NewWriter()
t.SetOutputMirror(p.out)
t.SetStyle(table.StyleRounded)
t.Style().Title.Align = text.AlignCenter
t.SetTitle(title)
// Build header row
headerRow := make(table.Row, len(headers))
colConfigs := make([]table.ColumnConfig, len(headers))
for i, h := range headers {
headerRow[i] = text.Colors{text.FgHiYellow}.Sprint(h)
colConfigs[i] = table.ColumnConfig{
Number: i + 1,
// WidthMax left as 0 (no max) and no Transformer -> no truncation
}
if h == "CIDR" || strings.Contains(strings.ToLower(h), "ip") {
colConfigs[i].Align = text.AlignCenter
}
}
t.AppendHeader(headerRow)
t.SetColumnConfigs(colConfigs)
for _, row := range rows {
tblRow := make(table.Row, len(row))
for i, cell := range row {
tblRow[i] = cell
}
t.AppendRow(tblRow)
}
t.Render()
}
package image
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ociImage "github.com/cnopslabs/ocloud/internal/oci/compute/image"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// GetImages retrieves and displays a paginated list of images.
func GetImages(appCtx *app.ApplicationContext, limit int, page int, useJSON bool) error {
computeClient, err := oci.NewComputeClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating compute client: %w", err)
}
imageAdapter := ociImage.NewAdapter(computeClient)
service := NewService(imageAdapter, appCtx.Logger, appCtx.CompartmentID)
images, totalCount, nextPageToken, err := service.FetchPaginatedImages(context.Background(), limit, page)
if err != nil {
return fmt.Errorf("listing images: %w", err)
}
return PrintImagesInfo(images, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON)
}
package image
import (
"context"
"errors"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ociImage "github.com/cnopslabs/ocloud/internal/oci/compute/image"
"github.com/cnopslabs/ocloud/internal/tui"
)
// ListImages lists all images in the given compartment, allowing the user to select one via a TUI and display its details.
func ListImages(ctx context.Context, appCtx *app.ApplicationContext, useJSON bool) error {
computeClient, err := oci.NewComputeClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating compute client: %w", err)
}
imageAdapter := ociImage.NewAdapter(computeClient)
service := NewService(imageAdapter, appCtx.Logger, appCtx.CompartmentID)
images, err := service.imageRepo.ListImages(ctx, appCtx.CompartmentID)
if err != nil {
return fmt.Errorf("listing images: %w", err)
}
// TUI
model := ociImage.NewImageListModel(images)
id, err := tui.Run(model)
if err != nil {
if errors.Is(err, tui.ErrCancelled) {
return nil
}
return fmt.Errorf("selecting image: %w", err)
}
image, err := service.imageRepo.GetImage(ctx, id)
if err != nil {
return fmt.Errorf("getting image: %w", err)
}
return PrintImageInfo(image, appCtx, useJSON)
}
package image
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// PrintImagesInfo displays instances in a formatted table or JSON format.
func PrintImagesInfo(images []Image, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
return util.MarshalDataToJSONResponse[Image](p, images, pagination)
}
if util.ValidateAndReportEmpty(images, pagination, appCtx.Stdout) {
return nil
}
// Print each image as a separate key-value.
for _, image := range images {
imageData := map[string]string{
"Name": image.DisplayName,
"Created": image.TimeCreated.String(),
"OSVersion": image.OperatingSystemVersion,
"OperatingSystem": image.OperatingSystem,
"LaunchMode": image.LaunchMode,
}
orderedKeys := []string{
"Name", "Created", "OperatingSystem", "OSVersion", "LaunchMode",
}
title := util.FormatColoredTitle(appCtx, image.DisplayName)
p.PrintKeyValues(title, imageData, orderedKeys)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
// PrintImageInfo prints a detailed view of an image.
func PrintImageInfo(image *Image, appCtx *app.ApplicationContext, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if useJSON {
return p.MarshalToJSON(image)
}
imageData := map[string]string{
"OCID": image.OCID,
"Name": image.DisplayName,
"Created": image.TimeCreated.String(),
"OSVersion": image.OperatingSystemVersion,
"OperatingSystem": image.OperatingSystem,
"LaunchMode": image.LaunchMode,
}
orderedKeys := []string{
"OCID", "Name", "Created", "OperatingSystem", "OSVersion", "LaunchMode",
}
title := util.FormatColoredTitle(appCtx, image.DisplayName)
p.PrintKeyValues(title, imageData, orderedKeys)
return nil
}
package image
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
ociImage "github.com/cnopslabs/ocloud/internal/oci/compute/image"
)
// SearchImages performs a search for images based on a given search term.
// It uses fuzzy matching to find relevant images in the specified OCI compartment.
// The results are printed in either a tabular or JSON format depending on the `useJSON` flag.
// An error is returned if there are issues with creating required clients, searching images, or printing results.
func SearchImages(appCtx *app.ApplicationContext, search string, useJSON bool) error {
computeClient, err := oci.NewComputeClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating compute client: %w", err)
}
imageAdapter := ociImage.NewAdapter(computeClient)
service := NewService(imageAdapter, appCtx.Logger, appCtx.CompartmentID)
matchedImages, err := service.FuzzySearch(context.Background(), search)
if err != nil {
return fmt.Errorf("finding images: %w", err)
}
err = PrintImagesInfo(matchedImages, appCtx, nil, useJSON)
if err != nil {
return fmt.Errorf("printing images: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Info, "Found matching images", "search", search, "matched", len(matchedImages))
return nil
}
package image
import (
"strings"
"github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/services/search"
)
// SearchableImage is an adapter to make to compute.Image searchable.
type SearchableImage struct {
compute.Image
}
// ToIndexable converts an Image to a map of searchable fields.
func (s SearchableImage) ToIndexable() map[string]any {
return map[string]any{
"Name": strings.ToLower(s.DisplayName),
"Created": strings.ToLower(s.TimeCreated.Format("2006-01-02 15:04:05")),
"OperatingSystem": strings.ToLower(s.OperatingSystem),
"OSVersion": strings.ToLower(s.OperatingSystemVersion),
"OCID": strings.ToLower(s.OCID),
"LaunchMode": strings.ToLower(s.LaunchMode),
}
}
// GetSearchableFields returns the list of fields to be indexed.
func GetSearchableFields() []string {
return []string{"Name", "OperatingSystem", "OSVersion", "OCID", "LaunchMode"}
}
// GetBoostedFields returns the list of fields to be boosted in the search.
func GetBoostedFields() []string {
return []string{"Name", "OperatingSystem", "OSVersion"}
}
// ToSearchableImages converts a slice of compute.Image to a slice of search.Indexable.
func ToSearchableImages(images []Image) []search.Indexable {
searchable := make([]search.Indexable, len(images))
for i, img := range images {
searchable[i] = SearchableImage{img}
}
return searchable
}
package image
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/go-logr/logr"
)
// Service is the application-layer service for image operations.
type Service struct {
imageRepo compute.ImageRepository
logger logr.Logger
compartmentID string
}
// NewService initializes a new Service instance.
func NewService(repo compute.ImageRepository, logger logr.Logger, compartmentID string) *Service {
return &Service{
imageRepo: repo,
logger: logger,
compartmentID: compartmentID,
}
}
// FetchPaginatedImages retrieves a paginated list of images.
func (s *Service) FetchPaginatedImages(ctx context.Context, limit, pageNum int) ([]Image, int, string, error) {
s.logger.V(logger.Debug).Info("listing images", "limit", limit, "pageNum", pageNum)
allImages, err := s.imageRepo.ListImages(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing images from repository: %w", err)
}
pagedResults, totalCount, nextPageToken := util.PaginateSlice(allImages, limit, pageNum)
s.logger.Info("completed image listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// FuzzySearch performs a fuzzy search for images.
func (s *Service) FuzzySearch(ctx context.Context, searchPattern string) ([]Image, error) {
s.logger.V(logger.Debug).Info("finding images with fuzzy search", "pattern", searchPattern)
allImages, err := s.imageRepo.ListImages(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("fetching all images for search: %w", err)
}
searchableImages := ToSearchableImages(allImages)
indexMapping := search.NewIndexMapping(GetSearchableFields())
idx, err := search.BuildIndex(searchableImages, indexMapping)
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
s.logger.V(logger.Debug).Info("Search index built successfully.", "numEntries", len(allImages))
matchedIdxs, err := search.FuzzySearch(idx, searchPattern, GetSearchableFields(), GetBoostedFields())
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
results := make([]Image, 0, len(matchedIdxs))
for _, i := range matchedIdxs {
if i >= 0 && i < len(allImages) {
results = append(results, allImages[i])
}
}
return results, nil
}
package instance
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ociInst "github.com/cnopslabs/ocloud/internal/oci/compute/instance"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// GetInstances retrieves and displays a paginated list of instances.
func GetInstances(appCtx *app.ApplicationContext, useJSON bool, limit, page int, showDetails bool) error {
computeClient, err := oci.NewComputeClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating compute client: %w", err)
}
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
instanceAdapter := ociInst.NewAdapter(computeClient, networkClient)
service := NewService(instanceAdapter, appCtx.Logger, appCtx.CompartmentID)
instances, totalCount, nextPageToken, err := service.FetchPaginatedInstances(context.Background(), limit, page)
if err != nil {
return fmt.Errorf("listing instances: %w", err)
}
return PrintInstancesInfo(instances, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON, showDetails)
}
package instance
import (
"context"
"errors"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ociInst "github.com/cnopslabs/ocloud/internal/oci/compute/instance"
"github.com/cnopslabs/ocloud/internal/tui"
)
// ListInstances lists instances in a formatted table or JSON format.
func ListInstances(appCtx *app.ApplicationContext, useJSON bool) error {
ctx := context.Background()
computeClient, err := oci.NewComputeClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating compute client: %w", err)
}
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
imageAdapter := ociInst.NewAdapter(computeClient, networkClient)
service := NewService(imageAdapter, appCtx.Logger, appCtx.CompartmentID)
allInstances, err := service.ListInstances(ctx)
if err != nil {
return fmt.Errorf("listing instances: %w", err)
}
//TUI
model := ociInst.NewImageListModel(allInstances)
id, err := tui.Run(model)
if err != nil {
if errors.Is(err, tui.ErrCancelled) {
return nil
}
return fmt.Errorf("selecting instance: %w", err)
}
instance, err := service.instanceRepo.GetEnrichedInstance(ctx, id)
if err != nil {
return fmt.Errorf("getting instance: %w", err)
}
return PrintInstanceInfo(instance, appCtx, useJSON, true)
}
package instance
import (
"fmt"
"time"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// InstanceOutput defines the structure for the JSON output of an instance.
type InstanceOutput struct {
Name string `json:"Name"`
ID string `json:"ID"`
IP string `json:"IP"`
ImageID string `json:"ImageID"`
SubnetID string `json:"SubnetID"`
Shape string `json:"Shape"`
State string `json:"State"`
CreatedAt time.Time `json:"CreatedAt"`
Placement Placement `json:"Placement"`
Resources Resources `json:"Resources"`
ImageName string `json:"ImageName,omitempty"`
ImageOS string `json:"ImageOS,omitempty"`
InstanceTags map[string]interface{} `json:"InstanceTags"`
Hostname string `json:"Hostname,omitempty"`
SubnetName string `json:"SubnetName,omitempty"`
VcnID string `json:"VcnID,omitempty"`
VcnName string `json:"VcnName,omitempty"`
PrivateDNSEnabled bool `json:"PrivateDNSEnabled,omitempty"`
RouteTableID string `json:"RouteTableID,omitempty"`
RouteTableName string `json:"RouteTableName,omitempty"`
}
// Placement represents the location of an instance.
type Placement struct {
Region string `json:"Region"`
AvailabilityDomain string `json:"AvailabilityDomain"`
FaultDomain string `json:"FaultDomain"`
}
// Resources represent the compute resources of an instance.
type Resources struct {
VCPUs int `json:"VCPUs"`
MemoryGB float32 `json:"MemoryGB"`
}
// PrintInstancesInfo displays instances in a formatted table or JSON format.
func PrintInstancesInfo(instances []compute.Instance, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool, showImageDetails bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
outputInstances := make([]InstanceOutput, len(instances))
for i, inst := range instances {
outputInstances[i] = InstanceOutput{
Name: inst.DisplayName,
ID: inst.OCID,
IP: inst.PrimaryIP,
ImageID: inst.ImageID,
SubnetID: inst.SubnetID,
Shape: inst.Shape,
State: inst.State,
CreatedAt: inst.TimeCreated,
Placement: Placement{
Region: inst.Region,
AvailabilityDomain: inst.AvailabilityDomain,
FaultDomain: inst.FaultDomain,
},
Resources: Resources{
VCPUs: inst.VCPUs,
MemoryGB: inst.MemoryGB,
},
ImageName: inst.ImageName,
ImageOS: inst.ImageOS,
InstanceTags: map[string]interface{}{
"FreeformTags": inst.FreeformTags,
"DefinedTags": inst.DefinedTags,
},
Hostname: inst.Hostname,
SubnetName: inst.SubnetName,
VcnID: inst.VcnID,
VcnName: inst.VcnName,
PrivateDNSEnabled: inst.PrivateDNSEnabled,
RouteTableID: inst.RouteTableID,
RouteTableName: inst.RouteTableName,
}
}
return util.MarshalDataToJSONResponse(p, outputInstances, pagination)
}
if util.ValidateAndReportEmpty(instances, pagination, appCtx.Stdout) {
return nil
}
for _, instance := range instances {
instanceData := map[string]string{
"Name": instance.DisplayName,
"Shape": instance.Shape,
"vCPUs": fmt.Sprintf("%d", instance.VCPUs),
"Memory": fmt.Sprintf("%d GB", int(instance.MemoryGB)),
"Created": instance.TimeCreated.String(),
"Private IP": instance.PrimaryIP,
"State": instance.State,
}
orderedKeys := []string{
"Name", "Shape", "vCPUs", "Memory", "Created", "Private IP", "State",
}
if showImageDetails {
instanceData["Image Name"] = instance.ImageName
instanceData["Operating System"] = instance.ImageOS
instanceData["AD"] = instance.AvailabilityDomain
instanceData["FD"] = instance.FaultDomain
instanceData["Region"] = instance.Region
instanceData["Subnet Name"] = instance.SubnetName
instanceData["VCN Name"] = instance.VcnName
instanceData["Hostname"] = instance.Hostname
instanceData["Private DNS Enabled"] = fmt.Sprintf("%t", instance.PrivateDNSEnabled)
instanceData["Route Table Name"] = instance.RouteTableName
imageKeys := []string{
"Image Name", "Operating System", "AD", "FD", "Region", "Subnet Name", "VCN Name", "Hostname", "Private DNS Enabled", "Route Table Name",
}
orderedKeys = append(orderedKeys, imageKeys...)
}
title := util.FormatColoredTitle(appCtx, instance.DisplayName)
p.PrintKeyValues(title, instanceData, orderedKeys)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
func PrintInstanceInfo(instance *compute.Instance, appCtx *app.ApplicationContext, useJSON bool, showDetails bool) error {
p := printer.New(appCtx.Stdout)
if instance == nil {
return fmt.Errorf("instance is nil")
}
if useJSON {
out := InstanceOutput{
Name: instance.DisplayName,
ID: instance.OCID,
IP: instance.PrimaryIP,
ImageID: instance.ImageID,
SubnetID: instance.SubnetID,
Shape: instance.Shape,
State: instance.State,
CreatedAt: instance.TimeCreated,
Placement: Placement{
Region: instance.Region,
AvailabilityDomain: instance.AvailabilityDomain,
FaultDomain: instance.FaultDomain,
},
Resources: Resources{
VCPUs: instance.VCPUs,
MemoryGB: instance.MemoryGB,
},
ImageName: instance.ImageName,
ImageOS: instance.ImageOS,
InstanceTags: map[string]interface{}{
"FreeformTags": instance.FreeformTags,
"DefinedTags": instance.DefinedTags,
},
Hostname: instance.Hostname,
SubnetName: instance.SubnetName,
VcnID: instance.VcnID,
VcnName: instance.VcnName,
PrivateDNSEnabled: instance.PrivateDNSEnabled,
RouteTableID: instance.RouteTableID,
RouteTableName: instance.RouteTableName,
}
return p.MarshalToJSON(out)
}
instanceData := map[string]string{
"Name": instance.DisplayName,
"Shape": instance.Shape,
"vCPUs": fmt.Sprintf("%d", instance.VCPUs),
"Memory": fmt.Sprintf("%d GB", int(instance.MemoryGB)),
"Created": instance.TimeCreated.String(),
"Private IP": instance.PrimaryIP,
"State": instance.State,
}
orderedKeys := []string{
"Name", "Shape", "vCPUs", "Memory", "Created", "Private IP", "State",
}
if showDetails {
instanceData["Image Name"] = instance.ImageName
instanceData["Operating System"] = instance.ImageOS
instanceData["AD"] = instance.AvailabilityDomain
instanceData["FD"] = instance.FaultDomain
instanceData["Region"] = instance.Region
instanceData["Subnet Name"] = instance.SubnetName
instanceData["VCN Name"] = instance.VcnName
instanceData["Hostname"] = instance.Hostname
instanceData["Private DNS Enabled"] = fmt.Sprintf("%t", instance.PrivateDNSEnabled)
instanceData["Route Table Name"] = instance.RouteTableName
imageKeys := []string{
"Image Name", "Operating System", "AD", "FD", "Region", "Subnet Name", "VCN Name", "Hostname", "Private DNS Enabled", "Route Table Name",
}
orderedKeys = append(orderedKeys, imageKeys...)
}
title := util.FormatColoredTitle(appCtx, instance.DisplayName)
p.PrintKeyValues(title, instanceData, orderedKeys)
return nil
}
package instance
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
ociInst "github.com/cnopslabs/ocloud/internal/oci/compute/instance"
)
// SearchInstances queries and retrieves matching instances based on a fuzzy search pattern.
// It uses OCI clients to fetch instance details and prints results based on the provided flags.
func SearchInstances(appCtx *app.ApplicationContext, search string, useJSON, showDetails bool) error {
computeClient, err := oci.NewComputeClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating compute client: %w", err)
}
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
instanceAdapter := ociInst.NewAdapter(computeClient, networkClient)
service := NewService(instanceAdapter, appCtx.Logger, appCtx.CompartmentID)
matchedInstances, err := service.FuzzySearch(context.Background(), search)
if err != nil {
return fmt.Errorf("finding instances: %w", err)
}
err = PrintInstancesInfo(matchedInstances, appCtx, nil, useJSON, showDetails)
if err != nil {
return fmt.Errorf("printing instances: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Info, "Found matching instances", "search", search, "matched", len(matchedInstances))
return nil
}
package instance
import (
"strings"
"github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// SearchableInstance is an adapter to make to compute.Instance searchable.
type SearchableInstance struct {
compute.Instance
}
// ToIndexable converts an Instance to a map of searchable fields.
func (s SearchableInstance) ToIndexable() map[string]any {
tagsKV, _ := util.FlattenTags(s.FreeformTags, s.DefinedTags)
tagsVal, _ := util.ExtractTagValues(s.FreeformTags, s.DefinedTags)
return map[string]any{
"Name": strings.ToLower(s.DisplayName),
"Hostname": strings.ToLower(s.Hostname),
"PrimaryIP": strings.ToLower(s.PrimaryIP),
"ImageName": strings.ToLower(s.ImageName),
"ImageOS": strings.ToLower(s.ImageOS),
"Shape": strings.ToLower(s.Shape),
"OCID": strings.ToLower(s.OCID),
"FD": strings.ToLower(s.FaultDomain),
"AD": strings.ToLower(s.AvailabilityDomain),
"VcnName": strings.ToLower(s.VcnName),
"SubnetName": strings.ToLower(s.SubnetName),
"TagsKV": strings.ToLower(tagsKV),
"TagsVal": strings.ToLower(tagsVal),
}
}
// GetSearchableFields returns the list of fields to be indexed.
func GetSearchableFields() []string {
return []string{
"Name", "Hostname", "ImageName", "ImageOS", "Shape",
"PrimaryIP", "OCID", "VcnName", "SubnetName", "FD", "AD",
"TagsKV", "TagsVal",
}
}
// GetBoostedFields returns the list of fields to be boosted in the search.
func GetBoostedFields() []string {
return []string{"Name", "Hostname"}
}
// ToSearchableInstances converts a slice of compute.Instance to a slice of search.Indexable.
func ToSearchableInstances(instances []Instance) []search.Indexable {
searchable := make([]search.Indexable, len(instances))
for i, inst := range instances {
searchable[i] = SearchableInstance{inst}
}
return searchable
}
package instance
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/go-logr/logr"
)
// Service is the application-layer service, for instance, operations.
type Service struct {
instanceRepo compute.InstanceRepository
logger logr.Logger
compartmentID string
}
// NewService initializes a new Service instance.
func NewService(repo compute.InstanceRepository, logger logr.Logger, compartmentID string) *Service {
return &Service{
instanceRepo: repo,
logger: logger,
compartmentID: compartmentID,
}
}
// ListInstances retrieves a list of instances.
func (s *Service) ListInstances(ctx context.Context) ([]Instance, error) {
s.logger.V(logger.Debug).Info("listing instances")
instances, err := s.instanceRepo.ListInstances(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("listing instances from repository: %w", err)
}
return instances, nil
}
// FetchPaginatedInstances retrieves a paginated list of instances.
func (s *Service) FetchPaginatedInstances(ctx context.Context, limit int, pageNum int) ([]Instance, int, string, error) {
s.logger.V(logger.Debug).Info("listing instances", "limit", limit, "pageNum", pageNum)
allInstances, err := s.instanceRepo.ListEnrichedInstances(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing instances from repository: %w", err)
}
pagedResults, totalCount, nextPageToken := util.PaginateSlice(allInstances, limit, pageNum)
s.logger.Info("completed instance listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// FuzzySearch performs a fuzzy search for instances.
func (s *Service) FuzzySearch(ctx context.Context, searchPattern string) ([]Instance, error) {
s.logger.V(logger.Debug).Info("finding instances", "pattern", searchPattern)
all, err := s.instanceRepo.ListEnrichedInstances(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("fetching instances: %w", err)
}
searchableInstances := ToSearchableInstances(all)
indexMapping := search.NewIndexMapping(GetSearchableFields())
idx, err := search.BuildIndex(searchableInstances, indexMapping)
if err != nil {
return nil, fmt.Errorf("build index: %w", err)
}
s.logger.V(logger.Debug).Info("index ready", "count", len(all))
matchedIdxs, err := search.FuzzySearch(idx, searchPattern, GetSearchableFields(), GetBoostedFields())
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
results := make([]Instance, 0, len(matchedIdxs))
for _, i := range matchedIdxs {
if i >= 0 && i < len(all) {
results = append(results, all[i])
}
}
return results, nil
}
package oke
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ocioke "github.com/cnopslabs/ocloud/internal/oci/compute/oke"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// GetClusters retrieves and displays a paginated list of OKE clusters.
func GetClusters(appCtx *app.ApplicationContext, useJSON bool, limit, page int) error {
containerEngineClient, err := oci.NewContainerEngineClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating container engine client: %w", err)
}
clusterAdapter := ocioke.NewAdapter(containerEngineClient)
service := NewService(clusterAdapter, appCtx.Logger, appCtx.CompartmentID)
clusters, totalCount, nextPageToken, err := service.FetchPaginatedClusters(context.Background(), limit, page)
if err != nil {
return fmt.Errorf("listing clusters: %w", err)
}
return PrintOKETable(clusters, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON)
}
package oke
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// kubeconfig model (unexported)
type kubeConfig struct {
APIVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
Clusters []namedCluster `yaml:"clusters"`
Users []namedUser `yaml:"users"`
Contexts []namedContext `yaml:"contexts"`
CurrentContext string `yaml:"current-context"`
}
// a namedCluster represents a named Kubernetes cluster configuration consisting of a name and its corresponding details.
type namedCluster struct {
Name string `yaml:"name"`
Cluster kcCluster `yaml:"cluster"`
}
// kcCluster represents a Kubernetes cluster configuration consisting of a server address and certificate details.
type kcCluster struct {
Server string `yaml:"server"`
CertificateAuthorityData string `yaml:"certificate-authority-data,omitempty"`
InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify,omitempty"`
}
// namedUser represents a named Kubernetes user configuration consisting of a name and its corresponding details.
type namedUser struct {
Name string `yaml:"name"`
User kcUser `yaml:"user"`
}
// kcUser represents a Kubernetes user configuration.
type kcUser struct {
Exec *kcExec `yaml:"exec,omitempty"`
}
// kcExec represents a Kubernetes exec configuration.
type kcExec struct {
APIVersion string `yaml:"apiVersion"`
Command string `yaml:"command"`
Args []string `yaml:"args"`
Env []any `yaml:"env"`
InteractiveMode string `yaml:"interactiveMode"`
ProvideClusterInfo bool `yaml:"provideClusterInfo"`
}
// namedContext represents a named Kubernetes context configuration consisting of a name and its corresponding details.
type namedContext struct {
Name string `yaml:"name"`
Context kcContext `yaml:"context"`
}
// kcContext represents Kubernetes context details including cluster, namespace, and user mappings.
type kcContext struct {
Cluster string `yaml:"cluster"`
Namespace string `yaml:"namespace"`
User string `yaml:"user"`
}
// EnsureKubeconfigForOKE ensures kubeconfig entries for the given cluster/region and local port.
// If entries already exist, it is a no-op.
func EnsureKubeconfigForOKE(cluster Cluster, region string, localPort int) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get home dir: %w", err)
}
kubeDir := filepath.Join(home, ".kube")
cfgPath := filepath.Join(kubeDir, "config")
if err := os.MkdirAll(kubeDir, 0o700); err != nil {
return fmt.Errorf("ensure kube dir: %w", err)
}
var kc kubeConfig
if b, err := os.ReadFile(cfgPath); err == nil {
_ = yaml.Unmarshal(b, &kc)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read kubeconfig: %w", err)
}
if kc.APIVersion == "" {
kc.APIVersion = "v1"
}
// First, check if any existing user exec config already targets this exact cluster id, region and profile.
for _, u := range kc.Users {
if u.User.Exec == nil {
continue
}
if matchOKEExec(u.User.Exec, cluster.OCID, region) {
return nil
}
}
suffix := shortID(cluster.OCID)
cName := "cluster-" + suffix
uName := "user-" + suffix
ctxName := "context-" + suffix
// If all present by our naming, skip
if hasNamed(kc.Users, func(n namedUser) bool { return n.Name == uName }) &&
hasNamed(kc.Clusters, func(n namedCluster) bool { return n.Name == cName }) &&
hasNamed(kc.Contexts, func(n namedContext) bool { return n.Name == ctxName }) {
return nil
}
if util.PromptYesNo(fmt.Sprintf("Do you want to enter a custom kube context name for this cluster? (Default is '%s')", ctxName)) {
if name, err := util.PromptString("Enter kube context name", ctxName); err == nil {
name = strings.TrimSpace(name)
if name != "" {
ctxName = name
}
}
}
server := fmt.Sprintf("https://127.0.0.1:%d", localPort)
kc.Clusters = upsertCluster(kc.Clusters, namedCluster{
Name: cName,
Cluster: kcCluster{
Server: server,
InsecureSkipTLSVerify: true,
},
})
kc.Users = upsertUser(kc.Users, namedUser{
Name: uName,
User: kcUser{Exec: &kcExec{
APIVersion: "client.authentication.k8s.io/v1beta1",
Command: "oci",
Args: []string{"ce", "cluster", "generate-token", "--cluster-id", cluster.OCID, "--region", region, "--auth", "security_token"},
Env: []any{},
InteractiveMode: "",
ProvideClusterInfo: false,
}},
})
kc.Contexts = upsertContext(kc.Contexts, namedContext{
Name: ctxName,
Context: kcContext{
Cluster: cName,
Namespace: "",
User: uName,
},
})
if kc.CurrentContext == "" {
kc.CurrentContext = ctxName
}
// write atomically to the target file.
// Backup if exists first.
if _, err := os.Stat(cfgPath); err == nil {
if old, err := os.ReadFile(cfgPath); err == nil {
bak := cfgPath + ".bak"
_ = os.WriteFile(bak, old, 0o600)
}
}
f, err := os.OpenFile(cfgPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open kubeconfig for write: %w", err)
}
defer f.Close()
enc := yaml.NewEncoder(f)
enc.SetIndent(2)
if err := enc.Encode(&kc); err != nil {
_ = enc.Close()
return fmt.Errorf("marshal kubeconfig: %w", err)
}
if err := enc.Close(); err != nil {
return fmt.Errorf("finalize kubeconfig write: %w", err)
}
return nil
}
// shortID returns a shortened version of the given cluster id.
func shortID(id string) string {
// Try to take suffix after the last '.' or '/'
s := id
if idx := strings.LastIndex(s, "."); idx >= 0 {
s = s[idx+1:]
}
if idx := strings.LastIndex(s, "/"); idx >= 0 {
s = s[idx+1:]
}
if len(s) > 12 {
s = s[len(s)-12:]
}
return s
}
// hasNamed returns true if the given array contains an element satisfying the given predicate.
func hasNamed[T any](arr []T, pred func(T) bool) bool {
for _, v := range arr {
if pred(v) {
return true
}
}
return false
}
// upsert* functions are used to upsert an element into an array of named elements.
func upsertCluster(arr []namedCluster, item namedCluster) []namedCluster {
for i, v := range arr {
if v.Name == item.Name {
arr[i] = item
return arr
}
}
return append(arr, item)
}
// upsert* functions are used to upsert an element into an array of named elements.
func upsertUser(arr []namedUser, item namedUser) []namedUser {
for i, v := range arr {
if v.Name == item.Name {
arr[i] = item
return arr
}
}
return append(arr, item)
}
// upsert* functions are used to upsert an element into an array of named elements.
func upsertContext(arr []namedContext, item namedContext) []namedContext {
for i, v := range arr {
if v.Name == item.Name {
arr[i] = item
return arr
}
}
return append(arr, item)
}
// matchOKEExec returns true if the kcExec represents an OCI command generating a token for the given
// cluster id, region.
func matchOKEExec(exec *kcExec, clusterID, region string) bool {
if exec == nil {
return false
}
if exec.Command != "oci" {
return false
}
if !containsStr(exec.Args, "generate-token") {
return false
}
flags := parseArgsToMap(exec.Args)
if flags["--cluster-id"] != clusterID {
return false
}
if flags["--region"] != region {
return false
}
return true
}
// parseArgsToMap converts a slice of CLI args into a simple flag->value map.
// Supports both ["--flag", "value"] and ["--flag=value"] forms.
func parseArgsToMap(args []string) map[string]string {
out := make(map[string]string)
for i := 0; i < len(args); i++ {
a := args[i]
if strings.HasPrefix(a, "--") {
if idx := strings.IndexByte(a, '='); idx > 0 {
key := a[:idx]
val := a[idx+1:]
out[key] = val
continue
}
// no equal sign, take next as value if present and not a flag
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
out[a] = args[i+1]
i++
} else {
out[a] = ""
}
}
}
return out
}
func containsStr(arr []string, needle string) bool {
for _, v := range arr {
if v == needle {
return true
}
}
return false
}
// KubeconfigExistsForOKE checks whether ~/.kube/config already contains an entry
// for the given OKE cluster identified by cluster ID, region.
// It returns true if a matching user exec section is found, false if not.
func KubeconfigExistsForOKE(cluster Cluster, region string) (bool, error) {
home, err := os.UserHomeDir()
if err != nil {
return false, fmt.Errorf("get home dir: %w", err)
}
cfgPath := filepath.Join(home, ".kube", "config")
var kc kubeConfig
if b, err := os.ReadFile(cfgPath); err == nil {
_ = yaml.Unmarshal(b, &kc)
} else if errors.Is(err, os.ErrNotExist) {
return false, nil
} else {
return false, fmt.Errorf("read kubeconfig: %w", err)
}
for _, u := range kc.Users {
if u.User.Exec == nil {
continue
}
if matchOKEExec(u.User.Exec, cluster.OCID, region) {
return true, nil
}
}
return false, nil
}
package oke
import (
"context"
"errors"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ociOke "github.com/cnopslabs/ocloud/internal/oci/compute/oke"
"github.com/cnopslabs/ocloud/internal/tui"
)
// ListClusters lists all OKE clusters in the tenancy.
func ListClusters(appCtx *app.ApplicationContext, useJSON bool) error {
ctx := context.Background()
containerEngineClient, err := oci.NewContainerEngineClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating container engine client: %w", err)
}
clusterAdapter := ociOke.NewAdapter(containerEngineClient)
service := NewService(clusterAdapter, appCtx.Logger, appCtx.CompartmentID)
clusters, err := service.ListClusters(ctx)
if err != nil {
return fmt.Errorf("listing allClusters: %w", err)
}
// TUI
model := ociOke.NewImageListModel(clusters)
id, err := tui.Run(model)
if err != nil {
if errors.Is(err, tui.ErrCancelled) {
return nil
}
return fmt.Errorf("selecting image: %w", err)
}
cluster, err := service.clusterRepo.GetCluster(ctx, id)
if err != nil {
return fmt.Errorf("getting image: %w", err)
}
return PrintOKEInfo(appCtx, cluster, useJSON)
}
package oke
import (
"fmt"
"sort"
"strings"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// PrintOKETable groups cluster metadata and node-pool details into one table per cluster.
func PrintOKETable(clusters []Cluster, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
return util.MarshalDataToJSONResponse[Cluster](p, clusters, pagination)
}
if util.ValidateAndReportEmpty(clusters, pagination, appCtx.Stdout) {
return nil
}
for _, c := range clusters {
headers := []string{"Name", "Type", "Version", "Shape/Endpoint", "Count/Created", "State"}
rows := [][]string{
{
c.DisplayName,
"Cluster",
c.KubernetesVersion,
c.PrivateEndpoint,
c.TimeCreated.Format("2006-01-02"),
c.State,
},
}
for _, np := range c.NodePools {
rows = append(rows, []string{
np.DisplayName,
"NodePool",
np.KubernetesVersion,
np.NodeShape,
fmt.Sprintf("%d", np.NodeCount),
"", // State for node pools is not in the domain model yet
})
}
title := util.FormatColoredTitle(appCtx, fmt.Sprintf("Cluster: %s (%d node pools)", c.DisplayName, len(c.NodePools)))
p.PrintTable(title, headers, rows)
fmt.Fprintln(appCtx.Stdout)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
// PrintOKEsInfo displays instances in a formatted table or JSON format.
func PrintOKEsInfo(clusters []Cluster, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
return util.MarshalDataToJSONResponse[Cluster](p, clusters, pagination)
}
if util.ValidateAndReportEmpty(clusters, pagination, appCtx.Stdout) {
return nil
}
sort.Slice(clusters, func(i, j int) bool {
return strings.ToLower(clusters[i].DisplayName) < strings.ToLower(clusters[j].DisplayName)
})
for _, c := range clusters {
renderCluster(p, appCtx, c)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
// PrintOKEInfo prints a detailed view of a cluster.
func PrintOKEInfo(appCtx *app.ApplicationContext, c *Cluster, useJSON bool) error {
if c == nil {
return fmt.Errorf("nil cluster")
}
p := printer.New(appCtx.Stdout)
if useJSON {
return util.MarshalDataToJSONResponse[Cluster](p, []Cluster{*c}, nil)
}
renderCluster(p, appCtx, *c)
return nil
}
// renderCluster renders a cluster in a formatted table or JSON format.
func renderCluster(p *printer.Printer, appCtx *app.ApplicationContext, c Cluster) {
created := "-"
if !c.TimeCreated.IsZero() {
created = c.TimeCreated.Format("2006-01-02 15:04:05")
}
summary := map[string]string{
"ID": c.OCID,
"Name": c.DisplayName,
"K8s Version": c.KubernetesVersion,
"Created": created,
"State": c.State,
"Private Endpoint": c.PrivateEndpoint,
"Node Pools": fmt.Sprintf("%d", len(c.NodePools)),
}
order := []string{"ID", "Name", "K8s Version", "Created", "State", "Private Endpoint", "Node Pools"}
title := util.FormatColoredTitle(appCtx, fmt.Sprintf("Cluster: %s", c.DisplayName))
p.PrintKeyValues(title, summary, order)
fmt.Fprintln(appCtx.Stdout)
if len(c.NodePools) > 0 {
headers := []string{"Node Pool", "Version", "Shape", "Node Count"}
rows := make([][]string, len(c.NodePools))
for i, np := range c.NodePools {
rows[i] = []string{
np.DisplayName,
np.KubernetesVersion,
np.NodeShape,
fmt.Sprintf("%d", np.NodeCount),
}
}
tableTitle := util.FormatColoredTitle(appCtx, "Node Pools")
p.PrintTable(tableTitle, headers, rows)
fmt.Fprintln(appCtx.Stdout)
}
}
package oke
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
ocioke "github.com/cnopslabs/ocloud/internal/oci/compute/oke"
)
// SearchOKEClusters searches for OKE clusters matching a search pattern and displays the results in table or JSON format.
// Parameters:
// - appCtx: The application context containing configuration and dependencies.
// - search: The search string used for matching cluster names.
// - useJSON: A flag indicating whether to display the output in JSON format.
// Returns an error if the search or display operation fails.
func SearchOKEClusters(appCtx *app.ApplicationContext, search string, useJSON bool) error {
containerEngineClient, err := oci.NewContainerEngineClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating container engine client: %w", err)
}
clusterAdapter := ocioke.NewAdapter(containerEngineClient)
service := NewService(clusterAdapter, appCtx.Logger, appCtx.CompartmentID)
matchedClusters, err := service.FuzzySearch(context.Background(), search)
if err != nil {
return fmt.Errorf("searching clusters: %w", err)
}
err = PrintOKEsInfo(matchedClusters, appCtx, nil, useJSON)
if err != nil {
return fmt.Errorf("printing clusters: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Info, "Found matching clusters", "search", search, "matched", len(matchedClusters))
return nil
}
package oke
import (
"strings"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// SearchableCluster adapts Cluster to the search.Indexable interface.
type SearchableCluster struct {
Cluster
}
// ToIndexable converts a Cluster to a map of searchable fields.
func (s SearchableCluster) ToIndexable() map[string]any {
tagsKV, _ := util.FlattenTags(s.FreeformTags, s.DefinedTags)
tagsVal, _ := util.ExtractTagValues(s.FreeformTags, s.DefinedTags)
npNames := make([]string, 0, len(s.NodePools))
npShapes := make([]string, 0, len(s.NodePools))
for _, np := range s.NodePools {
npNames = append(npNames, np.DisplayName)
npShapes = append(npShapes, np.NodeShape)
}
return map[string]any{
"Name": strings.ToLower(s.DisplayName),
"OCID": strings.ToLower(s.OCID),
"K8sVersion": strings.ToLower(s.KubernetesVersion),
"State": strings.ToLower(s.State),
"VcnOCID": strings.ToLower(s.VcnOCID),
"PrivEndpoint": strings.ToLower(s.PrivateEndpoint),
"PubEndpoint": strings.ToLower(s.PublicEndpoint),
"NodePools": strings.ToLower(strings.Join(npNames, ",")),
"NodeShapes": strings.ToLower(strings.Join(npShapes, ",")),
"TagsKV": strings.ToLower(tagsKV),
"TagsVal": strings.ToLower(tagsVal),
}
}
// GetSearchableFields returns the list of fields to be indexed for OKE clusters.
func GetSearchableFields() []string {
return []string{
"Name", "OCID", "K8sVersion", "State", "VcnOCID",
"PrivEndpoint", "PubEndpoint", "NodePools", "NodeShapes", "TagsKV", "TagsVal",
}
}
// GetBoostedFields returns the list of fields to be boosted in the search.
func GetBoostedFields() []string {
return []string{"Name", "NodePools", "NodeShapes"}
}
// ToSearchableClusters converts a slice of Cluster to a slice of search.Indexable.
func ToSearchableClusters(clusters []Cluster) []search.Indexable {
searchable := make([]search.Indexable, len(clusters))
for i, c := range clusters {
searchable[i] = SearchableCluster{c}
}
return searchable
}
package oke
import (
"context"
"fmt"
"strings"
"github.com/cnopslabs/ocloud/internal/domain/compute"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/go-logr/logr"
)
// Service is the application-layer service for OKE operations.
type Service struct {
clusterRepo compute.ClusterRepository
logger logr.Logger
compartmentID string
}
// NewService initializes a new Service instance.
func NewService(repo compute.ClusterRepository, logger logr.Logger, compartmentID string) *Service {
return &Service{
clusterRepo: repo,
logger: logger,
compartmentID: compartmentID,
}
}
func (s *Service) ListClusters(ctx context.Context) ([]Cluster, error) {
s.logger.V(logger.Debug).Info("listing clusters")
clusters, err := s.clusterRepo.ListClusters(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("listing clusters from repository: %w", err)
}
return clusters, nil
}
// FetchPaginatedClusters retrieves a paginated list of clusters.
func (s *Service) FetchPaginatedClusters(ctx context.Context, limit, pageNum int) ([]Cluster, int, string, error) {
s.logger.V(logger.Debug).Info("listing clusters", "limit", limit, "pageNum", pageNum)
allClusters, err := s.clusterRepo.ListClusters(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing clusters from repository: %w", err)
}
pagedResults, totalCount, nextPageToken := util.PaginateSlice(allClusters, limit, pageNum)
s.logger.Info("completed cluster listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// FuzzySearch performs a fuzzy search for clusters using the generic search engine.
func (s *Service) FuzzySearch(ctx context.Context, searchPattern string) ([]Cluster, error) {
s.logger.V(logger.Debug).Info("finding clusters with search", "pattern", searchPattern)
allClusters, err := s.clusterRepo.ListClusters(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("fetching all clusters for search: %w", err)
}
// If no search pattern provided, return all clusters
p := strings.TrimSpace(searchPattern)
if p == "" {
s.logger.V(logger.Debug).Info("empty search pattern, returning all clusters")
return allClusters, nil
}
// Build index using SearchableCluster adapter
idxMapping := search.NewIndexMapping(GetSearchableFields())
indexables := ToSearchableClusters(allClusters)
idx, err := search.BuildIndex(indexables, idxMapping)
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
// Execute fuzzy search
hits, err := search.FuzzySearch(idx, p, GetSearchableFields(), GetBoostedFields())
if err != nil {
return nil, fmt.Errorf("executing search: %w", err)
}
if len(hits) == 0 {
s.logger.V(logger.Debug).Info("no matches found for pattern")
return nil, nil
}
matched := make([]Cluster, 0, len(hits))
for _, i := range hits {
if i >= 0 && i < len(allClusters) {
matched = append(matched, allClusters[i])
}
}
s.logger.Info("cluster search complete", "matches", len(matched))
return matched, nil
}
package auth
import (
"fmt"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// AuthenticateWithOCI handles the authentication process with Oracle Cloud Infrastructure (OCI) using interactive inputs.
// It performs authentication with the provided filter and realm, displays environment variables, and optionally starts the auth refresher.
func AuthenticateWithOCI(filter, realm string) error {
s := NewService()
logger.LogWithLevel(s.logger, logger.Debug, "Authenticating with OCI", "filter", filter, "realm", realm)
result, err := s.performInteractiveAuthentication(filter, realm)
if err != nil {
return fmt.Errorf("performing interactive authentication: %w", err)
}
logger.LogWithLevel(s.logger, logger.Debug, "Interactive authentication completed", "tenancyID", result.TenancyID, "tenancyName", result.TenancyName)
logger.LogWithLevel(s.logger, logger.Debug, "Authentication process completed successfully")
logger.LogWithLevel(s.logger, logger.Debug, "Starting OCI auth refresher for profile", "profile", result.Profile)
logger.CmdLogger.V(logger.Debug).Info("Prompting for OCI Auth Refresher setup...")
if util.PromptYesNo("Do you want to set OCI_AUTH_AUTO_REFRESHER") {
if err := s.runOCIAuthRefresher(result.Profile); err != nil {
logger.LogWithLevel(s.logger, logger.Debug, "Failed to start OCI auth refresher", "error", err)
}
logger.LogWithLevel(s.logger, logger.Debug, "OCI auth refresher enabled")
} else {
logger.LogWithLevel(s.logger, logger.Debug, "OCI auth refresher disabled")
}
logger.LogWithLevel(s.logger, logger.Trace, "Displaying environment variables")
if err = PrintExportVariable(result.Profile, result.TenancyName, result.CompartmentName); err != nil {
return fmt.Errorf("printing export variables: %w", err)
}
return nil
}
package auth
import (
"fmt"
"runtime"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// BrowserOption represents a selectable browser option.
type BrowserOption struct {
Label string
Value string
}
// BrowserSelectionModel is a minimal Bubble Tea model for selecting a browser.
type BrowserSelectionModel struct {
Cursor int
Choice int
Options []BrowserOption
}
func newBrowserSelectionModel() BrowserSelectionModel {
isMac := runtime.GOOS == "darwin"
firefoxVal := "firefox"
chromeVal := "google-chrome"
braveVal := "brave"
safariVal := "open -b com.apple.Safari"
chromiumVal := "chromium"
if isMac {
firefoxVal = "open -b org.mozilla.firefox"
chromeVal = "open -b com.google.Chrome"
braveVal = "open -b com.brave.Browser"
chromiumVal = "open -b org.chromium.Chromium"
}
opts := []BrowserOption{
{Label: "Firefox", Value: firefoxVal},
{Label: "Google Chrome", Value: chromeVal},
{Label: "Chromium", Value: chromiumVal},
{Label: "Brave", Value: braveVal},
}
// Safari only listed as an explicit option on macOS
if isMac {
opts = append(opts, BrowserOption{Label: "Safari", Value: safariVal})
}
return BrowserSelectionModel{Cursor: 0, Choice: -1, Options: opts}
}
func (m BrowserSelectionModel) Init() tea.Cmd { return nil }
func (m BrowserSelectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "enter":
if m.Cursor >= 0 && m.Cursor < len(m.Options) {
m.Choice = m.Cursor
}
return m, tea.Quit
case "down", "j":
m.Cursor = (m.Cursor + 1) % len(m.Options)
case "up", "k":
m.Cursor--
if m.Cursor < 0 {
m.Cursor = len(m.Options) - 1
}
}
}
return m, nil
}
func (m BrowserSelectionModel) View() string {
var b strings.Builder
b.WriteString("Select a browser to use for OCI authentication:\n\n")
for i, opt := range m.Options {
mark := "( ) "
if m.Cursor == i {
mark = "(•) "
}
b.WriteString(mark + opt.Label + "\n")
}
b.WriteString("\nenter: confirm q: quit\n")
return b.String()
}
// RunBrowserPicker runs the TUI and returns the browser value to set in BROWSER and a boolean indicating whether to set it.
func RunBrowserPicker() (value string, set bool, err error) {
model := newBrowserSelectionModel()
p := tea.NewProgram(model)
res, err := p.StartReturningModel()
if err != nil {
return "", false, err
}
m, ok := res.(BrowserSelectionModel)
if !ok {
return "", false, fmt.Errorf("unexpected model type")
}
if m.Choice < 0 || m.Choice >= len(m.Options) {
return "", false, nil
}
chosen := m.Options[m.Choice]
switch chosen.Value {
case "__KEEP__":
return "", false, nil
case "__UNSET__":
return "__UNSET__", true, nil
default:
return chosen.Value, true, nil
}
}
package auth
import (
"fmt"
"os"
"strings"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/jedib0t/go-pretty/v6/text"
)
// DisplayRegionsTable displays the available OCI regions in a table format.
func DisplayRegionsTable(regions []RegionInfo, filter string) error {
p := printer.New(os.Stdout)
// Group regions by their prefix (e.g., us, eu, ap)
regionGroups := groupRegionsByPrefix(regions)
// Filter regions by prefix if filter is provided
if filter != "" {
filter = strings.ToLower(filter)
filteredGroups := make(map[string][]RegionInfo)
for prefix, prefixRegions := range regionGroups {
if strings.HasPrefix(strings.ToLower(prefix), filter) {
filteredGroups[prefix] = prefixRegions
}
}
regionGroups = filteredGroups
}
// Process each region group
for prefix, prefixRegions := range regionGroups {
regionTitle := getRegionGroupTitle(prefix)
groupTitle := text.Colors{text.FgMagenta}.Sprint(fmt.Sprintf("%s", regionTitle))
var rows [][]string
rows = append(rows, []string{""})
var currentRegions []string
for i, region := range prefixRegions {
regionName := text.Colors{text.FgGreen}.Sprint(region.Name)
regionID := text.Colors{text.FgRed}.Sprint(region.ID)
formattedRegion := fmt.Sprintf("%s: %s", regionID, regionName)
currentRegions = append(currentRegions, formattedRegion)
if (i+1)%4 == 0 || i == len(prefixRegions)-1 {
rows = append(rows, []string{strings.Join(currentRegions, " ")})
currentRegions = nil
}
}
p.PrintTable(groupTitle, []string{"Available OCI Regions"}, rows)
}
return nil
}
// groupRegionsByPrefix groups regions by their prefix (e.g., us, eu, ap).
func groupRegionsByPrefix(regions []RegionInfo) map[string][]RegionInfo {
// Use the package-level logger since this is not a method
logger.LogWithLevel(logger.Logger, 3, "Grouping regions by prefix", "regionCount", len(regions))
regionGroups := make(map[string][]RegionInfo)
for _, region := range regions {
// Extract the prefix (e.g., "us" from "us-ashburn-1")
parts := strings.Split(region.Name, "-")
if len(parts) > 0 {
prefix := parts[0]
regionGroups[prefix] = append(regionGroups[prefix], region)
}
}
for prefix, regions := range regionGroups {
logger.LogWithLevel(logger.Logger, 3, "Region group", "prefix", prefix, "count", len(regions))
}
return regionGroups
}
// getRegionGroupTitle returns a human-readable title for a region group.
func getRegionGroupTitle(prefix string) string {
// Use the package-level logger since this is not a method
logger.LogWithLevel(logger.Logger, 3, "Getting region group title", "prefix", prefix)
titles := map[string]string{
"af": "Africa",
"ap": "Asia Pacific",
"ca": "Canada",
"eu": "Europe",
"il": "Israel",
"me": "Middle East",
"mx": "Mexico",
"sa": "South America",
"uk": "United Kingdom",
"us": "United States",
}
if title, ok := titles[prefix]; ok {
logger.LogWithLevel(logger.Logger, 3, "Found title for prefix", "prefix", prefix, "title", title)
return title
}
logger.LogWithLevel(logger.Logger, 3, "No title found for prefix, using prefix as title", "prefix", prefix)
return prefix
}
// PrintExportVariable prints the environment variables in a centered table with color.
func PrintExportVariable(profile, tenancyName, compartment string) error {
logger.LogWithLevel(logger.Logger, 3, "Printing export variables", "profile", profile, "tenancyName", tenancyName, "compartment", compartment)
exportVars := make(map[string]string)
if profile != "" {
exportVars[flags.EnvKeyProfile] = profile
logger.Logger.V(logger.Trace).Info("Added profile to export variables", "profile", profile)
}
if tenancyName != "" {
exportVars[flags.EnvKeyTenancyName] = tenancyName
logger.Logger.V(logger.Trace).Info("Added tenancy name to export variables", "tenancyName", tenancyName)
}
if compartment != "" {
exportVars[flags.EnvKeyCompartment] = compartment
logger.Logger.V(logger.Trace).Info("Added compartment to export variables", "compartment", compartment)
}
// Create a printer and print the export variables in a table
p := printer.New(os.Stdout)
title := "Export Variable"
message := "ENVIRONMENT VARIABLES"
p.ResultTable(title, message, exportVars)
logger.LogWithLevel(logger.Logger, logger.Trace, "Printed export variables in table")
fmt.Println("\nTo persist your selection, export the following environment variables in your shell")
return nil
}
package auth
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/configuration/info"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/cnopslabs/ocloud/scripts"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/config"
"github.com/pkg/errors"
)
// NewService creates a new authentication service.
func NewService() *Service {
appCtx := &app.ApplicationContext{
Logger: logger.Logger,
}
service := &Service{
logger: appCtx.Logger,
Provider: config.LoadOCIConfig(),
}
return service
}
// Authenticate authenticates with OCI using the specified profile and region.
func (s *Service) Authenticate(profile, region string) (*AuthenticationResult, error) {
logger.Logger.V(logger.Info).Info("Starting OCI authentication.", "profile", profile, "region", region)
ociCmd := exec.Command("oci", "session", "authenticate", "--profile-name", profile, "--region", region)
env := os.Environ()
findIdx := -1
for i, kv := range env {
if strings.HasPrefix(kv, "BROWSER=") {
findIdx = i
break
}
}
manualOpen := runtime.GOOS == "darwin" && s.browserOverride != ""
if s.browserUnset {
if findIdx >= 0 {
env = append(env[:findIdx], env[findIdx+1:]...)
}
logger.LogWithLevel(s.logger, logger.Debug, "Passing to OCI CLI without BROWSER (system default)")
} else if s.browserOverride != "" {
if manualOpen {
if findIdx >= 0 {
env = append(env[:findIdx], env[findIdx+1:]...)
}
env = append(env, "BROWSER=true")
logger.LogWithLevel(s.logger, logger.Debug, "macOS manual open enabled: suppressing OCI auto-open with BROWSER=true")
} else {
if findIdx >= 0 {
env = append(env[:findIdx], env[findIdx+1:]...)
}
env = append(env, "BROWSER="+s.browserOverride)
logger.LogWithLevel(s.logger, logger.Debug, "Passing BROWSER override to OCI CLI (appended, deduped)", "BROWSER", s.browserOverride)
}
} else {
if findIdx >= 0 {
logger.LogWithLevel(s.logger, logger.Trace, "Using existing BROWSER from environment", "BROWSER", strings.TrimPrefix(env[findIdx], "BROWSER="))
} else {
logger.LogWithLevel(s.logger, logger.Trace, "No BROWSER set; OCI CLI/browser will use system default")
}
}
ociCmd.Env = env
logger.LogWithLevel(s.logger, logger.Trace, "Running OCI CLI command", "command", "oci session authenticate", "profile", profile, "region", region)
if manualOpen {
// Capture output to extract the login URL and open with the selected browser
stdoutPipe, err := ociCmd.StdoutPipe()
if err != nil {
return nil, errors.Wrap(err, "creating StdoutPipe")
}
stderrPipe, err := ociCmd.StderrPipe()
if err != nil {
return nil, errors.Wrap(err, "creating StderrPipe")
}
if err := ociCmd.Start(); err != nil {
return nil, errors.Wrap(err, "starting `oci session authenticate`")
}
re := regexp.MustCompile(`https?://[^\s]+`)
opened := false
openURL := func(url string) {
if opened {
return
}
opened = true
parts := strings.Fields(s.browserOverride)
if len(parts) == 0 {
return
}
cmd := exec.Command(parts[0], append(parts[1:], url)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Start()
logger.LogWithLevel(s.logger, logger.Debug, "Opened login URL with selected browser", "cmd", s.browserOverride, "url", url)
}
go func() {
s := bufio.NewScanner(io.MultiReader(stdoutPipe, stderrPipe))
for s.Scan() {
line := s.Text()
fmt.Fprintln(os.Stdout, line)
if !opened && strings.Contains(line, "http") {
if m := re.FindString(line); m != "" {
openURL(m)
}
}
}
}()
if err := ociCmd.Wait(); err != nil {
return nil, errors.Wrap(err, "`oci session authenticate` failed")
}
} else {
ociCmd.Stdout = os.Stdout
ociCmd.Stderr = os.Stderr
if err := ociCmd.Run(); err != nil {
return nil, errors.Wrap(err, "failed to run `oci session authenticate`")
}
}
logger.Logger.V(logger.Info).Info("OCI CLI authentication successful.")
os.Setenv(flags.EnvKeyProfile, profile)
os.Setenv(flags.EnvKeyRegion, region)
logger.LogWithLevel(s.logger, logger.Trace, "Set environment variables", flags.EnvKeyProfile, profile, flags.EnvKeyRegion, region)
tenancyOCID, err := s.Provider.TenancyOCID()
if err != nil {
return nil, errors.Wrap(err, "fetching tenancy OCID")
}
logger.LogWithLevel(s.logger, logger.Trace, "Fetched tenancy OCID", "tenancyOCID", tenancyOCID)
result := &AuthenticationResult{
TenancyID: tenancyOCID,
Profile: profile,
Region: region,
}
// Try to get a tenancy name from a mapping file
logger.LogWithLevel(s.logger, logger.Trace, "Attempting to get tenancy name from mapping file")
tenancies, err := config.LoadTenancyMap()
if err != nil {
logger.LogWithLevel(s.logger, logger.Trace, "Failed to load tenancy map, continuing without tenancy name", "error", err)
} else {
for _, t := range tenancies {
if t.TenancyID == tenancyOCID {
logger.LogWithLevel(s.logger, logger.Trace, "Found tenancy name in mapping file", "tenancy", t.Tenancy)
result.TenancyName = t.Tenancy
logger.LogWithLevel(s.logger, logger.Trace, "Set compartment name to tenancy name", "compartmentName", t.Tenancy)
break
}
}
logger.LogWithLevel(s.logger, logger.Trace, "No matching tenancy found in mapping file", "tenancyOCID", tenancyOCID)
}
logger.LogWithLevel(s.logger, logger.Debug, "Authentication successful", "profile", profile, "region", region, "tenancyID", tenancyOCID, "tenancyName", result.TenancyName)
return result, nil
}
func (s *Service) promptForProfile() (string, error) {
logger.Logger.V(logger.Info).Info("Prompting user for OCI profile selection.")
useCustom := util.PromptYesNo("Do you want to enter a custom OCI profile name? (Default is 'DEFAULT')")
profile := "DEFAULT"
if useCustom {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter profile name: ")
customProfile, err := reader.ReadString('\n')
if err != nil {
return "", errors.Wrap(err, "reading custom profile input")
}
profile = strings.TrimSpace(customProfile)
logger.LogWithLevel(s.logger, logger.Debug, "Using custom profile", "profile", profile)
fmt.Printf("Using profile: %s\n", profile)
} else {
logger.LogWithLevel(s.logger, logger.Debug, "Using DEFAULT profile")
fmt.Println("Using DEFAULT profile")
}
return profile, nil
}
// GetOCIRegions returns a list of all available OCI regions.
func (s *Service) getOCIRegions() []RegionInfo {
logger.Logger.V(logger.Info).Info("Fetching list of OCI regions.")
regions := []string{
"af-johannesburg-1", "ap-batam-1", "ap-chiyoda-1", "ap-chuncheon-1", "ap-chuncheon-2",
"ap-dcc-canberra-1", "ap-dcc-gazipur-1", "ap-delhi-1", "ap-hyderabad-1", "ap-ibaraki-1",
"ap-kulai-1", "ap-melbourne-1", "ap-mumbai-1", "ap-osaka-1", "ap-seoul-1",
"ap-seoul-2", "ap-singapore-1", "ap-singapore-2", "ap-suwon-1", "ap-sydney-1",
"ap-tokyo-1", "ca-montreal-1", "ca-toronto-1", "eu-amsterdam-1", "eu-crissier-1",
"eu-dcc-dublin-1", "eu-dcc-dublin-2", "eu-dcc-milan-1", "eu-dcc-milan-2",
"eu-dcc-rating-1", "eu-dcc-rating-2", "eu-dcc-zurich-1", "eu-frankfurt-1",
"eu-frankfurt-2", "eu-jovanovac-1", "eu-madrid-1", "eu-madrid-2",
"eu-marseille-1", "eu-milan-1", "eu-paris-1", "eu-stockholm-1",
"eu-zurich-1", "il-jerusalem-1", "me-abudhabi-1", "me-abudhabi-2",
"me-abudhabi-3", "me-abudhabi-4", "me-alain-1", "me-dcc-doha-1",
"me-dcc-muscat-1", "me-dubai-1", "me-jeddah-1", "me-riyadh-1",
"mx-monterrey-1", "mx-queretaro-1", "sa-bogota-1", "sa-santiago-1",
"sa-saopaulo-1", "sa-valparaiso-1", "sa-vinhedo-1", "uk-cardiff-1",
"uk-gov-cardiff-1", "uk-gov-london-1", "uk-london-1", "us-ashburn-1",
"us-ashburn-2", "us-chicago-1", "us-gov-ashburn-1", "us-gov-chicago-1",
"us-gov-phoenix-1", "us-langley-1", "us-luke-1", "us-phoenix-1",
"us-saltlake-2", "us-sanjose-1", "us-somerset-1", "us-thames-1",
}
var regionInfos []RegionInfo
for i, r := range regions {
regionInfos = append(regionInfos, RegionInfo{
ID: strconv.Itoa(i + 1),
Name: r,
})
}
logger.LogWithLevel(s.logger, logger.Trace, "Retrieved OCI regions", "count", len(regionInfos))
return regionInfos
}
// PromptForRegion prompts the user to select an OCI region.
func (s *Service) promptForRegion() (string, error) {
logger.Logger.V(logger.Info).Info("Prompting user for OCI region selection.")
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter region number or name: ")
input, err := reader.ReadString('\n')
if err != nil {
return "", errors.Wrap(err, "reading region input")
}
input = strings.TrimSpace(input)
logger.LogWithLevel(s.logger, logger.Trace, "User entered region input", "input", input)
regions := s.getOCIRegions()
var chosen string
if idx, err := strconv.Atoi(input); err == nil && idx >= 1 && idx <= len(regions) {
chosen = regions[idx-1].Name
logger.LogWithLevel(s.logger, logger.Trace, "Selected region by index", "index", idx, "region", chosen)
} else {
chosen = input
logger.LogWithLevel(s.logger, logger.Trace, "Selected region by name", "region", chosen)
}
return chosen, nil
}
// viewConfigurationWithErrorHandling is a helper function to handle viewing configuration
// and handling common errors like a missing tenancy mapping file.
func (s *Service) viewConfigurationWithErrorHandling(realm string) error {
err := info.ViewConfiguration(false, realm)
if err != nil {
if strings.Contains(err.Error(), "tenancy mapping file not found") {
logger.LogWithLevel(s.logger, logger.Trace, "Tenancy mapping file not found, continuing without it", "error", err)
return nil
}
return fmt.Errorf("viewing configuration: %w", err)
}
return nil
}
// performInteractiveAuthentication handles the interactive authentication process.
// It prompts the user for profile and region selection, authenticates with OCI
func (s *Service) performInteractiveAuthentication(filter, realm string) (*AuthenticationResult, error) {
if util.PromptYesNo("Do you want to pick a browser for the OCI login flow?") {
if val, set, err := RunBrowserPicker(); err == nil {
if set {
if val == "__UNSET__" {
s.browserUnset = true
s.browserOverride = ""
logger.LogWithLevel(s.logger, logger.Debug, "Will unset BROWSER for OCI CLI child process (use system default)")
} else {
s.browserUnset = false
s.browserOverride = val
logger.LogWithLevel(s.logger, logger.Debug, "Will set BROWSER for OCI CLI child process", "BROWSER", val)
}
} else {
logger.LogWithLevel(s.logger, logger.Trace, "Keeping existing BROWSER environment or system default")
}
} else {
logger.LogWithLevel(s.logger, logger.Debug, "Browser picker failed; proceeding without changes to BROWSER")
}
}
profile, err := s.promptForProfile()
if err != nil {
return nil, fmt.Errorf("selecting profile: %w", err)
}
logger.Logger.V(logger.Info).Info("Profile selected successfully.", "profile", profile)
err = s.viewConfigurationWithErrorHandling(realm)
if err != nil {
return nil, fmt.Errorf("viewing configuration: %w", err)
}
logger.LogWithLevel(s.logger, logger.Trace, "Getting OCI regions")
regions := s.getOCIRegions()
logger.LogWithLevel(s.logger, logger.Trace, "Displaying regions table", "regionCount", len(regions), "filter", filter)
if err := DisplayRegionsTable(regions, filter); err != nil {
return nil, fmt.Errorf("displaying regions: %w", err)
}
region, err := s.promptForRegion()
if err != nil {
return nil, fmt.Errorf("selecting region: %w", err)
}
fmt.Printf("Using region: %s\n", region)
logger.LogWithLevel(s.logger, logger.Trace, "Region selected", "region", region)
logger.LogWithLevel(s.logger, logger.Trace, "Authenticating with OCI", "profile", profile, "region", region)
result, err := s.Authenticate(profile, region)
if err != nil {
return nil, fmt.Errorf("authenticating with OCI: %w", err)
}
logger.Logger.V(logger.Info).Info("OCI authentication successful.")
err = s.viewConfigurationWithErrorHandling(realm)
if err != nil {
return nil, fmt.Errorf("viewing configuration: %w", err)
}
// Prompt for custom environment variables
if util.PromptYesNo("Do you want to set OCI_TENANCY_NAME and OCI_COMPARTMENT?") {
logger.Logger.V(logger.Info).Info("Prompting for custom environment variables.")
reader := bufio.NewReader(os.Stdin)
fmt.Printf("Enter %s: ", flags.EnvKeyTenancyName)
tenancy, err := reader.ReadString('\n')
if err != nil {
logger.LogWithLevel(s.logger, logger.Trace, "Error reading tenancy name input", "error", err)
}
fmt.Printf("Enter %s: ", flags.EnvKeyCompartment)
compartment, err := reader.ReadString('\n')
if err != nil {
logger.LogWithLevel(s.logger, logger.Trace, "Error reading compartment input", "error", err)
}
tenancy = strings.TrimSpace(tenancy)
compartment = strings.TrimSpace(compartment)
logger.LogWithLevel(s.logger, logger.Trace, "Custom environment variables entered", "tenancyName", tenancy, "compartment", compartment)
if tenancy != "" {
result.TenancyName = tenancy
logger.LogWithLevel(s.logger, logger.Trace, "Updated tenancy name", "tenancyName", tenancy)
}
if compartment != "" {
result.CompartmentName = compartment
logger.LogWithLevel(s.logger, logger.Trace, "Updated compartment", "compartment", compartment)
}
logger.Logger.V(logger.Info).Info("Custom environment variables set.")
} else {
logger.LogWithLevel(s.logger, logger.Trace, "Skipping variable setup")
fmt.Println("\n Skipping variable setup.")
}
logger.LogWithLevel(s.logger, logger.Debug, "Interactive authentication completed successfully", "profile", profile, "region", region)
return result, nil
}
// RunOCIAuthRefresher runs the OCI auth refresher script for the specified profile.
func (s *Service) runOCIAuthRefresher(profile string) error {
logger.Logger.V(logger.Info).Info("Starting OCI auth refresher setup.", "profile", profile)
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
scriptDir := filepath.Join(homeDir, flags.OCIConfigDirName, flags.OCloudDefaultDirName, flags.OCloudScriptsDirName)
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
return fmt.Errorf("failed to create script directory: %w", err)
}
scriptPath := fmt.Sprintf("%s/oci_auth_refresher.sh", scriptDir)
// Write the embedded script bytes to the disk
if err := os.WriteFile(scriptPath, scripts.OCIAuthRefresher, 0o700); err != nil {
return fmt.Errorf("failed to write OCI auth refresher script to file: %w", err)
}
// Use a background context so it can run indefinitely
ctx := context.Background()
cmd := exec.CommandContext(ctx, "bash", "-c", fmt.Sprintf("NOHUP=1 %s %s", scriptPath, profile))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start OCI auth refresher script: %w", err)
}
pid := cmd.Process.Pid
logger.LogWithLevel(logger.Logger, logger.Debug, "OCI auth refresher script started", "profile", profile, "pid", pid)
// Write refresher PID to a profile session
profileDir := filepath.Join(homeDir, flags.OCIConfigDirName, flags.OCISessionsDirName, profile)
pidFile := filepath.Join(profileDir, flags.OCIRefresherPIDFile)
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(pid)), 0o644); err != nil {
return fmt.Errorf("failed to write OCI auth refresher script pid to file: %w", err)
}
fmt.Printf("\nOCI auth refresher started for profile %s with PID %d\n", profile, pid)
fmt.Println("You can verify it's running with: pgrep -af oci_auth_refresher.sh")
reader := bufio.NewReader(os.Stdin)
fmt.Print("\nPress Enter to continue... ")
_, _ = reader.ReadString('\n')
return nil
}
package info
import (
"fmt"
"os"
"strings"
appConfig "github.com/cnopslabs/ocloud/internal/config"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/jedib0t/go-pretty/v6/text"
)
// PrintMappingsFile displays tenancy mapping information in a formatted table or JSON format.
// It takes a slice of MappingsFile, the application context, and a boolean indicating whether to use JSON format.
func PrintMappingsFile(mappings []appConfig.MappingsFile, useJSON bool) error {
p := printer.New(os.Stdout)
if useJSON {
if len(mappings) == 0 {
return p.MarshalToJSON(struct{}{})
}
return p.MarshalToJSON(mappings)
}
if util.ValidateAndReportEmpty(mappings, nil, os.Stdout) {
return nil
}
// Group mappings by realm
realmGroups := groupMappingsByRealm(mappings)
// headers for the table
headers := []string{"ENVIRONMENT", "TENANCY", "COMPARTMENTS", "REGIONS"}
// For each realm, create and display a separate table
for realm, realmMappings := range realmGroups {
// Convert mappings to rows for the table, handling long compartment names and regions
rows := make([][]string, 0, len(realmMappings))
for _, mapping := range realmMappings {
compart := strings.Join(mapping.Compartments, " ")
reg := strings.Join(mapping.Regions, " ")
compartments := util.SplitTextByMaxWidth(compart)
regions := util.SplitTextByMaxWidth(reg)
// Create the first row with all columns
firstRow := []string{
mapping.Environment,
mapping.Tenancy,
compartments[0],
regions[0],
}
rows = append(rows, firstRow)
maxAdditionalRows := len(compartments) - 1
if len(regions)-1 > maxAdditionalRows {
maxAdditionalRows = len(regions) - 1
}
// Create additional rows for compartments and regions if needed
for i := 0; i < maxAdditionalRows; i++ {
compartment := ""
if i+1 < len(compartments) {
compartment = compartments[i+1]
}
region := ""
if i+1 < len(regions) {
region = regions[i+1]
}
additionalRow := []string{
"",
"",
compartment,
region,
}
rows = append(rows, additionalRow)
}
}
coloredTitle := text.Colors{text.FgMagenta}.Sprint(fmt.Sprintf("Tenancy Mapping Information - Realm: %s", realm))
p.PrintTable(coloredTitle, headers, rows)
}
return nil
}
// groupMappingsByRealm groups mappings by their realm.
func groupMappingsByRealm(mappings []appConfig.MappingsFile) map[string][]appConfig.MappingsFile {
realmGroups := make(map[string][]appConfig.MappingsFile)
for _, mapping := range mappings {
realmGroups[mapping.Realm] = append(realmGroups[mapping.Realm], mapping)
}
return realmGroups
}
package info
import (
"fmt"
"strings"
"github.com/cnopslabs/ocloud/internal/app"
appConfig "github.com/cnopslabs/ocloud/internal/config"
"github.com/cnopslabs/ocloud/internal/logger"
)
// NewService initializes a new Service instance with the provided application context.
func NewService() *Service {
appCtx := &app.ApplicationContext{
Logger: logger.Logger,
}
service := &Service{
logger: appCtx.Logger,
}
return service
}
// LoadTenancyMappings loads the tenancy mappings from the file and filters them by realm if specified.
func (s *Service) LoadTenancyMappings(realm string) (*TenancyMappingResult, error) {
// Load the tenancy mapping from the file
logger.LogWithLevel(s.logger, logger.Trace, "Loading tenancy mappings", "realm", realm)
tenancies, err := appConfig.LoadTenancyMap()
if err != nil {
return nil, fmt.Errorf("loading tenancy map: %w", err)
}
// Filter by realm if specified
var filteredMappings []appConfig.MappingsFile
for _, tenancy := range tenancies {
if realm != "" && !strings.EqualFold(tenancy.Realm, realm) {
continue
}
filteredMappings = append(filteredMappings, tenancy)
}
logger.LogWithLevel(s.logger, logger.Trace, "Loaded tenancy mappings", "count", len(filteredMappings))
logger.Logger.V(logger.Info).Info("Tenancy mappings loaded successfully.", "count", len(filteredMappings))
return &TenancyMappingResult{
Mappings: filteredMappings,
}, nil
}
package info
import (
"fmt"
"github.com/cnopslabs/ocloud/internal/logger"
)
// ViewConfiguration displays the tenancy mapping information.
// It reads the tenancy-map.yaml file and displays its contents.
// If the realm is not empty, it filters the mappings by the specified realm.
func ViewConfiguration(useJSON bool, realm string) error {
s := NewService()
logger.LogWithLevel(s.logger, logger.Debug, "ViewConfiguration", "realm", realm)
result, err := s.LoadTenancyMappings(realm)
if err != nil {
return fmt.Errorf("loading tenancy mappings: %w", err)
}
err = PrintMappingsFile(result.Mappings, useJSON)
if err != nil {
return fmt.Errorf("printing tenancy mappings: %w", err)
}
return nil
}
package setup
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cnopslabs/ocloud/internal/app"
appConfig "github.com/cnopslabs/ocloud/internal/config"
"github.com/cnopslabs/ocloud/internal/config/flags"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/util"
"gopkg.in/yaml.v3"
)
// NewService initializes a new Service instance with the provided application context.
func NewService() *Service {
appCtx := &app.ApplicationContext{
Logger: logger.Logger,
}
service := &Service{
logger: appCtx.Logger,
}
logger.Logger.V(logger.Info).Info("Creating new configuration setup service.")
return service
}
// ConfigureTenancyFile creates or updates a tenancy mapping configuration file with user-provided inputs.
func (s *Service) ConfigureTenancyFile() (err error) {
logger.Logger.V(logger.Info).Info("Starting tenancy map configuration.")
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("getting user home directory: %w", err)
}
configDir := filepath.Join(home, flags.OCIConfigDirName, flags.OCloudDefaultDirName)
configFile := filepath.Join(configDir, flags.TenancyMapFileName)
var mappingFile []appConfig.MappingsFile
logger.LogWithLevel(s.logger, logger.Trace, "Creating config directory", "dir", configDir)
// Load existing records if a file exists
if _, err := os.Stat(configFile); err == nil {
logger.LogWithLevel(s.logger, logger.Trace, "Loading existing tenancy map")
mappingFile, err = appConfig.LoadTenancyMap()
if err != nil {
return fmt.Errorf("loading existing tenancy map: %w", err)
}
} else {
fmt.Println("\nTenancy mapping file not found at:", configFile)
logger.Logger.V(logger.Info).Info("Tenancy mapping file not found.", "path", configFile)
if !util.PromptYesNo("Do you want to create the file and set up tenancy mapping?") {
fmt.Println("Setup cancelled. Exiting.")
return nil
}
// Create the directory if it doesn't exist
if err := os.MkdirAll(configDir, 0o755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
logger.Logger.V(logger.Info).Info("Configuration directory created.", "dir", configDir)
logger.LogWithLevel(s.logger, logger.Trace, "Creating new tenancy map")
}
reader := bufio.NewReader(os.Stdin)
logger.Logger.V(logger.Info).Info("Prompting for new tenancy records.")
for {
fmt.Println("\t--- Add a new tenancy record ---")
type PromptField struct {
name string
promptText string
isMulti bool
}
// Maintain the exact order of prompts as specified
promptFields := []PromptField{
{"environment", "Environment", false},
{"tenancy", "Tenancy Name", false},
{"tenancy_id", "Tenancy OCID", false},
{"realm", "Realm", false},
{"compartments", "Compartments (space-separated)", true},
{"regions", "Regions (space-separated)", true},
}
// Collect values in the specified order
values := make(map[string]interface{})
for _, field := range promptFields {
if field.isMulti {
values[field.name] = promptMulti(reader, field.promptText)
} else if field.name == "realm" {
values[field.name] = promptWithValidation(reader, field.promptText, validateRealm)
} else if field.name == "tenancy_id" {
values[field.name] = promptWithValidation(reader, field.promptText, validateTenancyID)
} else {
values[field.name] = prompt(reader, field.promptText)
}
}
// Create a record with fields in the same order as prompted
record := appConfig.MappingsFile{
Environment: values["environment"].(string),
Tenancy: values["tenancy"].(string),
TenancyID: values["tenancy_id"].(string),
Realm: values["realm"].(string),
Compartments: values["compartments"].([]string),
Regions: values["regions"].([]string),
}
// Display a record before saving it to the file
fmt.Println("\t--- Record ---")
out, err := yaml.Marshal(record)
if err != nil {
return fmt.Errorf("marshalling tenancy map: %w", err)
}
fmt.Println(string(out))
if util.PromptYesNo("Do you want to add this record to the tenancy map?") {
mappingFile = append(mappingFile, record)
logger.Logger.V(logger.Info).Info("Record added to tenancy map.")
} else {
fmt.Println("Record discarded")
logger.Logger.V(logger.Info).Info("Record discarded.")
}
if !util.PromptYesNo("Do you want to add another record?") {
break
}
}
// Write to a file
logger.LogWithLevel(s.logger, logger.Trace, "Writing tenancy map to file")
out, err := yaml.Marshal(mappingFile)
if err != nil {
return fmt.Errorf("marshalling tenancy map: %w", err)
}
err = os.WriteFile(configFile, out, 0644)
if err != nil {
return fmt.Errorf("writing tenancy map to file: %w", err)
}
logger.Logger.V(logger.Info).Info("Tenancy map written to file successfully.", "file", configFile)
return nil
}
// prompt reads user input from the provided reader with a label and returns the trimmed input as a string.
func prompt(reader *bufio.Reader, label string) string {
logger.Logger.V(logger.Debug).Info("Prompting for input.", "label", label)
fmt.Printf("%s: ", label)
text, _ := reader.ReadString('\n')
return strings.TrimSpace(text)
}
// promptMulti reads a line of input for a given label and returns the input split into a slice of strings.
func promptMulti(reader *bufio.Reader, label string) []string {
logger.Logger.V(logger.Debug).Info("Prompting for multi-input.", "label", label)
fmt.Printf("%s: ", label)
text, _ := reader.ReadString('\n')
return strings.Fields(strings.TrimSpace(text))
}
// validateRealm ensures the realm is properly formatted
func validateRealm(realm string) (string, error) {
realm = strings.ToUpper(realm)
if len(realm) > 4 {
return "", fmt.Errorf("realm must be no more than 4 characters")
}
if len(realm) < 2 || realm[:2] != "OC" {
return "", fmt.Errorf("realm must start with OC")
}
return realm, nil
}
// validateTenancyID ensures the tenancy ID contains the word "tenancy"
func validateTenancyID(tenancyID string) (string, error) {
if !strings.Contains(tenancyID, "tenancy") {
return "", fmt.Errorf("tenancy ID must contain the word 'tenancy'")
}
return tenancyID, nil
}
// promptWithValidation prompts for input and validates it using the provided validation function
func promptWithValidation(reader *bufio.Reader, label string, validate func(string) (string, error)) string {
logger.Logger.V(logger.Debug).Info("Prompting for input with validation.", "label", label)
for {
fmt.Printf("%s: ", label)
text, _ := reader.ReadString('\n')
input := strings.TrimSpace(text)
validated, err := validate(input)
if err != nil {
fmt.Printf("Error: %s. Please try again.\n", err)
logger.Logger.V(logger.Debug).Info("Validation failed.", "error", err)
continue
}
logger.Logger.V(logger.Debug).Info("Validation successful.", "value", validated)
return validated
}
}
package setup
import (
"fmt"
"github.com/cnopslabs/ocloud/internal/logger"
)
// SetupTenancyMapping initializes and configures the tenancy mapping file by using the service's ConfigureTenancyFile method.
// It logs the operation and returns an error if the configuration process fails.
func SetupTenancyMapping() error {
s := NewService()
logger.LogWithLevel(s.logger, logger.Debug, "SetupTenancyMapping")
err := s.ConfigureTenancyFile()
if err != nil {
return fmt.Errorf("configuring tenancy mapping file: %w", err)
}
logger.Logger.V(logger.Info).Info("Tenancy mapping setup completed successfully.")
return nil
}
package autonomousdb
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
ociadb "github.com/cnopslabs/ocloud/internal/oci/database/autonomousdb"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// GetAutonomousDatabase retrieves a list of Autonomous Databases and displays them in a table or JSON format.
func GetAutonomousDatabase(appCtx *app.ApplicationContext, useJSON bool, limit, page int, showAll bool) error {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "Listing Autonomous Databases")
adapter, err := ociadb.NewAdapter(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating database adapter: %w", err)
}
service := NewService(adapter, appCtx)
ctx := context.Background()
allDatabases, totalCount, nextPageToken, err := service.FetchPaginatedAutonomousDb(ctx, limit, page)
if err != nil {
return fmt.Errorf("listing autonomous databases: %w", err)
}
return PrintAutonomousDbsInfo(allDatabases, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON, showAll)
}
package autonomousdb
import (
"context"
"errors"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
ociadb "github.com/cnopslabs/ocloud/internal/oci/database/autonomousdb"
"github.com/cnopslabs/ocloud/internal/tui"
)
// ListAutonomousDatabases lists all Autonomous Databases in the application context.
func ListAutonomousDatabases(appCtx *app.ApplicationContext, useJSON bool) error {
ctx := context.Background()
autonomousDatabaseAdapter, err := ociadb.NewAdapter(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating autonomous database adapter: %w", err)
}
service := NewService(autonomousDatabaseAdapter, appCtx)
allDatabases, err := service.ListAutonomousDb(ctx)
if err != nil {
return fmt.Errorf("listing autonomous databases: %w", err)
}
//TUI
model := ociadb.NewDatabaseListModel(allDatabases)
id, err := tui.Run(model)
if err != nil {
if errors.Is(err, tui.ErrCancelled) {
return nil
}
return fmt.Errorf("selecting database: %w", err)
}
database, err := service.repo.GetAutonomousDatabase(ctx, id)
if err != nil {
return fmt.Errorf("getting database: %w", err)
}
return PrintAutonomousDbInfo(database, appCtx, useJSON, true)
}
package autonomousdb
import (
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/domain/database"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// PrintAutonomousDbInfo prints a single Autonomous DB.
// - useJSON: if true, prints the single DB as JSON (no pagination envelope)
// - showAll: if true, prints the detailed view; otherwise, prints the summary view
func PrintAutonomousDbInfo(db *database.AutonomousDatabase, appCtx *app.ApplicationContext, useJSON bool, showAll bool) error {
p := printer.New(appCtx.Stdout)
if useJSON {
return p.MarshalToJSON(db)
}
return printOneAutonomousDb(p, appCtx, db, showAll)
}
// PrintAutonomousDbsInfo prints a list of Autonomous DBs.
// - pagination: optional, will be adjusted and logged if provided
// - useJSON: if true, prints databases with util.MarshalDataToJSONResponse
// - showAll: if true, prints detailed view; otherwise summary view
func PrintAutonomousDbsInfo(databases []database.AutonomousDatabase, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool, showAll bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
if len(databases) == 0 && pagination == nil {
return p.MarshalToJSON(struct{}{})
}
return util.MarshalDataToJSONResponse[database.AutonomousDatabase](p, databases, pagination)
}
if util.ValidateAndReportEmpty(databases, pagination, appCtx.Stdout) {
return nil
}
for _, db := range databases {
if err := printOneAutonomousDb(p, appCtx, &db, showAll); err != nil {
return err
}
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
func printOneAutonomousDb(p *printer.Printer, appCtx *app.ApplicationContext, db *database.AutonomousDatabase, showAll bool) error {
title := util.FormatColoredTitle(appCtx, db.Name)
// Prefer names to IDs when available
subnetVal := db.SubnetId
if db.SubnetName != "" {
subnetVal = db.SubnetName
}
vcnVal := db.VcnID
if db.VcnName != "" {
vcnVal = db.VcnName
}
nsgVal := ""
if len(db.NsgNames) > 0 {
nsgVal = fmt.Sprintf("%v", db.NsgNames)
} else if len(db.NsgIds) > 0 {
nsgVal = fmt.Sprintf("%v", db.NsgIds)
}
// Storage: prefer TBs over GBs
storage := ""
if s := intToString(db.DataStorageSizeInTBs); s != "" {
storage = s + " TB"
} else if g := intToString(db.DataStorageSizeInGBs); g != "" {
storage = g + " GB"
}
// CPU label/value based on compute model
cpuKey := "OCPUs"
cpuVal := floatToString(db.OcpuCount)
if db.ComputeModel == "ECPU" || db.EcpuCount != nil {
cpuKey = "ECPUs"
cpuVal = floatToString(db.EcpuCount)
}
if !showAll {
// Summary view
summary := map[string]string{
"Lifecycle State": db.LifecycleState,
"DB Version": db.DbVersion,
"Workload": db.DbWorkload,
"Compute Model": db.ComputeModel,
cpuKey: cpuVal,
"Storage": storage,
"Private IP": db.PrivateEndpointIp,
"Private Endpoint": db.PrivateEndpoint,
"Subnet": subnetVal,
"VCN": vcnVal,
"NSGs": nsgVal,
}
if db.TimeCreated != nil {
summary["Time Created"] = db.TimeCreated.Format("2006-01-02 15:04:05")
}
ordered := []string{
"Lifecycle State", "DB Version", "Workload", "Compute Model",
cpuKey, "Storage", "Private IP", "Private Endpoint", "Subnet", "VCN", "NSGs", "Time Created",
}
p.PrintKeyValues(title, summary, ordered)
return nil
}
// Detailed view
details := make(map[string]string)
orderedKeys := []string{}
// General
details["Lifecycle State"] = db.LifecycleState
details["DB Version"] = db.DbVersion
details["Workload"] = db.DbWorkload
details["License Model"] = db.LicenseModel
if db.TimeCreated != nil {
details["Time Created"] = db.TimeCreated.Format("2006-01-02 15:04:05")
}
orderedKeys = append(orderedKeys, "Lifecycle State", "Lifecycle Details", "DB Version", "Workload", "License Model", "Time Created")
// Capacity
details["Compute Model"] = db.ComputeModel
if db.ComputeModel == "ECPU" || db.EcpuCount != nil {
details["ECPUs"] = floatToString(db.EcpuCount)
orderedKeys = append(orderedKeys, "Compute Model", "ECPUs")
} else {
details["OCPUs"] = floatToString(db.OcpuCount)
details["CPU Cores"] = intToString(db.CpuCoreCount)
orderedKeys = append(orderedKeys, "Compute Model", "OCPUs", "CPU Cores")
}
details["Storage"] = storage
details["Auto Scaling"] = boolToString(db.IsAutoScalingEnabled)
orderedKeys = append(orderedKeys, "Storage", "Auto Scaling")
// Network
accessType := ""
if db.PrivateEndpoint != "" {
accessType = "Virtual cloud network"
}
details["Access Type"] = accessType
details["Private IP"] = db.PrivateEndpointIp
details["Private Endpoint"] = db.PrivateEndpoint
details["Subnet"] = subnetVal
details["VCN"] = vcnVal
details["NSGs"] = nsgVal
details["mTLS Required"] = boolToString(db.IsMtlsRequired)
if len(db.WhitelistedIps) > 0 {
details["Whitelisted IPs"] = fmt.Sprintf("%v", db.WhitelistedIps)
}
orderedKeys = append(orderedKeys, "Access Type", "Private IP", "Private Endpoint", "Subnet", "VCN", "NSGs", "mTLS Required", "Whitelisted IPs")
// Connection Strings
if details["High"] = db.ConnectionStrings["HIGH"]; details["High"] != "" {
orderedKeys = append(orderedKeys, "High")
}
if details["Medium"] = db.ConnectionStrings["MEDIUM"]; details["Medium"] != "" {
orderedKeys = append(orderedKeys, "Medium")
}
if details["Low"] = db.ConnectionStrings["LOW"]; details["Low"] != "" {
orderedKeys = append(orderedKeys, "Low")
}
if details["TP"] = db.ConnectionStrings["TP"]; details["TP"] != "" {
orderedKeys = append(orderedKeys, "TP")
}
if details["TPURGENT"] = db.ConnectionStrings["TPURGENT"]; details["TPURGENT"] != "" {
orderedKeys = append(orderedKeys, "TPURGENT")
}
p.PrintKeyValues(title, details, orderedKeys)
return nil
}
//-------------------------------------------------Helpers--------------------------------------------------------------
func boolToString(v *bool) string {
if v == nil {
return ""
}
if *v {
return "true"
}
return "false"
}
func intToString(v *int) string {
if v == nil {
return ""
}
return fmt.Sprintf("%d", *v)
}
func floatToString(v *float32) string {
if v == nil {
return ""
}
return fmt.Sprintf("%.2f", *v)
}
package autonomousdb
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
ociadb "github.com/cnopslabs/ocloud/internal/oci/database/autonomousdb"
)
// SearchAutonomousDatabases searches for OCI Autonomous Databases matching the given query string in the current context.
// Parameters:
// - appCtx: The application context containing OCI configuration and runtime settings.
// - search: The search query string to perform a fuzzy match against the available databases.
// - useJSON: A flag that determines if the output should be JSON formatted.
// - showAll: A flag indicating whether detailed or summary information should be displayed.
// Returns an error if database search or result processing fails.
func SearchAutonomousDatabases(appCtx *app.ApplicationContext, search string, useJSON bool, showAll bool) error {
adapter, err := ociadb.NewAdapter(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating database adapter: %w", err)
}
service := NewService(adapter, appCtx)
ctx := context.Background()
matchedDatabases, err := service.FuzzySearch(ctx, search)
if err != nil {
return fmt.Errorf("finding autonomous databases: %w", err)
}
err = PrintAutonomousDbsInfo(matchedDatabases, appCtx, nil, useJSON, showAll)
if err != nil {
return fmt.Errorf("printing autonomous databases: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Info, "Found matching autonomous databases", "search", search, "matched", len(matchedDatabases))
return nil
}
package autonomousdb
import (
"strconv"
"strings"
"github.com/cnopslabs/ocloud/internal/domain/database"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// SearchableAutonomousDatabase adapts AutonomousDatabase to the search.Indexable interface.
type SearchableAutonomousDatabase struct {
database.AutonomousDatabase
}
// ToIndexable converts an AutonomousDatabase to a map of searchable fields.
func (s SearchableAutonomousDatabase) ToIndexable() map[string]any {
tagsKV, _ := util.FlattenTags(s.FreeformTags, s.DefinedTags)
tagsVal, _ := util.ExtractTagValues(s.FreeformTags, s.DefinedTags)
// join slices safely
join := func(items []string) string {
return strings.ToLower(strings.Join(items, ","))
}
return map[string]any{
"Name": strings.ToLower(s.Name),
"OCID": strings.ToLower(s.ID),
"State": strings.ToLower(s.LifecycleState),
"DbVersion": strings.ToLower(s.DbVersion),
"Workload": strings.ToLower(s.DbWorkload),
"LicenseModel": strings.ToLower(s.LicenseModel),
"ComputeModel": strings.ToLower(s.ComputeModel),
"OcpuCount": strings.ToLower(formatFloat32Ptr(s.OcpuCount)),
"EcpuCount": strings.ToLower(formatFloat32Ptr(s.EcpuCount)),
"CpuCoreCount": strings.ToLower(formatIntPtr(s.CpuCoreCount)),
"StorageTB": strings.ToLower(formatIntPtr(s.DataStorageSizeInTBs)),
"StorageGB": strings.ToLower(formatIntPtr(s.DataStorageSizeInGBs)),
"VcnID": strings.ToLower(s.VcnID),
"VcnName": strings.ToLower(s.VcnName),
"SubnetId": strings.ToLower(s.SubnetId),
"SubnetName": strings.ToLower(s.SubnetName),
"PrivateEndpoint": strings.ToLower(s.PrivateEndpoint),
"PrivateEndpointIp": strings.ToLower(s.PrivateEndpointIp),
"PrivateEndpointLbl": strings.ToLower(s.PrivateEndpointLabel),
"WhitelistedIps": join(s.WhitelistedIps),
"NsgNames": join(s.NsgNames),
"NsgIds": join(s.NsgIds),
"TagsKV": strings.ToLower(tagsKV),
"TagsVal": strings.ToLower(tagsVal),
}
}
// formatFloat32Ptr returns string value of *float32 or empty if nil.
func formatFloat32Ptr(p *float32) string {
if p == nil {
return ""
}
return strconv.FormatFloat(float64(*p), 'f', -1, 64)
}
// formatIntPtr returns string value of *int or empty if nil.
func formatIntPtr(p *int) string {
if p == nil {
return ""
}
return strconv.Itoa(*p)
}
// GetSearchableFields returns the list of fields to be indexed for Autonomous Databases.
func GetSearchableFields() []string {
return []string{
"Name", "OCID", "State", "DbVersion", "Workload", "LicenseModel",
"ComputeModel", "OcpuCount", "EcpuCount", "CpuCoreCount", "StorageTB", "StorageGB",
"VcnID", "VcnName", "SubnetId", "SubnetName",
"PrivateEndpoint", "PrivateEndpointIp", "PrivateEndpointLbl",
"WhitelistedIps", "NsgNames", "NsgIds",
"TagsKV", "TagsVal",
}
}
// GetBoostedFields returns the list of fields to be boosted in the search.
func GetBoostedFields() []string {
return []string{"Name", "OCID", "VcnName", "SubnetName"}
}
// ToSearchableAutonomousDBs converts a slice of AutonomousDatabase to a slice of search.Indexable.
func ToSearchableAutonomousDBs(dbs []AutonomousDatabase) []search.Indexable {
searchable := make([]search.Indexable, len(dbs))
for i, db := range dbs {
searchable[i] = SearchableAutonomousDatabase{db}
}
return searchable
}
package autonomousdb
import (
"context"
"fmt"
"strings"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/domain/database"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/go-logr/logr"
)
// Service provides operations and functionalities related to database management, logging, and compartment handling.
type Service struct {
repo database.AutonomousDatabaseRepository
logger logr.Logger
compartmentID string
}
// NewService initializes a new Service instance with the provided application context.
func NewService(repo database.AutonomousDatabaseRepository, appCtx *app.ApplicationContext) *Service {
return &Service{
repo: repo,
logger: appCtx.Logger,
compartmentID: appCtx.CompartmentID,
}
}
// ListAutonomousDb retrieves and returns all databases from the given compartment in the OCI account.
func (s *Service) ListAutonomousDb(ctx context.Context) ([]AutonomousDatabase, error) {
s.logger.V(logger.Debug).Info("listing autonomous databases")
databases, err := s.repo.ListAutonomousDatabases(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("failed to list autonomous databases: %w", err)
}
return databases, nil
}
// FetchPaginatedAutonomousDb retrieves a paginated list of databases with given limit and page number parameters.
// It returns the slice of databases, total count, next page token, and an error if encountered.
func (s *Service) FetchPaginatedAutonomousDb(ctx context.Context, limit, pageNum int) ([]AutonomousDatabase, int, string, error) {
s.logger.V(logger.Debug).Info("listing autonomous databases", "limit", limit, "pageNum", pageNum)
allDatabases, err := s.repo.ListEnrichedAutonomousDatabase(ctx, s.compartmentID)
if err != nil {
allDatabases, err = s.repo.ListAutonomousDatabases(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to list autonomous databases: %w", err)
}
}
if len(allDatabases) == 0 {
var baseErr error
allDatabases, baseErr = s.repo.ListAutonomousDatabases(ctx, s.compartmentID)
if baseErr != nil {
return nil, 0, "", fmt.Errorf("failed to list autonomous databases: %w", baseErr)
}
}
pagedResults, totalCount, nextPageToken := util.PaginateSlice(allDatabases, limit, pageNum)
logger.LogWithLevel(s.logger, logger.Info, "completed database listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// FuzzySearch performs a fuzzy search for Autonomous Databases using the generic search engine.
func (s *Service) FuzzySearch(ctx context.Context, searchPattern string) ([]AutonomousDatabase, error) {
logger.LogWithLevel(s.logger, logger.Trace, "finding databases with search", "pattern", searchPattern)
allDatabases, err := s.repo.ListEnrichedAutonomousDatabase(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("failed to fetch all databases: %w", err)
}
p := strings.TrimSpace(searchPattern)
if p == "" {
return allDatabases, nil
}
// Build index using SearchableAutonomousDatabase
indexables := ToSearchableAutonomousDBs(allDatabases)
idxMapping := search.NewIndexMapping(GetSearchableFields())
idx, err := search.BuildIndex(indexables, idxMapping)
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
hits, err := search.FuzzySearch(idx, strings.ToLower(p), GetSearchableFields(), GetBoostedFields())
if err != nil {
return nil, fmt.Errorf("executing search: %w", err)
}
results := make([]AutonomousDatabase, 0, len(hits))
for _, i := range hits {
if i >= 0 && i < len(allDatabases) {
results = append(results, allDatabases[i])
}
}
return results, nil
}
package bastion
// Package-level shell execution helper for bastion-related flows.
// Keeping child-process spawning in the service layer ensures CLI code remains
// thin and focused on user interaction while services encapsulate the execution
// details. This also centralizes context-aware process handling.
import (
"context"
"io"
"os"
"os/exec"
)
// RunShell runs the given command line using `bash -lc` and ties its lifetime to ctx.
// Stdout/Stderr are wired; Stdin is inherited from the current process (enables interactive SSH by default).
func RunShell(ctx context.Context, stdout, stderr io.Writer, cmdLine string) error {
cmd := exec.CommandContext(ctx, "bash", "-lc", cmdLine)
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
package bastion
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
)
// ListBastions retrieves a list of bastion hosts and displays their information, optionally in JSON format.
func ListBastions(ctx context.Context, appCtx *app.ApplicationContext, useJSON bool) error {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "Listing bastions")
service, err := NewService(appCtx)
if err != nil {
return fmt.Errorf("creating bastion service: %w", err)
}
bastions, err := service.List(ctx)
if err != nil {
return fmt.Errorf("listing bastions: %w", err)
}
return PrintBastionInfo(bastions, appCtx, useJSON)
}
package bastion
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// PrintBastionInfo displays bastion instances in a formatted table or JSON format.
func PrintBastionInfo(bastions []Bastion, appCtx *app.ApplicationContext, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if useJSON {
if len(bastions) == 0 {
return p.MarshalToJSON(struct{}{})
}
return p.MarshalToJSON(bastions)
}
for _, b := range bastions {
bastionInfo := map[string]string{
"Name": b.Name,
"BastionType": string(b.BastionType),
"LifecycleState": string(b.LifecycleState),
"TargetVcn": b.TargetVcnName,
"TargetSubnet": b.TargetSubnetName,
}
orderedKeys := []string{
"Name", "BastionType", "LifecycleState", "TargetVcn", "TargetSubnet",
}
title := util.FormatColoredTitle(appCtx, b.Name)
p.PrintKeyValues(title, bastionInfo, orderedKeys)
}
return nil
}
package bastion
import (
"context"
"fmt"
"github.com/oracle/oci-go-sdk/v65/core"
)
// CanReach checks if the provided bastion can reach a target identified by target VCN and/or Subnet.
// The logic is intentionally simple/minimal:
// - If targetSubnetID is provided, we fetch it and compare its VCN ID with bastion.TargetVcnId.
// - Else if targetVcnID is provided, we compare it directly with bastion.TargetVcnId.
// - If neither targetVcnID nor targetSubnetID is provided, we cannot determine reachability.
func (s *Service) CanReach(ctx context.Context, b Bastion, targetVcnID string, targetSubnetID string) (bool, string) {
if b.TargetVcnId == "" {
return false, "Selected Bastion is not configured with a target VCN."
}
if targetSubnetID != "" {
subnet, err := s.fetchSubnetDetails(ctx, targetSubnetID)
if err != nil {
return false, fmt.Sprintf("Unable to verify reachability: failed to fetch target subnet: %v", err)
}
if vcnMatches(b.TargetVcnId, subnet) {
return true, "Bastion target VCN matches the target subnet's VCN."
}
return false, fmt.Sprintf("Bastion target VCN %s does not match target subnet's VCN %s", b.TargetVcnId, safeVcnID(subnet))
}
// Fall back to VCN comparison if available.
if targetVcnID != "" {
if b.TargetVcnId == targetVcnID {
return true, "Bastion target VCN matches the target VCN."
}
return false, fmt.Sprintf("Bastion target VCN %s does not match target VCN %s", b.TargetVcnId, targetVcnID)
}
return false, "Target network details are unavailable; cannot verify reachability."
}
// vcnMatches checks if the provided subnet's VCN ID matches the specified bastion VCN ID. Returns true if they match.
func vcnMatches(bastionVcnID string, subnet *core.Subnet) bool {
if subnet == nil || subnet.VcnId == nil {
return false
}
return bastionVcnID == *subnet.VcnId
}
// safeVcnID returns the VCN ID of the provided subnet, or an empty string if the subnet is nil or has no VCN ID.
func safeVcnID(subnet *core.Subnet) string {
if subnet == nil || subnet.VcnId == nil {
return ""
}
return *subnet.VcnId
}
package bastion
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
"github.com/oracle/oci-go-sdk/v65/bastion"
"github.com/oracle/oci-go-sdk/v65/core"
)
// NewService creates a new bastion service
func NewService(appCtx *app.ApplicationContext) (*Service, error) {
logger.Logger.V(logger.Info).Info("Creating new Bastion service.")
cfg := appCtx.Provider
bc, err := oci.NewBastionClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create bastion client: %w", err)
}
nc, err := oci.NewNetworkClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create network client: %w", err)
}
cc, err := oci.NewComputeClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create compute client: %w", err)
}
return &Service{
bastionClient: bc,
networkClient: nc,
computeClient: cc,
logger: appCtx.Logger,
compartmentID: appCtx.CompartmentID,
vcnCache: make(map[string]*core.Vcn),
subnetCache: make(map[string]*core.Subnet),
}, nil
}
// List retrieves and returns all bastion hosts from the given compartment in the OCI account.
func (s *Service) List(ctx context.Context) (bastions []Bastion, err error) {
logger.LogWithLevel(s.logger, logger.Debug, "Listing Bastions in compartment", "compartmentID", s.compartmentID)
request := bastion.ListBastionsRequest{
CompartmentId: &s.compartmentID,
}
response, err := s.bastionClient.ListBastions(ctx, request)
if err != nil {
return nil, fmt.Errorf("failed to list bastions: %w", err)
}
logger.Logger.V(logger.Debug).Info("Successfully listed bastions.", "count", len(response.Items))
var allBastions []Bastion
for _, b := range response.Items {
logger.Logger.V(logger.Debug).Info("Processing bastion", "bastionID", *b.Id, "bastionName", *b.Name)
toBastion := mapToBastion(b)
if b.TargetVcnId != nil && *b.TargetVcnId != "" {
vcn, err := s.fetchVcnDetails(ctx, *b.TargetVcnId)
if err != nil {
logger.LogWithLevel(s.logger, logger.Trace, "Failed to fetch VCN details", "vcnID", *b.TargetVcnId, "error", err)
} else if vcn.DisplayName != nil {
toBastion.TargetVcnName = *vcn.DisplayName
}
}
// Fetch Subnet details
if b.TargetSubnetId != nil && *b.TargetSubnetId != "" {
subnet, err := s.fetchSubnetDetails(ctx, *b.TargetSubnetId)
if err != nil {
logger.LogWithLevel(s.logger, logger.Trace, "Failed to fetch Subnet details", "subnetID", *b.TargetSubnetId, "error", err)
} else if subnet.DisplayName != nil {
toBastion.TargetSubnetName = *subnet.DisplayName
}
}
allBastions = append(allBastions, toBastion)
}
return allBastions, nil
}
// fetchVcnDetails retrieves the VCN details for the given VCN ID.
func (s *Service) fetchVcnDetails(ctx context.Context, vcnID string) (*core.Vcn, error) {
if vcn, ok := s.vcnCache[vcnID]; ok {
logger.LogWithLevel(s.logger, logger.Trace, "VCN cache hit", "vcnID", vcnID)
return vcn, nil
}
logger.LogWithLevel(s.logger, logger.Trace, "VCN cache miss", "vcnID", vcnID)
logger.Logger.V(logger.Debug).Info("Calling OCI API to get VCN details.", "vcnID", vcnID)
resp, err := s.networkClient.GetVcn(ctx, core.GetVcnRequest{
VcnId: &vcnID,
})
if err != nil {
return nil, fmt.Errorf("getting VCN details: %w", err)
}
s.vcnCache[vcnID] = &resp.Vcn
return &resp.Vcn, nil
}
// fetchSubnetDetails retrieves the subnet details for the given subnet ID.
// It uses a cache to avoid making repeated API calls for the same subnet.
func (s *Service) fetchSubnetDetails(ctx context.Context, subnetID string) (*core.Subnet, error) {
if subnet, ok := s.subnetCache[subnetID]; ok {
logger.LogWithLevel(s.logger, logger.Trace, "subnet cache hit", "subnetID", subnetID)
return subnet, nil
}
// Cache miss, fetch from API
logger.LogWithLevel(s.logger, logger.Trace, "subnet cache miss", "subnetID", subnetID)
logger.Logger.V(logger.Debug).Info("Calling OCI API to get Subnet details.", "subnetID", subnetID)
resp, err := s.networkClient.GetSubnet(ctx, core.GetSubnetRequest{
SubnetId: &subnetID,
})
if err != nil {
return nil, fmt.Errorf("getting subnet details: %w", err)
}
s.subnetCache[subnetID] = &resp.Subnet
return &resp.Subnet, nil
}
// mapToBastion converts a BastionSummary object to a Bastion object with relevant fields populated.
func mapToBastion(bastion bastion.BastionSummary) Bastion {
return Bastion{
ID: *bastion.Id,
Name: *bastion.Name,
BastionType: *bastion.BastionType,
LifecycleState: bastion.LifecycleState,
TargetVcnId: *bastion.TargetVcnId,
TargetSubnetId: *bastion.TargetSubnetId,
}
}
package bastion
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"github.com/oracle/oci-go-sdk/v65/bastion"
"github.com/oracle/oci-go-sdk/v65/common"
)
// Defaults used for session wait and ttl
var (
waitPollInterval = 3 * time.Second
defaultTTL = 10800 // seconds (3 hours)
)
// sanitizeDisplayName ensures the given string is a valid and safe display name by removing invalid characters and truncating the length.
func sanitizeDisplayName(s string) string {
allowed := regexp.MustCompile(`[^A-Za-z0-9._+@-]`)
clean := allowed.ReplaceAllString(s, "-")
if len(clean) > 255 {
clean = clean[:255]
}
if strings.Trim(clean, "-") == "" {
clean = fmt.Sprintf("ocloud-%d", time.Now().Unix())
}
return clean
}
// waitForSessionActive polls the bastion session until it reaches ACTIVE or the context is cancelled.
// It mirrors the previous inline loops and keeps the small sleep after ACTIVE to ensure readiness.
func (s *Service) waitForSessionActive(ctx context.Context, sessionID string) error {
for {
getResp, err := s.bastionClient.GetSession(ctx, bastion.GetSessionRequest{SessionId: &sessionID})
if err != nil {
return fmt.Errorf("waiting for session ACTIVE: %w", err)
}
if getResp.Session.LifecycleState == bastion.SessionLifecycleStateActive {
time.Sleep(waitPollInterval)
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(waitPollInterval):
}
}
}
// readPublicKey reads and returns the public key content from the given path.
func readPublicKey(publicKeyPath string) (string, error) {
data, err := os.ReadFile(publicKeyPath)
if err != nil {
return "", fmt.Errorf("reading public key: %w", err)
}
return string(data), nil
}
// listActiveSessions returns ACTIVE session summaries for a bastion, sorted by time created desc.
func (s *Service) listActiveSessions(ctx context.Context, bastionID string) ([]bastion.SessionSummary, error) {
lsReq := bastion.ListSessionsRequest{
BastionId: common.String(bastionID),
SessionLifecycleState: bastion.ListSessionsSessionLifecycleStateActive,
SortBy: bastion.ListSessionsSortByTimecreated,
SortOrder: bastion.ListSessionsSortOrderDesc,
}
lsResp, err := s.bastionClient.ListSessions(ctx, lsReq)
if err != nil {
return nil, fmt.Errorf("listing bastion sessions: %w", err)
}
return lsResp.Items, nil
}
// EnsurePortForwardSession finds an ACTIVE bastion session targeting the given IP:port and matching the provided public key.
// If not found, it creates a new session and waits until it becomes ACTIVE, returning the session ID.
func (s *Service) EnsurePortForwardSession(ctx context.Context, bastionID, targetIP string, port int, publicKeyPath string) (string, error) {
pubKey, err := readPublicKey(publicKeyPath)
if err != nil {
return "", err
}
// 1) Try to reuse an ACTIVE matching session
items, err := s.listActiveSessions(ctx, bastionID)
if err != nil {
return "", err
}
for _, item := range items {
if trd, ok := item.TargetResourceDetails.(bastion.PortForwardingSessionTargetResourceDetails); ok {
if trd.TargetResourcePrivateIpAddress != nil && trd.TargetResourcePort != nil &&
*trd.TargetResourcePrivateIpAddress == targetIP && *trd.TargetResourcePort == port {
getResp, err := s.bastionClient.GetSession(ctx, bastion.GetSessionRequest{SessionId: item.Id})
if err != nil {
return "", fmt.Errorf("getting bastion session: %w", err)
}
if getResp.KeyDetails != nil && getResp.KeyDetails.PublicKeyContent != nil && *getResp.KeyDetails.PublicKeyContent == pubKey {
return *item.Id, nil // Reuse
}
}
}
}
// 2) Create a new session
baseName := fmt.Sprintf("ocloud-%s-%d-%d", strings.ReplaceAll(targetIP, ".", "-"), port, time.Now().Unix())
displayName := sanitizeDisplayName(baseName)
createReq := bastion.CreateSessionRequest{
CreateSessionDetails: bastion.CreateSessionDetails{
BastionId: common.String(bastionID),
TargetResourceDetails: bastion.CreatePortForwardingSessionTargetResourceDetails{
TargetResourcePrivateIpAddress: common.String(targetIP),
TargetResourcePort: common.Int(port),
},
KeyDetails: &bastion.PublicKeyDetails{PublicKeyContent: &pubKey},
DisplayName: common.String(displayName),
SessionTtlInSeconds: common.Int(defaultTTL),
},
}
crResp, err := s.bastionClient.CreateSession(ctx, createReq)
if err != nil {
return "", fmt.Errorf("creating bastion session: %w", err)
}
sessionID := *crResp.Id
// 3) Wait for ACTIVE
if err := s.waitForSessionActive(ctx, sessionID); err != nil {
return "", err
}
return sessionID, nil
}
// EnsureManagedSSHSession finds or creates a Managed SSH bastion session for the given target instance and returns the session ID.
func (s *Service) EnsureManagedSSHSession(ctx context.Context, bastionID, targetInstanceID, targetIP, osUser string, port int, publicKeyPath string, ttlSeconds int) (string, error) {
if ttlSeconds <= 0 {
ttlSeconds = defaultTTL
}
pubKey, err := readPublicKey(publicKeyPath)
if err != nil {
return "", err
}
//-------------------------Try to reuse an ACTIVE matching Managed SSH session--------------------------------------
items, err := s.listActiveSessions(ctx, bastionID)
if err != nil {
return "", err
}
for _, item := range items {
if trd, ok := item.TargetResourceDetails.(bastion.ManagedSshSessionTargetResourceDetails); ok {
if trd.TargetResourceId != nil && trd.TargetResourcePrivateIpAddress != nil && trd.TargetResourcePort != nil && trd.TargetResourceOperatingSystemUserName != nil &&
*trd.TargetResourceId == targetInstanceID && *trd.TargetResourcePrivateIpAddress == targetIP && *trd.TargetResourcePort == port && *trd.TargetResourceOperatingSystemUserName == osUser {
getResp, err := s.bastionClient.GetSession(ctx, bastion.GetSessionRequest{SessionId: item.Id})
if err != nil {
return "", fmt.Errorf("getting bastion session: %w", err)
}
if getResp.KeyDetails != nil && getResp.KeyDetails.PublicKeyContent != nil && *getResp.KeyDetails.PublicKeyContent == pubKey {
return *item.Id, nil
}
}
}
}
//-----------------------------------------Create a new Managed SSH session-----------------------------------------
baseName := fmt.Sprintf("ocloud-%s-%d-%d", strings.ReplaceAll(targetIP, ".", "-"), port, time.Now().Unix())
displayName := sanitizeDisplayName(baseName)
createReq := bastion.CreateSessionRequest{
CreateSessionDetails: bastion.CreateSessionDetails{
BastionId: common.String(bastionID),
TargetResourceDetails: bastion.CreateManagedSshSessionTargetResourceDetails{
TargetResourceId: common.String(targetInstanceID),
TargetResourceOperatingSystemUserName: common.String(osUser),
TargetResourcePort: common.Int(port),
TargetResourcePrivateIpAddress: common.String(targetIP),
},
KeyDetails: &bastion.PublicKeyDetails{PublicKeyContent: &pubKey},
DisplayName: common.String(displayName),
SessionTtlInSeconds: common.Int(ttlSeconds),
},
}
crResp, err := s.bastionClient.CreateSession(ctx, createReq)
if err != nil {
return "", fmt.Errorf("creating bastion session: %w", err)
}
sessionID := *crResp.Id
//------------------------------------------------Wait for ACTIVE---------------------------------------------------
if err := s.waitForSessionActive(ctx, sessionID); err != nil {
return "", err
}
return sessionID, nil
}
// BuildManagedSSHCommand constructs the SSH command that uses ProxyCommand with the bastion Managed SSH session.
// It opens only a direct-tcpip channel on the bastion (accepted), while authenticating to bastion with the session OCID.
// The outer SSH connects to the target instance as targetUser@targetIP.
func BuildManagedSSHCommand(privateKeyPath, sessionID, region, targetIP, targetUser string) string {
realm := "oraclecloud"
parts := strings.Split(sessionID, ".")
if len(parts) > 2 && strings.Contains(parts[2], "2") {
realm = "oraclegovcloud"
}
proxy := fmt.Sprintf("ssh -i %s -W %%h:%%p -p 22 %s@host.bastion.%s.oci.%s.com", privateKeyPath, sessionID, region, realm)
return fmt.Sprintf("ssh -i %s -o ProxyCommand=\"%s\" -p 22 %s@%s", privateKeyPath, proxy, targetUser, targetIP)
}
// BuildPortForwardArgs constructs SSH command arguments for establishing a secure port-forwarding tunnel.
// It handles path expansion for the private key, determines the correct realm domain, and formats connection options.
func BuildPortForwardArgs(privateKeyPath, sessionID, region, targetIP string, localPort, remotePort int) ([]string, error) {
// Expand "~" if present
key, err := expandTilde(privateKeyPath)
if err != nil {
return nil, fmt.Errorf("expand key path: %w", err)
}
// Decide realm domain based on OCID (oc2/oc3 => gov)
realmDomain := "oraclecloud.com"
if strings.Contains(sessionID, ".oc2.") || strings.Contains(sessionID, ".oc3.") {
realmDomain = "oraclegovcloud.com"
}
bastionUser := fmt.Sprintf("%s@host.bastion.%s.oci.%s", sessionID, region, realmDomain)
args := []string{
"-i", key,
"-o", "StrictHostKeyChecking=accept-new",
// keepalives help the tunnel auto-detect dead links
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-N",
"-L", fmt.Sprintf("%d:%s:%d", localPort, targetIP, remotePort),
"-p", "22",
bastionUser,
}
return args, nil
}
// expandTilde resolves paths beginning with "~" to the current user's home directory, returning the expanded path or an error.
func expandTilde(p string) (string, error) {
if strings.HasPrefix(p, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, strings.TrimPrefix(p, "~")), nil
}
return p, nil
}
// SpawnDetached starts ssh in the background, detaches from your process, and returns its PID.
func SpawnDetached(args []string, logfile string) (int, error) {
sshPath, err := exec.LookPath("ssh")
if err != nil {
return 0, fmt.Errorf("ssh not found in PATH: %w", err)
}
// Ensure log dir exists
if err := os.MkdirAll(filepath.Dir(logfile), 0o755); err != nil {
return 0, fmt.Errorf("create log dir: %w", err)
}
f, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return 0, fmt.Errorf("open log file: %w", err)
}
defer f.Close()
cmd := exec.Command(sshPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // detach from our session/TTY
cmd.Stdout = f
cmd.Stderr = f
cmd.Stdin = nil
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("start ssh: %w", err)
}
pid := cmd.Process.Pid
_ = cmd.Process.Release()
return pid, nil
}
// WaitForListen wait until the localPort is listening (nice UX).
// Helps to avoid "connection refused" errors.
func WaitForListen(localPort int, timeout time.Duration) error {
addr := fmt.Sprintf("127.0.0.1:%d", localPort)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
c, err := net.DialTimeout("tcp", addr, 400*time.Millisecond)
if err == nil {
_ = c.Close()
return nil
}
time.Sleep(250 * time.Millisecond)
}
return fmt.Errorf("tunnel not up on %s after %s", addr, timeout)
}
package compartment
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci/identity/compartment"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// GetCompartments retrieves and displays a paginated list of compartments.
func GetCompartments(appCtx *app.ApplicationContext, useJSON bool, limit, page int, ocid string) error {
ctx := context.Background()
compartmentAdapter := compartment.NewCompartmentAdapter(appCtx.IdentityClient, ocid)
service := NewService(compartmentAdapter, appCtx.Logger, ocid)
compartments, totalCount, nextPageToken, err := service.FetchPaginateCompartments(ctx, limit, page)
if err != nil {
return fmt.Errorf("listing compartments: %w", err)
}
return PrintCompartmentsTable(compartments, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON)
}
package compartment
import (
"context"
"errors"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci/identity/compartment"
"github.com/cnopslabs/ocloud/internal/tui"
)
func ListCompartments(appCtx *app.ApplicationContext, ocid string, useJSON bool) error {
ctx := context.Background()
compartmentAdapter := compartment.NewCompartmentAdapter(appCtx.IdentityClient, ocid)
service := NewService(compartmentAdapter, appCtx.Logger, ocid)
compartments, err := service.compartmentRepo.ListCompartments(ctx, ocid)
if err != nil {
return fmt.Errorf("getting compartment: %w", err)
}
//TUI
model := compartment.NewPoliciesListModel(compartments)
id, err := tui.Run(model)
if err != nil {
if errors.Is(err, tui.ErrCancelled) {
return nil
}
return fmt.Errorf("selecting compartment: %w", err)
}
c, err := service.compartmentRepo.GetCompartment(ctx, id)
if err != nil {
return fmt.Errorf("getting compartment: %w", err)
}
return PrintCompartmentInfo(c, appCtx, useJSON)
}
package compartment
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// PrintCompartmentsTable displays a table or JSON representation of compartments based on the provided configuration.
// It optionally includes pagination details and writes to the application's standard output or as structured JSON.
func PrintCompartmentsTable(compartments []Compartment, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
if len(compartments) == 0 && pagination == nil {
return p.MarshalToJSON(struct{}{})
}
return util.MarshalDataToJSONResponse[Compartment](p, compartments, pagination)
}
if util.ValidateAndReportEmpty(compartments, pagination, appCtx.Stdout) {
return nil
}
// Define table headers
headers := []string{"Name", "ID"}
rows := make([][]string, len(compartments))
for i, c := range compartments {
rows[i] = []string{
c.DisplayName,
c.OCID,
}
}
// Print the table
title := util.FormatColoredTitle(appCtx, "Compartments")
p.PrintTable(title, headers, rows)
util.LogPaginationInfo(pagination, appCtx)
return nil
}
// PrintCompartmentsInfo displays information about a list of compartments in either JSON or formatted table output.
// It accepts a slice of Compartment, application context, pagination info, and a boolean to indicate JSON output.
// It adjusts pagination details, validates empty compartments, and logs pagination info post-output.
func PrintCompartmentsInfo(compartments []Compartment, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
if len(compartments) == 0 && pagination == nil {
return p.MarshalToJSON(struct{}{})
}
return util.MarshalDataToJSONResponse[Compartment](p, compartments, pagination)
}
if util.ValidateAndReportEmpty(compartments, pagination, appCtx.Stdout) {
return nil
}
// Print each Compartment as a separate key-value.
for _, compartment := range compartments {
compartmentData := map[string]string{
"Name": compartment.DisplayName,
"ID": compartment.OCID,
"Description": compartment.Description,
}
orderedKeys := []string{
"Name", "ID", "Description",
}
title := util.FormatColoredTitle(appCtx, compartment.DisplayName)
p.PrintKeyValues(title, compartmentData, orderedKeys)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
// PrintCompartmentInfo displays a detailed view of a compartment.
func PrintCompartmentInfo(compartment *Compartment, appCtx *app.ApplicationContext, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if useJSON {
return p.MarshalToJSON(compartment)
}
compartmentData := map[string]string{
"Name": compartment.DisplayName,
"ID": compartment.OCID,
"Description": compartment.Description,
}
orderedKeys := []string{
"Name", "ID", "Description",
}
title := util.FormatColoredTitle(appCtx, compartment.DisplayName)
p.PrintKeyValues(title, compartmentData, orderedKeys)
return nil
}
package compartment
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci/identity/compartment"
)
// SearchCompartments searches and displays compartments matching a given name pattern.
func SearchCompartments(appCtx *app.ApplicationContext, namePattern string, useJSON bool, ocid string) error {
ctx := context.Background()
compartmentAdapter := compartment.NewCompartmentAdapter(appCtx.IdentityClient, ocid)
// Create the application service, injecting the adapter.
service := NewService(compartmentAdapter, appCtx.Logger, ocid)
matchedCompartments, err := service.FuzzySearch(ctx, namePattern)
if err != nil {
return fmt.Errorf("finding matched compartments: %w", err)
}
err = PrintCompartmentsInfo(matchedCompartments, appCtx, nil, useJSON)
if err != nil {
return fmt.Errorf("printing matched compartments: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Info, "Found matching compartments", "search", namePattern, "matched", len(matchedCompartments))
return nil
}
package compartment
import (
"strings"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// SearchableCompartment adapts identity.Compartment to the search.Indexable interface.
type SearchableCompartment struct {
Compartment
}
// ToIndexable converts a Compartment to a map of searchable fields.
func (s SearchableCompartment) ToIndexable() map[string]any {
tagsKV, _ := util.FlattenTags(s.FreeformTags, s.DefinedTags)
tagsVal, _ := util.ExtractTagValues(s.FreeformTags, s.DefinedTags)
return map[string]any{
"Name": strings.ToLower(s.DisplayName),
"Description": strings.ToLower(s.Description),
"OCID": strings.ToLower(s.OCID),
"State": strings.ToLower(s.LifecycleState),
"TagsKV": strings.ToLower(tagsKV),
"TagsVal": strings.ToLower(tagsVal),
}
}
// GetSearchableFields returns the fields to index for compartments.
func GetSearchableFields() []string {
return []string{"Name", "Description", "OCID", "State", "TagsKV", "TagsVal"}
}
// GetBoostedFields returns fields to boost during the search for better relevance.
func GetBoostedFields() []string {
return []string{"Name", "OCID", "TagsKV", "TagsVal"}
}
// ToSearchableCompartments converts a slice of Compartment to a slice of search.Indexable.
func ToSearchableCompartments(items []Compartment) []search.Indexable {
out := make([]search.Indexable, len(items))
for i, c := range items {
out[i] = SearchableCompartment{c}
}
return out
}
package compartment
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/domain/identity"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/go-logr/logr"
)
// Service is the application-layer service for compartment operations.
// It depends on the domain repository for data access.
type Service struct {
compartmentRepo identity.CompartmentRepository
logger logr.Logger
compartmentID string
}
// NewService initializes and returns a new Service instance.
// It injects the domain repository, decoupling the service from the infrastructure layer.
func NewService(repo identity.CompartmentRepository, logger logr.Logger, ocid string) *Service {
return &Service{
compartmentRepo: repo,
logger: logger,
compartmentID: ocid,
}
}
// FetchPaginateCompartments fetches a page of compartments from the repository.
func (s *Service) FetchPaginateCompartments(ctx context.Context, limit, pageNum int) ([]Compartment, int, string, error) {
s.logger.V(logger.Debug).Info("listing compartments", "limit", limit, "pageNum", pageNum)
allCompartments, err := s.compartmentRepo.ListCompartments(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing compartments from repository: %w", err)
}
pagedResults, totalCount, nextPageToken := util.PaginateSlice(allCompartments, limit, pageNum)
s.logger.Info("completed compartment listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// FuzzySearch performs a fuzzy search for compartments based on the provided searchPattern.
func (s *Service) FuzzySearch(ctx context.Context, searchPattern string) ([]Compartment, error) {
s.logger.V(logger.Debug).Info("finding compartments with fuzzy search", "pattern", searchPattern)
allCompartments, err := s.compartmentRepo.ListCompartments(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("fetching all compartments for search: %w", err)
}
// Build the search index using the common search package and the compartment searcher adapter.
indexables := ToSearchableCompartments(allCompartments)
idxMapping := search.NewIndexMapping(GetSearchableFields())
idx, err := search.BuildIndex(indexables, idxMapping)
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
logger.Logger.V(logger.Debug).Info("Search index built successfully.", "numEntries", len(allCompartments))
matchedIdxs, err := search.FuzzySearch(idx, searchPattern, GetSearchableFields(), GetBoostedFields())
if err != nil {
return nil, fmt.Errorf("performing fuzzy search: %w", err)
}
logger.Logger.V(logger.Debug).Info("Fuzzy search completed.", "numMatches", len(matchedIdxs))
results := make([]Compartment, 0, len(matchedIdxs))
for _, i := range matchedIdxs {
if i >= 0 && i < len(allCompartments) {
results = append(results, allCompartments[i])
}
}
return results, nil
}
package policy
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci/identity/policy"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// GetPolicies retrieves and displays the policies for a given application context, supporting pagination and JSON output format.
func GetPolicies(appCtx *app.ApplicationContext, useJSON bool, limit, page int, ocid string) error {
ctx := context.Background()
policyAdapter := policy.NewAdapter(appCtx.IdentityClient)
service := NewService(policyAdapter, appCtx.Logger, ocid)
policies, totalCount, nextPageToken, err := service.FetchPaginatedPolies(ctx, limit, page)
if err != nil {
return fmt.Errorf("getting policies: %w", err)
}
return PrintPolicyInfo(policies, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON)
}
package policy
import (
"context"
"errors"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci/identity/policy"
"github.com/cnopslabs/ocloud/internal/tui"
)
// ListPolicies lists all policies in the specified compartment and prints their details in the specified format.
// It utilizes the application context for service initialization and handles output formatting via JSON or plain text.
func ListPolicies(appCtx *app.ApplicationContext, useJSON bool, ocid string) error {
ctx := context.Background()
policyAdapter := policy.NewAdapter(appCtx.IdentityClient)
service := NewService(policyAdapter, appCtx.Logger, ocid)
policies, err := service.ListPolicies(ctx)
if err != nil {
return fmt.Errorf("listing policies: %w", err)
}
//TUI
model := policy.NewPoliciesListModel(policies)
id, err := tui.Run(model)
if err != nil {
if errors.Is(err, tui.ErrCancelled) {
return nil
}
return fmt.Errorf("selecting policy: %w", err)
}
p, err := service.policyRepo.GetPolicy(ctx, id)
if err != nil {
return fmt.Errorf("getting policy: %w", err)
}
return PrintPolicyTable(p, appCtx, useJSON)
}
package policy
import (
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/domain/identity"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// PrintPolicyInfo prints the details of policies to the standard output or in JSON format.
// If pagination info is provided, it adjusts and logs it.
func PrintPolicyInfo(policies []identity.Policy, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
// If JSON output is requested, use the printer to marshal the response.
if useJSON {
return util.MarshalDataToJSONResponse[identity.Policy](p, policies, pagination)
}
if util.ValidateAndReportEmpty(policies, pagination, appCtx.Stdout) {
return nil
}
// Print each policy as a separate key-value table with a colored title,
for _, policy := range policies {
policyData := map[string]string{
"Name": policy.Name,
"ID": policy.ID,
"Description": policy.Description,
}
// Define ordered keys
orderedKeys := []string{
"Name", "ID", "Description",
}
// Create the colored title using components from the app context
title := util.FormatColoredTitle(appCtx, policy.Name)
// Call the printer method to render the key-value from the app context.
p.PrintKeyValues(title, policyData, orderedKeys)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
// PrintPolicyTable prints a detailed view of a policy.
func PrintPolicyTable(policy *identity.Policy, appCtx *app.ApplicationContext, useJSON bool) error {
p := printer.New(appCtx.Stdout)
// If JSON output is requested, use the printer to marshal the response.
if useJSON {
return p.MarshalToJSON(policy)
}
policyData := map[string]string{
"Name": policy.Name,
"ID": policy.ID,
"Description": policy.Description,
"TimeCreated": policy.TimeCreated.Format("2006-01-02"),
}
orderedKeys := []string{
"Name", "ID", "Description", "TimeCreated",
}
title := util.FormatColoredTitle(appCtx, policy.Name)
p.PrintKeyValues(title, policyData, orderedKeys)
return nil
}
package policy
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci/identity/policy"
)
func SearchPolicies(appCtx *app.ApplicationContext, search string, useJSON bool, ocid string) error {
ctx := context.Background()
policyAdapter := policy.NewAdapter(appCtx.IdentityClient)
// Create the application service, injecting the adapter.
service := NewService(policyAdapter, appCtx.Logger, ocid)
matchedPolicies, err := service.FuzzySearch(ctx, search)
if err != nil {
return fmt.Errorf("finding matched policies: %w", err)
}
err = PrintPolicyInfo(matchedPolicies, appCtx, nil, useJSON)
if err != nil {
return fmt.Errorf("printing matched policies: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Info, "Found matching policies", "search", search, "matched", len(matchedPolicies))
return nil
}
package policy
import (
"strings"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// SearchablePolicy adapts identity.Policy to the search.Indexable interface.
type SearchablePolicy struct {
Policy
}
// ToIndexable converts a Policy to a map of searchable fields.
func (s SearchablePolicy) ToIndexable() map[string]any {
// Flatten tags in two flavors: key:value form and just values
tagsKV, _ := util.FlattenTags(s.FreeformTags, s.DefinedTags)
tagsVal, _ := util.ExtractTagValues(s.FreeformTags, s.DefinedTags)
// Join statements into a single string
stmt := strings.ToLower(strings.Join(s.Statement, " "))
return map[string]any{
"Name": strings.ToLower(s.Name),
"Description": strings.ToLower(s.Description),
"OCID": strings.ToLower(s.ID),
"Statements": stmt,
"TagsKV": strings.ToLower(tagsKV),
"TagsVal": strings.ToLower(tagsVal),
}
}
// GetSearchableFields returns the fields to index for policies.
func GetSearchableFields() []string {
return []string{"Name", "Description", "OCID", "Statements", "TagsKV", "TagsVal"}
}
// GetBoostedFields returns fields to boost during the search for better relevance.
func GetBoostedFields() []string {
return []string{"Name", "OCID", "Statements", "TagsKV", "TagsVal"}
}
// ToSearchablePolicies converts a slice of Policy to a slice of search.Indexable.
func ToSearchablePolicies(items []Policy) []search.Indexable {
out := make([]search.Indexable, len(items))
for i, p := range items {
out[i] = SearchablePolicy{p}
}
return out
}
package policy
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/domain/identity"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/go-logr/logr"
)
type Service struct {
policyRepo identity.PolicyRepository
logger logr.Logger
CompartmentID string
}
// NewService initializes a new Service instance with the provided application context.
func NewService(repo identity.PolicyRepository, logger logr.Logger, ocid string) *Service {
return &Service{
policyRepo: repo,
logger: logger,
CompartmentID: ocid,
}
}
func (s *Service) FetchPaginatedPolies(ctx context.Context, limit, pageNum int) ([]Policy, int, string, error) {
s.logger.V(logger.Debug).Info("listing policies", "limit", limit, "pageNum", pageNum)
allPolicies, err := s.policyRepo.ListPolicies(ctx, s.CompartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing policies from repository: %w", err)
}
pagedResults, totalCount, nextPageToken := util.PaginateSlice(allPolicies, limit, pageNum)
s.logger.V(logger.Debug).Info("completed policy listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
func (s *Service) ListPolicies(ctx context.Context) ([]identity.Policy, error) {
s.logger.V(logger.Debug).Info("listing policies")
policies, err := s.policyRepo.ListPolicies(ctx, s.CompartmentID)
if err != nil {
return nil, fmt.Errorf("listing policies from repository: %w", err)
}
return policies, nil
}
// FuzzySearch performs a fuzzy search for policies based on the provided search pattern and returns matching policies.
func (s *Service) FuzzySearch(ctx context.Context, searchPattern string) ([]identity.Policy, error) {
s.logger.V(logger.Debug).Info("finding policies with fuzzy search", "pattern", searchPattern)
allPolicies, err := s.policyRepo.ListPolicies(ctx, s.CompartmentID)
if err != nil {
return nil, fmt.Errorf("fetching all policies for search: %w", err)
}
// Build the search index using the common search package and the policy searcher adapter.
indexables := ToSearchablePolicies(allPolicies)
idxMapping := search.NewIndexMapping(GetSearchableFields())
idx, err := search.BuildIndex(indexables, idxMapping)
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
s.logger.V(logger.Debug).Info("search index built successfully", "numEntries", len(allPolicies))
matchedIdxs, err := search.FuzzySearch(idx, searchPattern, GetSearchableFields(), GetBoostedFields())
if err != nil {
return nil, fmt.Errorf("performing fuzzy search: %w", err)
}
s.logger.V(logger.Debug).Info("fuzzy search completed", "numMatches", len(matchedIdxs))
results := make([]identity.Policy, 0, len(matchedIdxs))
for _, i := range matchedIdxs {
if i >= 0 && i < len(allPolicies) {
results = append(results, allPolicies[i])
}
}
return results, nil
}
package gateway
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
)
func GetGateway(appCtx *app.ApplicationContext, vcnName string, useJSON bool) error {
ctx := context.Background()
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
fmt.Println(networkClient)
fmt.Println(ctx)
return nil
}
package loadbalancer
import (
"context"
"fmt"
"time"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
oci "github.com/cnopslabs/ocloud/internal/oci"
ocilb "github.com/cnopslabs/ocloud/internal/oci/network/loadbalancer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// GetLoadBalancers retrieves load balancers and displays a paginated list.
func GetLoadBalancers(appCtx *app.ApplicationContext, useJSON bool, limit, page int, showAll bool) error {
start := time.Now()
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.START", "limit", limit, "page", page, "json", useJSON, "all", showAll)
lbClient, err := oci.NewLoadBalancerClient(appCtx.Provider)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "client_init_lb", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("creating load balancer client: %w", err)
}
nwClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "client_init_network", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("creating network client: %w", err)
}
certsClient, err := oci.NewCertificatesManagementClient(appCtx.Provider)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "client_init_certs", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("creating certificates management client: %w", err)
}
adapter := ocilb.NewAdapter(lbClient, nwClient, certsClient)
service := NewService(adapter, appCtx)
ctx := context.Background()
lbs, totalCount, nextPageToken, err := service.FetchPaginatedLoadBalancers(ctx, limit, page, showAll)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "fetch", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("listing load balancers: %w", err)
}
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.FINISH", "count", len(lbs), "total_count", totalCount, "next_page", nextPageToken, "duration_ms", time.Since(start).Seconds())
return PrintLoadBalancersInfo(lbs, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON, showAll)
}
package loadbalancer
import (
"context"
"errors"
"fmt"
"time"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
ocilb "github.com/cnopslabs/ocloud/internal/oci/network/loadbalancer"
"github.com/cnopslabs/ocloud/internal/tui"
)
func ListLoadBalancers(appCtx *app.ApplicationContext, useJSON, showAll bool) error {
ctx := context.Background()
start := time.Now()
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.START", "limit", "page", "json", useJSON, "all")
lbClient, err := oci.NewLoadBalancerClient(appCtx.Provider)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "client_init_lb", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("creating load balancer client: %w", err)
}
nwClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "client_init_network", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("creating network client: %w", err)
}
certsClient, err := oci.NewCertificatesManagementClient(appCtx.Provider)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "client_init_certs", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("creating certificates management client: %w", err)
}
adapter := ocilb.NewAdapter(lbClient, nwClient, certsClient)
service := NewService(adapter, appCtx)
allLoadBalancers, err := service.ListLoadBalancers(ctx)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "list", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("listing load balancers: %w", err)
}
//TUI
model := ocilb.NewLoadBalancerListModel(allLoadBalancers)
id, err := tui.Run(model)
if err != nil {
if errors.Is(err, tui.ErrCancelled) {
return nil
}
return fmt.Errorf("selecting database: %w", err)
}
lb, err := service.GetEnrichedLoadBalancer(ctx, id)
if err != nil {
return fmt.Errorf("getting load balancer: %w", err)
}
return PrintLoadBalancerInfo(lb, appCtx, useJSON, showAll)
}
package loadbalancer
import (
"fmt"
"sort"
"strings"
"github.com/cnopslabs/ocloud/internal/app"
network "github.com/cnopslabs/ocloud/internal/domain/network/loadbalancer"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
func PrintLoadBalancerInfo(lb *network.LoadBalancer, appCtx *app.ApplicationContext, useJSON bool, showAll bool) error {
p := printer.New(appCtx.Stdout)
if useJSON {
return p.MarshalToJSON(lb)
}
title := util.FormatColoredTitle(appCtx, lb.Name)
if showAll {
printAll(p, title, lb)
} else {
printDefault(p, title, lb)
}
return nil
}
func printDefault(p *printer.Printer, title string, lb *network.LoadBalancer) {
created := ""
if lb.Created != nil {
created = lb.Created.Format("2006-01-02")
}
rp := strings.Join(lb.RoutingPolicies, ", ")
if rp == "" {
rp = "-"
}
useSSL := "No"
if lb.UseSSL {
useSSL = "Yes"
}
vcn := "-"
if lb.VcnName != "" {
vcn = lb.VcnName
} else if lb.VcnID != "" {
vcn = lb.VcnID
}
data := map[string]string{
"Name": lb.Name,
"Shape": lb.Shape,
"Created": created,
"IP Addresses": strings.Join(lb.IPAddresses, ", "),
"State": lb.State,
"VCN Name": vcn,
"Listeners": formatListeners(lb.Listeners, false),
"Backend Health": formatBackendHealth(lb.BackendHealth),
"Routing Policy": rp,
"Use SSL": useSSL,
}
order := []string{"Name", "Shape", "Created", "IP Addresses", "State", "VCN Name", "Listeners", "Backend Health", "Routing Policy", "Use SSL"}
p.PrintKeyValues(title, data, order)
}
func printAll(p *printer.Printer, title string, lb *network.LoadBalancer) {
created := ""
if lb.Created != nil {
created = lb.Created.Format("2006-01-02")
}
data := map[string]string{
"Name": lb.Name,
"Shape": lb.Shape,
"Created": created,
"IP Addresses": strings.Join(lb.IPAddresses, ", "),
"State": lb.State,
"OCID": lb.OCID,
"Type": lb.Type,
"VCN Name": func() string {
if lb.VcnName != "" {
return lb.VcnName
}
if lb.VcnID != "" {
return lb.VcnID
}
return "-"
}(),
"Subnets": strings.Join(lb.Subnets, ", "),
"NSGs": strings.Join(lb.NSGs, ", "),
"Listeners": formatListeners(lb.Listeners, true),
"Backend Health": formatBackendHealth(lb.BackendHealth),
"Routing Policy": func() string {
if len(lb.RoutingPolicies) == 0 {
return "-"
}
return strings.Join(lb.RoutingPolicies, ", ")
}(),
"Hostnames": func() string {
if len(lb.Hostnames) == 0 {
return "-"
}
return formatHostnames(lb.Hostnames)
}(),
"Use SSL": func() string {
if lb.UseSSL {
return "Yes"
}
return "No"
}(),
"SSL Certificates": formatCertificates(lb.SSLCertificates),
}
order := []string{"Name", "Shape", "Created", "IP Addresses", "State", "OCID", "Type", "VCN Name", "Subnets", "NSGs", "Listeners", "Backend Health", "Routing Policy", "Hostnames", "Use SSL", "SSL Certificates"}
// Include backend set summaries as additional key-value entries (no separate tables)
// To avoid truncating long backend set names in the Key column, we print a short key
// like "Backend Set 1" and include the full backend set name as the first line of the value.
// Also, sort backend set names for a stable output order.
bsNames := make([]string, 0, len(lb.BackendSets))
for name := range lb.BackendSets {
bsNames = append(bsNames, name)
}
sort.Strings(bsNames)
for i, name := range bsNames {
bs := lb.BackendSets[name]
key := fmt.Sprintf("Backend Set %d", i+1)
val := fmt.Sprintf("%s\nPolicy: %s, HC: %s", name, bs.Policy, bs.Health)
if len(bs.Backends) > 0 {
parts := make([]string, 0, len(bs.Backends))
for _, b := range bs.Backends {
parts = append(parts, fmt.Sprintf("%s:%d (%s)", b.Name, b.Port, b.Status))
}
val = val + "\nBackends: " + strings.Join(parts, ", ")
}
data[key] = val
order = append(order, key)
}
p.PrintKeyValuesNoTruncate(title, data, order)
}
func formatListeners(listeners map[string]string, includeNames bool) string {
var parts []string
if includeNames {
for name, backend := range listeners {
normalized := normalizeListenerValue(backend)
parts = append(parts, fmt.Sprintf("%s → %s", name, normalized))
}
} else {
// Default view: omit listener names, show only protocol:port → backendset
for _, backend := range listeners {
normalized := normalizeListenerValue(backend)
parts = append(parts, normalized)
}
}
return strings.Join(parts, "\n")
}
// normalizeListenerValue ensures we display correct scheme labels for common ports
// and previously mislabeled values coming from upstream mapping. It operates on
// strings like "http:8443 → backendset" and fixes them to "https:8443 → backendset".
func normalizeListenerValue(s string) string {
// Split on the arrow to isolate the left side (proto:port)
leftRight := strings.SplitN(s, " → ", 2)
left := leftRight[0]
right := ""
if len(leftRight) == 2 {
right = leftRight[1]
}
// Expect left to be proto:port
lp := strings.SplitN(left, ":", 2)
if len(lp) != 2 {
return s
}
proto := strings.ToLower(strings.TrimSpace(lp[0]))
portStr := strings.TrimSpace(lp[1])
// Extract numeric port (strip anything after space just in case)
if i := strings.IndexByte(portStr, ' '); i >= 0 {
portStr = portStr[:i]
}
// Force schemes for common ports
switch portStr {
case "443", "8443":
proto = "https"
case "80":
proto = "http"
}
leftFixed := fmt.Sprintf("%s:%s", proto, portStr)
if right != "" {
return leftFixed + " → " + right
}
return leftFixed
}
func formatBackendHealth(health map[string]string) string {
if len(health) == 0 {
return ""
}
keys := make([]string, 0, len(health))
for k := range health {
keys = append(keys, k)
}
sort.Strings(keys)
const maxLines = 6
var parts []string
limit := len(keys)
if limit > maxLines {
limit = maxLines
}
for i := 0; i < limit; i++ {
k := keys[i]
parts = append(parts, fmt.Sprintf("%s: %s", k, health[k]))
}
if len(keys) > maxLines {
parts = append(parts, fmt.Sprintf("… (+%d more)", len(keys)-maxLines))
}
return strings.Join(parts, "\n")
}
// PrintLoadBalancersInfo displays a list of load balancers in a table or JSON with pagination support.
func PrintLoadBalancersInfo(lbs []network.LoadBalancer, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool, showAll bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
return util.MarshalDataToJSONResponse(p, lbs, pagination)
}
if util.ValidateAndReportEmpty(lbs, pagination, appCtx.Stdout) {
return nil
}
for i := range lbs {
lb := lbs[i]
if err := PrintLoadBalancerInfo(&lb, appCtx, false, showAll); err != nil {
return err
}
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
func formatCertificates(certs []string) string {
if len(certs) == 0 {
return ""
}
return strings.Join(certs, "\n")
}
func formatHostnames(hosts []string) string {
if len(hosts) == 0 {
return ""
}
return strings.Join(hosts, "\n")
}
package loadbalancer
import (
"context"
"fmt"
"time"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
ocilb "github.com/cnopslabs/ocloud/internal/oci/network/loadbalancer"
)
// SearchLoadBalancer searches for matching load balancers based on a fuzzy search string and displays their details.
// appCtx provides context and clients for API calls.
// search specifies the fuzzy search string to filter load balancers.
// useJSON determines if the output should be in JSON format.
// showAll includes all details about load balancers in the output if set to true.
// Returns an error if there is a failure in the process.
func SearchLoadBalancer(appCtx *app.ApplicationContext, search string, useJSON, showAll bool) error {
ctx := context.Background()
start := time.Now()
lbClient, err := oci.NewLoadBalancerClient(appCtx.Provider)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "client_init_lb", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("creating load balancer client: %w", err)
}
nwClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "client_init_network", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("creating network client: %w", err)
}
certsClient, err := oci.NewCertificatesManagementClient(appCtx.Provider)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "client_init_certs", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("creating certificates management client: %w", err)
}
adapter := ocilb.NewAdapter(lbClient, nwClient, certsClient)
service := NewService(adapter, appCtx)
matchedLoadBalancers, err := service.FuzzySearch(ctx, search)
if err != nil {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "lb.service.get.error", "stage", "list", "error", err.Error(), "duration_ms", time.Since(start).Milliseconds())
return fmt.Errorf("listing load balancers: %w", err)
}
err = PrintLoadBalancersInfo(matchedLoadBalancers, appCtx, nil, useJSON, showAll)
if err != nil {
return fmt.Errorf("printing load balancers: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Info, "Found matching load balancers", "search", search, "matched", len(matchedLoadBalancers))
return nil
}
package loadbalancer
import (
"strings"
"github.com/cnopslabs/ocloud/internal/services/search"
)
// SearchableLoadBalancer adapts LoadBalancer to the search.Indexable interface.
type SearchableLoadBalancer struct {
LoadBalancer
}
// ToIndexable converts a LoadBalancer to a map of searchable fields.
func (s SearchableLoadBalancer) ToIndexable() map[string]any {
join := func(ss []string) string {
if len(ss) == 0 {
return ""
}
out := make([]string, 0, len(ss))
for _, v := range ss {
v = strings.TrimSpace(v)
if v == "" {
continue
}
out = append(out, strings.ToLower(v))
}
return strings.Join(out, " ")
}
return map[string]any{
"Name": strings.ToLower(s.Name),
"OCID": strings.ToLower(s.OCID),
"Type": strings.ToLower(s.Type),
"State": strings.ToLower(s.State),
"VcnName": strings.ToLower(s.VcnName),
"Shape": strings.ToLower(s.Shape),
"IPAddresses": join(s.IPAddresses),
"Hostnames": join(s.Hostnames),
"SSLCertificates": join(s.SSLCertificates),
"Subnets": join(s.Subnets),
}
}
// GetSearchableFields returns the fields to index for load balancers.
func GetSearchableFields() []string {
return []string{"Name", "OCID", "Type", "State", "VcnName", "Shape", "IPAddresses", "Hostnames", "SSLCertificates", "Subnets"}
}
// GetBoostedFields returns fields to boost during the search for better relevance.
func GetBoostedFields() []string {
return []string{"Name", "OCID", "Hostnames"}
}
// ToSearchableLoadBalancers converts a slice of LoadBalancer to a slice of search.Indexable.
func ToSearchableLoadBalancers(items []LoadBalancer) []search.Indexable {
out := make([]search.Indexable, len(items))
for i, it := range items {
out[i] = SearchableLoadBalancer{it}
}
return out
}
package loadbalancer
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
domain "github.com/cnopslabs/ocloud/internal/domain/network/loadbalancer"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/go-logr/logr"
)
// Service provides operations for managing load balancers.
type Service struct {
repo domain.LoadBalancerRepository
logger logr.Logger
compartmentID string
}
// NewService creates a new load balancer service.
func NewService(repo domain.LoadBalancerRepository, appCtx *app.ApplicationContext) *Service {
return &Service{
repo: repo,
logger: appCtx.Logger,
compartmentID: appCtx.CompartmentID,
}
}
// GetLoadBalancer retrieves a load balancer by its OCID.
func (s *Service) GetLoadBalancer(ctx context.Context, ocid string) (*LoadBalancer, error) {
s.logger.V(logger.Debug).Info("getting load balancer", "ocid", ocid)
lb, err := s.repo.GetLoadBalancer(ctx, ocid)
if err != nil {
return nil, fmt.Errorf("failed to get load balancer: %w", err)
}
return lb, nil
}
// ListLoadBalancers lists all load balancers in the configured compartment.
func (s *Service) ListLoadBalancers(ctx context.Context) ([]LoadBalancer, error) {
s.logger.V(logger.Debug).Info("listing load balancers", "compartmentID", s.compartmentID)
lbs, err := s.repo.ListLoadBalancers(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("failed to list load balancers: %w", err)
}
return lbs, nil
}
// FetchPaginatedLoadBalancers returns a page of load balancers and pagination metadata.
// If showAll is true, it uses the enriched model; otherwise, it uses the basic model for performance.
func (s *Service) FetchPaginatedLoadBalancers(ctx context.Context, limit, pageNum int, showAll bool) ([]LoadBalancer, int, string, error) {
s.logger.V(logger.Debug).Info("fetching paginated load balancers", "limit", limit, "page", pageNum, "showAll", showAll)
var (
all []LoadBalancer
err error
)
if showAll {
all, err = s.repo.ListEnrichedLoadBalancers(ctx, s.compartmentID)
} else {
all, err = s.repo.ListLoadBalancers(ctx, s.compartmentID)
}
if err != nil {
return nil, 0, "", fmt.Errorf("listing load balancers from repository: %w", err)
}
total := len(all)
if pageNum <= 0 {
pageNum = 1
}
start := (pageNum - 1) * limit
end := start + limit
if start >= total {
return []LoadBalancer{}, total, "", nil
}
if end > total {
end = total
}
paged := all[start:end]
next := ""
if end < total {
next = fmt.Sprintf("%d", pageNum+1)
}
return paged, total, next, nil
}
// GetEnrichedLoadBalancer retrieves and returns the enriched load balancer by OCID.
func (s *Service) GetEnrichedLoadBalancer(ctx context.Context, ocid string) (*LoadBalancer, error) {
s.logger.V(logger.Debug).Info("getting enriched load balancer", "ocid", ocid)
lb, err := s.repo.GetEnrichedLoadBalancer(ctx, ocid)
if err != nil {
return nil, fmt.Errorf("failed to get enriched load balancer: %w", err)
}
return lb, nil
}
// FuzzySearch performs a fuzzy search for load balancers based on the provided search pattern.
func (s *Service) FuzzySearch(ctx context.Context, searchPattern string) ([]LoadBalancer, error) {
all, err := s.repo.ListEnrichedLoadBalancers(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("fetching all load balancers for search: %w", err)
}
// Build the search index using the common search package and the load balancer searcher adapter.
indexables := ToSearchableLoadBalancers(all)
idxMapping := search.NewIndexMapping(GetSearchableFields())
idx, err := search.BuildIndex(indexables, idxMapping)
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
matchedIdxs, err := search.FuzzySearch(idx, searchPattern, GetSearchableFields(), GetBoostedFields())
if err != nil {
return nil, fmt.Errorf("performing fuzzy search: %w", err)
}
results := make([]LoadBalancer, 0, len(matchedIdxs))
for _, i := range matchedIdxs {
if i >= 0 && i < len(all) {
results = append(results, all[i])
}
}
return results, nil
}
package subnet
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ocisubnet "github.com/cnopslabs/ocloud/internal/oci/network/subnet"
)
// FindSubnets finds and displays subnets matching a name pattern.
func FindSubnets(appCtx *app.ApplicationContext, namePattern string, useJSON bool) error {
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
subnetAdapter := ocisubnet.NewAdapter(networkClient)
service := NewService(subnetAdapter, appCtx.Logger, appCtx.CompartmentID)
matchedSubnets, err := service.Find(context.Background(), namePattern)
if err != nil {
return fmt.Errorf("finding subnets: %w", err)
}
return PrintSubnetInfo(matchedSubnets, appCtx, useJSON)
}
package subnet
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ocisubnet "github.com/cnopslabs/ocloud/internal/oci/network/subnet"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// ListSubnets retrieves and displays a paginated list of subnets.
func ListSubnets(appCtx *app.ApplicationContext, useJSON bool, limit, page int, sortBy string) error {
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
subnetAdapter := ocisubnet.NewAdapter(networkClient)
service := NewService(subnetAdapter, appCtx.Logger, appCtx.CompartmentID)
subnets, totalCount, nextPageToken, err := service.List(context.Background(), limit, page)
if err != nil {
return fmt.Errorf("listing subnets: %w", err)
}
return PrintSubnetTable(subnets, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON, sortBy)
}
package subnet
import (
"sort"
"strings"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/domain/network/subnet"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// PrintSubnetTable displays a table of subnets with details such as name, CIDR, and DNS info.
func PrintSubnetTable(subnets []subnet.Subnet, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool, sortBy string) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
return util.MarshalDataToJSONResponse[subnet.Subnet](p, subnets, pagination)
}
if util.ValidateAndReportEmpty(subnets, pagination, appCtx.Stdout) {
return nil
}
// Sort subnets based on sortBy parameter
if sortBy != "" {
sortBy = strings.ToLower(sortBy)
switch sortBy {
case "name":
sort.Slice(subnets, func(i, j int) bool {
return strings.ToLower(subnets[i].DisplayName) < strings.ToLower(subnets[j].DisplayName)
})
case "cidr":
sort.Slice(subnets, func(i, j int) bool {
return subnets[i].CidrBlock < subnets[j].CidrBlock
})
}
}
// Define table headers
headers := []string{"Name", "CIDR", "Public"}
// Create rows for the table
rows := make([][]string, len(subnets))
for i, s := range subnets {
// Create a row for this subnet
rows[i] = []string{
s.DisplayName,
s.CidrBlock,
util.FormatBool(s.Public),
}
}
// Print the table without truncation so fully qualified domains are visible
title := util.FormatColoredTitle(appCtx, "Subnets")
p.PrintTableNoTruncate(title, headers, rows)
util.LogPaginationInfo(pagination, appCtx)
return nil
}
// PrintSubnetInfo displays information about a list of subnets in either JSON format or a formatted table view.
func PrintSubnetInfo(subnets []subnet.Subnet, appCtx *app.ApplicationContext, useJSON bool) error {
// Create a new printer that writes to the application's standard output.
p := printer.New(appCtx.Stdout)
// If JSON output is requested, special-case empty for compact format expected by tests.
if useJSON {
if len(subnets) == 0 {
_, err := appCtx.Stdout.Write([]byte("{\"items\": []}\n"))
return err
}
return util.MarshalDataToJSONResponse[subnet.Subnet](p, subnets, nil)
}
if util.ValidateAndReportEmpty(subnets, nil, appCtx.Stdout) {
return nil
}
// Print each policy as a separate key-value.
for _, s := range subnets {
// Derive DNS label and domain for display purposes only.
dnsLabel := deriveDNSLabel(s.DisplayName)
dnsDomain := dnsLabel + ".vcn1.oraclevcn.com"
subnetData := map[string]string{
"Name": s.DisplayName,
"Public": util.FormatBool(s.Public),
"CIDR": s.CidrBlock,
"DNS Label": dnsLabel,
"DNS Domain": dnsDomain,
}
orderedKeys := []string{
"Name", "Public", "CIDR", "DNS Label", "DNS Domain",
}
// Create the colored title using components from the app context
title := util.FormatColoredTitle(appCtx, s.DisplayName)
p.PrintKeyValues(title, subnetData, orderedKeys)
}
util.LogPaginationInfo(nil, appCtx)
return nil
}
// deriveDNSLabel derives a simple DNS label for display by using a trailing number
// from the subnet name if present (e.g., "TestSubnet1" -> "subnet1"). Otherwise,
// it returns a sanitized lowercase version of the name.
func deriveDNSLabel(name string) string {
if name == "" {
return "subnet"
}
n := strings.ToLower(name)
// collect trailing digits
i := len(n) - 1
digits := ""
for i >= 0 {
c := n[i]
if c < '0' || c > '9' {
break
}
digits = string(c) + digits
i--
}
if digits != "" {
return "subnet" + digits
}
// sanitize: keep a-z, 0-9, replace spaces/underscores with '-'
var b []rune
for _, r := range n {
switch {
case r >= 'a' && r <= 'z':
b = append(b, r)
case r >= '0' && r <= '9':
b = append(b, r)
case r == ' ' || r == '_':
b = append(b, '-')
// drop others
}
}
if len(b) == 0 {
return "subnet"
}
return string(b)
}
package subnet
import (
"context"
"fmt"
"strings"
"github.com/cnopslabs/ocloud/internal/domain/network/subnet"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/go-logr/logr"
)
// Service is the application-layer service for subnet operations.
type Service struct {
subnetRepo subnet.SubnetRepository
logger logr.Logger
compartmentID string
}
// NewService creates and initializes a new Service instance.
func NewService(repo subnet.SubnetRepository, logger logr.Logger, compartmentID string) *Service {
return &Service{
subnetRepo: repo,
logger: logger,
compartmentID: compartmentID,
}
}
// List retrieves a paginated list of subnets.
func (s *Service) List(ctx context.Context, limit int, pageNum int) ([]subnet.Subnet, int, string, error) {
s.logger.V(logger.Debug).Info("listing subnets", "limit", limit, "pageNum", pageNum)
allSubnets, err := s.subnetRepo.ListSubnets(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing subnets from repository: %w", err)
}
// Manual pagination.
totalCount := len(allSubnets)
start := (pageNum - 1) * limit
end := start + limit
if start >= totalCount {
return []subnet.Subnet{}, totalCount, "", nil
}
if end > totalCount {
end = totalCount
}
pagedResults := allSubnets[start:end]
var nextPageToken string
if end < totalCount {
nextPageToken = fmt.Sprintf("%d", pageNum+1)
}
s.logger.Info("completed subnet listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// Find retrieves a slice of subnets whose attributes match the provided name pattern using fuzzy search.
func (s *Service) Find(ctx context.Context, namePattern string) ([]subnet.Subnet, error) {
s.logger.V(logger.Debug).Info("finding subnet with fuzzy search", "pattern", namePattern)
allSubnets, err := s.subnetRepo.ListSubnets(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("fetching all subnets for search: %w", err)
}
index, err := util.BuildIndex(allSubnets, func(s subnet.Subnet) any {
return mapToIndexableSubnets(s)
})
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
logger.Logger.V(logger.Debug).Info("Search index built successfully.", "numEntries", len(allSubnets))
fields := []string{"Name", "CIDR"}
matchedIdxs, err := util.FuzzySearchIndex(index, strings.ToLower(namePattern), fields)
if err != nil {
return nil, fmt.Errorf("performing fuzzy search: %w", err)
}
logger.Logger.V(logger.Debug).Info("Fuzzy search completed.", "numMatches", len(matchedIdxs))
var matchedSubnets []subnet.Subnet
for _, idx := range matchedIdxs {
if idx >= 0 && idx < len(allSubnets) {
matchedSubnets = append(matchedSubnets, allSubnets[idx])
}
}
s.logger.Info("found subnet", "count", len(matchedSubnets))
return matchedSubnets, nil
}
// mapToIndexableSubnets converts a domain.Subnet to a struct suitable for indexing.
func mapToIndexableSubnets(s subnet.Subnet) any {
return struct {
Name string
CIDR string
}{
Name: strings.ToLower(s.DisplayName),
CIDR: s.CidrBlock,
}
}
package vcn
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ocivcn "github.com/cnopslabs/ocloud/internal/oci/network/vcn"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// GetVCNs retrieves a VCN by OCID and prints its summary or JSON.
func GetVCNs(appCtx *app.ApplicationContext, limit, page int, useJSON, gateways, subnets, nsgs, routes, securityLists bool) error {
ctx := context.Background()
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
adapter := ocivcn.NewAdapter(networkClient)
service := NewService(adapter, appCtx.Logger, appCtx.CompartmentID)
vcns, totalCount, nextPageToken, err := service.FetchPaginatedVCNs(ctx, limit, page)
if err != nil {
return fmt.Errorf("getting vcn: %w", err)
}
return PrintVCNsInfo(vcns, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON, gateways, subnets, nsgs, routes, securityLists)
}
package vcn
import (
"context"
"errors"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
ocivcn "github.com/cnopslabs/ocloud/internal/oci/network/vcn"
"github.com/cnopslabs/ocloud/internal/tui"
)
func ListVCNs(appCtx *app.ApplicationContext, useJSON, gateways, subnets, nsgs, routes, securityLists bool) error {
ctx := context.Background()
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
adapter := ocivcn.NewAdapter(networkClient)
service := NewService(adapter, appCtx.Logger, appCtx.CompartmentID)
vcns, err := service.ListVcns(ctx)
if err != nil {
return fmt.Errorf("getting vcn: %w", err)
}
model := ocivcn.NewVCNListModel(vcns)
id, err := tui.Run(model)
if err != nil {
if errors.Is(err, tui.ErrCancelled) {
return nil
}
return fmt.Errorf("listing vcn: %w", err)
}
vcn, err := service.vcnRepo.GetEnrichedVcn(ctx, id)
if err != nil {
return fmt.Errorf("getting vcn: %w", err)
}
return PrintVCNInfo(vcn, appCtx, useJSON, gateways, subnets, nsgs, routes, securityLists)
}
package vcn
import (
"strings"
"github.com/cnopslabs/ocloud/internal/app"
domain "github.com/cnopslabs/ocloud/internal/domain/network/vcn"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// PrintVCNsInfo prints the VCN summary view or JSON if requested.
func PrintVCNsInfo(vcns []domain.VCN, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON, gateways, subnets, nsgs, routes, securityLists bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
return util.MarshalDataToJSONResponse[domain.VCN](p, vcns, pagination)
}
for _, v := range vcns {
title := util.FormatColoredTitle(appCtx, v.DisplayName)
cidrs := strings.Join(v.CidrBlocks, ", ")
ipv6 := "Disabled"
if v.Ipv6Enabled {
ipv6 = "Enabled"
}
dhcp := strings.TrimSpace(v.DhcpOptions.DisplayName)
if dhcp == "" {
if strings.TrimSpace(v.DhcpOptionsID) != "" {
dhcp = v.DhcpOptionsID
} else {
dhcp = "-"
}
} else if strings.TrimSpace(v.DhcpOptions.DomainNameType) != "" {
dhcp = dhcp + " (" + v.DhcpOptions.DomainNameType + ")"
}
data := map[string]string{
"OCID": v.OCID,
"State": strings.ToUpper(v.LifecycleState),
"CIDR Blocks": cidrs,
"IPv6": ipv6,
"DNS Label / Domain": strings.TrimSpace(strings.Join([]string{v.DnsLabel, v.DomainName}, " / ")),
"DHCP Options": dhcp,
"Created": v.TimeCreated.Format("2006-01-02"),
}
order := []string{"OCID", "State", "CIDR Blocks", "IPv6", "DNS Label / Domain", "DHCP Options", "Created"}
p.PrintKeyValues(title, data, order)
if gateways {
printGateways(p, v.Gateways)
}
if subnets {
printSubnets(p, v)
}
if nsgs {
printNSGs(p, v.NSGs)
}
if routes {
printRouteTables(p, v.RouteTables)
}
if securityLists {
printSecurityLists(p, v.SecurityLists)
}
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
//---------------------------------------------------------------------------------------------------------------------
// PrintVCNInfo prints the VCN summary view or JSON if requested.
func PrintVCNInfo(v domain.VCN, appCtx *app.ApplicationContext, useJSON, gateways, subnets, nsgs, routes, securityLists bool) error {
p := printer.New(appCtx.Stdout)
if useJSON {
return p.MarshalToJSON(v)
}
title := util.FormatColoredTitle(appCtx, v.DisplayName)
cidrs := strings.Join(v.CidrBlocks, ", ")
ipv6 := "Disabled"
if v.Ipv6Enabled {
ipv6 = "Enabled"
}
dhcp := strings.TrimSpace(v.DhcpOptions.DisplayName)
if dhcp == "" {
if strings.TrimSpace(v.DhcpOptionsID) != "" {
dhcp = v.DhcpOptionsID
} else {
dhcp = "-"
}
} else if strings.TrimSpace(v.DhcpOptions.DomainNameType) != "" {
dhcp = dhcp + " (" + v.DhcpOptions.DomainNameType + ")"
}
data := map[string]string{
"OCID": v.OCID,
"State": strings.ToUpper(v.LifecycleState),
"CIDR Blocks": cidrs,
"IPv6": ipv6,
"DNS Label / Domain": strings.TrimSpace(strings.Join([]string{v.DnsLabel, v.DomainName}, " / ")),
"DHCP Options": dhcp,
"Created": v.TimeCreated.Format("2006-01-02"),
}
order := []string{"OCID", "State", "CIDR Blocks", "IPv6", "DNS Label / Domain", "DHCP Options", "Created"}
p.PrintKeyValues(title, data, order)
if gateways {
printGateways(p, v.Gateways)
}
if subnets {
printSubnets(p, v)
}
if nsgs {
printNSGs(p, v.NSGs)
}
if routes {
printRouteTables(p, v.RouteTables)
}
if securityLists {
printSecurityLists(p, v.SecurityLists)
}
return nil
}
//---------------------------------------------------------------------------------------------------------------------
func printGateways(p *printer.Printer, gateways []domain.Gateway) {
if len(gateways) == 0 {
return
}
p.PrintTable("Gateways", []string{"Type", "Details"}, toGatewayRows(gateways))
}
func printSubnets(p *printer.Printer, v domain.VCN) {
subnets := v.Subnets
if len(subnets) == 0 {
return
}
headers := []string{"Name", "CIDR", "Publicity", "Route Table", "SecLists"}
p.PrintTableNoTruncate("Subnets", headers, toSubnetRows(v))
}
func printNSGs(p *printer.Printer, nsgs []domain.NSG) {
if len(nsgs) == 0 {
return
}
headers := []string{"Name", "State"}
p.PrintTableNoTruncate("Network Security Groups", headers, toNSGRows(nsgs))
}
func printRouteTables(p *printer.Printer, rts []domain.RouteTable) {
if len(rts) == 0 {
return
}
headers := []string{"Name", "State"}
p.PrintTableNoTruncate("Route Tables", headers, toRouteTableRows(rts))
}
func printSecurityLists(p *printer.Printer, sls []domain.SecurityList) {
if len(sls) == 0 {
return
}
headers := []string{"Name", "State"}
p.PrintTableNoTruncate("Security Lists", headers, toSecurityListRows(sls))
}
func toGatewayRows(gateways []domain.Gateway) [][]string {
var (
internet []string
nat []string
service []string
drg []string
lpg []string
)
for _, gw := range gateways {
switch gw.Type {
case "Internet":
internet = append(internet, gw.DisplayName)
case "NAT":
nat = append(nat, gw.DisplayName)
case "Service":
service = append(service, gw.DisplayName)
case "DRG":
drg = append(drg, gw.DisplayName)
case "Local Peering":
lpg = append(lpg, gw.DisplayName)
}
}
var rows [][]string
if len(internet) > 0 {
rows = append(rows, []string{"Internet", strings.Join(internet, ", ")})
}
if len(nat) > 0 {
rows = append(rows, []string{"NAT", strings.Join(nat, ", ")})
}
if len(service) > 0 {
rows = append(rows, []string{"Service GW", strings.Join(service, ", ")})
}
if len(drg) > 0 {
rows = append(rows, []string{"DRG", strings.Join(drg, ", ")})
}
if len(lpg) > 0 {
rows = append(rows, []string{"LPG Peers", strings.Join(lpg, ", ")})
}
return rows
}
func toSubnetRows(v domain.VCN) [][]string {
subnets := v.Subnets
rows := make([][]string, len(subnets))
for i, s := range subnets {
rt := lookupRouteTableName(v, s.RouteTableID)
rt = strings.Join(util.SplitTextByMaxWidth(rt), "\n")
sl := lookupSecurityListNames(v, s.SecurityListIDs)
sl = strings.Join(util.SplitTextByMaxWidth(sl), "\n")
rows[i] = []string{
s.DisplayName,
s.CidrBlock,
formatPublicity(s.Public),
rt,
sl,
}
}
return rows
}
// --- helpers ---
func formatPublicity(public bool) string {
if public {
return "PUBLIC"
}
return "PRIVATE"
}
func lookupRouteTableName(v domain.VCN, id string) string {
if strings.TrimSpace(id) == "" {
return "-"
}
for _, rt := range v.RouteTables {
if rt.OCID == id {
if strings.TrimSpace(rt.DisplayName) != "" {
return rt.DisplayName
}
return id
}
}
return id
}
func lookupSecurityListNames(v domain.VCN, ids []string) string {
if len(ids) == 0 {
return "-"
}
nameByID := make(map[string]string, len(v.SecurityLists))
for _, sl := range v.SecurityLists {
nameByID[sl.OCID] = sl.DisplayName
}
var names []string
for _, id := range ids {
name := nameByID[id]
if strings.TrimSpace(name) == "" {
name = id
}
names = append(names, name)
}
return strings.Join(names, ", ")
}
func toNSGRows(nsgs []domain.NSG) [][]string {
rows := make([][]string, len(nsgs))
for i, n := range nsgs {
rows[i] = []string{n.DisplayName, strings.ToUpper(n.LifecycleState)}
}
return rows
}
func toRouteTableRows(rts []domain.RouteTable) [][]string {
rows := make([][]string, len(rts))
for i, r := range rts {
rows[i] = []string{r.DisplayName, strings.ToUpper(r.LifecycleState)}
}
return rows
}
func toSecurityListRows(sls []domain.SecurityList) [][]string {
rows := make([][]string, len(sls))
for i, s := range sls {
rows[i] = []string{s.DisplayName, strings.ToUpper(s.LifecycleState)}
}
return rows
}
package vcn
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
ocivcn "github.com/cnopslabs/ocloud/internal/oci/network/vcn"
)
func SearchVCNs(appCtx *app.ApplicationContext, search string, useJSON, gateways, subnets, nsgs, routes, securityLists bool) error {
ctx := context.Background()
networkClient, err := oci.NewNetworkClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating network client: %w", err)
}
adapter := ocivcn.NewAdapter(networkClient)
service := NewService(adapter, appCtx.Logger, appCtx.CompartmentID)
vcns, err := service.FuzzySearch(ctx, search)
if err != nil {
return fmt.Errorf("finding vcn: %w", err)
}
err = PrintVCNsInfo(vcns, appCtx, nil, useJSON, gateways, subnets, nsgs, routes, securityLists)
if err != nil {
return fmt.Errorf("printing vcn: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Info, "Found matching vcn", "search", search, "matched", len(vcns))
return nil
}
package vcn
import (
"strings"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// SearchableVCN adapts VCN to the search.Indexable interface.
type SearchableVCN struct {
VCN
}
// ToIndexable converts a VCN to a map of searchable fields.
func (s SearchableVCN) ToIndexable() map[string]any {
join := func(ss []string) string {
if len(ss) == 0 {
return ""
}
out := make([]string, 0, len(ss))
for _, v := range ss {
v = strings.TrimSpace(v)
if v == "" {
continue
}
out = append(out, strings.ToLower(v))
}
return strings.Join(out, " ")
}
tagsKV, _ := util.FlattenTags(s.FreeformTags, s.DefinedTags)
tagsVal, _ := util.ExtractTagValues(s.FreeformTags, s.DefinedTags)
// Collect names of related resources for better search coverage.
gwNames := make([]string, 0, len(s.Gateways))
for _, g := range s.Gateways {
gwNames = append(gwNames, g.DisplayName)
}
subnetNames := make([]string, 0, len(s.Subnets))
for _, sn := range s.Subnets {
subnetNames = append(subnetNames, sn.DisplayName)
}
nsgNames := make([]string, 0, len(s.NSGs))
for _, n := range s.NSGs {
nsgNames = append(nsgNames, n.DisplayName)
}
rtNames := make([]string, 0, len(s.RouteTables))
for _, r := range s.RouteTables {
rtNames = append(rtNames, r.DisplayName)
}
slNames := make([]string, 0, len(s.SecurityLists))
for _, sl := range s.SecurityLists {
slNames = append(slNames, sl.DisplayName)
}
return map[string]any{
"Name": strings.ToLower(s.DisplayName),
"OCID": strings.ToLower(s.OCID),
"State": strings.ToLower(s.LifecycleState),
"CIDRs": join(s.CidrBlocks),
"DnsLabel": strings.ToLower(s.DnsLabel),
"DomainName": strings.ToLower(s.DomainName),
"TagsKV": strings.ToLower(tagsKV),
"TagsVal": strings.ToLower(tagsVal),
"Gateways": join(gwNames),
"Subnets": join(subnetNames),
"NSGs": join(nsgNames),
"RouteTables": join(rtNames),
"SecLists": join(slNames),
}
}
// GetSearchableFields returns the fields to index for VCNs.
func GetSearchableFields() []string {
return []string{"Name", "OCID", "State", "CIDRs", "DnsLabel", "DomainName", "TagsKV", "TagsVal", "Gateways", "Subnets", "NSGs", "RouteTables", "SecLists"}
}
// GetBoostedFields returns fields to boost during the search for better relevance.
func GetBoostedFields() []string {
return []string{"Name", "OCID", "DnsLabel", "DomainName", "TagsKV", "TagsVal"}
}
// ToSearchableVCNs converts a slice of VCN to a slice of search.Indexable.
func ToSearchableVCNs(items []VCN) []search.Indexable {
out := make([]search.Indexable, len(items))
for i, it := range items {
out[i] = SearchableVCN{it}
}
return out
}
package vcn
import (
"context"
"fmt"
domain "github.com/cnopslabs/ocloud/internal/domain/network/vcn"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/go-logr/logr"
)
// Service is the application-layer service for vcn operations.
type Service struct {
vcnRepo domain.VCNRepository
logger logr.Logger
compartmentID string
}
// NewService initializes a new Service instance.
func NewService(repo domain.VCNRepository, logger logr.Logger, compartmentID string) *Service {
return &Service{
vcnRepo: repo,
logger: logger,
compartmentID: compartmentID,
}
}
// FetchPaginatedVCNs retrieves a paginated list of vcns.
func (s *Service) FetchPaginatedVCNs(ctx context.Context, limit, pageNum int) ([]VCN, int, string, error) {
s.logger.V(logger.Debug).Info("listing vcns", "limit", limit, "pageNum", pageNum)
allVcn, err := s.vcnRepo.ListEnrichedVcns(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing vcns from repository: %w", err)
}
pagedResults, totalCount, nextPageToken := util.PaginateSlice(allVcn, limit, pageNum)
return pagedResults, totalCount, nextPageToken, nil
}
// ListVcns retrieves a list of vcns.
func (s *Service) ListVcns(ctx context.Context) ([]VCN, error) {
s.logger.V(logger.Debug).Info("listing vcns")
allVcn, err := s.vcnRepo.ListEnrichedVcns(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("listing vcns from repository: %w", err)
}
return allVcn, nil
}
// FuzzySearch performs a fuzzy search for vcns.
func (s *Service) FuzzySearch(ctx context.Context, searchPattern string) ([]VCN, error) {
all, err := s.vcnRepo.ListEnrichedVcns(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("fetching all VCNs for search: %w", err)
}
// Build the search index using the common search package and the VCN searcher adapter.
indexables := ToSearchableVCNs(all)
idxMapping := search.NewIndexMapping(GetSearchableFields())
idx, err := search.BuildIndex(indexables, idxMapping)
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
matchedIdxs, err := search.FuzzySearch(idx, searchPattern, GetSearchableFields(), GetBoostedFields())
if err != nil {
return nil, fmt.Errorf("performing fuzzy search: %w", err)
}
results := make([]VCN, 0, len(matchedIdxs))
for _, i := range matchedIdxs {
if i >= 0 && i < len(all) {
results = append(results, all[i])
}
}
return results, nil
}
package search
import (
"fmt"
"strconv"
"strings"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/mapping"
bleveQuery "github.com/blevesearch/bleve/v2/search/query"
// Register analyzers used in field mappings
_ "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
_ "github.com/blevesearch/bleve/v2/analysis/analyzer/simple"
)
// Indexable represents an object that can be converted into a searchable document.
type Indexable interface {
ToIndexable() map[string]any
}
// NewIndexMapping creates a new Bleve index mapping with analyzers.
func NewIndexMapping(fields []string) mapping.IndexMapping {
m := mapping.NewIndexMapping()
std := mapping.NewTextFieldMapping()
raw := mapping.NewTextFieldMapping()
raw.Analyzer = "keyword"
ng := mapping.NewTextFieldMapping()
// use simple analyzer (unicode + lowercase)
ng.Analyzer = "simple"
doc := mapping.NewDocumentMapping()
for _, f := range fields {
doc.AddFieldMappingsAt(f, std)
doc.AddFieldMappingsAt(f+".raw", raw)
doc.AddFieldMappingsAt(f+".ng", ng)
}
m.DefaultMapping = doc
return m
}
// BuildIndex builds an in-memory Bleve index for a slice of Indexable items.
func BuildIndex[T Indexable](items []T, indexMapping mapping.IndexMapping) (bleve.Index, error) {
idx, err := bleve.NewMemOnly(indexMapping)
if err != nil {
return nil, fmt.Errorf("creating index: %w", err)
}
for i, item := range items {
doc := item.ToIndexable()
dup := func(k string) {
if v, ok := doc[k].(string); ok && v != "" {
doc[k+".raw"] = v
doc[k+".ng"] = v
}
}
for k := range doc {
dup(k)
}
if err := idx.Index(strconv.Itoa(i), doc); err != nil {
return nil, fmt.Errorf("indexing %d: %w", i, err)
}
}
return idx, nil
}
// FuzzySearch performs a fuzzy search on the given index.
func FuzzySearch(index bleve.Index, pattern string, fields, boostedFields []string) ([]int, error) {
pattern = strings.ToLower(strings.TrimSpace(pattern))
if pattern == "" {
return nil, nil
}
looksSpecific := func(p string) bool {
if len(p) >= 15 {
return true
}
if strings.ContainsAny(p, ".:-_/[]@") {
return true
}
if strings.Count(p, ".") == 3 {
return true
}
return false
}
collect := func(q bleveQuery.Query, size int) ([]int, error) {
req := bleve.NewSearchRequestOptions(q, size, 0, false)
res, err := index.Search(req)
if err != nil {
return nil, err
}
out := make([]int, 0, len(res.Hits))
for _, h := range res.Hits {
if n, err := strconv.Atoi(h.ID); err == nil {
out = append(out, n)
}
}
return out, nil
}
if looksSpecific(pattern) {
var eqQs []bleveQuery.Query
for _, f := range fields {
tq := bleve.NewTermQuery(pattern)
tq.SetField(f + ".raw")
eqQs = append(eqQs, tq)
}
if hits, err := collect(bleve.NewDisjunctionQuery(eqQs...), 200); err != nil {
return nil, err
} else if len(hits) > 0 {
return hits, nil
}
var subQs []bleveQuery.Query
for _, f := range fields {
wq := bleve.NewWildcardQuery("*" + pattern + "*")
wq.SetField(f + ".raw")
wq.SetBoost(1.0)
subQs = append(subQs, wq)
}
if hits, err := collect(bleve.NewDisjunctionQuery(subQs...), 500); err != nil {
return nil, err
} else if len(hits) > 0 {
return hits, nil
}
}
var qs []bleveQuery.Query
for _, f := range fields {
fq := bleve.NewFuzzyQuery(pattern)
fq.SetField(f)
fq.SetFuzziness(2)
fq.SetBoost(1.2)
pq := bleve.NewPrefixQuery(pattern)
pq.SetField(f)
pq.SetBoost(1.3)
qs = append(qs, fq, pq)
mq := bleve.NewMatchQuery(pattern)
mq.SetField(f + ".ng")
mq.SetBoost(1.5)
qs = append(qs, mq)
wq := bleve.NewWildcardQuery("*" + pattern + "*")
wq.SetField(f + ".raw")
wq.SetBoost(1.1)
qs = append(qs, wq)
}
for _, boostField := range boostedFields {
bq := bleve.NewMatchQuery(pattern)
bq.SetField(boostField + ".ng")
bq.SetBoost(1.8)
qs = append(qs, bq)
}
return collect(bleve.NewDisjunctionQuery(qs...), 1000)
}
package objectstorage
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
os "github.com/cnopslabs/ocloud/internal/oci/storage/objectstorage"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// GetBuckets retrieves and displays a paginated list of object storage buckets in a given compartment.
// It uses a specified limit and page number for pagination and can output results as JSON based on the flag.
// Returns an error if bucket retrieval or output processing fails.
func GetBuckets(appCtx *app.ApplicationContext, limit int, page int, useJSON bool) error {
ctx := context.Background()
client, err := oci.NewObjectStorageClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating object storage client: %w", err)
}
bucketAdapter := os.NewAdapter(client)
service := NewService(bucketAdapter, appCtx.Logger, appCtx.CompartmentID)
buckets, total, next, err := service.FetchPaginatedBuckets(ctx, limit, page)
if err != nil {
return fmt.Errorf("listing buckets: %w", err)
}
return PrintBucketsInfo(buckets, appCtx, &util.PaginationInfo{CurrentPage: page, TotalCount: total, NextPageToken: next, Limit: limit}, useJSON)
}
package objectstorage
import (
"context"
"errors"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/oci"
os "github.com/cnopslabs/ocloud/internal/oci/storage/objectstorage"
"github.com/cnopslabs/ocloud/internal/tui"
)
// ListBuckets retrieves and lists all buckets in the specified compartment. It supports both TUI and JSON output formatting.
func ListBuckets(appCtx *app.ApplicationContext, useJSON bool) error {
ctx := context.Background()
client, err := oci.NewObjectStorageClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating object storage client: %w", err)
}
bucketAdapter := os.NewAdapter(client)
service := NewService(bucketAdapter, appCtx.Logger, appCtx.CompartmentID)
buckets, err := service.ListBuckets(ctx)
if err != nil {
return fmt.Errorf("listing buckets: %w", err)
}
//TUI
model := os.NewBucketListModel(buckets)
id, err := tui.Run(model)
if err != nil {
if errors.Is(err, tui.ErrCancelled) {
return nil
}
return fmt.Errorf("selecting bucket: %w", err)
}
name, err := service.osRepo.GetBucketNameByOCID(ctx, appCtx.CompartmentID, id)
bucket, err := service.osRepo.GetBucketByName(ctx, appCtx.CompartmentID, name)
if err != nil {
return fmt.Errorf("getting bucket: %w", err)
}
return PrintBucketInfo(bucket, appCtx, useJSON)
}
package objectstorage
import (
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/domain/storage/objectstorage"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// PrintBucketsInfo displays buckets in a formatted table or JSON format.
// If pagination info is provided, it adjusts and logs it.
func PrintBucketsInfo(buckets []Bucket, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
if useJSON {
return util.MarshalDataToJSONResponse[objectstorage.Bucket](p, buckets, pagination)
}
if util.ValidateAndReportEmpty(buckets, pagination, appCtx.Stdout) {
return nil
}
for _, bucket := range buckets {
bucketData := map[string]string{
"Name": bucket.Name,
"Namespace": bucket.Namespace,
"Created": bucket.TimeCreated.String(),
"OCID": bucket.OCID,
"StorageTier": bucket.StorageTier,
"Visibility": bucket.Visibility,
"Encryption": bucket.Encryption,
"Versioning": bucket.Versioning,
"ReplicationEnabled": fmt.Sprintf("%v", bucket.ReplicationEnabled),
"ReadOnly": fmt.Sprintf("%v", bucket.IsReadOnly),
"ApproximateCount": fmt.Sprintf("%d", bucket.ApproximateCount),
"ApproximateSize": util.HumanizeBytesIEC(bucket.ApproximateSize),
"ApproximateSizeBytes": fmt.Sprintf("%d", bucket.ApproximateSize),
}
orderedKeys := []string{
"Name", "OCID", "Namespace", "Created", "StorageTier", "Visibility", "Encryption", "Versioning", "ReplicationEnabled", "ReadOnly", "ApproximateCount", "ApproximateSize", "ApproximateSizeBytes",
}
title := util.FormatColoredTitle(appCtx, bucket.Name)
p.PrintKeyValues(title, bucketData, orderedKeys)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
func PrintBucketInfo(bucket *Bucket, appCtx *app.ApplicationContext, useJSON bool) error {
p := printer.New(appCtx.Stdout)
if useJSON {
return p.MarshalToJSON(bucket)
}
bucketData := map[string]string{
"Name": bucket.Name,
"Namespace": bucket.Namespace,
"Created": bucket.TimeCreated.String(),
"OCID": bucket.OCID,
"StorageTier": bucket.StorageTier,
"Visibility": bucket.Visibility,
"Encryption": bucket.Encryption,
"Versioning": bucket.Versioning,
"ReplicationEnabled": fmt.Sprintf("%v", bucket.ReplicationEnabled),
"ReadOnly": fmt.Sprintf("%v", bucket.IsReadOnly),
"ApproximateCount": fmt.Sprintf("%d", bucket.ApproximateCount),
"ApproximateSize": util.HumanizeBytesIEC(bucket.ApproximateSize),
"ApproximateSizeBytes": fmt.Sprintf("%d", bucket.ApproximateSize),
}
orderedKeys := []string{
"Name", "OCID", "Namespace", "Created", "StorageTier", "Visibility", "Encryption", "Versioning", "ReplicationEnabled", "ReadOnly", "ApproximateCount", "ApproximateSize", "ApproximateSizeBytes",
}
title := util.FormatColoredTitle(appCtx, bucket.Name)
p.PrintKeyValues(title, bucketData, orderedKeys)
return nil
}
package objectstorage
import (
"context"
"fmt"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/oci"
ociobj "github.com/cnopslabs/ocloud/internal/oci/storage/objectstorage"
)
func SearchBuckets(appCtx *app.ApplicationContext, pattern string, useJSON bool) error {
ctx := context.Background()
client, err := oci.NewObjectStorageClient(appCtx.Provider)
if err != nil {
return fmt.Errorf("creating object storage client: %w", err)
}
adapter := ociobj.NewAdapter(client)
svc := NewService(adapter, appCtx.Logger, appCtx.CompartmentID)
buckets, err := svc.FuzzySearch(ctx, pattern)
if err != nil {
return fmt.Errorf("searching buckets: %w", err)
}
if err := PrintBucketsInfo(buckets, appCtx, nil, useJSON); err != nil {
return fmt.Errorf("printing buckets: %w", err)
}
logger.LogWithLevel(logger.CmdLogger, logger.Info, "Found matching buckets", "search", pattern, "matched", len(buckets))
return nil
}
package objectstorage
import (
"strings"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
)
// SearchableBucket adapts Bucket to the search.Indexable interface.
type SearchableBucket struct {
Bucket
}
// ToIndexable converts a Bucket to a map of searchable fields.
func (s SearchableBucket) ToIndexable() map[string]any {
tagsKV, _ := util.FlattenTags(s.FreeformTags, s.DefinedTags)
tagsVal, _ := util.ExtractTagValues(s.FreeformTags, s.DefinedTags)
boolStr := func(b bool) string {
if b {
return "true"
}
return "false"
}
return map[string]any{
"Name": strings.ToLower(s.Name),
"OCID": strings.ToLower(s.OCID),
"Namespace": strings.ToLower(s.Namespace),
"StorageTier": strings.ToLower(s.StorageTier),
"Visibility": strings.ToLower(s.Visibility),
"Encryption": strings.ToLower(s.Encryption),
"Versioning": strings.ToLower(s.Versioning),
"ReplicationEnabled": boolStr(s.ReplicationEnabled),
"IsReadOnly": boolStr(s.IsReadOnly),
"TagsKV": strings.ToLower(tagsKV),
"TagsVal": strings.ToLower(tagsVal),
}
}
// GetSearchableFields returns the fields to index for Buckets.
func GetSearchableFields() []string {
return []string{"Name", "OCID", "Namespace", "StorageTier", "Visibility", "Encryption", "Versioning", "ReplicationEnabled", "IsReadOnly", "TagsKV", "TagsVal"}
}
// GetBoostedFields returns fields to boost during the search for better relevance.
func GetBoostedFields() []string {
return []string{"Name", "OCID", "Namespace", "TagsKV", "TagsVal"}
}
// ToSearchableBuckets converts a slice of Bucket to a slice of search.Indexable.
func ToSearchableBuckets(items []Bucket) []search.Indexable {
out := make([]search.Indexable, len(items))
for i, it := range items {
out[i] = SearchableBucket{it}
}
return out
}
package objectstorage
import (
"context"
"fmt"
storage "github.com/cnopslabs/ocloud/internal/domain/storage/objectstorage"
"github.com/cnopslabs/ocloud/internal/logger"
"github.com/cnopslabs/ocloud/internal/services/search"
"github.com/cnopslabs/ocloud/internal/services/util"
"github.com/go-logr/logr"
)
type Service struct {
osRepo storage.ObjectStorageRepository
logger logr.Logger
CompartmentID string
}
func NewService(repo storage.ObjectStorageRepository, logger logr.Logger, compartmentID string) *Service {
return &Service{
osRepo: repo,
logger: logger,
CompartmentID: compartmentID,
}
}
func (s *Service) ListBuckets(ctx context.Context) ([]Bucket, error) {
s.logger.V(logger.Debug).Info("listing object storage buckets")
buckets, err := s.osRepo.ListBuckets(ctx, s.CompartmentID)
if err != nil {
return nil, fmt.Errorf("listing buckets from repository: %w", err)
}
for i := range buckets {
name := buckets[i].Name
if name == "" {
continue
}
full, e := s.osRepo.GetBucketByName(ctx, s.CompartmentID, name)
if e != nil {
continue
}
buckets[i] = *full
}
return buckets, nil
}
// FetchPaginatedBuckets lists buckets and returns a page plus pagination metadata.
func (s *Service) FetchPaginatedBuckets(ctx context.Context, limit, pageNum int) ([]Bucket, int, string, error) {
s.logger.V(logger.Debug).Info("listing object storage buckets", "limit", limit, "page", pageNum)
all, err := s.osRepo.ListBuckets(ctx, s.CompartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing buckets from repository: %w", err)
}
for i := range all {
name := all[i].Name
if name == "" {
continue
}
full, e := s.osRepo.GetBucketByName(ctx, s.CompartmentID, name)
if e != nil {
continue
}
all[i] = *full
}
paged, total, next := util.PaginateSlice(all, limit, pageNum)
return paged, total, next, nil
}
func (s *Service) FuzzySearch(ctx context.Context, searchPattern string) ([]Bucket, error) {
s.logger.V(logger.Debug).Info("searching object storage buckets", "pattern", searchPattern)
// List and enrich buckets similar to ListBuckets behavior
all, err := s.osRepo.ListBuckets(ctx, s.CompartmentID)
if err != nil {
return nil, fmt.Errorf("fetching all buckets for search: %w", err)
}
for i := range all {
name := all[i].Name
if name == "" {
continue
}
if full, e := s.osRepo.GetBucketByName(ctx, s.CompartmentID, name); e == nil && full != nil {
all[i] = *full
}
}
// Build the search index using the common search package and the bucket searcher adapter.
indexables := ToSearchableBuckets(all)
idxMapping := search.NewIndexMapping(GetSearchableFields())
idx, err := search.BuildIndex(indexables, idxMapping)
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
matchedIdxs, err := search.FuzzySearch(idx, searchPattern, GetSearchableFields(), GetBoostedFields())
if err != nil {
return nil, fmt.Errorf("performing fuzzy search: %w", err)
}
results := make([]Bucket, 0, len(matchedIdxs))
for _, i := range matchedIdxs {
if i >= 0 && i < len(all) {
results = append(results, all[i])
}
}
return results, nil
}
package util
import "fmt"
// HumanizeBytesIEC converts a byte size into a human‑readable string using IEC units (powers of 1024).
// Examples:
// - 0 -> "0 B"
// - 1023 -> "1023 B"
// - 1024 -> "1.00 KiB"
// - 2.72 * 1024 * 1024 -> "2.72 MiB"
func HumanizeBytesIEC(b int64) string {
const (
_ = iota
KB int64 = 1 << (10 * iota) // 1024
MB
GB
TB
PB
)
switch {
case b < 1024:
return fmt.Sprintf("%d B", b)
case b < MB:
return fmt.Sprintf("%.2f KiB", float64(b)/float64(KB))
case b < GB:
return fmt.Sprintf("%.2f MiB", float64(b)/float64(MB))
case b < TB:
return fmt.Sprintf("%.2f GiB", float64(b)/float64(GB))
case b < PB:
return fmt.Sprintf("%.2f TiB", float64(b)/float64(TB))
default:
return fmt.Sprintf("%.2f PiB", float64(b)/float64(PB))
}
}
package util
import (
"fmt"
"github.com/cnopslabs/ocloud/internal/domain"
)
// ShowConstructionAnimation displays a placeholder animation indicating that a feature is under construction.
func ShowConstructionAnimation() {
fmt.Println("🚧 This feature is not implemented yet. Coming soon!")
}
// ConvertOciTagsToResourceTags converts OCI FreeformTags and DefinedTags to domain.ResourceTags.
func ConvertOciTagsToResourceTags(freeformTags map[string]string, definedTags map[string]map[string]interface{}) domain.ResourceTags {
resourceTags := make(domain.ResourceTags)
for k, v := range freeformTags {
resourceTags[k] = v
}
for namespace, tags := range definedTags {
for k, v := range tags {
if strVal, ok := v.(string); ok {
resourceTags[fmt.Sprintf("%s.%s", namespace, k)] = strVal
}
}
}
return resourceTags
}
// Package bastion Network-related utilities (hostname extraction and ctx-aware DNS).
package util
import (
"context"
"fmt"
"net"
"strings"
"time"
)
// ExtractHostname removes schema/port/path and returns just the host portion.
func ExtractHostname(endpoint string) string {
if endpoint == "" {
return ""
}
if strings.Contains(endpoint, "://") {
parts := strings.SplitN(endpoint, "://", 2)
endpoint = parts[1]
}
host := endpoint
if i := strings.IndexByte(host, '/'); i >= 0 {
host = host[:i]
}
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
return host
}
// ResolveHostToIP resolves the hostname to the first IP (IPv4/IPv6). It uses ctx so
// cancellation/timeouts propagate.
func ResolveHostToIP(ctx context.Context, hostname string) (string, error) {
var r net.Resolver
ips, err := r.LookupIP(ctx, "ip", hostname)
if err != nil {
return "", fmt.Errorf("resolve hostname %s: %w", hostname, err)
}
if len(ips) == 0 {
return "", fmt.Errorf("no IPs found for hostname %s", hostname)
}
return ips[0].String(), nil
}
// IsLocalTCPPortInUse checks if something is already listening on 127.0.0.1:port.
// It uses a short dial attempt; if successful, the port is in use.
func IsLocalTCPPortInUse(port int) bool {
addr := fmt.Sprintf("127.0.0.1:%d", port)
c, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
if err == nil {
_ = c.Close()
return true
}
return false
}
package util
import (
"fmt"
"strings"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/printer"
"github.com/jedib0t/go-pretty/v6/text"
)
// MarshalDataToJSONResponse now accepts a printer and returns an error.
func MarshalDataToJSONResponse[T any](p *printer.Printer, items []T, pagination *PaginationInfo) error {
response := JSONResponse[T]{
Items: items,
Pagination: pagination,
}
return p.MarshalToJSON(response)
}
// FormatColoredTitle builds a colorized title string with tenancy, compartment, and cluster.
func FormatColoredTitle(appCtx *app.ApplicationContext, name string) string {
coloredTenancy := text.Colors{text.FgMagenta}.Sprint(appCtx.TenancyName)
coloredCompartment := text.Colors{text.FgCyan}.Sprint(appCtx.CompartmentName)
coloredName := text.Colors{text.FgBlue}.Sprint(name)
title := fmt.Sprintf("%s: %s: %s", coloredTenancy, coloredCompartment, coloredName)
return title
}
// SplitTextByMaxWidth splits a space-separated string into multiple lines
// to ensure they are all visible in the table output with a maximum width per line.
func SplitTextByMaxWidth(text string) []string {
if text == "" {
return []string{""}
}
parts := strings.Fields(text)
if len(parts) <= 1 {
return []string{text}
}
result := make([]string, 0)
currentLine := parts[0]
for i := 1; i < len(parts); i++ {
if len(currentLine)+len(parts[i])+1 > 30 {
result = append(result, currentLine)
currentLine = parts[i]
} else {
currentLine += " " + parts[i]
}
}
result = append(result, currentLine)
return result
}
// FormatBool returns a consistent string representation for booleans used in table outputs.
// It mirrors the style used elsewhere in the codebase (e.g., fmt with %t), yielding "true"/"false".
func FormatBool(b bool) string {
if b {
return "Yes"
}
return "No"
}
package util
import (
"fmt"
"io"
"github.com/cnopslabs/ocloud/internal/app"
"github.com/cnopslabs/ocloud/internal/logger"
)
// PaginateSlice returns a page of items from the full slice, along with the total count and next page token.
// If pageNum <= 0, it is treated as 1. If the start index exceeds the total count, an empty page is returned.
// The next page token is a string page number (e.g., "2") or empty when there is no next page.
func PaginateSlice[T any](all []T, limit, pageNum int) ([]T, int, string) {
if pageNum <= 0 {
pageNum = 1
}
if limit <= 0 {
limit = 1
}
total := len(all)
start := (pageNum - 1) * limit
end := start + limit
if start >= total {
return []T{}, total, ""
}
if end > total {
end = total
}
paged := all[start:end]
next := ""
if end < total {
next = fmt.Sprintf("%d", pageNum+1)
}
return paged, total, next
}
// LogPaginationInfo logs pagination information if available and prints it to the output.
func LogPaginationInfo(pagination *PaginationInfo, appCtx *app.ApplicationContext) {
// Log pagination information if available
if pagination != nil {
// Determine if there's a next page
hasNextPage := pagination.NextPageToken != ""
// Log pagination information at the INFO level
appCtx.Logger.Info("--- Pagination Information ---",
"page", pagination.CurrentPage,
"records", fmt.Sprintf("%d", pagination.TotalCount),
"limit", pagination.Limit,
"nextPage", hasNextPage)
// Print pagination information to the output if stdout is available
if appCtx.Stdout != nil {
fmt.Fprintf(appCtx.Stdout, "Page %d | Total: %d\n", pagination.CurrentPage, pagination.TotalCount)
}
// Add debug logs for navigation hints
if pagination.CurrentPage > 1 {
logger.LogWithLevel(appCtx.Logger, logger.Trace, "Pagination navigation",
"action", "previous page",
"page", pagination.CurrentPage-1,
"limit", pagination.Limit)
}
// Check if there are more pages after the current page
// The most direct way to determine if there are more pages is to check if there's a next page token
if pagination.NextPageToken != "" {
logger.LogWithLevel(appCtx.Logger, logger.Trace, "Pagination navigation",
"action", "next page",
"page", pagination.CurrentPage+1,
"limit", pagination.Limit)
}
}
}
// AdjustPaginationInfo adjusts the pagination information to ensure that the total count
// is correctly displayed. It calculates the total records displayed so far and updates
// the TotalCount field of the pagination object to match this value.
func AdjustPaginationInfo(pagination *PaginationInfo) {
// Calculate the total records displayed so far
totalRecordsDisplayed := pagination.CurrentPage * pagination.Limit
if totalRecordsDisplayed > pagination.TotalCount {
totalRecordsDisplayed = pagination.TotalCount
}
// Update the total count to match the total records displayed so far
// This ensures that on page 1 we show 20, on page 2 we show 40, on page 3 we show 60, etc.
pagination.TotalCount = totalRecordsDisplayed
}
// ValidateAndReportEmpty handles the case when a generic list is empty and provides pagination hints.
func ValidateAndReportEmpty[T any](items []T, pagination *PaginationInfo, out io.Writer) bool {
if len(items) > 0 {
return false
}
fmt.Fprintf(out, "No Items found.\n")
if pagination != nil && pagination.TotalCount > 0 {
fmt.Fprintf(out, "Page %d is empty. Total records: %d\n", pagination.CurrentPage, pagination.TotalCount)
if pagination.CurrentPage > 1 {
fmt.Fprintf(out, "Try a lower page number (e.g., --page %d)\n", pagination.CurrentPage-1)
}
}
return true
}
package util
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
// PromptYesNo prompts the user with a yes or no question and returns true for 'yes' and false for 'no'.
func PromptYesNo(question string) bool {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Printf("%s [y/n]: ", question)
input, err := reader.ReadString('\n')
if err != nil {
return false
}
input = strings.ToLower(strings.TrimSpace(input))
if input == "y" || input == "yes" {
return true
} else if input == "n" || input == "no" {
return false
} else {
fmt.Println("Please enter y or n.")
}
}
}
// PromptPort prompts the user to enter a TCP port. If the user enters empty input, the defaultPort is returned.
// It validates the port is in range [1, 65535].
func PromptPort(question string, defaultPort int) (int, error) {
reader := bufio.NewReader(os.Stdin)
for {
if defaultPort > 0 {
fmt.Printf("%s [%d]: ", question, defaultPort)
} else {
fmt.Printf("%s: ", question)
}
input, err := reader.ReadString('\n')
if err != nil {
return 0, err
}
input = strings.TrimSpace(input)
if input == "" && defaultPort > 0 {
return defaultPort, nil
}
p, err := strconv.Atoi(input)
if err != nil || p < 1 || p > 65535 {
fmt.Println("Please enter a valid port number between 1 and 65535.")
continue
}
return p, nil
}
}
// PromptString prompts the user to enter a string. If the user enters empty input and defaultVal is provided, defaultVal is returned.
func PromptString(question string, defaultVal string) (string, error) {
reader := bufio.NewReader(os.Stdin)
if strings.TrimSpace(defaultVal) != "" {
fmt.Printf("%s [%s]: ", question, defaultVal)
} else {
fmt.Printf("%s: ", question)
}
input, err := reader.ReadString('\n')
if err != nil {
return "", err
}
input = strings.TrimSpace(input)
if input == "" {
return defaultVal, nil
}
return input, nil
}
package util
import (
"fmt"
"strconv"
"strings"
"github.com/blevesearch/bleve/v2"
bleveQuery "github.com/blevesearch/bleve/v2/search/query"
)
// BuildIndex creates an in-memory Bleve index from a slice of items using a provided mapping function.
// The mapping function converts each item into an indexable structure for searching.
// Returns the built index or an error if any indexing operation fails.
func BuildIndex[T any](items []T, mapToIndexable func(T) any) (bleve.Index, error) {
indexMapping := bleve.NewIndexMapping()
index, err := bleve.NewMemOnly(indexMapping)
if err != nil {
return nil, fmt.Errorf("creating index: %w", err)
}
for i, item := range items {
err := index.Index(fmt.Sprintf("%d", i), mapToIndexable(item))
if err != nil {
return nil, fmt.Errorf("indexing failed at %d: %w", i, err)
}
}
return index, nil
}
// FuzzySearchIndex performs a fuzzy search on a Bleve index for a given pattern across specified fields.
// It combines fuzzy, prefix, and wildcard queries, limits the results, and returns matched indices or an error.
func FuzzySearchIndex(index bleve.Index, pattern string, fields []string) ([]int, error) {
var limit = 1000
var queries []bleveQuery.Query
for _, field := range fields {
// Fuzzy match (Levenshtein distance)
fq := bleve.NewFuzzyQuery(pattern)
fq.SetField(field)
fq.SetFuzziness(2)
queries = append(queries, fq)
// Prefix match (useful for dev1, splunkdev1, etc.)
pq := bleve.NewPrefixQuery(pattern)
pq.SetField(field)
queries = append(queries, pq)
// Wildcard match (matches anywhere in token)
wq := bleve.NewWildcardQuery("*" + pattern + "*")
wq.SetField(field)
queries = append(queries, wq)
}
// OR all queries together
combinedQuery := bleve.NewDisjunctionQuery(queries...)
search := bleve.NewSearchRequestOptions(combinedQuery, limit, 0, false)
result, err := index.Search(search)
if err != nil {
return nil, err
}
var hits []int
for _, hit := range result.Hits {
idx, err := strconv.Atoi(hit.ID)
if err != nil {
continue
}
hits = append(hits, idx)
}
return hits, nil
}
// FlattenTags flattens freeform and defined tags into a single string with a specific format suitable for indexing.
// Freeform tags are processed as key:value pairs, while defined tags include namespace, key, and value.
// Returns the flattened string or an empty string if no valid tags are found.
func FlattenTags(freeform map[string]string, defined map[string]map[string]interface{}) (string, error) {
var parts []string
// Handle Freeform Tags
if freeform != nil {
for k, v := range freeform {
if k == "" || v == "" {
continue
}
parts = append(parts, fmt.Sprintf("%s:%s", strings.ToLower(k), strings.ToLower(v)))
}
}
// Handle Defined Tags
if defined != nil {
for ns, kv := range defined {
if ns == "" || kv == nil {
continue
}
for k, v := range kv {
if k == "" || v == nil {
continue
}
var valueStr string
switch val := v.(type) {
case string:
valueStr = val
default:
// fallback to fmt.Sprintf
valueStr = fmt.Sprintf("%v", val)
}
parts = append(parts, fmt.Sprintf("%s.%s:%s", strings.ToLower(ns), strings.ToLower(k), valueStr))
}
}
}
if len(parts) == 0 {
return "", nil // Return an empty string without error when no tags are found
}
return strings.Join(parts, " "), nil
}
// ExtractTagValues extracts only the values from freeform and defined tags into a single space-separated string.
// This is useful for making tag values directly searchable without requiring the key prefix.
// Returns the extracted values string or an empty string if no valid tag values are found.
func ExtractTagValues(freeform map[string]string, defined map[string]map[string]interface{}) (string, error) {
var values []string
// Extract values from Freeform Tags
if freeform != nil {
for _, v := range freeform {
if v == "" {
continue
}
values = append(values, strings.ToLower(v))
}
}
// Extract values from Defined Tags
if defined != nil {
for _, kv := range defined {
if kv == nil {
continue
}
for _, v := range kv {
if v == nil {
continue
}
var valueStr string
switch val := v.(type) {
case string:
valueStr = val
default:
valueStr = fmt.Sprintf("%v", val)
}
if valueStr != "" {
values = append(values, strings.ToLower(valueStr))
}
}
}
}
if len(values) == 0 {
return "", nil // Return an empty string without error when no tag values are found
}
return strings.Join(values, " "), nil
}
package tui
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
// ResourceItemData is a lightweight DTO for the list.
type ResourceItemData struct {
ID, Title, Description string
}
// resourceItem implements bubbles/list.Item.
type resourceItem struct {
id, title, description string
}
func (i resourceItem) Title() string { return i.title }
func (i resourceItem) Description() string { return i.description }
func (i resourceItem) FilterValue() string { return i.title + " " + i.description }
// KeyMap defines key bindings TODO: export if you want callers to override.
type KeyMap struct {
Confirm key.Binding
Quit key.Binding
}
// DefaultKeyMap returns a sensible default.
func DefaultKeyMap() KeyMap {
return KeyMap{
Confirm: key.NewBinding(key.WithKeys("enter")),
Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c")),
}
}
// Model is a reusable Bubble Tea model for a searchable list of resources.
type Model struct {
list list.Model
choice string
confirmed bool
keys KeyMap
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2)
case tea.KeyMsg:
if key.Matches(msg, m.keys.Quit) {
m.confirmed = false
return m, tea.Quit
}
if key.Matches(msg, m.keys.Confirm) {
if it, ok := m.list.SelectedItem().(resourceItem); ok {
m.choice = it.id
m.confirmed = true
}
return m, tea.Quit
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) View() string { return m.list.View() }
func (m Model) Choice() string { return m.choice }
// NewModel creates a list model from arbitrary data by using an adapter.
// adapter maps T -> ResourceItemData (id/title/description).
func NewModel[T any](title string, data []T, adapter func(T) ResourceItemData) Model {
items := make([]list.Item, 0, len(data))
for _, d := range data {
ri := adapter(d)
items = append(items, resourceItem{id: ri.ID, title: ri.Title, description: ri.Description})
}
delegate := list.NewDefaultDelegate()
l := list.New(items, delegate, 0, 0)
l.Title = title
l.SetShowTitle(true)
l.SetShowHelp(true)
l.SetFilteringEnabled(true)
l.SetShowFilter(true)
m := Model{list: l, keys: DefaultKeyMap()}
return m
}
package tui
import (
"errors"
tea "github.com/charmbracelet/bubbletea"
)
// ErrCancelled is returned when the user quits without confirming.
var ErrCancelled = errors.New("selection cancelled")
// Run returns the selected ID, or ErrCancelled if the user quit without confirming.
func Run(m Model) (string, error) {
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return "", err
}
if mm, ok := finalModel.(Model); ok {
if !mm.confirmed || mm.choice == "" {
return "", ErrCancelled
}
return mm.choice, nil
}
return "", ErrCancelled
}
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/cnopslabs/ocloud/cmd"
"github.com/oracle/oci-go-sdk/v65/common"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := cmd.Execute(ctx); err != nil {
var serviceErr common.ServiceError
if errors.As(err, &serviceErr) && serviceErr.GetHTTPStatusCode() == 401 {
fmt.Fprintf(os.Stderr, "Authentication failed (%d %s). Please run `ocloud config session authenticate` to configure your profile \n", serviceErr.GetHTTPStatusCode(), serviceErr.GetCode())
} else {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(1)
}
}