diff --git a/internal/pkg/api/handleCheckin.go b/internal/pkg/api/handleCheckin.go index 1aa720f8a7..95ed9a847b 100644 --- a/internal/pkg/api/handleCheckin.go +++ b/internal/pkg/api/handleCheckin.go @@ -167,12 +167,13 @@ func invalidateAPIKeysOfInactiveAgent(ctx context.Context, zlog zerolog.Logger, // validatedCheckin is a struct to wrap all the things that validateRequest returns. type validatedCheckin struct { - req *CheckinRequest - dur time.Duration - rawMeta []byte - rawComp []byte - seqno sqn.SeqNo - unhealthyReason *[]string + req *CheckinRequest + dur time.Duration + rawMeta []byte + rawComp []byte + seqno sqn.SeqNo + unhealthyReason *[]string + rawAvailableRollbacks []byte } func (ct *CheckinT) validateRequest(zlog zerolog.Logger, w http.ResponseWriter, r *http.Request, start time.Time, agent *model.Agent) (validatedCheckin, error) { @@ -251,13 +252,19 @@ func (ct *CheckinT) validateRequest(zlog zerolog.Logger, w http.ResponseWriter, return val, err } + rawRollbacks, err := parseAvailableRollbacks(zlog, agent, &req) + if err != nil { + return val, err + } + return validatedCheckin{ - req: &req, - dur: pollDuration, - rawMeta: rawMeta, - rawComp: rawComponents, - seqno: seqno, - unhealthyReason: unhealthyReason, + req: &req, + dur: pollDuration, + rawMeta: rawMeta, + rawComp: rawComponents, + seqno: seqno, + unhealthyReason: unhealthyReason, + rawAvailableRollbacks: rawRollbacks, }, nil } @@ -290,6 +297,7 @@ func (ct *CheckinT) ProcessRequest(zlog zerolog.Logger, w http.ResponseWriter, r checkin.WithVer(ver), checkin.WithUnhealthyReason(unhealthyReason), checkin.WithDeleteAudit(agent.AuditUnenrolledReason != "" || agent.UnenrolledAt != ""), + checkin.WithAvailableRollbacks(validated.rawAvailableRollbacks), } revID, opts, err := ct.processPolicyDetails(r.Context(), zlog, agent, req) @@ -1132,6 +1140,39 @@ func parseComponents(zlog zerolog.Logger, agent *model.Agent, req *CheckinReques return outComponents, &unhealthyReason, nil } +func parseAvailableRollbacks(zlog zerolog.Logger, agent *model.Agent, req *CheckinRequest) ([]byte, error) { + var reqRollbacks []model.AvailableRollback + if req.AvailableRollbacks != nil { + reqRollbacks = make([]model.AvailableRollback, len(*req.AvailableRollbacks)) + for i, rr := range *req.AvailableRollbacks { + reqRollbacks[i] = model.AvailableRollback{ + ValidUntil: rr.ValidUntil.UTC().Format(time.RFC3339), + Version: rr.Version, + } + } + } else { + // still set an empty slice in order to clear obsolete information, if any + reqRollbacks = []model.AvailableRollback{} + } + + var outRollbacks []byte + // Compare the deserialized meta structures and return the bytes to update if different + if !reflect.DeepEqual(reqRollbacks, agent.AvailableRollbacks) { + zlog.Trace(). + Any("oldAvailableRollbacks", agent.AvailableRollbacks). + Any("req.AvailableRollbacks", req.AvailableRollbacks). + Msg("available rollback data is not equal") + + zlog.Info().Msg("applying new rollback data") + marshalled, err := json.Marshal(reqRollbacks) + if err != nil { + return nil, fmt.Errorf("marshalling available rollbacks: %w", err) + } + outRollbacks = marshalled + } + return outRollbacks, nil +} + func calcUnhealthyReason(reqComponents []model.ComponentsItems) []string { var unhealthyReason []string hasUnhealthyInput := false diff --git a/internal/pkg/api/handleCheckin_test.go b/internal/pkg/api/handleCheckin_test.go index a02bd9c0e7..7181f1eeb7 100644 --- a/internal/pkg/api/handleCheckin_test.go +++ b/internal/pkg/api/handleCheckin_test.go @@ -1149,7 +1149,25 @@ func TestValidateCheckinRequest(t *testing.T) { }, }, expValid: validatedCheckin{ - rawMeta: []byte(`{"elastic": {"agent": {"id": "testid", "fips": true}}}`), + rawMeta: []byte(`{"elastic": {"agent": {"id": "testid", "fips": true}}}`), + rawAvailableRollbacks: []byte(`[]`), + }, + }, + { + name: "Available rollbacks are correctly parsed", + req: &http.Request{ + Body: io.NopCloser(strings.NewReader(`{"validJson": "test", "status": "test", "message": "test message", "available_rollbacks": [{"version": "1.2.3-SNAPSHOT", "valid_until": "2025-11-27T15:12:44Z"}]}`)), + }, + cfg: &config.Server{ + Limits: config.ServerLimits{ + CheckinLimit: config.Limit{ + MaxBody: 0, + }, + }, + }, + expErr: nil, + expValid: validatedCheckin{ + rawAvailableRollbacks: []byte(`[{"version": "1.2.3-SNAPSHOT", "valid_until": "2025-11-27T15:12:44Z"}]`), }, }, } @@ -1164,6 +1182,7 @@ func TestValidateCheckinRequest(t *testing.T) { if tc.expErr == nil { assert.NoError(t, err) assert.Equal(t, tc.expValid.rawMeta, valid.rawMeta) + assert.JSONEq(t, string(tc.expValid.rawAvailableRollbacks), string(valid.rawAvailableRollbacks)) } else { // Asserting error messages prior to ErrorAs becuase ErrorAs modifies // the target error. If we assert error messages after calling ErrorAs diff --git a/internal/pkg/api/openapi.gen.go b/internal/pkg/api/openapi.gen.go index 0bc7b6d9d3..856f60c461 100644 --- a/internal/pkg/api/openapi.gen.go +++ b/internal/pkg/api/openapi.gen.go @@ -308,6 +308,15 @@ type AuditUnenrollRequest struct { // AuditUnenrollRequestReason The unenroll reason type AuditUnenrollRequestReason string +// AvailableRollbacks Target versions available for a rollback +type AvailableRollbacks = []struct { + // ValidUntil timestamp indicating when the rollback target will expire + ValidUntil time.Time `json:"valid_until"` + + // Version version of the available rollback target, represented as string + Version string `json:"version"` +} + // CheckinRequest defines model for checkinRequest. type CheckinRequest struct { // AckToken The ack_token form a previous response if the agent has checked in before. @@ -317,6 +326,9 @@ type CheckinRequest struct { // AgentPolicyId The ID of the policy that the agent is currently running. AgentPolicyId *string `json:"agent_policy_id,omitempty"` + // AvailableRollbacks Target versions available for a rollback + AvailableRollbacks *AvailableRollbacks `json:"available_rollbacks,omitempty"` + // Components An embedded JSON object that holds component information that the agent is running. // Defined in fleet-server as a `json.RawMessage`, defined as an object in the elastic-agent. // fleet-server will update the components in an agent record if they differ from this object. diff --git a/internal/pkg/checkin/bulk.go b/internal/pkg/checkin/bulk.go index a2304ffd85..eddf8678d0 100644 --- a/internal/pkg/checkin/bulk.go +++ b/internal/pkg/checkin/bulk.go @@ -125,12 +125,22 @@ func WithPolicyRevisionIDX(idx int64) Option { } } +func WithAvailableRollbacks(availableRollbacks []byte) Option { + return func(pending *pendingT) { + if pending.extra == nil { + pending.extra = &extraT{} + } + pending.extra.availableRollbacks = availableRollbacks + } +} + type extraT struct { - meta []byte - seqNo sqn.SeqNo - ver string - components []byte - deleteAudit bool + meta []byte + seqNo sqn.SeqNo + ver string + components []byte + deleteAudit bool + availableRollbacks []byte } // Minimize the size of this structure. @@ -358,6 +368,10 @@ func toUpdateBody(now string, pending pendingT) ([]byte, error) { if pending.extra.seqNo.IsSet() { fields[dl.FieldActionSeqNo] = pending.extra.seqNo } + + if pending.extra.availableRollbacks != nil { + fields[dl.FieldAvailableRollbacks] = json.RawMessage(pending.extra.availableRollbacks) + } } return fields.Marshal() } diff --git a/internal/pkg/dl/constants.go b/internal/pkg/dl/constants.go index 7937d62877..615699c099 100644 --- a/internal/pkg/dl/constants.go +++ b/internal/pkg/dl/constants.go @@ -45,17 +45,17 @@ const ( FieldUnenrolledReason = "unenrolled_reason" FiledType = "type" FieldUnhealthyReason = "unhealthy_reason" - - FieldActive = "active" - FieldNamespaces = "namespaces" - FieldTags = "tags" - FieldUpdatedAt = "updated_at" - FieldUnenrolledAt = "unenrolled_at" - FieldUpgradedAt = "upgraded_at" - FieldUpgradeStartedAt = "upgrade_started_at" - FieldUpgradeStatus = "upgrade_status" - FieldUpgradeDetails = "upgrade_details" - FieldUpgradeAttempts = "upgrade_attempts" + FieldAvailableRollbacks = "available_rollbacks" + FieldActive = "active" + FieldNamespaces = "namespaces" + FieldTags = "tags" + FieldUpdatedAt = "updated_at" + FieldUnenrolledAt = "unenrolled_at" + FieldUpgradedAt = "upgraded_at" + FieldUpgradeStartedAt = "upgrade_started_at" + FieldUpgradeStatus = "upgrade_status" + FieldUpgradeDetails = "upgrade_details" + FieldUpgradeAttempts = "upgrade_attempts" FieldAuditUnenrolledTime = "audit_unenrolled_time" FieldAuditUnenrolledReason = "audit_unenrolled_reason" diff --git a/internal/pkg/model/schema.go b/internal/pkg/model/schema.go index f462dbc71d..8dd564c0f6 100644 --- a/internal/pkg/model/schema.go +++ b/internal/pkg/model/schema.go @@ -141,6 +141,9 @@ type Agent struct { // Agent timestamp for audit unenroll/uninstall action AuditUnenrolledTime string `json:"audit_unenrolled_time,omitempty"` + // list of available rollbacks for the agent + AvailableRollbacks []AvailableRollback `json:"available_rollbacks,omitempty"` + // Elastic Agent components detailed status information Components json.RawMessage `json:"components,omitempty"` @@ -286,6 +289,12 @@ type Artifact struct { PackageName string `json:"package_name,omitempty"` } +// AvailableRollback +type AvailableRollback struct { + ValidUntil string `json:"valid_until,omitempty"` + Version string `json:"version,omitempty"` +} + // Checkin An Elastic Agent checkin to Fleet type Checkin struct { ESDocument diff --git a/model/openapi.yml b/model/openapi.yml index 6d10389128..34ef6d5e75 100644 --- a/model/openapi.yml +++ b/model/openapi.yml @@ -371,6 +371,23 @@ components: - $ref: "#/components/schemas/upgrade_metadata_scheduled" - $ref: "#/components/schemas/upgrade_metadata_downloading" - $ref: "#/components/schemas/upgrade_metadata_failed" + available_rollbacks: + description: | + Target versions available for a rollback + type: array + items: + type: object + required: + - version + - valid_until + properties: + version: + description: version of the available rollback target, represented as string + type: string + valid_until: + description: timestamp indicating when the rollback target will expire + type: string + format: date-time checkinRequest: type: object required: @@ -431,6 +448,8 @@ components: The revision of the policy that the agent is currently running. type: integer format: int64 + available_rollbacks: + $ref: "#/components/schemas/available_rollbacks" actionSignature: description: Optional action signing data. type: object diff --git a/model/schema.json b/model/schema.json index bc65f19dbd..f87a8a60c3 100644 --- a/model/schema.json +++ b/model/schema.json @@ -514,6 +514,20 @@ } }, + "available_rollback": { + "title": "AvailableRollback", + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "valid_until": { + "type": "string", + "format": "date-time" + } + } + }, + "agent": { "title": "Agent", "description": "An Elastic Agent that has enrolled into Fleet", @@ -717,6 +731,13 @@ "replace_token": { "description": "hash of token provided during enrollment that allows replacement by another enrollment with same ID", "type": "string" + }, + "available_rollbacks": { + "description": "list of available rollbacks for the agent", + "type": "array", + "items": { + "$ref": "#/definitions/available_rollback" + } } }, "required": ["_id", "type", "active", "enrolled_at", "status"] diff --git a/pkg/api/types.gen.go b/pkg/api/types.gen.go index 9f9f78e24f..a09d6740ff 100644 --- a/pkg/api/types.gen.go +++ b/pkg/api/types.gen.go @@ -305,6 +305,15 @@ type AuditUnenrollRequest struct { // AuditUnenrollRequestReason The unenroll reason type AuditUnenrollRequestReason string +// AvailableRollbacks Target versions available for a rollback +type AvailableRollbacks = []struct { + // ValidUntil timestamp indicating when the rollback target will expire + ValidUntil time.Time `json:"valid_until"` + + // Version version of the available rollback target, represented as string + Version string `json:"version"` +} + // CheckinRequest defines model for checkinRequest. type CheckinRequest struct { // AckToken The ack_token form a previous response if the agent has checked in before. @@ -314,6 +323,9 @@ type CheckinRequest struct { // AgentPolicyId The ID of the policy that the agent is currently running. AgentPolicyId *string `json:"agent_policy_id,omitempty"` + // AvailableRollbacks Target versions available for a rollback + AvailableRollbacks *AvailableRollbacks `json:"available_rollbacks,omitempty"` + // Components An embedded JSON object that holds component information that the agent is running. // Defined in fleet-server as a `json.RawMessage`, defined as an object in the elastic-agent. // fleet-server will update the components in an agent record if they differ from this object.