Merge branch 'feature/enhanced-configuration'

This commit is contained in:
jon brookes 2025-09-05 17:21:19 +01:00
commit c23d2aef9f
34 changed files with 790 additions and 643 deletions

1
.gitignore vendored
View file

@ -23,3 +23,4 @@ scripts/ansible_inventory.ini
vagrant/dev/ubuntu/ansible/ansible_inventory.ini
*.cast
vagrant/dev/ubuntu/certs/
vagrant/dev/ubuntu/config-dev

View file

@ -145,9 +145,9 @@ Key configuration options:
- `env`: Environment file path
- `preview_path`: Path for preview functionality
### Customer Configuration (`config.json`)
### Project Configuration (`config.json`)
Copy and customize the customer-specific configuration:
Copy and customize the project-specific configuration:
```bash
cp config.json.example config.json
@ -155,7 +155,7 @@ cp config.json.example config.json
Key configuration options:
- `project`: Project name/identifier (used as Kubernetes namespace)
- `customer_directory`: Customer-specific directory
- `project_directory`: Project-specific directory
- `ui_url`: UI service URL
- `static_url`: Static content URL
- `port`: Service port
@ -186,7 +186,7 @@ Run the CLI by providing a path to your pipeline JSON file:
The tool will automatically:
1. Load base and customer configurations
1. Load base and project configurations
2. Initialize SQLite database for state management
3. Execute the deployment pipeline defined in your JSON file
4. Run scripts from the `scripts/` directory
@ -292,13 +292,13 @@ infctl-cli/
├── main.go # Application entry point
├── go.mod # Go module definition
├── base.json.example # Base configuration template
├── config.json.example # Customer configuration template
├── config.json.example # Project configuration template
├── app/ # Core application logic
│ ├── app.go # Pipeline orchestration and state management
│ └── k8s.go # Kubernetes operations (kubectl, kustomize)
├── config/ # Configuration management
│ ├── base.go # Base configuration handling
│ └── customer.go # Customer configuration handling
│ └── project.go # Project configuration handling
├── database/ # SQLite database operations
├── scripts/ # Shell scripts executed by the CLI
│ ├── install_*.sh # Infrastructure installation scripts

View file

@ -1,22 +1,21 @@
package app
import (
"database/sql"
"bufio"
"bytes"
"encoding/json"
"fmt"
"log"
"log/slog"
"os"
"os/exec"
"time"
"headshed/infctl-cli/config"
"headshed/infctl-cli/database"
)
type AppState struct {
Config config.BaseConfig
Customer config.CustomerConfig
DB *sql.DB
Project config.ProjectConfig
}
type PipelineStep struct {
@ -62,28 +61,31 @@ func (app *AppState) ToDoDeployment() []PipelineStep {
return []PipelineStep{}
}
func (app *AppState) RunJsonDeployment() []PipelineStep {
// func (app *AppState) RunJsonDeployment() []PipelineStep {
func (app *AppState) RunJsonDeployment() error {
jsonFile := app.Config.DeploymentFile
jsonFile := app.Project.DeploymentFile
if jsonFile == "" {
log.Fatal("no config specified with [-f|--deployment-file]=<path_to_config_file>")
return fmt.Errorf("no config specified with [-f|-deployment-file]=<path_to_config_file> => for all options see help with -h")
}
file, err := os.Open(jsonFile)
if err != nil {
slog.Error(fmt.Sprintf("Failed to open JSON file: %s", err))
os.Exit(1)
return fmt.Errorf("failed to open JSON file: %w", err)
}
defer file.Close()
// fmt.Printf("jsonFile is : %s\n", jsonFile)
slog.Info(fmt.Sprintf("Using jsonFile: %s", jsonFile))
steps, err := parseStepsFromJSON(jsonFile)
if err != nil {
slog.Error(fmt.Sprintf("Failed to parse JSON file: %s", err))
return fmt.Errorf("failed to parse JSON file: %w", err)
}
for _, step := range steps {
slog.Info(fmt.Sprintf("🔄 Running step: %s", step.Name))
slog.Info(fmt.Sprintf("run json deployment => 🔄 %s", step.Name))
function, exists := functionMap[step.Function]
if !exists {
slog.Error(fmt.Sprintf("Unknown function: %s", step.Function))
@ -91,134 +93,121 @@ func (app *AppState) RunJsonDeployment() []PipelineStep {
}
err := function(step.Params)
if err != nil {
slog.Error(fmt.Sprintf("❌ Step failed: %s, error: %v", step.Name, err))
var innerErr error
if step.RetryCount > 0 {
for i := 0; i < step.RetryCount; i++ {
sleep := app.Config.RetryDelaySenconds
slog.Info(fmt.Sprintf("Retrying step: %s (attempt %d/%d) after waiting for %d seconds...", step.Name, i+1, step.RetryCount, sleep))
time.Sleep(time.Duration(sleep) * time.Second)
if innerErr = function(step.Params); innerErr == nil {
slog.Info(fmt.Sprintf("✅ Step completed: %s\n", step.Name))
err = nil
break
}
}
if innerErr != nil {
if !step.ShouldAbort {
slog.Info(fmt.Sprintf("Not going to abort, step: %s", step.Name))
} else {
return fmt.Errorf("critical failure at step: %s", step.Name)
}
}
}
if step.ShouldAbort {
log.Fatalf("🚨Critical failure at step: %s", step.Name)
return fmt.Errorf("critical failure at step: %s", step.Name)
}
} else {
slog.Info(fmt.Sprintf("✅ Step completed: %s", step.Name))
}
}
return steps
return nil
}
func (app *AppState) getPipeline() []PipelineStep {
switch app.Config.DeploymentType {
func (app *AppState) getPipeline() error {
switch app.Project.DeploymentMode {
case "api":
return app.ToDoDeployment()
return fmt.Errorf("api mode is not yet implemented")
case "json":
return app.RunJsonDeployment()
default:
return app.RunJsonDeployment()
// return app.RunJsonDeployment()
return fmt.Errorf("unknown mode: %s", app.Project.DeploymentMode)
}
}
func NewAppState(cust config.CustomerConfig, config config.BaseConfig, dbPath string) (*AppState, error) {
db, err := database.NewDatabase(dbPath)
if err != nil {
return nil, err
}
func NewAppState(cust config.ProjectConfig, config config.BaseConfig) (*AppState, error) {
return &AppState{
Config: config,
Customer: cust,
DB: db,
Project: cust,
}, nil
}
func (app *AppState) runPipeline(steps []PipelineStep) error {
for _, step := range steps {
slog.Info(fmt.Sprintf("🔄 Running step: %s\n", step.Name))
func RunCommand(command string) error {
slog.Debug(fmt.Sprintf("🐞 Running command: %s", command))
cmd := exec.Command("sh", "-c", command)
// Look up the function in the functionMap
function, exists := functionMap[step.Function]
if !exists {
slog.Error(fmt.Sprintf("❌ Unknown function: %s", step.Function))
if step.ShouldAbort {
return fmt.Errorf("🚨critical failure: unknown function %s", step.Function)
}
continue
}
var stdout, stderr bytes.Buffer
// Execute the function with the provided parameters
err := function(step.Params)
// Get pipes for real-time reading
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
slog.Error(fmt.Sprintf("❌ Step failed: %s, error: %v", step.Name, err))
// Retry logic
if step.RetryCount > 0 {
for i := 0; i < step.RetryCount; i++ {
slog.Info("Waiting for 20 seconds before retrying...")
time.Sleep(20 * time.Second)
if innerErr := function(step.Params); innerErr == nil {
slog.Info(fmt.Sprintf("✅ Step completed: %s\n", step.Name))
err = nil
break
} else {
err = innerErr
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
}
}
// Handle failure after retries
stderrPipe, err := cmd.StderrPipe()
if err != nil {
if step.ShouldAbort {
return fmt.Errorf("🚨critical failure at step: %s", step.Name)
}
continue
}
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
slog.Info(fmt.Sprintf("✅ Step completed: %s\n", step.Name))
// Start the command
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start command: %w", err)
}
return nil
}
func (app *AppState) SetUpNewCustomer() error {
// Read stdout line by line and log through slog
go func() {
scanner := bufio.NewScanner(stdoutPipe)
for scanner.Scan() {
line := scanner.Text()
stdout.WriteString(line + "\n")
slog.Info(line)
}
}()
/*
| --------------------------
| main pipeline
| --------------------------
*/
// Read stderr line by line and log through slog
go func() {
scanner := bufio.NewScanner(stderrPipe)
for scanner.Scan() {
line := scanner.Text()
stderr.WriteString(line + "\n")
slog.Info(line)
}
}()
steps := app.getPipeline()
app.runPipeline(steps)
slog.Info(fmt.Sprintln("🎉 Pipeline setup complete!"))
// Wait for command to complete
err = cmd.Wait()
if err != nil {
slog.Error(fmt.Sprintf("❌ Command failed with error: %v", err))
slog.Debug(fmt.Sprintf("🐞 Stdout: %s\n", stdout.String()))
slog.Debug(fmt.Sprintf("🐞 Stderr: %s\n", stderr.String()))
return fmt.Errorf("failed to run script command: %w", err)
}
return nil
}
func (app *AppState) CreatePipeline() error {
isNew, err := database.CheckProjectName(app.DB, app.Customer.Project)
if err != nil {
return fmt.Errorf("failed to check project name: %w", err)
}
func (app *AppState) SetUpNewProject() error {
return app.getPipeline()
}
if isNew {
port, err := database.GetNextPortNumber(app.DB)
func (app *AppState) CreateProjectAndRunPipeline() error {
err := app.SetUpNewProject()
if err != nil {
return fmt.Errorf("failed to get next port number: %w", err)
return fmt.Errorf("Pipeline error: %w", err)
}
err = database.AddProjectName(app.DB, app.Customer.Project, port)
if err != nil {
return fmt.Errorf("failed to add project name: %w", err)
}
slog.Info(fmt.Sprintln("Project name added:", app.Customer.Project))
fmt.Printf("Port number assigned: %d\n", port)
app.Config.Port = port
}
err = app.SetUpNewCustomer()
if err != nil {
return fmt.Errorf("failed to set up new customer: %w", err)
}
return nil
}

View file

@ -2,129 +2,31 @@ package app
import (
"encoding/json"
"math/rand"
"headshed/infctl-cli/config"
"os"
"os/exec"
"path/filepath"
"testing"
"headshed/infctl-cli/config"
)
func TestMain(m *testing.M) {
// Setup: Set TEST_ENV=true for all tests
err := os.Setenv("TEST_ENV", "true")
if err != nil {
panic("Failed to set TEST_ENV")
}
// Run all tests
code := m.Run()
// Teardown: Unset TEST_ENV after all tests
os.Unsetenv("TEST_ENV")
// Exit with the test result code
os.Exit(code)
}
// Test only pipeline execution and shell command running
func TestRunPipeline(t *testing.T) {
// Create a temporary directory for test assets
tempDir, err := os.MkdirTemp("", "smoke-test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir) // Cleanup after test
// Create test scripts
scripts := map[string]string{
"good.sh": "#!/bin/bash\necho 'Good script executed'\nexit 0",
"warning.sh": "#!/bin/bash\necho 'Warning script executed'\nexit 0",
"error.sh": "#!/bin/bash\necho 'Error script executed'\nexit 1",
}
for name, content := range scripts {
scriptPath := filepath.Join(tempDir, name)
if err := os.WriteFile(scriptPath, []byte(content), 0755); err != nil {
t.Fatalf("Failed to create script %s: %v", name, err)
}
}
// Create a test JSON pipeline file
pipeline := []PipelineStep{
{Name: "Good Step", Function: "RunCommand", Params: []string{filepath.Join(tempDir, "good.sh")}, RetryCount: 0, ShouldAbort: false},
{Name: "Warning Step", Function: "RunCommand", Params: []string{filepath.Join(tempDir, "warning.sh")}, RetryCount: 0, ShouldAbort: false},
{Name: "Error Step", Function: "RunCommand", Params: []string{filepath.Join(tempDir, "error.sh")}, RetryCount: 0, ShouldAbort: true},
}
pipelineFile := filepath.Join(tempDir, "pipeline.json")
pipelineData, err := json.Marshal(pipeline)
if err != nil {
t.Fatalf("Failed to marshal pipeline: %v", err)
}
if err := os.WriteFile(pipelineFile, pipelineData, 0644); err != nil {
t.Fatalf("Failed to write pipeline file: %v", err)
}
// Set up AppState
app := &AppState{
Config: config.BaseConfig{
DeploymentFile: pipelineFile,
},
}
// Run the pipeline
err = app.runPipeline(pipeline)
if err == nil {
t.Errorf("Expected error due to 'Error Step', but got none")
}
}
func randomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
func TestK3DNamespaceCreation(t *testing.T) {
// Check if k3d is installed
_, err := exec.LookPath("k3d")
if err != nil {
t.Fatal("k3d is not installed. Please install k3d to run this test.")
}
// Create a test cluster
clusterName := "test-" + randomString(6)
cmd := exec.Command("k3d", "cluster", "create", clusterName, "--servers", "1")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create k3d cluster: %v", err)
}
defer func() {
// Clean up the test cluster
cmd := exec.Command("k3d", "cluster", "delete", clusterName)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
t.Errorf("Failed to delete k3d cluster: %v", err)
}
}()
// Create a temporary directory for the pipeline config
tempDir, err := os.MkdirTemp("", "k3d-test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create a test script
scriptPath := filepath.Join(tempDir, "good.sh")
scriptContent := "#!/bin/bash\necho 'Good script executed'\nexit 0"
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatalf("Failed to create script: %v", err)
}
// Create a test JSON pipeline file
pipeline := []PipelineStep{
{Name: "Ensure Namespace Exists", Function: "k8sNamespaceExists", Params: []string{"test-namespace"}, RetryCount: 0, ShouldAbort: true},
{Name: "Good Step", Function: "RunCommand", Params: []string{scriptPath}, RetryCount: 0, ShouldAbort: false},
}
pipelineFile := filepath.Join(tempDir, "pipeline.json")
pipelineData, err := json.Marshal(pipeline)
@ -137,22 +39,20 @@ func TestK3DNamespaceCreation(t *testing.T) {
// Set up AppState
app := &AppState{
Config: config.BaseConfig{
Config: config.BaseConfig{},
Project: config.ProjectConfig{
DeploymentFile: pipelineFile,
},
}
// Run the pipeline
err = app.runPipeline(pipeline)
err = app.RunJsonDeployment()
if err != nil {
t.Fatalf("Pipeline execution failed: %v", err)
}
// Verify the namespace exists
cmd = exec.Command("kubectl", "get", "ns", "test-namespace")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
t.Fatalf("Namespace 'test-namespace' was not created: %v", err)
t.Errorf("Expected no error, got: %v", err)
}
}
// Removed randomString: not needed for current tests
// Removed TestK3DNamespaceCreation: k3d and k8s namespace logic is no longer part of the app
// Removed TestSetUpNewProject: advanced project setup logic is no longer part of the app

View file

@ -1,7 +1,6 @@
package app
import (
"bufio"
"bytes"
"fmt"
"log/slog"
@ -64,56 +63,3 @@ func k8sCreateNamespace(project string) error {
return nil
}
func RunCommand(command string) error {
slog.Debug(fmt.Sprintf("🐞 Running command: %s", command))
cmd := exec.Command("sh", "-c", command)
var stdout, stderr bytes.Buffer
// Get pipes for real-time reading
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
// Start the command
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start command: %w", err)
}
// Read stdout line by line and log through slog
go func() {
scanner := bufio.NewScanner(stdoutPipe)
for scanner.Scan() {
line := scanner.Text()
stdout.WriteString(line + "\n")
slog.Info(line)
}
}()
// Read stderr line by line and log through slog
go func() {
scanner := bufio.NewScanner(stderrPipe)
for scanner.Scan() {
line := scanner.Text()
stderr.WriteString(line + "\n")
slog.Info(line)
}
}()
// Wait for command to complete
err = cmd.Wait()
if err != nil {
slog.Error(fmt.Sprintf("❌ Command failed with error: %v\n", err))
slog.Debug(fmt.Sprintf("🐞 Stdout: %s\n", stdout.String()))
slog.Debug(fmt.Sprintf("🐞 Stderr: %s\n", stderr.String()))
return fmt.Errorf("failed to run script command: %w", err)
}
return nil
}

View file

@ -1,17 +0,0 @@
{
"projects_directory": "/home/user/docker/",
"app_image": "headshead/some-app:0.0.1",
"webserver_image": "headsheddev/some-webserver_image:0.0.1",
"db": "database/database.sqlite",
"env": ".env",
"preview_path": "prv",
"data_www": "data_www",
"static_images": "data_images/",
"public_images": "images",
"php_conf": "php/local.ini",
"exports": "exports",
"logs": "logs/laravel.log",
"admin_url": ".headshed.dev",
"preview_url": "-prv.headshed.dev",
"nginx_conf": "nginx/conf.d"
}

View file

@ -5,6 +5,12 @@ mkdir -p bin
echo "Building for Linux..."
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/infctl-linux-amd64
echo "Building for Raspberry Pi (Linux ARM)..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w" -o bin/infctl-linux-armv7
echo "Building for Raspberry Pi (Linux ARM64)..."
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o bin/infctl-linux-arm64
echo "Building for Windows..."
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o bin/infctl-windows-amd64.exe

View file

@ -1,19 +0,0 @@
{
"project": "hdshd",
"project_data": "path_to/project_data",
"app_image": "headsheddev/some-app:0.0.1",
"env": "path_to/.env",
"images": "path_to_images",
"php_conf": "path_to/local.ini",
"exports": "path_to/exports",
"db": "path_to/db",
"logs": "path_to/logs",
"preview_path": "path_to_preview",
"webserver_image": "headsheddev/my-nginx:0.0.1",
"public_images": "path_to/images",
"data_www": "path_to/www",
"nginx_conf": "path_to/conf.d",
"admin_url": "admin_url.headshed.dev",
"preview_url": "app-prv.headshed.dev",
"ui_url": "ww2.headshed.dev"
}

View file

@ -7,41 +7,30 @@ import (
"os"
)
const Version = "v0.0.4"
const Version = "v0.0.5"
// Package-level variables for flags
var (
baseConfigFile string
projectConfigFile string
pipelineFile string
)
type BaseConfig struct {
ProjectsDirectory string `json:"projects_directory"`
Env string `json:"env"`
StaticImages string `json:"static_images"`
PublicImages string `json:"public_images"`
PhpConf string `json:"php_conf"`
Exports string `json:"exports"`
Logs string `json:"logs"`
PreviewPath string `json:"preview_path"`
DataWww string `json:"data_www"`
NginxConf string `json:"nginx_conf"`
AdminURL string `json:"admin_url"`
PreviewURL string `json:"preview_url"`
AppImage string `json:"app_image"`
WebserverImage string `json:"webserver_image"`
EmptyDB string `json:"empty_db"`
DB string `json:"db"`
EmptyImages string `json:"empty_imaages"`
DeploymentType string `json:"deployment_type"`
DeploymentFile string `json:"deployment_file"`
Port int `json:"port"`
RetryDelaySenconds int `json:"retry_delay_seconds"`
}
func ReadBaseConfig(path string) (BaseConfig, error) {
deploymentType := os.Getenv("DEPLOYMENT_TYPE")
deploymentFile := flag.String("deployment-file", "", "path to config file")
deploymentFileShorthand := flag.String("f", "", "shorthand for -deployment-file")
// ParseFlags parses all command-line flags and handles help/version flags
func ParseFlags() {
helpFlag := flag.Bool("help", false, "show help")
versionFlag := flag.Bool("version", false, "show version")
vFlag := flag.Bool("v", false, "show version (shorthand)")
flag.StringVar(&baseConfigFile, "base-config", "", "Path to base config file (optional)")
flag.StringVar(&projectConfigFile, "project-config", "", "Path to project config file (optional)")
flag.StringVar(&pipelineFile, "f", "", "Path to pipeline file")
flag.StringVar(&pipelineFile, "deployment-file", "", "Path to pipeline file (long format)")
flag.Parse()
if *helpFlag {
@ -50,17 +39,20 @@ func ReadBaseConfig(path string) (BaseConfig, error) {
os.Exit(0)
}
// Handle version flags
if *versionFlag || *vFlag {
fmt.Println("infctl-cli version:", Version)
os.Exit(0)
}
}
var config BaseConfig
if *deploymentFileShorthand != "" {
config.DeploymentFile = *deploymentFileShorthand
} else if *deploymentFile != "" {
config.DeploymentFile = *deploymentFile
func ReadBaseConfig(path string) (BaseConfig, error) {
config := BaseConfig{}
// If base.json does not exist, create it with default value
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := CreateDefaultBaseConfig(path); err != nil {
return BaseConfig{}, fmt.Errorf("failed to create default base config: %w", err)
}
}
data, err := os.ReadFile(path)
@ -69,9 +61,57 @@ func ReadBaseConfig(path string) (BaseConfig, error) {
}
if err := json.Unmarshal(data, &config); err != nil {
return BaseConfig{}, fmt.Errorf("failed to unmarshal JSON: %w", err)
return config, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
config.DeploymentType = deploymentType
return config, nil
}
// CreateDefaultBaseConfig creates a default base.json with retry_delay_seconds: 3
func CreateDefaultBaseConfig(path string) error {
defaultConfig := BaseConfig{
RetryDelaySenconds: 3,
}
data, err := json.MarshalIndent(defaultConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal default base config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("failed to write default base config to file: %w", err)
}
return nil
}
// LoadConfigs resolves config paths and loads both configs
func LoadConfigs() (BaseConfig, ProjectConfig, error) {
wd, err := os.Getwd()
if err != nil {
return BaseConfig{}, ProjectConfig{}, fmt.Errorf("failed to get current directory: %w", err)
}
var baseConfigPath string
if baseConfigFile == "" {
baseConfigPath = wd + string(os.PathSeparator) + "base.json"
} else {
baseConfigPath = baseConfigFile
}
var projectConfigPath string
if projectConfigFile == "" {
projectConfigPath = wd + string(os.PathSeparator) + "config.json"
} else {
projectConfigPath = projectConfigFile
}
baseConfig, err := ReadBaseConfig(baseConfigPath)
if err != nil {
return BaseConfig{}, ProjectConfig{}, fmt.Errorf("error reading base config file: %w", err)
}
projectConfig, err := ReadProjectConfig(projectConfigPath, &pipelineFile)
if err != nil {
return BaseConfig{}, ProjectConfig{}, fmt.Errorf("error reading project config file: %w", err)
}
return baseConfig, projectConfig, nil
}

50
config/base_test.go Normal file
View file

@ -0,0 +1,50 @@
package config
import (
"os"
"testing"
)
func TestReadBaseConfig_Basic(t *testing.T) {
// Create a temporary config file
file, err := os.CreateTemp("", "baseconfig_*.json")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(file.Name())
jsonContent := `{
"projects_directory": "/projects",
"env": "dev",
"static_images": "/static",
"public_images": "/public",
"php_conf": "/php.ini",
"exports": "/exports",
"logs": "/logs",
"preview_path": "/preview",
"data_www": "/data",
"nginx_conf": "/nginx.conf",
"admin_url": "http://admin",
"preview_url": "http://preview",
"app_image": "app:v1",
"webserver_image": "web:v1",
"empty_db": "empty.db",
"db": "app.db",
"empty_imaages": "empty.img",
"deployment_type": "json",
"deployment_file": "base.json",
"port": 8080
}`
file.WriteString(jsonContent)
file.Close()
os.Setenv("DEPLOYMENT_TYPE", "json")
config, err := ReadBaseConfig(file.Name())
if err != nil {
t.Fatalf("ReadBaseConfig failed: %v", err)
}
// Only check RetryDelaySenconds as that's the only field in BaseConfig now
if config.RetryDelaySenconds != 0 {
t.Errorf("expected RetryDelaySenconds 0, got %d", config.RetryDelaySenconds)
}
}

View file

@ -1,29 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"os"
)
type CustomerConfig struct {
Project string `json:"project"`
CustomerDirectory string `json:"customer_directory"`
UIURL string `json:"ui_url"`
StaticURL string `json:"static_url"`
Port int `json:"port"`
}
func ReadCustomerConfig(path string) (CustomerConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return CustomerConfig{}, fmt.Errorf("failed to read customer configfile: %w", err)
}
var cust CustomerConfig
if err := json.Unmarshal(data, &cust); err != nil {
return CustomerConfig{}, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
return cust, nil
}

108
config/project.go Normal file
View file

@ -0,0 +1,108 @@
package config
import (
"encoding/json"
"fmt"
"os"
)
type ProjectConfig struct {
LogFormat string `json:"log_format"`
DeploymentFile string `json:"deployment_file"`
DeploymentType string `json:"deployment_type"`
DeploymentMode string `json:"deployment_mode"`
}
func ValidateProjectConfig(config ProjectConfig) error {
if config.LogFormat != "full" && config.LogFormat != "basic" && config.LogFormat != "none" {
return fmt.Errorf("invalid LogFormat: %s (must be 'full', 'basic', or 'none')", config.LogFormat)
}
if _, err := os.Stat(config.DeploymentFile); os.IsNotExist(err) {
return fmt.Errorf("deployment file does not exist: %s", config.DeploymentFile)
} else if err != nil {
return fmt.Errorf("error checking deployment file: %w", err)
}
if config.DeploymentMode != "json" && config.DeploymentMode != "api" {
return fmt.Errorf("invalid DeploymentMode: %s (must be 'json' or 'api')", config.DeploymentMode)
}
fmt.Printf("DeploymentType: %s\n", config.DeploymentType)
if config.DeploymentType != "development" && config.DeploymentType != "pre-production" && config.DeploymentType != "production" {
return fmt.Errorf("invalid DeploymentType: %s (must be 'development', 'pre-production', or 'production')", config.DeploymentType)
}
return nil
}
func CreateDefaultJsonConfig(path string, depploymentFile string) error {
defaultConfig := ProjectConfig{
LogFormat: "full",
DeploymentType: "development",
DeploymentFile: depploymentFile,
DeploymentMode: "json",
}
data, err := json.MarshalIndent(defaultConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal default config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("failed to write default config to file: %w", err)
}
return nil
}
func ReadProjectConfig(path string, pipelineFile *string) (ProjectConfig, error) {
var config ProjectConfig
if pipelineFile == nil || *pipelineFile == "" {
return ProjectConfig{}, fmt.Errorf("no deployment file specified, please use -f or --deployment-file flag")
}
config.DeploymentFile = *pipelineFile
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := CreateDefaultJsonConfig(path, config.DeploymentFile); err != nil {
return ProjectConfig{}, fmt.Errorf("failed to create default config: %w", err)
}
}
data, err := os.ReadFile(path)
if err != nil {
return ProjectConfig{}, fmt.Errorf("failed to read project configfile: %w", err)
}
var proj ProjectConfig
if err := json.Unmarshal(data, &proj); err != nil {
return ProjectConfig{}, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
if proj.DeploymentMode != "" {
config.DeploymentMode = proj.DeploymentMode
} else {
config.DeploymentMode = "json"
}
deploymentModeEnv := os.Getenv("DEPLOYMENT_MODE")
if deploymentModeEnv != "" {
config.DeploymentMode = deploymentModeEnv
}
config.LogFormat = proj.LogFormat
if config.LogFormat == "" {
config.LogFormat = "full"
}
config.DeploymentType = proj.DeploymentType
if err := ValidateProjectConfig(config); err != nil {
return ProjectConfig{}, err
}
return config, nil
}

46
config/project_test.go Normal file
View file

@ -0,0 +1,46 @@
package config
import (
"os"
"testing"
)
func TestReadProjectConfig_Basic(t *testing.T) {
file, err := os.CreateTemp("", "projectconfig_*.json")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(file.Name())
jsonContent := `{
"log_format": "full",
"deployment_file": "pipeline.json",
"deployment_mode": "json",
"deployment_type": "development"
}`
file.WriteString(jsonContent)
file.Close()
pipelineFile, err := os.CreateTemp("", "pipeline_*.json")
if err != nil {
t.Fatalf("failed to create temp pipeline file: %v", err)
}
defer os.Remove(pipelineFile.Name())
pipelineFile.WriteString(`{}`) // minimal valid JSON
pipelineFile.Close()
pipelineFilePath := pipelineFile.Name()
config, err := ReadProjectConfig(file.Name(), &pipelineFilePath)
if err != nil {
t.Fatalf("ReadProjectConfig failed: %v", err)
}
if config.LogFormat != "full" {
t.Errorf("expected LogFormat 'full', got '%s'", config.LogFormat)
}
if config.DeploymentFile != pipelineFilePath {
t.Errorf("expected DeploymentFile '%s', got '%s'", pipelineFilePath, config.DeploymentFile)
}
if config.DeploymentMode != "json" {
t.Errorf("expected DeploymentMode 'json', got '%s'", config.DeploymentMode)
}
}

View file

@ -1,74 +0,0 @@
package database
import (
"database/sql"
"log"
"log/slog"
"os"
_ "modernc.org/sqlite"
)
func NewDatabase(dbPath string) (*sql.DB, error) {
// Check if the application is running in a test environment
if os.Getenv("TEST_ENV") == "true" {
dbPath = ":memory:" // Use in-memory database for tests
slog.Info("🧪 Running in test environment, using in-memory database")
log.Fatal("🧪 Running in test environment, using in-memory database ")
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
createTableSQL := `
CREATE TABLE IF NOT EXISTS project_name (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_name TEXT NOT NULL,
port INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`
_, err = db.Exec(createTableSQL)
if err != nil {
return nil, err
}
return db, nil
}
func CheckProjectName(db *sql.DB, projectName string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM project_name WHERE project_name = ? LIMIT 1);`
err := db.QueryRow(query, projectName).Scan(&exists)
if err != nil && err != sql.ErrNoRows {
return false, err
}
return !exists, nil
}
func AddProjectName(db *sql.DB, projectName string, port int) error {
query := `INSERT INTO project_name (project_name, port) VALUES (?, ?);`
_, err := db.Exec(query, projectName, port)
if err != nil {
return err
}
return nil
}
func GetNextPortNumber(db *sql.DB) (int, error) {
var maxPortNumber sql.NullInt64
query := `SELECT MAX(port) FROM project_name;`
err := db.QueryRow(query).Scan(&maxPortNumber)
if err != nil && err != sql.ErrNoRows {
return 0, err
}
if !maxPortNumber.Valid {
// No rows in the table, return a default port number
return 10000, nil
}
return int(maxPortNumber.Int64 + 1), nil
}

