Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ CLI (cmd/daemon.go) ─┘ version, sysext, systemd
### Testing patterns

- Mock interfaces for system commands: `sysext.SysextRunner`, `systemd.SystemctlRunner`
- `ClientConfig.SysextRunner` field for injecting mocks into the SDK client — `NewClient` stores the runner directly on the `Client` struct (does not mutate global state)
- `ClientConfig.SysextRunner` field for injecting mocks into the SDK client — `NewClient` stores the runner directly on the `Client` struct (does not mutate global state). An injected runner must implement `sysext.PathSysextRunner` (`SysextRunner` plus `LinkToSysextAt`) to be usable for a real install: the client links only through that method with its captured `SysextLinkDir`, and refuses a `SysextRunner`-only runner with `updex.ErrLegacySysextRunner` before mutating anything rather than falling back to the package-global `sysext.SysextDir`. `sysext.MockRunner` implements it and records the directory it was given in `LinkToSysextAtDir`; `updex/install_link_test.go` pins the refusal.
- `ClientConfig.SystemdManager` for injecting a `systemd.NewTestManager`
rooted at `t.TempDir()` with a mock `SystemctlRunner`; daemon SDK tests use
this instead of touching real units or invoking `systemctl`
Expand Down
16 changes: 16 additions & 0 deletions docs/specs/sdk-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ directory. `sysext.GetActiveVersionIn` additionally receives the captured
merged-image directory. The original package functions remain compatibility
wrappers over their package variables or production constants.

An injected `SysextRunner` is the one place that invariant depends on the
caller. The client links through `sysext.PathSysextRunner`
(`SysextRunner` plus `LinkToSysextAt(*config.Transfer, string) error`),
handing it the captured `SysextLinkDir`. A runner that implements only the
original four-method `SysextRunner` cannot be told a directory, so the client
does **not** fall back to `sysext.SysextDir` for it: every operation that may
link refuses such a runner with `updex.ErrLegacySysextRunner` (testable with
`errors.Is`) **before** it mutates anything —
`CatalogAdd` before it writes a definition, `EnableFeature` with `Now` before
it writes the drop-in, and `installTransfer` before it removes a legacy
symlink or downloads. Dry runs never link and stay available to any runner.
The `SysextRunner` interface itself is unchanged, so existing implementations
still compile; adding `LinkToSysextAt` is what makes one usable for a real
install. `sysext.DefaultRunner` and `sysext.MockRunner` both implement it.

Other fields: if `SysextRunner` is nil it defaults to `&sysext.DefaultRunner{}`; if `SystemdManager` is nil it defaults to `systemd.NewManager()` for `/etc/systemd/system` and the real `systemctl`; if `Progress` is nil it defaults to `reporter.NoopReporter{}`; if `HTTPClient` is nil a default `http.Client` with a 10-minute timeout, the standard 10-redirect limit, and an HTTPS-to-HTTP downgrade refusal is created via `internal/httpclient.New` — the same constructor `manifest.Fetch` and `download.Download` fall back to when their own `httpClient` parameter is nil, so the refusal is enforced consistently everywhere a caller supplies no client. HTTP-to-HTTP and HTTPS-to-HTTPS redirects remain allowed. A caller-supplied `HTTPClient` is stored unchanged, including its redirect policy. `OnDownloadProgress` is called with the HTTP response content length (-1 if unknown) and must return a fresh `io.Writer` per attempt to avoid double-counting retried downloads.

## Methods
Expand Down Expand Up @@ -467,6 +482,7 @@ recorded in [ADR-0008](../adr/0008-bounded-retry-no-resume.md).
### `sysext`

