feat(pi): update to the latest stable release by default
This commit is contained in:
@@ -54,8 +54,10 @@ Commands:
|
|||||||
Select closed backend defaults interactively on a TTY; all flags are required otherwise.
|
Select closed backend defaults interactively on a TTY; all flags are required otherwise.
|
||||||
pi restart --yes [--drain]
|
pi restart --yes [--drain]
|
||||||
Recreate only core with the currently selected Pi image and verify readiness.
|
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]
|
pi update --version V --source build --yes [--drain]
|
||||||
Rebuild a pinned Pi version and recreate only core.
|
Advanced explicit build form retained for compatibility.
|
||||||
pi update --version V --source pull --image IMAGE@sha256:DIGEST --yes [--drain]
|
pi update --version V --source pull --image IMAGE@sha256:DIGEST --yes [--drain]
|
||||||
Pull an immutable candidate and recreate only core.
|
Pull an immutable candidate and recreate only core.
|
||||||
pi rollback --yes Restore the image recorded by the latest Pi update.
|
pi rollback --yes Restore the image recorded by the latest Pi update.
|
||||||
@@ -75,6 +77,11 @@ Commands:
|
|||||||
workspace preprocess run --workspace ID [--resume RUN] [--json]
|
workspace preprocess run --workspace ID [--resume RUN] [--json]
|
||||||
`
|
`
|
||||||
|
|
||||||
|
var (
|
||||||
|
newPiRegistryClient = pi.NewNPMRegistryClient
|
||||||
|
readPiRuntimePackageName = pi.ReadRuntimePackageName
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
|
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
|
||||||
}
|
}
|
||||||
@@ -487,12 +494,21 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return commandUsageError(stderr, err.Error())
|
return commandUsageError(stderr, err.Error())
|
||||||
}
|
}
|
||||||
result, err := pi.Update(ctx, controlled, request)
|
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 {
|
if err != nil {
|
||||||
return piFailure(stderr, err, secretValues)
|
return piFailure(stderr, err, secretValues)
|
||||||
}
|
}
|
||||||
if result.Phase == pi.PhaseNoop {
|
if result.Phase == pi.PhaseNoop {
|
||||||
fmt.Fprintf(stdout, "Pi already runs requested version %s; no container was recreated.\n", request.Version)
|
fmt.Fprintf(stdout, "Pi already runs requested version %s; no container was recreated.\n", result.Version)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "Pi update verified. Recovery metadata: %s\n", result.StatePath)
|
fmt.Fprintf(stdout, "Pi update verified. Recovery metadata: %s\n", result.StatePath)
|
||||||
@@ -647,6 +663,7 @@ func parsePiConfigureArgs(args []string) (pi.Defaults, error) {
|
|||||||
|
|
||||||
func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Request, error) {
|
func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Request, error) {
|
||||||
request := pi.Request{StatePath: statePath, RestartStatePath: restartStatePath}
|
request := pi.Request{StatePath: statePath, RestartStatePath: restartStatePath}
|
||||||
|
sourceSpecified := false
|
||||||
for len(args) > 0 {
|
for len(args) > 0 {
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "--version":
|
case "--version":
|
||||||
@@ -659,6 +676,7 @@ func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Re
|
|||||||
return pi.Request{}, errors.New("--source requires build or pull")
|
return pi.Request{}, errors.New("--source requires build or pull")
|
||||||
}
|
}
|
||||||
request.Source, args = pi.Source(args[1]), args[2:]
|
request.Source, args = pi.Source(args[1]), args[2:]
|
||||||
|
sourceSpecified = true
|
||||||
case "--image":
|
case "--image":
|
||||||
if len(args) < 2 || request.Image != "" {
|
if len(args) < 2 || request.Image != "" {
|
||||||
return pi.Request{}, errors.New("--image requires one digest-pinned image reference")
|
return pi.Request{}, errors.New("--image requires one digest-pinned image reference")
|
||||||
@@ -678,11 +696,17 @@ func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Re
|
|||||||
return pi.Request{}, fmt.Errorf("unknown pi update option %q", args[0])
|
return pi.Request{}, fmt.Errorf("unknown pi update option %q", args[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if request.Version == "" {
|
if !sourceSpecified {
|
||||||
return pi.Request{}, errors.New("pi update requires --version <pinned-version>")
|
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.Source == "" {
|
if request.Version == "" {
|
||||||
return pi.Request{}, errors.New("pi update requires explicit --source build or pull")
|
return pi.Request{}, errors.New("pi update with --source requires --version <pinned-version>")
|
||||||
}
|
}
|
||||||
if request.Source != pi.BuildSource && request.Source != pi.PullSource {
|
if request.Source != pi.BuildSource && request.Source != pi.PullSource {
|
||||||
return pi.Request{}, errors.New("--source requires build or pull")
|
return pi.Request{}, errors.New("--source requires build or pull")
|
||||||
|
|||||||
+138
-40
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||||
|
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/pi"
|
"github.com/aritmolab/thothii/tools/tht/internal/pi"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/setup"
|
"github.com/aritmolab/thothii/tools/tht/internal/setup"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
|
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
|
||||||
@@ -60,7 +62,7 @@ func TestRootCommandIdentity(t *testing.T) {
|
|||||||
name: "retired command is not an alias",
|
name: "retired command is not an alias",
|
||||||
args: []string{"--installation", fixture.installationPath, retiredCommand},
|
args: []string{"--installation", fixture.installationPath, retiredCommand},
|
||||||
wantCode: 2,
|
wantCode: 2,
|
||||||
wantText: `tht: unknown command "` + retiredCommand + `"`,
|
wantText: "tht: unknown command \"" + retiredCommand + "\"",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -313,6 +315,35 @@ func TestParsePiRestartArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParsePiUpdateArgsDefaultsToLatestStableBuildForTheShortCommand(t *testing.T) {
|
||||||
|
statePath := "/var/lib/tht/update-state.json"
|
||||||
|
restartPath := "/var/lib/tht/restart-state.json"
|
||||||
|
got, err := parsePiUpdateArgs(nil, statePath, restartPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := pi.Request{
|
||||||
|
StatePath: statePath,
|
||||||
|
RestartStatePath: restartPath,
|
||||||
|
Source: pi.BuildSource,
|
||||||
|
Confirm: true,
|
||||||
|
Drain: true,
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("parsePiUpdateArgs(nil) = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePiUpdateArgsAllowsExplicitVersionWithoutAdvancedFlags(t *testing.T) {
|
||||||
|
got, err := parsePiUpdateArgs([]string{"--version", "0.81.0"}, "update.json", "restart.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Version != "0.81.0" || got.Source != pi.BuildSource || !got.Confirm || !got.Drain {
|
||||||
|
t.Fatalf("parsePiUpdateArgs() = %#v, want explicit version with safe build defaults", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
|
func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
|
||||||
fixture := newCLIFixture(t, "")
|
fixture := newCLIFixture(t, "")
|
||||||
fixture.setProfile(t, "server")
|
fixture.setProfile(t, "server")
|
||||||
@@ -569,8 +600,8 @@ func TestRunRedactsSecretWhenDoctorFails(t *testing.T) {
|
|||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
|
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
|
||||||
|
|
||||||
if exitCode != 41 {
|
if exitCode != 1 {
|
||||||
t.Errorf("run() exit code = %d, want 41", exitCode)
|
t.Errorf("run() exit code = %d, want normalized doctor failure exit 1", exitCode)
|
||||||
}
|
}
|
||||||
if strings.Contains(stdout.String()+stderr.String(), "doctor-secret") {
|
if strings.Contains(stdout.String()+stderr.String(), "doctor-secret") {
|
||||||
t.Errorf("doctor failure exposed secret: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
t.Errorf("doctor failure exposed secret: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
||||||
@@ -800,15 +831,9 @@ func TestRunDoctorValidatesTheRenderedInstallation(t *testing.T) {
|
|||||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||||
fixture.setEnvironment(t)
|
fixture.setEnvironment(t)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
report := runDoctorJSON(t, fixture)
|
||||||
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
|
assertDoctorCheck(t, report, "configuration", doctor.StatusPassed)
|
||||||
|
assertDoctorCheck(t, report, "services", doctor.StatusPassed)
|
||||||
if exitCode != 0 {
|
|
||||||
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
||||||
}
|
|
||||||
if stdout.String() != "Doctor checks passed.\n" {
|
|
||||||
t.Errorf("stdout = %q, want doctor success", stdout.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T) {
|
func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T) {
|
||||||
@@ -820,15 +845,9 @@ func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T)
|
|||||||
filepath.Join(fixture.projectDirectory, "legacy-workspace.yaml"),
|
filepath.Join(fixture.projectDirectory, "legacy-workspace.yaml"),
|
||||||
)
|
)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
report := runDoctorJSON(t, fixture)
|
||||||
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
|
assertDoctorCheck(t, report, "files", doctor.StatusPassed)
|
||||||
|
assertDoctorCheck(t, report, "configuration", doctor.StatusPassed)
|
||||||
if exitCode != 0 {
|
|
||||||
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
||||||
}
|
|
||||||
if stdout.String() != "Doctor checks passed.\n" {
|
|
||||||
t.Errorf("stdout = %q, want doctor success", stdout.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) {
|
func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) {
|
||||||
@@ -837,18 +856,14 @@ func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) {
|
|||||||
t.Setenv(
|
t.Setenv(
|
||||||
"THT_FAKE_PS",
|
"THT_FAKE_PS",
|
||||||
"{\"Service\":\"core\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
|
"{\"Service\":\"core\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
|
||||||
"{\"Service\":\"frontend\",\"State\":\"running\",\"Health\":\"healthy\"}",
|
"{\"Service\":\"frontend\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
|
||||||
|
"{\"Service\":\"qdrant\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
|
||||||
|
"{\"Service\":\"embedding\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
|
||||||
|
"{\"Service\":\"embedding-model-init\",\"State\":\"exited\",\"ExitCode\":0}",
|
||||||
)
|
)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
report := runDoctorJSON(t, fixture)
|
||||||
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
|
assertDoctorCheck(t, report, "services", doctor.StatusPassed)
|
||||||
|
|
||||||
if exitCode != 0 {
|
|
||||||
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
|
|
||||||
}
|
|
||||||
if stdout.String() != "Doctor checks passed.\n" {
|
|
||||||
t.Errorf("stdout = %q, want doctor success", stdout.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunPreservesChildExitCodes(t *testing.T) {
|
func TestRunPreservesChildExitCodes(t *testing.T) {
|
||||||
@@ -883,6 +898,28 @@ func TestRunPiStatusUsesImageBundledPi(t *testing.T) {
|
|||||||
assertInvocationContains(t, fixture.invocations(t), "exec", "-T", "core", "pi", "--version")
|
assertInvocationContains(t, fixture.invocations(t), "exec", "-T", "core", "pi", "--version")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunPiUpdateResolvesLatestStableVersionWhenVersionIsOmitted(t *testing.T) {
|
||||||
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
||||||
|
fixture.setEnvironment(t)
|
||||||
|
if err := os.WriteFile(filepath.Join(fixture.projectDirectory, "docker", "core.Dockerfile"), []byte("ARG PI_VERSION=0.79.0\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
previousRegistry := newPiRegistryClient
|
||||||
|
newPiRegistryClient = func() pi.RegistryClient {
|
||||||
|
return testRegistryClient{version: "0.80.3"}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newPiRegistryClient = previousRegistry })
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "pi", "update"}, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("run() exit code = %d, stderr = %s", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "Pi already runs requested version 0.80.3") {
|
||||||
|
t.Fatalf("stdout = %q, want registry-selected version", stdout.String())
|
||||||
|
}
|
||||||
|
assertInvocationNotContains(t, fixture.invocations(t), "build --pull")
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunPiUpdateRequiresExplicitConfirmationWithoutInvokingDocker(t *testing.T) {
|
func TestRunPiUpdateRequiresExplicitConfirmationWithoutInvokingDocker(t *testing.T) {
|
||||||
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
||||||
fixture.setEnvironment(t)
|
fixture.setEnvironment(t)
|
||||||
@@ -942,22 +979,21 @@ func TestRunPiRestartSanitizesSuccessOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunPiUpdateRequiresExplicitSourceWithoutInvokingDocker(t *testing.T) {
|
func TestRunPiUpdateAcceptsExplicitVersionWithoutAdvancedFlags(t *testing.T) {
|
||||||
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
||||||
fixture.setEnvironment(t)
|
fixture.setEnvironment(t)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
exitCode := run(context.Background(), []string{
|
exitCode := run(context.Background(), []string{
|
||||||
"--installation", fixture.installationPath, "pi", "update", "--version", "0.81.0", "--yes",
|
"--installation", fixture.installationPath, "pi", "update", "--version", "0.80.3",
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
|
|
||||||
if exitCode != 2 {
|
if exitCode != 0 {
|
||||||
t.Errorf("run() exit code = %d, want 2", exitCode)
|
t.Fatalf("run() exit code = %d, stderr = %q", exitCode, stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "requires explicit --source build or pull") {
|
if !strings.Contains(stdout.String(), "Pi already runs requested version 0.80.3") {
|
||||||
t.Errorf("stderr = %q, want source guidance", stderr.String())
|
t.Fatalf("stdout = %q, want explicit version result", stdout.String())
|
||||||
}
|
}
|
||||||
assertDockerNotInvoked(t, fixture)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunPiConfigureReportsTheActualHostAuthFile(t *testing.T) {
|
func TestRunPiConfigureReportsTheActualHostAuthFile(t *testing.T) {
|
||||||
@@ -1203,6 +1239,15 @@ type cliFixture struct {
|
|||||||
envTemplate string
|
envTemplate string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testRegistryClient struct {
|
||||||
|
version string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client testRegistryClient) LatestStable(context.Context, string) (string, error) {
|
||||||
|
return client.version, client.err
|
||||||
|
}
|
||||||
|
|
||||||
func newProjectWithoutInstallation(t *testing.T) string {
|
func newProjectWithoutInstallation(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
@@ -1232,6 +1277,18 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
|
|||||||
if err := os.MkdirAll(filepath.Join(projectDirectory, "deploy"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(projectDirectory, "deploy"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(projectDirectory, "docker"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(projectDirectory, "docker", "core.Dockerfile"), []byte("ARG PI_VERSION=0.80.3\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(projectDirectory, "docker", "pi-runtime"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(projectDirectory, "docker", "pi-runtime", "package.json"), []byte(`{"dependencies":{"@earendil-works/pi-coding-agent":"0.80.3"}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
for _, path := range []string{filepath.Join(projectDirectory, "compose.yaml"), filepath.Join(projectDirectory, "deploy", "compose.local.yaml")} {
|
for _, path := range []string{filepath.Join(projectDirectory, "compose.yaml"), filepath.Join(projectDirectory, "deploy", "compose.local.yaml")} {
|
||||||
if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -1257,7 +1314,7 @@ case " $* " in
|
|||||||
if [ -n "${THT_FAKE_CONFIG:-}" ]; then
|
if [ -n "${THT_FAKE_CONFIG:-}" ]; then
|
||||||
printf '%s\n' "$THT_FAKE_CONFIG"
|
printf '%s\n' "$THT_FAKE_CONFIG"
|
||||||
else
|
else
|
||||||
printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
|
printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
|
||||||
fi ;;
|
fi ;;
|
||||||
*" run --rm --no-deps --no-TTY session-migrate "*)
|
*" run --rm --no-deps --no-TTY session-migrate "*)
|
||||||
if [ "${THT_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then
|
if [ "${THT_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then
|
||||||
@@ -1268,6 +1325,8 @@ case " $* " in
|
|||||||
*" ps --all --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
|
*" ps --all --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
|
||||||
*" ps --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
|
*" ps --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
|
||||||
*" ps -q core "*) printf '%s\n' 'core-id' ;;
|
*" ps -q core "*) printf '%s\n' 'core-id' ;;
|
||||||
|
*" version --format {{.Client.Version}}"*) printf '%s\n' '27.0.0' ;;
|
||||||
|
*"compose version --short"*) printf '%s\n' 'v2.30.0' ;;
|
||||||
*" image inspect --format {{.Id}} "*) printf '%s\n' "${THT_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
*" image inspect --format {{.Id}} "*) printf '%s\n' "${THT_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
||||||
*"inspect --format {{.Image}} core-id"*) printf '%s\n' "${THT_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
*"inspect --format {{.Image}} core-id"*) printf '%s\n' "${THT_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
||||||
*"inspect --format {{json .Mounts}} core-id"*) printf '%s\n' '[{"Type":"volume","Name":"settings","Source":"settings","Destination":"/home/thoth/.pi","RW":true}]' ;;
|
*"inspect --format {{json .Mounts}} core-id"*) printf '%s\n' '[{"Type":"volume","Name":"settings","Source":"settings","Destination":"/home/thoth/.pi","RW":true}]' ;;
|
||||||
@@ -1278,6 +1337,7 @@ case " $* " in
|
|||||||
*"settings-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;;
|
*"settings-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;;
|
||||||
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
|
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
|
||||||
*"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;;
|
*"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;;
|
||||||
|
*" tht doctor --json"*) printf '%s\n' '{"ok":true}' ;;
|
||||||
*"/sessions?scope="*) printf '%s\n' '[]' ;;
|
*"/sessions?scope="*) printf '%s\n' '[]' ;;
|
||||||
*"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
|
*"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
|
||||||
*"/internal/maintenance/deactivate "*) printf '%s\n' '{"active":false,"admissions":0}' ;;
|
*"/internal/maintenance/deactivate "*) printf '%s\n' '{"active":false,"admissions":0}' ;;
|
||||||
@@ -1321,7 +1381,7 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) {
|
|||||||
t.Setenv("THT_FAKE_FAILURE", "")
|
t.Setenv("THT_FAKE_FAILURE", "")
|
||||||
t.Setenv("THT_FAKE_FAIL_ON", "")
|
t.Setenv("THT_FAKE_FAIL_ON", "")
|
||||||
t.Setenv("THT_FAKE_STOPPED_PS", "[]")
|
t.Setenv("THT_FAKE_STOPPED_PS", "[]")
|
||||||
t.Setenv("THT_FAKE_PS", `[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]`)
|
t.Setenv("THT_FAKE_PS", `[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"},{"Service":"qdrant","State":"running","Health":"healthy"},{"Service":"embedding","State":"running","Health":"healthy"},{"Service":"embedding-model-init","State":"exited","ExitCode":0}]`)
|
||||||
t.Setenv("THT_FAKE_CONFIG", "")
|
t.Setenv("THT_FAKE_CONFIG", "")
|
||||||
t.Setenv("THT_FAKE_MIGRATION_FAILURE", "")
|
t.Setenv("THT_FAKE_MIGRATION_FAILURE", "")
|
||||||
t.Setenv("THT_FAKE_MIGRATION_EXIT", "0")
|
t.Setenv("THT_FAKE_MIGRATION_EXIT", "0")
|
||||||
@@ -1368,6 +1428,44 @@ func assertDockerNotInvoked(t *testing.T, fixture cliFixture) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runDoctorJSON(t *testing.T, fixture cliFixture) doctor.Report {
|
||||||
|
t.Helper()
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor", "--json"}, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("doctor exit = %d, stderr = %s, stdout = %s", code, stderr.String(), stdout.String())
|
||||||
|
}
|
||||||
|
var report doctor.Report
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
|
||||||
|
t.Fatalf("doctor JSON = %q: %v", stdout.String(), err)
|
||||||
|
}
|
||||||
|
if !report.OK {
|
||||||
|
t.Fatalf("doctor report = %#v, want all checks passed", report)
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertDoctorCheck(t *testing.T, report doctor.Report, name, wantStatus string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, check := range report.Checks {
|
||||||
|
if check.Name == name {
|
||||||
|
if check.Status != wantStatus {
|
||||||
|
t.Fatalf("doctor check %q = %q, want %q", name, check.Status, wantStatus)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("doctor report does not contain check %q", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertInvocationNotContains(t *testing.T, invocations [][]string, fragment string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, invocation := range invocations {
|
||||||
|
if strings.Contains(strings.Join(invocation, " "), fragment) {
|
||||||
|
t.Errorf("Docker invocation %q unexpectedly contains %q", invocation, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertInvocationContains(t *testing.T, invocations [][]string, want ...string) {
|
func assertInvocationContains(t *testing.T, invocations [][]string, want ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, invocation := range invocations {
|
for _, invocation := range invocations {
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package pi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
piRegistryURL = "https://registry.npmjs.org"
|
||||||
|
registryBodyLimit = 5 << 20
|
||||||
|
registryTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var packageNamePattern = regexp.MustCompile(`^(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$`)
|
||||||
|
|
||||||
|
// RegistryClient resolves a published, stable Pi package version without involving a host npm
|
||||||
|
// executable. It is deliberately narrow so the update transaction can resolve a version before
|
||||||
|
// it acquires a lifecycle lock or contacts Docker.
|
||||||
|
type RegistryClient interface {
|
||||||
|
LatestStable(ctx context.Context, packageName string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type npmRegistryClient struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNPMRegistryClient returns the bounded HTTPS client for the authoritative npm registry.
|
||||||
|
func NewNPMRegistryClient() RegistryClient {
|
||||||
|
return newNPMRegistryClient(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNPMRegistryClient(client *http.Client) *npmRegistryClient {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: registryTimeout}
|
||||||
|
}
|
||||||
|
return &npmRegistryClient{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestStable returns the greatest released semantic version. npm's dist-tag is not trusted as
|
||||||
|
// it can point at a prerelease; selecting from the published versions keeps the default stable.
|
||||||
|
func (c *npmRegistryClient) LatestStable(ctx context.Context, packageName string) (string, error) {
|
||||||
|
if !packageNamePattern.MatchString(packageName) {
|
||||||
|
return "", errors.New("Pi package name is invalid")
|
||||||
|
}
|
||||||
|
endpoint, err := registryPackageURL(packageName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create Pi registry request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
response, err := c.client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("query Pi registry: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode == http.StatusNotFound {
|
||||||
|
return "", errors.New("Pi package was not found in the registry")
|
||||||
|
}
|
||||||
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return "", fmt.Errorf("Pi registry returned HTTP %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Versions map[string]json.RawMessage `json:"versions"`
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(response.Body, registryBodyLimit+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read Pi registry response: %w", err)
|
||||||
|
}
|
||||||
|
if len(body) > registryBodyLimit {
|
||||||
|
return "", errors.New("Pi registry response is too large")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
return "", errors.New("Pi registry response is invalid")
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
return "", errors.New("Pi registry response is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected semanticVersion
|
||||||
|
found := false
|
||||||
|
for rawVersion, rawMetadata := range payload.Versions {
|
||||||
|
if !json.Valid(rawMetadata) {
|
||||||
|
return "", errors.New("Pi registry response is invalid")
|
||||||
|
}
|
||||||
|
version, err := parseSemanticVersion(rawVersion)
|
||||||
|
if err != nil || version.prerelease != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found || selected.less(version) {
|
||||||
|
selected, found = version, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return "", errors.New("Pi registry contains no stable semantic version")
|
||||||
|
}
|
||||||
|
return selected.raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryPackageURL(packageName string) (string, error) {
|
||||||
|
base, err := url.Parse(piRegistryURL)
|
||||||
|
if err != nil || base.Scheme != "https" || base.Host != "registry.npmjs.org" {
|
||||||
|
return "", errors.New("Pi registry endpoint is not an authoritative HTTPS URL")
|
||||||
|
}
|
||||||
|
base.Path = "/" + packageName
|
||||||
|
base.RawPath = "/" + url.PathEscape(packageName)
|
||||||
|
return base.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadRuntimePackageName obtains the exact Pi package from the dependency manifest copied by
|
||||||
|
// docker/core.Dockerfile. Version discovery therefore follows the runtime the image actually
|
||||||
|
// builds, rather than a second, independently maintained package name.
|
||||||
|
func ReadRuntimePackageName(projectDirectory string) (string, error) {
|
||||||
|
contents, err := os.ReadFile(filepath.Join(projectDirectory, "docker", "pi-runtime", "package.json"))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read Pi runtime package manifest: %w", err)
|
||||||
|
}
|
||||||
|
var manifest struct {
|
||||||
|
Dependencies map[string]string `json:"dependencies"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(contents, &manifest); err != nil {
|
||||||
|
return "", errors.New("Pi runtime package manifest is invalid")
|
||||||
|
}
|
||||||
|
const piDependency = "@earendil-works/pi-coding-agent"
|
||||||
|
if !packageNamePattern.MatchString(piDependency) || strings.TrimSpace(manifest.Dependencies[piDependency]) == "" {
|
||||||
|
return "", errors.New("Pi runtime package manifest does not declare the Pi dependency")
|
||||||
|
}
|
||||||
|
return piDependency, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRequestedVersion validates an explicit version without a lookup, or resolves the latest
|
||||||
|
// published stable version before the caller starts any lifecycle mutation.
|
||||||
|
func ResolveRequestedVersion(ctx context.Context, requested string, packageName string, registry RegistryClient) (string, error) {
|
||||||
|
if strings.TrimSpace(requested) != "" {
|
||||||
|
version, err := parseSemanticVersion(requested)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return version.raw, nil
|
||||||
|
}
|
||||||
|
if registry == nil {
|
||||||
|
return "", errors.New("Pi registry client is unavailable")
|
||||||
|
}
|
||||||
|
version, err := registry.LatestStable(ctx, packageName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parsed, err := parseSemanticVersion(version)
|
||||||
|
if err != nil || parsed.prerelease != "" {
|
||||||
|
return "", errors.New("Pi registry did not return a stable semantic version")
|
||||||
|
}
|
||||||
|
return parsed.raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type semanticVersion struct {
|
||||||
|
raw string
|
||||||
|
major string
|
||||||
|
minor string
|
||||||
|
patch string
|
||||||
|
prerelease string
|
||||||
|
}
|
||||||
|
|
||||||
|
var semanticVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
|
||||||
|
|
||||||
|
func parseSemanticVersion(raw string) (semanticVersion, error) {
|
||||||
|
match := semanticVersionPattern.FindStringSubmatch(raw)
|
||||||
|
if len(match) == 0 {
|
||||||
|
return semanticVersion{}, errors.New("Pi version must be a strict semantic version")
|
||||||
|
}
|
||||||
|
if match[4] != "" {
|
||||||
|
for _, identifier := range strings.Split(match[4], ".") {
|
||||||
|
if allDigits(identifier) && len(identifier) > 1 && identifier[0] == '0' {
|
||||||
|
return semanticVersion{}, errors.New("Pi version must be a strict semantic version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return semanticVersion{raw: raw, major: match[1], minor: match[2], patch: match[3], prerelease: match[4]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func allDigits(value string) bool {
|
||||||
|
for _, character := range value {
|
||||||
|
if character < '0' || character > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (left semanticVersion) less(right semanticVersion) bool {
|
||||||
|
for _, pair := range [][2]string{{left.major, right.major}, {left.minor, right.minor}, {left.patch, right.patch}} {
|
||||||
|
if len(pair[0]) != len(pair[1]) {
|
||||||
|
return len(pair[0]) < len(pair[1])
|
||||||
|
}
|
||||||
|
if pair[0] != pair[1] {
|
||||||
|
return pair[0] < pair[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package pi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type registryFunc func(context.Context, string) (string, error)
|
||||||
|
|
||||||
|
func (f registryFunc) LatestStable(ctx context.Context, packageName string) (string, error) {
|
||||||
|
return f(ctx, packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||||
|
return f(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNPMRegistryClientSelectsHighestStableVersionAndIgnoresPrereleases(t *testing.T) {
|
||||||
|
client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||||
|
if request.URL.Scheme != "https" || request.URL.Host != "registry.npmjs.org" {
|
||||||
|
t.Fatalf("registry request = %s; want authoritative HTTPS registry", request.URL)
|
||||||
|
}
|
||||||
|
if request.URL.Path != "/@earendil-works%2Fpi-coding-agent" && request.URL.EscapedPath() != "/@earendil-works%2Fpi-coding-agent" {
|
||||||
|
t.Fatalf("registry path = %q", request.URL.EscapedPath())
|
||||||
|
}
|
||||||
|
return registryResponse(http.StatusOK, `{"versions":{"0.80.3":{},"0.81.0-rc.1":{},"0.81.0":{},"1.0.0-beta.2":{}}}`), nil
|
||||||
|
})})
|
||||||
|
|
||||||
|
got, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "0.81.0" {
|
||||||
|
t.Fatalf("LatestStable() = %q, want 0.81.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNPMRegistryClientRejectsInvalidResponses(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
wantText string
|
||||||
|
}{
|
||||||
|
{name: "prerelease only", status: http.StatusOK, body: `{"versions":{"1.0.0-rc.1":{}}}`, wantText: "no stable"},
|
||||||
|
{name: "malformed JSON", status: http.StatusOK, body: `{`, wantText: "invalid"},
|
||||||
|
{name: "no version data", status: http.StatusOK, body: `{"versions":{}}`, wantText: "no stable"},
|
||||||
|
{name: "package not found", status: http.StatusNotFound, body: `{"error":"not_found"}`, wantText: "not found"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return registryResponse(test.status, test.body), nil
|
||||||
|
})})
|
||||||
|
_, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent")
|
||||||
|
if err == nil || !strings.Contains(strings.ToLower(err.Error()), test.wantText) {
|
||||||
|
t.Fatalf("LatestStable() error = %v, want %q", err, test.wantText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNPMRegistryClientRejectsAnOversizedResponse(t *testing.T) {
|
||||||
|
client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return registryResponse(http.StatusOK, `{"versions":{"1.2.3":{}}}`+strings.Repeat(" ", registryBodyLimit+1)), nil
|
||||||
|
})})
|
||||||
|
_, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "too large") {
|
||||||
|
t.Fatalf("LatestStable() error = %v, want bounded-response error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNPMRegistryClientHonorsContextTimeout(t *testing.T) {
|
||||||
|
client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||||
|
<-request.Context().Done()
|
||||||
|
return nil, request.Context().Err()
|
||||||
|
})})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := client.LatestStable(ctx, "@earendil-works/pi-coding-agent")
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("LatestStable() error = %v, want deadline exceeded", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRequestedVersionBypassesRegistryForExplicitVersion(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
got, err := ResolveRequestedVersion(context.Background(), "1.2.3-rc.1", "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) {
|
||||||
|
called = true
|
||||||
|
return "", errors.New("registry must not be called")
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "1.2.3-rc.1" || called {
|
||||||
|
t.Fatalf("ResolveRequestedVersion() = %q, called=%t; want explicit prerelease without lookup", got, called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRequestedVersionRejectsNonStrictSemver(t *testing.T) {
|
||||||
|
for _, version := range []string{"1.2", "01.2.3", "v1.2.3", "1.2.3.4", "1.2.3-01"} {
|
||||||
|
_, err := ResolveRequestedVersion(context.Background(), version, "@earendil-works/pi-coding-agent", nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "semantic version") {
|
||||||
|
t.Fatalf("ResolveRequestedVersion(%q) error = %v, want strict semver error", version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateWithResolvedVersionDoesNotMutateWhenDiscoveryFails(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{name: "timeout", err: context.DeadlineExceeded},
|
||||||
|
{name: "package missing", err: errors.New("package not found")},
|
||||||
|
{name: "malformed", err: errors.New("registry response is invalid")},
|
||||||
|
{name: "no stable version", err: errors.New("registry contains no stable version")},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
fake := newFakeRunner()
|
||||||
|
statePath := t.TempDir() + "/state/update.json"
|
||||||
|
_, err := UpdateWithResolvedVersion(context.Background(), fake, Request{StatePath: statePath, Source: BuildSource, Confirm: true, Drain: true}, "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) {
|
||||||
|
return "", test.err
|
||||||
|
}))
|
||||||
|
if !errors.Is(err, test.err) {
|
||||||
|
t.Fatalf("UpdateWithResolvedVersion() error = %v, want %v", err, test.err)
|
||||||
|
}
|
||||||
|
if len(fake.calls) != 0 {
|
||||||
|
t.Fatalf("discovery failure invoked Docker or lifecycle commands: %v", fake.calls)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Dir(statePath)); !errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
t.Fatalf("discovery failure created update state directory: %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadRuntimePackageNameReadsThePiDependency(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
path := filepath.Join(root, "docker", "pi-runtime")
|
||||||
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(path, "package.json"), []byte(`{"dependencies":{"@earendil-works/pi-coding-agent":"0.80.3"}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := ReadRuntimePackageName(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "@earendil-works/pi-coding-agent" {
|
||||||
|
t.Fatalf("ReadRuntimePackageName() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryResponse(status int, body string) *http.Response {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -24,7 +23,6 @@ var (
|
|||||||
ErrActiveSessions = errors.New("active sessions must be drained before updating Pi; use --drain only after they are complete")
|
ErrActiveSessions = errors.New("active sessions must be drained before updating Pi; use --drain only after they are complete")
|
||||||
ErrInterruptedUpdate = errors.New("a previous Pi update is incomplete; run pi rollback --yes before starting another update")
|
ErrInterruptedUpdate = errors.New("a previous Pi update is incomplete; run pi rollback --yes before starting another update")
|
||||||
ErrInvalidRequest = errors.New("invalid Pi lifecycle request")
|
ErrInvalidRequest = errors.New("invalid Pi lifecycle request")
|
||||||
versionPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9]+){1,3}(?:[-+][0-9A-Za-z.-]+)?$`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Source chooses whether the candidate is built from this checkout or pulled from an immutable image.
|
// Source chooses whether the candidate is built from this checkout or pulled from an immutable image.
|
||||||
@@ -50,6 +48,7 @@ type Request struct {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Phase Phase
|
Phase Phase
|
||||||
StatePath string
|
StatePath string
|
||||||
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
type lifecycleHooks struct {
|
type lifecycleHooks struct {
|
||||||
@@ -69,6 +68,20 @@ func Update(ctx context.Context, runner Runner, request Request) (result Result,
|
|||||||
return updateWithHooks(ctx, runner, request, defaultLifecycleHooks)
|
return updateWithHooks(ctx, runner, request, defaultLifecycleHooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateWithResolvedVersion resolves a requested version before Update obtains its lifecycle lock
|
||||||
|
// or contacts Docker. A registry failure therefore cannot drain sessions, write state, or mutate
|
||||||
|
// images and configuration.
|
||||||
|
func UpdateWithResolvedVersion(ctx context.Context, runner Runner, request Request, packageName string, registry RegistryClient) (Result, error) {
|
||||||
|
version, err := ResolveRequestedVersion(ctx, request.Version, packageName, registry)
|
||||||
|
if err != nil {
|
||||||
|
return Result{StatePath: request.StatePath}, err
|
||||||
|
}
|
||||||
|
request.Version = version
|
||||||
|
result, err := Update(ctx, runner, request)
|
||||||
|
result.Version = version
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
func updateWithHooks(ctx context.Context, runner Runner, request Request, hooks lifecycleHooks) (result Result, retErr error) {
|
func updateWithHooks(ctx context.Context, runner Runner, request Request, hooks lifecycleHooks) (result Result, retErr error) {
|
||||||
if request.StatePath == "" {
|
if request.StatePath == "" {
|
||||||
return Result{}, errors.New("update state path is required")
|
return Result{}, errors.New("update state path is required")
|
||||||
@@ -87,8 +100,8 @@ func updateWithHooks(ctx context.Context, runner Runner, request Request, hooks
|
|||||||
if !request.Confirm {
|
if !request.Confirm {
|
||||||
return Result{StatePath: request.StatePath}, ErrConfirmationRequired
|
return Result{StatePath: request.StatePath}, ErrConfirmationRequired
|
||||||
}
|
}
|
||||||
if !versionPattern.MatchString(request.Version) {
|
if _, err := parseSemanticVersion(request.Version); err != nil {
|
||||||
return Result{StatePath: request.StatePath}, fmt.Errorf("%w: Pi version must be an explicit pinned version", ErrInvalidRequest)
|
return Result{StatePath: request.StatePath}, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
if request.Source == "" {
|
if request.Source == "" {
|
||||||
return Result{StatePath: request.StatePath}, fmt.Errorf("%w: Pi update requires an explicit source: build or pull", ErrInvalidRequest)
|
return Result{StatePath: request.StatePath}, fmt.Errorf("%w: Pi update requires an explicit source: build or pull", ErrInvalidRequest)
|
||||||
|
|||||||
@@ -54,21 +54,25 @@ func TestActiveSessionsUsesInstallationScopedInventory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *testing.T) {
|
func TestUpdateBuildsRegistrySelectedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *testing.T) {
|
||||||
fake := newFakeRunner()
|
fake := newFakeRunner()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
result, err := Update(context.Background(), fake, Request{
|
result, err := UpdateWithResolvedVersion(context.Background(), fake, Request{
|
||||||
StatePath: filepath.Join(dir, ".tht", "update-state.json"),
|
StatePath: filepath.Join(dir, ".tht", "update-state.json"),
|
||||||
Version: "0.81.0",
|
|
||||||
Source: BuildSource,
|
Source: BuildSource,
|
||||||
Confirm: true,
|
Confirm: true,
|
||||||
})
|
}, "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) {
|
||||||
|
return "0.81.0", nil
|
||||||
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Update() error = %v", err)
|
t.Fatalf("Update() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.Phase != PhaseVerified {
|
if result.Phase != PhaseVerified {
|
||||||
t.Fatalf("phase = %q, want %q", result.Phase, PhaseVerified)
|
t.Fatalf("phase = %q, want %q", result.Phase, PhaseVerified)
|
||||||
}
|
}
|
||||||
|
if result.Version != "0.81.0" {
|
||||||
|
t.Fatalf("resolved version = %q, want 0.81.0", result.Version)
|
||||||
|
}
|
||||||
assertCalled(t, fake.calls, "build --pull --build-arg PI_VERSION=0.81.0 core")
|
assertCalled(t, fake.calls, "build --pull --build-arg PI_VERSION=0.81.0 core")
|
||||||
assertCalled(t, fake.calls, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core")
|
assertCalled(t, fake.calls, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core")
|
||||||
assertNotCalled(t, fake.calls, "frontend")
|
assertNotCalled(t, fake.calls, "frontend")
|
||||||
@@ -81,6 +85,13 @@ func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *t
|
|||||||
if selected := readSelectorReference(t, currentImageOverridePath(result.StatePath)); selected != fake.buildReference {
|
if selected := readSelectorReference(t, currentImageOverridePath(result.StatePath)); selected != fake.buildReference {
|
||||||
t.Fatalf("durable selector = %q, want verified candidate %q", selected, fake.buildReference)
|
t.Fatalf("durable selector = %q, want verified candidate %q", selected, fake.buildReference)
|
||||||
}
|
}
|
||||||
|
state, err := readState(result.StatePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state.Target.Version != "0.81.0" {
|
||||||
|
t.Fatalf("durable update state target = %q, want registry-selected version", state.Target.Version)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateUsesATransactionScopedComposeOverrideWithoutMutatingTheConfiguredImage(t *testing.T) {
|
func TestUpdateUsesATransactionScopedComposeOverrideWithoutMutatingTheConfiguredImage(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user