From 8d26811669c26f32f09df4bbe472d01fc4c5a6dd Mon Sep 17 00:00:00 2001
From: Yassine Bounekhla <56373201+rudream@users.noreply.github.com>
Date: Sat, 24 Jan 2026 01:44:54 +0700
Subject: [PATCH] implement webapi for managed updates (#62909)
---
lib/autoupdate/report/report.go | 77 +++
lib/web/apiserver.go | 6 +
lib/web/managed_updates.go | 384 +++++++++++++++
lib/web/managed_updates_test.go | 620 +++++++++++++++++++++++++
lib/web/ui/managed_updates.go | 118 +++++
tool/tctl/common/autoupdate_command.go | 39 +-
6 files changed, 1210 insertions(+), 34 deletions(-)
create mode 100644 lib/autoupdate/report/report.go
create mode 100644 lib/web/managed_updates.go
create mode 100644 lib/web/managed_updates_test.go
create mode 100644 lib/web/ui/managed_updates.go
diff --git a/lib/autoupdate/report/report.go b/lib/autoupdate/report/report.go
new file mode 100644
index 00000000000..2ad9708c41f
--- /dev/null
+++ b/lib/autoupdate/report/report.go
@@ -0,0 +1,77 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+// Package report provides utilities for working with AutoUpdateAgentReport resources.
+package report
+
+import (
+ "fmt"
+ "slices"
+ "time"
+
+ autoupdatepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1"
+)
+
+// UserFriendlyState converts an AutoUpdateAgentGroupState or AutoUpdateAgentRolloutState
+// to a user-friendly string representation.
+func UserFriendlyState[T autoupdatepb.AutoUpdateAgentGroupState | autoupdatepb.AutoUpdateAgentRolloutState](state T) string {
+ switch state {
+ case 0:
+ return "Unknown"
+ case 1:
+ return "Unstarted"
+ case 2:
+ return "Active"
+ case 3:
+ return "Done"
+ case 4:
+ return "Rolledback"
+ case 5:
+ return "Canary"
+ default:
+ // If we don't know anything about this state, we display its integer
+ return fmt.Sprintf("Unknown state (%d)", state)
+ }
+}
+
+// ValidReports filters out stale reports and returns only valid ones.
+// Reports are generated every 1 minute, so any that are older than that must be stale.
+func ValidReports(reports []*autoupdatepb.AutoUpdateAgentReport, now time.Time) []*autoupdatepb.AutoUpdateAgentReport {
+ isStale := func(r *autoupdatepb.AutoUpdateAgentReport) bool {
+ return now.Sub(r.GetSpec().GetTimestamp().AsTime()) > time.Minute
+ }
+ return slices.DeleteFunc(slices.Clone(reports), isStale)
+}
+
+// AggregateVersionCounts aggregates agent reports into version counts by group
+func AggregateVersionCounts(reports []*autoupdatepb.AutoUpdateAgentReport) map[string]map[string]int {
+ out := make(map[string]map[string]int)
+
+ for _, report := range reports {
+ for groupName, group := range report.GetSpec().GetGroups() {
+ if out[groupName] == nil {
+ out[groupName] = make(map[string]int)
+ }
+ for version, versionCount := range group.GetVersions() {
+ out[groupName][version] += int(versionCount.GetCount())
+ }
+ }
+ }
+
+ return out
+}
diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index 6dc61e10085..7fd26090358 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -1181,6 +1181,12 @@ func (h *Handler) bindDefaultEndpoints() {
// Channel can contain "/", hence the use of a catch-all parameter
h.GET("/webapi/automaticupgrades/channel/*request", h.WithUnauthenticatedHighLimiter(h.automaticUpgrades109))
+ // Managed updates
+ h.GET("/webapi/managedupdates", h.WithAuth(h.getManagedUpdatesDetails))
+ h.POST("/webapi/managedupdates/groups/:groupName/start", h.WithAuth(h.startGroupUpdate))
+ h.POST("/webapi/managedupdates/groups/:groupName/done", h.WithAuth(h.markGroupDone))
+ h.POST("/webapi/managedupdates/groups/:groupName/rollback", h.WithAuth(h.rollbackGroup))
+
// GET Machine ID bot by name
h.GET("/webapi/sites/:site/machine-id/bot/:name", h.WithClusterAuth(h.getBot))
// GET Machine ID bots
diff --git a/lib/web/managed_updates.go b/lib/web/managed_updates.go
new file mode 100644
index 00000000000..3dca069423f
--- /dev/null
+++ b/lib/web/managed_updates.go
@@ -0,0 +1,384 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package web
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gravitational/trace"
+ "github.com/julienschmidt/httprouter"
+
+ "github.com/gravitational/teleport"
+ autoupdatepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/api/types/autoupdate"
+ "github.com/gravitational/teleport/api/utils/clientutils"
+ aur "github.com/gravitational/teleport/lib/autoupdate/report"
+ "github.com/gravitational/teleport/lib/httplib"
+ "github.com/gravitational/teleport/lib/itertools/stream"
+ "github.com/gravitational/teleport/lib/web/ui"
+)
+
+// getManagedUpdatesDetails returns managed updates details.
+func (h *Handler) getManagedUpdatesDetails(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (any, error) {
+ ctx := r.Context()
+ clt, err := sctx.GetClient()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ response := &ui.ManagedUpdatesDetails{}
+
+ autoUpdateConfig, err := clt.GetAutoUpdateConfig(ctx)
+ if err != nil {
+ if !trace.IsNotFound(err) {
+ return nil, trace.Wrap(err)
+ }
+ autoUpdateConfig = nil
+ }
+
+ autoUpdateVersion, err := clt.GetAutoUpdateVersion(ctx)
+ if err != nil {
+ if !trace.IsNotFound(err) {
+ return nil, trace.Wrap(err)
+ }
+ autoUpdateVersion = nil
+ }
+
+ response.Tools = getToolsInfo(autoUpdateConfig, autoUpdateVersion)
+
+ rollout, err := clt.GetAutoUpdateAgentRollout(ctx)
+ if err != nil {
+ if !trace.IsNotFound(err) {
+ return nil, trace.Wrap(err)
+ }
+ rollout = nil
+ }
+
+ if rollout != nil {
+ response.Rollout = getRolloutInfo(rollout)
+ }
+
+ reports, err := stream.Collect(clientutils.Resources(ctx, clt.ListAutoUpdateAgentReports))
+ if err != nil {
+ if !trace.IsNotFound(err) {
+ return nil, trace.Wrap(err)
+ }
+ reports = nil
+ }
+
+ // Filter and aggregate version counts from the reports
+ validReports := aur.ValidReports(reports, time.Now())
+ versionCountsByGroup := aur.AggregateVersionCounts(validReports)
+
+ if rollout != nil {
+ response.Groups = getGroupsInfo(rollout, versionCountsByGroup)
+ response.OrphanedAgentVersionCounts = getOrphanedAgentCounts(rollout, versionCountsByGroup)
+ }
+
+ // Get cluster maintenance info if this is a cloud cluster
+ if features := h.GetClusterFeatures(); features.GetCloud() {
+ maintenanceConfig, err := clt.GetClusterMaintenanceConfig(ctx)
+ if err != nil {
+ if !trace.IsNotFound(err) {
+ return nil, trace.Wrap(err)
+ }
+ maintenanceConfig = nil
+ }
+ if maintenanceConfig != nil {
+ response.ClusterMaintenance = getClusterMaintenanceInfo(maintenanceConfig)
+ }
+ }
+
+ return response, nil
+}
+
+// getToolsInfo builds the ToolsAutoUpdateInfo object.
+func getToolsInfo(config *autoupdatepb.AutoUpdateConfig, version *autoupdatepb.AutoUpdateVersion) *ui.ToolsAutoUpdateInfo {
+ var mode, targetVersion string
+
+ if config != nil {
+ mode = config.GetSpec().GetTools().GetMode()
+ }
+ if version != nil {
+ targetVersion = version.GetSpec().GetTools().GetTargetVersion()
+ }
+
+ // If empty, return nil
+ if mode == "" && targetVersion == "" {
+ return nil
+ }
+
+ return &ui.ToolsAutoUpdateInfo{
+ Mode: mode,
+ TargetVersion: targetVersion,
+ }
+}
+
+// getRolloutInfo builds the RolloutInfo object.
+func getRolloutInfo(rollout *autoupdatepb.AutoUpdateAgentRollout) *ui.RolloutInfo {
+ if rollout == nil || rollout.GetSpec() == nil {
+ return nil
+ }
+
+ spec := rollout.GetSpec()
+ status := rollout.GetStatus()
+
+ info := &ui.RolloutInfo{
+ StartVersion: spec.GetStartVersion(),
+ TargetVersion: spec.GetTargetVersion(),
+ Strategy: spec.GetStrategy(),
+ Schedule: spec.GetSchedule(),
+ State: strings.ToLower(aur.UserFriendlyState(status.GetState())),
+ Mode: spec.GetAutoupdateMode(),
+ }
+
+ // Set the rollout start time
+ if status != nil {
+ if startTime := status.GetStartTime(); startTime != nil && startTime.IsValid() {
+ t := startTime.AsTime()
+ if !t.IsZero() && t.Unix() != 0 {
+ info.StartTime = &t
+ }
+ }
+ }
+
+ return info
+}
+
+// getGroupsInfo builds the list of RolloutGroupInfo objects.
+func getGroupsInfo(rollout *autoupdatepb.AutoUpdateAgentRollout, versionCountsByGroup map[string]map[string]int) []ui.RolloutGroupInfo {
+ if rollout == nil {
+ return nil
+ }
+
+ groups := rollout.GetStatus().GetGroups()
+ if len(groups) == 0 {
+ return nil
+ }
+
+ out := make([]ui.RolloutGroupInfo, 0, len(groups))
+
+ for i, group := range groups {
+ groupInfo := ui.RolloutGroupInfo{
+ Name: group.GetName(),
+ State: strings.ToLower(aur.UserFriendlyState(group.GetState())),
+ InitialCount: group.GetInitialCount(),
+ PresentCount: group.GetPresentCount(),
+ UpToDateCount: group.GetUpToDateCount(),
+ StateReason: group.GetLastUpdateReason(),
+ CanaryCount: group.GetCanaryCount(),
+ IsCatchAll: i == len(groups)-1,
+ }
+
+ // Only set the position if the strategy is halt-on-error
+ if rollout.GetSpec().GetStrategy() == autoupdate.AgentsStrategyHaltOnError {
+ groupInfo.Position = i + 1
+ }
+
+ // Set the group start time
+ if startTime := group.GetStartTime(); startTime != nil && startTime.IsValid() {
+ t := startTime.AsTime()
+ if !t.IsZero() && t.Unix() != 0 {
+ groupInfo.StartTime = &t
+ }
+ }
+
+ // Set the last update time
+ if lastUpdateTime := group.GetLastUpdateTime(); lastUpdateTime != nil && lastUpdateTime.IsValid() {
+ t := lastUpdateTime.AsTime()
+ if !t.IsZero() && t.Unix() != 0 {
+ groupInfo.LastUpdateTime = &t
+ }
+ }
+
+ // Add the version counts from aggregated reports
+ if counts, ok := versionCountsByGroup[group.GetName()]; ok && len(counts) > 0 {
+ groupInfo.AgentVersionCounts = counts
+ }
+
+ // Calculate the CanarySuccessCount
+ if groupInfo.CanaryCount > 0 {
+ var successCount uint64
+ for _, canary := range group.GetCanaries() {
+ if canary.GetSuccess() {
+ successCount++
+ }
+ }
+ groupInfo.CanarySuccessCount = successCount
+ }
+
+ out = append(out, groupInfo)
+ }
+
+ return out
+}
+
+// getClusterMaintenaceInfo builds the ClusterMaintenanceInfo object.
+func getClusterMaintenanceInfo(cmc types.ClusterMaintenanceConfig) *ui.ClusterMaintenanceInfo {
+ window, ok := cmc.GetAgentUpgradeWindow()
+ if !ok {
+ return nil
+ }
+
+ return &ui.ClusterMaintenanceInfo{
+ ControlPlaneVersion: teleport.Version,
+ MaintenanceWeekdays: window.Weekdays,
+ MaintenanceStartHour: int(window.UTCStartHour),
+ }
+}
+
+// getOrphanedAgentCounts returns version counts for agents reporting group names
+// that don't match any defined rollout group.
+func getOrphanedAgentCounts(rollout *autoupdatepb.AutoUpdateAgentRollout, versionCountsByGroup map[string]map[string]int) map[string]int {
+ if rollout == nil || len(versionCountsByGroup) == 0 {
+ return nil
+ }
+
+ // Get the defined rollout groups
+ definedGroups := make(map[string]bool)
+ for _, group := range rollout.GetStatus().GetGroups() {
+ definedGroups[group.GetName()] = true
+ }
+
+ // Calculate how many agents don't belong to any of those groups, and their version.
+ orphanedCounts := make(map[string]int)
+ for groupName, versionCounts := range versionCountsByGroup {
+ if !definedGroups[groupName] {
+ for version, count := range versionCounts {
+ orphanedCounts[version] += count
+ }
+ }
+ }
+
+ if len(orphanedCounts) == 0 {
+ return nil
+ }
+
+ return orphanedCounts
+}
+
+func getAutoUpdateServiceClient(sctx *SessionContext) autoupdatepb.AutoUpdateServiceClient {
+ return autoupdatepb.NewAutoUpdateServiceClient(sctx.GetClientConnection())
+}
+
+// startGroupUpdate starts an update for a specified rollout group.
+func (h *Handler) startGroupUpdate(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (any, error) {
+ ctx := r.Context()
+
+ groupName := params.ByName("groupName")
+ if groupName == "" {
+ return nil, trace.BadParameter("group name is required")
+ }
+
+ var req ui.StartGroupUpdateRequest
+ if err := httplib.ReadJSON(r, &req); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ state := autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_UNSPECIFIED
+ // If the force flag is set to true, set the desired state to active to skip canary phase.
+ if req.Force {
+ state = autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_ACTIVE
+ }
+
+ client := getAutoUpdateServiceClient(sctx)
+ rollout, err := client.TriggerAutoUpdateAgentGroup(ctx, &autoupdatepb.TriggerAutoUpdateAgentGroupRequest{
+ Groups: []string{groupName},
+ DesiredState: state,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ group, err := findGroupInfo(rollout, groupName)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return &ui.GroupActionResponse{Group: group}, nil
+}
+
+// markGroupDone marks a specified rollout group as done.
+func (h *Handler) markGroupDone(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (any, error) {
+ ctx := r.Context()
+
+ groupName := params.ByName("groupName")
+ if groupName == "" {
+ return nil, trace.BadParameter("group name is required")
+ }
+
+ client := getAutoUpdateServiceClient(sctx)
+ rollout, err := client.ForceAutoUpdateAgentGroup(ctx, &autoupdatepb.ForceAutoUpdateAgentGroupRequest{
+ Groups: []string{groupName},
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ group, err := findGroupInfo(rollout, groupName)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return &ui.GroupActionResponse{Group: group}, nil
+}
+
+// rollbackGroup rolls back a specified rollout group.
+func (h *Handler) rollbackGroup(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (any, error) {
+ ctx := r.Context()
+
+ groupName := params.ByName("groupName")
+ if groupName == "" {
+ return nil, trace.BadParameter("group name is required")
+ }
+
+ auClient := getAutoUpdateServiceClient(sctx)
+ rollout, err := auClient.RollbackAutoUpdateAgentGroup(ctx, &autoupdatepb.RollbackAutoUpdateAgentGroupRequest{
+ Groups: []string{groupName},
+ AllStartedGroups: false,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ group, err := findGroupInfo(rollout, groupName)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return &ui.GroupActionResponse{Group: group}, nil
+}
+
+// findGroupInfo gets the RolloutGroupInfo for a specified group name.
+func findGroupInfo(rollout *autoupdatepb.AutoUpdateAgentRollout, groupName string) (*ui.RolloutGroupInfo, error) {
+ if rollout == nil || rollout.GetStatus() == nil {
+ return nil, trace.NotFound("group %q not found in rollout", groupName)
+ }
+
+ groups := getGroupsInfo(rollout, nil)
+ for i := range groups {
+ if groups[i].Name == groupName {
+ return &groups[i], nil
+ }
+ }
+ return nil, trace.NotFound("group %q not found in rollout", groupName)
+}
diff --git a/lib/web/managed_updates_test.go b/lib/web/managed_updates_test.go
new file mode 100644
index 00000000000..932e46cc334
--- /dev/null
+++ b/lib/web/managed_updates_test.go
@@ -0,0 +1,620 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package web
+
+import (
+ "encoding/json"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/durationpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ autoupdatepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1"
+ headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/api/types/autoupdate"
+ "github.com/gravitational/teleport/lib/web/ui"
+)
+
+// TestGetManagedUpdateDetails tests fetching managed updates details.
+func TestGetManagedUpdatesDetails(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ env := newWebPack(t, 1)
+ proxy := env.proxies[0]
+
+ // Create a role with permissions to read autoupdate resources
+ role, err := types.NewRole("testrole", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ types.NewRule(types.KindAutoUpdateConfig, []string{types.VerbRead}),
+ types.NewRule(types.KindAutoUpdateVersion, []string{types.VerbRead}),
+ types.NewRule(types.KindAutoUpdateAgentRollout, []string{types.VerbRead}),
+ types.NewRule(types.KindAutoUpdateAgentReport, []string{types.VerbRead, types.VerbList}),
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertRole(ctx, role)
+ require.NoError(t, err)
+
+ pack := proxy.authPack(t, "testuser", []types.Role{role})
+
+ // Create an auto update config
+ config, err := autoupdate.NewAutoUpdateConfig(&autoupdatepb.AutoUpdateConfigSpec{
+ Tools: &autoupdatepb.AutoUpdateConfigSpecTools{
+ Mode: autoupdate.ToolsUpdateModeEnabled,
+ },
+ Agents: &autoupdatepb.AutoUpdateConfigSpecAgents{
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyTimeBased,
+ MaintenanceWindowDuration: durationpb.New(time.Hour),
+ Schedules: &autoupdatepb.AgentAutoUpdateSchedules{
+ Regular: []*autoupdatepb.AgentAutoUpdateGroup{
+ {Name: "all", Days: []string{"Mon", "Tue", "Wed"}, StartHour: 14},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateConfig(ctx, config)
+ require.NoError(t, err)
+
+ // Create AutoUpdateVersion
+ version, err := autoupdate.NewAutoUpdateVersion(&autoupdatepb.AutoUpdateVersionSpec{
+ Tools: &autoupdatepb.AutoUpdateVersionSpecTools{
+ TargetVersion: "18.2.0",
+ },
+ Agents: &autoupdatepb.AutoUpdateVersionSpecAgents{
+ StartVersion: "18.1.0",
+ TargetVersion: "18.2.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateVersion(ctx, version)
+ require.NoError(t, err)
+
+ // Create AutoUpdateAgentRollout
+ rollout := &autoupdatepb.AutoUpdateAgentRollout{
+ Kind: types.KindAutoUpdateAgentRollout,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: types.MetaNameAutoUpdateAgentRollout,
+ },
+ Spec: &autoupdatepb.AutoUpdateAgentRolloutSpec{
+ StartVersion: "18.1.0",
+ TargetVersion: "18.2.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ AutoupdateMode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyTimeBased,
+ },
+ Status: &autoupdatepb.AutoUpdateAgentRolloutStatus{
+ State: autoupdatepb.AutoUpdateAgentRolloutState_AUTO_UPDATE_AGENT_ROLLOUT_STATE_ACTIVE,
+ Groups: []*autoupdatepb.AutoUpdateAgentRolloutStatusGroup{
+ {
+ Name: "all",
+ State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_ACTIVE,
+ PresentCount: 100,
+ UpToDateCount: 75,
+ ConfigDays: []string{"Mon", "Tue", "Wed"},
+ ConfigStartHour: 14,
+ },
+ },
+ },
+ }
+ _, err = env.server.Auth().UpsertAutoUpdateAgentRollout(ctx, rollout)
+ require.NoError(t, err)
+
+ // Make the request
+ resp, err := pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "managedupdates"), url.Values{})
+ require.NoError(t, err)
+
+ var result ui.ManagedUpdatesDetails
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &result))
+
+ // Verify tools info
+ require.NotNil(t, result.Tools)
+ require.Equal(t, autoupdate.ToolsUpdateModeEnabled, result.Tools.Mode)
+ require.Equal(t, "18.2.0", result.Tools.TargetVersion)
+
+ // Verify rollout info
+ require.NotNil(t, result.Rollout)
+ require.Equal(t, "18.1.0", result.Rollout.StartVersion)
+ require.Equal(t, "18.2.0", result.Rollout.TargetVersion)
+ require.Equal(t, autoupdate.AgentsStrategyTimeBased, result.Rollout.Strategy)
+ require.Equal(t, autoupdate.AgentsScheduleRegular, result.Rollout.Schedule)
+ require.Equal(t, "active", result.Rollout.State)
+ require.Equal(t, autoupdate.AgentsUpdateModeEnabled, result.Rollout.Mode)
+
+ // Verify groups info
+ require.Len(t, result.Groups, 1)
+ require.Equal(t, "all", result.Groups[0].Name)
+ require.Equal(t, "active", result.Groups[0].State)
+ require.Equal(t, uint64(100), result.Groups[0].PresentCount)
+ require.Equal(t, uint64(75), result.Groups[0].UpToDateCount)
+
+ // Verify cluster maintenance info is not set (not a cloud cluster)
+ require.Nil(t, result.ClusterMaintenance)
+}
+
+// TestGetOrphanedAgentCounts tests that orphaned agents (agents in a group that doesn't exist) are correctly included in the response.
+func TestGetOrphanedAgentCounts(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ env := newWebPack(t, 1)
+ proxy := env.proxies[0]
+
+ // Create a role with permissions to read autoupdate resources
+ role, err := types.NewRole("testrole", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ types.NewRule(types.KindAutoUpdateConfig, []string{types.VerbRead}),
+ types.NewRule(types.KindAutoUpdateVersion, []string{types.VerbRead}),
+ types.NewRule(types.KindAutoUpdateAgentRollout, []string{types.VerbRead}),
+ types.NewRule(types.KindAutoUpdateAgentReport, []string{types.VerbRead, types.VerbList}),
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertRole(ctx, role)
+ require.NoError(t, err)
+
+ pack := proxy.authPack(t, "testuser", []types.Role{role})
+
+ // Create AutoUpdateConfig with "prod" as the only group
+ config, err := autoupdate.NewAutoUpdateConfig(&autoupdatepb.AutoUpdateConfigSpec{
+ Agents: &autoupdatepb.AutoUpdateConfigSpecAgents{
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyTimeBased,
+ MaintenanceWindowDuration: durationpb.New(time.Hour),
+ Schedules: &autoupdatepb.AgentAutoUpdateSchedules{
+ Regular: []*autoupdatepb.AgentAutoUpdateGroup{
+ {Name: "prod", Days: []string{"*"}, StartHour: 10},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateConfig(ctx, config)
+ require.NoError(t, err)
+
+ // Create AutoUpdateVersion
+ version, err := autoupdate.NewAutoUpdateVersion(&autoupdatepb.AutoUpdateVersionSpec{
+ Agents: &autoupdatepb.AutoUpdateVersionSpecAgents{
+ StartVersion: "18.1.0",
+ TargetVersion: "18.2.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateVersion(ctx, version)
+ require.NoError(t, err)
+
+ // Create the AutoUpdateAgentRollout
+ rollout := &autoupdatepb.AutoUpdateAgentRollout{
+ Kind: types.KindAutoUpdateAgentRollout,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: types.MetaNameAutoUpdateAgentRollout,
+ },
+ Spec: &autoupdatepb.AutoUpdateAgentRolloutSpec{
+ StartVersion: "18.1.0",
+ TargetVersion: "18.2.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ AutoupdateMode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyTimeBased,
+ },
+ Status: &autoupdatepb.AutoUpdateAgentRolloutStatus{
+ State: autoupdatepb.AutoUpdateAgentRolloutState_AUTO_UPDATE_AGENT_ROLLOUT_STATE_ACTIVE,
+ Groups: []*autoupdatepb.AutoUpdateAgentRolloutStatusGroup{
+ {
+ Name: "prod",
+ State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_ACTIVE,
+ PresentCount: 50,
+ UpToDateCount: 25,
+ ConfigDays: []string{"*"},
+ ConfigStartHour: 10,
+ },
+ },
+ },
+ }
+ _, err = env.server.Auth().UpsertAutoUpdateAgentRollout(ctx, rollout)
+ require.NoError(t, err)
+
+ // Create AutoUpdateAgentReport with some agents in "prod" (valid) and some in "invalidgroup" (orphaned)
+ report := &autoupdatepb.AutoUpdateAgentReport{
+ Kind: types.KindAutoUpdateAgentReport,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: "test-auth-server",
+ },
+ Spec: &autoupdatepb.AutoUpdateAgentReportSpec{
+ Timestamp: timestamppb.Now(),
+ Groups: map[string]*autoupdatepb.AutoUpdateAgentReportSpecGroup{
+ "prod": {
+ Versions: map[string]*autoupdatepb.AutoUpdateAgentReportSpecGroupVersion{
+ "18.1.0": {Count: 30},
+ "18.2.0": {Count: 20},
+ },
+ },
+ "invalidgroup": {
+ Versions: map[string]*autoupdatepb.AutoUpdateAgentReportSpecGroupVersion{
+ "18.1.0": {Count: 5},
+ "18.2.0": {Count: 3},
+ },
+ },
+ },
+ },
+ }
+ _, err = env.server.Auth().UpsertAutoUpdateAgentReport(ctx, report)
+ require.NoError(t, err)
+
+ // Make the request
+ resp, err := pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "managedupdates"), url.Values{})
+ require.NoError(t, err)
+
+ var result ui.ManagedUpdatesDetails
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &result))
+
+ // Verify that the orphaned agent version counts are present and correct
+ require.NotNil(t, result.OrphanedAgentVersionCounts)
+ require.Equal(t, 5, result.OrphanedAgentVersionCounts["18.1.0"])
+ require.Equal(t, 3, result.OrphanedAgentVersionCounts["18.2.0"])
+
+ // Verify that the prod group agent version counts are present and correct
+ require.Len(t, result.Groups, 1)
+ require.Equal(t, "prod", result.Groups[0].Name)
+ require.Equal(t, 30, result.Groups[0].AgentVersionCounts["18.1.0"])
+ require.Equal(t, 20, result.Groups[0].AgentVersionCounts["18.2.0"])
+}
+
+// TestStartGroupUpdate tests starting an update for a group.
+func TestStartGroupUpdate(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ env := newWebPack(t, 1)
+ proxy := env.proxies[0]
+
+ role, err := types.NewRole("testrole", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ types.NewRule(types.KindAutoUpdateConfig, []string{types.VerbRead, types.VerbCreate, types.VerbUpdate}),
+ types.NewRule(types.KindAutoUpdateVersion, []string{types.VerbRead, types.VerbCreate, types.VerbUpdate}),
+ types.NewRule(types.KindAutoUpdateAgentRollout, []string{types.VerbRead, types.VerbCreate, types.VerbUpdate}),
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertRole(ctx, role)
+ require.NoError(t, err)
+
+ pack := proxy.authPack(t, "testuser", []types.Role{role})
+
+ config, err := autoupdate.NewAutoUpdateConfig(&autoupdatepb.AutoUpdateConfigSpec{
+ Agents: &autoupdatepb.AutoUpdateConfigSpecAgents{
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyHaltOnError,
+ Schedules: &autoupdatepb.AgentAutoUpdateSchedules{
+ Regular: []*autoupdatepb.AgentAutoUpdateGroup{
+ {Name: "dev", Days: []string{"*"}, StartHour: 10, WaitHours: 0},
+ {Name: "staging", Days: []string{"*"}, StartHour: 10, WaitHours: 24},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateConfig(ctx, config)
+ require.NoError(t, err)
+
+ version, err := autoupdate.NewAutoUpdateVersion(&autoupdatepb.AutoUpdateVersionSpec{
+ Agents: &autoupdatepb.AutoUpdateVersionSpecAgents{
+ StartVersion: "18.0.0",
+ TargetVersion: "18.1.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateVersion(ctx, version)
+ require.NoError(t, err)
+
+ rollout := &autoupdatepb.AutoUpdateAgentRollout{
+ Kind: types.KindAutoUpdateAgentRollout,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: types.MetaNameAutoUpdateAgentRollout,
+ },
+ Spec: &autoupdatepb.AutoUpdateAgentRolloutSpec{
+ StartVersion: "18.0.0",
+ TargetVersion: "18.1.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ AutoupdateMode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyHaltOnError,
+ },
+ Status: &autoupdatepb.AutoUpdateAgentRolloutStatus{
+ State: autoupdatepb.AutoUpdateAgentRolloutState_AUTO_UPDATE_AGENT_ROLLOUT_STATE_ACTIVE,
+ Groups: []*autoupdatepb.AutoUpdateAgentRolloutStatusGroup{
+ {
+ Name: "dev",
+ State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_UNSTARTED,
+ ConfigDays: []string{"*"},
+ ConfigStartHour: 10,
+ ConfigWaitHours: 0,
+ CanaryCount: 1,
+ },
+ {
+ Name: "staging",
+ State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_UNSTARTED,
+ ConfigDays: []string{"*"},
+ ConfigStartHour: 10,
+ ConfigWaitHours: 24,
+ },
+ },
+ },
+ }
+ _, err = env.server.Auth().UpsertAutoUpdateAgentRollout(ctx, rollout)
+ require.NoError(t, err)
+
+ // Start the update
+ resp, err := pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "managedupdates", "groups", "dev", "start"), nil)
+ require.NoError(t, err)
+
+ // Verify the group goes to canary state
+ var result ui.GroupActionResponse
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &result))
+ require.NotNil(t, result.Group)
+ require.Equal(t, "canary", result.Group.State)
+
+ // Start the update with the force flag set
+ resp, err = pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "managedupdates", "groups", "staging", "start"), ui.StartGroupUpdateRequest{
+ Force: true,
+ })
+ require.NoError(t, err)
+
+ // Verify that the group goes straight to active
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &result))
+ require.NotNil(t, result.Group)
+ require.Equal(t, "staging", result.Group.Name)
+ require.Equal(t, "active", result.Group.State)
+
+ // Trying to start a nonexistent group returns an error
+ _, err = pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "managedupdates", "groups", "nonexistent", "start"), nil)
+ require.Error(t, err)
+}
+
+// TestMarkGroupDone tests marking a managed update group as done.
+func TestMarkGroupDone(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ env := newWebPack(t, 1)
+ proxy := env.proxies[0]
+
+ role, err := types.NewRole("testrole", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ types.NewRule(types.KindAutoUpdateConfig, []string{types.VerbRead, types.VerbCreate, types.VerbUpdate}),
+ types.NewRule(types.KindAutoUpdateVersion, []string{types.VerbRead, types.VerbCreate, types.VerbUpdate}),
+ types.NewRule(types.KindAutoUpdateAgentRollout, []string{types.VerbRead, types.VerbCreate, types.VerbUpdate}),
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertRole(ctx, role)
+ require.NoError(t, err)
+
+ pack := proxy.authPack(t, "testuser", []types.Role{role})
+
+ config, err := autoupdate.NewAutoUpdateConfig(&autoupdatepb.AutoUpdateConfigSpec{
+ Agents: &autoupdatepb.AutoUpdateConfigSpecAgents{
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyHaltOnError,
+ Schedules: &autoupdatepb.AgentAutoUpdateSchedules{
+ Regular: []*autoupdatepb.AgentAutoUpdateGroup{
+ {Name: "dev", Days: []string{"*"}, StartHour: 10, WaitHours: 0},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateConfig(ctx, config)
+ require.NoError(t, err)
+
+ version, err := autoupdate.NewAutoUpdateVersion(&autoupdatepb.AutoUpdateVersionSpec{
+ Agents: &autoupdatepb.AutoUpdateVersionSpecAgents{
+ StartVersion: "18.0.0",
+ TargetVersion: "18.1.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateVersion(ctx, version)
+ require.NoError(t, err)
+
+ rollout := &autoupdatepb.AutoUpdateAgentRollout{
+ Kind: types.KindAutoUpdateAgentRollout,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: types.MetaNameAutoUpdateAgentRollout,
+ },
+ Spec: &autoupdatepb.AutoUpdateAgentRolloutSpec{
+ StartVersion: "18.0.0",
+ TargetVersion: "18.1.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ AutoupdateMode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyHaltOnError,
+ },
+ Status: &autoupdatepb.AutoUpdateAgentRolloutStatus{
+ State: autoupdatepb.AutoUpdateAgentRolloutState_AUTO_UPDATE_AGENT_ROLLOUT_STATE_ACTIVE,
+ Groups: []*autoupdatepb.AutoUpdateAgentRolloutStatusGroup{
+ {
+ Name: "dev",
+ State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_UNSTARTED,
+ ConfigDays: []string{"*"},
+ ConfigStartHour: 10,
+ ConfigWaitHours: 0,
+ },
+ },
+ },
+ }
+ _, err = env.server.Auth().UpsertAutoUpdateAgentRollout(ctx, rollout)
+ require.NoError(t, err)
+
+ // Force-start the group
+ resp, err := pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "managedupdates", "groups", "dev", "start"), ui.StartGroupUpdateRequest{
+ Force: true,
+ })
+ require.NoError(t, err)
+
+ // Verify that the state is now active
+ var result ui.GroupActionResponse
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &result))
+ require.NotNil(t, result.Group)
+ require.Equal(t, "active", result.Group.State)
+
+ // Mark the group as done
+ resp, err = pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "managedupdates", "groups", "dev", "done"), nil)
+ require.NoError(t, err)
+
+ // Verify that the state is now done
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &result))
+ require.NotNil(t, result.Group)
+ require.Equal(t, "dev", result.Group.Name)
+ require.Equal(t, "done", result.Group.State)
+
+ // Trying to mark a nonexistent group as done returns an error
+ _, err = pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "managedupdates", "groups", "nonexistent", "done"), nil)
+ require.Error(t, err)
+}
+
+// TestRollbackGroup tests rolling back a managed update group.
+func TestRollbackGroup(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ env := newWebPack(t, 1)
+ proxy := env.proxies[0]
+
+ role, err := types.NewRole("testrole", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ types.NewRule(types.KindAutoUpdateConfig, []string{types.VerbRead, types.VerbCreate, types.VerbUpdate}),
+ types.NewRule(types.KindAutoUpdateVersion, []string{types.VerbRead, types.VerbCreate, types.VerbUpdate}),
+ types.NewRule(types.KindAutoUpdateAgentRollout, []string{types.VerbRead, types.VerbCreate, types.VerbUpdate}),
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertRole(ctx, role)
+ require.NoError(t, err)
+
+ pack := proxy.authPack(t, "testuser", []types.Role{role})
+
+ config, err := autoupdate.NewAutoUpdateConfig(&autoupdatepb.AutoUpdateConfigSpec{
+ Agents: &autoupdatepb.AutoUpdateConfigSpecAgents{
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyHaltOnError,
+ Schedules: &autoupdatepb.AgentAutoUpdateSchedules{
+ Regular: []*autoupdatepb.AgentAutoUpdateGroup{
+ {Name: "prod", Days: []string{"*"}, StartHour: 10, WaitHours: 0},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateConfig(ctx, config)
+ require.NoError(t, err)
+
+ version, err := autoupdate.NewAutoUpdateVersion(&autoupdatepb.AutoUpdateVersionSpec{
+ Agents: &autoupdatepb.AutoUpdateVersionSpecAgents{
+ StartVersion: "18.0.0",
+ TargetVersion: "18.1.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ Mode: autoupdate.AgentsUpdateModeEnabled,
+ },
+ })
+ require.NoError(t, err)
+ _, err = env.server.Auth().UpsertAutoUpdateVersion(ctx, version)
+ require.NoError(t, err)
+
+ rollout := &autoupdatepb.AutoUpdateAgentRollout{
+ Kind: types.KindAutoUpdateAgentRollout,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: types.MetaNameAutoUpdateAgentRollout,
+ },
+ Spec: &autoupdatepb.AutoUpdateAgentRolloutSpec{
+ StartVersion: "18.0.0",
+ TargetVersion: "18.1.0",
+ Schedule: autoupdate.AgentsScheduleRegular,
+ AutoupdateMode: autoupdate.AgentsUpdateModeEnabled,
+ Strategy: autoupdate.AgentsStrategyHaltOnError,
+ },
+ Status: &autoupdatepb.AutoUpdateAgentRolloutStatus{
+ State: autoupdatepb.AutoUpdateAgentRolloutState_AUTO_UPDATE_AGENT_ROLLOUT_STATE_ACTIVE,
+ Groups: []*autoupdatepb.AutoUpdateAgentRolloutStatusGroup{
+ {
+ Name: "prod",
+ State: autoupdatepb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_UNSTARTED,
+ ConfigDays: []string{"*"},
+ ConfigStartHour: 10,
+ ConfigWaitHours: 0,
+ },
+ },
+ },
+ }
+ _, err = env.server.Auth().UpsertAutoUpdateAgentRollout(ctx, rollout)
+ require.NoError(t, err)
+
+ // Force-start the group
+ resp, err := pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "managedupdates", "groups", "prod", "start"), ui.StartGroupUpdateRequest{
+ Force: true,
+ })
+ require.NoError(t, err)
+
+ // Verify that the state is now active
+ var result ui.GroupActionResponse
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &result))
+ require.NotNil(t, result.Group)
+ require.Equal(t, "active", result.Group.State)
+
+ // Rollback the group
+ resp, err = pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "managedupdates", "groups", "prod", "rollback"), nil)
+ require.NoError(t, err)
+
+ // Verify that the state is now rolledback
+ require.NoError(t, json.Unmarshal(resp.Bytes(), &result))
+ require.NotNil(t, result.Group)
+ require.Equal(t, "prod", result.Group.Name)
+ require.Equal(t, "rolledback", result.Group.State)
+
+ // Trying to rollback a nonexistent group returns an error
+ _, err = pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "managedupdates", "groups", "nonexistent", "rollback"), nil)
+ require.Error(t, err)
+}
diff --git a/lib/web/ui/managed_updates.go b/lib/web/ui/managed_updates.go
new file mode 100644
index 00000000000..911cd2c75f8
--- /dev/null
+++ b/lib/web/ui/managed_updates.go
@@ -0,0 +1,118 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package ui
+
+import "time"
+
+// ManagedUpdatesDetails is the response to the request for managed updates details.
+type ManagedUpdatesDetails struct {
+ // Rollout contains information about the agent rollout configuration.
+ Rollout *RolloutInfo `json:"rollout,omitempty"`
+
+ // Tools contains information about the tools autoupdates configuration.
+ Tools *ToolsAutoUpdateInfo `json:"tools,omitempty"`
+
+ // ClusterMaintenance contains information about the cluster maintenance configuration. This is for Cloud only.
+ ClusterMaintenance *ClusterMaintenanceInfo `json:"clusterMaintenance,omitempty"`
+
+ // Groups contains information about rollout groups.
+ Groups []RolloutGroupInfo `json:"groups,omitempty"`
+
+ // OrphanedAgentVersionCounts is a map representing how many orphaned agents are in each version.
+ // An orphaned agent is an agent belonging to a group that is not defined in the rollout.
+ OrphanedAgentVersionCounts map[string]int `json:"orphanedAgentVersionCounts,omitempty"`
+}
+
+// RolloutInfo contains information about the agent rollout configuration.
+type RolloutInfo struct {
+ // StartVersion is the starting version for the rollout.
+ StartVersion string `json:"startVersion,omitempty"`
+ // TargetVersion is the version to update to in the rollout.
+ TargetVersion string `json:"targetVersion,omitempty"`
+ // Strategy is the rollout strategy, either halt-on-error or time-based.
+ Strategy string `json:"strategy,omitempty"`
+ // Schedule is the rollout schedule.
+ Schedule string `json:"schedule,omitempty"`
+ // State is the current state of the rollout.
+ State string `json:"state,omitempty"`
+ // Mode is the autoupdate mode option.
+ Mode string `json:"mode,omitempty"`
+ // StartTime is when the rollout was created/started.
+ StartTime *time.Time `json:"startTime,omitempty"`
+}
+
+// ToolsAutoUpdateInfo contains information about the tools autoupdates configuration.
+type ToolsAutoUpdateInfo struct {
+ // Mode is the tools autoupdate mode, either enabled or disabled.
+ Mode string `json:"mode,omitempty"`
+ // TargetVersion is the target version.
+ TargetVersion string `json:"targetVersion,omitempty"`
+}
+
+// ClusterMaintenanceInfo contains information about the cluster maintenance configuration. This is for Cloud only.
+type ClusterMaintenanceInfo struct {
+ // ControlPlaneVersion is the current version of the control plane.
+ ControlPlaneVersion string `json:"controlPlaneVersion,omitempty"`
+ // MaintenanceWeekdays is the list of days when maintenance can occur.
+ MaintenanceWeekdays []string `json:"maintenanceWeekdays,omitempty"`
+ // MaintenanceStartHour is the maintenance window start hour (UTC time).
+ MaintenanceStartHour int `json:"maintenanceStartHour"`
+}
+
+// RolloutGroupInfo contains information about a rollout group.
+type RolloutGroupInfo struct {
+ // Name is the group name.
+ Name string `json:"name"`
+ // Position is the position of this group in the rollout order, this is only applicable for halt-on-error strategy.
+ Position int `json:"position,omitempty"`
+ // State is this group's current state in the rollout.
+ State string `json:"state"`
+ // InitialCount is the number of agents in this group when the rollout started.
+ InitialCount uint64 `json:"initialCount"`
+ // PresentCount is the number of agents in this group currently connected.
+ PresentCount uint64 `json:"presentCount"`
+ // UpToDateCount is the number of agents in this group running the target version.
+ UpToDateCount uint64 `json:"upToDateCount"`
+ // StateReason is the optional state reason text.
+ StateReason string `json:"stateReason,omitempty"`
+ // StartTime is the time when the group rollout started.
+ StartTime *time.Time `json:"startTime,omitempty"`
+ // LastUpdateTime is the time of the last state update for this group.
+ LastUpdateTime *time.Time `json:"lastUpdateTime,omitempty"`
+ // AgentVersionCounts is a map representing how many agents in this group are on each version.
+ AgentVersionCounts map[string]int `json:"agentVersionCounts,omitempty"`
+ // CanaryCount is the number of canary agents that need to be updated before the whole group is updated.
+ CanaryCount uint64 `json:"canaryCount,omitempty"`
+ // CanarySuccessCount is the number of canary agents that have been successfully updated.
+ CanarySuccessCount uint64 `json:"canarySuccessCount,omitempty"`
+ // IsCatchAll indicates whether this group is the catch-all group for orphaned agents.
+ IsCatchAll bool `json:"isCatchAll,omitempty"`
+}
+
+// StartGroupUpdateRequest is the request body for starting a group update.
+type StartGroupUpdateRequest struct {
+ // Force, if true, skips canary phase and goes directly to active state.
+ Force bool `json:"force,omitempty"`
+}
+
+// GroupActionResponse is the response for a group action like start update, mark done, and rollback.
+type GroupActionResponse struct {
+ // Group contains the updated group information.
+ Group *RolloutGroupInfo `json:"group,omitempty"`
+}
diff --git a/tool/tctl/common/autoupdate_command.go b/tool/tctl/common/autoupdate_command.go
index 8ba4e77ab34..a544ffafe47 100644
--- a/tool/tctl/common/autoupdate_command.go
+++ b/tool/tctl/common/autoupdate_command.go
@@ -38,6 +38,7 @@ import (
autoupdatev1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1"
"github.com/gravitational/teleport/api/types/autoupdate"
"github.com/gravitational/teleport/lib/asciitable"
+ aur "github.com/gravitational/teleport/lib/autoupdate/report"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/utils"
commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
@@ -243,7 +244,7 @@ func (c *AutoUpdateCommand) agentsStatusCommand(ctx context.Context, client auto
sb.WriteString("Target version: " + target + "\n")
}
if state := rollout.GetStatus().GetState(); state != autoupdatev1pb.AutoUpdateAgentRolloutState_AUTO_UPDATE_AGENT_ROLLOUT_STATE_UNSPECIFIED {
- sb.WriteString("Rollout state: " + userFriendlyState(state) + "\n")
+ sb.WriteString("Rollout state: " + aur.UserFriendlyState(state) + "\n")
}
if schedule := rollout.GetSpec().GetSchedule(); schedule == autoupdate.AgentsScheduleImmediate {
sb.WriteString("Schedule is immediate. Every group immediately updates to the target version.\n")
@@ -279,7 +280,7 @@ func (c *AutoUpdateCommand) agentsReportCommand(ctx context.Context, client auto
return trace.BadParameter("no reports returned, but the server did not return a NotFoundError, this ia a bug")
}
- validReports := filterValidReports(reports, now)
+ validReports := aur.ValidReports(reports, now)
if len(validReports) == 0 {
fmt.Fprintf(c.stdout, "Read %d reports, but they are expired. If you just (re)deployed the Auth service, you might want to retry after 60 seconds.\n", len(reports))
@@ -329,16 +330,6 @@ func (c *AutoUpdateCommand) agentsReportCommand(ctx context.Context, client auto
return trace.Wrap(err)
}
-func filterValidReports(reports []*autoupdatev1pb.AutoUpdateAgentReport, now time.Time) []*autoupdatev1pb.AutoUpdateAgentReport {
- var validReports []*autoupdatev1pb.AutoUpdateAgentReport
- for _, report := range reports {
- if now.Sub(report.GetSpec().GetTimestamp().AsTime()) <= time.Minute {
- validReports = append(validReports, report)
- }
- }
- return validReports
-}
-
func (c *AutoUpdateCommand) omittedSummary(reports []*autoupdatev1pb.AutoUpdateAgentReport) string {
aggregated := make(map[string]int)
var totalOmitted int
@@ -403,7 +394,7 @@ func rolloutGroupTable(rollout *autoupdatev1pb.AutoUpdateAgentRollout, writer io
if i == len(groups)-1 {
groupName = groupName + " (catch-all)"
}
- state := userFriendlyState(group.GetState())
+ state := aur.UserFriendlyState(group.GetState())
// If this is the canary state, we annotate the group state with the canary progress
if group.GetState() == autoupdatev1pb.AutoUpdateAgentGroupState_AUTO_UPDATE_AGENT_GROUP_STATE_CANARY {
@@ -434,7 +425,7 @@ func rolloutGroupTable(rollout *autoupdatev1pb.AutoUpdateAgentRollout, writer io
groupName := group.GetName()
table.AddRow([]string{
groupName,
- userFriendlyState(group.GetState()),
+ aur.UserFriendlyState(group.GetState()),
formatTimeIfNotEmpty(group.GetStartTime().AsTime(), time.DateTime),
group.GetLastUpdateReason()})
}
@@ -528,26 +519,6 @@ func formatTimeIfNotEmpty(t time.Time, format string) string {
return t.Format(format)
}
-func userFriendlyState[T autoupdatev1pb.AutoUpdateAgentGroupState | autoupdatev1pb.AutoUpdateAgentRolloutState](state T) string {
- switch state {
- case 0:
- return "Unknown"
- case 1:
- return "Unstarted"
- case 2:
- return "Active"
- case 3:
- return "Done"
- case 4:
- return "Rolledback"
- case 5:
- return "Canary"
- default:
- // If we don't know anything about this state, we display its integer
- return fmt.Sprintf("Unknown state (%d)", state)
- }
-}
-
// ToolsStatus makes request to auth service to fetch client tools auto update version and mode.
func (c *AutoUpdateCommand) ToolsStatus(ctx context.Context, client autoupdateClient) error {
var response getResponse