Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func init() {
createCmd.Flags().StringVar(&opt.Domain, "domain", "", "request a domain name for the VM")
createCmd.Flags().StringSliceVarP(&opt.Variables, "vars", "e", []string{}, "Environment variables passed to the script")
createCmd.Flags().StringVarP(&opt.ConfigFile, "file", "f", "", "Path to configuration YAML file")
createCmd.Flags().StringVarP(&opt.Vm.JumpHost, "jump-host", "j", "", "Jump host")
createCmd.SetUsageTemplate(createCmd.UsageTemplate() + `
Environment Variables:
CLOUDFLARE_API_TOKEN Cloudflare API Token (required for --domain)
Expand Down Expand Up @@ -165,8 +166,44 @@ var createCmd = &cobra.Command{
if err != nil {
log.Println(err)
}

// Resolve jumphost name to IP address if it's not already an IP
resolvedJumpHost := opt.Vm.JumpHost
if opt.Vm.JumpHost != "" {
jumpHostVM, err := provider.GetByName(opt.Vm.JumpHost)
if err != nil {
log.Printf("[WARNING] Could not resolve jumphost '%s': %v", opt.Vm.JumpHost, err)
} else {
resolvedJumpHost = jumpHostVM.IP
log.Printf("[DEBUG] Resolved jumphost '%s' to IP '%s'", opt.Vm.JumpHost, resolvedJumpHost)
}
}

// Determine which IP to use for the target VM
var targetIP string
var displayIP string
if resolvedJumpHost != "" && (vm.IP == "" || vm.IP == "<nil>") {
// If using jumphost and no public IP, use private IP
if vm.PrivateIP != "" && vm.PrivateIP != "N/A" {
targetIP = vm.PrivateIP
displayIP = vm.PrivateIP + " (private, via jumphost)"
log.Printf("[DEBUG] Using private IP '%s' for target VM", targetIP)
} else {
log.Fatalln("No private IP available for VM")
}
} else {
// Use public IP if available
if vm.IP != "" && vm.IP != "<nil>" {
targetIP = vm.IP
displayIP = vm.IP
log.Printf("[DEBUG] Using public IP '%s' for target VM", targetIP)
} else {
log.Fatalln("No public IP available for VM and no jumphost specified")
}
}

s.Restart()
s.Suffix = " VM IP: " + vm.IP
s.Suffix = " VM IP: " + displayIP
s.Stop()
fmt.Println("\033[32m\u2714\033[0m" + s.Suffix)

Expand All @@ -183,19 +220,33 @@ var createCmd = &cobra.Command{
// fmt.Println("\033[32m\u2714\033[0m VM Starting...")
remote := tools.Remote{
Username: viper.GetString(cloudProvider + ".vm.username"),
IPAddress: vm.IP,
IPAddress: targetIP,
SSHPort: opt.Vm.SSHPort,
PrivateKey: string(privateKey),
Spinner: s,
JumpHost: resolvedJumpHost,
}

// BEGIN Domain
if opt.Domain != "" {
s.Restart()
s.Suffix = " Requesting Domain..."

// For domain registration, use jumphost IP if VM has no public IP
var domainIP string
if resolvedJumpHost != "" && (vm.IP == "" || vm.IP == "<nil>") {
// Use jumphost IP for domain when VM has no public IP
domainIP = resolvedJumpHost
log.Printf("[DEBUG] Using jumphost IP '%s' for domain registration", domainIP)
} else {
// Use VM's public IP for domain
domainIP = vm.IP
log.Printf("[DEBUG] Using VM public IP '%s' for domain registration", domainIP)
}

_, err := domain.NewCloudFlareService().SetRecord(&domain.SetRecordRequest{
Subdomain: opt.Domain,
Ipaddress: vm.IP,
Ipaddress: domainIP,
})
s.Stop()
if err != nil {
Expand Down
158 changes: 158 additions & 0 deletions cmd/network.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cmd

import (
"encoding/json"
"fmt"
"log"
"os"
"sync"
"time"

"github.com/briandowns/spinner"
"github.com/cdalar/onctl/internal/cloud"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

var (
nOpt cloud.Network
)

func init() {
networkCmd.AddCommand(networkCreateCmd)
networkCmd.AddCommand(networkListCmd)
networkCmd.AddCommand(networkDeleteCmd)
networkCreateCmd.Flags().StringVar(&nOpt.CIDR, "cidr", "", "CIDR for the network ex. 10.0.0.0/16 ")
networkCreateCmd.Flags().StringVarP(&nOpt.Name, "name", "n", "", "Name for the network")
}

var networkCmd = &cobra.Command{
Use: "network",
Aliases: []string{"net"},
Short: "Manage network resources",
Long: `Manage network resources`,
}

var networkCreateCmd = &cobra.Command{
Use: "create",
Aliases: []string{"new", "add", "up"},
Short: "Create a network",
Long: `Create a network`,
Run: func(cmd *cobra.Command, args []string) {
// Do network creation
log.Println("[DEBUG] Creating network")
_, err := networkManager.Create(cloud.Network{
Name: nOpt.Name,
CIDR: nOpt.CIDR,
})
if err != nil {
log.Println(err)
}
},
}

var networkListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List networks",
Long: `List networks`,
Run: func(cmd *cobra.Command, args []string) {
// Do network listing
log.Println("[DEBUG] Listing networks")
netlist, err := networkManager.List()
if err != nil {
log.Println(err)
}
switch output {
case "json":
jsonList, err := json.Marshal(netlist)
if err != nil {
log.Println(err)
}
fmt.Println(string(jsonList))
case "yaml":
yamlList, err := yaml.Marshal(netlist)
if err != nil {
log.Println(err)
}
fmt.Println(string(yamlList))
default:
tmpl := "CLOUD\tID\tNAME\tCIDR\tSERVERS\tAGE\n{{range .}}{{.Provider}}\t{{.ID}}\t{{.Name}}\t{{.CIDR}}\t{{.Servers}}\t{{durationFromCreatedAt .CreatedAt}}\n{{end}}"
TabWriter(netlist, tmpl)
}

},
}
var networkDeleteCmd = &cobra.Command{
Use: "delete",
Aliases: []string{"rm", "remove", "destroy", "down", "del"},
Short: "Delete a network",
Long: `Delete a network`,
Run: func(cmd *cobra.Command, args []string) {
// Do network deletion
// log.Println("Deleting network")
s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) // Build our new spinner
log.Println("[DEBUG] args: ", args)
if len(args) == 0 {
fmt.Println("Please provide a network id")
return
}
switch args[0] {
case "all":
// Delete all networks
if !force {
if !yesNo() {
os.Exit(0)
}
}
log.Println("[DEBUG] Delete All Networks")
networks, err := networkManager.List()
if err != nil {
log.Println(err)
}
log.Println("[DEBUG] Networks: ", networks)
var wg sync.WaitGroup
for _, network := range networks {
wg.Add(1)
go func(network cloud.Network) {
defer wg.Done()
s.Start()
s.Suffix = " Destroying VM..."
if err := networkManager.Delete(network); err != nil {
fmt.Println("\033[31m\u2718\033[0m Could not delete Network: " + network.Name)
log.Println(err)
}
s.Stop()
fmt.Println("\033[32m\u2714\033[0m Network Deleted: " + network.Name)
}(network)
}
wg.Wait()
fmt.Println("\033[32m\u2714\033[0m ALL Network(s) are destroyed")
default:
// Tear down specific server
networkName := args[0]
netlist, err := networkManager.List()
if err != nil {
log.Println(err)
}
for _, network := range netlist {
if network.Name == networkName {
log.Println("[DEBUG] Delete network: " + networkName)
s.Start()
s.Suffix = " Destroying Network..."
err := networkManager.Delete(cloud.Network{
ID: network.ID,
})
if err != nil {
s.Stop()
fmt.Println("\033[31m\u2718\033[0m Cannot destroy Network: " + networkName)
fmt.Println(err)
os.Exit(1)
}
s.Stop()
fmt.Println("\033[32m\u2714\033[0m Network Destroyed: " + networkName)
}
}
}
},
}
9 changes: 9 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ var (
cloudProvider string
cloudProviderList = []string{"aws", "hetzner", "azure", "gcp"}
provider cloud.CloudProviderInterface
networkManager cloud.NetworkManager
)

func checkCloudProvider() string {
Expand Down Expand Up @@ -78,6 +79,9 @@ func Execute() error {
provider = &cloud.ProviderHetzner{
Client: providerhtz.GetClient(),
}
networkManager = &cloud.NetworkProviderHetzner{
Client: providerhtz.GetClient(),
}
case "gcp":
provider = &cloud.ProviderGcp{
Client: providergcp.GetClient(),
Expand All @@ -88,6 +92,9 @@ func Execute() error {
provider = &cloud.ProviderAws{
Client: provideraws.GetClient(),
}
networkManager = &cloud.NetworkProviderAws{
Client: provideraws.GetClient(),
}
case "azure":
provider = &cloud.ProviderAzure{
ResourceGraphClient: providerazure.GetResourceGraphClient(),
Expand All @@ -108,4 +115,6 @@ func init() {
rootCmd.AddCommand(destroyCmd)
rootCmd.AddCommand(sshCmd)
rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(networkCmd)
rootCmd.AddCommand(vmCmd)
}
50 changes: 48 additions & 2 deletions cmd/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type cmdSSHOptions struct {
DotEnvFile string `yaml:"dotEnvFile"`
Variables []string `yaml:"variables"`
ConfigFile string `yaml:"configFile"`
JumpHost string `yaml:"jumpHost"`
}

var sshOpt cmdSSHOptions
Expand Down Expand Up @@ -55,6 +56,7 @@ func init() {
sshCmd.Flags().StringVar(&sshOpt.DotEnvFile, "dot-env", "", "dot-env (.env) file")
sshCmd.Flags().StringSliceVarP(&sshOpt.Variables, "vars", "e", []string{}, "Environment variables passed to the script")
sshCmd.Flags().StringVarP(&sshOpt.ConfigFile, "file", "f", "", "Path to configuration YAML file")
sshCmd.Flags().StringVarP(&sshOpt.JumpHost, "jump-host", "j", "", "Jump host")
}

var sshCmd = &cobra.Command{
Expand Down Expand Up @@ -108,6 +110,9 @@ var sshCmd = &cobra.Command{
if len(config.Variables) > 0 {
sshOpt.Variables = append(sshOpt.Variables, config.Variables...)
}
if config.JumpHost != "" {
sshOpt.JumpHost = config.JumpHost
}
}

s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) // Build our new spinner
Expand All @@ -132,12 +137,46 @@ var sshCmd = &cobra.Command{
if err != nil {
log.Fatalln(err)
}

// Resolve jumphost name to IP address if it's not already an IP
resolvedJumpHost := sshOpt.JumpHost
if sshOpt.JumpHost != "" {
jumpHostVM, err := provider.GetByName(sshOpt.JumpHost)
if err != nil {
log.Printf("[WARNING] Could not resolve jumphost '%s': %v", sshOpt.JumpHost, err)
} else {
resolvedJumpHost = jumpHostVM.IP
log.Printf("[DEBUG] Resolved jumphost '%s' to IP '%s'", sshOpt.JumpHost, resolvedJumpHost)
}
}

// Determine which IP to use for the target VM
var targetIP string
if resolvedJumpHost != "" && (vm.IP == "" || vm.IP == "<nil>") {
Comment on lines +154 to +155
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The string comparison vm.IP == \"<nil>\" suggests inconsistent handling of nil/empty IP addresses. Consider standardizing how empty IP addresses are represented throughout the codebase (e.g., always use empty string or a dedicated constant).

Copilot uses AI. Check for mistakes.
// If using jumphost and no public IP, use private IP
if vm.PrivateIP != "" && vm.PrivateIP != "N/A" {
targetIP = vm.PrivateIP
log.Printf("[DEBUG] Using private IP '%s' for target VM", targetIP)
} else {
log.Fatalln("No private IP available for VM")
}
} else {
// Use public IP if available
if vm.IP != "" && vm.IP != "<nil>" {
targetIP = vm.IP
log.Printf("[DEBUG] Using public IP '%s' for target VM", targetIP)
} else {
log.Fatalln("No public IP available for VM and no jumphost specified")
}
}

remote := tools.Remote{
Username: viper.GetString(cloudProvider + ".vm.username"),
IPAddress: vm.IP,
IPAddress: targetIP,
SSHPort: sshOpt.Port,
PrivateKey: string(privateKey),
Spinner: s,
JumpHost: resolvedJumpHost,
}

if sshOpt.DotEnvFile != "" {
Expand Down Expand Up @@ -174,7 +213,14 @@ var sshCmd = &cobra.Command{
ProcessDownloadSlice(sshOpt.DownloadFiles, remote)
}
if sshOpt.ConfigFile == "" && len(applyFileFound) == 0 && len(sshOpt.DownloadFiles) == 0 && len(sshOpt.UploadFiles) == 0 {
provider.SSHInto(args[0], sshOpt.Port, privateKeyFile)
// Call SSH directly with the calculated target IP and resolved jump host
tools.SSHIntoVM(tools.SSHIntoVMRequest{
IPAddress: targetIP,
User: viper.GetString(cloudProvider + ".vm.username"),
Port: sshOpt.Port,
PrivateKeyFile: privateKeyFile,
JumpHost: resolvedJumpHost,
})
}
},
}
Loading
Loading