Skip to content
Merged
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 deployment-files/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
DB_DSN: "${DB_DSN:-}"
ENCRYPT_SERVICE_MASTER_KEY: "${ENCRYPT_SERVICE_MASTER_KEY}"
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-true}"
UPDATES_ENABLED: "${UPDATES_ENABLED:-true}"
PLUGINS_DIR: "/app/plugins"
PLUGINS_ENABLED: "true"
ENABLE_VIRTUAL_MINERS: "${ENABLE_VIRTUAL_MINERS:-false}"
Expand Down

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions server/cmd/fleetd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/block/proto-fleet/server/internal/domain/telemetry"
"github.com/block/proto-fleet/server/internal/domain/telemetry/scheduler"
"github.com/block/proto-fleet/server/internal/domain/token"
"github.com/block/proto-fleet/server/internal/domain/updates"
"github.com/block/proto-fleet/server/internal/infrastructure/db"
"github.com/block/proto-fleet/server/internal/infrastructure/encrypt"
"github.com/block/proto-fleet/server/internal/infrastructure/files"
Expand Down Expand Up @@ -54,6 +55,7 @@ type Config struct {
Plugins plugins.Config `embed:"" prefix:"plugins-" envprefix:"PLUGINS_"`
IPScanner ipscanner.Config `embed:"" prefix:"ipscanner-" envprefix:"IPSCANNER_"`
Diagnostics diagnostics.Config `embed:"" prefix:"diagnostics-" envprefix:"DIAGNOSTICS_"`
Updates updates.Config `embed:"" prefix:"updates-" envprefix:"UPDATES_"`
Comment thread
mcharles-square marked this conversation as resolved.
Infrastructure infrastructureDomain.Config `embed:"" prefix:"infrastructure-" envprefix:"INFRASTRUCTURE_"`
Files files.Config `embed:"" prefix:"files-" envprefix:"FILES_"`
FleetTelemetry fleet_telemetry.Config `embed:"" prefix:"fleet-telemetry-" envprefix:"FLEET_TELEMETRY_"`
Expand Down
26 changes: 25 additions & 1 deletion server/cmd/fleetd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import (
"github.com/block/proto-fleet/server/generated/grpc/fleetnodegateway/v1/fleetnodegatewayv1connect"
"github.com/block/proto-fleet/server/generated/grpc/foremanimport/v1/foremanimportv1connect"
"github.com/block/proto-fleet/server/generated/grpc/infrastructure/v1/infrastructurev1connect"
"github.com/block/proto-fleet/server/generated/grpc/instance/v1/instancev1connect"
"github.com/block/proto-fleet/server/generated/grpc/minercommand/v1/minercommandv1connect"
"github.com/block/proto-fleet/server/generated/grpc/networkinfo/v1/networkinfov1connect"
"github.com/block/proto-fleet/server/generated/grpc/onboarding/v1/onboardingv1connect"
Expand Down Expand Up @@ -95,6 +96,7 @@ import (
"github.com/block/proto-fleet/server/internal/domain/telemetry"
"github.com/block/proto-fleet/server/internal/domain/telemetry/scheduler"
tokenDomain "github.com/block/proto-fleet/server/internal/domain/token"
updatesDomain "github.com/block/proto-fleet/server/internal/domain/updates"
"github.com/block/proto-fleet/server/internal/ha"
activityHandler "github.com/block/proto-fleet/server/internal/handlers/activity"
"github.com/block/proto-fleet/server/internal/handlers/alertmanagerwebhook"
Expand Down Expand Up @@ -126,6 +128,7 @@ import (
sitemapHandler "github.com/block/proto-fleet/server/internal/handlers/sitemap"
sitesHandler "github.com/block/proto-fleet/server/internal/handlers/sites"
telemetryHandler "github.com/block/proto-fleet/server/internal/handlers/telemetry"
updatesHandler "github.com/block/proto-fleet/server/internal/handlers/updates"
"github.com/block/proto-fleet/server/internal/infrastructure/db"
"github.com/block/proto-fleet/server/internal/infrastructure/mqttclient"
"github.com/block/proto-fleet/server/internal/infrastructure/server"
Expand Down Expand Up @@ -172,6 +175,7 @@ var reflectEnabledServices = []string{
sitemapv1connect.SiteMapServiceName,
curtailmentv1connect.CurtailmentServiceName,
device_setv1connect.DeviceSetServiceName,
instancev1connect.InstanceUpdateServiceName,
}

func start(config *Config) error {
Expand Down Expand Up @@ -600,6 +604,18 @@ func start(config *Config) error {
alertsDeliverer := alertsDomain.NewDeliverer(alertChannelStore, alertRouteStore, encryptSvc, alertChannelStore, config.Metrics.AlertDestinations, config.PublicURL)
alertsSvc := alertsDomain.NewService(grafanaClient, alertChannelStore, alertRouteStore, encryptSvc, alertsDeliverer, config.Metrics.AlertDestinations)

// Both updates URLs end up inside a copy-paste upgrade command, so an
// http:// base must fail startup (explicit Validate, like Plugins above —
// kong only auto-validates flag leaves, not embedded config structs).
if err := config.Updates.Validate(); err != nil {
return fmt.Errorf("invalid updates configuration: %w", err)
}
// The checker is constructed even when disabled: the updates service still
// answers version/status calls, reading the zero snapshot as "no offer".
releaseChecker := updatesDomain.NewChecker(config.Updates, version)
updatesSvc := updatesDomain.NewService(config.Updates, version, releaseChecker,
db.NewFailoverResettingQuerier(db.NewRetryDB(conn)))

// The public listener is bound before this group starts. This channel keeps
// the first system heartbeat from clearing its stale alert before then.
listenerBound := make(chan struct{})
Expand All @@ -619,6 +635,12 @@ func start(config *Config) error {
chunkedUploadCleanup := newBackgroundLoop(func(ctx context.Context) {
chunkedMgr.StartCleanup(ctx, config.Files.ChunkedUploadSessionTTL)
})
// nil-when-disabled mirrors systemMonitoring: newRuntimeJobs skips
// optional jobs entirely instead of starting a lifecycle that no-ops.
var releaseCheckerJob runtimejobs.Lifecycle
if config.Updates.Enabled {
releaseCheckerJob = releaseChecker
}
jobs, err := newRuntimeJobs(runtimeJobLifecycles{
identityStateCleanup: identityStateCleanup,
commandArtifactCleanup: commandArtifactCleanup,
Expand All @@ -632,6 +654,7 @@ func start(config *Config) error {
curtailmentAlertMetrics: curtailmentAlertMetrics,
chunkedUploadCleanup: chunkedUploadCleanup,
systemMonitoring: systemMonitoring,
releaseChecker: releaseCheckerJob,
})
if err != nil {
return err
Expand Down Expand Up @@ -742,6 +765,8 @@ func start(config *Config) error {
// nav only when the sidecar this feature proxies is actually enabled.
mux.Handle("GET /api/v1/alerts/enabled", activeHTTP.Wrap(alertsHandler.NewEnabledHandler(config.Metrics.Enabled)))

mux.Handle(instancev1connect.NewInstanceUpdateServiceHandler(updatesHandler.NewHandler(updatesSvc), li))

if config.HTTP.PprofAddr != "" {
ln, err := net.Listen("tcp", config.HTTP.PprofAddr)
if err != nil {
Expand Down Expand Up @@ -777,7 +802,6 @@ func start(config *Config) error {
Handler: handler,
ReadHeaderTimeout: config.HTTP.ReadHeaderTimeout,
}

listener, err := net.Listen("tcp", config.HTTP.Address)
if err != nil {
return fmt.Errorf("listen on %s: %w", config.HTTP.Address, err)
Expand Down
6 changes: 6 additions & 0 deletions server/cmd/fleetd/runtime_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ type runtimeJobLifecycles struct {
curtailmentAlertMetrics runtimejobs.Lifecycle
chunkedUploadCleanup runtimejobs.Lifecycle
systemMonitoring runtimejobs.Lifecycle
releaseChecker runtimejobs.Lifecycle
}

func newRuntimeJobs(lifecycles runtimeJobLifecycles) ([]runtimejobs.Job, error) {
Expand Down Expand Up @@ -250,6 +251,11 @@ func newRuntimeJobs(lifecycles runtimeJobLifecycles) ([]runtimejobs.Job, error)
return nil, err
}
}
if lifecycles.releaseChecker != nil {
if err := add("release-checker", lifecycles.releaseChecker); err != nil {
return nil, err
}
}

return jobs, nil
}
3 changes: 3 additions & 0 deletions server/cmd/fleetd/runtime_jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ func TestNewRuntimeJobs(t *testing.T) {
curtailmentAlertMetrics: noopLifecycle{},
chunkedUploadCleanup: noopLifecycle{},
systemMonitoring: noopLifecycle{},
releaseChecker: noopLifecycle{},
}

jobs, err := newRuntimeJobs(all)
Expand All @@ -93,10 +94,12 @@ func TestNewRuntimeJobs(t *testing.T) {
"curtailment-alert-metrics",
"chunked-upload-cleanup",
"system-monitoring",
"release-checker",
}, jobNames(jobs))

all.curtailmentAlertMetrics = nil
all.systemMonitoring = nil
all.releaseChecker = nil
jobs, err = newRuntimeJobs(all)
require.NoError(t, err)
require.Equal(t, []string{
Expand Down
15 changes: 10 additions & 5 deletions server/internal/domain/updates/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func newChecker(cfg Config, releasesAPIURL, serverVersion string, logger *slog.L
return &Checker{
cfg: cfg,
logger: logger,
client: newGitHubClient(releasesAPIURL, serverVersion, cfg.GitHubToken, logger),
client: newGitHubClient(releasesAPIURL, serverVersion, logger),
revalidationFailures: make(map[string]int),
}
}
Expand Down Expand Up @@ -204,12 +204,20 @@ func (c *Checker) run(ctx context.Context) {
func (c *Checker) checkSafely(ctx context.Context) {
defer func() {
if recovered := recover(); recovered != nil {
c.markUnavailable()
c.logger.Error("release check panicked", "panic", recovered, "stack", string(debug.Stack()))
}
}()
c.check(ctx)
}

func (c *Checker) markUnavailable() {
c.mu.Lock()
c.snapshot.StableAvailable = false
c.snapshot.RCAvailable = false
c.mu.Unlock()
}

// jitteredInterval subtracts 10-20% random jitter from the configured
// interval. Subtract-only: it staggers fleets restarted together without ever
// stretching the worst-case gap past the configured interval.
Expand All @@ -234,10 +242,7 @@ func (c *Checker) check(ctx context.Context) {
list, err := c.client.fetchReleases(ctx)
if err != nil {
c.logFetchFailure("release check skipped", err)
c.mu.Lock()
c.snapshot.StableAvailable = false
c.snapshot.RCAvailable = false
c.mu.Unlock()
c.markUnavailable()
return
}
previous := c.Snapshot()
Expand Down
31 changes: 19 additions & 12 deletions server/internal/domain/updates/checker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ func TestReleasePageRejectsMoreThanPageSizeEntries(t *testing.T) {

gh := newGHServer(t)
gh.setList(http.StatusOK, releasesJSON(t, nightlies(releasesPageSize+1, "oversized")))
client := newGitHubClient(gh.srv.URL, "test-version", "", slog.Default())
client := newGitHubClient(gh.srv.URL, "test-version", slog.Default())

_, err := client.fetchReleases(context.Background())
require.Error(t, err)
Expand All @@ -522,17 +522,16 @@ func TestLatestReleaseRejectsTrailingJSON(t *testing.T) {
gh := newGHServer(t)
body := append(fixture(t, "latest_stable.json"), []byte("\n{}")...)
gh.setLatest(http.StatusOK, body)
client := newGitHubClient(gh.srv.URL, "test-version", "", slog.Default())
client := newGitHubClient(gh.srv.URL, "test-version", slog.Default())

_, err := client.fetchLatestStableFallback(context.Background())
require.Error(t, err)
assert.ErrorContains(t, err, "unexpected trailing JSON value")
}

func TestAuthenticatedConditionalRequestsReuseCachedResponses(t *testing.T) {
func TestConditionalRequestsReuseCachedResponses(t *testing.T) {
t.Parallel()

const token = "release-check-token"
var mu sync.Mutex
requests := make([]ghRequest, 0, 4)
counts := make(map[string]int)
Expand Down Expand Up @@ -560,7 +559,7 @@ func TestAuthenticatedConditionalRequestsReuseCachedResponses(t *testing.T) {
}))
t.Cleanup(srv.Close)

client := newGitHubClient(srv.URL, "test-version", token, slog.Default())
client := newGitHubClient(srv.URL, "test-version", slog.Default())
firstLatest, err := client.fetchLatestStableFallback(context.Background())
require.NoError(t, err)
secondLatest, err := client.fetchLatestStableFallback(context.Background())
Expand All @@ -576,9 +575,6 @@ func TestAuthenticatedConditionalRequestsReuseCachedResponses(t *testing.T) {
mu.Lock()
defer mu.Unlock()
require.Len(t, requests, 4)
for _, request := range requests {
assert.Equal(t, "Bearer "+token, request.header.Get("Authorization"))
}
assert.Empty(t, requests[0].header.Get("If-None-Match"))
assert.Equal(t, `"releases/latest-etag"`, requests[1].header.Get("If-None-Match"))
assert.Empty(t, requests[2].header.Get("If-None-Match"))
Expand Down Expand Up @@ -654,7 +650,7 @@ func TestReleaseByTagRequiresMatchingStrictResponse(t *testing.T) {

gh := newGHServer(t)
gh.setTag("v2.0.0", tt.status, tt.body)
client := newGitHubClient(gh.srv.URL, "test-version", "", slog.Default())
client := newGitHubClient(gh.srv.URL, "test-version", slog.Default())

rel, found, err := client.fetchReleaseByTag(context.Background(), "v2.0.0")
if tt.wantErr != "" {
Expand All @@ -676,7 +672,7 @@ func TestHTTPTransportErrorDoesNotExposeRequestURL(t *testing.T) {
t.Parallel()

const sensitiveURL = "https://user:password@example.com/releases?token=secret"
client := newGitHubClient("https://example.com", "test-version", "", slog.Default())
client := newGitHubClient("https://example.com", "test-version", slog.Default())
client.httpClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, &url.Error{
Op: http.MethodGet,
Expand Down Expand Up @@ -1125,15 +1121,15 @@ func TestNotesURLUsesCanonicalRepositoryAndTag(t *testing.T) {
assert.Empty(t, releaseNotesURL("v1.2.3/../../phishing"))
}

func TestCheckSafelyRecoversAndAllowsNextCycle(t *testing.T) {
func TestCheckSafelyInvalidatesPrimedSnapshotAndAllowsNextCycle(t *testing.T) {
t.Parallel()

gh := newGHServer(t)
c, h := newTestChecker(t, gh.config(), gh.srv.URL)
calls := 0
c.client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls == 1 {
if calls == 3 {
panic("unexpected transport defect")
}
body := []byte("[]")
Expand All @@ -1148,11 +1144,22 @@ func TestCheckSafelyRecoversAndAllowsNextCycle(t *testing.T) {
}, nil
})

c.checkSafely(context.Background())
primed := c.Snapshot()
require.True(t, primed.StableAvailable)
require.True(t, primed.RCAvailable)
require.NotNil(t, primed.LatestStable)

assert.NotPanics(t, func() { c.checkSafely(context.Background()) })
records := h.recordsAbove(slog.LevelDebug)
require.Len(t, records, 1)
assert.Equal(t, slog.LevelError, records[0].Level)
assert.Equal(t, "release check panicked", records[0].Message)
afterPanic := c.Snapshot()
assert.False(t, afterPanic.StableAvailable)
assert.False(t, afterPanic.RCAvailable)
assert.Equal(t, primed.LatestStable, afterPanic.LatestStable,
"panic recovery should retain cached data while making it ineligible")

c.checkSafely(context.Background())
stable, available := c.Snapshot().EligibleStable()
Expand Down
1 change: 0 additions & 1 deletion server/internal/domain/updates/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ type Config struct {
CheckInterval time.Duration `help:"Maximum interval between GitHub release checks" default:"1h" env:"CHECK_INTERVAL"`
DownloadBaseURL string `help:"Allowlisted base URL release artifacts are downloaded from" default:"https://github.com/block/proto-fleet/releases/download" env:"DOWNLOAD_BASE_URL"`
Enabled bool `help:"Enable release update checks" default:"true" env:"ENABLED"`
GitHubToken string `help:"Optional GitHub token for authenticated, conditional release checks" default:"" env:"GITHUB_TOKEN"`
}

// Validate validates the configuration. DownloadBaseURL ends up in a
Expand Down
7 changes: 1 addition & 6 deletions server/internal/domain/updates/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ type githubRelease struct {
type githubClient struct {
baseURL string
userAgent string
token string
logger *slog.Logger
httpClient *http.Client

Expand All @@ -66,11 +65,10 @@ func (e *githubRateLimitError) Error() string {
return "GitHub API rate limit exceeded"
}

func newGitHubClient(baseURL, serverVersion, token string, logger *slog.Logger) *githubClient {
func newGitHubClient(baseURL, serverVersion string, logger *slog.Logger) *githubClient {
return &githubClient{
baseURL: strings.TrimRight(baseURL, "/"),
userAgent: "fleetd/" + serverVersion,
token: token,
logger: logger,
httpClient: &http.Client{Timeout: githubHTTPTimeout},
}
Expand Down Expand Up @@ -230,9 +228,6 @@ func (c *githubClient) get(ctx context.Context, endpoint, etag string) (*http.Re
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", githubMediaType)
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
Expand Down
Loading
Loading