1174 lines
42 KiB
Go
1174 lines
42 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 [--complete|--configure-only] [--installation-id ID] [--profile local|server]
|
|
[--shell-mode full|embedded] [--shell-default-locale BCP47-TAG] [--shell-adapter omics-portal]
|
|
Create, validate, and optionally complete the local installation.
|
|
installation prepare --directory NEW_PATH [--json]
|
|
Create commented installation, environment and database bootstrap templates.
|
|
installation credentials --directory PATH [--json]
|
|
Explicitly generate protected technical credentials before setup.
|
|
installation validate --workspaces PATH [--bootstrap PATH] [--json]
|
|
Check prepared application documents; requires --installation.
|
|
installation preflight --directory PATH [--release MANIFEST] [--json]
|
|
Check host prerequisites and optionally the published release, without a stack.
|
|
installation plan --workspaces PATH --release MANIFEST --output NEW_PLAN [--bootstrap PATH] [--json]
|
|
Validate documents and live dependencies, then seal a private plan; requires --installation.
|
|
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.
|
|
installation generate
|
|
Publish runtime projections from the descriptor without invoking Docker.
|
|
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 prepare --directory NEW_PATH --id ID --name NAME [--language en|it] [--json]
|
|
Prepare workspace documents locally, before installation.
|
|
workspace validate --directory PATH [--json]
|
|
Validate local workspace documents without starting services.
|
|
workspace inspect --workspace ID [--json]
|
|
workspace pull [--json]
|
|
Pull and activate the configured workspace repository.
|
|
workspace test [--json]
|
|
Test configured database, Evidence, Qdrant, and embedding connectivity.
|
|
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" {
|
|
if len(commandArgs) > 0 && (commandArgs[0] == "preflight" || commandArgs[0] == "plan") {
|
|
return installationPreflightCommand(ctx, installationPath, commandArgs, stdout)
|
|
}
|
|
if len(commandArgs) > 0 && (commandArgs[0] == "prepare" || commandArgs[0] == "credentials" || commandArgs[0] == "validate") {
|
|
return installationDocumentsCommand(ctx, installationPath, commandArgs, stdout)
|
|
}
|
|
if len(commandArgs) > 0 && commandArgs[0] == "generate" {
|
|
return installationGenerationCommand(installationPath, commandArgs[1:], stdout, stderr)
|
|
}
|
|
return installationMigrationCommand(installationPath, commandArgs, stdout, stderr)
|
|
}
|
|
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
|
|
}
|
|
if command == "workspace" && len(commandArgs) > 0 && (commandArgs[0] == "prepare" || commandArgs[0] == "validate") {
|
|
return workspaceDocumentsCommand(ctx, commandArgs, stdout, stderr)
|
|
}
|
|
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 installationGenerationCommand(installationPath string, args []string, stdout, stderr io.Writer) int {
|
|
if len(args) != 0 {
|
|
return commandUsageError(stderr, "installation generate does not accept arguments")
|
|
}
|
|
workingDirectory, err := os.Getwd()
|
|
if err != nil {
|
|
return commandUsageError(stderr, "current directory is unavailable")
|
|
}
|
|
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 err := modelprojection.Generate(installation); err != nil {
|
|
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
|
return 2
|
|
}
|
|
fmt.Fprintf(stdout, "Runtime projections generated in %s.\n", installation.GeneratedDirectory())
|
|
return 0
|
|
}
|
|
|
|
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 "--complete":
|
|
if request.Complete {
|
|
return setup.Request{}, errors.New("--complete may be supplied once")
|
|
}
|
|
request.Complete = true
|
|
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 "--shell-mode":
|
|
target = &request.Answers.ShellMode
|
|
case "--shell-default-locale":
|
|
target = &request.Answers.ShellDefaultLocale
|
|
case "--shell-adapter":
|
|
target = &request.Answers.ShellAdapter
|
|
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
|
|
}
|
|
}
|
|
if request.Complete && request.ConfigureOnly {
|
|
return setup.Request{}, errors.New("--complete and --configure-only cannot be combined")
|
|
}
|
|
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 {
|
|
if len(args) > 0 && (args[0] == "pull" || args[0] == "test") {
|
|
return workspaceOperatorCommand(ctx, installation, runner, args, secretValues, stdout, stderr)
|
|
}
|
|
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 workspaceOperatorCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
|
|
action := "workspace-" + args[0]
|
|
jsonMode := false
|
|
for _, arg := range args[1:] {
|
|
if arg != "--json" || jsonMode {
|
|
return commandUsageError(stderr, "workspace pull/test accepts only --json")
|
|
}
|
|
jsonMode = true
|
|
}
|
|
result, err := runner.Run(ctx, installation.ComposeArgs("exec", "-T", "core", "node", "dist/operator-command.js", action), nil)
|
|
if err != nil {
|
|
return writeResult(result, err, secretValues, stdout, stderr)
|
|
}
|
|
var payload struct {
|
|
Ready bool `json:"ready"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil {
|
|
fmt.Fprintln(stderr, "tht: workspace operator returned invalid JSON")
|
|
return 1
|
|
}
|
|
if jsonMode {
|
|
fmt.Fprintln(stdout, output.Sanitize(result.Stdout, secretValues))
|
|
} else {
|
|
fmt.Fprintf(stdout, "workspace %s: %s\n", args[0], output.Sanitize(workspaceOperatorSummary(payload), secretValues))
|
|
}
|
|
if !payload.Ready {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func workspaceOperatorSummary(payload struct {
|
|
Ready bool `json:"ready"`
|
|
Status string `json:"status"`
|
|
}) string {
|
|
if payload.Status != "" {
|
|
return payload.Status
|
|
}
|
|
if payload.Ready {
|
|
return "ready"
|
|
}
|
|
return "failed"
|
|
}
|
|
|
|
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
|
|
}
|