15
go.mod
View file

@ -1,18 +1,3 @@
module headshed/infctl-cli
go 1.23.3
require modernc.org/sqlite v1.38.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.33.0 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

47
go.sum
View file

@ -1,47 +0,0 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -25,7 +25,7 @@ case "$OS" in
esac
# Construct the download URL
VERSION="v0.0.4"
VERSION="v0.0.5"
BINARY_URL="https://codeberg.org/headshed/infctl-cli/releases/download/$VERSION/infctl-$OS-$ARCH"
# Download the binary

119
logger/logger.go Normal file
View file

@ -0,0 +1,119 @@
package logger
import (
"context"
"fmt"
"log/slog"
"os"
)
// multiHandler writes to multiple slog.Handlers
type multiHandler struct {
handlers []slog.Handler
}
func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, h := range m.handlers {
if h.Enabled(ctx, level) {
return true
}
}
return false
}
func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error {
var err error
for _, h := range m.handlers {
if e := h.Handle(ctx, r); e != nil && err == nil {
err = e
}
}
return err
}
func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newHandlers := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
newHandlers[i] = h.WithAttrs(attrs)
}
return &multiHandler{handlers: newHandlers}
}
func (m *multiHandler) WithGroup(name string) slog.Handler {
newHandlers := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
newHandlers[i] = h.WithGroup(name)
}
return &multiHandler{handlers: newHandlers}
}
type customMessageOnlyHandler struct {
output *os.File
}
func (h *customMessageOnlyHandler) Enabled(_ context.Context, _ slog.Level) bool {
return true
}
func (h *customMessageOnlyHandler) Handle(_ context.Context, r slog.Record) error {
msg := r.Message
if msg != "" {
_, err := fmt.Fprintln(h.output, msg)
return err
}
return nil
}
func (h *customMessageOnlyHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return h
}
func (h *customMessageOnlyHandler) WithGroup(_ string) slog.Handler {
return h
}
// SetupLogger configures slog based on format and file
func SetupLogger(logFormat string, logFilePath string, level slog.Level) *slog.Logger {
var handlers []slog.Handler
var logFile *os.File
var levelVar slog.LevelVar
levelVar.Set(level)
if logFilePath != "" {
f, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err == nil {
logFile = f
// Do not close here; let OS handle it at process exit
}
}
switch logFormat {
case "basic":
handlers = append(handlers, slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}))
if logFile != nil {
handlers = append(handlers, slog.NewTextHandler(logFile, &slog.HandlerOptions{}))
}
case "none":
handlers = append(handlers, &customMessageOnlyHandler{output: os.Stdout})
if logFile != nil {
handlers = append(handlers, &customMessageOnlyHandler{output: logFile})
}
default:
handlers = append(handlers, slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &levelVar}))
if logFile != nil {
handlers = append(handlers, slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: &levelVar}))
}
}
if len(handlers) == 1 {
return slog.New(handlers[0])
}
return slog.New(&multiHandler{handlers: handlers})
}

