Files
osmedeus/internal/cloud/pulumi.go
T
j3ssie 52b4f05709 feat: auto-install pulumi cli and adjust ssh retry configuration
- Auto-install Pulumi CLI when not found instead of returning error
- Add installed binary to PATH for immediate availability
- Update SSH pool retry config to 5 attempts with 10s fixed delays
- Improve user feedback with progress indicators
2026-04-06 23:09:01 +08:00

194 lines
5.8 KiB
Go

package cloud
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/pulumi/pulumi/sdk/v3/go/auto"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optdestroy"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// PulumiManager manages Pulumi stacks for infrastructure provisioning
type PulumiManager struct {
projectName string
stackName string
statePath string
workspace auto.Workspace
stack auto.Stack
}
// NewPulumiManager creates a new Pulumi manager with local backend
func NewPulumiManager(projectName, stackName, statePath string) (*PulumiManager, error) {
// Ensure Pulumi is installed
if err := ensurePulumiInstalled(); err != nil {
return nil, err
}
// Ensure state directory exists
if err := os.MkdirAll(statePath, 0755); err != nil {
return nil, fmt.Errorf("failed to create state directory: %w", err)
}
// Set passphrase for local secrets encryption if not already set.
// Pulumi requires this for its local backend to encrypt config secrets.
if os.Getenv("PULUMI_CONFIG_PASSPHRASE") == "" && os.Getenv("PULUMI_CONFIG_PASSPHRASE_FILE") == "" {
_ = os.Setenv("PULUMI_CONFIG_PASSPHRASE", "")
}
ctx := context.Background()
// Use file:// backend pointing at the state directory
backendURL := fmt.Sprintf("file://%s", statePath)
// Initialize stack with inline program and explicit local backend
stack, err := auto.UpsertStackInlineSource(ctx, stackName, projectName, func(ctx *pulumi.Context) error {
return nil
}, auto.EnvVars(map[string]string{
"PULUMI_BACKEND_URL": backendURL,
"PULUMI_CONFIG_PASSPHRASE": os.Getenv("PULUMI_CONFIG_PASSPHRASE"),
}))
if err != nil {
return nil, fmt.Errorf("failed to create stack: %w", err)
}
return &PulumiManager{
projectName: projectName,
stackName: stackName,
statePath: statePath,
workspace: stack.Workspace(),
stack: stack,
}, nil
}
// Up provisions infrastructure using the provided Pulumi program
func (pm *PulumiManager) Up(ctx context.Context, program pulumi.RunFunc) error {
// Set the program for the stack
pm.stack.Workspace().SetProgram(program)
// Set stack configuration if needed
// This can be extended to set provider-specific config
// Run pulumi up with colorized progress streaming
pw := NewPulumiWriter()
_, err := pm.stack.Up(ctx, optup.ProgressStreams(pw))
pw.Flush()
if err != nil {
return fmt.Errorf("failed to provision infrastructure: %w", err)
}
return nil
}
// Destroy tears down the infrastructure and removes the stack and its state
func (pm *PulumiManager) Destroy(ctx context.Context) error {
pw := NewPulumiWriter()
_, err := pm.stack.Destroy(ctx, optdestroy.ProgressStreams(pw))
pw.Flush()
if err != nil {
return fmt.Errorf("failed to destroy infrastructure: %w", err)
}
// Remove stack history and configuration after successful destroy
if err := pm.stack.Workspace().RemoveStack(ctx, pm.stackName); err != nil {
return fmt.Errorf("failed to remove stack: %w", err)
}
// Clean up leftover Pulumi state directory for this stack
pm.cleanupStackState()
return nil
}
// cleanupStackState removes leftover Pulumi local backend files for the stack
func (pm *PulumiManager) cleanupStackState() {
statePath := pm.statePath
// Remove stack-specific state files from the local backend
stackDir := filepath.Join(statePath, ".pulumi", "stacks", pm.projectName)
stackFile := filepath.Join(stackDir, pm.stackName+".json")
_ = os.Remove(stackFile)
stackFileBak := filepath.Join(stackDir, pm.stackName+".json.bak")
_ = os.Remove(stackFileBak)
// Remove the project directory if empty
entries, err := os.ReadDir(stackDir)
if err == nil && len(entries) == 0 {
_ = os.Remove(stackDir)
}
}
// GetOutputs retrieves the stack outputs (IPs, IDs, etc.)
func (pm *PulumiManager) GetOutputs(ctx context.Context) (map[string]auto.OutputValue, error) {
outputs, err := pm.stack.Outputs(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get stack outputs: %w", err)
}
return outputs, nil
}
// SetConfig sets a Pulumi stack configuration value
func (pm *PulumiManager) SetConfig(ctx context.Context, key, value string, secret bool) error {
return pm.stack.SetConfig(ctx, key, auto.ConfigValue{
Value: value,
Secret: secret,
})
}
// GetStackName returns the stack name
func (pm *PulumiManager) GetStackName() string {
return pm.stackName
}
// ensurePulumiInstalled checks if Pulumi CLI is installed
func ensurePulumiInstalled() error {
// Check if pulumi is in PATH
if _, err := exec.LookPath("pulumi"); err == nil {
return nil
}
// Check in common installation paths
commonPaths := []string{
filepath.Join(os.Getenv("HOME"), ".pulumi", "bin", "pulumi"),
"/usr/local/bin/pulumi",
"/opt/homebrew/bin/pulumi",
}
for _, path := range commonPaths {
if _, err := os.Stat(path); err == nil {
// Add to PATH for current session
currentPath := os.Getenv("PATH")
if err := os.Setenv("PATH", filepath.Dir(path)+":"+currentPath); err != nil {
return fmt.Errorf("failed to set PATH: %w", err)
}
return nil
}
}
// Auto-install Pulumi CLI
fmt.Println(" ▷ Pulumi CLI not found, installing automatically...")
installCmd := exec.Command("bash", "-c", "curl -fsSL https://get.pulumi.com | sh")
installCmd.Stdout = os.Stdout
installCmd.Stderr = os.Stderr
if err := installCmd.Run(); err != nil {
return fmt.Errorf("failed to install pulumi CLI: %w", err)
}
// Add the installed pulumi to PATH
pulumiPath := filepath.Join(os.Getenv("HOME"), ".pulumi", "bin", "pulumi")
if _, err := os.Stat(pulumiPath); err == nil {
currentPath := os.Getenv("PATH")
if err := os.Setenv("PATH", filepath.Dir(pulumiPath)+":"+currentPath); err != nil {
return fmt.Errorf("failed to set PATH: %w", err)
}
fmt.Println(" ✓ Pulumi CLI installed successfully")
return nil
}
return fmt.Errorf("pulumi CLI installation completed but binary not found at %s", pulumiPath)
}