From 23b6cd6212609d39c59667de67cd7e7aeab54b79 Mon Sep 17 00:00:00 2001 From: Aiden Fine Date: Fri, 12 Jun 2026 15:42:08 -0400 Subject: [PATCH 1/2] capsule support --- foreman-builder/cmd/create.go | 201 +++++++++++++++++- foreman-builder/cmd/delete.go | 2 +- foreman-builder/cmd/list.go | 23 +- foreman-builder/orbstack.go | 9 +- foreman-builder/ssh.go | 116 ++++++++++ .../templates/orbstack-capsule.yml.tmpl | 67 ++++++ foreman-builder/yml.go | 31 +++ 7 files changed, 440 insertions(+), 9 deletions(-) create mode 100644 foreman-builder/ssh.go create mode 100644 foreman-builder/templates/orbstack-capsule.yml.tmpl diff --git a/foreman-builder/cmd/create.go b/foreman-builder/cmd/create.go index 75c6a98..4e2fa7f 100644 --- a/foreman-builder/cmd/create.go +++ b/foreman-builder/cmd/create.go @@ -8,6 +8,7 @@ import ( "os/user" "path/filepath" "strings" + "time" foremanbuilder "github.com/aidenfine/foreman-builder/foreman-builder" "github.com/spf13/cobra" @@ -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") @@ -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) } @@ -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...) @@ -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 +} diff --git a/foreman-builder/cmd/delete.go b/foreman-builder/cmd/delete.go index a5f28df..d7cb02f 100644 --- a/foreman-builder/cmd/delete.go +++ b/foreman-builder/cmd/delete.go @@ -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]") diff --git a/foreman-builder/cmd/list.go b/foreman-builder/cmd/list.go index ba166cf..63907d8 100644 --- a/foreman-builder/cmd/list.go +++ b/foreman-builder/cmd/list.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "strings" + foremanbuilder "github.com/aidenfine/foreman-builder/foreman-builder" "github.com/spf13/cobra" ) @@ -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) + } } } diff --git a/foreman-builder/orbstack.go b/foreman-builder/orbstack.go index 99cb634..a424541 100644 --- a/foreman-builder/orbstack.go +++ b/foreman-builder/orbstack.go @@ -16,15 +16,22 @@ type OrbInstance struct { IP string `json:"ip"` } -type ContainerInfoStruct struct { +type ContainerInfoRecord struct { Id string `json:"id"` Name string `json:"name"` State string `json:"state"` } +type ContainerInfoStruct struct { + Record ContainerInfoRecord `json:"record"` +} + type OrbOptions struct { ContainerName string `json:"containerName"` Username string `json:"username"` + ParentName string `json:"parentName,omitempty"` + CapsuleFQDN string `json:"capsuleFQDN,omitempty"` + ParentFQDN string `json:"parentFQDN,omitempty"` } var execCommand = exec.Command diff --git a/foreman-builder/ssh.go b/foreman-builder/ssh.go new file mode 100644 index 0000000..d2b109f --- /dev/null +++ b/foreman-builder/ssh.go @@ -0,0 +1,116 @@ +package foremanbuilder + +import ( + "fmt" + "os/exec" + "strings" + "time" +) + +func SSHRun(container, user, cmd string) (string, error) { + target := fmt.Sprintf("%s@%s@orb", user, container) + out, err := exec.Command("ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + "-o", "LogLevel=ERROR", + target, cmd, + ).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func WaitForSSH(container string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + _, err := SSHRun(container, "root", "echo ok") + if err == nil { + Logger.Info("SSH is available") + return nil + } + Logger.Info("waiting for SSH... retrying in 10s") + time.Sleep(10 * time.Second) + } + return fmt.Errorf("SSH did not become available within %v", timeout) +} + +func WaitForCloudInit(container string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + out, err := SSHRun(container, "root", "cloud-init status 2>/dev/null || echo unknown") + if err == nil { + if strings.Contains(out, "done") { + Logger.Info("cloud-init completed") + return nil + } + if strings.Contains(out, "error") || strings.Contains(out, "recoverable error") { + Logger.Infof("cloud-init finished with status: %s", out) + return nil + } + } + Logger.Info("cloud-init still running, checking again in 30s...") + time.Sleep(30 * time.Second) + } + return fmt.Errorf("cloud-init did not complete within %v", timeout) +} + +func SCPFrom(container, user, remotePath, localPath string) error { + remote := fmt.Sprintf("%s@%s@orb:%s", user, container, remotePath) + cmd := exec.Command("scp", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + remote, localPath, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("scp from %s failed: %w\noutput: %s", container, err, string(out)) + } + return nil +} + +func SCPTo(container, user, localPath, remotePath string) error { + remote := fmt.Sprintf("%s@%s@orb:%s", user, container, remotePath) + cmd := exec.Command("scp", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + localPath, remote, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("scp to %s failed: %w\noutput: %s", container, err, string(out)) + } + return nil +} + +func ParseCertGenOutput(output string) (string, error) { + lines := strings.Split(output, "\n") + var capturing bool + var cmdLines []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !capturing && (strings.HasPrefix(trimmed, "foreman-installer ") || trimmed == "foreman-installer\\" || trimmed == "foreman-installer" || + strings.HasPrefix(trimmed, "satellite-installer ") || trimmed == "satellite-installer\\" || trimmed == "satellite-installer") { + capturing = true + } + if capturing { + cmdLines = append(cmdLines, trimmed) + if !strings.HasSuffix(trimmed, "\\") { + break + } + } + } + + if len(cmdLines) == 0 { + return "", fmt.Errorf("could not find foreman-installer command in cert generation output") + } + + cmd := strings.Join(cmdLines, " ") + cmd = strings.ReplaceAll(cmd, "\\", "") + // collapse multiple spaces + for strings.Contains(cmd, " ") { + cmd = strings.ReplaceAll(cmd, " ", " ") + } + return strings.TrimSpace(cmd), nil +} diff --git a/foreman-builder/templates/orbstack-capsule.yml.tmpl b/foreman-builder/templates/orbstack-capsule.yml.tmpl new file mode 100644 index 0000000..56d0487 --- /dev/null +++ b/foreman-builder/templates/orbstack-capsule.yml.tmpl @@ -0,0 +1,67 @@ +#cloud-config + +runcmd: + - dnf install -y epel-release + - dnf install -y git zsh curl wget htop + + {{ if .InstallString }}- dnf install -y {{ .InstallString }}{{ end }} + + - useradd -m -G wheel -s /bin/bash {{ .Username }} + - chown -R {{ .Username }}:{{ .Username }} /home/{{ .Username }} + + - hostnamectl set-hostname {{ .CapsuleFQDN }} + - | + cat > /etc/hosts < /etc/sudoers.d/{{ .Username }} + + # Configure Foreman/Katello repos + - | + cat > /etc/yum.repos.d/foreman.repo < /etc/yum.repos.d/katello.repo < /etc/yum.repos.d/pulpcore.repo < /etc/yum.repos.d/puppet.repo < Date: Fri, 12 Jun 2026 22:09:43 -0400 Subject: [PATCH 2/2] refactor integration tests, create capsule e2e test --- Makefile | 6 +- README.md | 3 - foreman-builder/capsule_e2e_test.go | 29 ++++ foreman-builder/e2e_helpers_test.go | 150 ++++++++++++++++++ .../{integration_test.go => host_e2e_test.go} | 93 +---------- 5 files changed, 185 insertions(+), 96 deletions(-) create mode 100644 foreman-builder/capsule_e2e_test.go create mode 100644 foreman-builder/e2e_helpers_test.go rename foreman-builder/{integration_test.go => host_e2e_test.go} (51%) diff --git a/Makefile b/Makefile index a7cd54f..35be761 100644 --- a/Makefile +++ b/Makefile @@ -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 ./... diff --git a/README.md b/README.md index e81d412..ec876d7 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,3 @@ Delete a container via name. Note you only be allowed to delete containers creat ```bash foreman-builder delete ``` - - - diff --git a/foreman-builder/capsule_e2e_test.go b/foreman-builder/capsule_e2e_test.go new file mode 100644 index 0000000..d5a865c --- /dev/null +++ b/foreman-builder/capsule_e2e_test.go @@ -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) + } + }) +} diff --git a/foreman-builder/e2e_helpers_test.go b/foreman-builder/e2e_helpers_test.go new file mode 100644 index 0000000..7ee9ae6 --- /dev/null +++ b/foreman-builder/e2e_helpers_test.go @@ -0,0 +1,150 @@ +//go:build e2e + +package foremanbuilder_test + +import ( + "fmt" + "os/exec" + "os/user" + "path/filepath" + "strings" + "testing" + "time" + + foremanbuilder "github.com/aidenfine/foreman-builder/foreman-builder" +) + +func sshRun(t *testing.T, container, sshUser, cmd string) (string, error) { + t.Helper() + target := fmt.Sprintf("%s@%s@orb", sshUser, container) + out, err := exec.Command("ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + "-o", "LogLevel=ERROR", + target, cmd, + ).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func waitForSSH(t *testing.T, container string) { + t.Helper() + deadline := time.Now().Add(5 * time.Minute) + for time.Now().Before(deadline) { + _, err := sshRun(t, container, "root", "echo ok") + if err == nil { + t.Log("SSH is available") + return + } + t.Log("waiting for SSH... retrying in 10s") + time.Sleep(10 * time.Second) + } + t.Fatal("SSH did not become available within 5 minutes") +} + +func waitForCloudInit(t *testing.T, container string) { + t.Helper() + deadline := time.Now().Add(45 * time.Minute) + for time.Now().Before(deadline) { + out, err := sshRun(t, container, "root", "cloud-init status 2>/dev/null || echo unknown") + if err == nil { + if strings.Contains(out, "done") { + t.Log("cloud-init completed") + return + } + if strings.Contains(out, "error") || strings.Contains(out, "recoverable error") { + t.Logf("cloud-init finished with status: %s", out) + return + } + } + t.Log("cloud-init still running, checking again in 30s...") + time.Sleep(30 * time.Second) + } + t.Fatal("cloud-init did not complete within 45 minutes") +} + +func createHostContainer(t *testing.T) string { + t.Helper() + if !foremanbuilder.IsOrbStackRunning() { + t.Skip("OrbStack is not running, skipping integration test") + } + + currentUser, err := user.Current() + if err != nil { + t.Fatalf("failed to get current user: %v", err) + } + username := currentUser.Username + containerName := fmt.Sprintf("fb-test-%d", time.Now().Unix()) + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "cloud-init.yml") + + data := foremanbuilder.OrbstackConfigData{ + Username: username, + Packages: []string{"tmux", "tree"}, + } + if err := foremanbuilder.GenerateContainerConfig(data, configPath); err != nil { + t.Fatalf("failed to generate config: %v", err) + } + + orbArgs := []string{"create", "-a", "amd64", "-c", configPath, "rocky:9", containerName} + t.Logf("creating container: orb %s", strings.Join(orbArgs, " ")) + createCmd := exec.Command("orb", orbArgs...) + if out, err := createCmd.CombinedOutput(); err != nil { + t.Fatalf("failed to create container: %v\noutput: %s", err, out) + } + t.Logf("container %s created", containerName) + + t.Cleanup(func() { + t.Logf("cleaning up container %s", containerName) + exec.Command("orbctl", "stop", containerName).Run() + exec.Command("orbctl", "delete", containerName, "-f").Run() + }) + + waitForSSH(t, containerName) + waitForCloudInit(t, containerName) + return containerName +} + +func createCapsuleContainer(t *testing.T, mainServerName string) string { + t.Helper() + if !foremanbuilder.IsOrbStackRunning() { + t.Skip("OrbStack is not running, skipping integration test") + } + + currentUser, err := user.Current() + if err != nil { + t.Fatalf("failed to get current user: %v", err) + } + username := currentUser.Username + containerName := fmt.Sprintf("fb-capsule-test-%d", time.Now().Unix()) + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "cloud-init.yml") + + data := foremanbuilder.OrbstackConfigData{ + Username: username, + Packages: []string{"tmux", "tree"}, + } + if err := foremanbuilder.GenerateContainerConfig(data, configPath); err != nil { + t.Fatalf("failed to generate config: %v", err) + } + + orbArgs := []string{"create", "-a", "amd64", "-c", configPath, "rocky:9", containerName} + t.Logf("creating container: orb %s", strings.Join(orbArgs, " ")) + createCmd := exec.Command("orb", orbArgs...) + if out, err := createCmd.CombinedOutput(); err != nil { + t.Fatalf("failed to create container: %v\noutput: %s", err, out) + } + t.Logf("container %s created", containerName) + + t.Cleanup(func() { + t.Logf("cleaning up container %s", containerName) + exec.Command("orbctl", "stop", containerName).Run() + exec.Command("orbctl", "delete", containerName, "-f").Run() + }) + + waitForSSH(t, containerName) + waitForCloudInit(t, containerName) + return containerName +} diff --git a/foreman-builder/integration_test.go b/foreman-builder/host_e2e_test.go similarity index 51% rename from foreman-builder/integration_test.go rename to foreman-builder/host_e2e_test.go index 78c9ba1..0ae511f 100644 --- a/foreman-builder/integration_test.go +++ b/foreman-builder/host_e2e_test.go @@ -1,109 +1,22 @@ -//go:build integration +//go:build e2e + package foremanbuilder_test import ( "fmt" - "os/exec" "os/user" - "path/filepath" "strings" "testing" - "time" - - foremanbuilder "github.com/aidenfine/foreman-builder/foreman-builder" ) -func sshRun(t *testing.T, container, sshUser, cmd string) (string, error) { - t.Helper() - target := fmt.Sprintf("%s@%s@orb", sshUser, container) - out, err := exec.Command("ssh", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "ConnectTimeout=5", - "-o", "LogLevel=ERROR", - target, cmd, - ).CombinedOutput() - return strings.TrimSpace(string(out)), err -} - -func waitForSSH(t *testing.T, container string) { - t.Helper() - deadline := time.Now().Add(5 * time.Minute) - for time.Now().Before(deadline) { - _, err := sshRun(t, container, "root", "echo ok") - if err == nil { - t.Log("SSH is available") - return - } - t.Log("waiting for SSH... retrying in 10s") - time.Sleep(10 * time.Second) - } - t.Fatal("SSH did not become available within 5 minutes") -} - -func waitForCloudInit(t *testing.T, container string) { - t.Helper() - deadline := time.Now().Add(45 * time.Minute) - for time.Now().Before(deadline) { - out, err := sshRun(t, container, "root", "cloud-init status 2>/dev/null || echo unknown") - if err == nil { - if strings.Contains(out, "done") { - t.Log("cloud-init completed") - return - } - if strings.Contains(out, "error") || strings.Contains(out, "recoverable error") { - t.Logf("cloud-init finished with status: %s", out) - return - } - } - t.Log("cloud-init still running, checking again in 30s...") - time.Sleep(30 * time.Second) - } - t.Fatal("cloud-init did not complete within 45 minutes") -} - func TestContainerLifecycle(t *testing.T) { - if !foremanbuilder.IsOrbStackRunning() { - t.Skip("OrbStack is not running, skipping integration test") - } - currentUser, err := user.Current() if err != nil { t.Fatalf("failed to get current user: %v", err) } username := currentUser.Username - containerName := fmt.Sprintf("fb-test-%d", time.Now().Unix()) - - // Generate cloud-init config in a temp directory - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "cloud-init.yml") - - data := foremanbuilder.OrbstackConfigData{ - Username: username, - Packages: []string{"tmux", "tree"}, - } - if err := foremanbuilder.GenerateContainerConfig(data, configPath); err != nil { - t.Fatalf("failed to generate config: %v", err) - } - - // Create container - orbArgs := []string{"create", "-a", "amd64", "-c", configPath, "rocky:9", containerName} - t.Logf("creating container: orb %s", strings.Join(orbArgs, " ")) - createCmd := exec.Command("orb", orbArgs...) - if out, err := createCmd.CombinedOutput(); err != nil { - t.Fatalf("failed to create container: %v\noutput: %s", err, out) - } - t.Logf("container %s created", containerName) - - // Always clean up the container - t.Cleanup(func() { - t.Logf("cleaning up container %s", containerName) - exec.Command("orbctl", "stop", containerName).Run() - exec.Command("orbctl", "delete", containerName, "-f").Run() - }) - waitForSSH(t, containerName) - waitForCloudInit(t, containerName) + containerName := createHostContainer(t) t.Run("ssh_as_user", func(t *testing.T) { out, err := sshRun(t, containerName, username, "echo hello")