Publish documentation / publish (push) Successful in 1m27s
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation. Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
1053 lines
37 KiB
Go
1053 lines
37 KiB
Go
// tht is the host-side operator command for a local ThothII installation.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/authstorage"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/backup"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/lifecycle"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/modelmigration"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/modelprojection"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/output"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/pi"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/project"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/serverops"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/service"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/setup"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/version"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/workspaceops"
|
|
)
|
|
|
|
var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady
|
|
|
|
const usage = `Usage: tht [--installation <absolute-path>/thothii-installation.yaml] <command>
|
|
|
|
When --installation is omitted, tht uses THOTHII_INSTALLATION or discovers one valid
|
|
descriptor in the current project tree.
|
|
|
|
Commands:
|
|
setup [--configure-only] [--installation-id ID] [--profile local|server]
|
|
Create or validate the local non-secret installation configuration.
|
|
installation migrate --output PATH --session-default PROVIDER/MODEL
|
|
--embedding-id PROVIDER/MODEL --embedding-dimensions N
|
|
Create a review-only schema-v2 candidate from all three legacy model sources.
|
|
version [--json] Show the host CLI build identity.
|
|
auth configure --mode local|oidc ...
|
|
Configure local users or OIDC group mapping; see tht auth for exact options.
|
|
auth status [--json] Show the redacted authentication configuration status.
|
|
auth user ... Manage local users; unavailable for OIDC installations.
|
|
status Show the Compose service state.
|
|
doctor [--json] Run non-mutating host, Compose, workflow, and Pi diagnostics.
|
|
logs Show the latest 200 sanitized service log lines (bounded; no follow mode).
|
|
start [--build] Start the installation; --build builds current-checkout images first.
|
|
stop Stop the installation.
|
|
update --check-only Validate the current installation without changing containers.
|
|
backup [--output PATH] [--include-secrets --yes] [--drain]
|
|
Create one transactional installation backup.
|
|
restore ARCHIVE --yes [--drain]
|
|
Restore one validated installation backup transactionally.
|
|
sessions migrate --yes
|
|
Run only the server session migrator and verify pending=[] and drifted=[].
|
|
remove Display exact stopped app container IDs without mutation.
|
|
remove --yes ID... Remove only the stopped IDs copied from the preceding display.
|
|
pi status Show the Pi version embedded in core.
|
|
pi doctor Check Pi preconditions without changing the installation.
|
|
pi test Run the temporary Pi/core smoke checks.
|
|
pi check Alias for pi test.
|
|
pi restart --yes [--drain]
|
|
Recreate only core with the currently selected Pi image and verify readiness.
|
|
pi update [--version V]
|
|
Rebuild the latest stable Pi release, or an explicit version, and recreate only core.
|
|
pi update --version V --source build --yes [--drain]
|
|
Advanced explicit build form retained for compatibility.
|
|
pi update --version V --source pull --image IMAGE@sha256:DIGEST --yes [--drain]
|
|
Pull an immutable candidate and recreate only core.
|
|
pi rollback --yes Restore the image recorded by the latest Pi update.
|
|
pi maintenance status
|
|
Show the durable core admission-gate state.
|
|
pi maintenance recover --yes
|
|
Verify a terminal installation, remove stale lifecycle files, and clear maintenance.
|
|
pi logs Show the latest 200 sanitized core log lines (bounded; no follow mode).
|
|
workspace inspect --workspace ID [--json]
|
|
workspace evidence consolidate --workspace ID [--json]
|
|
workspace evidence refresh --workspace ID [--json]
|
|
workspace evidence decide --workspace ID --source-id SHA --revision SHA --decision keep|replace [--json]
|
|
Inspect the active registry snapshot for one workspace.
|
|
workspace preprocess run --workspace ID [--json]
|
|
Rebuild Catalog metadata, LSH, schema/Evidence vectors, then enable the core.
|
|
workspace preprocess clear --workspace ID [--json]
|
|
Clear schema/Evidence vectors and LSH while preserving workspace memory.
|
|
`
|
|
|
|
var (
|
|
newPiRegistryClient = pi.NewNPMRegistryClient
|
|
readPiRuntimePackageName = pi.ReadRuntimePackageName
|
|
)
|
|
|
|
func main() {
|
|
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
|
|
}
|
|
|
|
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|
if len(args) > 0 && args[0] == "_auth-storage" {
|
|
return authstorage.Run(ctx, args[1:], os.Stdin, stdout, stderr)
|
|
}
|
|
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
|
|
fmt.Fprint(stdout, usage)
|
|
return 0
|
|
}
|
|
installationPath, command, commandArgs, err := parseArgs(args)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "tht: %s\n\n%s", err, usage)
|
|
return 2
|
|
}
|
|
if isBootstrapCommand(command) {
|
|
if command == "help" {
|
|
fmt.Fprint(stdout, usage)
|
|
return 0
|
|
}
|
|
if command == "setup" {
|
|
return setupCommand(ctx, installationPath, commandArgs, stdout, stderr)
|
|
}
|
|
if command == "version" {
|
|
return versionCommand(commandArgs, stdout, stderr)
|
|
}
|
|
if command == "installation" {
|
|
return installationMigrationCommand(installationPath, commandArgs, stdout, stderr)
|
|
}
|
|
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
|
|
}
|
|
workingDirectory, err := os.Getwd()
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "tht: current directory is unavailable: %s\n", output.Sanitize(err.Error(), nil))
|
|
return 2
|
|
}
|
|
installationPath, err = config.Resolve(installationPath, os.Getenv, workingDirectory)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
|
return 2
|
|
}
|
|
installation, err := config.Load(installationPath)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
|
return 2
|
|
}
|
|
if command != "auth" || len(commandArgs) == 0 || commandArgs[0] != "configure" {
|
|
if err := safeio.ValidatePrivateDirectory(installation.AuthenticationDirectory()); err != nil {
|
|
fmt.Fprintln(stderr, "tht: authentication configuration directory is unavailable or unsafe")
|
|
return 2
|
|
}
|
|
}
|
|
secretFiles, err := installation.SecretFiles()
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, "tht: installation secret declarations could not be read")
|
|
return 2
|
|
}
|
|
secretValues, err := output.SecretValuesFromFiles(secretFiles)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, "tht: declared secret file could not be read")
|
|
return 2
|
|
}
|
|
|
|
runner := compose.NewRunner("")
|
|
var result compose.Result
|
|
switch command {
|
|
case "auth":
|
|
return authconfig.Run(ctx, installation, commandArgs, os.Stdin, stdout, stderr)
|
|
case "status":
|
|
if len(commandArgs) != 0 {
|
|
return commandUsageError(stderr, "status does not accept arguments")
|
|
}
|
|
result, err = runner.Run(ctx, installation.ComposeArgs("ps", "--format", "json"), nil)
|
|
case "logs":
|
|
logArgs, argumentError := logsArgs(commandArgs)
|
|
if argumentError != nil {
|
|
return commandUsageError(stderr, argumentError.Error())
|
|
}
|
|
result, err = runner.Run(ctx, installation.ComposeArgs(logArgs...), nil)
|
|
case "start":
|
|
build, argumentError := startArgs(commandArgs)
|
|
if argumentError != nil {
|
|
return commandUsageError(stderr, argumentError.Error())
|
|
}
|
|
if err := service.Start(ctx, installation, runner, build); err != nil {
|
|
return lifecycleFailure(stderr, err, secretValues)
|
|
}
|
|
fmt.Fprintln(stdout, "ThothII services are healthy.")
|
|
return 0
|
|
case "stop":
|
|
if len(commandArgs) != 0 {
|
|
return commandUsageError(stderr, "stop does not accept arguments")
|
|
}
|
|
result, err = runner.Run(ctx, installation.ComposeArgs("stop"), nil)
|
|
case "update":
|
|
if len(commandArgs) != 1 || commandArgs[0] != "--check-only" {
|
|
return commandUsageError(stderr, "update currently requires --check-only")
|
|
}
|
|
if err := requireRuntimeAuthProjectionReady(installation); err != nil {
|
|
return lifecycleFailure(stderr, errors.New("runtime authentication projection is unavailable"), secretValues)
|
|
}
|
|
result, err = runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil)
|
|
case "backup":
|
|
return backupCommand(ctx, installation, commandArgs, stdout, stderr)
|
|
case "restore":
|
|
return restoreCommand(ctx, installation, commandArgs, stdout, stderr)
|
|
case "doctor":
|
|
return doctorCommand(ctx, installation, runner, commandArgs, stdout, stderr)
|
|
case "pi":
|
|
return piCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr)
|
|
case "sessions":
|
|
if len(commandArgs) != 2 || commandArgs[0] != "migrate" || commandArgs[1] != "--yes" {
|
|
return commandUsageError(stderr, "sessions migrate requires --yes")
|
|
}
|
|
status, operationErr := serverops.MigrateSessions(ctx, installation, runner, true)
|
|
if operationErr != nil {
|
|
return serverOperationFailure(stderr, operationErr, secretValues)
|
|
}
|
|
if encodeErr := json.NewEncoder(stdout).Encode(status); encodeErr != nil {
|
|
fmt.Fprintln(stderr, "tht: migration status could not be written")
|
|
return 1
|
|
}
|
|
return 0
|
|
case "remove":
|
|
var confirmedIDs []string
|
|
if len(commandArgs) > 0 {
|
|
if commandArgs[0] != "--yes" || len(commandArgs) < 2 {
|
|
return commandUsageError(stderr, "remove requires either no arguments or --yes followed by every displayed container ID")
|
|
}
|
|
confirmedIDs = commandArgs[1:]
|
|
}
|
|
removal, operationErr := serverops.Remove(ctx, installation, runner, confirmedIDs)
|
|
writeRemovalTargets(stdout, installation.ProjectName(), removal.Targets)
|
|
if errors.Is(operationErr, serverops.ErrConfirmationRequired) {
|
|
fmt.Fprint(stderr, "tht: inspect the exact targets above, then re-run with remove --yes")
|
|
for _, target := range removal.Targets {
|
|
fmt.Fprintf(stderr, " %s", target.ID)
|
|
}
|
|
fmt.Fprintln(stderr)
|
|
return 2
|
|
}
|
|
if operationErr != nil {
|
|
return serverOperationFailure(stderr, operationErr, secretValues)
|
|
}
|
|
fmt.Fprintf(stdout, "Removed %d stopped app containers; verified %d preserved paths.\n", len(removal.Targets), removal.Preserved)
|
|
return 0
|
|
case "workspace":
|
|
return workspaceCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr)
|
|
default:
|
|
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
|
|
}
|
|
return writeResult(result, err, secretValues, stdout, stderr)
|
|
}
|
|
|
|
func isBootstrapCommand(command string) bool {
|
|
return command == "help" || command == "setup" || command == "version" || command == "installation"
|
|
}
|
|
|
|
func installationMigrationCommand(installationPath string, args []string, stdout, stderr io.Writer) int {
|
|
if installationPath == "" {
|
|
return commandUsageError(stderr, "installation migrate requires --installation with the legacy descriptor")
|
|
}
|
|
if len(args) == 0 || args[0] != "migrate" {
|
|
return commandUsageError(stderr, "installation requires migrate")
|
|
}
|
|
values := make(map[string]string)
|
|
for index := 1; index < len(args); index += 2 {
|
|
if index+1 >= len(args) || !strings.HasPrefix(args[index], "--") {
|
|
return commandUsageError(stderr, "installation migrate requires flag/value pairs")
|
|
}
|
|
if _, duplicate := values[args[index]]; duplicate {
|
|
return commandUsageError(stderr, args[index]+" may be supplied once")
|
|
}
|
|
values[args[index]] = args[index+1]
|
|
}
|
|
for _, required := range []string{"--output", "--session-default", "--embedding-id", "--embedding-dimensions"} {
|
|
if values[required] == "" {
|
|
return commandUsageError(stderr, "installation migrate requires "+required)
|
|
}
|
|
}
|
|
if len(values) != 4 {
|
|
return commandUsageError(stderr, "installation migrate received an unknown option")
|
|
}
|
|
dimensions, err := modelmigration.ParseDimensions(values["--embedding-dimensions"])
|
|
if err != nil {
|
|
return commandUsageError(stderr, err.Error())
|
|
}
|
|
request := modelmigration.Request{
|
|
InstallationPath: installationPath,
|
|
OutputPath: values["--output"],
|
|
SessionDefault: values["--session-default"],
|
|
EmbeddingID: values["--embedding-id"],
|
|
EmbeddingDimensions: dimensions,
|
|
}
|
|
if err := modelmigration.Run(request); err != nil {
|
|
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
|
return 2
|
|
}
|
|
fmt.Fprintf(stdout, "Installation schema-v2 candidate written to %s. Legacy inputs were not changed.\n", request.OutputPath)
|
|
return 0
|
|
}
|
|
|
|
type setupExecutor func(context.Context, compose.Runner, setup.Request, io.Reader, io.Writer) (setup.Result, error)
|
|
|
|
func setupCommand(ctx context.Context, installationPath string, args []string, stdout, stderr io.Writer) int {
|
|
return setupCommandWith(ctx, installationPath, args, os.Stdin, compose.NewRunner(""), setup.Run, stdout, stderr)
|
|
}
|
|
|
|
func setupCommandWith(
|
|
ctx context.Context,
|
|
installationPath string,
|
|
args []string,
|
|
input io.Reader,
|
|
runner compose.Runner,
|
|
execute setupExecutor,
|
|
stdout, stderr io.Writer,
|
|
) int {
|
|
request, err := parseSetupArgs(args)
|
|
if err != nil {
|
|
return commandUsageError(stderr, err.Error())
|
|
}
|
|
workingDirectory, err := os.Getwd()
|
|
if err != nil {
|
|
return commandUsageError(stderr, "current directory is unavailable")
|
|
}
|
|
root, err := project.Discover(workingDirectory)
|
|
if err != nil {
|
|
return commandUsageError(stderr, err.Error())
|
|
}
|
|
request.ProjectRoot = root.Path
|
|
if installationPath != "" {
|
|
id, pathErr := installationIDFromPath(root.Path, installationPath)
|
|
if pathErr != nil {
|
|
return commandUsageError(stderr, pathErr.Error())
|
|
}
|
|
if request.InstallationID != "" && request.InstallationID != id {
|
|
return commandUsageError(stderr, "--installation and --installation-id must identify the same installation")
|
|
}
|
|
request.InstallationID = id
|
|
}
|
|
_, err = execute(ctx, runner, request, input, stdout)
|
|
if err != nil {
|
|
return commandUsageError(stderr, err.Error())
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func installationIDFromPath(root, installationPath string) (string, error) {
|
|
if filepath.Base(installationPath) != "thothii-installation.yaml" {
|
|
return "", errors.New("--installation must name thothii-installation.yaml below deploy/<installation-id>")
|
|
}
|
|
relative, err := filepath.Rel(filepath.Join(root, "deploy"), installationPath)
|
|
if err != nil {
|
|
return "", errors.New("--installation must be below this project's deploy directory")
|
|
}
|
|
parts := strings.Split(filepath.Clean(relative), string(filepath.Separator))
|
|
if len(parts) != 2 || parts[0] == "." || parts[0] == ".." || parts[1] != "thothii-installation.yaml" {
|
|
return "", errors.New("--installation must be below this project's deploy/<installation-id> directory")
|
|
}
|
|
return parts[0], nil
|
|
}
|
|
|
|
func parseSetupArgs(args []string) (setup.Request, error) {
|
|
request := setup.Request{}
|
|
for len(args) > 0 {
|
|
flag := args[0]
|
|
args = args[1:]
|
|
switch flag {
|
|
case "--configure-only":
|
|
if request.ConfigureOnly {
|
|
return setup.Request{}, errors.New("--configure-only may be supplied once")
|
|
}
|
|
request.ConfigureOnly = true
|
|
case "--non-interactive":
|
|
if request.NonInteractive {
|
|
return setup.Request{}, errors.New("--non-interactive may be supplied once")
|
|
}
|
|
request.NonInteractive = true
|
|
case "--create-secret-templates":
|
|
if request.Answers.CreateSecretTemplates {
|
|
return setup.Request{}, errors.New("--create-secret-templates may be supplied once")
|
|
}
|
|
request.Answers.CreateSecretTemplates = true
|
|
default:
|
|
if len(args) == 0 {
|
|
return setup.Request{}, fmt.Errorf("%s requires a value", flag)
|
|
}
|
|
value := args[0]
|
|
args = args[1:]
|
|
var target *string
|
|
switch flag {
|
|
case "--installation-id":
|
|
target = &request.InstallationID
|
|
case "--profile":
|
|
target = &request.Profile
|
|
case "--workspace-remote":
|
|
target = &request.Answers.WorkspaceRemote
|
|
case "--workspace-branch":
|
|
target = &request.Answers.WorkspaceBranch
|
|
case "--workspace-access":
|
|
target = &request.Answers.WorkspaceAccess
|
|
case "--dwh-rest-url":
|
|
target = &request.Answers.DWHRESTURL
|
|
case "--llm-url":
|
|
target = &request.Answers.LLMURL
|
|
case "--secrets-file":
|
|
target = &request.Answers.SecretsFile
|
|
case "--pi-auth-file":
|
|
target = &request.Answers.PiAuthFile
|
|
case "--git-credentials-file":
|
|
target = &request.Answers.GitCredentialsFile
|
|
case "--git-ca-file":
|
|
target = &request.Answers.GitCAFile
|
|
case "--git-ssh-key-file":
|
|
target = &request.Answers.GitSSHKeyFile
|
|
case "--git-known-hosts-file":
|
|
target = &request.Answers.GitKnownHostsFile
|
|
case "--auth-mode":
|
|
target = &request.Answers.AuthMode
|
|
case "--auth-public-url":
|
|
target = &request.Answers.AuthPublicURL
|
|
case "--auth-admin-user":
|
|
target = &request.Answers.AuthAdminUser
|
|
case "--auth-admin-display-name":
|
|
target = &request.Answers.AuthAdminDisplayName
|
|
case "--auth-password-file":
|
|
target = &request.Answers.AuthPasswordFile
|
|
case "--auth-issuer":
|
|
target = &request.Answers.AuthIssuer
|
|
case "--auth-client-id":
|
|
target = &request.Answers.AuthClientID
|
|
case "--auth-authentik-base-url":
|
|
target = &request.Answers.AuthAuthentikBaseURL
|
|
case "--auth-user-group":
|
|
target = &request.Answers.AuthUserGroup
|
|
case "--auth-admin-group":
|
|
target = &request.Answers.AuthAdminGroup
|
|
default:
|
|
return setup.Request{}, fmt.Errorf("unknown setup option %q", flag)
|
|
}
|
|
if *target != "" {
|
|
return setup.Request{}, fmt.Errorf("%s may be supplied once", flag)
|
|
}
|
|
*target = value
|
|
}
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func writeRemovalTargets(outputWriter io.Writer, project string, targets []serverops.Container) {
|
|
fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project)
|
|
if len(targets) == 0 {
|
|
fmt.Fprintln(outputWriter, " (none)")
|
|
return
|
|
}
|
|
for _, target := range targets {
|
|
fmt.Fprintf(outputWriter, " service=%s name=%s id=%s state=%s\n", target.Service, target.Name, target.ID, target.State)
|
|
}
|
|
}
|
|
|
|
func workspaceCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
|
|
request, err := workspaceops.Parse(args)
|
|
if err != nil {
|
|
return commandUsageError(stderr, err.Error())
|
|
}
|
|
result, err := workspaceops.Execute(ctx, installation, runner, request)
|
|
if err != nil {
|
|
return workspaceFailure(stderr, err, secretValues)
|
|
}
|
|
if request.JSONMode() {
|
|
encoder := json.NewEncoder(stdout)
|
|
encoder.SetEscapeHTML(false)
|
|
if encodeErr := encoder.Encode(result); encodeErr != nil {
|
|
fmt.Fprintln(stderr, "tht: workspace result could not be written")
|
|
return 1
|
|
}
|
|
} else {
|
|
fmt.Fprint(stdout, workspaceops.Human(result))
|
|
}
|
|
switch result.Status {
|
|
case "blocked":
|
|
return 3
|
|
case "failed":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func workspaceFailure(stderr io.Writer, err error, secretValues []string) int {
|
|
message := output.Sanitize(err.Error(), secretValues)
|
|
var operationErr *workspaceops.OperationError
|
|
if errors.As(err, &operationErr) && operationErr.Detail() != "" {
|
|
detail := output.SanitizeDetail(operationErr.Detail(), secretValues)
|
|
fmt.Fprintf(stderr, "tht: %s: %s\n", message, detail)
|
|
} else {
|
|
fmt.Fprintf(stderr, "tht: %s\n", message)
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func serverOperationFailure(stderr io.Writer, err error, secretValues []string) int {
|
|
message := output.Sanitize(err.Error(), secretValues)
|
|
var operationErr *serverops.OperationError
|
|
if errors.As(err, &operationErr) && operationErr.Detail() != "" {
|
|
detail := output.SanitizeDetail(operationErr.Detail(), secretValues)
|
|
fmt.Fprintf(stderr, "tht: %s: %s\n", message, detail)
|
|
} else {
|
|
fmt.Fprintf(stderr, "tht: %s\n", message)
|
|
}
|
|
if errors.Is(err, serverops.ErrConfirmationRequired) || errors.Is(err, serverops.ErrUnsafeState) {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func piCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
|
|
if len(args) == 0 {
|
|
return commandUsageError(stderr, "pi requires a subcommand")
|
|
}
|
|
if piMutationRequiresLifecycleLock(args) {
|
|
lock, err := lifecycle.Acquire(installation)
|
|
if err != nil {
|
|
return lifecycleFailure(stderr, err, secretValues)
|
|
}
|
|
defer func() { _ = lock.Release() }()
|
|
}
|
|
if args[0] == "restart" || args[0] == "update" || args[0] == "rollback" {
|
|
drift, err := modelprojection.Check(installation)
|
|
if err != nil {
|
|
return commandUsageError(stderr, "installation model catalog could not be verified: "+err.Error())
|
|
}
|
|
if len(drift) > 0 {
|
|
return commandUsageError(stderr,
|
|
"installation model catalog changed; run tht start to apply the complete runtime projection")
|
|
}
|
|
}
|
|
controlled := compose.InstallationRunner{Installation: installation, Runner: runner}
|
|
switch args[0] {
|
|
case "status":
|
|
if len(args) != 1 {
|
|
return commandUsageError(stderr, "pi status does not accept arguments")
|
|
}
|
|
version, err := pi.Status(ctx, controlled)
|
|
if err != nil {
|
|
return piFailure(stderr, err, secretValues)
|
|
}
|
|
fmt.Fprintf(stdout, "Pi version: %s\n", output.Sanitize(version, secretValues))
|
|
return 0
|
|
case "doctor":
|
|
if len(args) != 1 {
|
|
return commandUsageError(stderr, "pi doctor does not accept arguments")
|
|
}
|
|
if err := pi.Doctor(ctx, controlled); err != nil {
|
|
return piFailure(stderr, err, secretValues)
|
|
}
|
|
fmt.Fprintln(stdout, "Pi preflight checks passed.")
|
|
return 0
|
|
case "test", "check":
|
|
if len(args) != 1 {
|
|
return commandUsageError(stderr, "pi test does not accept arguments")
|
|
}
|
|
if err := pi.Test(ctx, controlled); err != nil {
|
|
return piFailure(stderr, err, secretValues)
|
|
}
|
|
fmt.Fprintln(stdout, "Pi/core smoke checks passed.")
|
|
return 0
|
|
case "logs":
|
|
if len(args) != 1 {
|
|
return commandUsageError(stderr, "pi logs does not support --follow; use bounded snapshots")
|
|
}
|
|
logArgs := []string{"logs", "--tail", "200", "core"}
|
|
result, err := controlled.Run(ctx, append([]string{"compose"}, logArgs...), nil)
|
|
return writeResult(result, err, secretValues, stdout, stderr)
|
|
case "restart":
|
|
request, err := parsePiRestartArgs(
|
|
args[1:],
|
|
installation.RestartStatePath(),
|
|
installation.UpdateStatePath(),
|
|
)
|
|
if err != nil {
|
|
return commandUsageError(stderr, err.Error())
|
|
}
|
|
result, err := pi.Restart(ctx, controlled, request)
|
|
if err != nil {
|
|
return piFailure(stderr, err, secretValues)
|
|
}
|
|
fmt.Fprintf(stdout, "Pi core restarted with the existing image; version %s readiness and smoke checks passed.\n", output.Sanitize(result.Version, secretValues))
|
|
return 0
|
|
case "update":
|
|
request, err := parsePiUpdateArgs(
|
|
args[1:],
|
|
installation.UpdateStatePath(),
|
|
installation.RestartStatePath(),
|
|
)
|
|
if err != nil {
|
|
return commandUsageError(stderr, err.Error())
|
|
}
|
|
var registry pi.RegistryClient
|
|
packageName := ""
|
|
if request.Version == "" {
|
|
packageName, err = readPiRuntimePackageName(installation.ProjectDirectory)
|
|
if err != nil {
|
|
return commandUsageError(stderr, "pi update latest-version lookup is unavailable: "+err.Error())
|
|
}
|
|
registry = newPiRegistryClient()
|
|
}
|
|
result, err := pi.UpdateWithResolvedVersion(ctx, controlled, request, packageName, registry)
|
|
if err != nil {
|
|
return piFailure(stderr, err, secretValues)
|
|
}
|
|
if result.Phase == pi.PhaseNoop {
|
|
fmt.Fprintf(stdout, "Pi already runs requested version %s; no container was recreated.\n", result.Version)
|
|
return 0
|
|
}
|
|
fmt.Fprintf(stdout, "Pi update verified. Recovery metadata: %s\n", result.StatePath)
|
|
return 0
|
|
case "rollback":
|
|
if len(args) != 2 || args[1] != "--yes" {
|
|
return commandUsageError(stderr, "pi rollback requires --yes")
|
|
}
|
|
result, err := pi.Rollback(
|
|
ctx,
|
|
controlled,
|
|
installation.UpdateStatePath(),
|
|
installation.RestartStatePath(),
|
|
true,
|
|
)
|
|
if err != nil {
|
|
return piFailure(stderr, err, secretValues)
|
|
}
|
|
fmt.Fprintf(stdout, "Pi rollback restored the recorded core image. Recovery metadata: %s\n", result.StatePath)
|
|
return 0
|
|
case "maintenance":
|
|
if len(args) == 2 && args[1] == "status" {
|
|
status, err := pi.MaintenanceStatus(ctx, controlled)
|
|
if err != nil {
|
|
return piFailure(stderr, err, secretValues)
|
|
}
|
|
fmt.Fprintf(stdout, "Pi maintenance active: %t (admissions: %d)\n", status.Active, status.Admissions)
|
|
return 0
|
|
}
|
|
if len(args) == 3 && args[1] == "recover" && args[2] == "--yes" {
|
|
if err := pi.RecoverLifecycleMaintenance(
|
|
ctx,
|
|
controlled,
|
|
installation.UpdateStatePath(),
|
|
installation.RestartStatePath(),
|
|
true,
|
|
); err != nil {
|
|
return piFailure(stderr, err, secretValues)
|
|
}
|
|
fmt.Fprintln(stdout, "Pi maintenance recovery verified; stale lifecycle files were removed and admissions are open.")
|
|
return 0
|
|
}
|
|
return commandUsageError(stderr, "pi maintenance requires status or recover --yes")
|
|
default:
|
|
return commandUsageError(stderr, fmt.Sprintf("unknown pi command %q", args[0]))
|
|
}
|
|
}
|
|
|
|
func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Request, error) {
|
|
request := pi.Request{StatePath: statePath, RestartStatePath: restartStatePath}
|
|
sourceSpecified := false
|
|
for len(args) > 0 {
|
|
switch args[0] {
|
|
case "--version":
|
|
if len(args) < 2 || request.Version != "" {
|
|
return pi.Request{}, errors.New("pi update requires one --version <pinned-version>")
|
|
}
|
|
request.Version, args = args[1], args[2:]
|
|
case "--source":
|
|
if len(args) < 2 {
|
|
return pi.Request{}, errors.New("--source requires build or pull")
|
|
}
|
|
request.Source, args = pi.Source(args[1]), args[2:]
|
|
sourceSpecified = true
|
|
case "--image":
|
|
if len(args) < 2 || request.Image != "" {
|
|
return pi.Request{}, errors.New("--image requires one digest-pinned image reference")
|
|
}
|
|
request.Image, args = args[1], args[2:]
|
|
case "--yes":
|
|
if request.Confirm {
|
|
return pi.Request{}, errors.New("--yes may be supplied once")
|
|
}
|
|
request.Confirm, args = true, args[1:]
|
|
case "--drain":
|
|
if request.Drain {
|
|
return pi.Request{}, errors.New("--drain may be supplied once")
|
|
}
|
|
request.Drain, args = true, args[1:]
|
|
default:
|
|
return pi.Request{}, fmt.Errorf("unknown pi update option %q", args[0])
|
|
}
|
|
}
|
|
if !sourceSpecified {
|
|
if request.Image != "" {
|
|
return pi.Request{}, errors.New("--image is valid only with --source pull")
|
|
}
|
|
request.Source = pi.BuildSource
|
|
request.Confirm = true
|
|
request.Drain = true
|
|
return request, nil
|
|
}
|
|
if request.Version == "" {
|
|
return pi.Request{}, errors.New("pi update with --source requires --version <pinned-version>")
|
|
}
|
|
if request.Source != pi.BuildSource && request.Source != pi.PullSource {
|
|
return pi.Request{}, errors.New("--source requires build or pull")
|
|
}
|
|
if request.Source == pi.PullSource && request.Image == "" {
|
|
return pi.Request{}, errors.New("--source pull requires --image <digest-reference>")
|
|
}
|
|
if request.Source == pi.BuildSource && request.Image != "" {
|
|
return pi.Request{}, errors.New("--image is valid only with --source pull")
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func parsePiRestartArgs(args []string, restartStatePath, updateStatePath string) (pi.RestartRequest, error) {
|
|
request := pi.RestartRequest{StatePath: restartStatePath, UpdateStatePath: updateStatePath}
|
|
for len(args) > 0 {
|
|
switch args[0] {
|
|
case "--yes":
|
|
if request.Confirm {
|
|
return pi.RestartRequest{}, errors.New("--yes may be supplied once")
|
|
}
|
|
request.Confirm, args = true, args[1:]
|
|
case "--drain":
|
|
if request.Drain {
|
|
return pi.RestartRequest{}, errors.New("--drain may be supplied once")
|
|
}
|
|
request.Drain, args = true, args[1:]
|
|
default:
|
|
return pi.RestartRequest{}, errors.New("unknown pi restart option")
|
|
}
|
|
}
|
|
if !request.Confirm {
|
|
return pi.RestartRequest{}, errors.New("pi restart requires --yes")
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func piFailure(stderr io.Writer, err error, secretValues []string) int {
|
|
code := 1
|
|
if errors.Is(err, pi.ErrConfirmationRequired) || errors.Is(err, pi.ErrInvalidRequest) || errors.Is(err, pi.ErrActiveSessions) || errors.Is(err, pi.ErrInterruptedUpdate) || errors.Is(err, pi.ErrInterruptedRestart) {
|
|
code = 2
|
|
}
|
|
var childExit interface{ ExitCode() int }
|
|
if errors.As(err, &childExit) && childExit.ExitCode() != 0 {
|
|
code = childExit.ExitCode()
|
|
}
|
|
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), secretValues))
|
|
return code
|
|
}
|
|
|
|
func parseArgs(args []string) (string, string, []string, error) {
|
|
var installation string
|
|
commandArgs := make([]string, 0, len(args))
|
|
for index := 0; index < len(args); index++ {
|
|
if args[index] != "--installation" {
|
|
commandArgs = append(commandArgs, args[index])
|
|
continue
|
|
}
|
|
if index+1 >= len(args) {
|
|
return "", "", nil, errors.New("--installation requires an absolute path")
|
|
}
|
|
if installation != "" {
|
|
return "", "", nil, errors.New("--installation may be supplied once")
|
|
}
|
|
installation = args[index+1]
|
|
if !filepath.IsAbs(installation) {
|
|
return "", "", nil, errors.New("--installation must be an absolute path")
|
|
}
|
|
index++
|
|
}
|
|
if len(commandArgs) == 0 {
|
|
return "", "", nil, errors.New("a command is required")
|
|
}
|
|
return installation, commandArgs[0], commandArgs[1:], nil
|
|
}
|
|
|
|
func logsArgs(args []string) ([]string, error) {
|
|
if len(args) == 0 {
|
|
return []string{"logs", "--tail", "200"}, nil
|
|
}
|
|
return nil, errors.New("logs does not accept arguments; use bounded snapshots")
|
|
}
|
|
|
|
type backupExecutor func(context.Context, config.Installation, backup.CreateRequest) (backup.Result, error)
|
|
|
|
func backupCommand(ctx context.Context, installation config.Installation, args []string, stdout, stderr io.Writer) int {
|
|
return backupCommandWith(ctx, installation, args, backup.Create, stdout, stderr)
|
|
}
|
|
|
|
func backupCommandWith(
|
|
ctx context.Context,
|
|
installation config.Installation,
|
|
args []string,
|
|
execute backupExecutor,
|
|
stdout, stderr io.Writer,
|
|
) int {
|
|
request, err := parseBackupArgs(args)
|
|
if err != nil {
|
|
return commandUsageError(stderr, err.Error())
|
|
}
|
|
if execute == nil {
|
|
return commandUsageError(stderr, "backup executor is unavailable")
|
|
}
|
|
result, err := execute(ctx, installation, request)
|
|
if err != nil {
|
|
message := output.Sanitize(err.Error(), nil)
|
|
fmt.Fprintf(stderr, "tht: %s\n", message)
|
|
if errors.Is(err, backup.ErrActiveSessions) || errors.Is(err, backup.ErrConfirmationRequired) || errors.Is(err, lifecycle.ErrLocked) {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stdout, "Backup created: %s\n", result.Path)
|
|
if result.Warning != "" {
|
|
fmt.Fprintln(stderr, output.Sanitize(result.Warning, nil))
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseBackupArgs(args []string) (backup.CreateRequest, error) {
|
|
request := backup.CreateRequest{}
|
|
for len(args) > 0 {
|
|
option := args[0]
|
|
args = args[1:]
|
|
switch option {
|
|
case "--output":
|
|
if request.Output != "" {
|
|
return backup.CreateRequest{}, errors.New("--output may be supplied once")
|
|
}
|
|
if len(args) == 0 || args[0] == "" {
|
|
return backup.CreateRequest{}, errors.New("--output requires a path")
|
|
}
|
|
request.Output, args = args[0], args[1:]
|
|
case "--include-secrets":
|
|
if request.IncludeSecrets {
|
|
return backup.CreateRequest{}, errors.New("--include-secrets may be supplied once")
|
|
}
|
|
request.IncludeSecrets = true
|
|
case "--yes":
|
|
if request.Confirm {
|
|
return backup.CreateRequest{}, errors.New("--yes may be supplied once")
|
|
}
|
|
request.Confirm = true
|
|
case "--drain":
|
|
if request.Drain {
|
|
return backup.CreateRequest{}, errors.New("--drain may be supplied once")
|
|
}
|
|
request.Drain = true
|
|
default:
|
|
return backup.CreateRequest{}, fmt.Errorf("unknown backup option %q", option)
|
|
}
|
|
}
|
|
if request.IncludeSecrets && !request.Confirm {
|
|
return backup.CreateRequest{}, errors.New("--include-secrets requires --yes")
|
|
}
|
|
if request.Confirm && !request.IncludeSecrets {
|
|
return backup.CreateRequest{}, errors.New("--yes is only valid with --include-secrets")
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
type restoreExecutor func(context.Context, config.Installation, backup.RestoreRequest) (backup.RestoreResult, error)
|
|
|
|
func restoreCommand(ctx context.Context, installation config.Installation, args []string, stdout, stderr io.Writer) int {
|
|
return restoreCommandWith(ctx, installation, args, backup.Restore, stdout, stderr)
|
|
}
|
|
|
|
func restoreCommandWith(
|
|
ctx context.Context,
|
|
installation config.Installation,
|
|
args []string,
|
|
execute restoreExecutor,
|
|
stdout, stderr io.Writer,
|
|
) int {
|
|
request, err := parseRestoreArgs(args)
|
|
if err != nil {
|
|
return commandUsageError(stderr, err.Error())
|
|
}
|
|
if execute == nil {
|
|
return commandUsageError(stderr, "restore executor is unavailable")
|
|
}
|
|
result, err := execute(ctx, installation, request)
|
|
if result.Checkpoint != "" {
|
|
checkpoint := output.Sanitize(result.Checkpoint, nil)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "Recovery checkpoint: %s\n", checkpoint)
|
|
} else {
|
|
fmt.Fprintf(stdout, "Recovery checkpoint: %s\n", checkpoint)
|
|
}
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
|
if errors.Is(err, backup.ErrRestoreConfirmationRequired) || errors.Is(err, backup.ErrActiveSessions) {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
fmt.Fprintln(stdout, "Restore completed and verified.")
|
|
return 0
|
|
}
|
|
|
|
func parseRestoreArgs(args []string) (backup.RestoreRequest, error) {
|
|
request := backup.RestoreRequest{}
|
|
for len(args) > 0 {
|
|
argument := args[0]
|
|
args = args[1:]
|
|
switch argument {
|
|
case "--yes":
|
|
if request.Confirm {
|
|
return backup.RestoreRequest{}, errors.New("--yes may be supplied once")
|
|
}
|
|
request.Confirm = true
|
|
case "--drain":
|
|
if request.Drain {
|
|
return backup.RestoreRequest{}, errors.New("--drain may be supplied once")
|
|
}
|
|
request.Drain = true
|
|
default:
|
|
if strings.HasPrefix(argument, "-") {
|
|
return backup.RestoreRequest{}, fmt.Errorf("unknown restore option %q", argument)
|
|
}
|
|
if request.Archive != "" {
|
|
return backup.RestoreRequest{}, errors.New("restore accepts exactly one archive")
|
|
}
|
|
request.Archive = argument
|
|
}
|
|
}
|
|
if request.Archive == "" {
|
|
return backup.RestoreRequest{}, errors.New("restore archive is required")
|
|
}
|
|
if !request.Confirm {
|
|
return backup.RestoreRequest{}, errors.New("restore requires --yes")
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func piMutationRequiresLifecycleLock(args []string) bool {
|
|
if len(args) == 0 {
|
|
return false
|
|
}
|
|
switch args[0] {
|
|
case "restart", "update", "rollback":
|
|
return true
|
|
case "maintenance":
|
|
return len(args) > 1 && args[1] == "recover"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func commandUsageError(stderr io.Writer, message string) int {
|
|
fmt.Fprintf(stderr, "tht: %s\n", message)
|
|
return 2
|
|
}
|
|
|
|
func versionCommand(args []string, stdout, stderr io.Writer) int {
|
|
jsonMode := false
|
|
if len(args) == 1 && args[0] == "--json" {
|
|
jsonMode = true
|
|
} else if len(args) != 0 {
|
|
return commandUsageError(stderr, "version accepts only --json")
|
|
}
|
|
info := version.Current()
|
|
if jsonMode {
|
|
encoder := json.NewEncoder(stdout)
|
|
encoder.SetEscapeHTML(false)
|
|
if err := encoder.Encode(info); err != nil {
|
|
fmt.Fprintln(stderr, "tht: version output could not be written")
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
fmt.Fprintf(stdout, "tht %s\ncommit: %s\nbuilt: %s\nplatform: %s/%s\n", info.Version, info.Commit, info.BuildTime, info.OS, info.Arch)
|
|
return 0
|
|
}
|
|
|
|
func startArgs(args []string) (bool, error) {
|
|
if len(args) == 0 {
|
|
return false, nil
|
|
}
|
|
if len(args) == 1 && args[0] == "--build" {
|
|
return true, nil
|
|
}
|
|
return false, errors.New("start accepts only --build")
|
|
}
|
|
|
|
func doctorCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, stdout, stderr io.Writer) int {
|
|
jsonMode := false
|
|
if len(args) == 1 && args[0] == "--json" {
|
|
jsonMode = true
|
|
} else if len(args) != 0 {
|
|
return commandUsageError(stderr, "doctor accepts only --json")
|
|
}
|
|
report, err := doctor.Run(ctx, installation, runner)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
|
return 1
|
|
}
|
|
if jsonMode {
|
|
encoder := json.NewEncoder(stdout)
|
|
encoder.SetEscapeHTML(false)
|
|
if err := encoder.Encode(report); err != nil {
|
|
fmt.Fprintln(stderr, "tht: doctor report could not be written")
|
|
return 1
|
|
}
|
|
} else {
|
|
for _, check := range report.Checks {
|
|
fmt.Fprintf(stdout, "%s: %s", check.Name, check.Status)
|
|
if check.Detail != "" {
|
|
fmt.Fprintf(stdout, " - %s", check.Detail)
|
|
}
|
|
fmt.Fprintln(stdout)
|
|
}
|
|
}
|
|
if report.OK {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func lifecycleFailure(stderr io.Writer, err error, secretValues []string) int {
|
|
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), secretValues))
|
|
return 1
|
|
}
|
|
|
|
func writeResult(result compose.Result, err error, secretValues []string, stdout, stderr io.Writer) int {
|
|
if result.Stdout != "" {
|
|
fmt.Fprint(stdout, output.Sanitize(result.Stdout, secretValues))
|
|
}
|
|
if result.Stderr != "" {
|
|
fmt.Fprint(stderr, output.Sanitize(result.Stderr, secretValues))
|
|
}
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
fmt.Fprintln(stderr, "tht: Docker is not installed or is not on PATH")
|
|
}
|
|
if result.ExitCode != 0 {
|
|
return result.ExitCode
|
|
}
|
|
return 1
|
|
}
|