Files
ThothII/tools/tht/internal/config/installation.go
T

695 lines
24 KiB
Go

// Package config loads the non-secret, local installation descriptor used by tht.
package config
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"unicode"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
const installationFileName = "thothii-installation.yaml"
const maxEnvironmentFileBytes = 1 << 20
const maxMetadataSecretBundleBytes = 64 << 10
const maxSecretSources = 32
var dotenvParseMu sync.Mutex
type descriptor struct {
SchemaVersion int `yaml:"schemaVersion"`
Profile string `yaml:"profile"`
ProjectDirectory string `yaml:"projectDirectory"`
EnvFile string `yaml:"envFile"`
WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"`
Authentication authenticationDescriptor `yaml:"authentication"`
ModelCatalog ModelCatalog `yaml:"modelCatalog"`
Overrides []string `yaml:"overrides"`
}
type authenticationDescriptor struct {
ConfigDirectory string `yaml:"configDirectory"`
RuntimeProjection *runtimeProjectionDescriptor `yaml:"runtimeProjection"`
}
type runtimeProjectionDescriptor struct {
Directory string `yaml:"directory"`
UID uint32 `yaml:"uid"`
GID uint32 `yaml:"gid"`
}
type workspaceRepositoryDescriptor struct {
Remote string `yaml:"remote"`
Branch string `yaml:"branch"`
Access string `yaml:"access"`
}
// WorkspaceRepository is the non-secret Git source identity declared by one installation.
type WorkspaceRepository struct {
Remote string
Branch string
Access string
}
// RuntimeProjection is the non-secret runtime root and numeric container ownership contract.
type RuntimeProjection struct {
Directory string
UID uint32
GID uint32
}
// Authentication is the non-secret filesystem location for the installation auth configuration.
type Authentication struct {
ConfigDirectory string
RuntimeProjection *RuntimeProjection
}
// Installation is a validated local Compose installation. It intentionally contains paths, not
// environment values or secret content.
type Installation struct {
Path string
SchemaVersion int
Profile string
ProjectDirectory string
EnvFile string
WorkspaceRepository WorkspaceRepository
Authentication Authentication
ModelCatalog ModelCatalog
Overrides []string
}
// Load reads and validates an installation descriptor at an absolute path.
func Load(path string) (Installation, error) {
if !filepath.IsAbs(path) {
return Installation{}, fmt.Errorf("installation path must be absolute")
}
path = filepath.Clean(path)
if filepath.Base(path) != installationFileName {
return Installation{}, fmt.Errorf("installation file must be named %s", installationFileName)
}
if err := requireRegularFile(path, "installation file"); err != nil {
return Installation{}, err
}
file, err := os.Open(path)
if err != nil {
return Installation{}, fmt.Errorf("open installation file: %w", err)
}
defer file.Close()
var raw descriptor
decoder := yaml.NewDecoder(file)
decoder.KnownFields(true)
if err := decoder.Decode(&raw); err != nil {
if strings.Contains(err.Error(), "field metadataGeneration not found") {
return Installation{}, errors.New("migration_required: metadataGeneration was replaced by modelCatalog in installation schema version 2")
}
return Installation{}, fmt.Errorf("read installation file: %w", err)
}
if err := ensureOnlyOneDocument(decoder); err != nil {
return Installation{}, err
}
if raw.SchemaVersion != 2 {
return Installation{}, errors.New("migration_required: installation schemaVersion must be 2")
}
if raw.Profile != "local" && raw.Profile != "server" {
return Installation{}, fmt.Errorf("profile must be local or server")
}
if err := requireDirectory(raw.ProjectDirectory, "projectDirectory"); err != nil {
return Installation{}, err
}
for _, legacyProjection := range []string{
filepath.Join(raw.ProjectDirectory, "deploy", "pi", "models.json"),
filepath.Join(raw.ProjectDirectory, "deploy", "pi", "settings.json"),
} {
if _, err := os.Lstat(legacyProjection); err == nil {
return Installation{}, errors.New("migration_required: remove legacy deploy/pi model sources after reviewing the schema version 2 candidate")
} else if !errors.Is(err, os.ErrNotExist) {
return Installation{}, errors.New("legacy deploy/pi model sources could not be inspected")
}
}
if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil {
return Installation{}, err
}
if err := safeio.ValidateCanonicalPath(raw.Authentication.ConfigDirectory); err != nil {
return Installation{}, errors.New("authentication.configDirectory must be an absolute canonical path")
}
authentication := Authentication{ConfigDirectory: raw.Authentication.ConfigDirectory}
if raw.Authentication.RuntimeProjection != nil {
authentication.RuntimeProjection = &RuntimeProjection{
Directory: raw.Authentication.RuntimeProjection.Directory,
UID: raw.Authentication.RuntimeProjection.UID,
GID: raw.Authentication.RuntimeProjection.GID,
}
}
installation := Installation{
Path: path,
SchemaVersion: raw.SchemaVersion,
Profile: raw.Profile,
ProjectDirectory: filepath.Clean(raw.ProjectDirectory),
EnvFile: filepath.Clean(raw.EnvFile),
WorkspaceRepository: WorkspaceRepository{
Remote: raw.WorkspaceRepository.Remote,
Branch: raw.WorkspaceRepository.Branch,
Access: raw.WorkspaceRepository.Access,
},
Authentication: authentication,
ModelCatalog: raw.ModelCatalog,
Overrides: make([]string, 0, len(raw.Overrides)),
}
values, err := installation.environmentValues()
if err != nil {
return Installation{}, errors.New("installation secret declarations could not be read")
}
if err := installation.ModelCatalog.Validate(values); err != nil {
return Installation{}, err
}
if values["THT_AUTH_CONFIG_ROOT"] != installation.AuthenticationDirectory() {
return Installation{}, errors.New("authentication.configDirectory must match THT_AUTH_CONFIG_ROOT")
}
for _, override := range raw.Overrides {
if err := requireRegularFile(override, "override"); err != nil {
return Installation{}, err
}
installation.Overrides = append(installation.Overrides, filepath.Clean(override))
}
if err := installation.validateRuntimeAuthProjection(values); err != nil {
return Installation{}, err
}
if installation.HasRuntimeAuthProjection() {
if err := requireRegularFile(installation.runtimeAuthProjectionComposePath(), "runtime authentication Compose override"); err != nil {
return Installation{}, err
}
}
for _, composeFile := range installation.ComposeFiles()[:2] {
if err := requireRegularFile(composeFile, "Compose file"); err != nil {
return Installation{}, err
}
}
if err := installation.validateWorkspaceRepository(); err != nil {
return Installation{}, err
}
if info, err := os.Lstat(installation.CurrentImageOverridePath()); err == nil {
if !info.Mode().IsRegular() {
return Installation{}, errors.New("installation current-image override must be a regular file")
}
} else if !errors.Is(err, os.ErrNotExist) {
return Installation{}, errors.New("installation current-image override could not be inspected")
}
return installation, nil
}
var metadataSecretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
var metadataAPIKeyEnvironments = map[string]struct{}{
"THT_MODEL_API_KEY": {},
"THT_METADATA_API_KEY": {},
"ANTHROPIC_API_KEY": {},
"AZURE_API_KEY": {},
"GEMINI_API_KEY": {},
"DEEPSEEK_API_KEY": {},
"OPENAI_API_KEY": {},
"OPENROUTER_API_KEY": {},
"ZAI_API_KEY": {},
}
var metadataSecretBundleKeys = map[string]struct{}{
"THT_MODEL_API_KEY": {},
"THT_DWH_API_KEY": {},
"THT_VEC_API_KEY": {},
"THT_VEC_WRITE_API_KEY": {},
"THT_CA": {},
"THT_SSL_CA": {},
"THT_VECTOR_BOOTSTRAP_PASSWORD": {},
"THT_VECTOR_MIGRATOR_PASSWORD": {},
"THT_VECTOR_READER_PASSWORD": {},
"THT_VECTOR_WRITER_PASSWORD": {},
"PI_PROVIDER_API_KEY": {},
"THT_OIDC_CLIENT_SECRET": {},
"THT_AUTHENTIK_API_TOKEN": {},
"THT_METADATA_API_KEY": {},
"ANTHROPIC_API_KEY": {},
"AZURE_API_KEY": {},
"GEMINI_API_KEY": {},
"DEEPSEEK_API_KEY": {},
"OPENAI_API_KEY": {},
"OPENROUTER_API_KEY": {},
"ZAI_API_KEY": {},
}
func readMetadataGenerationSecrets(path string) (map[string]string, error) {
contents, err := safeio.ReadCanonicalPrivateRegular(path, maxMetadataSecretBundleBytes)
if err != nil {
return nil, errors.New("metadataGeneration secrets in THT_SECRETS_FILE are unavailable")
}
values := make(map[string]string)
for _, raw := range strings.Split(string(contents), "\n") {
line := strings.TrimSuffix(raw, "\r")
if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found || !metadataSecretBundleKeyPattern.MatchString(key) {
return nil, errors.New("metadataGeneration secrets in THT_SECRETS_FILE are invalid")
}
if _, allowed := metadataSecretBundleKeys[key]; !allowed {
return nil, errors.New("metadataGeneration secrets in THT_SECRETS_FILE are invalid")
}
if _, duplicate := values[key]; duplicate {
return nil, errors.New("metadataGeneration secrets in THT_SECRETS_FILE contain duplicate keys")
}
values[key] = value
}
return values, nil
}
func usableMetadataGenerationSecret(value string) bool {
return len(value) > 0 && len(value) <= 16*1024 && strings.TrimSpace(value) == value &&
strings.IndexFunc(value, func(character rune) bool {
return unicode.IsSpace(character) || unicode.IsControl(character)
}) < 0
}
// AuthenticationDirectory returns the descriptor-owned, non-secret authentication root.
func (i Installation) AuthenticationDirectory() string { return i.Authentication.ConfigDirectory }
// RuntimeAuthProjection returns an independent descriptor copy when this installation uses the
// Linux-only runtime auth publication contract.
func (i Installation) RuntimeAuthProjection() *RuntimeProjection {
if i.Authentication.RuntimeProjection == nil {
return nil
}
projection := *i.Authentication.RuntimeProjection
return &projection
}
// HasRuntimeAuthProjection reports whether the descriptor selects the runtime auth projection.
func (i Installation) HasRuntimeAuthProjection() bool {
return i.Authentication.RuntimeProjection != nil
}
func (i Installation) validateRuntimeAuthProjection(values map[string]string) error {
projection := i.RuntimeAuthProjection()
if projection == nil {
if values["THT_AUTH_RUNTIME_ROOT"] != "" {
return errors.New("THT_AUTH_RUNTIME_ROOT requires authentication.runtimeProjection")
}
return nil
}
if i.Profile != "server" {
return errors.New("authentication.runtimeProjection requires the server profile")
}
if err := safeio.ValidateCanonicalPath(projection.Directory); err != nil || projection.Directory == i.AuthenticationDirectory() {
return errors.New("authentication.runtimeProjection.directory must be a distinct absolute canonical path")
}
if projection.UID != 10001 || projection.GID != 10001 {
return errors.New("authentication.runtimeProjection requires uid and gid 10001")
}
if values["THT_AUTH_RUNTIME_ROOT"] != projection.Directory {
return errors.New("authentication.runtimeProjection.directory must match THT_AUTH_RUNTIME_ROOT")
}
for _, override := range i.Overrides {
if filepath.Clean(override) == i.runtimeAuthProjectionComposePath() {
return errors.New("runtime authentication Compose override is automatic and must not be declared")
}
}
return nil
}
var safeGitBranch = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
var scpSSHRemote = regexp.MustCompile(`^git@[^:/\s]+:[^\s]+$`)
func (i Installation) validateWorkspaceRepository() error {
gitAccess := ""
for _, override := range i.Overrides {
switch filepath.Base(override) {
case "compose.git-ssh.yaml":
if gitAccess != "" {
return errors.New("installation must select exactly one Git transport override")
}
gitAccess = "ssh"
case "compose.git-https.yaml":
if gitAccess != "" {
return errors.New("installation must select exactly one Git transport override")
}
gitAccess = "https"
}
}
declared := i.WorkspaceRepository
if gitAccess == "" {
if declared.Remote != "" || declared.Branch != "" || declared.Access != "" {
return errors.New("workspaceRepository requires one Git transport override")
}
return nil
}
if declared.Remote == "" || declared.Branch == "" || declared.Access == "" {
return errors.New("workspaceRepository is required for a Git installation")
}
if declared.Access != gitAccess {
return errors.New("workspaceRepository access does not match the Git transport override")
}
if !safeGitBranch.MatchString(declared.Branch) || strings.Contains(declared.Branch, "..") ||
strings.Contains(declared.Branch, "@{") || strings.HasPrefix(declared.Branch, "-") ||
strings.HasSuffix(declared.Branch, ".lock") {
return errors.New("workspaceRepository branch is invalid")
}
if err := validateRepositoryRemote(declared.Remote, declared.Access); err != nil {
return err
}
values, err := i.environmentValues()
if err != nil {
return err
}
if values["THT_WORKSPACE_GIT_REMOTE"] != declared.Remote ||
values["THT_WORKSPACE_GIT_BRANCH"] != declared.Branch {
return errors.New("workspaceRepository does not match the installation environment")
}
required := []string{"THT_WORKSPACE_GIT_CREDENTIALS_FILE", "THT_WORKSPACE_GIT_CA_FILE"}
if gitAccess == "ssh" {
required = []string{"THT_WORKSPACE_GIT_SSH_KEY_FILE", "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE"}
}
for _, name := range required {
if err := requireRegularFile(values[name], "workspaceRepository credential"); err != nil {
return errors.New("workspaceRepository credentials are unavailable")
}
}
return nil
}
func validateRepositoryRemote(remote, access string) error {
if remote == "" || strings.TrimSpace(remote) != remote || strings.ContainsRune(remote, '\x00') {
return errors.New("workspaceRepository remote is invalid")
}
if access == "ssh" && scpSSHRemote.MatchString(remote) {
return nil
}
parsed, err := url.Parse(remote)
if err != nil || parsed.Hostname() == "" || parsed.RawQuery != "" || parsed.Fragment != "" ||
parsed.User != nil && access == "https" || parsed.User != nil && strings.Contains(parsed.User.String(), ":") {
return errors.New("workspaceRepository remote is invalid")
}
if access == "https" && parsed.Scheme != "https" {
return errors.New("workspaceRepository remote does not match HTTPS access")
}
if access == "ssh" && parsed.Scheme != "ssh" {
return errors.New("workspaceRepository remote does not match SSH access")
}
return nil
}
// ComposeFiles returns the base file, selected profile file, and declared optional overrides in
// the exact order Compose applies them.
func (i Installation) ComposeFiles() []string {
files := []string{
filepath.Join(i.ProjectDirectory, "compose.yaml"),
filepath.Join(i.ProjectDirectory, "deploy", "compose."+i.Profile+".yaml"),
}
files = append(files, i.Overrides...)
files = append(files, i.ModelProjectionComposePath())
if i.HasRuntimeAuthProjection() {
files = append(files, i.runtimeAuthProjectionComposePath())
}
currentImage := i.CurrentImageOverridePath()
if info, err := os.Lstat(currentImage); err == nil && info.Mode().IsRegular() {
files = append(files, currentImage)
}
return files
}
// GeneratedDirectory contains disposable runtime adapters derived from modelCatalog.
func (i Installation) GeneratedDirectory() string {
return filepath.Join(filepath.Dir(i.Path), "generated")
}
func (i Installation) GeneratedModelCatalogPath() string {
return filepath.Join(i.GeneratedDirectory(), "catalog.json")
}
func (i Installation) GeneratedPiModelsPath() string {
return filepath.Join(i.GeneratedDirectory(), "pi", "models.json")
}
func (i Installation) GeneratedPiSettingsPath() string {
return filepath.Join(i.GeneratedDirectory(), "pi", "settings.json")
}
func (i Installation) ModelProjectionComposePath() string {
return filepath.Join(i.GeneratedDirectory(), "compose.models.yaml")
}
func (i Installation) runtimeAuthProjectionComposePath() string {
return filepath.Join(i.ProjectDirectory, "deploy", "compose.auth-runtime-projection.yaml")
}
// ControlDirectory contains state that is private to one installation descriptor, even when
// multiple installations intentionally share one source checkout.
func (i Installation) ControlDirectory() string {
return filepath.Join(i.ProjectDirectory, ".tht", i.ProjectName())
}
func (i Installation) CurrentImageOverridePath() string {
return filepath.Join(i.ControlDirectory(), "current-image.yaml")
}
func (i Installation) UpdateStatePath() string {
return filepath.Join(i.ControlDirectory(), "update-state.json")
}
func (i Installation) RestartStatePath() string {
return filepath.Join(i.ControlDirectory(), "restart-state.json")
}
// ProjectName is stable for one installation and avoids collisions between different checkouts.
func (i Installation) ProjectName() string {
sum := sha256.Sum256([]byte(i.Path))
return fmt.Sprintf("thothii-%x", sum[:6])
}
// ComposeArgs builds Docker Compose arguments without shell quoting or interpolation.
func (i Installation) ComposeArgs(command ...string) []string {
return i.composeArgs(i.ComposeFiles(), command...)
}
// ComposeArgsWithFinalOverride appends one validated, generated override after every durable
// installation selector and before the Compose command.
func (i Installation) ComposeArgsWithFinalOverride(override string, command ...string) ([]string, error) {
if filepath.Clean(override) != override || !filepath.IsAbs(override) {
return nil, errors.New("final Compose override must be an absolute canonical path")
}
if err := requireRegularFile(override, "final Compose override"); err != nil {
return nil, err
}
files := append(i.ComposeFiles(), override)
return i.composeArgs(files, command...), nil
}
func (i Installation) composeArgs(files []string, command ...string) []string {
args := []string{"compose", "--project-name", i.ProjectName(), "--project-directory", i.ProjectDirectory, "--env-file", i.EnvFile}
for _, composeFile := range files {
args = append(args, "-f", composeFile)
}
return append(args, command...)
}
// SecretFiles returns canonical local secret paths declared through *_FILE or *_SOURCE variables.
// Compose's dotenv parser resolves comments, quotes, escapes, and interpolation. Unsupported or
// unresolved source interpolation is rejected before tht invokes Docker.
func (i Installation) SecretFiles() ([]string, error) {
contents, err := safeio.ReadCanonicalRegular(i.EnvFile, maxEnvironmentFileBytes)
if err != nil {
return nil, errors.New("installation secret declarations could not be read")
}
values, err := parseComposeDotenv(contents)
if err != nil {
return nil, errors.New("installation secret declarations could not be read")
}
files := make([]string, 0, len(values))
seen := make(map[string]struct{})
for key, value := range values {
key = strings.ToUpper(key)
if !strings.HasSuffix(key, "_FILE") && !strings.HasSuffix(key, "_SOURCE") {
continue
}
if err := safeio.ValidateCanonicalPath(value); err != nil {
return nil, errors.New("installation secret declarations could not be read")
}
if _, exists := seen[value]; !exists {
files = append(files, value)
seen[value] = struct{}{}
if len(files) > maxSecretSources {
return nil, errors.New("installation secret declarations could not be read")
}
}
}
sort.Strings(files)
return files, nil
}
// EnvironmentValue returns one declared installation value without exposing dotenv parsing to
// callers. It is used only for operator-visible file locations, never for secret content.
func (i Installation) EnvironmentValue(name string) (string, error) {
values, err := i.environmentValues()
if err != nil {
return "", err
}
return values[name], nil
}
func (i Installation) environmentValues() (map[string]string, error) {
contents, err := safeio.ReadCanonicalRegular(i.EnvFile, maxEnvironmentFileBytes)
if err != nil {
return nil, errors.New("installation environment could not be read")
}
values, err := parseComposeDotenv(contents)
if err != nil {
return nil, errors.New("installation environment could not be read")
}
return values, nil
}
// PreservationPaths returns the server bind roots, backup root, and declared secret files whose
// filesystem identities must survive a data-preserving removal.
func (i Installation) PreservationPaths() ([]string, error) {
if i.Profile != "server" {
return nil, errors.New("data-preserving removal requires a server installation")
}
values, err := i.environmentValues()
if err != nil {
return nil, err
}
paths := make([]string, 0)
seen := make(map[string]struct{})
for _, name := range []string{
"THT_DATA_ROOT", "THT_PI_STATE_ROOT", "THT_WORKSPACE_REGISTRY_ROOT", "THT_BACKUP_ROOT",
} {
path := values[name]
if err := requireCanonicalDirectory(path); err != nil {
return nil, fmt.Errorf("%s must identify an existing canonical directory", name)
}
if _, exists := seen[path]; !exists {
paths = append(paths, path)
seen[path] = struct{}{}
}
}
secretFiles, err := i.SecretFiles()
if err != nil {
return nil, err
}
for _, path := range secretFiles {
if _, exists := seen[path]; !exists {
paths = append(paths, path)
seen[path] = struct{}{}
}
}
return paths, nil
}
func requireCanonicalDirectory(path string) error {
if err := safeio.ValidateCanonicalPath(path); err != nil {
return err
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil || resolved != path {
return errors.New("directory path is unavailable or contains a symlink")
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return errors.New("directory path is unavailable")
}
return nil
}
func parseComposeDotenv(contents []byte) (map[string]string, error) {
dotenvParseMu.Lock()
defer dotenvParseMu.Unlock()
logger := logrus.StandardLogger()
previousOutput := logger.Out
previousHooks := logger.ReplaceHooks(make(logrus.LevelHooks))
logger.SetOutput(io.Discard)
warnings := &dotenvWarnings{}
logger.AddHook(warnings)
defer func() {
logger.SetOutput(previousOutput)
logger.ReplaceHooks(previousHooks)
}()
values, err := dotenv.ParseWithLookup(bytes.NewReader(contents), os.LookupEnv)
if err != nil || warnings.seen {
return nil, errors.New("dotenv parsing failed")
}
return values, nil
}
type dotenvWarnings struct {
seen bool
}
func (w *dotenvWarnings) Levels() []logrus.Level {
return logrus.AllLevels
}
func (w *dotenvWarnings) Fire(entry *logrus.Entry) error {
if entry.Level == logrus.WarnLevel {
w.seen = true
}
return nil
}
func ensureOnlyOneDocument(decoder *yaml.Decoder) error {
var extra any
err := decoder.Decode(&extra)
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("read installation file: %w", err)
}
return fmt.Errorf("installation file must contain one YAML document")
}
func requireDirectory(path, field string) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("%s must be an absolute path", field)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s is unavailable: %w", field, err)
}
if !info.IsDir() {
return fmt.Errorf("%s must be a directory", field)
}
return nil
}
func requireRegularFile(path, field string) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("%s must be an absolute path", field)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s is unavailable: %w", field, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s must be a regular file", field)
}
return nil
}