42
logger/logger_test.go Normal file
View file

@ -0,0 +1,42 @@
package logger
import (
"log/slog"
"os"
"testing"
)
func TestMultiHandlerBasic(t *testing.T) {
// Create two custom handlers that write to files
f1, err := os.CreateTemp("", "log1_*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(f1.Name())
f2, err := os.CreateTemp("", "log2_*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(f2.Name())
h1 := &customMessageOnlyHandler{output: f1}
h2 := &customMessageOnlyHandler{output: f2}
mh := &multiHandler{handlers: []slog.Handler{h1, h2}}
logger := slog.New(mh)
logger.Info("test message")
f1.Seek(0, 0)
buf1 := make([]byte, 100)
n1, _ := f1.Read(buf1)
if string(buf1[:n1]) != "test message\n" {
t.Errorf("expected message in log1, got: %q", string(buf1[:n1]))
}
f2.Seek(0, 0)
buf2 := make([]byte, 100)
n2, _ := f2.Read(buf2)
if string(buf2[:n2]) != "test message\n" {
t.Errorf("expected message in log2, got: %q", string(buf2[:n2]))
}
}

106
main.go
View file

@ -14,101 +14,61 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"os"
"headshed/infctl-cli/app"
"headshed/infctl-cli/config"
"headshed/infctl-cli/logger"
)
type customMessageOnlyHandler struct {
output *os.File
}
func (h *customMessageOnlyHandler) Enabled(_ context.Context, _ slog.Level) bool {
return true
}
func (h *customMessageOnlyHandler) Handle(_ context.Context, r slog.Record) error {
// Directly retrieve the message from the record
msg := r.Message
if msg != "" {
_, err := fmt.Fprintln(h.output, msg)
return err
}
return nil
}
func (h *customMessageOnlyHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return h
}
func (h *customMessageOnlyHandler) WithGroup(_ string) slog.Handler {
return h
}
func main() {
var levelVar slog.LevelVar
levelVar.Set(slog.LevelDebug)
var logger *slog.Logger
if os.Getenv("LOG_FORMAT") == "basic" {
logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}))
} else if os.Getenv("LOG_FORMAT") == "none" {
logger = slog.New(&customMessageOnlyHandler{output: os.Stdout})
} else {
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &levelVar}))
config.ParseFlags()
baseConfig, projectConfig, err := config.LoadConfigs()
if err != nil {
fmt.Fprintf(os.Stderr, "Config error: %v\n", err)
os.Exit(1)
}
logFormat := projectConfig.LogFormat
if os.Getenv("LOG_FORMAT") != "" {
logFormat = os.Getenv("LOG_FORMAT")
}
logFilePath := os.Getenv("LOG_FILE")
logger := logger.SetupLogger(logFormat, logFilePath, slog.LevelDebug)
slog.SetDefault(logger)
if err := run(); err != nil {
log.Fatalf("Application error: %v", err)
if err := run(projectConfig, baseConfig); err != nil {
slog.Error("❌ 💥 Pipeline error: " + err.Error())
os.Exit(1)
} else {
slog.Info("✅ 🚀 Pipeline completed successfully")
}
}
func run() error {
func run(projectConfig config.ProjectConfig, baseConfig config.BaseConfig) error {
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
baseConfigPath := wd + string(os.PathSeparator) + "base.json"
configPath := wd + string(os.PathSeparator) + "config.json"
baseConfig, err := config.ReadBaseConfig(baseConfigPath)
if err != nil {
return fmt.Errorf("error reading base config file: %w", err)
}
customerConfig, err := config.ReadCustomerConfig(configPath)
if err != nil {
return fmt.Errorf("error reading customer config file: %w", err)
}
appState, err := app.NewAppState(customerConfig, baseConfig, "app.db")
appState, err := app.NewAppState(projectConfig, baseConfig)
if err != nil {
return fmt.Errorf("failed to initialize app state: %w", err)
}
defer func() {
if err := appState.DB.Close(); err != nil {
log.Printf("Error closing database: %v", err)
}
}()
if err := appState.CreatePipeline(); err != nil {
return fmt.Errorf("failed to create customer project: %w", err)
// Pretty-print appState as JSON
if os.Getenv("DEBUG") == "1" {
if jsonBytes, err := json.MarshalIndent(appState, "", " "); err == nil {
fmt.Printf(">>DEBUG>> appState:\n%s\n", string(jsonBytes))
} else {
fmt.Printf(">>DEBUG>> appState: %+v\n", appState)
}
}
if err := appState.CreateProjectAndRunPipeline(); err != nil {
return fmt.Errorf("pipeline error: %w", err)
}
return nil

10
main_test.go Normal file
View file

@ -0,0 +1,10 @@
package main
import "testing"
// Example test for main package
func TestMainDummy(t *testing.T) {
// This is a placeholder test.
// Add real tests for functions in main.go if possible.
t.Log("main package test ran")
}

View file

@ -0,0 +1,20 @@
[
{
"name": "run a failing job",
"function": "RunCommand",
"params": [
"./scripts/failue.sh"
],
"retryCount": 2,
"shouldAbort": false
},
{
"name": "run a successful job",
"function": "RunCommand",
"params": [
"./scripts/success.sh"
],
"retryCount": 0,
"shouldAbort": true
}
]

View file

@ -0,0 +1,20 @@
[
{
"name": "run a failing job",
"function": "RunCommand",
"params": [
"./scripts/failue.sh"
],
"retryCount": 2,
"shouldAbort": true
},
{
"name": "run a successful job",
"function": "RunCommand",
"params": [
"./scripts/success.sh"
],
"retryCount": 0,
"shouldAbort": true
}
]

View file

@ -1,7 +1,6 @@
[
{
"name": "Create Vagrant nodes",
"name": "run a failing job",
"function": "RunCommand",
"params": [
"./scripts/failue.sh"
@ -9,7 +8,6 @@
"retryCount": 0,
"shouldAbort": true
},
{
"name": "Configure Vagrant K3s",
"function": "RunCommand",
@ -19,8 +17,6 @@
"retryCount": 0,
"shouldAbort": true
},
{
"name": "Create Vagrant workstation",
"function": "RunCommand",

View file

@ -0,0 +1,38 @@
[
{
"name": "run succeeding pipeline",
"function": "RunCommand",
"params": [
"LOG_FORMAT=none infctl -f pipelines/dev/succeeding.json"
],
"retryCount": 0,
"shouldAbort": true
},
{
"name": "run failing pipeline",
"function": "RunCommand",
"params": [
"LOG_FORMAT=none infctl -f pipelines/dev/failing.json"
],
"retryCount": 0,
"shouldAbort": false
},
{
"name": "run retry pipeline",
"function": "RunCommand",
"params": [
"LOG_FORMAT=none infctl -f pipelines/dev/retry.json"
],
"retryCount": 0,
"shouldAbort": false
},
{
"name": "run carry on pipeline",
"function": "RunCommand",
"params": [
"LOG_FORMAT=none infctl -f pipelines/dev/carry_on.json"
],
"retryCount": 0,
"shouldAbort": true
}
]

11
pipelines/dev/retry.json Normal file
View file

@ -0,0 +1,11 @@
[
{
"name": "run a failing job",
"function": "RunCommand",
"params": [
"./scripts/failue.sh"
],
"retryCount": 1,
"shouldAbort": true
}
]

View file

@ -0,0 +1,11 @@
[
{
"name": "run a successful job",
"function": "RunCommand",
"params": [
"./scripts/success.sh"
],
"retryCount": 0,
"shouldAbort": true
}
]

View file

@ -1,24 +1,21 @@
#!/usr/bin/env bash
# sleep 5
echo "crash"
sleep 1
# sleep 1
echo "bang"
sleep 2
# sleep 2
echo "wallop"
echo
echo
echo
# sleep 1
echo "Houston, we have a problem"
echo
echo
echo
sleep 1
exit 1

21
scripts/success.sh Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# sleep 5
echo "bish"
# sleep 1
echo "bash"
# sleep 2
echo "bosh"
# sleep 1
echo "lovely jubbly"
sleep 1
exit 0

View file

@ -106,7 +106,7 @@ if ! grep -qF "$BLOCK_START" "$BASHRC"; then
eval `ssh-agent -s`
ssh-add ~/machines/*/virtualbox/private_key
ssh-add -L
source ~/vagrant/.envrc
source /vagrant/.envrc
EOF
else
echo "Provisioning block already present in $BASHRC"

View file

@ -0,0 +1,34 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: infmon-cli
namespace: default
spec:
selector:
matchLabels:
app: infmon-cli
replicas: 1
template:
metadata:
labels:
app: infmon-cli
spec:
containers:
- name: infmon-cli
image: 192.168.2.190:5000/infmon-cli:0.0.1
command: ["sleep", "infinity"]
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
volumeMounts:
- name: kubeconfig
mountPath: /root/.kube/config
subPath: config
volumes:
- name: kubeconfig
secret:
secretName: infmon-kubeconfig

View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
kubectl apply -f pvc.yaml
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
kubectl apply -f ingress.yaml

View file

@ -16,14 +16,5 @@
],
"retryCount": 0,
"shouldAbort": true
},
{
"name": "Wait for Longhorn pods to come up",
"function": "RunCommand",
"params": [
"./scripts/wait_for_longhorn.sh"
],
"retryCount": 10,
"shouldAbort": true
}
]

View file

@ -14,21 +14,6 @@ then
install_infctl
fi
# base.json.example config.json.example
# https://codeberg.org/headshed/infctl-cli/raw/branch/main/base.json.example
# https://codeberg.org/headshed/infctl-cli/raw/branch/main/config.json.example
if [ ! -f "base.json" ]; then
echo "base.json not found in home directory, downloading..."
curl -o "base.json" https://codeberg.org/headshed/infctl-cli/raw/branch/main/base.json.example
fi
if [ ! -f "config.json" ]; then
echo "config.json not found in home directory, downloading..."
curl -o "config.json" https://codeberg.org/headshed/infctl-cli/raw/branch/main/config.json.example
fi