Add security hardening features for worker execution: - Worker config with sandboxing options (network_mode, read_only, secrets) - Execution setup with security context propagation - Podman container runtime security enhancements - Security configuration management in config package - Add homelab-sandbox.yaml example configuration Supports running jobs in isolated, restricted environments.
223 lines
5.3 KiB
Go
223 lines
5.3 KiB
Go
// Package execution provides job execution utilities for the worker
|
|
package execution
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/jfraeys/fetch_ml/internal/config"
|
|
"github.com/jfraeys/fetch_ml/internal/container"
|
|
"github.com/jfraeys/fetch_ml/internal/errtypes"
|
|
"github.com/jfraeys/fetch_ml/internal/logging"
|
|
"github.com/jfraeys/fetch_ml/internal/storage"
|
|
)
|
|
|
|
// PrepareContainerEnv prepares environment and secrets for container execution
|
|
func PrepareContainerEnv(
|
|
ctx context.Context,
|
|
taskID string,
|
|
jobName string,
|
|
requestedSecrets []string,
|
|
allowedSecrets []string,
|
|
allowSecrets bool,
|
|
logger *logging.Logger,
|
|
) ([]container.PodmanSecret, map[string]string, error) {
|
|
env := make(map[string]string)
|
|
secrets := []container.PodmanSecret{}
|
|
|
|
// Add standard env vars
|
|
env["FETCH_ML_JOB_NAME"] = jobName
|
|
env["FETCH_ML_TASK_ID"] = taskID
|
|
|
|
// Inject requested secrets if allowed
|
|
if allowSecrets && len(requestedSecrets) > 0 {
|
|
for _, secretName := range requestedSecrets {
|
|
if !isSecretAllowed(secretName, allowedSecrets) {
|
|
logger.Warn("secret not in allowlist, skipping", "secret", secretName)
|
|
continue
|
|
}
|
|
|
|
value, err := getSecretValue(secretName)
|
|
if err != nil {
|
|
logger.Warn("failed to get secret value", "secret", secretName, "error", err)
|
|
continue
|
|
}
|
|
|
|
secret := container.PodmanSecret{
|
|
Name: fmt.Sprintf("fetchml_%s_%s", strings.ToLower(secretName), taskID[:8]),
|
|
Data: []byte(value),
|
|
EnvVar: secretName, // Mount as env var
|
|
}
|
|
secrets = append(secrets, secret)
|
|
|
|
logger.Info("injected secret", "secret", secretName, "task", taskID)
|
|
}
|
|
}
|
|
|
|
return secrets, env, nil
|
|
}
|
|
|
|
func isSecretAllowed(name string, allowedList []string) bool {
|
|
if len(allowedList) == 0 {
|
|
return false // Default deny
|
|
}
|
|
for _, allowed := range allowedList {
|
|
if strings.EqualFold(name, allowed) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func getSecretValue(name string) (string, error) {
|
|
// Try environment variable first
|
|
value := os.Getenv(name)
|
|
if value != "" {
|
|
return value, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("secret %s not found in environment", name)
|
|
}
|
|
|
|
// JobPaths holds the directory paths for a job
|
|
type JobPaths struct {
|
|
JobDir string
|
|
OutputDir string
|
|
LogFile string
|
|
}
|
|
|
|
// SetupJobDirectories creates the necessary directories for a job using PathRegistry
|
|
func SetupJobDirectories(
|
|
basePath string,
|
|
jobName string,
|
|
taskID string,
|
|
) (jobDir, outputDir, logFile string, err error) {
|
|
jobPaths := storage.NewJobPaths(basePath)
|
|
pendingDir := jobPaths.PendingPath()
|
|
jobDir = filepath.Join(pendingDir, jobName)
|
|
outputDir = filepath.Join(jobPaths.RunningPath(), jobName)
|
|
logFile = filepath.Join(outputDir, "output.log")
|
|
|
|
// Use PathRegistry for consistent directory creation
|
|
paths := config.FromEnv()
|
|
|
|
// Create pending directory
|
|
if err := paths.EnsureDir(pendingDir); err != nil {
|
|
return "", "", "", &errtypes.TaskExecutionError{
|
|
TaskID: taskID,
|
|
JobName: jobName,
|
|
Phase: "setup",
|
|
Err: fmt.Errorf("failed to create pending dir: %w", err),
|
|
}
|
|
}
|
|
|
|
// Create job directory in pending
|
|
if err := paths.EnsureDir(jobDir); err != nil {
|
|
return "", "", "", &errtypes.TaskExecutionError{
|
|
TaskID: taskID,
|
|
JobName: jobName,
|
|
Phase: "setup",
|
|
Err: fmt.Errorf("failed to create job dir: %w", err),
|
|
}
|
|
}
|
|
|
|
// Sanitize paths
|
|
jobDir, err = container.SanitizePath(jobDir)
|
|
if err != nil {
|
|
return "", "", "", &errtypes.TaskExecutionError{
|
|
TaskID: taskID,
|
|
JobName: jobName,
|
|
Phase: "validation",
|
|
Err: err,
|
|
}
|
|
}
|
|
outputDir, err = container.SanitizePath(outputDir)
|
|
if err != nil {
|
|
return "", "", "", &errtypes.TaskExecutionError{
|
|
TaskID: taskID,
|
|
JobName: jobName,
|
|
Phase: "validation",
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
// Create running directory
|
|
if err := paths.EnsureDir(outputDir); err != nil {
|
|
return "", "", "", &errtypes.TaskExecutionError{
|
|
TaskID: taskID,
|
|
JobName: jobName,
|
|
Phase: "setup",
|
|
Err: fmt.Errorf("failed to create running dir: %w", err),
|
|
}
|
|
}
|
|
|
|
return jobDir, outputDir, logFile, nil
|
|
}
|
|
|
|
// CopyDir copies a directory tree from src to dst
|
|
func CopyDir(src, dst string) error {
|
|
src = filepath.Clean(src)
|
|
dst = filepath.Clean(dst)
|
|
|
|
srcInfo, err := os.Stat(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !srcInfo.IsDir() {
|
|
return fmt.Errorf("source is not a directory")
|
|
}
|
|
|
|
if err := os.MkdirAll(dst, 0750); err != nil {
|
|
return err
|
|
}
|
|
|
|
return filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
rel, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.Clean(rel)
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, "..") {
|
|
return fmt.Errorf("invalid relative path")
|
|
}
|
|
outPath := filepath.Join(dst, rel)
|
|
if d.IsDir() {
|
|
return os.MkdirAll(outPath, 0750)
|
|
}
|
|
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mode := info.Mode() & 0777
|
|
return copyFile(filepath.Clean(path), outPath, mode)
|
|
})
|
|
}
|
|
|
|
// copyFile copies a single file
|
|
func copyFile(src, dst string, mode os.FileMode) error {
|
|
srcFile, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dstFile.Close()
|
|
|
|
_, err = io.Copy(dstFile, srcFile)
|
|
return err
|
|
}
|