Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ build:
go build -o ./build/

clean:
go clean -testcache
go clean -testcache
test:
go test ./...

integration-test:
go test -tags=integration -v -timeout 60m ./...
e2e-test:
go test -tags=e2e -v -timeout 60m ./...
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,3 @@ Delete a container via name. Note you only be allowed to delete containers creat
```bash
foreman-builder delete <container-name>
```



29 changes: 29 additions & 0 deletions foreman-builder/capsule_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//go:build e2e

package foremanbuilder_test

import (
"os/user"
"testing"
)

func TestCapsuleLifecycle(t *testing.T) {
currentUser, err := user.Current()
if err != nil {
t.Fatalf("failed to get current user: %v", err)
}
username := currentUser.Username

hostContainer := createHostContainer(t)
capsuleContainer := createCapsuleContainer(t, hostContainer)

t.Run("ssh_as_user", func(t *testing.T) {
out, err := sshRun(t, capsuleContainer, username, "echo hello")
if err != nil {
t.Fatalf("SSH as %s failed: %v, output: %s", username, err, out)
}
if out != "hello" {
t.Fatalf("expected 'hello', got %q", out)
}
})
}
201 changes: 199 additions & 2 deletions foreman-builder/cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/user"
"path/filepath"
"strings"
"time"

foremanbuilder "github.com/aidenfine/foreman-builder/foreman-builder"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -53,6 +54,68 @@ func (u User) runCreate() {
os.Exit(1)
}

fmt.Println("\nWhat would you like to create?")
fmt.Println("1) Foreman/Katello server (default)")
fmt.Println("2) Capsule (Smart Proxy)")
fmt.Print("Choice [1]: ")
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
if choice == "" {
choice = "1"
}

if choice == "2" {
fmt.Print("Parent Foreman container name: ")
parentName, _ := reader.ReadString('\n')
parentName = strings.TrimSpace(parentName)
if parentName == "" {
fmt.Println("Parent container name is required")
os.Exit(1)
}

parentLine, err := foremanbuilder.GetLineInFile(u.containersPath, parentName, "")
if err != nil || parentLine == "" {
fmt.Printf("Parent container '%s' not found. Create a Foreman server first.\n", parentName)
os.Exit(1)
}

if strings.Contains(parentLine, "::capsule::") {
fmt.Printf("'%s' is a capsule, not a Foreman server. Choose a Foreman server as the parent.\n", parentName)
os.Exit(1)
}

parentInfo, err := foremanbuilder.ContainerInfo(parentName)
if err != nil {
fmt.Printf("Could not get info for parent container '%s': %v\n", parentName, err)
os.Exit(1)
}
if parentInfo.Record.State != "running" {
fmt.Printf("Parent container '%s' is not running (state: %s). Start it first.\n", parentName, parentInfo.Record.State)
os.Exit(1)
}

err = foremanbuilder.AppendToFile(u.containersPath, fmt.Sprintf("%s::%s::capsule::%s", containerName, containerType, parentName))
if err != nil {
foremanbuilder.Logger.Error("Failed to write container to container file")
}

foremanbuilder.Logger.Info("Starting capsule environment creation")

opts := foremanbuilder.OrbOptions{
Username: username,
ContainerName: containerName,
ParentName: parentName,
CapsuleFQDN: containerName + ".orb.local",
ParentFQDN: parentName + ".orb.local",
}
err = u.createCapsuleContainer(opts)
if err != nil {
fmt.Printf("An error has occurred during capsule creation: %v\n", err)
os.Exit(1)
}
return
}

