737 lines
29 KiB
Go
737 lines
29 KiB
Go
package setup
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
descriptorName = "thothii-installation.yaml"
|
|
environmentName = "operator.env"
|
|
maxSecretBytes = 64 << 10
|
|
)
|
|
|
|
var installationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
|
|
var secretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
|
|
|
|
// atomicWriteNewFile is a seam for failure testing. Its implementation never replaces an existing
|
|
// file and leaves no final target until all content is synced.
|
|
var atomicWriteNewFile = writeNewFileAtomically
|
|
|
|
// effectiveUID is a package-private seam so projected server setup can prove its root gate
|
|
// happens before any filesystem mutation.
|
|
var effectiveUID = currentEffectiveUID
|
|
|
|
type answers struct {
|
|
shell config.Shell
|
|
installationID, profile string
|
|
workspaceRemote, workspaceBranch string
|
|
workspaceAccess string
|
|
dwhRESTURL, llmURL string
|
|
secretsFile, piAuthFile string
|
|
gitCredentialsFile, gitCAFile string
|
|
gitSSHKeyFile, gitKnownHostsFile string
|
|
complete bool
|
|
createSecretTemplates bool
|
|
}
|
|
|
|
type generatedDescriptor struct {
|
|
SchemaVersion int `yaml:"schemaVersion"`
|
|
Profile string `yaml:"profile"`
|
|
ProjectDirectory string `yaml:"projectDirectory"`
|
|
EnvFile string `yaml:"envFile"`
|
|
Workspace struct {
|
|
Remote string `yaml:"remote"`
|
|
Branch string `yaml:"branch"`
|
|
Access string `yaml:"access"`
|
|
} `yaml:"workspaceRepository"`
|
|
Authentication struct {
|
|
ConfigDirectory string `yaml:"configDirectory"`
|
|
RuntimeProjection *struct {
|
|
Directory string `yaml:"directory"`
|
|
UID uint32 `yaml:"uid"`
|
|
GID uint32 `yaml:"gid"`
|
|
} `yaml:"runtimeProjection,omitempty"`
|
|
} `yaml:"authentication"`
|
|
ModelCatalog config.ModelCatalog `yaml:"modelCatalog"`
|
|
Shell config.Shell `yaml:"shell,omitempty"`
|
|
Overrides []string `yaml:"overrides"`
|
|
}
|
|
|
|
// EnsureFiles writes a descriptor and non-secret environment file below deploy/<installation-id>.
|
|
// Existing files are accepted only when their bytes exactly match the requested configuration.
|
|
func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResult, error) {
|
|
root, err := canonicalProjectRoot(request.ProjectRoot)
|
|
if err != nil {
|
|
return FilesResult{}, err
|
|
}
|
|
values, err := collectAnswers(request, input, output, root)
|
|
if err != nil {
|
|
return FilesResult{}, err
|
|
}
|
|
if err := validateAnswers(values); err != nil {
|
|
return FilesResult{}, err
|
|
}
|
|
if values.profile == "server" && effectiveUID() != 0 {
|
|
return FilesResult{}, errors.New("projected server setup requires root")
|
|
}
|
|
|
|
directory, err := installationDirectory(root, values.installationID)
|
|
if err != nil {
|
|
return FilesResult{}, err
|
|
}
|
|
descriptorPath := filepath.Join(directory, descriptorName)
|
|
environmentPath := filepath.Join(directory, environmentName)
|
|
if values.profile == "server" {
|
|
if err := ensureProjectedAuthDirectories(filepath.Join(directory, "auth"), filepath.Join(directory, "auth-runtime")); err != nil {
|
|
return FilesResult{}, errors.New("projected authentication directories are unavailable or unsafe")
|
|
}
|
|
} else if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, "auth")); err != nil {
|
|
return FilesResult{}, errors.New("authentication directory is unavailable or unsafe")
|
|
}
|
|
result := FilesResult{DescriptorPath: descriptorPath, EnvironmentPath: environmentPath}
|
|
|
|
descriptor, environment, err := render(root, descriptorPath, values)
|
|
if err != nil {
|
|
return FilesResult{}, err
|
|
}
|
|
if err := requireCompatibleOrAbsent(descriptorPath, descriptor); err != nil {
|
|
return FilesResult{}, err
|
|
}
|
|
if err := requireCompatibleOrAbsent(environmentPath, environment); err != nil {
|
|
return FilesResult{}, err
|
|
}
|
|
if err := validateOrCreateSecretFiles(values, output); err != nil {
|
|
return FilesResult{}, err
|
|
}
|
|
if values.complete {
|
|
if err := validateCompleteProtectedFiles(values); err != nil {
|
|
return FilesResult{}, err
|
|
}
|
|
}
|
|
|
|
created := make([]string, 0, 2)
|
|
cleanup := func() {
|
|
for index := len(created) - 1; index >= 0; index-- {
|
|
_ = os.Remove(created[index])
|
|
}
|
|
}
|
|
if err := writeIfAbsent(descriptorPath, descriptor, &created); err != nil {
|
|
cleanup()
|
|
return FilesResult{}, err
|
|
}
|
|
if err := writeIfAbsent(environmentPath, environment, &created); err != nil {
|
|
cleanup()
|
|
return FilesResult{}, err
|
|
}
|
|
result.Created = created
|
|
return result, nil
|
|
}
|
|
|
|
func canonicalProjectRoot(path string) (string, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return "", errors.New("setup requires the current ThothII project root")
|
|
}
|
|
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
|
|
return "", errors.New("setup project root must be an absolute canonical path")
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(path)
|
|
if err != nil || resolved != path {
|
|
return "", errors.New("setup project root is unavailable or contains a symlink")
|
|
}
|
|
for _, required := range []string{filepath.Join(path, "compose.yaml"), filepath.Join(path, "deploy")} {
|
|
info, statErr := os.Stat(required)
|
|
if statErr != nil || (filepath.Base(required) == "deploy" && !info.IsDir()) || (filepath.Base(required) != "deploy" && !info.Mode().IsRegular()) {
|
|
return "", errors.New("setup project root is not a ThothII checkout")
|
|
}
|
|
}
|
|
deployInfo, err := os.Lstat(filepath.Join(path, "deploy"))
|
|
if err != nil || deployInfo.Mode()&os.ModeSymlink != 0 {
|
|
return "", errors.New("setup project deployment directory is unavailable or contains a symlink")
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func collectAnswers(request Request, input io.Reader, output io.Writer, root string) (answers, error) {
|
|
value := answersFromRequest(request)
|
|
value.installationID = firstNonEmpty(request.InstallationID, os.Getenv("THT_SETUP_INSTALLATION_ID"), "local")
|
|
value.profile = firstNonEmpty(request.Profile, os.Getenv("THT_SETUP_PROFILE"), "local")
|
|
value.complete = request.Complete
|
|
if request.Complete {
|
|
// The complete path has one predictable protected directory. The user only fills the
|
|
// bundle and any repository credential that is genuinely required; catalog passwords
|
|
// are generated below and never appear in the questionnaire.
|
|
directory := filepath.Join(root, "deploy", value.installationID, "secrets")
|
|
value.workspaceBranch = firstNonEmpty(value.workspaceBranch, "main")
|
|
value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))
|
|
value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))
|
|
if request.NonInteractive {
|
|
value.workspaceAccess = firstNonEmpty(value.workspaceAccess, accessForRemote(value.workspaceRemote))
|
|
if value.workspaceAccess == "ssh" {
|
|
value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))
|
|
value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))
|
|
} else {
|
|
value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))
|
|
value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))
|
|
}
|
|
}
|
|
value.createSecretTemplates = true
|
|
}
|
|
if request.NonInteractive {
|
|
return requireNonInteractiveAnswers(value)
|
|
}
|
|
scanner := bufio.NewScanner(input)
|
|
var err error
|
|
if value.installationID, err = prompt(scanner, output, "Installation ID", value.installationID); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.profile, err = prompt(scanner, output, "Deployment profile (local or server)", value.profile); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.dwhRESTURL, err = prompt(scanner, output, "DWH API endpoint (optional)", value.dwhRESTURL); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.llmURL, err = prompt(scanner, output, "LLM API endpoint (optional)", value.llmURL); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.workspaceRemote, err = prompt(scanner, output, "Workspace repository URL", firstNonEmpty(value.workspaceRemote, "https://git.example.invalid/thothii-workspaces.git")); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.workspaceBranch, err = prompt(scanner, output, "Workspace repository branch", firstNonEmpty(value.workspaceBranch, "main")); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.workspaceAccess, err = prompt(scanner, output, "Workspace repository access (https or ssh)", firstNonEmpty(value.workspaceAccess, accessForRemote(value.workspaceRemote))); err != nil {
|
|
return answers{}, err
|
|
}
|
|
directory := filepath.Join(root, "deploy", value.installationID, "secrets")
|
|
if request.Complete {
|
|
value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))
|
|
value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))
|
|
if value.workspaceAccess == "ssh" {
|
|
value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))
|
|
value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))
|
|
} else {
|
|
value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))
|
|
value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))
|
|
}
|
|
return value, nil
|
|
}
|
|
if value.secretsFile, err = prompt(scanner, output, "Secret file location", firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.piAuthFile, err = prompt(scanner, output, "Pi credentials file location", firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.workspaceAccess == "ssh" {
|
|
if value.gitSSHKeyFile, err = prompt(scanner, output, "Workspace Git SSH key location", firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.gitKnownHostsFile, err = prompt(scanner, output, "Workspace Git known-hosts location", firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))); err != nil {
|
|
return answers{}, err
|
|
}
|
|
} else {
|
|
if value.gitCredentialsFile, err = prompt(scanner, output, "Workspace Git credentials file location", firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))); err != nil {
|
|
return answers{}, err
|
|
}
|
|
if value.gitCAFile, err = prompt(scanner, output, "Workspace Git CA file location", firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))); err != nil {
|
|
return answers{}, err
|
|
}
|
|
}
|
|
missing, missingErr := missingSecretFiles(value)
|
|
if missingErr != nil {
|
|
return answers{}, missingErr
|
|
}
|
|
if len(missing) > 0 {
|
|
if request.Complete {
|
|
value.createSecretTemplates = true
|
|
} else {
|
|
answer, promptErr := prompt(scanner, output, "Create blank secret-file templates for the missing locations? Type yes to confirm", "no")
|
|
if promptErr != nil {
|
|
return answers{}, promptErr
|
|
}
|
|
value.createSecretTemplates = strings.EqualFold(answer, "yes")
|
|
}
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func answersFromRequest(request Request) answers {
|
|
answer := request.Answers
|
|
return answers{
|
|
shell: config.Shell{
|
|
Mode: firstNonEmpty(answer.ShellMode, os.Getenv("THT_SETUP_SHELL_MODE")),
|
|
DefaultLocale: firstNonEmpty(answer.ShellDefaultLocale, os.Getenv("THT_SETUP_SHELL_DEFAULT_LOCALE")),
|
|
Adapter: firstNonEmpty(answer.ShellAdapter, os.Getenv("THT_SETUP_SHELL_ADAPTER")),
|
|
},
|
|
workspaceRemote: firstNonEmpty(answer.WorkspaceRemote, os.Getenv("THT_SETUP_WORKSPACE_REMOTE")),
|
|
workspaceBranch: firstNonEmpty(answer.WorkspaceBranch, os.Getenv("THT_SETUP_WORKSPACE_BRANCH")),
|
|
workspaceAccess: firstNonEmpty(answer.WorkspaceAccess, os.Getenv("THT_SETUP_WORKSPACE_ACCESS")),
|
|
dwhRESTURL: firstNonEmpty(answer.DWHRESTURL, os.Getenv("THT_SETUP_DWH_REST_URL")),
|
|
llmURL: firstNonEmpty(answer.LLMURL, os.Getenv("THT_SETUP_LLM_URL")),
|
|
secretsFile: firstNonEmpty(answer.SecretsFile, os.Getenv("THT_SETUP_SECRETS_FILE")),
|
|
piAuthFile: firstNonEmpty(answer.PiAuthFile, os.Getenv("THT_SETUP_PI_AUTH_FILE")),
|
|
gitCredentialsFile: firstNonEmpty(answer.GitCredentialsFile, os.Getenv("THT_SETUP_GIT_CREDENTIALS_FILE")),
|
|
gitCAFile: firstNonEmpty(answer.GitCAFile, os.Getenv("THT_SETUP_GIT_CA_FILE")),
|
|
gitSSHKeyFile: firstNonEmpty(answer.GitSSHKeyFile, os.Getenv("THT_SETUP_GIT_SSH_KEY_FILE")),
|
|
gitKnownHostsFile: firstNonEmpty(answer.GitKnownHostsFile, os.Getenv("THT_SETUP_GIT_KNOWN_HOSTS_FILE")),
|
|
createSecretTemplates: answer.CreateSecretTemplates,
|
|
}
|
|
}
|
|
|
|
func requireNonInteractiveAnswers(value answers) (answers, error) {
|
|
required := []struct{ name, value string }{
|
|
{"THT_SETUP_WORKSPACE_REMOTE", value.workspaceRemote}, {"THT_SETUP_WORKSPACE_BRANCH", value.workspaceBranch},
|
|
{"THT_SETUP_WORKSPACE_ACCESS", value.workspaceAccess}, {"THT_SETUP_SECRETS_FILE", value.secretsFile}, {"THT_SETUP_PI_AUTH_FILE", value.piAuthFile},
|
|
}
|
|
if value.workspaceAccess == "ssh" {
|
|
required = append(required, struct{ name, value string }{"THT_SETUP_GIT_SSH_KEY_FILE", value.gitSSHKeyFile}, struct{ name, value string }{"THT_SETUP_GIT_KNOWN_HOSTS_FILE", value.gitKnownHostsFile})
|
|
} else if value.workspaceAccess == "https" {
|
|
required = append(required, struct{ name, value string }{"THT_SETUP_GIT_CREDENTIALS_FILE", value.gitCredentialsFile}, struct{ name, value string }{"THT_SETUP_GIT_CA_FILE", value.gitCAFile})
|
|
}
|
|
for _, requiredValue := range required {
|
|
if strings.TrimSpace(requiredValue.value) == "" {
|
|
return answers{}, fmt.Errorf("non-interactive setup requires %s or its matching setup flag", requiredValue.name)
|
|
}
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func prompt(scanner *bufio.Scanner, output io.Writer, question, defaultValue string) (string, error) {
|
|
fmt.Fprintf(output, "%s [%s]: ", question, defaultValue)
|
|
if !scanner.Scan() {
|
|
return "", fmt.Errorf("setup input ended while waiting for %s", strings.ToLower(question))
|
|
}
|
|
value := strings.TrimSpace(scanner.Text())
|
|
if value == "" {
|
|
return defaultValue, nil
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func validateAnswers(value answers) error {
|
|
if err := value.shell.NormalizeDefaults(); err != nil {
|
|
return err
|
|
}
|
|
if !installationIDPattern.MatchString(value.installationID) {
|
|
return errors.New("installation ID must contain only letters, numbers, dashes, and underscores")
|
|
}
|
|
if value.profile != "local" && value.profile != "server" {
|
|
return errors.New("deployment profile must be local or server")
|
|
}
|
|
if value.workspaceAccess != "ssh" && value.workspaceAccess != "https" {
|
|
return errors.New("workspace repository access must be ssh or https")
|
|
}
|
|
for name, endpoint := range map[string]string{"DWH API": value.dwhRESTURL, "LLM API": value.llmURL} {
|
|
if err := validateServiceEndpoint(name, endpoint); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for name, path := range map[string]string{
|
|
"secret file location": value.secretsFile, "Pi credentials file location": value.piAuthFile,
|
|
"workspace Git credentials file location": value.gitCredentialsFile, "workspace Git CA file location": value.gitCAFile,
|
|
"workspace Git SSH key location": value.gitSSHKeyFile, "workspace Git known-hosts location": value.gitKnownHostsFile,
|
|
} {
|
|
if path == "" && ((value.workspaceAccess == "ssh" && (name == "workspace Git credentials file location" || name == "workspace Git CA file location")) || (value.workspaceAccess == "https" && (name == "workspace Git SSH key location" || name == "workspace Git known-hosts location"))) {
|
|
continue
|
|
}
|
|
if err := safeio.ValidateCanonicalPath(path); err != nil {
|
|
return fmt.Errorf("%s must be an absolute canonical path", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func installationDirectory(root, id string) (string, error) {
|
|
directory := filepath.Join(root, "deploy", id)
|
|
if err := safeio.ValidateCanonicalPath(directory); err != nil {
|
|
return "", errors.New("installation directory is unsafe")
|
|
}
|
|
if info, err := os.Lstat(directory); err == nil {
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return "", fmt.Errorf("installation directory %s is unavailable or unsafe", directory)
|
|
}
|
|
return directory, nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return "", fmt.Errorf("installation directory %s could not be inspected", directory)
|
|
}
|
|
if err := os.Mkdir(directory, 0o700); err != nil {
|
|
return "", fmt.Errorf("create installation directory %s: %w", directory, err)
|
|
}
|
|
return directory, nil
|
|
}
|
|
|
|
func render(root, descriptorPath string, value answers) ([]byte, []byte, error) {
|
|
descriptor := generatedDescriptor{SchemaVersion: 2, Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName), ModelCatalog: defaultModelCatalog()}
|
|
// Preserve the legacy descriptor bytes when no shell setting was requested.
|
|
if value.shell != (config.Shell{}) {
|
|
if err := value.shell.NormalizeDefaults(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
descriptor.Shell = value.shell
|
|
}
|
|
descriptor.Workspace.Remote, descriptor.Workspace.Branch, descriptor.Workspace.Access = value.workspaceRemote, value.workspaceBranch, value.workspaceAccess
|
|
descriptor.Authentication.ConfigDirectory = filepath.Join(filepath.Dir(descriptorPath), "auth")
|
|
if value.profile == "server" {
|
|
descriptor.Authentication.RuntimeProjection = &struct {
|
|
Directory string `yaml:"directory"`
|
|
UID uint32 `yaml:"uid"`
|
|
GID uint32 `yaml:"gid"`
|
|
}{
|
|
Directory: filepath.Join(filepath.Dir(descriptorPath), "auth-runtime"),
|
|
UID: 10001,
|
|
GID: 10001,
|
|
}
|
|
}
|
|
descriptor.Overrides = []string{filepath.Join(root, "deploy", "compose.git-"+value.workspaceAccess+".yaml")}
|
|
descriptorBytes, err := yaml.Marshal(descriptor)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
lines := []string{
|
|
"# Generated by tht setup. This file contains locations, never secret values.",
|
|
"THT_WORKSPACE_GIT_REMOTE=" + dotenvValue(value.workspaceRemote),
|
|
"THT_WORKSPACE_GIT_BRANCH=" + dotenvValue(value.workspaceBranch),
|
|
"THT_WORKSPACE_INSTALLATION_ID=" + dotenvValue(value.installationID),
|
|
"THT_AUTH_CONFIG_ROOT=" + dotenvValue(descriptor.Authentication.ConfigDirectory),
|
|
"THT_INSTALLATION_CONFIG_SOURCE=" + dotenvValue(descriptorPath),
|
|
"THT_SECRETS_FILE=" + dotenvValue(value.secretsFile),
|
|
"PI_AUTH_FILE=" + dotenvValue(value.piAuthFile),
|
|
"THOTH_HTTP_PORT=8080", "THOTH_CORE_HTTP_PORT=8787", "MAX_PI_PROCESSES=4",
|
|
}
|
|
if value.workspaceAccess == "ssh" {
|
|
lines = append(lines, "THT_WORKSPACE_GIT_SSH_KEY_FILE="+dotenvValue(value.gitSSHKeyFile), "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE="+dotenvValue(value.gitKnownHostsFile))
|
|
} else {
|
|
lines = append(lines, "THT_WORKSPACE_GIT_CREDENTIALS_FILE="+dotenvValue(value.gitCredentialsFile), "THT_WORKSPACE_GIT_CA_FILE="+dotenvValue(value.gitCAFile))
|
|
}
|
|
if value.dwhRESTURL != "" {
|
|
lines = append(lines, "THT_DWH_REST_URL="+dotenvValue(value.dwhRESTURL))
|
|
}
|
|
if value.llmURL != "" {
|
|
lines = append(lines, "THT_LLM_URL="+dotenvValue(value.llmURL))
|
|
}
|
|
if value.complete {
|
|
passwordDirectory := filepath.Dir(value.secretsFile)
|
|
lines = append(lines,
|
|
"THT_CATALOG_RUNTIME_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-runtime-password")),
|
|
"THT_CATALOG_MIGRATOR_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-migrator-password")),
|
|
)
|
|
}
|
|
if value.profile == "server" {
|
|
installationDirectory := filepath.Dir(descriptorPath)
|
|
lines = append(lines,
|
|
"THT_AUTH_RUNTIME_ROOT="+dotenvValue(descriptor.Authentication.RuntimeProjection.Directory),
|
|
"THT_DATA_ROOT="+dotenvValue(filepath.Join(installationDirectory, "data")),
|
|
"THT_PI_STATE_ROOT="+dotenvValue(filepath.Join(installationDirectory, "pi-state")),
|
|
"THT_WORKSPACE_REGISTRY_ROOT="+dotenvValue(filepath.Join(installationDirectory, "workspace-registry")),
|
|
"THT_BACKUP_ROOT="+dotenvValue(filepath.Join(installationDirectory, "backups")),
|
|
)
|
|
}
|
|
return descriptorBytes, []byte(strings.Join(lines, "\n") + "\n"), nil
|
|
}
|
|
|
|
func defaultModelCatalog() config.ModelCatalog {
|
|
return config.ModelCatalog{
|
|
Defaults: config.ModelCatalogDefaults{Interaction: "deepseek/deepseek-v4-pro"},
|
|
Embedding: config.ModelCatalogEmbedding{ID: "ollama/qwen3-embedding:0.6b", Dimensions: 1024},
|
|
Providers: map[string]config.ModelProvider{
|
|
"deepseek": {
|
|
Authentication: config.ModelAuthentication{Mode: "pi_auth"},
|
|
Session: &config.ModelSessionAdapter{Mode: "pi_builtin"},
|
|
Models: map[string]config.CatalogModel{
|
|
"deepseek-v4-pro": {Session: &config.SessionModel{}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func dotenvValue(value string) string { return strconv.Quote(value) }
|
|
|
|
func requireCompatibleOrAbsent(path string, expected []byte) error {
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("configuration file %s is unsafe; choose a different installation ID or remove the unsafe file", path)
|
|
}
|
|
actual, err := os.ReadFile(path)
|
|
if err != nil || !bytes.Equal(actual, expected) {
|
|
return fmt.Errorf("configuration file %s already exists with different content; choose a different installation ID or move that file before running setup", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeIfAbsent(path string, contents []byte, created *[]string) error {
|
|
if _, err := os.Lstat(path); err == nil {
|
|
if err := requireCompatibleOrAbsent(path, contents); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("inspect configuration file %s: %w", path, err)
|
|
}
|
|
if err := atomicWriteNewFile(path, contents, 0o600); err != nil {
|
|
return fmt.Errorf("write configuration file %s: %w", path, err)
|
|
}
|
|
*created = append(*created, path)
|
|
return nil
|
|
}
|
|
|
|
func validateServiceEndpoint(name, endpoint string) error {
|
|
if endpoint == "" {
|
|
return nil
|
|
}
|
|
parsed, err := url.Parse(endpoint)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" ||
|
|
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return fmt.Errorf("%s endpoint must be an http(s) URL without user information, password, query, or fragment", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func missingSecretFiles(value answers) ([]string, error) {
|
|
paths := configuredSecretPaths(value)
|
|
missing := make([]string, 0, len(paths))
|
|
for _, path := range paths {
|
|
exists, err := inspectExistingSecretFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
missing = append(missing, path)
|
|
}
|
|
}
|
|
sort.Strings(missing)
|
|
return missing, nil
|
|
}
|
|
|
|
func validateOrCreateSecretFiles(value answers, output io.Writer) error {
|
|
missing, err := missingSecretFiles(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(missing) > 0 && !value.createSecretTemplates {
|
|
return fmt.Errorf("secret files are missing: %s; create them yourself or explicitly confirm blank secret-file templates", strings.Join(missing, ", "))
|
|
}
|
|
for _, path := range missing {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return fmt.Errorf("create secret-template directory: %w", err)
|
|
}
|
|
if err := atomicWriteNewFile(path, secretTemplate(path), 0o600); err != nil {
|
|
return fmt.Errorf("create secret-file template %s: %w", path, err)
|
|
}
|
|
fmt.Fprintf(output, "Created blank secret-file template: %s\n", path)
|
|
}
|
|
if value.complete {
|
|
for _, path := range catalogPasswordPaths(value) {
|
|
exists, err := inspectExistingSecretFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
continue
|
|
}
|
|
contents, err := generatedCatalogPassword()
|
|
if err != nil {
|
|
return fmt.Errorf("generate catalog password: %w", err)
|
|
}
|
|
if err := atomicWriteNewFile(path, contents, 0o600); err != nil {
|
|
return fmt.Errorf("create catalog password %s: %w", path, err)
|
|
}
|
|
fmt.Fprintf(output, "Created generated catalog password file: %s\n", path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func catalogPasswordPaths(value answers) []string {
|
|
directory := filepath.Dir(value.secretsFile)
|
|
return []string{
|
|
filepath.Join(directory, "catalog-runtime-password"),
|
|
filepath.Join(directory, "catalog-migrator-password"),
|
|
}
|
|
}
|
|
|
|
func generatedCatalogPassword() ([]byte, error) {
|
|
value := make([]byte, 32)
|
|
if _, err := rand.Read(value); err != nil {
|
|
return nil, errors.New("secure random source is unavailable")
|
|
}
|
|
return []byte(hex.EncodeToString(value) + "\n"), nil
|
|
}
|
|
|
|
func validateCompleteProtectedFiles(value answers) error {
|
|
if err := validateSecretBundle(value.secretsFile); err != nil {
|
|
return err
|
|
}
|
|
// The generated catalog deliberately uses Pi's built-in provider. A syntactically empty
|
|
// auth store would let Docker start only to fail at the first provider check, so catch it
|
|
// before any image is built. Other model providers can be selected later in the descriptor.
|
|
contents, err := safeio.ReadCanonicalRegular(value.piAuthFile, maxSecretBytes)
|
|
if err != nil || strings.TrimSpace(string(contents)) == "" || strings.TrimSpace(string(contents)) == "{}" {
|
|
return fmt.Errorf("complete setup requires usable Pi credentials in %s", value.piAuthFile)
|
|
}
|
|
if value.workspaceAccess == "ssh" {
|
|
for name, path := range map[string]string{
|
|
"workspace Git SSH key": value.gitSSHKeyFile,
|
|
"workspace Git known-hosts": value.gitKnownHostsFile,
|
|
} {
|
|
contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes)
|
|
if readErr != nil || strings.TrimSpace(string(contents)) == "" {
|
|
return fmt.Errorf("complete setup requires usable %s in %s", name, path)
|
|
}
|
|
}
|
|
} else {
|
|
for name, path := range map[string]string{
|
|
"workspace Git credentials": value.gitCredentialsFile,
|
|
"workspace Git CA": value.gitCAFile,
|
|
} {
|
|
contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes)
|
|
if readErr != nil || strings.TrimSpace(string(contents)) == "" {
|
|
return fmt.Errorf("complete setup requires usable %s in %s", name, path)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSecretBundle(path string) error {
|
|
contents, err := safeio.ReadCanonicalRegular(path, maxSecretBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("complete setup cannot read the secret bundle %s", path)
|
|
}
|
|
seen := make(map[string]struct{})
|
|
for lineNumber, raw := range strings.Split(string(contents), "\n") {
|
|
line := strings.TrimSuffix(raw, "\r")
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
key, secret, found := strings.Cut(line, "=")
|
|
invalid := !found || !secretBundleKeyPattern.MatchString(key) || strings.TrimSpace(key) != key ||
|
|
secret == "" || strings.TrimSpace(secret) != secret ||
|
|
strings.Contains(strings.ToLower(secret), "replace-me") ||
|
|
strings.IndexFunc(secret, unicode.IsSpace) >= 0
|
|
if invalid {
|
|
return fmt.Errorf("complete setup found an invalid secret bundle entry at line %d", lineNumber+1)
|
|
}
|
|
if _, duplicate := seen[key]; duplicate {
|
|
return fmt.Errorf("complete setup found a duplicate secret bundle key %s", key)
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inspectExistingSecretFile(path string) (bool, error) {
|
|
before, err := os.Lstat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("secret file %s could not be inspected; choose a readable regular file", path)
|
|
}
|
|
if !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 {
|
|
return false, fmt.Errorf("secret file %s must be a readable regular file, not a directory, symlink, or special file", path)
|
|
}
|
|
if _, err := safeio.ReadCanonicalRegular(path, maxSecretBytes); err != nil {
|
|
return false, fmt.Errorf("secret file %s must be a canonical readable regular file", path)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func configuredSecretPaths(value answers) []string {
|
|
paths := []string{value.secretsFile, value.piAuthFile}
|
|
if value.workspaceAccess == "ssh" {
|
|
paths = append(paths, value.gitSSHKeyFile, value.gitKnownHostsFile)
|
|
} else {
|
|
paths = append(paths, value.gitCredentialsFile, value.gitCAFile)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func secretTemplate(path string) []byte {
|
|
if strings.HasSuffix(path, ".json") {
|
|
return []byte("{}\n")
|
|
}
|
|
return []byte("# Add the required credential value to this protected local file.\n")
|
|
}
|
|
|
|
func writeNewFileAtomically(path string, contents []byte, mode os.FileMode) error {
|
|
if err := safeio.ValidateCanonicalPath(path); err != nil {
|
|
return err
|
|
}
|
|
directory := filepath.Dir(path)
|
|
if info, err := os.Lstat(directory); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return safeio.ErrUnsafeFile
|
|
}
|
|
resolvedDirectory, err := filepath.EvalSymlinks(directory)
|
|
if err != nil || resolvedDirectory != directory {
|
|
return safeio.ErrUnsafeFile
|
|
}
|
|
temporary, err := os.CreateTemp(directory, ".tht-setup-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
defer os.Remove(temporaryPath)
|
|
if err := temporary.Chmod(mode); err != nil {
|
|
temporary.Close()
|
|
return err
|
|
}
|
|
if _, err := temporary.Write(contents); err != nil {
|
|
temporary.Close()
|
|
return err
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
temporary.Close()
|
|
return err
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Link(temporaryPath, path); err != nil {
|
|
return err
|
|
}
|
|
directoryFile, err := os.Open(directory)
|
|
if err == nil {
|
|
_ = directoryFile.Sync()
|
|
_ = directoryFile.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func accessForRemote(remote string) string {
|
|
if strings.HasPrefix(remote, "git@") || strings.HasPrefix(remote, "ssh://") {
|
|
return "ssh"
|
|
}
|
|
return "https"
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|