package image
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/compute/image"
"github.com/spf13/cobra"
)
// Long description for the find command
var findLong = `
Find images in the specified compartment that match the given pattern.
The search is performed using a fuzzy matching algorithm that searches across multiple fields:
Searchable Fields:
- Name: Image name
- ImageOSVersion: Operating system version of the image
- OperatingSystem: Operating system of the image
- LunchMode: Launch mode of the image
- Tags: All image tags in format (e.g., "flock")
The search pattern is automatically wrapped with wildcards, so partial matches are supported.
For example, searching for "oracle" will match "oracle" etc.
`
// Examples for the find command
var findExamples = `
# Find images with "oracle" in their name
ocloud compute image find oracle
# Find images with a specific operating system
ocloud compute image find linux
# Find images with a specific tag value (searching just the value)
ocloud compute image find 8.10
# Find images with "server" in their name and output in JSON format
ocloud compute image find server --json
# Find images with a specific launch mode
ocloud compute image find native
`
// NewFindCmd creates a new command for finding image by name pattern
func NewFindCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "find [pattern]",
Aliases: []string{"f"},
Short: "Find image 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 image find command", "pattern", namePattern, "in compartment", appCtx.CompartmentName, "json", useJSON)
err := image.FindImages(appCtx, namePattern, useJSON)
if err != nil {
return err
}
return nil
}
package image
import (
paginationFlags "github.com/rozdolsky33/ocloud/cmd/flags"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/compute/image"
"github.com/spf13/cobra"
)
// Dedicated documentation for the get command
var listLong = `
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 listExamples = `
# 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: "Get all images",
Long: listLong,
Example: listExamples,
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 image list command in", "compartment", appCtx.CompartmentName, "limit", limit, "page", page, "json", useJSON)
err := image.GetImages(appCtx, limit, page, useJSON)
if err != nil {
return err
}
return nil
}
package image
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/compute/image"
"github.com/spf13/cobra"
)
// Dedicated documentation for the list command (separate from get)
var listCmdLong = `
Interactively browse and search images in the specified compartment using a TUI.
This command launches a Bubble Tea-based terminal UI that loads available images and lets you:
- Search/filter images 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.
`
var listCmdExamples = `
# Launch the interactive image browser
ocloud compute image list
# Use fuzzy search in the UI to quickly find what you need
ocloud compute image list
`
// 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: listCmdLong,
Example: listCmdExamples,
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()
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running image list (TUI) command in", "compartment", appCtx.CompartmentName)
err := image.ListImages(ctx, appCtx)
if err != nil {
return err
}
return nil
}
package image
import (
"github.com/rozdolsky33/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 Image",
Long: "Manage Oracle Cloud Infrastructure Compute Image - list all image or find image by name pattern.",
Example: " ocloud compute image list\n ocloud compute image find <image-name>",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewGetCmd(appCtx))
cmd.AddCommand(NewFindCmd(appCtx))
cmd.AddCommand(NewListCmd(appCtx))
return cmd
}
package instance
import (
instaceFlags "github.com/rozdolsky33/ocloud/cmd/compute/flags"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/compute/instance"
"github.com/spf13/cobra"
)
var findLong = `
Find instances in the specified compartment that match the given pattern.
The search is performed using a fuzzy matching algorithm that searches across multiple fields:
Searchable Fields:
- Name: Instance name
- ImageName: Name of the image used by the instance
- ImageOperatingSystem: Operating system of the image
- TagValues: Just the values of tags without keys (e.g., "8.10")
The search pattern is automatically wrapped with wildcards, so partial matches are supported.
For example, searching for "web" will match "webserver" etc.
`
var findExamples = `
# Find instances with "web" in their name
ocloud compute instance find web
# Find instances with a specific tag value (searching just the value)
ocloud compute instance find 8.10
# Find instances with "api" in their name and include image details
ocloud compute instance find api --all
# Find instances with "server" in their name and output in JSON format
ocloud compute instance find server --json
# Find instances with "oracle" in their image operating system
ocloud compute instance find oracle
`
// NewFindCmd creates a new command for finding instances by name pattern
func NewFindCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "find [pattern]",
Aliases: []string{"f"},
Short: "Find instances 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)
},
}
instaceFlags.ImageDetailsFlag.Add(cmd)
return cmd
}
// RunFindCommand handles the execution of the find command
func RunFindCommand(cmd *cobra.Command, args []string, appCtx *app.ApplicationContext) error {
namePattern := args[0]
showDetails := flags.GetBoolFlag(cmd, flags.FlagNameAllInformation, false)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running instance find command", "pattern", namePattern, "in compartment", appCtx.CompartmentName, "json", useJSON)
err := instance.FindInstances(appCtx, namePattern, useJSON, showDetails)
if err != nil {
return err
}
return nil
}
package instance
import (
instaceFlags "github.com/rozdolsky33/ocloud/cmd/compute/flags"
paginationFlags "github.com/rozdolsky33/ocloud/cmd/flags"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/compute/instance"
"github.com/spf13/cobra"
)
var listLong = `
List 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 information about the image used by each instance
- Use --json (-j) to output the results in JSON format
- The command only shows running instances by default
`
var listExamples = `
# List all instances with default pagination (20 per page)
ocloud compute instance list
# List instances with custom pagination (10 per page, page 2)
ocloud compute instance list --limit 10 --page 2
# List instances and include image details
ocloud compute instance list --all
# List instances with image details (using shorthand flag)
ocloud compute instance list -A
# List instances and output in JSON format
ocloud compute instance list --json
# List instances with both image details and JSON output
ocloud compute instance list --all --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)
},
}
paginationFlags.LimitFlag.Add(cmd)
paginationFlags.PageFlag.Add(cmd)
instaceFlags.ImageDetailsFlag.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)
imageDetails := flags.GetBoolFlag(cmd, flags.FlagNameAllInformation, false)
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running instance list command in", "compartment", appCtx.CompartmentName, "limit", limit, "page", page, "json", useJSON, "imageDetails", imageDetails)
err := instance.ListInstances(appCtx, useJSON, limit, page, imageDetails)
if err != nil {
return err
}
return nil
}
package instance
import (
"github.com/spf13/cobra"
"github.com/rozdolsky33/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 Instances",
Long: "Manage Oracle Cloud Infrastructure Compute Instances - list all instances or find instances by name pattern.",
Example: " ocloud compute instance list\n ocloud compute instance find myinstance",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewFindCmd(appCtx))
return cmd
}
package oke
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/compute/oke"
"github.com/spf13/cobra"
)
var findLong = `
Find Oracle Kubernetes Engine (OKE) clusters in the specified compartment that match the given pattern.
This command searches for OKE clusters whose names or node pool names match the specified pattern.
By default, it shows detailed cluster information such as name, ID, Kubernetes version,
endpoint, 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 findExamples = `
# Find clusters with names containing "prod"
ocloud compute oke find prod
# Find clusters with names containing "dev" and output in JSON format
ocloud compute oke find dev --json
# Find clusters with names containing "test" (case-insensitive)
ocloud compute oke find test
`
// NewFindCmd creates a new command for finding OKE clusters by name pattern
func NewFindCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "find [pattern]",
Aliases: []string{"f"},
Short: "Find OKE clusters 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 oke find command", "pattern", namePattern, "in compartment", appCtx.CompartmentName, "json", useJSON)
err := oke.FindClusters(appCtx, namePattern, useJSON)
if err != nil {
return err
}
return nil
}
package oke
import (
paginationFlags "github.com/rozdolsky33/ocloud/cmd/flags"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/compute/oke"
"github.com/spf13/cobra"
)
var listLong = `
List 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 listExamples = `
# List all OKE clusters in the current compartment
ocloud compute oke list
# List all OKE clusters and output in JSON format
ocloud compute oke list --json
# List OKE clusters with pagination (10 per page, page 2)
ocloud compute oke list --limit 10 --page 2
`
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 {
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 list command in", "compartment", appCtx.CompartmentName, "json", useJSON)
err := oke.ListClusters(appCtx, useJSON, limit, page)
if err != nil {
return err
}
return nil
}
package oke
import (
"github.com/rozdolsky33/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.\n\nThis command allows you to list all clusters in a compartment or find specific clusters by name 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 find myoke\n ocloud compute oke find myoke --json",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewFindCmd(appCtx))
return cmd
}
package compute
import (
"github.com/rozdolsky33/ocloud/cmd/compute/image"
"github.com/rozdolsky33/ocloud/cmd/compute/instance"
"github.com/rozdolsky33/ocloud/cmd/compute/oke"
"github.com/spf13/cobra"
"github.com/rozdolsky33/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, image, and more.",
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/rozdolsky33/ocloud/cmd/configuration/flags"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/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)
err := auth.AuthenticateWithOCI(filter, realm)
if err != nil {
return err
}
logger.CmdLogger.V(logger.Info).Info("Authentication command completed.")
return nil
}
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/rozdolsky33/ocloud/cmd/configuration/flags"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/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)
err := info.ViewConfiguration(useJSON, realm)
if err != nil {
return err
}
return nil
}
package configuration
import (
"github.com/rozdolsky33/ocloud/cmd/configuration/auth"
"github.com/rozdolsky33/ocloud/cmd/configuration/info"
"github.com/rozdolsky33/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/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/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")
err := setup.SetupTenancyMapping()
if err != nil {
return err
}
logger.CmdLogger.V(logger.Info).Info("Setup command completed.")
return nil
}
package cmd
import (
"context"
"fmt"
"os"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/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 (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
ociadb "github.com/rozdolsky33/ocloud/internal/oci/database/autonomousdb"
"github.com/rozdolsky33/ocloud/internal/services/database/autonomousdb"
"github.com/spf13/cobra"
)
// Long description for the find command
var findLong = `
Find Autonomous Databases in the specified compartment that match the given pattern.
The search is performed using a fuzzy matching algorithm that searches across multiple fields:
Searchable Fields:
- Name: Database name
- DisplayName: Display name of the database
- DbName: Database name
The search pattern is automatically wrapped with wildcards, so partial matches are supported.
For example, searching for "prod" will match "production", etc.
`
// Examples for the find command
var findExamples = `
# Find Autonomous Databases with "prod" in their name
ocloud database autonomous find prod
# Find Autonomous Databases with "test" in their name and output in JSON format
ocloud database autonomous find test --json
`
// NewFindCmd creates a new command for finding compartments by name pattern
func NewFindCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "find [pattern]",
Aliases: []string{"f"},
Short: "Find Database 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
}
//TODO:
// 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 find command", "pattern", namePattern, "json", useJSON)
repo, err := ociadb.NewAdapter(appCtx.Provider, appCtx.CompartmentID)
if err != nil {
return err
}
service := autonomousdb.NewService(repo, appCtx)
databases, err := service.Find(cmd.Context(), namePattern)
if err != nil {
return err
}
// Convert a service type to a domain type for output
domainDbs := make([]domain.AutonomousDatabase, 0, len(databases))
for _, db := range databases {
domainDbs = append(domainDbs, domain.AutonomousDatabase(db))
}
err = autonomousdb.PrintAutonomousDbInfo(domainDbs, appCtx, nil, useJSON)
if err != nil {
return err
}
logger.CmdLogger.V(logger.Info).Info("Autonomous DB find command completed.")
return nil
}
package autonomousdb
import (
paginationFlags "github.com/rozdolsky33/ocloud/cmd/flags"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/domain"
ociadb "github.com/rozdolsky33/ocloud/internal/oci/database/autonomousdb"
"github.com/rozdolsky33/ocloud/internal/services/database/autonomousdb"
"github.com/rozdolsky33/ocloud/internal/services/util"
"github.com/spf13/cobra"
)
// Long description for the list command
var listLong = `
List all 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 listExamples = `
# List all Autonomous Databases with default pagination (20 per page)
ocloud database autonomous list
# List Autonomous Databases with custom pagination (10 per page, page 2)
ocloud database autonomous list --limit 10 --page 2
# List Autonomous Databases and output in JSON format
ocloud database autonomous list --json
# List Autonomous Databases with custom pagination and JSON output
ocloud database autonomous list --limit 5 --page 3 --json
`
// NewListCmd creates a "list" subcommand for listing all databases in the specified compartment with pagination support.
func NewListCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "List all Databases in the specified 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)
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)
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, paginationFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, paginationFlags.FlagDefaultPage)
repo, err := ociadb.NewAdapter(appCtx.Provider, appCtx.CompartmentID)
if err != nil {
return err
}
service := autonomousdb.NewService(repo, appCtx)
databases, totalCount, nextPageToken, err := service.List(cmd.Context(), limit, page)
if err != nil {
return err
}
// Convert a service type to a domain type for output
domainDbs := make([]domain.AutonomousDatabase, 0, len(databases))
for _, db := range databases {
domainDbs = append(domainDbs, domain.AutonomousDatabase(db))
}
return autonomousdb.PrintAutonomousDbInfo(domainDbs, appCtx, &util.PaginationInfo{
TotalCount: totalCount,
Limit: limit,
CurrentPage: page,
NextPageToken: nextPageToken,
}, useJSON)
}
package autonomousdb
import (
"github.com/rozdolsky33/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 Compartments",
Long: "Manage Oracle Cloud Infrastructure Databases - list all databases or find database by pattern.",
Example: " ocloud database autonomous list \n ocloud database autonomous find mydatabase",
SilenceUsage: true,
SilenceErrors: true,
}
// Add subcommands
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewFindCmd(appCtx))
return cmd
}
package database
import (
"github.com/rozdolsky33/ocloud/cmd/database/autonomousdb"
"github.com/rozdolsky33/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,
}
// Add subcommands, passing in the ApplicationContext
cmd.AddCommand(autonomousdb.NewAutonomousDatabaseCmd(appCtx))
return cmd
}
// Package bastion Command wiring and orchestration for "bastion creates".
package bastion
import (
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
bastionSvc "github.com/rozdolsky33/ocloud/internal/services/identity/bastion"
"github.com/rozdolsky33/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/oracle/oci-go-sdk/v65/bastion"
"github.com/rozdolsky33/ocloud/internal/app"
bastionSvc "github.com/rozdolsky33/ocloud/internal/services/identity/bastion"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// 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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
ociadb "github.com/rozdolsky33/ocloud/internal/oci/database/autonomousdb"
adbSvc "github.com/rozdolsky33/ocloud/internal/services/database/autonomousdb"
bastionSvc "github.com/rozdolsky33/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, appCtx.CompartmentID)
if err != nil {
return fmt.Errorf("error creating database adapter: %w", err)
}
dbService := adbSvc.NewService(adapter, appCtx)
dbs, _, _, err := dbService.List(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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/oci"
ociInst "github.com/rozdolsky33/ocloud/internal/oci/compute/instance"
instSvc "github.com/rozdolsky33/ocloud/internal/services/compute/instance"
bastionSvc "github.com/rozdolsky33/ocloud/internal/services/identity/bastion"
"github.com/rozdolsky33/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.List(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(1).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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/oci"
ociInst "github.com/rozdolsky33/ocloud/internal/oci/compute/instance"
ociOke "github.com/rozdolsky33/ocloud/internal/oci/compute/oke"
instSvc "github.com/rozdolsky33/ocloud/internal/services/compute/instance"
okeSvc "github.com/rozdolsky33/ocloud/internal/services/compute/oke"
bastionSvc "github.com/rozdolsky33/ocloud/internal/services/identity/bastion"
"github.com/rozdolsky33/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.List(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.List(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(1).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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/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: "List 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)
err := bastion.ListBastions(ctx, appCtx, useJSON)
if err != nil {
return err
}
return nil
}
package bastion
import (
"github.com/rozdolsky33/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/rozdolsky33/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/rozdolsky33/ocloud/internal/services/compute/instance"
okeSvc "github.com/rozdolsky33/ocloud/internal/services/compute/oke"
adbSvc "github.com/rozdolsky33/ocloud/internal/services/database/autonomousdb"
bastionSvc "github.com/rozdolsky33/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 (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/identity/compartment"
"github.com/spf13/cobra"
)
// Long description for the find command
var findLong = `
Find Compartments in the specified tenancy that match the given pattern.
This command searches for compartments whose names match the specified pattern.
By default, it shows basic compartment information such as name, ID, and description
for all matching compartments.
The search is performed using fuzzy matching, which means it will find compartments
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 compartments in the tenancy
`
// Examples for the find command
var findExamples = `
# Find compartments with names containing "prod"
ocloud identity compartment find prod
# Find compartments with names containing "dev" and output in JSON format
ocloud identity compartment find dev --json
# Find compartments with names containing "test" (case-insensitive)
ocloud identity compartment find test
`
// NewFindCmd creates a new command for finding compartments by name pattern
func NewFindCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "find [pattern]",
Aliases: []string{"f"},
Short: "Find compartment 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 find command", "pattern", namePattern, "json", useJSON)
err := compartment.FindCompartments(appCtx, namePattern, useJSON)
if err != nil {
return err
}
logger.CmdLogger.V(logger.Info).Info("Compartment find command completed.")
return nil
}
package compartment
import (
paginationFlags "github.com/rozdolsky33/ocloud/cmd/flags"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/identity/compartment"
"github.com/spf13/cobra"
)
// Long description for the list command
var listLong = `
List all Compartments in the specified tenancy or compartment with pagination support.
This command displays information about compartments in the current tenancy.
By default, it shows basic compartment information such as name, ID, and description.
The output is paginated, with a default limit of 20 compartments per page. You can navigate
through pages using the --page flag and control the number of compartments per page with
the --limit flag.
Additional Information:
- Use --json (-j) to output the results in JSON format
- The command shows all available compartments in the tenancy
`
// Examples for the list command
var listExamples = `
# List all compartments with default pagination (20 per page)
ocloud identity compartment list
# List compartments with custom pagination (10 per page, page 2)
ocloud identity compartment list --limit 10 --page 2
# List compartments and output in JSON format
ocloud identity compartment list --json
# List compartments with custom pagination and JSON output
ocloud identity compartment list --limit 5 --page 3 --json
`
// NewListCmd creates a new Cobra command for listing 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)
},
}
// Add flags specific to the list command
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 {
// Get pagination parameters
limit := flags.GetIntFlag(cmd, flags.FlagNameLimit, paginationFlags.FlagDefaultLimit)
page := flags.GetIntFlag(cmd, flags.FlagNamePage, paginationFlags.FlagDefaultPage)
useJSON := flags.GetBoolFlag(cmd, flags.FlagNameJSON, false)
// Use LogWithLevel to ensure debug logs work with shorthand flags
logger.LogWithLevel(logger.CmdLogger, logger.Debug, "Running compartment list command in", "compartment", appCtx.CompartmentName, "json", useJSON)
err := compartment.ListCompartments(appCtx, useJSON, limit, page)
if err != nil {
return err
}
logger.CmdLogger.V(logger.Info).Info("Compartment list command completed.")
return nil
}
package compartment
import (
"github.com/rozdolsky33/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"},
Short: "Manage OCI Compartments",
Long: "Manage Oracle Cloud Infrastructure Compartments - list all compartments or find compartment by pattern.",
Example: " ocloud identity compartment list \n ocloud identity compartment find mycompartment",
SilenceUsage: true,
SilenceErrors: true,
}
// Add subcommands
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewFindCmd(appCtx))
return cmd
}
package policy
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/identity/policy"
"github.com/spf13/cobra"
)
// Long description for the find command
var findLong = `
Find Policies in the specified compartment that match the given pattern.
The search is performed using a fuzzy matching algorithm that searches across multiple fields:
Searchable Fields:
- Name: Policy name
- Description: Description of the policy
- Statement: Policy statements
The search pattern is automatically wrapped with wildcards, so partial matches are supported.
For example, searching for "admin" will match "administrators" etc.
`
// Examples for the find command
var findExamples = `
# Find Policies with "admin" in their name
ocloud identity policy find admin
# Find Policies with "network" in their name and output in JSON format
ocloud identity policy find network --json
`
// NewFindCmd creates a new command for finding policies by name pattern
func NewFindCmd(appCtx *app.ApplicationContext) *cobra.Command {
cmd := &cobra.Command{
Use: "find [pattern]",
Aliases: []string{"f"},
Short: "Find Policies 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 policy find command", "pattern", namePattern, "json", useJSON)
err := policy.FindPolicies(appCtx, namePattern, useJSON)
if err != nil {
return err
}
logger.CmdLogger.V(logger.Info).Info("Policy find command completed.")
return nil
}
package policy
import (
paginationFlags "github.com/rozdolsky33/ocloud/cmd/flags"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/identity/policy"
"github.com/spf13/cobra"
)
// Long description for the list command
var listLong = `
List all Policies in the specified compartment with pagination support.
This command displays information about available Policies in the current compartment.
By default, it shows basic policy information such as name, ID, and description.
The output is paginated, with a default limit of 20 policies per page. You can navigate
through pages using the --page flag and control the number of policies per page with
the --limit flag.
Additional Information:
- Use --json (-j) to output the results in JSON format
- The command shows all available Policies in the compartment
`
// Examples for the list command
var listExamples = `
# List all Policies with default pagination (20 per page)
ocloud identity policy list
# List Policies with custom pagination (10 per page, page 2)
ocloud identity policy list --limit 10 --page 2
# List Policies and output in JSON format
ocloud identity policy list --json
# List Policies with custom pagination and JSON output
ocloud identity policy list --limit 5 --page 3 --json
`
// NewListCmd creates a new cobra.Command for listing all policies in a specified tenancy or compartment.
// The command supports pagination through the --limit and --page flags for controlling list size and navigation.
// It also provides optional JSON output for formatted results using the --JSON flag.
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)
},
}
// Add flags specific to the list command
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 {
// Get pagination parameters
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 policy list command in", "compartment", appCtx.CompartmentName, "json", useJSON)
err := policy.ListPolicies(appCtx, useJSON, limit, page)
if err != nil {
return err
}
logger.CmdLogger.V(logger.Info).Info("Policy list command completed.")
return nil
}
package policy
import (
"github.com/rozdolsky33/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 all policies or find policy by pattern.",
Example: " ocloud identity policy list \n ocloud identity policy find mypolicy",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(NewListCmd(appCtx))
cmd.AddCommand(NewFindCmd(appCtx))
return cmd
}
package identity
import (
"github.com/rozdolsky33/ocloud/cmd/identity/bastion"
"github.com/rozdolsky33/ocloud/cmd/identity/compartment"
"github.com/rozdolsky33/ocloud/cmd/identity/policy"
"github.com/rozdolsky33/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 and more.",
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 network
import (
"github.com/rozdolsky33/ocloud/cmd/network/subnet"
"github.com/rozdolsky33/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 networking services",
Long: "Manage Oracle Cloud Infrastructure Networking services such as vcn, subnets and more.",
SilenceUsage: true,
SilenceErrors: true,
}
cmd.AddCommand(subnet.NewSubnetCmd(appCtx))
return cmd
}
package subnet
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/network/subnet"
"github.com/spf13/cobra"
)
// Long description for the find command
var findLong = `
Find 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 = `
# Find subnets with names containing "prod"
ocloud network subnet find prod
# Find subnets with names containing "dev" and output in JSON format
ocloud network subnet find dev --json
# Find 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: "Find 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)
err := subnet.FindSubnets(appCtx, namePattern, useJSON)
if err != nil {
return err
}
logger.CmdLogger.V(logger.Info).Info("Subnet find command completed.")
return nil
}
package subnet
import (
paginationFlags "github.com/rozdolsky33/ocloud/cmd/flags"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/network/subnet"
"github.com/spf13/cobra"
)
// Long description for the list command
var listLong = `
List 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 = `
# List all subnets in the current compartment
ocloud network subnet list
# List all subnets and output in JSON format
ocloud network subnet list --json
# List subnets with pagination (10 per page, page 2)
ocloud network subnet list --limit 10 --page 2
# List subnets sorted by name
ocloud network subnet list --sort name
# List 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: "List 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)
err := subnet.ListSubnets(appCtx, useJSON, limit, page, sortBy)
if err != nil {
return err
}
logger.CmdLogger.V(logger.Info).Info("Subnet list command completed.")
return nil
}
package subnet
import (
"github.com/rozdolsky33/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 cmd
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/cmd/shared/cmdcreate"
"github.com/rozdolsky33/ocloud/cmd/shared/cmdutil"
"github.com/rozdolsky33/ocloud/cmd/shared/display"
cmdlogger "github.com/rozdolsky33/ocloud/cmd/shared/logger"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/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,
}
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"
"github.com/rozdolsky33/ocloud/cmd/compute"
"github.com/rozdolsky33/ocloud/cmd/configuration"
"github.com/rozdolsky33/ocloud/cmd/database"
"github.com/rozdolsky33/ocloud/cmd/identity"
"github.com/rozdolsky33/ocloud/cmd/network"
"github.com/rozdolsky33/ocloud/cmd/version"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/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)
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))
}
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 networking 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/fatih/color"
"github.com/rozdolsky33/ocloud/buildinfo"
"github.com/rozdolsky33/ocloud/internal/config"
"github.com/rozdolsky33/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.OCIRefresherPIDFileName)
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/rozdolsky33/ocloud/cmd/version"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/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 version
import (
"fmt"
"io"
"os"
"github.com/rozdolsky33/ocloud/buildinfo"
"github.com/rozdolsky33/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) {
// 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(os.Stdout)
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/rozdolsky33/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/rozdolsky33/ocloud/internal/config"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/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, 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
}
// prepare the base request
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)
}
// scan each compartment summary for a name match
for _, comp := range resp.Items {
if comp.Name != nil && *comp.Name == compartmentName {
return *comp.Id, nil
}
}
// if there's no next page, we're done searching
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/rozdolsky33/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 defines 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 Get*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/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/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 image
import (
"context"
"fmt"
"github.com/oracle/oci-go-sdk/v65/core"
"github.com/rozdolsky33/ocloud/internal/domain"
)
// 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 := a.toDomainModel(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, a.toDomainModel(item))
}
if resp.OpcNextPage == nil {
break
}
page = *resp.OpcNextPage
}
return images, nil
}
// toDomainModel converts an OCI SDK image object to our application's domain model.
func (a *Adapter) toDomainModel(img core.Image) domain.Image {
return domain.Image{
OCID: *img.Id,
DisplayName: *img.DisplayName,
OperatingSystem: *img.OperatingSystem,
OperatingSystemVersion: *img.OperatingSystemVersion,
LaunchMode: string(img.LaunchMode),
TimeCreated: img.TimeCreated.Time,
}
}
package instance
import (
"context"
"fmt"
"net/http"
"sync"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
"github.com/rozdolsky33/ocloud/internal/domain"
)
// 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,
}
}
// ListInstances fetches all running instances in a compartment and enriches them with network and image details.
func (a *Adapter) ListInstances(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 := domain.Instance{
OCID: *ociInstance.Id,
DisplayName: *ociInstance.DisplayName,
State: string(ociInstance.LifecycleState),
Shape: *ociInstance.Shape,
ImageID: *ociInstance.ImageId,
TimeCreated: ociInstance.TimeCreated.Time,
Region: *ociInstance.Region,
AvailabilityDomain: *ociInstance.AvailabilityDomain,
FaultDomain: *ociInstance.FaultDomain,
VCPUs: int(*ociInstance.ShapeConfig.Vcpus),
MemoryGB: *ociInstance.ShapeConfig.MemoryInGBs,
FreeformTags: ociInstance.FreeformTags,
DefinedTags: ociInstance.DefinedTags,
}
vnic, err := a.getPrimaryVnic(ctx, *ociInstance.Id, *ociInstance.CompartmentId)
if err != nil {
errChan <- fmt.Errorf("enriching instance %s with network: %w", dm.OCID, err)
return
}
if vnic != nil {
dm.PrimaryIP = *vnic.PrivateIp
dm.SubnetID = *vnic.SubnetId
if vnic.HostnameLabel != nil {
dm.Hostname = *vnic.HostnameLabel
}
dm.PrivateDNSEnabled = vnic.SkipSourceDestCheck == nil || !*vnic.SkipSourceDestCheck
subnet, err := a.getSubnet(ctx, *vnic.SubnetId)
if err != nil {
errChan <- fmt.Errorf("enriching instance %s with subnet: %w", dm.OCID, err)
return
}
if subnet != nil {
dm.SubnetName = *subnet.DisplayName
dm.VcnID = *subnet.VcnId
if subnet.RouteTableId != nil {
dm.RouteTableID = *subnet.RouteTableId
rt, err := a.getRouteTable(ctx, *subnet.RouteTableId)
if err != nil {
errChan <- fmt.Errorf("enriching instance %s with route table: %w", dm.OCID, err)
return
}
if rt != nil {
dm.RouteTableName = *rt.DisplayName
}
}
vcn, err := a.getVcn(ctx, *subnet.VcnId)
if err != nil {
errChan <- fmt.Errorf("enriching instance %s with vcn: %w", dm.OCID, err)
return
}
if vcn != nil {
dm.VcnName = *vcn.DisplayName
}
}
}
image, err := a.getImage(ctx, *ociInstance.ImageId)
if err != nil {
errChan <- fmt.Errorf("enriching instance %s with image: %w", dm.OCID, err)
return
}
if image != nil {
dm.ImageName = *image.DisplayName
dm.ImageOS = *image.OperatingSystem
}
domainInstances[i] = dm
}(i, ociInstance)
}
wg.Wait()
close(errChan)
for err := range errChan {
return nil, err
}
return domainInstances, 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
maxRetries := 5
initialBackoff := 1 * time.Second
maxBackoff := 32 * time.Second
// Retry ListVnicAttachments with a unified helper
err = retryOnRateLimit(ctx, maxRetries, initialBackoff, maxBackoff, 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
// Retry GetVnic using a unified helper; if it still fails, move on to the next VNIC
vnicErr = retryOnRateLimit(ctx, maxRetries, initialBackoff, maxBackoff, 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
// Retry parameters
maxRetries := 5
initialBackoff := 1 * time.Second
maxBackoff := 32 * time.Second
err = retryOnRateLimit(ctx, maxRetries, initialBackoff, maxBackoff, 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
// Retry
maxRetries := 5
initialBackoff := 1 * time.Second
maxBackoff := 32 * time.Second
err = retryOnRateLimit(ctx, maxRetries, initialBackoff, maxBackoff, 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
// Retry parameters
maxRetries := 5
initialBackoff := 1 * time.Second
maxBackoff := 32 * time.Second
err = retryOnRateLimit(ctx, maxRetries, initialBackoff, maxBackoff, 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
// Retry parameters
maxRetries := 5
initialBackoff := 1 * time.Second
maxBackoff := 32 * time.Second
err = retryOnRateLimit(ctx, maxRetries, initialBackoff, maxBackoff, 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 oke
import (
"context"
"fmt"
"github.com/oracle/oci-go-sdk/v65/containerengine"
"github.com/rozdolsky33/ocloud/internal/domain"
)
// 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}
}
// 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 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 := domain.Cluster{
OCID: *ociCluster.Id,
DisplayName: *ociCluster.Name,
KubernetesVersion: *ociCluster.KubernetesVersion,
VcnOCID: *ociCluster.VcnId,
State: string(ociCluster.LifecycleState),
}
if ociCluster.Endpoints != nil {
if ociCluster.Endpoints.PrivateEndpoint != nil {
dc.PrivateEndpoint = *ociCluster.Endpoints.PrivateEndpoint
}
if ociCluster.Endpoints.Kubernetes != nil {
dc.PublicEndpoint = *ociCluster.Endpoints.Kubernetes
}
}
if ociCluster.Metadata != nil && ociCluster.Metadata.TimeCreated != nil {
dc.TimeCreated = ociCluster.Metadata.TimeCreated.Time
}
if ociCluster.CompartmentId == nil || ociCluster.Id == nil {
domainClusters = append(domainClusters, dc)
continue
}
nodePools, err := a.listNodePools(ctx, *ociCluster.CompartmentId, *ociCluster.Id)
if err != nil {
// In a real-world scenario, you might want to handle this more gracefully
// (e.g., log the error and continue), but for now, we'll fail fast.
return nil, fmt.Errorf("enriching cluster %s with node pools: %w", dc.OCID, err)
}
dc.NodePools = nodePools
domainClusters = append(domainClusters, dc)
}
return domainClusters, 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 {
dnp := domain.NodePool{
OCID: *ociNodePool.Id,
DisplayName: *ociNodePool.Name,
KubernetesVersion: *ociNodePool.KubernetesVersion,
NodeShape: *ociNodePool.NodeShape,
}
if ociNodePool.NodeConfigDetails != nil && ociNodePool.NodeConfigDetails.Size != nil {
dnp.NodeCount = *ociNodePool.NodeConfigDetails.Size
}
domainNodePools = append(domainNodePools, dnp)
}
if resp.OpcNextPage == nil {
break
}
page = resp.OpcNextPage
}
return domainNodePools, nil
}
package autonomousdb
import (
"context"
"fmt"
"github.com/oracle/oci-go-sdk/v65/database"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/oci"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// Adapter implements the domain.AutonomousDatabaseRepository interface for OCI.
type Adapter struct {
dbClient database.DatabaseClient
compartmentID string
}
// NewAdapter creates a new Adapter instance.
func NewAdapter(provider oci.ClientProvider, compartmentID string) (*Adapter, error) {
dbClient, err := oci.NewDatabaseClient(provider)
if err != nil {
return nil, fmt.Errorf("failed to create database client: %w", err)
}
return &Adapter{
dbClient: dbClient,
compartmentID: compartmentID,
}, 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
page := ""
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, mapAutonomousDatabaseSummaryToDomain(item))
}
if resp.OpcNextPage == nil {
break
}
page = *resp.OpcNextPage
}
return allDatabases, nil
}
// FindAutonomousDatabase finds a specific autonomous database by name.
// This implementation fetches all and filters, which might not be optimal for very large numbers of databases.
// OCI SDK does not provide a direct "Get by Name" for autonomous databases.
func (a *Adapter) FindAutonomousDatabase(ctx context.Context, compartmentID, name string) (*domain.AutonomousDatabase, error) {
dbs, err := a.ListAutonomousDatabases(ctx, compartmentID)
if err != nil {
return nil, err
}
for _, db := range dbs {
if db.Name == name {
return &db, nil
}
}
return nil, domain.NewNotFoundError("autonomous database", name)
}
// mapAutonomousDatabaseSummaryToDomain transforms a database.AutonomousDatabaseSummary instance into a domain.AutonomousDatabase struct.
func mapAutonomousDatabaseSummaryToDomain(db database.AutonomousDatabaseSummary) domain.AutonomousDatabase {
return domain.AutonomousDatabase{
Name: *db.DbName,
ID: *db.Id,
PrivateEndpoint: *db.PrivateEndpoint,
PrivateEndpointIp: *db.PrivateEndpointIp,
ConnectionStrings: db.ConnectionStrings.AllConnectionStrings,
Profiles: db.ConnectionStrings.Profiles,
DatabaseTags: util.ConvertOciTagsToResourceTags(db.FreeformTags, db.DefinedTags),
}
}
package identity
import (
"context"
"fmt"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/identity"
"github.com/rozdolsky33/ocloud/internal/domain"
)
// CompartmentAdapter is an infrastructure-layer adapter that implements the domain.CompartmentRepository interface.
type CompartmentAdapter struct {
client identity.IdentityClient
tenancyID string
}
// NewCompartmentAdapter creates a new adapter for interacting with OCI compartments.
func NewCompartmentAdapter(client identity.IdentityClient, tenancyID string) *CompartmentAdapter {
return &CompartmentAdapter{
client: client,
tenancyID: tenancyID,
}
}
// GetCompartment retrieves a single compartment by its OCID.
func (a *CompartmentAdapter) 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)
}
comp := a.toDomainModel(resp.Compartment)
return &comp, nil
}
// ListCompartments retrieves all active compartments under a given parent compartment.
// It handles pagination to fetch all results from OCI.
func (a *CompartmentAdapter) ListCompartments(ctx context.Context, parentCompartmentID string) ([]domain.Compartment, error) {
var compartments []domain.Compartment
page := ""
// If no parent is specified, use the tenancy root.
if parentCompartmentID == "" {
parentCompartmentID = a.tenancyID
}
for {
resp, err := a.client.ListCompartments(ctx, identity.ListCompartmentsRequest{
CompartmentId: &parentCompartmentID,
Page: &page,
AccessLevel: identity.ListCompartmentsAccessLevelAccessible,
LifecycleState: identity.CompartmentLifecycleStateActive,
CompartmentIdInSubtree: common.Bool(true),
})
if err != nil {
return nil, fmt.Errorf("listing compartments from OCI: %w", err)
}
for _, item := range resp.Items {
compartments = append(compartments, a.toDomainModel(item))
}
if resp.OpcNextPage == nil {
break
}
page = *resp.OpcNextPage
}
return compartments, nil
}
// toDomainModel converts an OCI SDK compartment object to our application's domain model.
// This is the anti-corruption layer in action.
func (a *CompartmentAdapter) toDomainModel(c identity.Compartment) domain.Compartment {
var parentID string
if c.CompartmentId != nil {
parentID = *c.CompartmentId
}
var state string
if c.LifecycleState != "" {
state = string(c.LifecycleState)
}
return domain.Compartment{
OCID: *c.Id,
DisplayName: *c.Name,
Description: *c.Description,
LifecycleState: state,
ParentCompartmentID: parentID,
}
}
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 subnet
import (
"context"
"fmt"
"github.com/oracle/oci-go-sdk/v65/core"
"github.com/rozdolsky33/ocloud/internal/domain"
)
// 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) (*domain.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) ([]domain.Subnet, error) {
var subnets []domain.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's domain model.
func (a *Adapter) toDomainModel(s core.Subnet) domain.Subnet {
return domain.Subnet{
OCID: *s.Id,
DisplayName: *s.DisplayName,
CIDRBlock: *s.CidrBlock,
VcnOCID: *s.VcnId,
RouteTableOCID: *s.RouteTableId,
SecurityListOCIDs: s.SecurityListIds,
DhcpOptionsOCID: *s.DhcpOptionsId,
ProhibitPublicIPOnVnic: *s.ProhibitPublicIpOnVnic,
ProhibitInternetIngress: *s.ProhibitInternetIngress,
DNSLabel: *s.DnsLabel,
SubnetDomainName: *s.SubnetDomainName,
}
}
package oci
import (
"fmt"
"github.com/oracle/oci-go-sdk/v65/bastion"
"github.com/oracle/oci-go-sdk/v65/identity"
"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"
)
// 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)
}
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
}
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()
}
// -----------------------------------------------------------------------------
// 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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/oci"
ociimage "github.com/rozdolsky33/ocloud/internal/oci/compute/image"
)
// FindImages finds and displays images matching a name pattern.
func FindImages(appCtx *app.ApplicationContext, namePattern 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.Find(context.Background(), namePattern)
if err != nil {
return fmt.Errorf("finding images: %w", err)
}
return PrintImagesInfo(matchedImages, appCtx, nil, useJSON)
}
package image
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/oci"
ociimage "github.com/rozdolsky33/ocloud/internal/oci/compute/image"
"github.com/rozdolsky33/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.Get(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"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/oci"
ociimage "github.com/rozdolsky33/ocloud/internal/oci/compute/image"
)
// 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) 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 selection
im := NewImageListModelFancy(images)
ip := tea.NewProgram(im, tea.WithContext(ctx))
ires, err := ip.Run()
if err != nil {
return fmt.Errorf("image selection TUI: %w", err)
}
chosen, ok := ires.(ResourceListModel)
if !ok || chosen.Choice() == "" {
return err
}
var img Image
for _, it := range images {
if it.OCID == chosen.Choice() {
img = it
break
}
}
err = PrintImageInfo(img, appCtx)
if err != nil {
return fmt.Errorf("printing image info: %w", err)
}
return nil
}
package image
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/printer"
"github.com/rozdolsky33/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(),
"OS Version": image.OperatingSystemVersion,
"OperatingSystem": image.OperatingSystem,
"LaunchMode": image.LaunchMode,
}
orderedKeys := []string{
"Name", "Created", "OperatingSystem", "OS Version", "LaunchMode",
}
title := util.FormatColoredTitle(appCtx, image.DisplayName)
p.PrintKeyValues(title, imageData, orderedKeys)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
func PrintImageInfo(image Image, appCtx *app.ApplicationContext) error {
p := printer.New(appCtx.Stdout)
imageData := map[string]string{
"ID": image.OCID,
"Name": image.DisplayName,
"Created": image.TimeCreated.String(),
"OS Version": image.OperatingSystemVersion,
"OperatingSystem": image.OperatingSystem,
"LaunchMode": image.LaunchMode,
}
orderedKeys := []string{
"ID", "Name", "Created", "OperatingSystem", "OS Version", "LaunchMode",
}
title := util.FormatColoredTitle(appCtx, image.DisplayName)
p.PrintKeyValues(title, imageData, orderedKeys)
return nil
}
package image
import (
"context"
"fmt"
"strings"
"github.com/go-logr/logr"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// Service is the application-layer service for image operations.
type Service struct {
imageRepo domain.ImageRepository
logger logr.Logger
compartmentID string
}
// NewService initializes a new Service instance.
func NewService(repo domain.ImageRepository, logger logr.Logger, compartmentID string) *Service {
return &Service{
imageRepo: repo,
logger: logger,
compartmentID: compartmentID,
}
}
// Get retrieves a paginated list of images.
func (s *Service) Get(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)
}
totalCount := len(allImages)
start := (pageNum - 1) * limit
end := start + limit
if start >= totalCount {
return []Image{}, totalCount, "", nil
}
if end > totalCount {
end = totalCount
}
pagedResults := allImages[start:end]
var nextPageToken string
if end < totalCount {
nextPageToken = fmt.Sprintf("%d", pageNum+1)
}
s.logger.Info("completed image listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// Find performs a fuzzy search for images.
func (s *Service) Find(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)
}
index, err := util.BuildIndex(allImages, func(img Image) any {
return mapToIndexableImage(img)
})
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))
fields := []string{"Name", "OperatingSystem", "OperatingSystemVersion"}
matchedIdxs, err := util.FuzzySearchIndex(index, strings.ToLower(searchPattern), fields)
if err != nil {
return nil, fmt.Errorf("performing fuzzy search: %w", err)
}
s.logger.V(logger.Debug).Info("Fuzzy search completed.", "numMatches", len(matchedIdxs))
var results []Image
for _, idx := range matchedIdxs {
if idx >= 0 && idx < len(allImages) {
results = append(results, allImages[idx])
}
}
s.logger.Info("image search complete", "matches", len(results))
return results, nil
}
// mapToIndexableImage converts a domain.Image to a struct suitable for indexing.
func mapToIndexableImage(img domain.Image) any {
return struct {
Name string
OperatingSystem string
OperatingSystemVersion string
}{
Name: strings.ToLower(img.DisplayName),
OperatingSystem: strings.ToLower(img.OperatingSystem),
OperatingSystemVersion: strings.ToLower(img.OperatingSystemVersion),
}
}
package image
import (
"fmt"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
// 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
}
// NewImageListModelFancy creates a ResourceListModel to display instances in a searchable and interactive list.
// It transforms each instance into a resourceItem with its name, ID, and VCN name as attributes.
func NewImageListModelFancy(images []Image) ResourceListModel {
items := make([]list.Item, 0, len(images))
for _, img := range images {
name := img.DisplayName
desc := fmt.Sprintf("ImageOSVersion: %s", img.OperatingSystemVersion)
id := img.OCID
items = append(items, resourceItem{id: id, title: name, description: desc})
}
return newResourceList("Images", items)
}
package instance
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/oci"
ociInst "github.com/rozdolsky33/ocloud/internal/oci/compute/instance"
)
// FindInstances finds and displays instances matching a name pattern.
func FindInstances(appCtx *app.ApplicationContext, namePattern 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.Find(context.Background(), namePattern)
if err != nil {
return fmt.Errorf("finding instances: %w", err)
}
return PrintInstancesInfo(matchedInstances, appCtx, nil, useJSON, showDetails)
}
package instance
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/oci"
ociinstance "github.com/rozdolsky33/ocloud/internal/oci/compute/instance"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// ListInstances retrieves and displays a paginated list of instances.
func ListInstances(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 := ociinstance.NewAdapter(computeClient, networkClient)
service := NewService(instanceAdapter, appCtx.Logger, appCtx.CompartmentID)
instances, totalCount, nextPageToken, err := service.List(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 (
"fmt"
"time"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/printer"
"github.com/rozdolsky33/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 []domain.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
}
package instance
import (
"context"
"fmt"
"strings"
"github.com/go-logr/logr"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// Service is the application-layer service, for instance, operations.
type Service struct {
instanceRepo domain.InstanceRepository
logger logr.Logger
compartmentID string
}
// NewService initializes a new Service instance.
func NewService(repo domain.InstanceRepository, logger logr.Logger, compartmentID string) *Service {
return &Service{
instanceRepo: repo,
logger: logger,
compartmentID: compartmentID,
}
}
// List retrieves a paginated list of instances.
func (s *Service) List(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.ListInstances(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing instances from repository: %w", err)
}
// Manual pagination.
totalCount := len(allInstances)
// Handle pageNum=0 as the first page
if pageNum <= 0 {
pageNum = 1
}
start := (pageNum - 1) * limit
end := start + limit
if start >= totalCount {
return []Instance{}, totalCount, "", nil
}
if end > totalCount {
end = totalCount
}
pagedResults := allInstances[start:end]
var nextPageToken string
if end < totalCount {
nextPageToken = fmt.Sprintf("%d", pageNum+1)
}
s.logger.Info("completed instance listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// Find performs a fuzzy search for instances.
func (s *Service) Find(ctx context.Context, searchPattern string) ([]Instance, error) {
s.logger.V(logger.Debug).Info("finding instances with fuzzy search", "pattern", searchPattern)
allInstances, err := s.instanceRepo.ListInstances(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("fetching all instances for search: %w", err)
}
index, err := util.BuildIndex(allInstances, func(inst Instance) any {
return mapToIndexableInstance(inst)
})
if err != nil {
return nil, fmt.Errorf("building search index: %w", err)
}
s.logger.V(logger.Debug).Info("Search index built successfully.", "numEntries", len(allInstances))
fields := []string{"Name", "PrimaryIP", "ImageName", "ImageOS"}
matchedIdxs, err := util.FuzzySearchIndex(index, strings.ToLower(searchPattern), fields)
if err != nil {
return nil, fmt.Errorf("performing fuzzy search: %w", err)
}
s.logger.V(logger.Debug).Info("Fuzzy search completed.", "numMatches", len(matchedIdxs))
var results []Instance
for _, idx := range matchedIdxs {
if idx >= 0 && idx < len(allInstances) {
results = append(results, allInstances[idx])
}
}
s.logger.Info("instance search complete", "matches", len(results))
return results, nil
}
// mapToIndexableInstance converts a domain.Instance to a struct suitable for indexing.
func mapToIndexableInstance(inst domain.Instance) any {
return struct {
Name string
PrimaryIP string
ImageName string
ImageOS string
}{
Name: strings.ToLower(inst.DisplayName),
PrimaryIP: inst.PrimaryIP,
ImageName: strings.ToLower(inst.ImageName),
ImageOS: strings.ToLower(inst.ImageOS),
}
}
package oke
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/oci"
ocioke "github.com/rozdolsky33/ocloud/internal/oci/compute/oke"
)
// FindClusters finds and displays OKE clusters matching a name pattern.
func FindClusters(appCtx *app.ApplicationContext, namePattern 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.Find(context.Background(), namePattern)
if err != nil {
return fmt.Errorf("finding clusters: %w", err)
}
return PrintOKEInfo(matchedClusters, appCtx, nil, useJSON)
}
package oke
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"github.com/rozdolsky33/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"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/oci"
ocioke "github.com/rozdolsky33/ocloud/internal/oci/compute/oke"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// ListClusters retrieves and displays a paginated list of OKE clusters.
func ListClusters(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.List(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 (
"fmt"
"sort"
"strings"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/printer"
"github.com/rozdolsky33/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
}
// PrintOKEInfo prints a detailed view of OKE clusters.
func PrintOKEInfo(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 {
summary := map[string]string{
"ID": c.OCID,
"Name": c.DisplayName,
"K8s Version": c.KubernetesVersion,
"Created": c.TimeCreated.Format("2006-01-02 15:04:05"),
"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)
}
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
package oke
import (
"context"
"fmt"
"strings"
"github.com/go-logr/logr"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
)
// Service is the application-layer service for OKE operations.
type Service struct {
clusterRepo domain.ClusterRepository
logger logr.Logger
compartmentID string
}
// NewService initializes a new Service instance.
func NewService(repo domain.ClusterRepository, logger logr.Logger, compartmentID string) *Service {
return &Service{
clusterRepo: repo,
logger: logger,
compartmentID: compartmentID,
}
}
// List retrieves a paginated list of clusters.
func (s *Service) List(ctx context.Context, limit, pageNum int) ([]Cluster, int, string, error) {
s.logger.V(logger.Debug).Info("listing clusters", "limit", limit, "pageNum", pageNum)
// Ensure pageNum is at least 1 to avoid negative slice indices
if pageNum < 1 {
pageNum = 1
}
allClusters, err := s.clusterRepo.ListClusters(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing clusters from repository: %w", err)
}
// Manual pagination.
totalCount := len(allClusters)
start := (pageNum - 1) * limit
end := start + limit
if start >= totalCount {
return []Cluster{}, totalCount, "", nil
}
if end > totalCount {
end = totalCount
}
pagedResults := allClusters[start:end]
var nextPageToken string
if end < totalCount {
nextPageToken = fmt.Sprintf("%d", pageNum+1)
}
s.logger.Info("completed cluster listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// Find performs a case-insensitive search for clusters.
func (s *Service) Find(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 searchPattern == "" {
s.logger.V(logger.Debug).Info("Empty search pattern, returning all clusters.")
return allClusters, nil
}
var matchedClusters []Cluster
searchPattern = strings.ToLower(searchPattern)
s.logger.V(logger.Trace).Info("Starting cluster iteration for search.", "totalClusters", len(allClusters))
for _, cluster := range allClusters {
if strings.Contains(strings.ToLower(cluster.DisplayName), searchPattern) {
matchedClusters = append(matchedClusters, cluster)
continue
}
for _, nodePool := range cluster.NodePools {
if strings.Contains(strings.ToLower(nodePool.DisplayName), searchPattern) {
matchedClusters = append(matchedClusters, cluster)
break
}
}
}
s.logger.Info("cluster search complete", "matches", len(matchedClusters))
return matchedClusters, nil
}
package auth
import (
"fmt"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/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, 1, "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, 3, "Interactive authentication completed", "tenancyID", result.TenancyID, "tenancyName", result.TenancyName)
logger.LogWithLevel(s.logger, 1, "Authentication process completed successfully")
logger.LogWithLevel(s.logger, logger.Debug, "Starting OCI auth refresher for profile", "profile", result.Profile)
logger.CmdLogger.V(logger.Info).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, 1, "Failed to start OCI auth refresher", "error", err)
}
logger.LogWithLevel(s.logger, 1, "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/jedib0t/go-pretty/v6/text"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/printer"
)
// 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/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/configuration/info"
"github.com/rozdolsky33/ocloud/internal/services/util"
"github.com/rozdolsky33/ocloud/scripts"
"github.com/pkg/errors"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/config"
)
// 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.OCIRefresherPIDFileName)
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"
"github.com/jedib0t/go-pretty/v6/text"
appConfig "github.com/rozdolsky33/ocloud/internal/config"
"github.com/rozdolsky33/ocloud/internal/printer"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// 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/rozdolsky33/ocloud/internal/app"
appConfig "github.com/rozdolsky33/ocloud/internal/config"
"github.com/rozdolsky33/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/rozdolsky33/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)
}
logger.Logger.V(logger.Info).Info("Configuration viewed successfully.")
return nil
}
package setup
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/rozdolsky33/ocloud/internal/app"
appConfig "github.com/rozdolsky33/ocloud/internal/config"
"github.com/rozdolsky33/ocloud/internal/config/flags"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/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/rozdolsky33/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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
ocidbadapter "github.com/rozdolsky33/ocloud/internal/oci/database/autonomousdb"
)
// FindAutonomousDatabases searches for Autonomous Databases matching the provided name pattern in the application context.
// Logs database discovery tasks and can format the result based on the useJSON flag.
func FindAutonomousDatabases(appCtx *app.ApplicationContext, namePattern string, useJSON bool) error {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "Finding Autonomous Databases", "pattern", namePattern)
adapter, err := ocidbadapter.NewAdapter(appCtx.Provider, appCtx.CompartmentID)
if err != nil {
return fmt.Errorf("creating database adapter: %w", err)
}
service := NewService(adapter, appCtx)
ctx := context.Background()
matchedDatabases, err := service.Find(ctx, namePattern)
if err != nil {
return fmt.Errorf("finding autonomous databases: %w", err)
}
// Convert to a domain type for printing
domainDbs := make([]domain.AutonomousDatabase, 0, len(matchedDatabases))
for _, db := range matchedDatabases {
domainDbs = append(domainDbs, domain.AutonomousDatabase(db))
}
if err := PrintAutonomousDbInfo(domainDbs, appCtx, nil, useJSON); err != nil {
return fmt.Errorf("printing autonomous databases: %w", err)
}
logger.Logger.V(logger.Info).Info("Autonomous Database find operation completed successfully.")
return nil
}
package autonomousdb
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
ocidbadapter "github.com/rozdolsky33/ocloud/internal/oci/database/autonomousdb"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// ListAutonomousDatabase fetches and lists all autonomous databases within a specified application context.
// appCtx represents the application context containing configuration and client details.
// useJSON if true, outputs the list of databases in JSON format; otherwise, uses a plain-text format.
func ListAutonomousDatabase(appCtx *app.ApplicationContext, useJSON bool, limit, page int) error {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "Listing Autonomous Databases")
adapter, err := ocidbadapter.NewAdapter(appCtx.Provider, appCtx.CompartmentID)
if err != nil {
return fmt.Errorf("creating database adapter: %w", err)
}
service := NewService(adapter, appCtx)
ctx := context.Background()
allDatabases, totalCount, nextPageToken, err := service.List(ctx, limit, page)
if err != nil {
return fmt.Errorf("listing autonomous databases: %w", err)
}
// Convert to a domain type for printing
domainDbs := make([]domain.AutonomousDatabase, 0, len(allDatabases))
for _, db := range allDatabases {
domainDbs = append(domainDbs, domain.AutonomousDatabase(db))
}
// Display database information with pagination details
if err := PrintAutonomousDbInfo(domainDbs, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON); err != nil {
return fmt.Errorf("printing image table: %w", err)
}
logger.Logger.V(logger.Info).Info("Autonomous Database list operation completed successfully.")
return nil
}
package autonomousdb
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/printer"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// PrintAutonomousDbInfo displays instances in a formatted table or JSON format.
// It now returns an error to allow for proper error handling by the caller.
func PrintAutonomousDbInfo(databases []domain.AutonomousDatabase, appCtx *app.ApplicationContext, pagination *util.PaginationInfo, useJSON bool) error {
p := printer.New(appCtx.Stdout)
// Adjust the pagination information if available
if pagination != nil {
util.AdjustPaginationInfo(pagination)
}
// If JSON output is requested, use the printer to marshal the response.
if useJSON {
if len(databases) == 0 && pagination == nil {
return p.MarshalToJSON(struct{}{})
}
return util.MarshalDataToJSONResponse[domain.AutonomousDatabase](p, databases, pagination)
}
if util.ValidateAndReportEmpty(databases, pagination, appCtx.Stdout) {
return nil
}
// Print each Compartment as a separate key-value table with a colored title.
for _, database := range databases {
databaseData := map[string]string{
"Private IP": database.PrivateEndpointIp,
"Private Endpoint": database.PrivateEndpoint,
"High": database.ConnectionStrings["HIGH"],
"Medium": database.ConnectionStrings["MEDIUM"],
"Low": database.ConnectionStrings["LOW"],
}
// Define ordered Keys
orderedKeys := []string{
"Private IP", "Private Endpoint", "High", "Medium", "Low",
}
title := util.FormatColoredTitle(appCtx, database.Name)
// Call the printer method to tender the key-value table for this instance
p.PrintKeyValues(title, databaseData, orderedKeys)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
package autonomousdb
import (
"context"
"fmt"
"strings"
"github.com/go-logr/logr"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// Service provides operations and functionalities related to database management, logging, and compartment handling.
type Service struct {
repo domain.AutonomousDatabaseRepository
logger logr.Logger
compartmentID string
}
// NewService initializes a new Service instance with the provided application context.
func NewService(repo domain.AutonomousDatabaseRepository, appCtx *app.ApplicationContext) *Service {
return &Service{
repo: repo,
logger: appCtx.Logger,
compartmentID: appCtx.CompartmentID,
}
}
// List 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) List(ctx context.Context, limit, pageNum int) ([]AutonomousDatabase, int, string, error) {
// Log input parameters at debug level
logger.LogWithLevel(s.logger, 3, "List called with pagination parameters",
"limit", limit,
"pageNum", pageNum)
var databases []AutonomousDatabase
var nextPageToken string
var totalCount int
allDatabases, err := s.repo.ListAutonomousDatabases(ctx, s.compartmentID)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to list autonomous databases: %w", err)
}
// Apply pagination logic
start := (pageNum - 1) * limit
end := start + limit
if start >= len(allDatabases) {
logger.LogWithLevel(s.logger, logger.Trace, "Pagination: start index out of bounds", "start", start, "totalDatabases", len(allDatabases))
return []AutonomousDatabase{}, 0, "", nil // No results for this page
}
if end > len(allDatabases) {
end = len(allDatabases)
logger.LogWithLevel(s.logger, logger.Trace, "Pagination: adjusted end index", "end", end, "totalDatabases", len(allDatabases))
}
databases = make([]AutonomousDatabase, 0, limit)
for _, db := range allDatabases[start:end] {
databases = append(databases, AutonomousDatabase(db)) // Convert domain.AutonomousDatabase to local AutonomousDatabase
}
totalCount = len(allDatabases)
if end < len(allDatabases) {
nextPageToken = "true" // Indicate there's a next page
}
// Calculate if there are more pages after the current page
hasNextPage := pageNum*limit < totalCount
logger.LogWithLevel(s.logger, logger.Trace, "Completed instance listing with pagination",
"returnedCount", len(databases),
"totalCount", totalCount,
"page", pageNum,
"limit", limit,
"hasNextPage", hasNextPage)
logger.Logger.V(logger.Info).Info("Autonomous Database list completed.", "returnedCount", len(databases), "totalCount", totalCount)
return databases, totalCount, nextPageToken, nil
}
// Find performs a fuzzy search to find autonomous databases matching the given search pattern in their Name field.
func (s *Service) Find(ctx context.Context, searchPattern string) ([]AutonomousDatabase, error) {
logger.LogWithLevel(s.logger, logger.Trace, "finding database with bleve fuzzy search", "pattern", searchPattern)
// 1: Fetch all databases
allDatabases, err := s.repo.ListAutonomousDatabases(ctx, s.compartmentID)
if err != nil {
return nil, fmt.Errorf("failed to fetch all databases: %w", err)
}
// 2: Build index
index, err := util.BuildIndex(allDatabases, func(db domain.AutonomousDatabase) any {
return mapToIndexableDatabase(db)
})
if err != nil {
return nil, fmt.Errorf("failed to build index: %w", err)
}
// Step 3: Fuzzy search on multiple fields
fields := []string{"Name"}
matchedIdxs, err := util.FuzzySearchIndex(index, strings.ToLower(searchPattern), fields)
if err != nil {
return nil, fmt.Errorf("failed to fuzzy search index: %w", err)
}
var results []AutonomousDatabase
for _, idx := range matchedIdxs {
if idx >= 0 && idx < len(allDatabases) {
results = append(results, AutonomousDatabase(allDatabases[idx]))
}
}
logger.LogWithLevel(s.logger, logger.Trace, "Compartment search complete", "matches", len(results))
return results, nil
}
// mapToIndexableDatabase converts an AutonomousDatabase object into an IndexableAutonomousDatabase object.
// It maps only relevant fields required for indexing, such as the database\'s name.
func mapToIndexableDatabase(db domain.AutonomousDatabase) IndexableAutonomousDatabase {
return IndexableAutonomousDatabase{
Name: db.Name,
}
}
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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/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)
}
err = PrintBastionInfo(bastions, appCtx, useJSON)
if err != nil {
return fmt.Errorf("printing bastions: %w", err)
}
logger.Logger.V(logger.Info).Info("Bastion list operation completed successfully.")
return nil
}
package bastion
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/printer"
"github.com/rozdolsky33/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/oracle/oci-go-sdk/v65/bastion"
"github.com/oracle/oci-go-sdk/v65/core"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/oci"
)
// 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.Info).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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/oci/identity"
)
// FindCompartments searches and displays compartments matching a given name pattern.
func FindCompartments(appCtx *app.ApplicationContext, namePattern string, useJSON bool) error {
appCtx.Logger.V(logger.Debug).Info("finding compartments", "pattern", namePattern)
// Create the infrastructure adapter.
compartmentAdapter := identity.NewCompartmentAdapter(appCtx.IdentityClient, appCtx.TenancyID)
// Create the application service, injecting the adapter.
service := NewService(compartmentAdapter, appCtx.Logger, appCtx.TenancyID)
ctx := context.Background()
matchedCompartments, err := service.Find(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.Logger.V(logger.Info).Info("Compartment find operation completed successfully.")
return nil
}
package compartment
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/oci/identity"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// ListCompartments retrieves and displays a paginated list of compartments.
func ListCompartments(appCtx *app.ApplicationContext, useJSON bool, limit, page int) error {
appCtx.Logger.V(logger.Debug).Info("listing compartments", "limit", limit, "page", page)
// Create the infrastructure adapter.
compartmentAdapter := identity.NewCompartmentAdapter(appCtx.IdentityClient, appCtx.TenancyID)
// Create the application service, injecting the adapter.
// The service is now decoupled from the OCI SDK.
service := NewService(compartmentAdapter, appCtx.Logger, appCtx.TenancyID)
ctx := context.Background()
compartments, totalCount, nextPageToken, err := service.List(ctx, limit, page)
if err != nil {
return fmt.Errorf("listing compartments: %w", err)
}
// Display compartment information with pagination details.
err = PrintCompartmentsTable(compartments, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON)
if err != nil {
return fmt.Errorf("printing compartments: %w", err)
}
logger.Logger.V(logger.Info).Info("Compartment list operation completed successfully.")
return nil
}
package compartment
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/printer"
"github.com/rozdolsky33/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 JSON output is requested, use the printer to marshal the response.
if useJSON {
// Special case for empty compartments list - return an empty object
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"}
// Create rows for the table
rows := make([][]string, len(compartments))
for i, c := range compartments {
// Create a row for this compartment
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 JSON output is requested, use the printer to marshal the response.
if useJSON {
// Special case for empty compartments list - return an empty object
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,
}
// Define ordered keys
orderedKeys := []string{
"Name", "ID", "Description",
}
title := util.FormatColoredTitle(appCtx, compartment.DisplayName)
p.PrintKeyValues(title, compartmentData, orderedKeys)
}
util.LogPaginationInfo(pagination, appCtx)
return nil
}
package compartment
import (
"context"
"fmt"
"strings"
"github.com/go-logr/logr"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// Service is the application-layer service for compartment operations.
// It depends on the domain repository for data access.
type Service struct {
compartmentRepo domain.CompartmentRepository
logger logr.Logger
tenancyID string
}
// NewService initializes and returns a new Service instance.
// It injects the domain repository, decoupling the service from the infrastructure layer.
func NewService(repo domain.CompartmentRepository, logger logr.Logger, tenancyID string) *Service {
return &Service{
compartmentRepo: repo,
logger: logger,
tenancyID: tenancyID,
}
}
// List retrieves a paginated list of compartments.
// Note: The pagination logic here is simplified. A real implementation might need more robust cursor handling.
func (s *Service) List(ctx context.Context, limit, pageNum int) ([]domain.Compartment, int, string, error) {
s.logger.V(logger.Debug).Info("listing compartments", "limit", limit, "pageNum", pageNum)
// Fetch all compartments from the repository.
// The underlying adapter handles the complexity of OCI pagination.
allCompartments, err := s.compartmentRepo.ListCompartments(ctx, s.tenancyID)
if err != nil {
return nil, 0, "", fmt.Errorf("listing compartments from repository: %w", err)
}
// Manual pagination over the full list.
totalCount := len(allCompartments)
start := (pageNum - 1) * limit
end := start + limit
if start >= totalCount {
return []domain.Compartment{}, totalCount, "", nil // Page number is out of bounds
}
if end > totalCount {
end = totalCount
}
pagedResults := allCompartments[start:end]
// Determine if there is a next page.
var nextPageToken string
if end < totalCount {
nextPageToken = fmt.Sprintf("%d", pageNum+1)
}
s.logger.Info("completed compartment listing", "returnedCount", len(pagedResults), "totalCount", totalCount)
return pagedResults, totalCount, nextPageToken, nil
}
// Find performs a fuzzy search for compartments based on the provided searchPattern.
func (s *Service) Find(ctx context.Context, searchPattern string) ([]domain.Compartment, error) {
s.logger.V(logger.Debug).Info("finding compartments with fuzzy search", "pattern", searchPattern)
// Step 1: Fetch all compartments from the repository.
allCompartments, err := s.compartmentRepo.ListCompartments(ctx, s.tenancyID)
if err != nil {
return nil, fmt.Errorf("fetching all compartments for search: %w", err)
}
// Step 2: Build the search index from the domain models.
index, err := util.BuildIndex(allCompartments, func(c domain.Compartment) any {
return mapToIndexableCompartment(c)
})
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))
// Step 3: Perform the fuzzy search.
fields := []string{"Name", "Description"}
matchedIdxs, err := util.FuzzySearchIndex(index, strings.ToLower(searchPattern), 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 results []domain.Compartment
for _, idx := range matchedIdxs {
if idx >= 0 && idx < len(allCompartments) {
results = append(results, allCompartments[idx])
}
}
s.logger.Info("compartment search complete", "matches", len(results))
return results, nil
}
// mapToIndexableCompartment converts a domain.Compartment to a struct suitable for indexing.
func mapToIndexableCompartment(compartment domain.Compartment) any {
return struct {
Name string
Description string
}{
Name: strings.ToLower(compartment.DisplayName),
Description: strings.ToLower(compartment.Description),
}
}
package policy
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
)
// FindPolicies retrieves and processes policies matching the provided name pattern within the application context.
// appCtx represents the application context with the necessary clients and configurations.
// namePattern specifies the pattern to filter policy names.
// useJSON determines whether the output should be formatted as JSON.
func FindPolicies(appCtx *app.ApplicationContext, namePattern string, useJSON bool) error {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "Finding Policies", "pattern", namePattern)
service, err := NewService(appCtx)
if err != nil {
return fmt.Errorf("creating policies service: %w", err)
}
ctx := context.Background()
matchedPolicies, err := service.Find(ctx, namePattern)
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.Logger.V(logger.Info).Info("Policy find operation completed successfully.")
return nil
}
package policy
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// ListPolicies retrieves and displays the policies for a given application context, supporting pagination and JSON output format.
func ListPolicies(appCtx *app.ApplicationContext, useJSON bool, limit, page int) error {
logger.LogWithLevel(appCtx.Logger, logger.Debug, "Listing Policies", "limit", limit, "page", page)
service, err := NewService(appCtx)
if err != nil {
return fmt.Errorf("creating policy service: %w", err)
}
ctx := context.Background()
policies, totalCount, nextPageToken, err := service.List(ctx, limit, page)
if err != nil {
return fmt.Errorf("listing policies: %w", err)
}
// Display policies information with pagination details
err = PrintPolicyInfo(policies, appCtx, &util.PaginationInfo{
CurrentPage: page,
TotalCount: totalCount,
Limit: limit,
NextPageToken: nextPageToken,
}, useJSON)
if err != nil {
return fmt.Errorf("printing policies: %w", err)
}
logger.Logger.V(logger.Info).Info("Policy list operation completed successfully.")
return nil
}
package policy
import (
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/printer"
"github.com/rozdolsky33/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 []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[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
}
package policy
import (
"context"
"fmt"
"strings"
"github.com/oracle/oci-go-sdk/v65/identity"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// NewService initializes a new Service instance with the provided application context.
func NewService(appCtx *app.ApplicationContext) (*Service, error) {
return &Service{
identityClient: appCtx.IdentityClient,
logger: appCtx.Logger,
CompartmentID: appCtx.CompartmentID,
}, nil
}
// List retrieves a paginated list of policies based on the provided limit and page number parameters.
func (s *Service) List(ctx context.Context, limit, pageNum int) ([]Policy, int, string, error) {
logger.LogWithLevel(s.logger, 1, "Listing Policies", "limit", limit, "page", pageNum)
var policies []Policy
var nextPageToken string
var totalCount int
// Prepare the base request
request := identity.ListPoliciesRequest{
CompartmentId: &s.CompartmentID,
}
// Add limit parameters if specified
if limit > 0 {
request.Limit = &limit
logger.LogWithLevel(s.logger, 1, "Limiting policies to", "limit", limit)
}
// If pageNum > 1, we need to fetch the appropriate page token
if pageNum > 1 && limit > 0 {
logger.LogWithLevel(s.logger, 1, "Calculating page token for page", "pageNum", pageNum)
// We need to fetch page tokens until we reach the desired page
page := ""
currentPage := 1
for currentPage < pageNum {
// Fetch Just the page token, not actual data
// Usu the same limit to ensure consistent pagination
tokenRequest := identity.ListPoliciesRequest{
CompartmentId: &s.CompartmentID,
Page: &page,
}
if limit > 0 {
tokenRequest.Limit = &limit
}
resp, err := s.identityClient.ListPolicies(ctx, tokenRequest)
if err != nil {
return nil, 0, "", fmt.Errorf("fetching page token: %w", err)
}
// If there's no next page, we've reached the end
if resp.OpcNextPage == nil {
logger.LogWithLevel(s.logger, 3, "Reached end of data while calculating page token",
"currentPage", currentPage, "targetPage", pageNum)
// Return an empty result since the requested page is beyond available data
return []Policy{}, 0, "", nil
}
// Move to the next page
page = *resp.OpcNextPage
currentPage++
}
// Set the page token for the actual request
request.Page = &page
logger.LogWithLevel(s.logger, 1, "Using page token for page", "pageNum", pageNum, "token", page)
}
// Fetch Policies for the request
resp, err := s.identityClient.ListPolicies(ctx, request)
if err != nil {
return nil, 0, "", fmt.Errorf("listing policies: %w", err)
}
// Set the total count to the number of policies returned
// If we have a next page, this is an estimate
totalCount = len(resp.Items)
// If we have a next page, we know there are more instances
if resp.OpcNextPage != nil {
// Estimate total count based on current page and items per rage
totalCount = pageNum*limit + limit
}
// Save the next page token if available
if resp.OpcNextPage != nil {
nextPageToken = *resp.OpcNextPage
logger.LogWithLevel(s.logger, 1, "Next page token", "token", nextPageToken)
}
// Process the policies
for _, p := range resp.Items {
policies = append(policies, mapToPolicies(p))
}
// Calculate if there are more pages after the current page
hasNextPage := pageNum*limit < totalCount
logger.LogWithLevel(s.logger, logger.Trace, "Completed instance listing with pagination",
"returnedCount", len(policies),
"totalCount", totalCount,
"page", pageNum,
"limit", limit,
"hasNextPage", hasNextPage)
return policies, totalCount, nextPageToken, nil
}
// Find performs a fuzzy search for policies based on the provided searchPattern and returns matching policy.
func (s *Service) Find(ctx context.Context, searchPattern string) ([]Policy, error) {
logger.LogWithLevel(s.logger, logger.Debug, "Finding Policies", "pattern", searchPattern)
var allPolicies []Policy
// 1. Fetch all policies in the compartment
allPolicies, err := s.fetchAllPolicies(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch all policies: %w", err)
}
// 2. Build index
index, err := util.BuildIndex(allPolicies, func(p Policy) any {
return mapToIndexablePolicy(p)
})
if err != nil {
return nil, fmt.Errorf("failed to build index: %w", err)
}
// 3. Fuzzy search on multiple files
fields := []string{"Name", "Description", "Statement", "Tags", "TagValues"}
matchedIdxs, err := util.FuzzySearchIndex(index, strings.ToLower(searchPattern), fields)
if err != nil {
return nil, fmt.Errorf("failed to fuzzy search index: %w", err)
}
// Return matched policies
var matchedPolicies []Policy
for _, idx := range matchedIdxs {
if idx >= 0 && idx < len(allPolicies) {
matchedPolicies = append(matchedPolicies, allPolicies[idx])
}
}
logger.LogWithLevel(s.logger, logger.Trace, "Found policies", "count", len(matchedPolicies))
return matchedPolicies, nil
}
// fetchAllPolicies retrieves all policies within a specific compartment using pagination. Returns a slice of Policy or an error.
func (s *Service) fetchAllPolicies(ctx context.Context) ([]Policy, error) {
var allPolicies []Policy
page := ""
for {
resp, err := s.identityClient.ListPolicies(ctx, identity.ListPoliciesRequest{
CompartmentId: &s.CompartmentID,
Page: &page,
})
if err != nil {
return nil, fmt.Errorf("failed to list policies: %w", err)
}
for _, p := range resp.Items {
allPolicies = append(allPolicies, mapToPolicies(p))
}
if resp.OpcNextPage == nil {
logger.Logger.V(logger.Trace).Info("No more pages for policies.")
break
}
page = *resp.OpcNextPage
}
logger.Logger.V(logger.Debug).Info("Finished fetching all policies.", "count", len(allPolicies))
return allPolicies, nil
}
// mapToPolicies converts an identity.Policy object to a shared Policy representation, mapping all fields correspondingly.
func mapToPolicies(policy identity.Policy) Policy {
return Policy{
Name: *policy.Name,
ID: *policy.Id,
Statement: policy.Statements,
Description: *policy.Description,
PolicyTags: util.ResourceTags{
FreeformTags: policy.FreeformTags,
DefinedTags: policy.DefinedTags,
},
}
}
// mapToIndexablePolicy transforms a Policy object into an IndexablePolicy object with indexed and searchable fields.
func mapToIndexablePolicy(p Policy) IndexablePolicy {
flattenedTags, _ := util.FlattenTags(p.PolicyTags.FreeformTags, p.PolicyTags.DefinedTags)
tagValues, _ := util.ExtractTagValues(p.PolicyTags.FreeformTags, p.PolicyTags.DefinedTags)
joinedStatements := strings.Join(p.Statement, " ") // Concatenate all the statements into one string for fuzzy search/indexing.
return IndexablePolicy{
Name: p.Name,
Description: p.Description,
Statement: joinedStatements,
Tags: flattenedTags,
TagValues: tagValues,
}
}
package subnet
import (
"context"
"fmt"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/oci"
ocisubnet "github.com/rozdolsky33/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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/oci"
ocisubnet "github.com/rozdolsky33/ocloud/internal/oci/network/subnet"
"github.com/rozdolsky33/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/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/printer"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// PrintSubnetTable displays a table of subnets with details such as name, CIDR, and DNS info.
func PrintSubnetTable(subnets []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](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 IP", "DNS Label", "Subnet Domain"}
// Create rows for the table
rows := make([][]string, len(subnets))
for i, subnet := range subnets {
// Determine if public IP is allowed
publicIPAllowed := "No"
if !subnet.ProhibitPublicIPOnVnic {
publicIPAllowed = "Yes"
}
// Create a row for this subnet
rows[i] = []string{
subnet.DisplayName,
subnet.CIDRBlock,
publicIPAllowed,
subnet.DNSLabel,
subnet.SubnetDomainName,
}
}
// 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, 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](p, subnets, nil)
}
if util.ValidateAndReportEmpty(subnets, nil, appCtx.Stdout) {
return nil
}
// Print each policy as a separate key-value.
for _, subnet := range subnets {
publicIPAllowed := "No"
if !subnet.ProhibitPublicIPOnVnic {
publicIPAllowed = "Yes"
}
subnetData := map[string]string{
"Name": subnet.DisplayName,
"Public IP": publicIPAllowed,
"CIDR": subnet.CIDRBlock,
"DNS Label": subnet.DNSLabel,
"Subnet Domain": subnet.SubnetDomainName,
}
orderedKeys := []string{
"Name", "Public IP", "CIDR", "DNS Label", "Subnet Domain",
}
// Create the colored title using components from the app context
title := util.FormatColoredTitle(appCtx, subnet.DisplayName)
p.PrintKeyValues(title, subnetData, orderedKeys)
}
util.LogPaginationInfo(nil, appCtx)
return nil
}
package subnet
import (
"context"
"fmt"
"strings"
"github.com/go-logr/logr"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
"github.com/rozdolsky33/ocloud/internal/services/util"
)
// Service is the application-layer service for subnet operations.
type Service struct {
subnetRepo domain.SubnetRepository
logger logr.Logger
compartmentID string
}
// NewService creates and initializes a new Service instance.
func NewService(repo domain.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, 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{}, 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, 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) 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
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 domain.Subnet) any {
return struct {
Name string
CIDR string
}{
Name: strings.ToLower(s.DisplayName),
CIDR: s.CIDRBlock,
}
}
package util
import (
"fmt"
"io"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/domain"
"github.com/rozdolsky33/ocloud/internal/logger"
)
// 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
}
// 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/jedib0t/go-pretty/v6/text"
"github.com/rozdolsky33/ocloud/internal/app"
"github.com/rozdolsky33/ocloud/internal/printer"
)
// 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
}
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:
// fallback to fmt.Sprintf
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 main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/rozdolsky33/ocloud/cmd"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := cmd.Execute(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}