- `SysextRunner` interface — `Refresh()`, `Merge()`, `Unmerge()`, `LinkToSysext(*config.Transfer)` methods executed via `DefaultRunner` (real commands) or `MockRunner` (tests)
- `PathSysextRunner` interface — `SysextRunner` plus `LinkToSysextAt(*config.Transfer, sysextDir string) error`. `updex.Client` links only through this interface, with the `SysextLinkDir` it captured at construction; a runner implementing only `SysextRunner` is refused with `updex.ErrLegacySysextRunner` rather than redirected to the package-global `SysextDir`. `DefaultRunner` and `MockRunner` both implement it; `MockRunner.LinkToSysextAtDir` records the directory it was given (and `LinkToSysextCalled` is set by either entry point)
- `GetInstalledVersions(t *config.Transfer) ([]string, string, error)` — List installed + current version
- `GetActiveVersion(t *config.Transfer) (string, error)` — Get the version considered active by updex: first a legacy `CurrentSymlink`, then an image name in `RunExtensionsDir` (`/run/extensions`)
- `GetActiveVersionIn(t *config.Transfer, defaultDir, runExtensionsDir string) (string, error)` — Explicit-directory variant used by `updex.Client`; the sysext link directory (`/var/lib/extensions`) is only the fallback for locating a legacy `CurrentSymlink`, not evidence that an image is merged
Expand Down
37 changes: 30 additions & 7 deletions sysext/mock_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,33 @@ package sysext

import "github.com/frostyard/updex/config"

// MockRunner is a test double for SysextRunner
// MockRunner is a test double for SysextRunner. It implements
// PathSysextRunner too, so an updex.Client links through
// LinkToSysextAt with the directory the client captured at construction —
// exactly like DefaultRunner and unlike a runner that predates
// PathSysextRunner, which the SDK now refuses rather than silently
// redirecting to the package-global SysextDir.
type MockRunner struct {
RefreshCalled bool
RefreshErr error
MergeCalled bool
MergeErr error
UnmergeCalled bool
UnmergeErr error
RefreshCalled bool
RefreshErr error
MergeCalled bool
MergeErr error
UnmergeCalled bool
UnmergeErr error
// LinkToSysextCalled records a link through either entry point.
LinkToSysextCalled bool
LinkToSysextErr error
// LinkToSysextAtDir records the directory the last LinkToSysextAt call
// was given. It stays empty when only the pathless LinkToSysext was
// called, so a test can tell the two apart.
LinkToSysextAtDir string
}

// MockRunner is a PathSysextRunner: a compile-time reminder that dropping
// LinkToSysextAt would turn every client using it into a refused legacy
// runner.
var _ PathSysextRunner = (*MockRunner)(nil)

