fix: make server operations executable
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/output"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/pi"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/serverops"
|
||||
)
|
||||
|
||||
const usage = `Usage: thothctl --installation <absolute-path>/thothii-installation.yaml <command>
|
||||
@@ -29,6 +30,10 @@ Commands:
|
||||
start Start the installation in the background.
|
||||
stop Stop the installation.
|
||||
update --check-only Validate the current installation without changing containers.
|
||||
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.
|
||||
@@ -113,12 +118,67 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
return doctor(ctx, installation, runner, secretValues, 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, "thothctl: 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, "thothctl: 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
|
||||
default:
|
||||
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
|
||||
}
|
||||
return writeResult(result, err, secretValues, stdout, stderr)
|
||||
}
|
||||
|
||||
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 serverOperationFailure(stderr io.Writer, err error, secretValues []string) int {
|
||||
fmt.Fprintf(stderr, "thothctl: %s\n", output.Sanitize(err.Error(), secretValues))
|
||||
if errors.Is(err, serverops.ErrConfirmationRequired) || errors.Is(err, serverops.ErrUnsafeState) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// installationRunner transforms only Compose invocations into the installation's validated,
|
||||
// profile-specific argument list. Direct Docker image commands remain host-side and use arguments.
|
||||
type installationRunner struct {
|
||||
|
||||
@@ -79,6 +79,8 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes
|
||||
"--source pull --image IMAGE@sha256:DIGEST",
|
||||
"pi maintenance status",
|
||||
"pi maintenance recover --yes",
|
||||
"sessions migrate --yes",
|
||||
"remove --yes ID...",
|
||||
} {
|
||||
if !strings.Contains(usage, required) {
|
||||
t.Errorf("usage missing %q", required)
|
||||
@@ -86,6 +88,48 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setProfile(t, "server")
|
||||
fixture.setEnvironment(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := run(context.Background(), []string{
|
||||
"--installation", fixture.installationPath, "sessions", "migrate",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if code != 2 || !strings.Contains(stderr.String(), "sessions migrate requires --yes") {
|
||||
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
assertDockerNotInvoked(t, fixture)
|
||||
}
|
||||
|
||||
func TestRunRemoveDisplaysExactInstallationTargetsBeforeConfirmation(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setProfile(t, "server")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THOTHCTL_FAKE_STOPPED_PS", `[{"ID":"core-id","Name":"exact-core","Service":"core","State":"exited"},{"ID":"front-id","Name":"exact-frontend","Service":"frontend","State":"exited"}]`)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := run(context.Background(), []string{
|
||||
"--installation", fixture.installationPath, "remove",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if code != 2 || !strings.Contains(stderr.String(), "re-run with remove --yes core-id front-id") {
|
||||
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
for _, value := range []string{"exact-core", "core-id", "exact-frontend", "front-id", "exited"} {
|
||||
if !strings.Contains(stdout.String(), value) {
|
||||
t.Errorf("target display %q missing %q", stdout.String(), value)
|
||||
}
|
||||
}
|
||||
calls := fixture.invocations(t)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("Docker calls = %#v", calls)
|
||||
}
|
||||
assertInvocationContains(t, calls, "ps", "--all", "--format", "json", "core", "frontend")
|
||||
}
|
||||
|
||||
type wizardRunner struct{ calls []string }
|
||||
|
||||
func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
||||
@@ -615,6 +659,7 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
|
||||
printf '%s\n' "$@" >> "$THOTHCTL_FAKE_ARGS"
|
||||
printf '%s\n' -- >> "$THOTHCTL_FAKE_ARGS"
|
||||
case " $* " in
|
||||
*" ps --all --format json core frontend "*) printf '%s\n' "${THOTHCTL_FAKE_STOPPED_PS:-[]}" ;;
|
||||
*" config --format json "*) printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}' ;;
|
||||
*" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;;
|
||||
*"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;;
|
||||
@@ -661,6 +706,19 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) {
|
||||
t.Setenv("THOTHCTL_FAKE_LOG", "")
|
||||
t.Setenv("THOTHCTL_FAKE_FAILURE", "")
|
||||
t.Setenv("THOTHCTL_FAKE_FAIL_ON", "")
|
||||
t.Setenv("THOTHCTL_FAKE_STOPPED_PS", "[]")
|
||||
}
|
||||
|
||||
func (f cliFixture) setProfile(t *testing.T, profile string) {
|
||||
t.Helper()
|
||||
composePath := filepath.Join(f.projectDirectory, "deploy", "compose."+profile+".yaml")
|
||||
if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\n"
|
||||
if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f cliFixture) invocations(t *testing.T) [][]string {
|
||||
|
||||
Reference in New Issue
Block a user