Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,4 @@ tests/integration/local/logs/
tests/integration/local/.runs/
tests/integration/logs-run.out
tests/integration/logs-run*.out
.gstack/
51 changes: 51 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"log"
"log/slog"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -568,6 +569,23 @@ func main() {
}()
}

if enterpriseControls.HasAppReconcile() {
appInstaller := enterprisecontrol.PilotctlInstaller{BinaryPath: pilotctlBinaryPath()}
reconcileApps(fleetControlCtx, enterpriseControls, appInstaller)
go func() {
ticker := time.NewTicker(enterpriseControls.AppReconcileInterval())
defer ticker.Stop()
for {
select {
case <-fleetControlCtx.Done():
return
case <-ticker.C:
reconcileApps(fleetControlCtx, enterpriseControls, appInstaller)
}
}
}()
}

receiptExportCtx, receiptExportCancel := context.WithCancel(context.Background())
if enterpriseControls.HasReceiptExport() {
if err := enterpriseControls.ExportReceiptsOnce(receiptExportCtx); err != nil {
Expand Down Expand Up @@ -769,6 +787,39 @@ func synchronizeFleetControl(ctx context.Context, controls *enterprisecontrol.Ru
}
}

// pilotctlBinaryPath resolves the pilotctl that ships beside this daemon.
// Preferring the sibling binary over $PATH keeps the verified install path
// pinned to the same release as the daemon rather than to whatever a user
// happens to have earlier in their environment.
func pilotctlBinaryPath() string {
if executable, err := os.Executable(); err == nil {
sibling := filepath.Join(filepath.Dir(executable), "pilotctl")
if info, statErr := os.Stat(sibling); statErr == nil && !info.IsDir() {
return sibling
}
}
if resolved, err := exec.LookPath("pilotctl"); err == nil {
return resolved
}
return "pilotctl"
}

// reconcileApps converges installed apps toward the authority's desired set.
// A failure here must never disturb policy enforcement or the state mirror, so
// it is logged and retried on the next tick rather than propagated.
func reconcileApps(ctx context.Context, controls *enterprisecontrol.Runtime, installer enterprisecontrol.AppInstaller) {
result, err := controls.ReconcileApps(ctx, installer)
if err != nil {
slog.Warn("managed app reconcile failed", "err", err)
return
}
if result.Installed+result.Staged+result.Removed+result.Failed > 0 {
slog.Info("managed apps reconciled",
"desired", result.Desired, "installed", result.Installed,
"awaiting_grants", result.Staged, "removed", result.Removed, "failed", result.Failed)
}
}

func synchronizeFleetState(ctx context.Context, controls *enterprisecontrol.Runtime) {
result, err := controls.SyncFleetState(ctx)
if err != nil {
Expand Down
24 changes: 21 additions & 3 deletions cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -1037,16 +1037,26 @@
func cmdAppStoreInstall(args []string) {
if len(args) < 1 {
fatalHint("invalid_argument",
"usage: pilotctl appstore install <app-id-or-dir> [--force] [--local]",
"usage: pilotctl appstore install <app-id-or-dir> [--force] [--local] [--version <v>]",
"missing app id or bundle dir")
}
target := args[0]
force := false
wantVersion := ""
allowLocal := false
for i := 1; i < len(args); i++ {
switch args[i] {
case "--force", "-f":
force = true
case "--version":
// Read the value before advancing so the bound is checked against
// the index actually used.
if i+1 >= len(args) {
fatalHint("invalid_argument", "usage: --version <exact-catalogue-version>", "--version needs a value")
return
}
wantVersion = args[i+1]
i++
case "--local":
// Required acknowledgement when installing from a local
// directory. Catalogue installs ignore this; path installs
Expand All @@ -1056,7 +1066,7 @@
allowLocal = true
default:
fatalHint("invalid_argument",
"available flags: --force, --local",
"available flags: --force, --local, --version",
"unknown install flag: %s", args[i])
}
}
Expand All @@ -1065,8 +1075,16 @@
// Catalogue path = signed, runs the standard signature gate.
// Local path = sideload, requires --local AND must satisfy the
// sideload allow-list before the supervisor will load it.
bundleDir, source, err := resolveInstallTarget(target)
bundleDir, source, err := resolveInstallTargetVersion(target, wantVersion)
if err != nil {
if errors.Is(err, ErrCatalogueVersionUnavailable) {
// Distinct from a bad argument: the caller asked for a version the
// catalogue no longer carries. Installing whatever is current
// instead would hand a node software nobody approved.
fatalHint("version_unavailable",
"the catalogue carries only each app's current release; re-approve the app to move the pinned version forward",
"%v", err)
}
fatalHint("invalid_argument",
"the argument must be either a catalogue ID (`pilotctl appstore catalogue` to list) or a path to a bundle dir containing manifest.json",
"%v", err)
Expand Down
31 changes: 31 additions & 0 deletions cmd/pilotctl/appstore_catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,37 @@ const (
// `target` matches a catalogue ID, the catalogue entry is fetched,
// verified, and unpacked. Otherwise `target` is treated as a local
// path and the caller is expected to apply sideload policy.
// ErrCatalogueVersionUnavailable means the catalogue no longer offers the
// version the caller pinned. The catalogue carries only the current release of
// each app, so a pin that has moved on cannot be satisfied — and installing the
// version that happens to be current instead would silently give a node
// software its operator never approved.
var ErrCatalogueVersionUnavailable = errors.New("catalogue does not offer the requested version")

// resolveInstallTargetVersion resolves target, and when wantVersion is set it
// refuses any catalogue entry that does not match it exactly.
func resolveInstallTargetVersion(target, wantVersion string) (string, installSource, error) {
if strings.TrimSpace(wantVersion) == "" {
return resolveInstallTarget(target)
}
c, err := loadCatalogue()
if err == nil {
for _, e := range c.Apps {
if target != e.ID {
continue
}
if e.Version != wantVersion {
return "", installSourceCatalogue, fmt.Errorf("%w: %s offers %q, not %q", ErrCatalogueVersionUnavailable, e.ID, e.Version, wantVersion)
}
dir, fetchErr := fetchAndUnpackBundle(e)
return dir, installSourceCatalogue, fetchErr
}
}
// A pinned version only means anything against the signed catalogue; a
// local sideload has no version to check it against.
return "", installSourceLocal, fmt.Errorf("%w: %s is not in the catalogue", ErrCatalogueVersionUnavailable, target)
}

func resolveInstallTarget(target string) (string, installSource, error) {
c, err := loadCatalogue()
if err != nil {
Expand Down
16 changes: 16 additions & 0 deletions cmd/pilotctl/enterprise_adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,22 @@ func installEnrolledAttachment(outputDirectory string, claim authorityhttp.NodeE
config.Fleet.StateDirectory = "state"
}
}
// App reconciliation converges the installed set toward the desired
// document the App Store writes into fleet state. It is gated on state
// sync because both the desired document and the observed report travel
// as fleet state; without it the node would accept the option and then
// silently never reconcile.
if claim.Options.Apps && claim.Options.FleetControl && claim.Options.StateSync {
config.Apps = &enterprisecontrol.AppsConfig{
Enabled: true,
// Left to the runtime defaults: apps install under ~/.pilot/apps
// and stage under ~/.pilot/apps-pending, which the runtime already
// enforces as siblings so a staged app is never inside the root the
// supervisor scans and can never be started before its grants are
// accepted.
ReconcileIntervalSeconds: 30,
}
}
controlPath := filepath.Join(stage, "enterprise-control.json")
if err := writeAdoptionJSON(controlPath, config); err != nil {
return "", err
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/pilot-protocol/pilotprotocol

go 1.25.12
go 1.25.13

require (
github.com/coder/websocket v1.8.15
Expand Down
43 changes: 43 additions & 0 deletions internal/enterprisecontrol/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Config struct {
Receipts *ReceiptConfig `json:"receipts,omitempty"`
Rollout *RolloutConfig `json:"rollout,omitempty"`
Fleet *FleetConfig `json:"fleet,omitempty"`
Apps *AppsConfig `json:"apps,omitempty"`
OutboundDecisions *OutboundDecisionConfig `json:"outbound_decisions,omitempty"`
ActionControl *ActionControlConfig `json:"action_control,omitempty"`
ContentInspection *ContentInspectionConfig `json:"content_inspection,omitempty"`
Expand Down Expand Up @@ -277,6 +278,12 @@ type Runtime struct {
fleetStateRevision uint64
fleetStateRootHash string
fleetStatePendingResults []authority.FleetStateMutationResult
appsEnabled bool
appsInterval time.Duration
appsInstallRoot string
appsStagingRoot string
appsMu sync.Mutex
appsManaged map[string]struct{}
outboundClient *decisionhttp.Client
outboundAgentID string
outboundKeyID string
Expand Down Expand Up @@ -636,6 +643,42 @@ func Load(path string) (*Runtime, error) {
}
}
}
if config.Apps != nil && config.Apps.Enabled {
// Apps ride on the state mirror: the desired document arrives as an
// ordinary signed state mutation, so without state sync there is no
// channel to receive one and nothing to reconcile toward.
if !runtime.fleetStateEnabled {
return nil, fmt.Errorf("enterprise control: app reconciliation requires fleet state sync")
}
home, homeErr := os.UserHomeDir()
if homeErr != nil && (config.Apps.InstallRoot == "" || config.Apps.StagingRoot == "") {
return nil, fmt.Errorf("enterprise control: app roots: %w", homeErr)
}
installRoot := strings.TrimSpace(config.Apps.InstallRoot)
if installRoot == "" {
installRoot = filepath.Join(home, ".pilot", "apps")
}
stagingRoot := strings.TrimSpace(config.Apps.StagingRoot)
if stagingRoot == "" {
// Deliberately a sibling of the install root, never inside it: the
// supervisor scans the install root, and an app awaiting grant
// acceptance must be somewhere it will not be spawned from.
stagingRoot = filepath.Join(home, ".pilot", "apps-pending")
}
if installRoot == stagingRoot {
return nil, fmt.Errorf("enterprise control: app staging_root must differ from install_root")
}
if withinDirectory(installRoot, stagingRoot) {
return nil, fmt.Errorf("enterprise control: app staging_root must not sit inside install_root")
}
runtime.appsEnabled = true
runtime.appsInstallRoot, runtime.appsStagingRoot = installRoot, stagingRoot
runtime.appsManaged = make(map[string]struct{})
runtime.appsInterval = time.Duration(config.Apps.ReconcileIntervalSeconds) * time.Second
if runtime.appsInterval == 0 {
runtime.appsInterval = 30 * time.Second
}
}
if config.DataExchange != nil {
runtime.dataEnabled = true
runtime.dataRequired = config.DataExchange.RequireGoverned
Expand Down
Loading
Loading