func (m *MockRunner) Refresh() error {
m.RefreshCalled = true
return m.RefreshErr
Expand All @@ -33,3 +48,11 @@ func (m *MockRunner) LinkToSysext(_ *config.Transfer) error {
m.LinkToSysextCalled = true
return m.LinkToSysextErr
}

// LinkToSysextAt records sysextDir and reports LinkToSysextErr. It creates
// no link: callers that need a real one inject DefaultRunner instead.
func (m *MockRunner) LinkToSysextAt(_ *config.Transfer, sysextDir string) error {
m.LinkToSysextCalled = true
m.LinkToSysextAtDir = sysextDir
return m.LinkToSysextErr
}
10 changes: 9 additions & 1 deletion updex/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ func (c *Client) CatalogAdd(ctx context.Context, name string, opts CatalogAddOpt
if err := catalog.ValidateSysextName(name); err != nil {
return nil, err
}
// A real add ends in EnableFeature(Now), so it ends in a link. Refuse a
// runner that cannot be told where to link before any definition is
// written, rather than part-way through the add's rollback transaction.
if !opts.DryRun {
if err := c.requireLinkableRunner(); err != nil {
return nil, err
}
}
repos, err := c.catalogRepos()
if err != nil {
return nil, err
Expand Down Expand Up @@ -304,7 +312,7 @@ func (c *Client) CatalogAdd(ctx context.Context, name string, opts CatalogAddOpt
if generatedTransfer == nil {
return fail(fmt.Errorf("generated transfer %q was not loadable", name))
}
state, err := snapshotCatalogManagedState(generatedTransfer, c.sysextLinkDirForRunner())
state, err := snapshotCatalogManagedState(generatedTransfer, c.paths.sysextLinkDir)
if err != nil {
return fail(fmt.Errorf("failed to snapshot catalog-managed install state: %w", err))
}
Expand Down
11 changes: 11 additions & 0 deletions updex/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ func (c *Client) EnableFeature(ctx context.Context, name string, opts EnableFeat
DryRun: opts.DryRun,
}

// --now installs, and every install ends in a link. Refuse a runner
// that cannot be told where to link before the drop-in is written, so
// the feature is not left enabled with nothing staged.
if opts.Now && !opts.DryRun {
if err := c.requireLinkableRunner(); err != nil {
result.Error = err.Error()
c.warn("%s", result.Error)
return result, err
}
}

features, transfers, err := c.loadDomain(opts.Component)
if err != nil {
result.Error = err.Error()
Expand Down
67 changes: 46 additions & 21 deletions updex/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package updex

import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
Expand All @@ -17,6 +18,16 @@ import (
// It returns the version selected, the resolved manifest, whether a download occurred, and any error.
// If opts.CachedManifest is non-nil, it is used instead of fetching the manifest over HTTP.
func (c *Client) installTransfer(ctx context.Context, transfer *config.Transfer, opts installTransferOptions) (string, *manifest.Manifest, bool, error) {
// A real install ends in a link. Refuse a runner that cannot be told
// where to link before removing a legacy symlink or downloading
// anything, so the failure leaves no half-installed state behind. A dry
// run never links, so it stays available.
if !opts.DryRun {
if err := c.requireLinkableRunner(); err != nil {
return "", nil, false, err
}
}

// Get available versions (applies MinVersion filter)
available, m, patterns, err := c.getAvailableVersions(ctx, transfer, opts.CachedManifest)
if err != nil {
Expand Down Expand Up @@ -124,34 +135,48 @@ func (c *Client) installTransfer(ctx context.Context, transfer *config.Transfer,
return versionToInstall, m, true, refreshErr
}

// ErrLegacySysextRunner reports a ClientConfig.SysextRunner that implements
// only the original [sysext.SysextRunner] interface and not
// [sysext.PathSysextRunner]. Such a runner cannot be told which directory to
// link into, so it can only link into the mutable package-global
// sysext.SysextDir — which would silently ignore
// RuntimePaths.SysextLinkDir and break the ADR-0011 capture-at-construction
// invariant. Operations that may link refuse with this error before touching
// the filesystem instead. Callers can test for it with errors.Is.
var ErrLegacySysextRunner = errors.New("sysext runner does not implement sysext.PathSysextRunner, so it cannot honor the client's SysextLinkDir")

// pathRunner returns the client's runner as a sysext.PathSysextRunner, or
// ErrLegacySysextRunner when the injected runner predates that interface.
func (c *Client) pathRunner() (sysext.PathSysextRunner, error) {
runner, ok := c.runner.(sysext.PathSysextRunner)
if !ok {
return nil, fmt.Errorf("%w: %T must add LinkToSysextAt(*config.Transfer, string) error", ErrLegacySysextRunner, c.runner)
}
return runner, nil
}

// requireLinkableRunner is the precondition every non-dry-run operation that
// may end up linking checks before it mutates anything, so a legacy runner
// fails cleanly rather than part-way through an install.
func (c *Client) requireLinkableRunner() error {
_, err := c.pathRunner()
return err
}

// linkToSysext points the systemd-sysext link for transfer at its newest
// staged image through the client's runner, in the client's link directory
// when the runner supports one.
// staged image through the client's runner, in the directory the client
// captured at construction.
func (c *Client) linkToSysext(transfer *config.Transfer) error {
var linkErr error
if runner, ok := c.runner.(sysext.PathSysextRunner); ok {
linkErr = runner.LinkToSysextAt(transfer, c.sysextLinkDirForRunner())
} else {
// Preserve compatibility with injected runners that implement the
// original SysextRunner interface.
linkErr = c.runner.LinkToSysext(transfer)
runner, err := c.pathRunner()
if err != nil {
return fmt.Errorf("failed to link to sysext: %w", err)
}
if linkErr != nil {
return fmt.Errorf("failed to link to sysext: %w", linkErr)
if err := runner.LinkToSysextAt(transfer, c.paths.sysextLinkDir); err != nil {
return fmt.Errorf("failed to link to sysext: %w", err)
}
return nil
}

// sysextLinkDirForRunner returns the directory that linkToSysext will
// actually mutate. Legacy runners use the package default because their
// interface cannot accept a client-specific path.
func (c *Client) sysextLinkDirForRunner() string {
if _, ok := c.runner.(sysext.PathSysextRunner); ok {
return c.paths.sysextLinkDir
}
return sysext.SysextDir
}

// buildTargetFilename derives the installed filename for a version from the
// target match patterns. Downloads are always stored decompressed, so it
// prefers the first pattern whose name carries no compression suffix; if every
Expand Down
Loading