feat(pi): update to the latest stable release by default
This commit is contained in:
@@ -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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -24,7 +23,6 @@ var (
|
||||
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")
|
||||
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.
|
||||
@@ -50,6 +48,7 @@ type Request struct {
|
||||
type Result struct {
|
||||
Phase Phase
|
||||
StatePath string
|
||||
Version string
|
||||
}
|
||||
|
||||
type lifecycleHooks struct {
|
||||
@@ -69,6 +68,20 @@ func Update(ctx context.Context, runner Runner, request Request) (result Result,
|
||||
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) {
|
||||
if request.StatePath == "" {
|
||||
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 {
|
||||
return Result{StatePath: request.StatePath}, ErrConfirmationRequired
|
||||
}
|
||||
if !versionPattern.MatchString(request.Version) {
|
||||
return Result{StatePath: request.StatePath}, fmt.Errorf("%w: Pi version must be an explicit pinned version", ErrInvalidRequest)
|
||||
if _, err := parseSemanticVersion(request.Version); err != nil {
|
||||
return Result{StatePath: request.StatePath}, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||
}
|
||||
if request.Source == "" {
|
||||
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()
|
||||
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"),
|
||||
Version: "0.81.0",
|
||||
Source: BuildSource,
|
||||
Confirm: true,
|
||||
})
|
||||
}, "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) {
|
||||
return "0.81.0", nil
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if 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, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core")
|
||||
assertNotCalled(t, fake.calls, "frontend")
|
||||
@@ -81,6 +85,13 @@ func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *t
|
||||
if selected := readSelectorReference(t, currentImageOverridePath(result.StatePath)); 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) {
|
||||
|
||||
Reference in New Issue
Block a user