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