Files
tiny-forge/internal/volume/resolver.go
T
alexei.dolgolyov 791cd4d6af
Build / build (push) Successful in 12m20s
feat: rename Docker Watcher to Tinyforge
Rebrand the project as Tinyforge to reflect its evolution from a Docker
container watcher into a self-hosted mini CI/deployment platform.

Rename covers: Go module path, Docker labels, DB/config filenames,
JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend
i18n, README with static sites docs, and all code comments.
2026-04-12 21:30:39 +03:00

109 lines
3.3 KiB
Go

package volume
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/alexei/tinyforge/internal/store"
)
// ResolveParams holds the parameters needed to resolve a volume's host path.
type ResolveParams struct {
BasePath string
ProjectName string
StageName string // required for instance and stage scopes
ImageTag string // required for instance scope
AllowedVolumePaths string // JSON array of allowed absolute paths (from settings)
}
// ResolvePath returns the absolute host path for a volume based on its scope.
// Returns an error for ephemeral volumes (no host path) or missing parameters.
func ResolvePath(vol store.Volume, params ResolveParams) (string, error) {
scope := vol.Scope
if scope == "" {
switch vol.Mode {
case "isolated":
scope = "instance"
default:
scope = "project"
}
}
if scope == "ephemeral" {
return "", fmt.Errorf("ephemeral volumes have no host path")
}
if scope == "absolute" {
return resolveAbsolute(vol.Source, params.AllowedVolumePaths)
}
switch scope {
case "instance":
if params.StageName == "" || params.ImageTag == "" {
return "", fmt.Errorf("instance scope requires stage and tag parameters")
}
return filepath.Join(params.BasePath, params.ProjectName, fmt.Sprintf("%s-%s", params.StageName, params.ImageTag), vol.Source), nil
case "stage":
if params.StageName == "" {
return "", fmt.Errorf("stage scope requires stage parameter")
}
return filepath.Join(params.BasePath, params.ProjectName, params.StageName, vol.Source), nil
case "project":
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
case "project_named":
return filepath.Join(params.BasePath, params.ProjectName, "_named", vol.Name, vol.Source), nil
case "named":
return filepath.Join(params.BasePath, "_named", vol.Name, vol.Source), nil
default:
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
}
}
// resolveAbsolute validates that the source path is under one of the allowed prefixes.
func resolveAbsolute(source, allowedPathsJSON string) (string, error) {
if source == "" {
return "", fmt.Errorf("absolute scope requires a source path")
}
cleaned := filepath.Clean(source)
if !filepath.IsAbs(cleaned) {
return "", fmt.Errorf("absolute scope requires an absolute source path (starting with /)")
}
allowed, err := parseAllowedPaths(allowedPathsJSON)
if err != nil {
return "", fmt.Errorf("failed to parse allowed volume paths: %w", err)
}
if len(allowed) == 0 {
return "", fmt.Errorf("absolute volume paths are disabled (no allowed paths configured in settings)")
}
for _, prefix := range allowed {
prefixClean := filepath.Clean(prefix)
if strings.HasPrefix(cleaned, prefixClean+string(filepath.Separator)) || cleaned == prefixClean {
return cleaned, nil
}
}
return "", fmt.Errorf("path %q is not under any allowed volume path", source)
}
// parseAllowedPaths parses a JSON array of path strings.
func parseAllowedPaths(jsonStr string) ([]string, error) {
if jsonStr == "" || jsonStr == "[]" {
return nil, nil
}
var paths []string
if err := json.Unmarshal([]byte(jsonStr), &paths); err != nil {
return nil, err
}
return paths, nil
}
// ParseAllowedPaths is the exported version for use in API validation.
func ParseAllowedPaths(jsonStr string) ([]string, error) {
return parseAllowedPaths(jsonStr)
}