diff --git a/api/client/client.go b/api/client/client.go index fc08ad53467c7..86cf448bcbe77 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -3018,6 +3018,59 @@ func (c *Client) DeleteAutoUpdateVersion(ctx context.Context) error { return trace.Wrap(err) } +// CreateAutoUpdateAgentRollout creates AutoUpdateAgentRollout resource. +func (c *Client) CreateAutoUpdateAgentRollout(ctx context.Context, rollout *autoupdatev1pb.AutoUpdateAgentRollout) (*autoupdatev1pb.AutoUpdateAgentRollout, error) { + client := autoupdatev1pb.NewAutoUpdateServiceClient(c.conn) + resp, err := client.CreateAutoUpdateAgentRollout(ctx, &autoupdatev1pb.CreateAutoUpdateAgentRolloutRequest{ + Rollout: rollout, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return resp, nil +} + +// GetAutoUpdateAgentRollout gets AutoUpdateAgentRollout resource. +func (c *Client) GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdatev1pb.AutoUpdateAgentRollout, error) { + client := autoupdatev1pb.NewAutoUpdateServiceClient(c.conn) + resp, err := client.GetAutoUpdateAgentRollout(ctx, &autoupdatev1pb.GetAutoUpdateAgentRolloutRequest{}) + if err != nil { + return nil, trace.Wrap(err) + } + return resp, nil +} + +// UpdateAutoUpdateAgentRollout updates AutoUpdateAgentRollout resource. +func (c *Client) UpdateAutoUpdateAgentRollout(ctx context.Context, rollout *autoupdatev1pb.AutoUpdateAgentRollout) (*autoupdatev1pb.AutoUpdateAgentRollout, error) { + client := autoupdatev1pb.NewAutoUpdateServiceClient(c.conn) + resp, err := client.UpdateAutoUpdateAgentRollout(ctx, &autoupdatev1pb.UpdateAutoUpdateAgentRolloutRequest{ + Rollout: rollout, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return resp, nil +} + +// UpsertAutoUpdateAgentRollout updates or creates AutoUpdateAgentRollout resource. +func (c *Client) UpsertAutoUpdateAgentRollout(ctx context.Context, rollout *autoupdatev1pb.AutoUpdateAgentRollout) (*autoupdatev1pb.AutoUpdateAgentRollout, error) { + client := autoupdatev1pb.NewAutoUpdateServiceClient(c.conn) + resp, err := client.UpsertAutoUpdateAgentRollout(ctx, &autoupdatev1pb.UpsertAutoUpdateAgentRolloutRequest{ + Rollout: rollout, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return resp, nil +} + +// DeleteAutoUpdateAgentRollout deletes AutoUpdateAgentRollout resource. +func (c *Client) DeleteAutoUpdateAgentRollout(ctx context.Context) error { + client := autoupdatev1pb.NewAutoUpdateServiceClient(c.conn) + _, err := client.DeleteAutoUpdateAgentRollout(ctx, &autoupdatev1pb.DeleteAutoUpdateAgentRolloutRequest{}) + return trace.Wrap(err) +} + // GetClusterAccessGraphConfig retrieves the Cluster Access Graph configuration from Auth server. func (c *Client) GetClusterAccessGraphConfig(ctx context.Context) (*clusterconfigpb.AccessGraphConfig, error) { rsp, err := c.ClusterConfigClient().GetClusterAccessGraphConfig(ctx, &clusterconfigpb.GetClusterAccessGraphConfigRequest{}) diff --git a/api/client/events.go b/api/client/events.go index 0cce9664d248a..89c7260e38ac6 100644 --- a/api/client/events.go +++ b/api/client/events.go @@ -118,6 +118,11 @@ func EventToGRPC(in types.Event) (*proto.Event, error) { out.Resource = &proto.Event_ProvisioningPrincipalState{ ProvisioningPrincipalState: r, } + case *autoupdate.AutoUpdateAgentRollout: + out.Resource = &proto.Event_AutoUpdateAgentRollout{ + AutoUpdateAgentRollout: r, + } + default: return nil, trace.BadParameter("resource type %T is not supported", r) } @@ -574,6 +579,9 @@ func EventFromGRPC(in *proto.Event) (*types.Event, error) { } else if r := in.GetAutoUpdateVersion(); r != nil { out.Resource = types.Resource153ToLegacy(r) return &out, nil + } else if r := in.GetAutoUpdateAgentRollout(); r != nil { + out.Resource = types.Resource153ToLegacy(r) + return &out, nil } else if r := in.GetUserTask(); r != nil { out.Resource = types.Resource153ToLegacy(r) return &out, nil diff --git a/lib/auth/authclient/api.go b/lib/auth/authclient/api.go index 57821e8995795..efc4eab13b54d 100644 --- a/lib/auth/authclient/api.go +++ b/lib/auth/authclient/api.go @@ -314,6 +314,9 @@ type ReadProxyAccessPoint interface { // GetAutoUpdateVersion gets the AutoUpdateVersion from the backend. GetAutoUpdateVersion(ctx context.Context) (*autoupdate.AutoUpdateVersion, error) + + // GetAutoUpdateAgentRollout gets the AutoUpdateAgentRollout from the backend. + GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error) } // SnowflakeSessionWatcher is watcher interface used by Snowflake web session watcher. @@ -1212,6 +1215,9 @@ type Cache interface { // GetAutoUpdateVersion gets the AutoUpdateVersion from the backend. GetAutoUpdateVersion(ctx context.Context) (*autoupdate.AutoUpdateVersion, error) + // GetAutoUpdateAgentRollout gets the AutoUpdateAgentRollout from the backend. + GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error) + // GetAccessGraphSettings returns the access graph settings. GetAccessGraphSettings(context.Context) (*clusterconfigpb.AccessGraphSettings, error) diff --git a/lib/auth/autoupdate/autoupdatev1/service.go b/lib/auth/autoupdate/autoupdatev1/service.go index b14edeb13d6f9..4852edaf6707a 100644 --- a/lib/auth/autoupdate/autoupdatev1/service.go +++ b/lib/auth/autoupdate/autoupdatev1/service.go @@ -37,6 +37,9 @@ type Cache interface { // GetAutoUpdateVersion gets the AutoUpdateVersion from the backend. GetAutoUpdateVersion(ctx context.Context) (*autoupdate.AutoUpdateVersion, error) + + // GetAutoUpdateAgentRollout gets the AutoUpdateAgentRollout from the backend. + GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error) } // ServiceConfig holds configuration options for the auto update gRPC service. @@ -268,3 +271,136 @@ func (s *Service) DeleteAutoUpdateVersion(ctx context.Context, req *autoupdate.D } return &emptypb.Empty{}, nil } + +// GetAutoUpdateAgentRollout gets the current AutoUpdateAgentRollout singleton. +func (s *Service) GetAutoUpdateAgentRollout(ctx context.Context, req *autoupdate.GetAutoUpdateAgentRolloutRequest) (*autoupdate.AutoUpdateAgentRollout, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.CheckAccessToKind(types.KindAutoUpdateAgentRollout, types.VerbRead); err != nil { + return nil, trace.Wrap(err) + } + + plan, err := s.cache.GetAutoUpdateAgentRollout(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + return plan, nil +} + +// CreateAutoUpdateAgentRollout creates AutoUpdateAgentRollout singleton. +func (s *Service) CreateAutoUpdateAgentRollout(ctx context.Context, req *autoupdate.CreateAutoUpdateAgentRolloutRequest) (*autoupdate.AutoUpdateAgentRollout, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + // Editing the AU agent plan is restricted to cluster administrators. As of today we don't have any way of having + // resources that can only be edited by Teleport Cloud (when running cloud-hosted). + // The workaround is to check if the caller has the auth system role. + // This is not ideal as it forces local tctl usage. In the future, if we expand the permission system and make cloud + // a first class citizen, we'll want to update this permission check. + if !authz.HasBuiltinRole(*authCtx, string(types.RoleAuth)) { + return nil, trace.AccessDenied("this request can be only executed by an auth server") + } + + if err := authCtx.CheckAccessToKind(types.KindAutoUpdateAgentRollout, types.VerbCreate); err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.AuthorizeAdminActionAllowReusedMFA(); err != nil { + return nil, trace.Wrap(err) + } + + autoUpdateAgentRollout, err := s.backend.CreateAutoUpdateAgentRollout(ctx, req.Rollout) + return autoUpdateAgentRollout, trace.Wrap(err) +} + +// UpdateAutoUpdateAgentRollout updates AutoUpdateAgentRollout singleton. +func (s *Service) UpdateAutoUpdateAgentRollout(ctx context.Context, req *autoupdate.UpdateAutoUpdateAgentRolloutRequest) (*autoupdate.AutoUpdateAgentRollout, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + // Editing the AU agent plan is restricted to cluster administrators. As of today we don't have any way of having + // resources that can only be edited by Teleport Cloud (when running cloud-hosted). + // The workaround is to check if the caller has the auth system role. + // This is not ideal as it forces local tctl usage. In the future, if we expand the permission system and make cloud + // a first class citizen, we'll want to update this permission check. + if !authz.HasBuiltinRole(*authCtx, string(types.RoleAuth)) { + return nil, trace.AccessDenied("this request can be only executed by an auth server") + } + + if err := authCtx.CheckAccessToKind(types.KindAutoUpdateAgentRollout, types.VerbUpdate); err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.AuthorizeAdminActionAllowReusedMFA(); err != nil { + return nil, trace.Wrap(err) + } + + autoUpdateAgentRollout, err := s.backend.UpdateAutoUpdateAgentRollout(ctx, req.Rollout) + return autoUpdateAgentRollout, trace.Wrap(err) +} + +// UpsertAutoUpdateAgentRollout updates or creates AutoUpdateAgentRollout singleton. +func (s *Service) UpsertAutoUpdateAgentRollout(ctx context.Context, req *autoupdate.UpsertAutoUpdateAgentRolloutRequest) (*autoupdate.AutoUpdateAgentRollout, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + // Editing the AU agent plan is restricted to cluster administrators. As of today we don't have any way of having + // resources that can only be edited by Teleport Cloud (when running cloud-hosted). + // The workaround is to check if the caller has the auth system role. + // This is not ideal as it forces local tctl usage. In the future, if we expand the permission system and make cloud + // a first class citizen, we'll want to update this permission check. + if !authz.HasBuiltinRole(*authCtx, string(types.RoleAuth)) { + return nil, trace.AccessDenied("this request can be only executed by an auth server") + } + + if err := authCtx.CheckAccessToKind(types.KindAutoUpdateAgentRollout, types.VerbCreate, types.VerbUpdate); err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.AuthorizeAdminActionAllowReusedMFA(); err != nil { + return nil, trace.Wrap(err) + } + + autoUpdateAgentRollout, err := s.backend.UpsertAutoUpdateAgentRollout(ctx, req.Rollout) + return autoUpdateAgentRollout, trace.Wrap(err) +} + +// DeleteAutoUpdateAgentRollout deletes AutoUpdateAgentRollout singleton. +func (s *Service) DeleteAutoUpdateAgentRollout(ctx context.Context, req *autoupdate.DeleteAutoUpdateAgentRolloutRequest) (*emptypb.Empty, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + // Editing the AU agent plan is restricted to cluster administrators. As of today we don't have any way of having + // resources that can only be edited by Teleport Cloud (when running cloud-hosted). + // The workaround is to check if the caller has the auth system role. + // This is not ideal as it forces local tctl usage. In the future, if we expand the permission system and make cloud + // a first class citizen, we'll want to update this permission check. + if !authz.HasBuiltinRole(*authCtx, string(types.RoleAuth)) { + return nil, trace.AccessDenied("this request can be only executed by an auth server") + } + + if err := authCtx.CheckAccessToKind(types.KindAutoUpdateAgentRollout, types.VerbDelete); err != nil { + return nil, trace.Wrap(err) + } + + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + if err := s.backend.DeleteAutoUpdateAgentRollout(ctx); err != nil { + return nil, trace.Wrap(err) + } + return &emptypb.Empty{}, nil +} diff --git a/lib/auth/autoupdate/autoupdatev1/service_test.go b/lib/auth/autoupdate/autoupdatev1/service_test.go index 24acfbf575eea..dd1d6df087db5 100644 --- a/lib/auth/autoupdate/autoupdatev1/service_test.go +++ b/lib/auth/autoupdate/autoupdatev1/service_test.go @@ -81,6 +81,7 @@ func TestServiceAccess(t *testing.T) { allowedVerbs []string allowedStates []authz.AdminActionAuthState disallowedStates []authz.AdminActionAuthState + builtinRole *authz.BuiltinRole }{ { name: "CreateAutoUpdateConfig", @@ -167,6 +168,53 @@ func TestServiceAccess(t *testing.T) { allowedStates: []authz.AdminActionAuthState{authz.AdminActionAuthNotRequired, authz.AdminActionAuthMFAVerified}, allowedVerbs: []string{types.VerbDelete}, }, + // AutoUpdate agent rollout check. + { + name: "GetAutoUpdateAgentRollout", + allowedStates: []authz.AdminActionAuthState{ + authz.AdminActionAuthUnauthorized, + authz.AdminActionAuthNotRequired, + authz.AdminActionAuthMFAVerified, + authz.AdminActionAuthMFAVerifiedWithReuse, + }, + allowedVerbs: []string{types.VerbRead}, + }, + { + name: "CreateAutoUpdateAgentRollout", + allowedStates: []authz.AdminActionAuthState{ + authz.AdminActionAuthNotRequired, + authz.AdminActionAuthMFAVerified, + authz.AdminActionAuthMFAVerifiedWithReuse, + }, + allowedVerbs: []string{types.VerbCreate}, + builtinRole: &authz.BuiltinRole{Role: types.RoleAuth}, + }, + { + name: "UpdateAutoUpdateAgentRollout", + allowedStates: []authz.AdminActionAuthState{ + authz.AdminActionAuthNotRequired, + authz.AdminActionAuthMFAVerified, + authz.AdminActionAuthMFAVerifiedWithReuse, + }, + allowedVerbs: []string{types.VerbUpdate}, + builtinRole: &authz.BuiltinRole{Role: types.RoleAuth}, + }, + { + name: "UpsertAutoUpdateAgentRollout", + allowedStates: []authz.AdminActionAuthState{ + authz.AdminActionAuthNotRequired, + authz.AdminActionAuthMFAVerified, + authz.AdminActionAuthMFAVerifiedWithReuse, + }, + allowedVerbs: []string{types.VerbUpdate, types.VerbCreate}, + builtinRole: &authz.BuiltinRole{Role: types.RoleAuth}, + }, + { + name: "DeleteAutoUpdateAgentRollout", + allowedStates: []authz.AdminActionAuthState{authz.AdminActionAuthNotRequired, authz.AdminActionAuthMFAVerified}, + allowedVerbs: []string{types.VerbDelete}, + builtinRole: &authz.BuiltinRole{Role: types.RoleAuth}, + }, } for _, tt := range testCases { @@ -177,7 +225,7 @@ func TestServiceAccess(t *testing.T) { t.Run(stateToString(state), func(t *testing.T) { for _, verbs := range utils.Combinations(tt.allowedVerbs) { t.Run(fmt.Sprintf("verbs=%v", verbs), func(t *testing.T) { - service := newService(t, state, fakeChecker{allowedVerbs: verbs}) + service := newService(t, state, fakeChecker{allowedVerbs: verbs, builtinRole: tt.builtinRole}) err := callMethod(t, service, tt.name) // expect access denied except with full set of verbs. if len(verbs) == len(tt.allowedVerbs) { @@ -201,7 +249,7 @@ func TestServiceAccess(t *testing.T) { t.Run(stateToString(state), func(t *testing.T) { // it is enough to test against tt.allowedVerbs, // this is the only different data point compared to the test cases above. - service := newService(t, state, fakeChecker{allowedVerbs: tt.allowedVerbs}) + service := newService(t, state, fakeChecker{allowedVerbs: tt.allowedVerbs, builtinRole: tt.builtinRole}) err := callMethod(t, service, tt.name) require.True(t, trace.IsAccessDenied(err)) }) @@ -210,21 +258,9 @@ func TestServiceAccess(t *testing.T) { }) } - // TODO(hugoShaka): remove this as we implement the service for the autoupdate agent rollout resource - notImplementedYetRPCs := []string{ - "GetAutoUpdateAgentRollout", - "CreateAutoUpdateAgentRollout", - "UpdateAutoUpdateAgentRollout", - "UpsertAutoUpdateAgentRollout", - "DeleteAutoUpdateAgentRollout", - } - // verify that all declared methods have matching test cases t.Run("verify coverage", func(t *testing.T) { for _, method := range autoupdate.AutoUpdateService_ServiceDesc.Methods { - if slices.Contains(notImplementedYetRPCs, method.MethodName) { - continue - } t.Run(method.MethodName, func(t *testing.T) { match := false for _, testCase := range testCases { @@ -238,11 +274,12 @@ func TestServiceAccess(t *testing.T) { type fakeChecker struct { allowedVerbs []string + builtinRole *authz.BuiltinRole services.AccessChecker } func (f fakeChecker) CheckAccessToRule(_ services.RuleContext, _ string, resource string, verb string) error { - if resource == types.KindAutoUpdateConfig || resource == types.KindAutoUpdateVersion { + if resource == types.KindAutoUpdateConfig || resource == types.KindAutoUpdateVersion || resource == types.KindAutoUpdateAgentRollout { for _, allowedVerb := range f.allowedVerbs { if allowedVerb == verb { return nil @@ -253,7 +290,21 @@ func (f fakeChecker) CheckAccessToRule(_ services.RuleContext, _ string, resourc return trace.AccessDenied("access denied to rule=%v/verb=%v", resource, verb) } -func newService(t *testing.T, authState authz.AdminActionAuthState, checker services.AccessChecker) *Service { +func (f fakeChecker) HasRole(name string) bool { + if f.builtinRole == nil { + return false + } + return name == f.builtinRole.Role.String() +} + +func (f fakeChecker) identityGetter() authz.IdentityGetter { + if f.builtinRole != nil { + return *f.builtinRole + } + return nil +} + +func newService(t *testing.T, authState authz.AdminActionAuthState, checker fakeChecker) *Service { t.Helper() bk, err := memory.New(memory.Config{}) @@ -265,7 +316,7 @@ func newService(t *testing.T, authState authz.AdminActionAuthState, checker serv return newServiceWithStorage(t, authState, checker, storage) } -func newServiceWithStorage(t *testing.T, authState authz.AdminActionAuthState, checker services.AccessChecker, storage services.AutoUpdateService) *Service { +func newServiceWithStorage(t *testing.T, authState authz.AdminActionAuthState, checker fakeChecker, storage services.AutoUpdateService) *Service { t.Helper() authorizer := authz.AuthorizerFunc(func(ctx context.Context) (*authz.Context, error) { @@ -273,10 +324,12 @@ func newServiceWithStorage(t *testing.T, authState authz.AdminActionAuthState, c if err != nil { return nil, err } + return &authz.Context{ User: user, Checker: checker, AdminActionAuthState: authState, + Identity: checker.identityGetter(), }, nil }) diff --git a/lib/authz/permissions.go b/lib/authz/permissions.go index 86375ca1bb5ba..9b045a9879eb0 100644 --- a/lib/authz/permissions.go +++ b/lib/authz/permissions.go @@ -915,6 +915,7 @@ func roleSpecForProxy(clusterName string) types.RoleSpecV6 { types.NewRule(types.KindClusterMaintenanceConfig, services.RO()), types.NewRule(types.KindAutoUpdateConfig, services.RO()), types.NewRule(types.KindAutoUpdateVersion, services.RO()), + types.NewRule(types.KindAutoUpdateAgentRollout, services.RO()), types.NewRule(types.KindIntegration, append(services.RO(), types.VerbUse)), types.NewRule(types.KindAuditQuery, services.RO()), types.NewRule(types.KindSecurityReport, services.RO()), diff --git a/lib/cache/cache.go b/lib/cache/cache.go index 312705c258198..40c71ca1094e6 100644 --- a/lib/cache/cache.go +++ b/lib/cache/cache.go @@ -190,6 +190,7 @@ func ForAuth(cfg Config) Config { {Kind: types.KindStaticHostUser}, {Kind: types.KindAutoUpdateVersion}, {Kind: types.KindAutoUpdateConfig}, + {Kind: types.KindAutoUpdateAgentRollout}, {Kind: types.KindUserTask}, {Kind: types.KindProvisioningPrincipalState}, } @@ -247,6 +248,7 @@ func ForProxy(cfg Config) Config { {Kind: types.KindKubeWaitingContainer}, {Kind: types.KindAutoUpdateConfig}, {Kind: types.KindAutoUpdateVersion}, + {Kind: types.KindAutoUpdateAgentRollout}, {Kind: types.KindUserTask}, } cfg.QueueSize = defaults.ProxyQueueSize @@ -1994,6 +1996,29 @@ func (c *Cache) GetAutoUpdateVersion(ctx context.Context) (*autoupdate.AutoUpdat return rg.reader.GetAutoUpdateVersion(ctx) } +// GetAutoUpdateAgentRollout gets the AutoUpdateAgentRollout from the backend. +func (c *Cache) GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error) { + ctx, span := c.Tracer.Start(ctx, "cache/GetAutoUpdateAgentRollout") + defer span.End() + + rg, err := readCollectionCache(c, c.collections.autoUpdateAgentRollouts) + if err != nil { + return nil, trace.Wrap(err) + } + defer rg.Release() + if !rg.IsCacheRead() { + cachedAgentRollout, err := utils.FnCacheGet(ctx, c.fnCache, autoUpdateCacheKey{"rollout"}, func(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error) { + version, err := rg.reader.GetAutoUpdateAgentRollout(ctx) + return version, err + }) + if err != nil { + return nil, trace.Wrap(err) + } + return protobuf.Clone(cachedAgentRollout).(*autoupdate.AutoUpdateAgentRollout), nil + } + return rg.reader.GetAutoUpdateAgentRollout(ctx) +} + func (c *Cache) GetUIConfig(ctx context.Context) (types.UIConfig, error) { ctx, span := c.Tracer.Start(ctx, "cache/GetUIConfig") defer span.End() diff --git a/lib/cache/cache_test.go b/lib/cache/cache_test.go index 4176f637fc655..1139a68ecaeb4 100644 --- a/lib/cache/cache_test.go +++ b/lib/cache/cache_test.go @@ -2803,6 +2803,42 @@ func TestAutoUpdateVersion(t *testing.T) { }) } +// TestAutoUpdateAgentRollout tests that CRUD operations on AutoUpdateAgentRollout resource are +// replicated from the backend to the cache. +func TestAutoUpdateAgentRollout(t *testing.T) { + t.Parallel() + + p := newTestPack(t, ForAuth) + t.Cleanup(p.Close) + + testResources153(t, p, testFuncs153[*autoupdate.AutoUpdateAgentRollout]{ + newResource: func(name string) (*autoupdate.AutoUpdateAgentRollout, error) { + return newAutoUpdateAgentRollout(t), nil + }, + create: func(ctx context.Context, item *autoupdate.AutoUpdateAgentRollout) error { + _, err := p.autoUpdateService.UpsertAutoUpdateAgentRollout(ctx, item) + return trace.Wrap(err) + }, + list: func(ctx context.Context) ([]*autoupdate.AutoUpdateAgentRollout, error) { + item, err := p.autoUpdateService.GetAutoUpdateAgentRollout(ctx) + if trace.IsNotFound(err) { + return []*autoupdate.AutoUpdateAgentRollout{}, nil + } + return []*autoupdate.AutoUpdateAgentRollout{item}, trace.Wrap(err) + }, + cacheList: func(ctx context.Context) ([]*autoupdate.AutoUpdateAgentRollout, error) { + item, err := p.cache.GetAutoUpdateAgentRollout(ctx) + if trace.IsNotFound(err) { + return []*autoupdate.AutoUpdateAgentRollout{}, nil + } + return []*autoupdate.AutoUpdateAgentRollout{item}, trace.Wrap(err) + }, + deleteAll: func(ctx context.Context) error { + return trace.Wrap(p.autoUpdateService.DeleteAutoUpdateAgentRollout(ctx)) + }, + }) +} + // TestGlobalNotifications tests that CRUD operations on global notification resources are // replicated from the backend to the cache. func TestGlobalNotifications(t *testing.T) { @@ -3452,6 +3488,7 @@ func TestCacheWatchKindExistsInEvents(t *testing.T) { types.KindStaticHostUser: types.Resource153ToLegacy(newStaticHostUser(t, "test")), types.KindAutoUpdateConfig: types.Resource153ToLegacy(newAutoUpdateConfig(t)), types.KindAutoUpdateVersion: types.Resource153ToLegacy(newAutoUpdateVersion(t)), + types.KindAutoUpdateAgentRollout: types.Resource153ToLegacy(newAutoUpdateAgentRollout(t)), types.KindUserTask: types.Resource153ToLegacy(newUserTasks(t)), types.KindProvisioningPrincipalState: types.Resource153ToLegacy(newProvisioningPrincipalState("u-alice@example.com")), } @@ -4024,6 +4061,20 @@ func newAutoUpdateVersion(t *testing.T) *autoupdate.AutoUpdateVersion { return r } +func newAutoUpdateAgentRollout(t *testing.T) *autoupdate.AutoUpdateAgentRollout { + t.Helper() + + r, err := update.NewAutoUpdateAgentRollout(&autoupdate.AutoUpdateAgentRolloutSpec{ + StartVersion: "1.2.3", + TargetVersion: "2.3.4", + Schedule: "regular", + AutoupdateMode: "enabled", + Strategy: "time-based", + }) + require.NoError(t, err) + return r +} + func withKeepalive[T any](fn func(context.Context, T) (*types.KeepAlive, error)) func(context.Context, T) error { return func(ctx context.Context, resource T) error { _, err := fn(ctx, resource) diff --git a/lib/cache/collections.go b/lib/cache/collections.go index e072212a45784..130cfd303c23f 100644 --- a/lib/cache/collections.go +++ b/lib/cache/collections.go @@ -268,6 +268,7 @@ type cacheCollections struct { spiffeFederations collectionReader[SPIFFEFederationReader] autoUpdateConfigs collectionReader[autoUpdateConfigGetter] autoUpdateVersions collectionReader[autoUpdateVersionGetter] + autoUpdateAgentRollouts collectionReader[autoUpdateAgentRolloutGetter] provisioningStates collectionReader[provisioningStateGetter] } @@ -815,6 +816,15 @@ func setupCollections(c *Cache, watches []types.WatchKind) (*cacheCollections, e watch: watch, } collections.byKind[resourceKind] = collections.autoUpdateVersions + case types.KindAutoUpdateAgentRollout: + if c.AutoUpdateService == nil { + return nil, trace.BadParameter("missing parameter AutoUpdateService") + } + collections.autoUpdateAgentRollouts = &genericCollection[*autoupdate.AutoUpdateAgentRollout, autoUpdateAgentRolloutGetter, autoUpdateAgentRolloutExecutor]{ + cache: c, + watch: watch, + } + collections.byKind[resourceKind] = collections.autoUpdateAgentRollouts case types.KindProvisioningPrincipalState: if c.ProvisioningStates == nil { @@ -1370,6 +1380,41 @@ type autoUpdateVersionGetter interface { var _ executor[*autoupdate.AutoUpdateVersion, autoUpdateVersionGetter] = autoUpdateVersionExecutor{} +type autoUpdateAgentRolloutExecutor struct{} + +func (autoUpdateAgentRolloutExecutor) getAll(ctx context.Context, cache *Cache, loadSecrets bool) ([]*autoupdate.AutoUpdateAgentRollout, error) { + plan, err := cache.AutoUpdateService.GetAutoUpdateAgentRollout(ctx) + return []*autoupdate.AutoUpdateAgentRollout{plan}, trace.Wrap(err) +} + +func (autoUpdateAgentRolloutExecutor) upsert(ctx context.Context, cache *Cache, resource *autoupdate.AutoUpdateAgentRollout) error { + _, err := cache.autoUpdateCache.UpsertAutoUpdateAgentRollout(ctx, resource) + return trace.Wrap(err) +} + +func (autoUpdateAgentRolloutExecutor) deleteAll(ctx context.Context, cache *Cache) error { + return cache.autoUpdateCache.DeleteAutoUpdateAgentRollout(ctx) +} + +func (autoUpdateAgentRolloutExecutor) delete(ctx context.Context, cache *Cache, resource types.Resource) error { + return cache.autoUpdateCache.DeleteAutoUpdateAgentRollout(ctx) +} + +func (autoUpdateAgentRolloutExecutor) isSingleton() bool { return true } + +func (autoUpdateAgentRolloutExecutor) getReader(cache *Cache, cacheOK bool) autoUpdateAgentRolloutGetter { + if cacheOK { + return cache.autoUpdateCache + } + return cache.Config.AutoUpdateService +} + +type autoUpdateAgentRolloutGetter interface { + GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error) +} + +var _ executor[*autoupdate.AutoUpdateAgentRollout, autoUpdateAgentRolloutGetter] = autoUpdateAgentRolloutExecutor{} + type userExecutor struct{} func (userExecutor) getAll(ctx context.Context, cache *Cache, loadSecrets bool) ([]types.User, error) { diff --git a/lib/services/autoupdates.go b/lib/services/autoupdates.go index 5fa7a4eed4677..72d51b4ac2338 100644 --- a/lib/services/autoupdates.go +++ b/lib/services/autoupdates.go @@ -31,6 +31,9 @@ type AutoUpdateServiceGetter interface { // GetAutoUpdateVersion gets the AutoUpdateVersion singleton resource. GetAutoUpdateVersion(ctx context.Context) (*autoupdate.AutoUpdateVersion, error) + + // GetAutoUpdateAgentRollout gets the AutoUpdateAgentRollout singleton resource. + GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error) } // AutoUpdateService stores the autoupdate service. @@ -60,4 +63,16 @@ type AutoUpdateService interface { // DeleteAutoUpdateVersion deletes the AutoUpdateVersion singleton resource. DeleteAutoUpdateVersion(ctx context.Context) error + + // CreateAutoUpdateAgentRollout creates the AutoUpdateAgentRollout singleton resource. + CreateAutoUpdateAgentRollout(ctx context.Context, plan *autoupdate.AutoUpdateAgentRollout) (*autoupdate.AutoUpdateAgentRollout, error) + + // UpdateAutoUpdateAgentRollout updates the AutoUpdateAgentRollout singleton resource. + UpdateAutoUpdateAgentRollout(ctx context.Context, plan *autoupdate.AutoUpdateAgentRollout) (*autoupdate.AutoUpdateAgentRollout, error) + + // UpsertAutoUpdateAgentRollout sets the AutoUpdateAgentRollout singleton resource. + UpsertAutoUpdateAgentRollout(ctx context.Context, plan *autoupdate.AutoUpdateAgentRollout) (*autoupdate.AutoUpdateAgentRollout, error) + + // DeleteAutoUpdateAgentRollout deletes the AutoUpdateAgentRollout singleton resource. + DeleteAutoUpdateAgentRollout(ctx context.Context) error } diff --git a/lib/services/local/autoupdate.go b/lib/services/local/autoupdate.go index 27c505d4fb505..879e5348d1d4e 100644 --- a/lib/services/local/autoupdate.go +++ b/lib/services/local/autoupdate.go @@ -32,14 +32,16 @@ import ( ) const ( - autoUpdateConfigPrefix = "auto_update_config" - autoUpdateVersionPrefix = "auto_update_version" + autoUpdateConfigPrefix = "auto_update_config" + autoUpdateVersionPrefix = "auto_update_version" + autoUpdateAgentRolloutPrefix = "auto_update_agent_rollout" ) // AutoUpdateService is responsible for managing AutoUpdateConfig and AutoUpdateVersion singleton resources. type AutoUpdateService struct { config *generic.ServiceWrapper[*autoupdate.AutoUpdateConfig] version *generic.ServiceWrapper[*autoupdate.AutoUpdateVersion] + rollout *generic.ServiceWrapper[*autoupdate.AutoUpdateAgentRollout] } // NewAutoUpdateService returns a new AutoUpdateService. @@ -74,10 +76,26 @@ func NewAutoUpdateService(b backend.Backend) (*AutoUpdateService, error) { if err != nil { return nil, trace.Wrap(err) } + rollout, err := generic.NewServiceWrapper( + generic.ServiceWrapperConfig[*autoupdate.AutoUpdateAgentRollout]{ + Backend: b, + ResourceKind: types.KindAutoUpdateAgentRollout, + BackendPrefix: backend.NewKey(autoUpdateAgentRolloutPrefix), + MarshalFunc: services.MarshalProtoResource[*autoupdate.AutoUpdateAgentRollout], + UnmarshalFunc: services.UnmarshalProtoResource[*autoupdate.AutoUpdateAgentRollout], + ValidateFunc: update.ValidateAutoUpdateAgentRollout, + KeyFunc: func(_ *autoupdate.AutoUpdateAgentRollout) string { + return types.MetaNameAutoUpdateAgentRollout + }, + }) + if err != nil { + return nil, trace.Wrap(err) + } return &AutoUpdateService{ config: config, version: version, + rollout: rollout, }, nil } @@ -156,3 +174,41 @@ func (s *AutoUpdateService) GetAutoUpdateVersion(ctx context.Context) (*autoupda func (s *AutoUpdateService) DeleteAutoUpdateVersion(ctx context.Context) error { return trace.Wrap(s.version.DeleteResource(ctx, types.MetaNameAutoUpdateVersion)) } + +// CreateAutoUpdateAgentRollout creates the AutoUpdateAgentRollout singleton resource. +func (s *AutoUpdateService) CreateAutoUpdateAgentRollout( + ctx context.Context, + v *autoupdate.AutoUpdateAgentRollout, +) (*autoupdate.AutoUpdateAgentRollout, error) { + rollout, err := s.rollout.CreateResource(ctx, v) + return rollout, trace.Wrap(err) +} + +// UpdateAutoUpdateAgentRollout updates the AutoUpdateAgentRollout singleton resource. +func (s *AutoUpdateService) UpdateAutoUpdateAgentRollout( + ctx context.Context, + v *autoupdate.AutoUpdateAgentRollout, +) (*autoupdate.AutoUpdateAgentRollout, error) { + rollout, err := s.rollout.UpdateResource(ctx, v) + return rollout, trace.Wrap(err) +} + +// UpsertAutoUpdateAgentRollout sets the AutoUpdateAgentRollout singleton resource. +func (s *AutoUpdateService) UpsertAutoUpdateAgentRollout( + ctx context.Context, + v *autoupdate.AutoUpdateAgentRollout, +) (*autoupdate.AutoUpdateAgentRollout, error) { + rollout, err := s.rollout.UpsertResource(ctx, v) + return rollout, trace.Wrap(err) +} + +// GetAutoUpdateAgentRollout gets the AutoUpdateAgentRollout singleton resource. +func (s *AutoUpdateService) GetAutoUpdateAgentRollout(ctx context.Context) (*autoupdate.AutoUpdateAgentRollout, error) { + rollout, err := s.rollout.GetResource(ctx, types.MetaNameAutoUpdateAgentRollout) + return rollout, trace.Wrap(err) +} + +// DeleteAutoUpdateAgentRollout deletes the AutoUpdateAgentRollout singleton resource. +func (s *AutoUpdateService) DeleteAutoUpdateAgentRollout(ctx context.Context) error { + return trace.Wrap(s.rollout.DeleteResource(ctx, types.MetaNameAutoUpdateAgentRollout)) +} diff --git a/lib/services/local/events.go b/lib/services/local/events.go index f0ef42d33648a..09084857dbf06 100644 --- a/lib/services/local/events.go +++ b/lib/services/local/events.go @@ -100,6 +100,8 @@ func (e *EventsService) NewWatcher(ctx context.Context, watch types.Watch) (type parser = newAutoUpdateConfigParser() case types.KindAutoUpdateVersion: parser = newAutoUpdateVersionParser() + case types.KindAutoUpdateAgentRollout: + parser = newAutoUpdateAgentRolloutParser() case types.KindNamespace: parser = newNamespaceParser(kind.Name) case types.KindRole: @@ -812,6 +814,41 @@ func (p *autoUpdateVersionParser) parse(event backend.Event) (types.Resource, er } } +func newAutoUpdateAgentRolloutParser() *autoUpdateAgentRolloutParser { + return &autoUpdateAgentRolloutParser{ + baseParser: newBaseParser(backend.NewKey(autoUpdateAgentRolloutPrefix)), + } +} + +type autoUpdateAgentRolloutParser struct { + baseParser +} + +func (p *autoUpdateAgentRolloutParser) parse(event backend.Event) (types.Resource, error) { + switch event.Type { + case types.OpDelete: + return &types.ResourceHeader{ + Kind: types.KindAutoUpdateAgentRollout, + Version: types.V1, + Metadata: types.Metadata{ + Name: types.MetaNameAutoUpdateAgentRollout, + Namespace: apidefaults.Namespace, + }, + }, nil + case types.OpPut: + autoUpdateAgentRollout, err := services.UnmarshalProtoResource[*autoupdate.AutoUpdateAgentRollout](event.Item.Value, + services.WithExpires(event.Item.Expires), + services.WithRevision(event.Item.Revision), + ) + if err != nil { + return nil, trace.Wrap(err) + } + return types.Resource153ToLegacy(autoUpdateAgentRollout), nil + default: + return nil, trace.BadParameter("event %v is not supported", event.Type) + } +} + func newNamespaceParser(name string) *namespaceParser { prefix := backend.NewKey(namespacesPrefix) if name != "" { diff --git a/lib/services/resource.go b/lib/services/resource.go index 819ec724cdc81..fdb886e0a25ca 100644 --- a/lib/services/resource.go +++ b/lib/services/resource.go @@ -253,6 +253,8 @@ func ParseShortcut(in string) (string, error) { return types.KindAutoUpdateConfig, nil case types.KindAutoUpdateVersion: return types.KindAutoUpdateVersion, nil + case types.KindAutoUpdateAgentRollout: + return types.KindAutoUpdateAgentRollout, nil } return "", trace.BadParameter("unsupported resource: %q - resources should be expressed as 'type/name', for example 'connector/github'", in) }