err = foremanbuilder.AppendToFile(u.containersPath, fmt.Sprintf("%s::%s", containerName, containerType))
if err != nil {
foremanbuilder.Logger.Error("Failed to write container to container file")
Expand All @@ -67,7 +130,6 @@ func (u User) runCreate() {
}
err := foremanUser.createOrbstackContainer(orbOpts)
if err != nil {
// better error message to show?
fmt.Println("An error has occured during container creation")
os.Exit(1)
}
Expand Down Expand Up @@ -100,7 +162,6 @@ func (u User) createOrbstackContainer(opts foremanbuilder.OrbOptions) error {
return err
}

// run command to create container
orbArgs := []string{"create", "-a", "amd64", "-c", pathName, "rocky:9", opts.ContainerName}
foremanbuilder.Logger.Info("running: orb", strings.Join(orbArgs, " "))
cmd := exec.Command("orb", orbArgs...)
Expand All @@ -115,3 +176,139 @@ func (u User) createOrbstackContainer(opts foremanbuilder.OrbOptions) error {
return nil

}

func (u User) createCapsuleContainer(opts foremanbuilder.OrbOptions) error {
config, err := foremanbuilder.GetYmlValues("./config.yml")
if err != nil {
foremanbuilder.Logger.Info("No config file found, skipping")
}

data := foremanbuilder.CapsuleConfigData{
Username: opts.Username,
Packages: config.Packages,
CapsuleFQDN: opts.CapsuleFQDN,
ParentFQDN: opts.ParentFQDN,
ContainerName: opts.ContainerName,
}

confsDir := filepath.Join(u.dotFilePath, "confs")
if err := os.MkdirAll(confsDir, 0755); err != nil {
foremanbuilder.Logger.Errorf("Failed to create confs directory: %v", err)
return err
}

pathName := filepath.Join(confsDir, fmt.Sprintf("orbstack-capsule-%s.yml", data.Username))
foremanbuilder.Logger.Info("using", pathName)
err = foremanbuilder.GenerateCapsuleConfig(data, pathName)
if err != nil {
foremanbuilder.Logger.Errorf("failed to generate capsule config, err: %v\n", err)
return err
}

// Create the OrbStack container
orbArgs := []string{"create", "-a", "amd64", "-c", pathName, "rocky:9", opts.ContainerName}
foremanbuilder.Logger.Info("running: orb", strings.Join(orbArgs, " "))
cmd := exec.Command("orb", orbArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
foremanbuilder.Logger.Errorf("Error creating container: %s", err)
return err
}
fmt.Println("Capsule container created. Waiting for setup to complete...")

// Wait for SSH and cloud-init
fmt.Println("Waiting for SSH...")
if err := foremanbuilder.WaitForSSH(opts.ContainerName, 5*time.Minute); err != nil {
return fmt.Errorf("SSH failed: %w", err)
}

fmt.Println("Waiting for cloud-init to complete (this may take 15-30 minutes)...")
if err := foremanbuilder.WaitForCloudInit(opts.ContainerName, 45*time.Minute); err != nil {
return fmt.Errorf("cloud-init failed: %w", err)
}

// Orchestrate certificate generation and installer
if err := u.orchestrateCapsuleCerts(opts); err != nil {
return fmt.Errorf("certificate orchestration failed: %w", err)
}

fmt.Println("Capsule setup complete!")
fmt.Printf(" SSH: ssh %s@%s@orb\n", opts.Username, opts.ContainerName)
fmt.Printf(" Parent: %s\n", opts.ParentName)
return nil
}

func (u User) orchestrateCapsuleCerts(opts foremanbuilder.OrbOptions) error {
certsFile := fmt.Sprintf("/root/%s-certs.tar", opts.ContainerName)

// Generate certs on the parent Foreman server
fmt.Println("Generating certificates on parent server...")
certGenCmd := fmt.Sprintf(
"sudo foreman-proxy-certs-generate --foreman-proxy-fqdn %s --certs-tar %s",
opts.CapsuleFQDN, certsFile,
)
certOutput, err := foremanbuilder.SSHRun(opts.ParentName, "root", certGenCmd)
if err != nil {
return fmt.Errorf("cert generation failed on '%s': %w\noutput: %s", opts.ParentName, err, certOutput)
}
foremanbuilder.Logger.Debugf("cert gen output: %s", certOutput)

// Transfer certs: parent -> macOS host -> capsule
fmt.Println("Transferring certificates...")
tmpDir := filepath.Join(u.dotFilePath, "tmp")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return fmt.Errorf("failed to create tmp directory: %w", err)
}
localCertsPath := filepath.Join(tmpDir, fmt.Sprintf("%s-certs.tar", opts.ContainerName))
defer os.Remove(localCertsPath)

if err := foremanbuilder.SCPFrom(opts.ParentName, "root", certsFile, localCertsPath); err != nil {
return fmt.Errorf("failed to copy certs from parent: %w", err)
}

if err := foremanbuilder.SCPTo(opts.ContainerName, "root", localCertsPath, certsFile); err != nil {
return fmt.Errorf("failed to copy certs to capsule: %w", err)
}

// Add /etc/hosts entries so both VMs can resolve each other
fmt.Println("Configuring cross-resolution between capsule and parent...")
parentIP, err := foremanbuilder.SSHRun(opts.ParentName, "root", "hostname -I | awk '{print $1}'")
if err != nil {
return fmt.Errorf("failed to get parent IP: %w", err)
}
parentFQDN, err := foremanbuilder.SSHRun(opts.ParentName, "root", "hostname -f")
if err != nil {
return fmt.Errorf("failed to get parent FQDN: %w", err)
}
capsuleIP, err := foremanbuilder.SSHRun(opts.ContainerName, "root", "hostname -I | awk '{print $1}'")
if err != nil {
return fmt.Errorf("failed to get capsule IP: %w", err)
}

hostsEntry := fmt.Sprintf("echo '%s %s' >> /etc/hosts", parentIP, parentFQDN)
if _, err := foremanbuilder.SSHRun(opts.ContainerName, "root", hostsEntry); err != nil {
return fmt.Errorf("failed to add parent to capsule /etc/hosts: %w", err)
}
hostsEntry = fmt.Sprintf("echo '%s %s' >> /etc/hosts", capsuleIP, opts.CapsuleFQDN)
if _, err := foremanbuilder.SSHRun(opts.ParentName, "root", hostsEntry); err != nil {
return fmt.Errorf("failed to add capsule to parent /etc/hosts: %w", err)
}

// Parse the cert-gen output for the foreman-installer command
installerCmd, err := foremanbuilder.ParseCertGenOutput(certOutput)
if err != nil {
return fmt.Errorf("failed to parse cert generation output: %w", err)
}
foremanbuilder.Logger.Debugf("installer command: %s", installerCmd)

// Run foreman-installer on the capsule
fmt.Println("Running foreman-installer on capsule (this may take 10-15 minutes)...")
installOutput, err := foremanbuilder.SSHRun(opts.ContainerName, "root", installerCmd)
if err != nil {
return fmt.Errorf("foreman-installer failed on capsule: %w\noutput: %s", err, installOutput)
}
foremanbuilder.Logger.Debugf("installer output: %s", installOutput)

return nil
}
2 changes: 1 addition & 1 deletion foreman-builder/cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func (u User) runDelete(containerName string) {
}

}
if containerInfo.State == "running" {
if containerInfo.Record.State == "running" {
var input string
for input != "y" && input != "n" {
fmt.Println("Container is currently running! \n Do you want to stop the container and delete? \n [y/n]")
Expand Down
23 changes: 18 additions & 5 deletions foreman-builder/cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"fmt"
"strings"

foremanbuilder "github.com/aidenfine/foreman-builder/foreman-builder"
"github.com/spf13/cobra"
)
Expand All @@ -15,16 +17,27 @@ var listCmd = &cobra.Command{
},
}

func(u User) runList() {
func (u User) runList() {
fmt.Println("Foreman containers:")
containersPath := u.containersPath
containers, err := foremanbuilder.GetAllLines(containersPath, "::")
lines, err := foremanbuilder.GetAllLines(containersPath, "")
if err != nil {
fmt.Printf("Error getting all containers %v\n", err)
return
}

// i - 1 due to the empty line present at the end
for i := 0; i < len(containers)-1; i++ {
fmt.Printf("- %s\n", containers[i])
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, "::")
name := parts[0]

if len(parts) >= 4 && parts[2] == "capsule" {
fmt.Printf("- %s (capsule -> %s)\n", name, parts[3])
} else {
fmt.Printf("- %s (server)\n", name)
}
}
}
Loading
Loading