From f2fa582f489ecb9d6371c582a51a1196085a5c18 Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Tue, 4 Aug 2026 20:38:22 +0200 Subject: [PATCH 1/9] feat: expose endpoint with result history Signed-off-by: Olivier Vernin --- CONTRIBUTING.md | 1 + docs/docs.go | 572 +++++++++++++++++- docs/swagger.json | 572 +++++++++++++++++- docs/swagger.yaml | 430 ++++++++++++- pkg/database/database_test.go | 76 +++ ...elineReports_denormalized_columns.down.sql | 7 + ...ipelineReports_denormalized_columns.up.sql | 24 + pkg/database/report.go | 402 ++++++++++-- pkg/database/time_utils.go | 69 ++- pkg/server/endpoints.go | 2 + pkg/server/endpoints_test.go | 450 +++++++++++++- pkg/server/labeldb_handlers.go | 55 +- pkg/server/report_handlers.go | 178 ++++++ pkg/server/var.go | 33 +- 14 files changed, 2741 insertions(+), 130 deletions(-) create mode 100644 pkg/database/migrations/000010_fix_pipelineReports_denormalized_columns.down.sql create mode 100644 pkg/database/migrations/000010_fix_pipelineReports_denormalized_columns.up.sql diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1748bb4b..c809db4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,7 @@ The file `pkg/server/main.go` contains the following endpoint: * `/api/pipeline/scms`[GET] * `/api/pipeline/reports`[GET][POST] * `/api/pipeline/reports/:id`[GET][PUT][DELETE] +* `/api/pipeline/reports/summary`[POST] ## 3. Frontend diff --git a/docs/docs.go b/docs/docs.go index d5634be6..669b9c37 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -441,6 +441,131 @@ const docTemplate = `{ } } }, + "/api/pipeline/labels": { + "get": { + "description": "List labels data from the database with optional filtering", + "tags": [ + "Labels" + ], + "summary": "List labels", + "parameters": [ + { + "type": "string", + "description": "Filter by label ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Filter by label key", + "name": "key", + "in": "query" + }, + { + "type": "string", + "description": "Filter by label value", + "name": "value", + "in": "query" + }, + { + "type": "string", + "description": "Return only unique label keys (true/false)", + "name": "keyonly", + "in": "query" + }, + { + "type": "string", + "description": "Limit the number of labels returned, default is 100", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Page number for pagination, default is 1", + "name": "page", + "in": "query" + }, + { + "type": "string", + "description": "Start time for filtering labels (RFC3339 format)", + "name": "start_time", + "in": "query" + }, + { + "type": "string", + "description": "End time for filtering labels (RFC3339 format)", + "name": "end_time", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.ListLabelsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/pipeline/labels/search": { + "post": { + "description": "Search labels in the database using advanced filtering", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Labels" + ], + "summary": "Search labels", + "parameters": [ + { + "description": "Search parameters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.SearchLabelsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.ListLabelsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, "/api/pipeline/reports": { "get": { "description": "List all pipeline reports from the database", @@ -520,6 +645,12 @@ const docTemplate = `{ "$ref": "#/definitions/server.CreatePipelineReportResponse" } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -578,6 +709,52 @@ const docTemplate = `{ } } }, + "/api/pipeline/reports/summary": { + "post": { + "description": "Return the number of pipeline reports per result for each time bucket of the requested time range.\nBuckets are UTC hours, UTC calendar days, ISO weeks or calendar months depending on the\ngranularity, and the date of an entry is the start of its bucket, formatted as RFC3339.\nEvery report is counted, including several reports of the same pipeline, and buckets without\nany report are returned with a zeroed entry.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Pipeline Reports" + ], + "summary": "Summarize pipeline reports", + "parameters": [ + { + "description": "Summary filters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.SearchPipelineReportsSummaryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.SearchPipelineReportsSummaryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, "/api/pipeline/reports/{id}": { "get": { "description": "Get the latest pipeline report for a specific ID", @@ -595,8 +772,8 @@ const docTemplate = `{ } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { "$ref": "#/definitions/server.GetPipelineReportByIDResponse" } @@ -661,8 +838,8 @@ const docTemplate = `{ } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { "$ref": "#/definitions/server.DefaultResponseModel" } @@ -736,6 +913,58 @@ const docTemplate = `{ "responses": { "200": { "description": "OK", + "schema": { + "$ref": "#/definitions/server.ListSCMsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/pipeline/scms/search": { + "post": { + "description": "Search SCM data using JSON filters. When summary is true, the response contains SCM summary data for all matching SCMs.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SCMs" + ], + "summary": "Search SCMs", + "parameters": [ + { + "description": "SCM search filters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.SearchSCMsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.ListSCMsResponse" + } + }, + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/server.DefaultResponseModel" } @@ -812,9 +1041,37 @@ const docTemplate = `{ } } }, + "database.ReportResultSummaryEntry": { + "type": "object", + "properties": { + "date": { + "description": "Date is the start of the bucket, in UTC, formatted as RFC3339.", + "type": "string" + }, + "results": { + "description": "Results contains the number of reports per Updatecli result for that bucket.", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "total": { + "description": "Total is the number of reports for that bucket, all results combined.", + "type": "integer" + } + } + }, "database.SearchLatestReportData": { "type": "object", "properties": { + "conditionConfigIDs": { + "description": "ConditionConfigIDs contains the config condition IDs associated with the report.", + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] + }, "createdAt": { "description": "CreatedAt represents the creation date of the report.", "type": "string" @@ -843,6 +1100,22 @@ const docTemplate = `{ "description": "Result represents the result of the report.", "type": "string" }, + "sourceConfigIDs": { + "description": "SourceConfigIDs contains the config source IDs associated with the report.", + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] + }, + "targetConfigIDs": { + "description": "TargetConfigIDs contains the config target IDs associated with the report.", + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] + }, "updatedAt": { "description": "UpdatedAt represents the last update date of the report.", "type": "string" @@ -926,15 +1199,45 @@ const docTemplate = `{ } } }, + "model.Label": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is the time the label was created", + "type": "string" + }, + "id": { + "description": "ID is a unique identifier for the label, generated as a UUID.", + "type": "string" + }, + "key": { + "description": "Key is the label name", + "type": "string" + }, + "last_pipeline_report_at": { + "description": "LastPipelineReportAt is the time the label was last used in a pipeline report", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt is the time the label was last updated", + "type": "string" + }, + "value": { + "description": "Value is the value associated with the label", + "type": "string" + } + } + }, "model.PipelineReport": { "type": "object", "properties": { "conditionConfigIDs": { "description": "ConditionConfigIDs is a list of unique identifiers of the condition configuration associated with the database.", - "type": "object", - "additionalProperties": { - "type": "string" - } + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] }, "created_at": { "description": "Create_at represent the creation date of the record.", @@ -944,6 +1247,13 @@ const docTemplate = `{ "description": "ID is the unique identifier of the record in the database.", "type": "string" }, + "labelIDs": { + "description": "LabelIDs is a list of unique identifiers of the labels associated with the database.", + "type": "array", + "items": { + "type": "string" + } + }, "pipeline": { "description": "Pipeline represent the Updatecli pipeline report.", "allOf": [ @@ -966,17 +1276,19 @@ const docTemplate = `{ }, "sourceConfigIDs": { "description": "SourceConfigIDs is a list of unique identifiers of the source configuration associated with the database.", - "type": "object", - "additionalProperties": { - "type": "string" - } + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] }, "targetConfigIDs": { "description": "TargetConfigIDs is a list of unique identifiers of the target configuration associated with the database.", - "type": "object", - "additionalProperties": { - "type": "string" - } + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] }, "targetScmIDs": { "description": "TargetScmIDs is a list of unique identifiers of the scm configuration associated with the database.", @@ -991,6 +1303,37 @@ const docTemplate = `{ } } }, + "model.SCM": { + "type": "object", + "properties": { + "branch": { + "description": "Branch is the Git branch", + "type": "string" + }, + "created_at": { + "description": "Created_at is the time the SCM configuration was created", + "type": "string" + }, + "id": { + "description": "ID is a unique identifier for the SCM configuration", + "type": "string" + }, + "updated_at": { + "description": "Updated_at is the time the SCM configuration was last updated", + "type": "string" + }, + "url": { + "description": "URL is the Git repository URL", + "type": "string" + } + } + }, + "pgtype.Hstore": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "reports.Action": { "type": "object", "properties": { @@ -1064,6 +1407,17 @@ const docTemplate = `{ } } }, + "reports.CIData": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, "reports.PipelineURL": { "type": "object", "properties": { @@ -1086,6 +1440,9 @@ const docTemplate = `{ "$ref": "#/definitions/reports.Action" } }, + "ci": { + "$ref": "#/definitions/reports.CIData" + }, "conditions": { "type": "object", "additionalProperties": { @@ -1102,6 +1459,12 @@ const docTemplate = `{ "description": "ID defines the report ID", "type": "string" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "name": { "type": "string" }, @@ -1445,6 +1808,183 @@ const docTemplate = `{ } } }, + "server.ListLabelsResponse": { + "type": "object", + "properties": { + "labels": { + "description": "Labels is a list of labels.", + "type": "array", + "items": { + "$ref": "#/definitions/model.Label" + } + }, + "total_count": { + "description": "TotalCount is the total number of labels matching the query.", + "type": "integer" + } + } + }, + "server.ListSCMsResponse": { + "type": "object", + "properties": { + "scms": { + "description": "SCMs is a list of SCMs.", + "type": "array", + "items": { + "$ref": "#/definitions/model.SCM" + } + }, + "total_count": { + "description": "TotalCount is the total number of SCMs matching the query.", + "type": "integer" + } + } + }, + "server.SearchLabelsRequest": { + "type": "object", + "properties": { + "end_time": { + "description": "EndTime is the end time for the time range filter\nThis is optional and can be used to filter labels by a specific end time\nTime format is RFC3339: 2006-01-02T15:04:05Z07:00", + "type": "string" + }, + "id": { + "description": "Id is the unique identifier of the label.", + "type": "string" + }, + "key": { + "description": "Key is the key of the label.", + "type": "string" + }, + "key_only": { + "description": "KeyOnly specifies if we only need to retrieve a list of uniq Label keys", + "type": "boolean" + }, + "limit": { + "description": "Limit is the maximum number of labels to return\nThis is optional and can be used to limit the number of labels returned", + "type": "integer" + }, + "page": { + "description": "Page is the page number for pagination\nThis is optional and can be used to paginate the results", + "type": "integer" + }, + "start_time": { + "description": "StartTime is the start time for the time range filter\nThis is optional and can be used to filter labels by a specific start time\nTime format is RFC3339: 2006-01-02T15:04:05Z07:00", + "type": "string" + }, + "value": { + "description": "Value is the value of the label.", + "type": "string" + } + } + }, + "server.SearchPipelineReportsSummaryRequest": { + "type": "object", + "properties": { + "days": { + "description": "Days is the number of days to summarize, today included.\nIt defaults to 7 and is ignored when hours, or start_time and end_time, are provided.", + "type": "integer" + }, + "end_time": { + "description": "EndTime is the end time for the time range filter.\nTime format is: 2006-01-02 15:04:05Z07:00", + "type": "string" + }, + "granularity": { + "description": "Granularity is the size of the time buckets, one of \"hour\", \"day\", \"week\" or\n\"month\". It defaults to \"day\".", + "type": "string" + }, + "hours": { + "description": "Hours is the number of hours to summarize, the current hour included.\nIt cannot be combined with days and is ignored when start_time and end_time are provided.", + "type": "integer" + }, + "labels": { + "description": "Labels is a map of labels to filter reports by.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "metric": { + "description": "Metric is what the reports are counted by. It defaults to \"result\", which is\nthe only value supported so far.", + "type": "string" + }, + "scmid": { + "description": "ScmID is the ID of the SCM to filter reports by.\nUse \"none\" to only count the reports which are not attached to any SCM.", + "type": "string" + }, + "start_time": { + "description": "StartTime is the start time for the time range filter.\nTime format is: 2006-01-02 15:04:05Z07:00", + "type": "string" + } + } + }, + "server.SearchPipelineReportsSummaryResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data contains one entry per time bucket, ordered from the oldest to the most recent one.", + "type": "array", + "items": { + "$ref": "#/definitions/database.ReportResultSummaryEntry" + } + }, + "granularity": { + "description": "Granularity is the size of the time buckets of the entries.", + "type": "string" + }, + "metric": { + "description": "Metric is the metric the reports were counted by.", + "type": "string" + }, + "total_count": { + "description": "TotalCount is the total number of reports matching the query.", + "type": "integer" + } + } + }, + "server.SearchSCMsRequest": { + "type": "object", + "properties": { + "branch": { + "description": "Branch is the SCM branch to filter by.", + "type": "string" + }, + "end_time": { + "description": "EndTime is the end time for the time range filter.\nTime format is RFC3339: 2006-01-02T15:04:05Z07:00", + "type": "string" + }, + "labels": { + "description": "Labels filters SCM summaries by report labels.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "limit": { + "description": "Limit is the maximum number of SCMs to return.", + "type": "integer" + }, + "page": { + "description": "Page is the page number for pagination.", + "type": "integer" + }, + "scmid": { + "description": "ScmID is the ID of the SCM to filter by.", + "type": "string" + }, + "start_time": { + "description": "StartTime is the start time for the time range filter.\nTime format is RFC3339: 2006-01-02T15:04:05Z07:00", + "type": "string" + }, + "summary": { + "description": "Summary indicates if the response should contain SCM summary data.", + "type": "boolean" + }, + "url": { + "description": "URL is the SCM URL to filter by.", + "type": "string" + } + } + }, "server.SourceConfigResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 3b7d0d95..97557645 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -430,6 +430,131 @@ } } }, + "/api/pipeline/labels": { + "get": { + "description": "List labels data from the database with optional filtering", + "tags": [ + "Labels" + ], + "summary": "List labels", + "parameters": [ + { + "type": "string", + "description": "Filter by label ID", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "Filter by label key", + "name": "key", + "in": "query" + }, + { + "type": "string", + "description": "Filter by label value", + "name": "value", + "in": "query" + }, + { + "type": "string", + "description": "Return only unique label keys (true/false)", + "name": "keyonly", + "in": "query" + }, + { + "type": "string", + "description": "Limit the number of labels returned, default is 100", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Page number for pagination, default is 1", + "name": "page", + "in": "query" + }, + { + "type": "string", + "description": "Start time for filtering labels (RFC3339 format)", + "name": "start_time", + "in": "query" + }, + { + "type": "string", + "description": "End time for filtering labels (RFC3339 format)", + "name": "end_time", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.ListLabelsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/pipeline/labels/search": { + "post": { + "description": "Search labels in the database using advanced filtering", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Labels" + ], + "summary": "Search labels", + "parameters": [ + { + "description": "Search parameters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.SearchLabelsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.ListLabelsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, "/api/pipeline/reports": { "get": { "description": "List all pipeline reports from the database", @@ -509,6 +634,12 @@ "$ref": "#/definitions/server.CreatePipelineReportResponse" } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -567,6 +698,52 @@ } } }, + "/api/pipeline/reports/summary": { + "post": { + "description": "Return the number of pipeline reports per result for each time bucket of the requested time range.\nBuckets are UTC hours, UTC calendar days, ISO weeks or calendar months depending on the\ngranularity, and the date of an entry is the start of its bucket, formatted as RFC3339.\nEvery report is counted, including several reports of the same pipeline, and buckets without\nany report are returned with a zeroed entry.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Pipeline Reports" + ], + "summary": "Summarize pipeline reports", + "parameters": [ + { + "description": "Summary filters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.SearchPipelineReportsSummaryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.SearchPipelineReportsSummaryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, "/api/pipeline/reports/{id}": { "get": { "description": "Get the latest pipeline report for a specific ID", @@ -584,8 +761,8 @@ } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { "$ref": "#/definitions/server.GetPipelineReportByIDResponse" } @@ -650,8 +827,8 @@ } ], "responses": { - "201": { - "description": "Created", + "200": { + "description": "OK", "schema": { "$ref": "#/definitions/server.DefaultResponseModel" } @@ -725,6 +902,58 @@ "responses": { "200": { "description": "OK", + "schema": { + "$ref": "#/definitions/server.ListSCMsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/pipeline/scms/search": { + "post": { + "description": "Search SCM data using JSON filters. When summary is true, the response contains SCM summary data for all matching SCMs.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "SCMs" + ], + "summary": "Search SCMs", + "parameters": [ + { + "description": "SCM search filters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.SearchSCMsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.ListSCMsResponse" + } + }, + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/server.DefaultResponseModel" } @@ -801,9 +1030,37 @@ } } }, + "database.ReportResultSummaryEntry": { + "type": "object", + "properties": { + "date": { + "description": "Date is the start of the bucket, in UTC, formatted as RFC3339.", + "type": "string" + }, + "results": { + "description": "Results contains the number of reports per Updatecli result for that bucket.", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "total": { + "description": "Total is the number of reports for that bucket, all results combined.", + "type": "integer" + } + } + }, "database.SearchLatestReportData": { "type": "object", "properties": { + "conditionConfigIDs": { + "description": "ConditionConfigIDs contains the config condition IDs associated with the report.", + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] + }, "createdAt": { "description": "CreatedAt represents the creation date of the report.", "type": "string" @@ -832,6 +1089,22 @@ "description": "Result represents the result of the report.", "type": "string" }, + "sourceConfigIDs": { + "description": "SourceConfigIDs contains the config source IDs associated with the report.", + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] + }, + "targetConfigIDs": { + "description": "TargetConfigIDs contains the config target IDs associated with the report.", + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] + }, "updatedAt": { "description": "UpdatedAt represents the last update date of the report.", "type": "string" @@ -915,15 +1188,45 @@ } } }, + "model.Label": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is the time the label was created", + "type": "string" + }, + "id": { + "description": "ID is a unique identifier for the label, generated as a UUID.", + "type": "string" + }, + "key": { + "description": "Key is the label name", + "type": "string" + }, + "last_pipeline_report_at": { + "description": "LastPipelineReportAt is the time the label was last used in a pipeline report", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt is the time the label was last updated", + "type": "string" + }, + "value": { + "description": "Value is the value associated with the label", + "type": "string" + } + } + }, "model.PipelineReport": { "type": "object", "properties": { "conditionConfigIDs": { "description": "ConditionConfigIDs is a list of unique identifiers of the condition configuration associated with the database.", - "type": "object", - "additionalProperties": { - "type": "string" - } + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] }, "created_at": { "description": "Create_at represent the creation date of the record.", @@ -933,6 +1236,13 @@ "description": "ID is the unique identifier of the record in the database.", "type": "string" }, + "labelIDs": { + "description": "LabelIDs is a list of unique identifiers of the labels associated with the database.", + "type": "array", + "items": { + "type": "string" + } + }, "pipeline": { "description": "Pipeline represent the Updatecli pipeline report.", "allOf": [ @@ -955,17 +1265,19 @@ }, "sourceConfigIDs": { "description": "SourceConfigIDs is a list of unique identifiers of the source configuration associated with the database.", - "type": "object", - "additionalProperties": { - "type": "string" - } + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] }, "targetConfigIDs": { "description": "TargetConfigIDs is a list of unique identifiers of the target configuration associated with the database.", - "type": "object", - "additionalProperties": { - "type": "string" - } + "allOf": [ + { + "$ref": "#/definitions/pgtype.Hstore" + } + ] }, "targetScmIDs": { "description": "TargetScmIDs is a list of unique identifiers of the scm configuration associated with the database.", @@ -980,6 +1292,37 @@ } } }, + "model.SCM": { + "type": "object", + "properties": { + "branch": { + "description": "Branch is the Git branch", + "type": "string" + }, + "created_at": { + "description": "Created_at is the time the SCM configuration was created", + "type": "string" + }, + "id": { + "description": "ID is a unique identifier for the SCM configuration", + "type": "string" + }, + "updated_at": { + "description": "Updated_at is the time the SCM configuration was last updated", + "type": "string" + }, + "url": { + "description": "URL is the Git repository URL", + "type": "string" + } + } + }, + "pgtype.Hstore": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "reports.Action": { "type": "object", "properties": { @@ -1053,6 +1396,17 @@ } } }, + "reports.CIData": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, "reports.PipelineURL": { "type": "object", "properties": { @@ -1075,6 +1429,9 @@ "$ref": "#/definitions/reports.Action" } }, + "ci": { + "$ref": "#/definitions/reports.CIData" + }, "conditions": { "type": "object", "additionalProperties": { @@ -1091,6 +1448,12 @@ "description": "ID defines the report ID", "type": "string" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "name": { "type": "string" }, @@ -1434,6 +1797,183 @@ } } }, + "server.ListLabelsResponse": { + "type": "object", + "properties": { + "labels": { + "description": "Labels is a list of labels.", + "type": "array", + "items": { + "$ref": "#/definitions/model.Label" + } + }, + "total_count": { + "description": "TotalCount is the total number of labels matching the query.", + "type": "integer" + } + } + }, + "server.ListSCMsResponse": { + "type": "object", + "properties": { + "scms": { + "description": "SCMs is a list of SCMs.", + "type": "array", + "items": { + "$ref": "#/definitions/model.SCM" + } + }, + "total_count": { + "description": "TotalCount is the total number of SCMs matching the query.", + "type": "integer" + } + } + }, + "server.SearchLabelsRequest": { + "type": "object", + "properties": { + "end_time": { + "description": "EndTime is the end time for the time range filter\nThis is optional and can be used to filter labels by a specific end time\nTime format is RFC3339: 2006-01-02T15:04:05Z07:00", + "type": "string" + }, + "id": { + "description": "Id is the unique identifier of the label.", + "type": "string" + }, + "key": { + "description": "Key is the key of the label.", + "type": "string" + }, + "key_only": { + "description": "KeyOnly specifies if we only need to retrieve a list of uniq Label keys", + "type": "boolean" + }, + "limit": { + "description": "Limit is the maximum number of labels to return\nThis is optional and can be used to limit the number of labels returned", + "type": "integer" + }, + "page": { + "description": "Page is the page number for pagination\nThis is optional and can be used to paginate the results", + "type": "integer" + }, + "start_time": { + "description": "StartTime is the start time for the time range filter\nThis is optional and can be used to filter labels by a specific start time\nTime format is RFC3339: 2006-01-02T15:04:05Z07:00", + "type": "string" + }, + "value": { + "description": "Value is the value of the label.", + "type": "string" + } + } + }, + "server.SearchPipelineReportsSummaryRequest": { + "type": "object", + "properties": { + "days": { + "description": "Days is the number of days to summarize, today included.\nIt defaults to 7 and is ignored when hours, or start_time and end_time, are provided.", + "type": "integer" + }, + "end_time": { + "description": "EndTime is the end time for the time range filter.\nTime format is: 2006-01-02 15:04:05Z07:00", + "type": "string" + }, + "granularity": { + "description": "Granularity is the size of the time buckets, one of \"hour\", \"day\", \"week\" or\n\"month\". It defaults to \"day\".", + "type": "string" + }, + "hours": { + "description": "Hours is the number of hours to summarize, the current hour included.\nIt cannot be combined with days and is ignored when start_time and end_time are provided.", + "type": "integer" + }, + "labels": { + "description": "Labels is a map of labels to filter reports by.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "metric": { + "description": "Metric is what the reports are counted by. It defaults to \"result\", which is\nthe only value supported so far.", + "type": "string" + }, + "scmid": { + "description": "ScmID is the ID of the SCM to filter reports by.\nUse \"none\" to only count the reports which are not attached to any SCM.", + "type": "string" + }, + "start_time": { + "description": "StartTime is the start time for the time range filter.\nTime format is: 2006-01-02 15:04:05Z07:00", + "type": "string" + } + } + }, + "server.SearchPipelineReportsSummaryResponse": { + "type": "object", + "properties": { + "data": { + "description": "Data contains one entry per time bucket, ordered from the oldest to the most recent one.", + "type": "array", + "items": { + "$ref": "#/definitions/database.ReportResultSummaryEntry" + } + }, + "granularity": { + "description": "Granularity is the size of the time buckets of the entries.", + "type": "string" + }, + "metric": { + "description": "Metric is the metric the reports were counted by.", + "type": "string" + }, + "total_count": { + "description": "TotalCount is the total number of reports matching the query.", + "type": "integer" + } + } + }, + "server.SearchSCMsRequest": { + "type": "object", + "properties": { + "branch": { + "description": "Branch is the SCM branch to filter by.", + "type": "string" + }, + "end_time": { + "description": "EndTime is the end time for the time range filter.\nTime format is RFC3339: 2006-01-02T15:04:05Z07:00", + "type": "string" + }, + "labels": { + "description": "Labels filters SCM summaries by report labels.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "limit": { + "description": "Limit is the maximum number of SCMs to return.", + "type": "integer" + }, + "page": { + "description": "Page is the page number for pagination.", + "type": "integer" + }, + "scmid": { + "description": "ScmID is the ID of the SCM to filter by.", + "type": "string" + }, + "start_time": { + "description": "StartTime is the start time for the time range filter.\nTime format is RFC3339: 2006-01-02T15:04:05Z07:00", + "type": "string" + }, + "summary": { + "description": "Summary indicates if the response should contain SCM summary data.", + "type": "boolean" + }, + "url": { + "description": "URL is the SCM URL to filter by.", + "type": "string" + } + } + }, "server.SourceConfigResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f7a7221c..4cba9546 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -70,8 +70,28 @@ definitions: $ref: '#/definitions/transformer.Transformer' type: array type: object + database.ReportResultSummaryEntry: + properties: + date: + description: Date is the start of the bucket, in UTC, formatted as RFC3339. + type: string + results: + additionalProperties: + type: integer + description: Results contains the number of reports per Updatecli result for + that bucket. + type: object + total: + description: Total is the number of reports for that bucket, all results combined. + type: integer + type: object database.SearchLatestReportData: properties: + conditionConfigIDs: + allOf: + - $ref: '#/definitions/pgtype.Hstore' + description: ConditionConfigIDs contains the config condition IDs associated + with the report. createdAt: description: CreatedAt represents the creation date of the report. type: string @@ -93,6 +113,16 @@ definitions: result: description: Result represents the result of the report. type: string + sourceConfigIDs: + allOf: + - $ref: '#/definitions/pgtype.Hstore' + description: SourceConfigIDs contains the config source IDs associated with + the report. + targetConfigIDs: + allOf: + - $ref: '#/definitions/pgtype.Hstore' + description: TargetConfigIDs contains the config target IDs associated with + the report. updatedAt: description: UpdatedAt represents the last update date of the report. type: string @@ -150,20 +180,47 @@ definitions: description: Updated_at represent the last update date of the record. type: string type: object + model.Label: + properties: + created_at: + description: CreatedAt is the time the label was created + type: string + id: + description: ID is a unique identifier for the label, generated as a UUID. + type: string + key: + description: Key is the label name + type: string + last_pipeline_report_at: + description: LastPipelineReportAt is the time the label was last used in a + pipeline report + type: string + updated_at: + description: UpdatedAt is the time the label was last updated + type: string + value: + description: Value is the value associated with the label + type: string + type: object model.PipelineReport: properties: conditionConfigIDs: - additionalProperties: - type: string + allOf: + - $ref: '#/definitions/pgtype.Hstore' description: ConditionConfigIDs is a list of unique identifiers of the condition configuration associated with the database. - type: object created_at: description: Create_at represent the creation date of the record. type: string id: description: ID is the unique identifier of the record in the database. type: string + labelIDs: + description: LabelIDs is a list of unique identifiers of the labels associated + with the database. + items: + type: string + type: array pipeline: allOf: - $ref: '#/definitions/reports.Report' @@ -183,17 +240,15 @@ definitions: description: Result represent the result of the pipeline execution. type: string sourceConfigIDs: - additionalProperties: - type: string + allOf: + - $ref: '#/definitions/pgtype.Hstore' description: SourceConfigIDs is a list of unique identifiers of the source configuration associated with the database. - type: object targetConfigIDs: - additionalProperties: - type: string + allOf: + - $ref: '#/definitions/pgtype.Hstore' description: TargetConfigIDs is a list of unique identifiers of the target configuration associated with the database. - type: object targetScmIDs: description: TargetScmIDs is a list of unique identifiers of the scm configuration associated with the database. @@ -204,6 +259,28 @@ definitions: description: Updated_at represent the last update date of the record. type: string type: object + model.SCM: + properties: + branch: + description: Branch is the Git branch + type: string + created_at: + description: Created_at is the time the SCM configuration was created + type: string + id: + description: ID is a unique identifier for the SCM configuration + type: string + updated_at: + description: Updated_at is the time the SCM configuration was last updated + type: string + url: + description: URL is the Git repository URL + type: string + type: object + pgtype.Hstore: + additionalProperties: + type: string + type: object reports.Action: properties: actionUrl: @@ -253,6 +330,13 @@ definitions: description: Title is the title of the changelog type: string type: object + reports.CIData: + properties: + name: + type: string + url: + type: string + type: object reports.PipelineURL: properties: name: @@ -268,6 +352,8 @@ definitions: additionalProperties: $ref: '#/definitions/reports.Action' type: object + ci: + $ref: '#/definitions/reports.CIData' conditions: additionalProperties: $ref: '#/definitions/result.Condition' @@ -279,6 +365,10 @@ definitions: id: description: ID defines the report ID type: string + labels: + additionalProperties: + type: string + type: object name: type: string pipelineID: @@ -520,6 +610,164 @@ definitions: total_count: type: integer type: object + server.ListLabelsResponse: + properties: + labels: + description: Labels is a list of labels. + items: + $ref: '#/definitions/model.Label' + type: array + total_count: + description: TotalCount is the total number of labels matching the query. + type: integer + type: object + server.ListSCMsResponse: + properties: + scms: + description: SCMs is a list of SCMs. + items: + $ref: '#/definitions/model.SCM' + type: array + total_count: + description: TotalCount is the total number of SCMs matching the query. + type: integer + type: object + server.SearchLabelsRequest: + properties: + end_time: + description: |- + EndTime is the end time for the time range filter + This is optional and can be used to filter labels by a specific end time + Time format is RFC3339: 2006-01-02T15:04:05Z07:00 + type: string + id: + description: Id is the unique identifier of the label. + type: string + key: + description: Key is the key of the label. + type: string + key_only: + description: KeyOnly specifies if we only need to retrieve a list of uniq + Label keys + type: boolean + limit: + description: |- + Limit is the maximum number of labels to return + This is optional and can be used to limit the number of labels returned + type: integer + page: + description: |- + Page is the page number for pagination + This is optional and can be used to paginate the results + type: integer + start_time: + description: |- + StartTime is the start time for the time range filter + This is optional and can be used to filter labels by a specific start time + Time format is RFC3339: 2006-01-02T15:04:05Z07:00 + type: string + value: + description: Value is the value of the label. + type: string + type: object + server.SearchPipelineReportsSummaryRequest: + properties: + days: + description: |- + Days is the number of days to summarize, today included. + It defaults to 7 and is ignored when hours, or start_time and end_time, are provided. + type: integer + end_time: + description: |- + EndTime is the end time for the time range filter. + Time format is: 2006-01-02 15:04:05Z07:00 + type: string + granularity: + description: |- + Granularity is the size of the time buckets, one of "hour", "day", "week" or + "month". It defaults to "day". + type: string + hours: + description: |- + Hours is the number of hours to summarize, the current hour included. + It cannot be combined with days and is ignored when start_time and end_time are provided. + type: integer + labels: + additionalProperties: + type: string + description: Labels is a map of labels to filter reports by. + type: object + metric: + description: |- + Metric is what the reports are counted by. It defaults to "result", which is + the only value supported so far. + type: string + scmid: + description: |- + ScmID is the ID of the SCM to filter reports by. + Use "none" to only count the reports which are not attached to any SCM. + type: string + start_time: + description: |- + StartTime is the start time for the time range filter. + Time format is: 2006-01-02 15:04:05Z07:00 + type: string + type: object + server.SearchPipelineReportsSummaryResponse: + properties: + data: + description: Data contains one entry per time bucket, ordered from the oldest + to the most recent one. + items: + $ref: '#/definitions/database.ReportResultSummaryEntry' + type: array + granularity: + description: Granularity is the size of the time buckets of the entries. + type: string + metric: + description: Metric is the metric the reports were counted by. + type: string + total_count: + description: TotalCount is the total number of reports matching the query. + type: integer + type: object + server.SearchSCMsRequest: + properties: + branch: + description: Branch is the SCM branch to filter by. + type: string + end_time: + description: |- + EndTime is the end time for the time range filter. + Time format is RFC3339: 2006-01-02T15:04:05Z07:00 + type: string + labels: + additionalProperties: + type: string + description: Labels filters SCM summaries by report labels. + type: object + limit: + description: Limit is the maximum number of SCMs to return. + type: integer + page: + description: Page is the page number for pagination. + type: integer + scmid: + description: ScmID is the ID of the SCM to filter by. + type: string + start_time: + description: |- + StartTime is the start time for the time range filter. + Time format is RFC3339: 2006-01-02T15:04:05Z07:00 + type: string + summary: + description: Summary indicates if the response should contain SCM summary + data. + type: boolean + url: + description: URL is the SCM URL to filter by. + type: string + type: object server.SourceConfigResponse: properties: configs: @@ -1083,6 +1331,88 @@ paths: summary: Search configuration targets tags: - Configuration Targets + /api/pipeline/labels: + get: + description: List labels data from the database with optional filtering + parameters: + - description: Filter by label ID + in: query + name: id + type: string + - description: Filter by label key + in: query + name: key + type: string + - description: Filter by label value + in: query + name: value + type: string + - description: Return only unique label keys (true/false) + in: query + name: keyonly + type: string + - description: Limit the number of labels returned, default is 100 + in: query + name: limit + type: string + - description: Page number for pagination, default is 1 + in: query + name: page + type: string + - description: Start time for filtering labels (RFC3339 format) + in: query + name: start_time + type: string + - description: End time for filtering labels (RFC3339 format) + in: query + name: end_time + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.ListLabelsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.DefaultResponseModel' + summary: List labels + tags: + - Labels + /api/pipeline/labels/search: + post: + consumes: + - application/json + description: Search labels in the database using advanced filtering + parameters: + - description: Search parameters + in: body + name: body + required: true + schema: + $ref: '#/definitions/server.SearchLabelsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.ListLabelsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.DefaultResponseModel' + summary: Search labels + tags: + - Labels /api/pipeline/reports: get: consumes: @@ -1134,6 +1464,10 @@ paths: description: Created schema: $ref: '#/definitions/server.CreatePipelineReportResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' "500": description: Internal Server Error schema: @@ -1151,8 +1485,8 @@ paths: required: true type: string responses: - "201": - description: Created + "200": + description: OK schema: $ref: '#/definitions/server.DefaultResponseModel' "500": @@ -1171,8 +1505,8 @@ paths: required: true type: string responses: - "201": - description: Created + "200": + description: OK schema: $ref: '#/definitions/server.GetPipelineReportByIDResponse' "404": @@ -1239,6 +1573,41 @@ paths: summary: Search pipeline reports tags: - Pipeline Reports + /api/pipeline/reports/summary: + post: + consumes: + - application/json + description: |- + Return the number of pipeline reports per result for each time bucket of the requested time range. + Buckets are UTC hours, UTC calendar days, ISO weeks or calendar months depending on the + granularity, and the date of an entry is the start of its bucket, formatted as RFC3339. + Every report is counted, including several reports of the same pipeline, and buckets without + any report are returned with a zeroed entry. + parameters: + - description: Summary filters + in: body + name: body + required: true + schema: + $ref: '#/definitions/server.SearchPipelineReportsSummaryRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.SearchPipelineReportsSummaryResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.DefaultResponseModel' + summary: Summarize pipeline reports + tags: + - Pipeline Reports /api/pipeline/scms: get: description: List SCMs data from the database @@ -1278,6 +1647,10 @@ paths: responses: "200": description: OK + schema: + $ref: '#/definitions/server.ListSCMsResponse' + "400": + description: Bad Request schema: $ref: '#/definitions/server.DefaultResponseModel' "500": @@ -1287,4 +1660,35 @@ paths: summary: List SCMs tags: - SCMs + /api/pipeline/scms/search: + post: + consumes: + - application/json + description: Search SCM data using JSON filters. When summary is true, the response + contains SCM summary data for all matching SCMs. + parameters: + - description: SCM search filters + in: body + name: body + required: true + schema: + $ref: '#/definitions/server.SearchSCMsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.ListSCMsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.DefaultResponseModel' + summary: Search SCMs + tags: + - SCMs swagger: "2.0" diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index 296a2589..3fc814fa 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -3,10 +3,13 @@ package database import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/updatecli/udash/test" + "github.com/updatecli/updatecli/pkg/core/reports" + "github.com/updatecli/updatecli/pkg/core/result" ) func TestDatabase(t *testing.T) { @@ -25,4 +28,77 @@ func TestDatabase(t *testing.T) { t.Log("Postgres Container connected") require.NoError(t, RunMigrationUp()) t.Log("Postgres Container migrations run") + + t.Run("truncateToBucket matches date_trunc", func(t *testing.T) { + // The summary zero fills its buckets from truncateToBucket while the counted + // rows are bucketed by date_trunc. Any divergence between the two silently + // drops reports from the dataset, so they are compared here rather than left + // to the endpoint tests to notice. + granularities := []SummaryGranularity{ + SummaryGranularityHour, + SummaryGranularityDay, + SummaryGranularityWeek, + SummaryGranularityMonth, + } + + // A monday, a sunday, the first and the last day of a month, a leap day and + // the boundaries of a day. + samples := []time.Time{ + time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2026, 1, 5, 13, 45, 12, 0, time.UTC), + time.Date(2026, 1, 11, 23, 59, 59, 0, time.UTC), + time.Date(2026, 2, 28, 12, 0, 0, 0, time.UTC), + time.Date(2024, 2, 29, 6, 30, 0, 0, time.UTC), + time.Date(2026, 12, 31, 23, 0, 0, 0, time.UTC), + } + + for _, granularity := range granularities { + for _, sample := range samples { + want := time.Time{} + require.NoError(t, DB.QueryRow(ctx, + "SELECT date_trunc($1, $2::timestamp)", string(granularity), sample, + ).Scan(&want)) + + assert.Equal(t, want.UTC(), truncateToBucket(sample, granularity), + "granularity %q, sample %s", granularity, sample) + } + } + }) + + t.Run("migration 000010 backfills pipeline_result", func(t *testing.T) { + // Migration 000004 read "data ->> 'result'" while a marshalled report stores + // the key as "Result", so its backfill silently did nothing and every report + // inserted before it still has an empty pipeline_result. + id, err := InsertReport(ctx, reports.Report{ + Name: "ci: bump Venom version", + Result: result.SUCCESS, + ID: "1de1797bbc925e08e473178425b11eb16fc547291f4b45274da24c2b00e2afc3", + PipelineID: "venom", + }) + require.NoError(t, err) + t.Cleanup(func() { + _, err := DB.Exec(ctx, "DELETE FROM pipelineReports WHERE id = $1", id) + assert.NoError(t, err) + }) + + _, err = DB.Exec(ctx, + "UPDATE pipelineReports SET pipeline_result = '', pipeline_name = '' WHERE id = $1", id) + require.NoError(t, err) + + // Replaying the migration itself rather than a copy of its statements is what + // makes this a regression test for the jsonb key casing. + migration, err := fs.ReadFile("migrations/000010_fix_pipelineReports_denormalized_columns.up.sql") + require.NoError(t, err) + + _, err = DB.Exec(ctx, string(migration)) + require.NoError(t, err) + + pipelineResult, pipelineName := "", "" + require.NoError(t, DB.QueryRow(ctx, + "SELECT pipeline_result, pipeline_name FROM pipelineReports WHERE id = $1", id, + ).Scan(&pipelineResult, &pipelineName)) + + assert.Equal(t, result.SUCCESS, pipelineResult) + assert.Equal(t, "ci: bump Venom version", pipelineName) + }) } diff --git a/pkg/database/migrations/000010_fix_pipelineReports_denormalized_columns.down.sql b/pkg/database/migrations/000010_fix_pipelineReports_denormalized_columns.down.sql new file mode 100644 index 00000000..6d674f31 --- /dev/null +++ b/pkg/database/migrations/000010_fix_pipelineReports_denormalized_columns.down.sql @@ -0,0 +1,7 @@ +-- Only the index is dropped: emptying pipeline_result and pipeline_name again would +-- destroy data rather than restore the previous state. +BEGIN; + +DROP INDEX IF EXISTS idx_pipelinereports_updated_at_pipeline_result; + +COMMIT; diff --git a/pkg/database/migrations/000010_fix_pipelineReports_denormalized_columns.up.sql b/pkg/database/migrations/000010_fix_pipelineReports_denormalized_columns.up.sql new file mode 100644 index 00000000..ac93eeec --- /dev/null +++ b/pkg/database/migrations/000010_fix_pipelineReports_denormalized_columns.up.sql @@ -0,0 +1,24 @@ +-- Migration 000004 backfilled pipeline_result and pipeline_name from "data ->> 'result'" +-- and "data ->> 'name'", but a marshalled report stores those keys as "Result" and "Name". +-- jsonb keys are case sensitive so NULLIF(TRIM(...), '') always evaluated to NULL, the +-- COALESCE fell back to the column's own default and the backfill did nothing. Only +-- pipeline_id used the right casing, which is why it is the only one of the three that is +-- queried today. Every report inserted before 000004 therefore still has an empty +-- pipeline_result, which the reports summary would report as an unknown result. +BEGIN; + +UPDATE pipelineReports +SET + pipeline_result = COALESCE(NULLIF(TRIM(data ->> 'Result'), ''), pipeline_result), + pipeline_name = COALESCE(NULLIF(TRIM(data ->> 'Name'), ''), pipeline_name) +WHERE + TRIM(pipeline_result) = '' + OR TRIM(pipeline_name) = ''; + +-- The reports summary groups the reports of a time range per result. idx_pipelinereports_updated_at +-- already serves the range predicate but the result still has to be fetched from the heap +-- row by row, so a composite index is what makes the aggregation an index only scan. +CREATE INDEX IF NOT EXISTS idx_pipelinereports_updated_at_pipeline_result +ON pipelineReports (updated_at, pipeline_result); + +COMMIT; diff --git a/pkg/database/report.go b/pkg/database/report.go index b88a2d1d..bbe44528 100644 --- a/pkg/database/report.go +++ b/pkg/database/report.go @@ -3,8 +3,10 @@ package database import ( "context" "encoding/json" + "errors" "fmt" "slices" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -17,6 +19,7 @@ import ( "github.com/stephenafamo/bob/dialect/psql/sm" "github.com/updatecli/udash/pkg/model" "github.com/updatecli/updatecli/pkg/core/reports" + "github.com/updatecli/updatecli/pkg/core/result" ) // SearchLatestReportData represents a report. @@ -99,8 +102,6 @@ type SearchLatestReportsParams struct { } // SearchLatestReports searches the latest reports according some parameters. -// -//nolint:funlen func SearchLatestReports(params SearchLatestReportsParams) ([]SearchLatestReportData, int, error) { queryString := "" var args []any @@ -167,39 +168,8 @@ func SearchLatestReports(params SearchLatestReportsParams) ([]SearchLatestReport } } - switch params.ScmID { - case "": - case "none", "null", "nil": - query.Apply( - sm.Where( - psql.Or( - psql.Quote("cardinality(target_db_scm_ids) = 0"), - psql.Quote("target_db_scm_ids").IsNull(), - ), - ), - ) - - default: - scm, _, err := GetSCM(params.Ctx, params.ScmID, "", "", 0, 1) - if err != nil { - logrus.Errorf("get scm data: %s", err) - return nil, 0, err - } - - switch len(scm) { - case 0: - logrus.Errorf("scm data not found") - case 1: - query.Apply( - sm.Where( - psql.Raw(`target_db_scm_ids && ?`, fmt.Sprintf("{%s}", scm[0].ID.String())), - ), - ) - default: - // Normally we should never have multiple scms with the same id - // so we should never reach this point. - logrus.Errorf("unexpected behavior: multiple scms found") - } + if err := applyScmFilter(params.Ctx, &query, params.ScmID); err != nil { + return nil, 0, err } // Total counter query must be built before applying pagination @@ -318,6 +288,323 @@ func SearchLatestReports(params SearchLatestReportsParams) ([]SearchLatestReport return dataset, totalCount, nil } +// SummaryGranularity is the size of the time buckets a reports summary is grouped by. +type SummaryGranularity string + +const ( + // SummaryGranularityHour groups the reports per UTC hour. + SummaryGranularityHour SummaryGranularity = "hour" + // SummaryGranularityDay groups the reports per UTC day. + SummaryGranularityDay SummaryGranularity = "day" + // SummaryGranularityWeek groups the reports per ISO week, starting on monday. + SummaryGranularityWeek SummaryGranularity = "week" + // SummaryGranularityMonth groups the reports per calendar month. + SummaryGranularityMonth SummaryGranularity = "month" +) + +// IsValid reports whether the granularity is one this package knows how to bucket. +func (g SummaryGranularity) IsValid() bool { + switch g { + case SummaryGranularityHour, SummaryGranularityDay, SummaryGranularityWeek, SummaryGranularityMonth: + return true + default: + return false + } +} + +// ErrSummaryRangeTooWide is returned when the requested time range spans more days than +// the caller allows. Callers are expected to turn it into a client error. +var ErrSummaryRangeTooWide = errors.New("requested time range is too wide") + +// ErrSummaryTooManyBuckets is returned when the requested time range and granularity would +// produce more buckets than the caller allows. Callers are expected to turn it into a +// client error. +var ErrSummaryTooManyBuckets = errors.New("requested time range produces too many buckets") + +// summaryDateFormat is the layout used to identify the bucket of a summary entry. It has to +// carry the time of the day, otherwise every bucket of an hourly summary would share the +// same identifier and their counts would be merged together. +const summaryDateFormat = time.RFC3339 + +// summaryUnknownResult is the key reporting the reports whose result is empty or is not +// an Updatecli result. +const summaryUnknownResult = "unknown" + +// summaryResultKeys contains the keys always reported for a bucket, even when no report +// matched, so that consumers always retrieve the same set of keys. +var summaryResultKeys = []string{ + result.SUCCESS, + result.FAILURE, + result.ATTENTION, + result.SKIPPED, + summaryUnknownResult, +} + +// ReportSummaryParams contains the parameters used to summarize reports per time bucket. +type ReportSummaryParams struct { + Ctx context.Context + // Days is how far back to look for reports, in days. + // It is ignored when Hours, or StartTime and EndTime, are provided. + Days int + // Hours is how far back to look for reports, in hours. It takes precedence over + // Days and is ignored when StartTime and EndTime are provided. + Hours int + // Granularity is the size of the time buckets, it defaults to a day. + Granularity SummaryGranularity + // MaxDays is the widest time range accepted, in days. A value lower than one + // does not enforce any limit. + MaxDays int + // MaxBuckets is the largest number of buckets a summary may return. A value lower + // than one does not enforce any limit. + MaxBuckets int + // StartTime and EndTime define an explicit time range, both must be provided. + StartTime string + EndTime string + // ScmID restricts the summary to the reports of a specific scm. + ScmID string + // Labels restricts the summary to the reports matching those labels. + Labels map[string]string +} + +// ReportResultSummaryEntry contains the number of reports per result for a single time bucket. +type ReportResultSummaryEntry struct { + // Date is the start of the bucket, in UTC, formatted as RFC3339. + Date string `json:"date"` + // Results contains the number of reports per Updatecli result for that bucket. + Results map[string]int `json:"results"` + // Total is the number of reports for that bucket, all results combined. + Total int `json:"total"` +} + +// SearchReportsSummary returns the number of reports per result for each time bucket of +// the requested time range. Buckets without any report are reported with a zeroed entry +// so that the returned dataset always covers the whole time range. +// +// The summary always covers whole buckets: an explicit time range is widened to the +// buckets it overlaps, otherwise a partial bucket would be reported as a drop of activity. +func SearchReportsSummary(params ReportSummaryParams) ([]ReportResultSummaryEntry, int, error) { + + granularity := params.Granularity + if granularity == "" { + granularity = SummaryGranularityDay + } + + if !granularity.IsValid() { + return nil, 0, fmt.Errorf("unsupported granularity %q", params.Granularity) + } + + firstBucket, lastBucket, err := summaryRange(params, granularity) + if err != nil { + return nil, 0, fmt.Errorf("resolving summary range: %w", err) + } + + // granularity is one of the constants above, never the raw value received from a + // caller, so it cannot inject anything into the query. + dateTrunc := fmt.Sprintf("date_trunc('%s', updated_at)", granularity) + + query := psql.Select( + sm.From("pipelineReports"), + sm.Columns( + dateTrunc, + // pipeline_result is denormalized from data ->> 'Result' when the report is + // inserted, grouping on it avoids parsing the jsonb document of every report. + "pipeline_result", + "count(*)", + ), + sm.Where( + psql.Raw("updated_at >= ? AND updated_at < ?", firstBucket, nextBucket(lastBucket, granularity)), + ), + sm.GroupBy(dateTrunc), + sm.GroupBy("pipeline_result"), + sm.OrderBy(dateTrunc), + ) + + if err := applyScmFilter(params.Ctx, &query, params.ScmID); err != nil { + return nil, 0, err + } + + if len(params.Labels) > 0 { + // The report window is widened to whole buckets so the label lookup must cover + // the same range, otherwise labels timestamped within the widened part would be + // missed and their reports silently dropped. An empty range keeps the lookup + // unbounded, as SearchLatestReports does. + labelStartTime, labelEndTime := "", "" + if params.StartTime != "" || params.EndTime != "" { + labelStartTime = firstBucket.Format(timeRangeLayout) + labelEndTime = nextBucket(lastBucket, granularity).Format(timeRangeLayout) + } + + if err := applyLabelFilter(labelFilterParams{ + Ctx: params.Ctx, + Query: &query, + Labels: params.Labels, + StartTime: labelStartTime, + EndTime: labelEndTime, + }); err != nil { + return nil, 0, fmt.Errorf("applying label filter: %w", err) + } + } + + queryString, args, err := query.Build(params.Ctx) + if err != nil { + return nil, 0, fmt.Errorf("building query failed: %s\n\t%s", queryString, err) + } + + rows, err := DB.Query(params.Ctx, queryString, args...) + if err != nil { + return nil, 0, fmt.Errorf("query failed: %q\n\t%s", queryString, err) + } + defer rows.Close() + + countByDate := map[string]map[string]int{} + totalCount := 0 + + for rows.Next() { + bucket := time.Time{} + reportResult := "" + count := 0 + + if err := rows.Scan(&bucket, &reportResult, &count); err != nil { + return nil, 0, fmt.Errorf("parsing result: %s", err) + } + + date := bucket.UTC().Format(summaryDateFormat) + if countByDate[date] == nil { + countByDate[date] = map[string]int{} + } + + countByDate[date][summaryResultKey(reportResult)] += count + totalCount += count + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("reading results: %s", err) + } + + dataset := []ReportResultSummaryEntry{} + for bucket := firstBucket; !bucket.After(lastBucket); bucket = nextBucket(bucket, granularity) { + entry := ReportResultSummaryEntry{ + Date: bucket.Format(summaryDateFormat), + Results: map[string]int{}, + } + + for _, r := range summaryResultKeys { + entry.Results[r] = 0 + } + + for r, count := range countByDate[entry.Date] { + entry.Results[r] += count + entry.Total += count + } + + dataset = append(dataset, entry) + } + + return dataset, totalCount, nil +} + +// summaryResultKey maps a stored pipeline result to the key it is reported under. +// Anything unexpected, including the empty result of a report inserted before the +// pipeline_result column was backfilled, is folded into a single bucket so that the +// reported keys stay stable. +func summaryResultKey(pipelineResult string) string { + switch pipelineResult { + case result.SUCCESS, result.FAILURE, result.ATTENTION, result.SKIPPED: + return pipelineResult + default: + return summaryUnknownResult + } +} + +// summaryRange returns the first and the last bucket, both included, covered by a +// summary. Both are the start of a bucket, in UTC. +func summaryRange(params ReportSummaryParams, granularity SummaryGranularity) (time.Time, time.Time, error) { + + firstTime, lastTime := time.Time{}, time.Time{} + + switch { + case params.StartTime != "" || params.EndTime != "": + var err error + firstTime, lastTime, err = resolveTimeRange(0, params.StartTime, params.EndTime) + if err != nil { + return time.Time{}, time.Time{}, err + } + + case params.Hours > 0: + // The window includes the bucket of the current hour, as the Days one includes + // the bucket of the current day. + lastTime = time.Now().UTC() + firstTime = lastTime.Add(-time.Duration(params.Hours-1) * time.Hour) + + default: + days := params.Days + if days < 1 { + days = 1 + } + + lastTime = time.Now().UTC() + firstTime = lastTime.AddDate(0, 0, -(days - 1)) + } + + // The limit is checked against the requested range rather than the widened one: + // widening adds up to a bucket on each side, which a month granularity would + // otherwise turn into a rejection of a request that is within the limit. + if params.MaxDays > 0 && lastTime.Sub(firstTime) > time.Duration(params.MaxDays)*24*time.Hour { + return time.Time{}, time.Time{}, ErrSummaryRangeTooWide + } + + firstBucket := truncateToBucket(firstTime, granularity) + lastBucket := truncateToBucket(lastTime, granularity) + + // MaxDays bounds how much of the table the query scans, this bounds how large the + // response gets: an hourly summary of a year is a cheap scan but ~8800 entries. + if params.MaxBuckets > 0 { + count := 0 + for bucket := firstBucket; !bucket.After(lastBucket); bucket = nextBucket(bucket, granularity) { + count++ + if count > params.MaxBuckets { + return time.Time{}, time.Time{}, ErrSummaryTooManyBuckets + } + } + } + + return firstBucket, lastBucket, nil +} + +// truncateToBucket returns the start, in UTC, of the bucket containing the provided time. +// It must return the same instant as the matching date_trunc call, otherwise the zeroed +// buckets would not line up with the counted rows. +func truncateToBucket(t time.Time, granularity SummaryGranularity) time.Time { + t = t.UTC() + + switch granularity { + case SummaryGranularityHour: + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, time.UTC) + case SummaryGranularityWeek: + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + // date_trunc truncates a week to its ISO monday. + return day.AddDate(0, 0, -((int(day.Weekday()) + 6) % 7)) + case SummaryGranularityMonth: + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) + default: + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + } +} + +// nextBucket returns the start of the bucket following the provided bucket start. +func nextBucket(t time.Time, granularity SummaryGranularity) time.Time { + switch granularity { + case SummaryGranularityHour: + return t.Add(time.Hour) + case SummaryGranularityWeek: + return t.AddDate(0, 0, 7) + case SummaryGranularityMonth: + return t.AddDate(0, 1, 0) + default: + return t.AddDate(0, 0, 1) + } +} + // InsertReport inserts a new report into the database. func InsertReport(ctx context.Context, report reports.Report) (string, error) { var err error @@ -678,3 +965,48 @@ func applyResourceConfigFilter(query *bob.BaseQuery[*dialect.SelectQuery], id, k ) return nil } + +// applyScmFilter restricts the given query to the reports associated to a specific scm. +// An empty scmID does not filter anything while "none", "null", or "nil" only keeps +// the reports which are not associated to any scm. +func applyScmFilter(ctx context.Context, query *bob.BaseQuery[*dialect.SelectQuery], scmID string) error { + + switch scmID { + case "": + case "none", "null", "nil": + // psql.Quote would quote the whole expression as a column identifier, + // so the cardinality call must be passed as a raw expression. + query.Apply( + sm.Where( + psql.Or( + psql.Raw("cardinality(target_db_scm_ids) = 0"), + psql.Quote("target_db_scm_ids").IsNull(), + ), + ), + ) + + default: + scm, _, err := GetSCM(ctx, scmID, "", "", 0, 1) + if err != nil { + logrus.Errorf("get scm data: %s", err) + return err + } + + switch len(scm) { + case 0: + logrus.Errorf("scm data not found") + case 1: + query.Apply( + sm.Where( + psql.Raw(`target_db_scm_ids && ?`, fmt.Sprintf("{%s}", scm[0].ID.String())), + ), + ) + default: + // Normally we should never have multiple scms with the same id + // so we should never reach this point. + logrus.Errorf("unexpected behavior: multiple scms found") + } + } + + return nil +} diff --git a/pkg/database/time_utils.go b/pkg/database/time_utils.go index dd45e146..2a174dee 100644 --- a/pkg/database/time_utils.go +++ b/pkg/database/time_utils.go @@ -10,6 +10,9 @@ import ( "github.com/stephenafamo/bob/dialect/psql/sm" ) +// timeRangeLayout is the layout used to parse the startTime and endTime filters. +const timeRangeLayout = "2006-01-02 15:04:05Z07:00" + // dateRangeFilterParams holds parameters for applying a date range filter to a query. type dateRangeFilterParams struct { Query *bob.BaseQuery[*dialect.SelectQuery] @@ -18,36 +21,32 @@ type dateRangeFilterParams struct { EndTime string } -// applyRangeFilter applies a time range filter to the given query based on the provided -// startTime and endTime strings in RFC3339 format. If both are empty and dateRangeDays is greater than zero, -// it filters records updated within the last dateRangeDays days. -func applyRangeFilter(columnName string, r dateRangeFilterParams) error { +// resolveTimeRange returns the time window, in UTC, described by the provided +// startTime and endTime strings. If both are empty and days is greater than zero, +// the window ends now and starts days days ago. If both are empty and days is not +// greater than zero, both returned times are zero, meaning that no time boundary applies. +func resolveTimeRange(days int, startTime, endTime string) (time.Time, time.Time, error) { - if r.StartTime == "" && r.EndTime == "" && r.DateRangeDays > 0 { - start := time.Now().UTC().Add(-time.Duration(r.DateRangeDays) * 24 * time.Hour) - r.Query.Apply( - sm.Where( - psql.Raw(columnName+" > ?", start), - ), - ) - return nil - } + if startTime == "" && endTime == "" { + if days <= 0 { + return time.Time{}, time.Time{}, nil + } - if r.StartTime == "" && r.EndTime == "" { - return nil + end := time.Now().UTC() + return end.Add(-time.Duration(days) * 24 * time.Hour), end, nil } - if r.StartTime == "" || r.EndTime == "" { - return fmt.Errorf("both startTime %q and endTime %q must be provided for time range filtering", r.StartTime, r.EndTime) + if startTime == "" || endTime == "" { + return time.Time{}, time.Time{}, fmt.Errorf("both startTime %q and endTime %q must be provided for time range filtering", startTime, endTime) } - startT, err := time.Parse("2006-01-02 15:04:05Z07:00", r.StartTime) + startT, err := time.Parse(timeRangeLayout, startTime) if err != nil { - return fmt.Errorf("parsing startTime: %w", err) + return time.Time{}, time.Time{}, fmt.Errorf("parsing startTime: %w", err) } - endT, err := time.Parse("2006-01-02 15:04:05Z07:00", r.EndTime) + endT, err := time.Parse(timeRangeLayout, endTime) if err != nil { - return fmt.Errorf("parsing endTime: %w", err) + return time.Time{}, time.Time{}, fmt.Errorf("parsing endTime: %w", err) } startTimeUTC := startT.UTC() @@ -57,6 +56,34 @@ func applyRangeFilter(columnName string, r dateRangeFilterParams) error { startTimeUTC, endTimeUTC = endTimeUTC, startTimeUTC } + return startTimeUTC, endTimeUTC, nil +} + +// applyRangeFilter applies a time range filter to the given query based on the provided +// startTime and endTime strings in RFC3339 format. If both are empty and dateRangeDays is greater than zero, +// it filters records updated within the last dateRangeDays days. +func applyRangeFilter(columnName string, r dateRangeFilterParams) error { + + startTimeUTC, endTimeUTC, err := resolveTimeRange(r.DateRangeDays, r.StartTime, r.EndTime) + if err != nil { + return err + } + + if startTimeUTC.IsZero() && endTimeUTC.IsZero() { + return nil + } + + // Without an explicit time range, only the lower boundary is applied so that + // records updated while the query runs are still returned. + if r.StartTime == "" && r.EndTime == "" { + r.Query.Apply( + sm.Where( + psql.Raw(columnName+" > ?", startTimeUTC), + ), + ) + return nil + } + r.Query.Apply( sm.Where( psql.Raw(columnName+" >= ? AND "+columnName+" < ?", startTimeUTC, endTimeUTC), diff --git a/pkg/server/endpoints.go b/pkg/server/endpoints.go index 05340927..32dbbbdf 100644 --- a/pkg/server/endpoints.go +++ b/pkg/server/endpoints.go @@ -193,6 +193,7 @@ func newGinEngine(opts Options) *gin.Engine { r.POST("/api/pipeline/config/targets/search", SearchConfigTargets) r.POST("/api/pipeline/labels/search", SearchLabels) r.POST("/api/pipeline/reports/search", SearchPipelineReports) + r.POST("/api/pipeline/reports/summary", SearchPipelineReportsSummary) r.POST("/api/pipeline/scms/search", SearchSCMs) } else { apiPipeline.POST("/config/sources/search", SearchConfigSources) @@ -200,6 +201,7 @@ func newGinEngine(opts Options) *gin.Engine { apiPipeline.POST("/config/targets/search", SearchConfigTargets) apiPipeline.POST("/labels/search", SearchLabels) apiPipeline.POST("/reports/search", SearchPipelineReports) + apiPipeline.POST("/reports/summary", SearchPipelineReportsSummary) apiPipeline.POST("/scms/search", SearchSCMs) } diff --git a/pkg/server/endpoints_test.go b/pkg/server/endpoints_test.go index 2b6aa0de..72f0b623 100644 --- a/pkg/server/endpoints_test.go +++ b/pkg/server/endpoints_test.go @@ -10,8 +10,10 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/stephenafamo/bob/dialect/psql" "github.com/stephenafamo/bob/dialect/psql/dm" "github.com/stretchr/testify/assert" @@ -237,7 +239,7 @@ func TestEndpoints(t *testing.T) { t.Run("GET /api/pipeline/reports/:id", func(t *testing.T) { t.Run("with an unknown report ID", func(t *testing.T) { resp := doGetRequest(t, srv, "/api/pipeline/reports/daa9b61e-42b9-4e35-b9d7-071461a36838") - assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assertErrorResponse(t, resp, http.StatusNotFound, pgx.ErrNoRows.Error()) }) t.Run("with a known report ID", func(t *testing.T) { @@ -484,6 +486,452 @@ func TestEndpoints(t *testing.T) { }, }, removeFieldsAsserter("labels", "created_at", "updated_at", "last_pipeline_report_at")) }) + + // This subtest must run last as it removes every pipeline report. + t.Run("POST /api/pipeline/reports/summary", func(t *testing.T) { + const summaryPath = "/api/pipeline/reports/summary" + // Every bucket identifies itself by its start, formatted as RFC3339, whatever + // the granularity is. + const bucketLayout = time.RFC3339 + + // The previous subtests leave reports behind which would all be + // counted in today's bucket. + truncateReports(t) + t.Cleanup(func() { + truncateReports(t) + }) + + // The expected days are derived from the same clock as the request, so + // a request crossing midnight would make this subtest flaky. Seeding at + // the current time of day keeps that window as small as possible. + now := time.Now().UTC() + + seedReport := func(pipelineResult string, dayOffset int) string { + t.Helper() + + id, err := database.InsertReport(ctx, reports.Report{ + Name: "ci: bump Venom version", + Result: pipelineResult, + ID: "1de1797bbc925e08e473178425b11eb16fc547291f4b45274da24c2b00e2afc3", + PipelineID: "venom", + }) + require.NoError(t, err) + + setReportTimestamp(t, id, now.AddDate(0, 0, dayOffset)) + return id + } + + day := func(dayOffset int) string { + return dayStart(now.AddDate(0, 0, dayOffset)).Format(bucketLayout) + } + + // bucketEntry builds the expected response entry for a bucket, starting from + // a zeroed set of results. + bucketEntry := func(date string, results map[string]any) map[string]any { + allResults := map[string]any{ + "✔": float64(0), + "✗": float64(0), + "⚠": float64(0), + "-": float64(0), + "unknown": float64(0), + } + total := float64(0) + for k, v := range results { + allResults[k] = v + total += v.(float64) + } + + return map[string]any{ + "date": date, + "results": allResults, + "total": total, + } + } + + entry := func(dayOffset int, results map[string]any) map[string]any { + return bucketEntry(day(dayOffset), results) + } + + // want builds the expected response body, the metric and the granularity being + // echoed back by the endpoint. + want := func(granularity string, totalCount float64, entries ...any) map[string]any { + return map[string]any{ + "metric": "result", + "granularity": granularity, + "data": entries, + "total_count": totalCount, + } + } + + seeds := []struct { + result string + offset int + }{ + {"✔", 0}, + {"✔", 0}, + {"✗", 0}, + {"✔", -3}, + // Outside of the default seven days window. + {"⚠", -9}, + } + + seededIDs := make([]string, 0, len(seeds)) + for _, seed := range seeds { + seededIDs = append(seededIDs, seedReport(seed.result, seed.offset)) + } + scmReportID := seededIDs[0] + + // bucketedEntries builds the expected entries of a window of days, bucketing the + // seeded reports the same way the endpoint does. Expressing the expectation this + // way keeps the week and month cases independent from the day this test runs on. + bucketedEntries := func(days int, truncate, next func(time.Time) time.Time) []any { + counts := map[string]map[string]any{} + for _, seed := range seeds { + date := truncate(now.AddDate(0, 0, seed.offset)).Format(bucketLayout) + if counts[date] == nil { + counts[date] = map[string]any{} + } + + previous, _ := counts[date][seed.result].(float64) + counts[date][seed.result] = previous + 1 + } + + entries := []any{} + last := truncate(now) + for bucket := truncate(now.AddDate(0, 0, -(days - 1))); !bucket.After(last); bucket = next(bucket) { + date := bucket.Format(bucketLayout) + entries = append(entries, bucketEntry(date, counts[date])) + } + + return entries + } + + t.Run("with the default window", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{}) + + assertJSONResponse(t, resp, want("day", 4, + entry(-6, nil), + entry(-5, nil), + entry(-4, nil), + entry(-3, map[string]any{"✔": float64(1)}), + entry(-2, nil), + entry(-1, nil), + entry(0, map[string]any{"✔": float64(2), "✗": float64(1)}), + ), assert.Equal) + }) + + t.Run("with an explicit number of days", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "days": 3, + }) + + assertJSONResponse(t, resp, want("day", 3, + entry(-2, nil), + entry(-1, nil), + entry(0, map[string]any{"✔": float64(2), "✗": float64(1)}), + ), assert.Equal) + }) + + t.Run("with a window wide enough to catch every report", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "days": 10, + }) + + blob := map[string]any{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&blob)) + defer resp.Body.Close() + + assert.Len(t, blob["data"], 10) + assert.Equal(t, float64(5), blob["total_count"]) + assert.Equal(t, entry(-9, map[string]any{"⚠": float64(1)}), blob["data"].([]any)[0]) + }) + + t.Run("with a week granularity", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "days": 10, + "granularity": "week", + }) + + assertJSONResponse(t, resp, want("week", 5, + bucketedEntries(10, weekStart, func(t time.Time) time.Time { + return t.AddDate(0, 0, 7) + })..., + ), assert.Equal) + }) + + t.Run("with a month granularity", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "days": 10, + "granularity": "month", + }) + + assertJSONResponse(t, resp, want("month", 5, + bucketedEntries(10, monthStart, func(t time.Time) time.Time { + return t.AddDate(0, 1, 0) + })..., + ), assert.Equal) + }) + + t.Run("filtered by scm", func(t *testing.T) { + scmID, err := database.InsertSCM(ctx, "https://example.com/summary.git", "main") + require.NoError(t, err) + t.Cleanup(func() { + deleteSCM(t, scmID) + }) + + attachReportToSCM(t, scmReportID, scmID) + + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "days": 1, + "scmid": scmID, + }) + + assertJSONResponse(t, resp, want("day", 1, + entry(0, map[string]any{"✔": float64(1)}), + ), assert.Equal) + + t.Run("without any scm", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "days": 1, + "scmid": "none", + }) + + assertJSONResponse(t, resp, want("day", 2, + entry(0, map[string]any{"✔": float64(1), "✗": float64(1)}), + ), assert.Equal) + }) + }) + + t.Run("with an invalid number of days", func(t *testing.T) { + for _, days := range []int{-1, maxMonitoringDurationDays + 1} { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "days": days, + }) + + assertErrorResponse(t, resp, http.StatusBadRequest, ErrInvalidDaysParam) + } + }) + + t.Run("with an incomplete time range", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "start_time": now.Format(timeRangeLayout), + }) + + assertErrorResponse(t, resp, http.StatusBadRequest, ErrInvalidTimeRangeParams) + }) + + t.Run("with an unsupported metric", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "metric": "duration", + }) + + assertErrorResponse(t, resp, http.StatusBadRequest, ErrInvalidMetricParam) + }) + + t.Run("with an unsupported granularity", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "granularity": "minute", + }) + + assertErrorResponse(t, resp, http.StatusBadRequest, ErrInvalidGranularityParam) + }) + + t.Run("with both days and hours", func(t *testing.T) { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "days": 1, + "hours": 1, + }) + + assertErrorResponse(t, resp, http.StatusBadRequest, ErrConflictingWindowParams) + }) + + t.Run("with an invalid number of hours", func(t *testing.T) { + for _, hours := range []int{-1, maxMonitoringDurationDays*24 + 1} { + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "hours": hours, + }) + + assertErrorResponse(t, resp, http.StatusBadRequest, ErrInvalidHoursParam) + } + }) + + t.Run("with a granularity producing too many buckets", func(t *testing.T) { + // The days limit bounds how much of the table is scanned, not how large the + // response gets: a year of hourly buckets is a cheap scan but thousands of + // entries, so it has to be rejected by the bucket limit instead. + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "granularity": "hour", + "days": maxMonitoringDurationDays, + }) + + assertErrorResponse(t, resp, http.StatusBadRequest, ErrTooManyBuckets) + }) + + t.Run("with a time range wider than the limit", func(t *testing.T) { + // The days validation does not cover an explicit time range, so this is + // the only guard against summarizing the whole table. + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "start_time": now.AddDate(0, 0, -(maxMonitoringDurationDays + 1)).Format(timeRangeLayout), + "end_time": now.Format(timeRangeLayout), + }) + + assertErrorResponse(t, resp, http.StatusBadRequest, ErrTimeRangeTooWide) + }) + + // The remaining subtests seed reports of their own, so they must run after the + // ones asserting on the counts above. + t.Run("with a report without any result", func(t *testing.T) { + id := seedReport("", 0) + t.Cleanup(func() { + deleteReport(t, id) + }) + + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "days": 1, + }) + + assertJSONResponse(t, resp, want("day", 4, + entry(0, map[string]any{"✔": float64(2), "✗": float64(1), "unknown": float64(1)}), + ), assert.Equal) + }) + + // This subtest replaces the seeded dataset, so it must run after every other one. + t.Run("with an hour granularity", func(t *testing.T) { + truncateReports(t) + + currentHour := hourStart(now) + + seedReportAt := func(pipelineResult string, at time.Time) { + t.Helper() + + id, err := database.InsertReport(ctx, reports.Report{ + Name: "ci: bump Venom version", + Result: pipelineResult, + ID: "1de1797bbc925e08e473178425b11eb16fc547291f4b45274da24c2b00e2afc3", + PipelineID: "venom", + }) + require.NoError(t, err) + + setReportTimestamp(t, id, at) + } + + // Halfway into each hour, so that a report cannot land in a neighbouring + // bucket. + halfPast := 30 * time.Minute + seedReportAt("✔", currentHour.Add(halfPast)) + seedReportAt("✔", currentHour.Add(-1*time.Hour+halfPast)) + seedReportAt("✗", currentHour.Add(-1*time.Hour+halfPast)) + seedReportAt("⚠", currentHour.Add(-3*time.Hour+halfPast)) + + hour := func(hourOffset int) string { + return currentHour.Add(time.Duration(hourOffset) * time.Hour).Format(bucketLayout) + } + + // Driving this with an explicit time range rather than the hours window keeps + // the expected buckets independent from the clock, so a request crossing an + // hour boundary cannot shift them. + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "granularity": "hour", + "start_time": currentHour.Add(-3 * time.Hour).Format(timeRangeLayout), + "end_time": currentHour.Format(timeRangeLayout), + }) + + assertJSONResponse(t, resp, want("hour", 4, + bucketEntry(hour(-3), map[string]any{"⚠": float64(1)}), + bucketEntry(hour(-2), nil), + bucketEntry(hour(-1), map[string]any{"✔": float64(1), "✗": float64(1)}), + bucketEntry(hour(0), map[string]any{"✔": float64(1)}), + ), assert.Equal) + + t.Run("with a relative hours window", func(t *testing.T) { + // hours is resolved against the server clock, so a request crossing an + // hour boundary shifts the whole window by one bucket. The window is wide + // enough for every seeded report to stay inside it either way, and only + // the shape of the response is asserted. + resp := doPostRequest(t, srv, summaryPath, map[string]any{ + "granularity": "hour", + "hours": 6, + }) + + blob := map[string]any{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&blob)) + defer resp.Body.Close() + + assert.Equal(t, "hour", blob["granularity"]) + assert.Len(t, blob["data"], 6) + assert.Equal(t, float64(4), blob["total_count"]) + }) + }) + }) +} + +// hourStart returns the beginning of the UTC hour of the provided time. +func hourStart(t time.Time) time.Time { + t = t.UTC() + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, time.UTC) +} + +// dayStart returns the midnight of the UTC day of the provided time. +func dayStart(t time.Time) time.Time { + t = t.UTC() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) +} + +// weekStart returns the monday of the UTC week of the provided time, matching how +// Postgres truncates a timestamp to a week. +func weekStart(t time.Time) time.Time { + t = t.UTC() + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + return day.AddDate(0, 0, -((int(day.Weekday()) + 6) % 7)) +} + +// monthStart returns the first day of the UTC month of the provided time. +func monthStart(t time.Time) time.Time { + t = t.UTC() + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) +} + +// timeRangeLayout is the layout the start_time and end_time filters are expected in. +const timeRangeLayout = "2006-01-02 15:04:05Z07:00" + +// truncateReports removes every pipeline report from the database. +func truncateReports(t *testing.T) { + t.Helper() + + _, err := database.DB.Exec(context.TODO(), "DELETE FROM pipelineReports") + require.NoError(t, err) +} + +// deleteReport removes a single pipeline report from the database. +func deleteReport(t *testing.T, id string) { + t.Helper() + + _, err := database.DB.Exec(context.TODO(), "DELETE FROM pipelineReports WHERE id = $1", id) + require.NoError(t, err) +} + +// setReportTimestamp forces the creation and update date of an existing report. +// InsertReport always relies on the database defaults, so backdating a report +// requires updating it afterwards. +func setReportTimestamp(t *testing.T, id string, at time.Time) { + t.Helper() + + // The value must be normalized to UTC: the driver sends the wall clock of + // its own location, which is what the timestamp column stores. + _, err := database.DB.Exec(context.TODO(), + "UPDATE pipelineReports SET created_at = $1, updated_at = $1 WHERE id = $2", + at.UTC(), id) + require.NoError(t, err) +} + +// attachReportToSCM associates an existing report to an scm. +func attachReportToSCM(t *testing.T, reportID, scmID string) { + t.Helper() + + _, err := database.DB.Exec(context.TODO(), + "UPDATE pipelineReports SET target_db_scm_ids = ARRAY[$1]::uuid[] WHERE id = $2", + scmID, reportID) + require.NoError(t, err) } func doGetRequest(t *testing.T, ts *httptest.Server, path string) *http.Response { diff --git a/pkg/server/labeldb_handlers.go b/pkg/server/labeldb_handlers.go index fc41ef4c..79328239 100644 --- a/pkg/server/labeldb_handlers.go +++ b/pkg/server/labeldb_handlers.go @@ -112,10 +112,36 @@ func ListLabels(c *gin.Context) { } } +// SearchLabelsRequest represents the filters used to search labels. +type SearchLabelsRequest struct { + // Id is the unique identifier of the label. + Id string `json:"id"` + // Key is the key of the label. + Key string `json:"key"` + // Value is the value of the label. + Value string `json:"value"` + // Limit is the maximum number of labels to return + // This is optional and can be used to limit the number of labels returned + Limit int `json:"limit"` + // Page is the page number for pagination + // This is optional and can be used to paginate the results + Page int `json:"page"` + // StartTime is the start time for the time range filter + // This is optional and can be used to filter labels by a specific start time + // Time format is RFC3339: 2006-01-02T15:04:05Z07:00 + StartTime string `json:"start_time"` + // EndTime is the end time for the time range filter + // This is optional and can be used to filter labels by a specific end time + // Time format is RFC3339: 2006-01-02T15:04:05Z07:00 + EndTime string `json:"end_time"` + // KeyOnly specifies if we only need to retrieve a list of uniq Label keys + KeyOnly bool `json:"key_only"` +} + // SearchLabels searches labels from the database using advanced filtering // @Summary Search labels // @Description Search labels in the database using advanced filtering -// @Param body body queryData true "Search parameters" +// @Param body body SearchLabelsRequest true "Search parameters" // @Tags Labels // @Accept json // @Produce json @@ -125,32 +151,7 @@ func ListLabels(c *gin.Context) { // @Router /api/pipeline/labels/search [post] func SearchLabels(c *gin.Context) { - type queryData struct { - // Id is the unique identifier of the label. - Id string `json:"id"` - // Key is the key of the label. - Key string `json:"key"` - // Value is the value of the label. - Value string `json:"value"` - // Limit is the maximum number of labels to return - // This is optional and can be used to limit the number of labels returned - Limit int `json:"limit"` - // Page is the page number for pagination - // This is optional and can be used to paginate the results - Page int `json:"page"` - // StartTime is the start time for the time range filter - // This is optional and can be used to filter labels by a specific start time - // Time format is RFC3339: 2006-01-02T15:04:05Z07:00 - StartTime string `json:"start_time"` - // EndTime is the end time for the time range filter - // This is optional and can be used to filter labels by a specific end time - // Time format is RFC3339: 2006-01-02T15:04:05Z07:00 - EndTime string `json:"end_time"` - // KeyOnly specifies if we only need to retrieve a list of uniq Label keys - KeyOnly bool `json:"key_only"` - } - - queryParams := queryData{} + queryParams := SearchLabelsRequest{} if err := c.ShouldBindJSON(&queryParams); err != nil { logrus.Errorf("failed to read json body: %s", err) diff --git a/pkg/server/report_handlers.go b/pkg/server/report_handlers.go index ae0e507e..765db2f9 100644 --- a/pkg/server/report_handlers.go +++ b/pkg/server/report_handlers.go @@ -173,6 +173,184 @@ func SearchPipelineReports(c *gin.Context) { }) } +// SearchPipelineReportsSummaryRequest represents the filters used to summarize +// pipeline reports. +type SearchPipelineReportsSummaryRequest struct { + // Metric is what the reports are counted by. It defaults to "result", which is + // the only value supported so far. + Metric string `json:"metric,omitempty"` + // Granularity is the size of the time buckets, one of "hour", "day", "week" or + // "month". It defaults to "day". + Granularity string `json:"granularity,omitempty"` + // Days is the number of days to summarize, today included. + // It defaults to 7 and is ignored when hours, or start_time and end_time, are provided. + Days int `json:"days,omitempty"` + // Hours is the number of hours to summarize, the current hour included. + // It cannot be combined with days and is ignored when start_time and end_time are provided. + Hours int `json:"hours,omitempty"` + // ScmID is the ID of the SCM to filter reports by. + // Use "none" to only count the reports which are not attached to any SCM. + ScmID string `json:"scmid,omitempty"` + // Labels is a map of labels to filter reports by. + Labels map[string]string `json:"labels,omitempty"` + // StartTime is the start time for the time range filter. + // Time format is: 2006-01-02 15:04:05Z07:00 + StartTime string `json:"start_time,omitempty"` + // EndTime is the end time for the time range filter. + // Time format is: 2006-01-02 15:04:05Z07:00 + EndTime string `json:"end_time,omitempty"` +} + +// SearchPipelineReportsSummaryResponse represents the response for the +// SearchPipelineReportsSummary endpoint. +type SearchPipelineReportsSummaryResponse struct { + // Metric is the metric the reports were counted by. + Metric string `json:"metric"` + // Granularity is the size of the time buckets of the entries. + Granularity string `json:"granularity"` + // Data contains one entry per time bucket, ordered from the oldest to the most recent one. + Data []database.ReportResultSummaryEntry `json:"data"` + // TotalCount is the total number of reports matching the query. + TotalCount int `json:"total_count"` +} + +// SearchPipelineReportsSummary returns the number of pipeline reports per result, per time bucket. +// @Summary Summarize pipeline reports +// @Description Return the number of pipeline reports per result for each time bucket of the requested time range. +// @Description Buckets are UTC hours, UTC calendar days, ISO weeks or calendar months depending on the +// @Description granularity, and the date of an entry is the start of its bucket, formatted as RFC3339. +// @Description Every report is counted, including several reports of the same pipeline, and buckets without +// @Description any report are returned with a zeroed entry. +// @Tags Pipeline Reports +// @Accept json +// @Produce json +// @Param body body SearchPipelineReportsSummaryRequest true "Summary filters" +// @Success 200 {object} SearchPipelineReportsSummaryResponse +// @Failure 400 {object} DefaultResponseModel +// @Failure 500 {object} DefaultResponseModel +// @Router /api/pipeline/reports/summary [post] +func SearchPipelineReportsSummary(c *gin.Context) { + queryParams := SearchPipelineReportsSummaryRequest{} + + if err := c.ShouldBindJSON(&queryParams); err != nil { + logrus.Errorf("failed to read json body: %s", err) + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: err.Error(), + }) + return + } + + metric := queryParams.Metric + if metric == "" { + metric = summaryMetricResult + } + + if metric != summaryMetricResult { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: ErrInvalidMetricParam, + }) + return + } + + granularity := database.SummaryGranularity(queryParams.Granularity) + if granularity == "" { + granularity = database.SummaryGranularityDay + } + + if !granularity.IsValid() { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: ErrInvalidGranularityParam, + }) + return + } + + if queryParams.Days != 0 && queryParams.Hours != 0 { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: ErrConflictingWindowParams, + }) + return + } + + hours := queryParams.Hours + if hours < 0 || hours > maxMonitoringDurationDays*24 { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: ErrInvalidHoursParam, + }) + return + } + + days := queryParams.Days + switch { + case days == 0: + // Only fall back to the default window when no window was asked for at all, + // otherwise it would silently override hours. + if hours == 0 { + days = monitoringDurationDays + } + case days < 0 || days > maxMonitoringDurationDays: + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: ErrInvalidDaysParam, + }) + return + } + + // Catching this here returns a 400 instead of the 500 that the database layer + // would return for the same mistake. + if (queryParams.StartTime == "") != (queryParams.EndTime == "") { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: ErrInvalidTimeRangeParams, + }) + return + } + + dataset, totalCount, err := database.SearchReportsSummary( + database.ReportSummaryParams{ + Ctx: c, + Days: days, + Hours: hours, + Granularity: granularity, + MaxDays: maxMonitoringDurationDays, + MaxBuckets: maxSummaryBuckets, + ScmID: queryParams.ScmID, + Labels: queryParams.Labels, + StartTime: queryParams.StartTime, + EndTime: queryParams.EndTime, + }, + ) + if err != nil { + // An explicit time range bypasses the days validation above, so this is the + // only place a range wider than the limit can be caught. + if errors.Is(err, database.ErrSummaryRangeTooWide) { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: ErrTimeRangeTooWide, + }) + return + } + + // The number of buckets depends on the granularity, which the validation above + // cannot account for on its own. + if errors.Is(err, database.ErrSummaryTooManyBuckets) { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: ErrTooManyBuckets, + }) + return + } + + logrus.Errorf("summarizing reports: %s", err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{ + Err: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, SearchPipelineReportsSummaryResponse{ + Metric: metric, + Granularity: string(granularity), + Data: dataset, + TotalCount: totalCount, + }) +} + // ListPipelineReports returns all pipeline reports from the database // @Summary List all pipeline reports // @Description List all pipeline reports from the database diff --git a/pkg/server/var.go b/pkg/server/var.go index cd2159ce..8e0cded9 100644 --- a/pkg/server/var.go +++ b/pkg/server/var.go @@ -6,6 +6,15 @@ var ( // performance of the database queries. // The goal is to minimize the impact in small environment monitoringDurationDays int = 7 + // maxMonitoringDurationDays is the largest number of days a summary query may span. + // The time range itself is indexed but the aggregation runs over every matching row, + // so a wide window means scanning most of the table. + maxMonitoringDurationDays int = 366 + // maxSummaryBuckets is the largest number of buckets a summary may return. + // maxMonitoringDurationDays bounds how much of the table a summary scans, this bounds + // how large its response gets: an hourly summary of a year is a cheap scan but would + // return more than eight thousand entries. + maxSummaryBuckets int = 1000 // errMessageType is the key used in JSON responses to indicate an error message. errMessageType = "error" // successMessageType is used to indicate a successful operation in API responses. @@ -19,5 +28,27 @@ const ( ErrInvalidSummaryParam = "invalid summary parameter" // ErrInvalidKeyOnlyParam is the error message returned when the keyonly parameter is invalid. ErrInvalidKeyOnlyParam = "invalid keyonly parameter" - ErrInvalidJWT = "JWT is invalid" + // ErrInvalidDaysParam is the error message returned when the days parameter is out of range. + ErrInvalidDaysParam = "invalid days parameter" + // ErrInvalidTimeRangeParams is the error message returned when only one of the time range boundaries is provided. + ErrInvalidTimeRangeParams = "both start_time and end_time must be provided" + // ErrInvalidMetricParam is the error message returned when the requested summary metric is not supported. + ErrInvalidMetricParam = "invalid metric parameter" + // ErrInvalidGranularityParam is the error message returned when the requested summary granularity is not supported. + ErrInvalidGranularityParam = "invalid granularity parameter" + // ErrTimeRangeTooWide is the error message returned when the requested time range spans more + // than maxMonitoringDurationDays days. + ErrTimeRangeTooWide = "requested time range exceeds the maximum allowed span" + // ErrInvalidHoursParam is the error message returned when the hours parameter is out of range. + ErrInvalidHoursParam = "invalid hours parameter" + // ErrConflictingWindowParams is the error message returned when both days and hours are provided. + ErrConflictingWindowParams = "days and hours cannot be combined" + // ErrTooManyBuckets is the error message returned when the requested time range and granularity + // would produce more than maxSummaryBuckets entries. + ErrTooManyBuckets = "requested time range and granularity produce too many buckets" + ErrInvalidJWT = "JWT is invalid" + + // summaryMetricResult counts the pipeline reports per Updatecli result. It is the + // only metric supported by the reports summary so far. + summaryMetricResult = "result" ) From 9097f468b5646c1a893f7ca4ced55efacb5edc1d Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Wed, 5 Aug 2026 20:46:47 +0200 Subject: [PATCH 2/9] feat: allow to filter based on report result Signed-off-by: Olivier Vernin --- pkg/database/database_test.go | 2 +- pkg/database/report.go | 33 ++++++++++++++++++++++++++++++++- pkg/database/scm.go | 23 ++++++++++++++++++----- pkg/server/endpoints_test.go | 2 +- pkg/server/report_handlers.go | 10 ++++++++++ pkg/server/scmdb_handlers.go | 9 +++++++-- 6 files changed, 69 insertions(+), 10 deletions(-) diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index 3fc814fa..d31b2af7 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -66,7 +66,7 @@ func TestDatabase(t *testing.T) { }) t.Run("migration 000010 backfills pipeline_result", func(t *testing.T) { - // Migration 000004 read "data ->> 'result'" while a marshalled report stores + // Migration 000004 read "data ->> 'result'" while a marshaled report stores // the key as "Result", so its backfill silently did nothing and every report // inserted before it still has an empty pipeline_result. id, err := InsertReport(ctx, reports.Report{ diff --git a/pkg/database/report.go b/pkg/database/report.go index bbe44528..396d670f 100644 --- a/pkg/database/report.go +++ b/pkg/database/report.go @@ -99,6 +99,9 @@ type SearchLatestReportsParams struct { Page int Latest bool Labels map[string]string + // Results restricts the search to the reports whose pipeline result is one of + // them. An empty list does not filter anything out. + Results []string } // SearchLatestReports searches the latest reports according some parameters. @@ -172,6 +175,8 @@ func SearchLatestReports(params SearchLatestReportsParams) ([]SearchLatestReport return nil, 0, err } + applyResultFilter(&query, params.Results) + // Total counter query must be built before applying pagination // because it needs to count all the reports matching the query. totalCountQuery := psql.Select(sm.From(query), sm.Columns("count(*)")) @@ -364,6 +369,9 @@ type ReportSummaryParams struct { ScmID string // Labels restricts the summary to the reports matching those labels. Labels map[string]string + // Results restricts the summary to the reports whose pipeline result is one of + // them. An empty list does not filter anything out. + Results []string } // ReportResultSummaryEntry contains the number of reports per result for a single time bucket. @@ -423,6 +431,8 @@ func SearchReportsSummary(params ReportSummaryParams) ([]ReportResultSummaryEntr return nil, 0, err } + applyResultFilter(&query, params.Results) + if len(params.Labels) > 0 { // The report window is widened to whole buckets so the label lookup must cover // the same range, otherwise labels timestamped within the widened part would be @@ -520,7 +530,8 @@ func summaryResultKey(pipelineResult string) string { // summary. Both are the start of a bucket, in UTC. func summaryRange(params ReportSummaryParams, granularity SummaryGranularity) (time.Time, time.Time, error) { - firstTime, lastTime := time.Time{}, time.Time{} + var firstTime time.Time + var lastTime time.Time switch { case params.StartTime != "" || params.EndTime != "": @@ -966,6 +977,26 @@ func applyResourceConfigFilter(query *bob.BaseQuery[*dialect.SelectQuery], id, k return nil } +// applyResultFilter restricts the given query to the reports whose pipeline result is +// one of those given. An empty list does not filter anything out. +// +// pipeline_result is denormalized from data ->> 'Result' when the report is inserted, +// and indexed alongside updated_at, so this does not have to reach into the jsonb +// payload. A result which is not an Updatecli one simply matches no report, rather +// than being silently dropped from the filter. +func applyResultFilter(query *bob.BaseQuery[*dialect.SelectQuery], results []string) { + if len(results) == 0 { + return + } + + args := make([]bob.Expression, len(results)) + for i := range results { + args[i] = psql.Arg(results[i]) + } + + query.Apply(sm.Where(psql.Quote("pipeline_result").In(args...))) +} + // applyScmFilter restricts the given query to the reports associated to a specific scm. // An empty scmID does not filter anything while "none", "null", or "nil" only keeps // the reports which are not associated to any scm. diff --git a/pkg/database/scm.go b/pkg/database/scm.go index d5be685b..37f85b97 100644 --- a/pkg/database/scm.go +++ b/pkg/database/scm.go @@ -3,6 +3,7 @@ package database import ( "context" "fmt" + "slices" "github.com/google/uuid" "github.com/sirupsen/logrus" @@ -14,7 +15,6 @@ import ( ) // InsertSCM creates a new SCM and inserts it into the database. -// // It returns the ID of the newly created SCM. func InsertSCM(ctx context.Context, url, branch string) (string, error) { //"INSERT INTO scms (url, branch) VALUES ($1, $2) RETURNING id" @@ -151,10 +151,13 @@ type GetSCMSummaryParams struct { StartTime string EndTime string Labels map[string]string - TotalCount int - TotalActions int - Ctx context.Context - ScmRows []model.SCM + // Results restricts the summary to the reports whose pipeline result is one of + // them. An empty list does not filter anything out. + Results []string + TotalCount int + TotalActions int + Ctx context.Context + ScmRows []model.SCM } // GetSCMSummary returns a list of scms summary from the scm database table. @@ -255,6 +258,16 @@ func GetSCMSummary(params GetSCMSummaryParams) (*SCMDataset, error) { return nil, fmt.Errorf("scanning scm summary row: %w", err) } + // The results are dropped here rather than in the query above on purpose. + // That query keeps the latest report of every pipeline, so this summary + // reports where each pipeline stands now; filtering the reports before + // that would instead keep the latest report which happened to carry one + // of those results, reporting a pipeline as failing long after it + // recovered. + if len(params.Results) > 0 && !slices.Contains(params.Results, result) { + continue + } + resultFound := false for r := range dataset.Data[scmURL][scmBranch].TotalResultByType { if r == result { diff --git a/pkg/server/endpoints_test.go b/pkg/server/endpoints_test.go index 72f0b623..b55a42ab 100644 --- a/pkg/server/endpoints_test.go +++ b/pkg/server/endpoints_test.go @@ -815,7 +815,7 @@ func TestEndpoints(t *testing.T) { setReportTimestamp(t, id, at) } - // Halfway into each hour, so that a report cannot land in a neighbouring + // Halfway into each hour, so that a report cannot land in a neighboring // bucket. halfPast := 30 * time.Minute seedReportAt("✔", currentHour.Add(halfPast)) diff --git a/pkg/server/report_handlers.go b/pkg/server/report_handlers.go index 765db2f9..18355b81 100644 --- a/pkg/server/report_handlers.go +++ b/pkg/server/report_handlers.go @@ -131,6 +131,10 @@ func SearchPipelineReports(c *gin.Context) { Latest bool `json:"latest"` // Labels is a map of labels to filter reports by Labels map[string]string `json:"labels,omitempty"` + // Results is a list of pipeline results to filter reports by, such as + // "✔", "✗", "⚠" or "-". A report matches when its result is any of them. + // This is optional and an empty list does not filter anything out. + Results []string `json:"results,omitempty"` } queryParams := queryData{} @@ -157,6 +161,7 @@ func SearchPipelineReports(c *gin.Context) { Page: queryParams.Page, Latest: queryParams.Latest, Labels: queryParams.Labels, + Results: queryParams.Results, }, ) if err != nil { @@ -193,6 +198,10 @@ type SearchPipelineReportsSummaryRequest struct { ScmID string `json:"scmid,omitempty"` // Labels is a map of labels to filter reports by. Labels map[string]string `json:"labels,omitempty"` + // Results is a list of pipeline results to filter reports by, such as + // "✔", "✗", "⚠" or "-". A report is counted when its result is any of them. + // An empty list does not filter anything out. + Results []string `json:"results,omitempty"` // StartTime is the start time for the time range filter. // Time format is: 2006-01-02 15:04:05Z07:00 StartTime string `json:"start_time,omitempty"` @@ -313,6 +322,7 @@ func SearchPipelineReportsSummary(c *gin.Context) { MaxBuckets: maxSummaryBuckets, ScmID: queryParams.ScmID, Labels: queryParams.Labels, + Results: queryParams.Results, StartTime: queryParams.StartTime, EndTime: queryParams.EndTime, }, diff --git a/pkg/server/scmdb_handlers.go b/pkg/server/scmdb_handlers.go index 991dc708..9dedf57a 100644 --- a/pkg/server/scmdb_handlers.go +++ b/pkg/server/scmdb_handlers.go @@ -30,6 +30,9 @@ type SearchSCMsRequest struct { EndTime string `json:"end_time"` // Labels filters SCM summaries by report labels. Labels map[string]string `json:"labels,omitempty"` + // Results filters SCM summaries by pipeline result, such as "✔", "✗", "⚠" or + // "-". An empty list does not filter anything out. + Results []string `json:"results,omitempty"` // URL is the SCM URL to filter by. URL string `json:"url,omitempty"` // Branch is the SCM branch to filter by. @@ -82,6 +85,7 @@ func SearchSCMs(c *gin.Context) { queryParams.StartTime, queryParams.EndTime, queryParams.Labels, + queryParams.Results, ) return } @@ -154,7 +158,7 @@ func ListSCMs(c *gin.Context) { } if summary { - findSCMSummary(c, rows, totalCount, queryValues.Get("start_time"), queryValues.Get("end_time"), map[string]string{}) + findSCMSummary(c, rows, totalCount, queryValues.Get("start_time"), queryValues.Get("end_time"), map[string]string{}, nil) return } @@ -182,7 +186,7 @@ type FindSCMSummaryResponse struct { } // findSCMSummary returns a summary of all git repositories detected. -func findSCMSummary(c *gin.Context, scmRows []model.SCM, totalCount int, startTime, endTime string, labels map[string]string) { +func findSCMSummary(c *gin.Context, scmRows []model.SCM, totalCount int, startTime, endTime string, labels map[string]string, results []string) { var data map[string]database.SCMBranchDataset dataset, err := database.GetSCMSummary(database.GetSCMSummaryParams{ @@ -193,6 +197,7 @@ func findSCMSummary(c *gin.Context, scmRows []model.SCM, totalCount int, startTi StartTime: startTime, EndTime: endTime, Labels: labels, + Results: results, }) if err != nil { logrus.Errorf("getting scm summary failed: %s", err) From b8abcd018dc4306205c3d6a4537546aebfbd76d5 Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Thu, 6 Aug 2026 10:12:56 +0200 Subject: [PATCH 3/9] feat: allow to filter by open/close action Signed-off-by: Olivier Vernin --- docs/docs.go | 29 +++ docs/swagger.json | 29 +++ docs/swagger.yaml | 45 ++++ pkg/database/database_test.go | 102 ++++++++ ...alter_pipelineReports_open_action.down.sql | 5 + ...1_alter_pipelineReports_open_action.up.sql | 29 +++ pkg/database/report.go | 77 +++++- pkg/database/scm.go | 41 ++- pkg/server/endpoints_test.go | 246 +++++++++++++++++- pkg/server/report_handlers.go | 19 ++ pkg/server/scmdb_handlers.go | 67 +++-- 11 files changed, 650 insertions(+), 39 deletions(-) create mode 100644 pkg/database/migrations/000011_alter_pipelineReports_open_action.down.sql create mode 100644 pkg/database/migrations/000011_alter_pipelineReports_open_action.up.sql diff --git a/docs/docs.go b/docs/docs.go index 669b9c37..5493305f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1048,6 +1048,13 @@ const docTemplate = `{ "description": "Date is the start of the bucket, in UTC, formatted as RFC3339.", "type": "string" }, + "open_actions": { + "description": "OpenActions contains, for each Updatecli result, how many of the reports counted in\nResults also carry an open action, such as a pull request still waiting to be merged.\nIt is a breakdown of Results, not an addition to it, so its counts are always lower\nthan or equal to the matching ones in Results.\n\nThe interesting one is the count reported under the success result: those pipelines\nran fine and had nothing to change only because the change is already waiting in a\npull request.", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, "results": { "description": "Results contains the number of reports per Updatecli result for that bucket.", "type": "object", @@ -1907,6 +1914,17 @@ const docTemplate = `{ "description": "Metric is what the reports are counted by. It defaults to \"result\", which is\nthe only value supported so far.", "type": "string" }, + "open_action": { + "description": "OpenAction filters reports by whether they carry an action left open, such as a\npull request still waiting to be merged. This is optional: unset does not filter\nanything out, true only counts the reports with an open action and false only the\nones without.\n\nThe same breakdown is reported without filtering anything out under the open_actions\nkey of every bucket.", + "type": "boolean" + }, + "results": { + "description": "Results is a list of pipeline results to filter reports by, such as\n\"✔\", \"✗\", \"⚠\" or \"-\". A report is counted when its result is any of them.\nAn empty list does not filter anything out.", + "type": "array", + "items": { + "type": "string" + } + }, "scmid": { "description": "ScmID is the ID of the SCM to filter reports by.\nUse \"none\" to only count the reports which are not attached to any SCM.", "type": "string" @@ -1963,10 +1981,21 @@ const docTemplate = `{ "description": "Limit is the maximum number of SCMs to return.", "type": "integer" }, + "open_action": { + "description": "OpenAction filters SCM summaries by whether a pipeline carries an action left open,\nsuch as a pull request still waiting to be merged. This is optional: unset does not\nfilter anything out, true only keeps the pipelines with an open action and false only\nthe ones without.", + "type": "boolean" + }, "page": { "description": "Page is the page number for pagination.", "type": "integer" }, + "results": { + "description": "Results filters SCM summaries by pipeline result, such as \"✔\", \"✗\", \"⚠\" or\n\"-\". An empty list does not filter anything out.", + "type": "array", + "items": { + "type": "string" + } + }, "scmid": { "description": "ScmID is the ID of the SCM to filter by.", "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index 97557645..81d6d0b3 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1037,6 +1037,13 @@ "description": "Date is the start of the bucket, in UTC, formatted as RFC3339.", "type": "string" }, + "open_actions": { + "description": "OpenActions contains, for each Updatecli result, how many of the reports counted in\nResults also carry an open action, such as a pull request still waiting to be merged.\nIt is a breakdown of Results, not an addition to it, so its counts are always lower\nthan or equal to the matching ones in Results.\n\nThe interesting one is the count reported under the success result: those pipelines\nran fine and had nothing to change only because the change is already waiting in a\npull request.", + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, "results": { "description": "Results contains the number of reports per Updatecli result for that bucket.", "type": "object", @@ -1896,6 +1903,17 @@ "description": "Metric is what the reports are counted by. It defaults to \"result\", which is\nthe only value supported so far.", "type": "string" }, + "open_action": { + "description": "OpenAction filters reports by whether they carry an action left open, such as a\npull request still waiting to be merged. This is optional: unset does not filter\nanything out, true only counts the reports with an open action and false only the\nones without.\n\nThe same breakdown is reported without filtering anything out under the open_actions\nkey of every bucket.", + "type": "boolean" + }, + "results": { + "description": "Results is a list of pipeline results to filter reports by, such as\n\"✔\", \"✗\", \"⚠\" or \"-\". A report is counted when its result is any of them.\nAn empty list does not filter anything out.", + "type": "array", + "items": { + "type": "string" + } + }, "scmid": { "description": "ScmID is the ID of the SCM to filter reports by.\nUse \"none\" to only count the reports which are not attached to any SCM.", "type": "string" @@ -1952,10 +1970,21 @@ "description": "Limit is the maximum number of SCMs to return.", "type": "integer" }, + "open_action": { + "description": "OpenAction filters SCM summaries by whether a pipeline carries an action left open,\nsuch as a pull request still waiting to be merged. This is optional: unset does not\nfilter anything out, true only keeps the pipelines with an open action and false only\nthe ones without.", + "type": "boolean" + }, "page": { "description": "Page is the page number for pagination.", "type": "integer" }, + "results": { + "description": "Results filters SCM summaries by pipeline result, such as \"✔\", \"✗\", \"⚠\" or\n\"-\". An empty list does not filter anything out.", + "type": "array", + "items": { + "type": "string" + } + }, "scmid": { "description": "ScmID is the ID of the SCM to filter by.", "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4cba9546..289e03a8 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -75,6 +75,19 @@ definitions: date: description: Date is the start of the bucket, in UTC, formatted as RFC3339. type: string + open_actions: + additionalProperties: + type: integer + description: |- + OpenActions contains, for each Updatecli result, how many of the reports counted in + Results also carry an open action, such as a pull request still waiting to be merged. + It is a breakdown of Results, not an addition to it, so its counts are always lower + than or equal to the matching ones in Results. + + The interesting one is the count reported under the success result: those pipelines + ran fine and had nothing to change only because the change is already waiting in a + pull request. + type: object results: additionalProperties: type: integer @@ -702,6 +715,24 @@ definitions: Metric is what the reports are counted by. It defaults to "result", which is the only value supported so far. type: string + open_action: + description: |- + OpenAction filters reports by whether they carry an action left open, such as a + pull request still waiting to be merged. This is optional: unset does not filter + anything out, true only counts the reports with an open action and false only the + ones without. + + The same breakdown is reported without filtering anything out under the open_actions + key of every bucket. + type: boolean + results: + description: |- + Results is a list of pipeline results to filter reports by, such as + "✔", "✗", "⚠" or "-". A report is counted when its result is any of them. + An empty list does not filter anything out. + items: + type: string + type: array scmid: description: |- ScmID is the ID of the SCM to filter reports by. @@ -749,9 +780,23 @@ definitions: limit: description: Limit is the maximum number of SCMs to return. type: integer + open_action: + description: |- + OpenAction filters SCM summaries by whether a pipeline carries an action left open, + such as a pull request still waiting to be merged. This is optional: unset does not + filter anything out, true only keeps the pipelines with an open action and false only + the ones without. + type: boolean page: description: Page is the page number for pagination. type: integer + results: + description: |- + Results filters SCM summaries by pipeline result, such as "✔", "✗", "⚠" or + "-". An empty list does not filter anything out. + items: + type: string + type: array scmid: description: ScmID is the ID of the SCM to filter by. type: string diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index d31b2af7..e6d84885 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -101,4 +101,106 @@ func TestDatabase(t *testing.T) { assert.Equal(t, result.SUCCESS, pipelineResult) assert.Equal(t, "ci: bump Venom version", pipelineName) }) + + t.Run("openActionSQLExpr detects an action left open", func(t *testing.T) { + // This is the contract the whole open action dimension rests on: Updatecli reports + // a pipeline which had nothing to change as a success even when its change is + // already waiting in an open pull request, and the only trace of it in the payload + // is reports.Action.Link, serialized as "actionUrl" and omitted when empty. + // + // The expression is exercised through the reports it is meant to tell apart rather + // than through a handcrafted jsonb document, so that a change to the Action struct + // of the Updatecli module this repository depends on breaks this test. + testdata := []struct { + name string + report reports.Report + want bool + }{ + { + name: "success with a pull request left open", + report: reports.Report{ + Name: "succeeded, pull request still open", + Result: result.SUCCESS, + ID: "open-action-success", + Actions: map[string]*reports.Action{ + "default": { + ID: "default", + Link: "https://github.com/updatecli/udash/pull/42", + }, + }, + }, + want: true, + }, + { + name: "success with an action but no pull request", + report: reports.Report{ + Name: "succeeded, nothing to follow up", + Result: result.SUCCESS, + ID: "no-open-action-success", + Actions: map[string]*reports.Action{ + "default": {ID: "default"}, + }, + }, + want: false, + }, + { + name: "pipeline without any action configured", + report: reports.Report{ + Name: "no action configured", + Result: result.SUCCESS, + ID: "no-action-at-all", + }, + want: false, + }, + { + name: "attention with a pull request left open", + report: reports.Report{ + Name: "changed something and opened a pull request", + Result: result.ATTENTION, + ID: "open-action-attention", + Actions: map[string]*reports.Action{ + "default": { + ID: "default", + Link: "https://github.com/updatecli/udash/pull/43", + }, + }, + }, + want: true, + }, + } + + for _, tt := range testdata { + t.Run(tt.name, func(t *testing.T) { + id, err := InsertReport(ctx, tt.report) + require.NoError(t, err) + t.Cleanup(func() { + _, err := DB.Exec(ctx, "DELETE FROM pipelineReports WHERE id = $1", id) + assert.NoError(t, err) + }) + + got := false + require.NoError(t, DB.QueryRow(ctx, + "SELECT "+openActionSQLExpr+" FROM pipelineReports WHERE id = $1", id, + ).Scan(&got)) + + assert.Equal(t, tt.want, got) + }) + } + }) + + t.Run("migration 000011 indexes the open action expression", func(t *testing.T) { + // The jsonpath is inlined in openActionSQLExpr so that it matches the index + // expression. Binding it as a parameter would still return the right reports while + // silently falling back to a sequential scan over every stored payload. + indexed := false + require.NoError(t, DB.QueryRow(ctx, ` + SELECT count(*) = 1 + FROM pg_indexes + WHERE tablename = 'pipelinereports' + AND indexname = 'idx_pipelinereports_updated_at_result_open_action' + AND indexdef LIKE '%jsonb_path_exists%'`, + ).Scan(&indexed)) + + assert.True(t, indexed) + }) } diff --git a/pkg/database/migrations/000011_alter_pipelineReports_open_action.down.sql b/pkg/database/migrations/000011_alter_pipelineReports_open_action.down.sql new file mode 100644 index 00000000..978279bf --- /dev/null +++ b/pkg/database/migrations/000011_alter_pipelineReports_open_action.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_pipelinereports_updated_at_result_open_action; + +COMMIT; diff --git a/pkg/database/migrations/000011_alter_pipelineReports_open_action.up.sql b/pkg/database/migrations/000011_alter_pipelineReports_open_action.up.sql new file mode 100644 index 00000000..901a3036 --- /dev/null +++ b/pkg/database/migrations/000011_alter_pipelineReports_open_action.up.sql @@ -0,0 +1,29 @@ +-- Updatecli reports a pipeline which had nothing to change as a success, even when the +-- change it would have made is already sitting in a pull request nobody merged. That state +-- is the one which needs a human, yet it is indistinguishable from a genuinely up to date +-- pipeline when looking at the result alone. +-- +-- It is however recorded in the report payload: reports.Action.Link is serialized as +-- "actionUrl", is omitted when empty, and Updatecli only ever fills it from an open pull +-- request. So "$.Actions.*.actionUrl" existing is an exact, self clearing marker for +-- "a pull request is open right now", and it is already true of every report stored so far. +-- +-- The expression must stay byte for byte the one in openActionSQLExpr, otherwise the queries +-- keep returning the right reports while silently falling back to a sequential scan. +-- +-- An expression index is used rather than a denormalized column: migration 000010 exists +-- precisely because a denormalized column silently drifted from the payload, and a generated +-- column would rewrite the whole table. An index needs no backfill, cannot drift, and covers +-- every existing row as soon as it is built. The result and the range predicates are part of +-- it so that the reports search and the reports summary, which always filter on a time range +-- and group per result, keep their index only scan. +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_pipelinereports_updated_at_result_open_action +ON pipelineReports ( + updated_at, + pipeline_result, + (jsonb_path_exists(data, '$.Actions.*.actionUrl')) +); + +COMMIT; diff --git a/pkg/database/report.go b/pkg/database/report.go index 396d670f..e12e577b 100644 --- a/pkg/database/report.go +++ b/pkg/database/report.go @@ -102,6 +102,10 @@ type SearchLatestReportsParams struct { // Results restricts the search to the reports whose pipeline result is one of // them. An empty list does not filter anything out. Results []string + // OpenAction restricts the search to the reports which carry an open action, such as + // a pull request still waiting to be merged, or to the ones which do not. A nil value + // does not filter anything out. + OpenAction *bool } // SearchLatestReports searches the latest reports according some parameters. @@ -176,6 +180,7 @@ func SearchLatestReports(params SearchLatestReportsParams) ([]SearchLatestReport } applyResultFilter(&query, params.Results) + applyOpenActionFilter(&query, params.OpenAction) // Total counter query must be built before applying pagination // because it needs to count all the reports matching the query. @@ -372,6 +377,10 @@ type ReportSummaryParams struct { // Results restricts the summary to the reports whose pipeline result is one of // them. An empty list does not filter anything out. Results []string + // OpenAction restricts the summary to the reports which carry an open action, such as + // a pull request still waiting to be merged, or to the ones which do not. A nil value + // does not filter anything out. + OpenAction *bool } // ReportResultSummaryEntry contains the number of reports per result for a single time bucket. @@ -380,6 +389,15 @@ type ReportResultSummaryEntry struct { Date string `json:"date"` // Results contains the number of reports per Updatecli result for that bucket. Results map[string]int `json:"results"` + // OpenActions contains, for each Updatecli result, how many of the reports counted in + // Results also carry an open action, such as a pull request still waiting to be merged. + // It is a breakdown of Results, not an addition to it, so its counts are always lower + // than or equal to the matching ones in Results. + // + // The interesting one is the count reported under the success result: those pipelines + // ran fine and had nothing to change only because the change is already waiting in a + // pull request. + OpenActions map[string]int `json:"open_actions"` // Total is the number of reports for that bucket, all results combined. Total int `json:"total"` } @@ -417,6 +435,7 @@ func SearchReportsSummary(params ReportSummaryParams) ([]ReportResultSummaryEntr // pipeline_result is denormalized from data ->> 'Result' when the report is // inserted, grouping on it avoids parsing the jsonb document of every report. "pipeline_result", + openActionSQLExpr, "count(*)", ), sm.Where( @@ -424,6 +443,7 @@ func SearchReportsSummary(params ReportSummaryParams) ([]ReportResultSummaryEntr ), sm.GroupBy(dateTrunc), sm.GroupBy("pipeline_result"), + sm.GroupBy(openActionSQLExpr), sm.OrderBy(dateTrunc), ) @@ -432,6 +452,7 @@ func SearchReportsSummary(params ReportSummaryParams) ([]ReportResultSummaryEntr } applyResultFilter(&query, params.Results) + applyOpenActionFilter(&query, params.OpenAction) if len(params.Labels) > 0 { // The report window is widened to whole buckets so the label lookup must cover @@ -467,23 +488,31 @@ func SearchReportsSummary(params ReportSummaryParams) ([]ReportResultSummaryEntr defer rows.Close() countByDate := map[string]map[string]int{} + openActionCountByDate := map[string]map[string]int{} totalCount := 0 for rows.Next() { bucket := time.Time{} reportResult := "" + hasOpenAction := false count := 0 - if err := rows.Scan(&bucket, &reportResult, &count); err != nil { + if err := rows.Scan(&bucket, &reportResult, &hasOpenAction, &count); err != nil { return nil, 0, fmt.Errorf("parsing result: %s", err) } date := bucket.UTC().Format(summaryDateFormat) if countByDate[date] == nil { countByDate[date] = map[string]int{} + openActionCountByDate[date] = map[string]int{} } - countByDate[date][summaryResultKey(reportResult)] += count + resultKey := summaryResultKey(reportResult) + + countByDate[date][resultKey] += count + if hasOpenAction { + openActionCountByDate[date][resultKey] += count + } totalCount += count } @@ -494,12 +523,14 @@ func SearchReportsSummary(params ReportSummaryParams) ([]ReportResultSummaryEntr dataset := []ReportResultSummaryEntry{} for bucket := firstBucket; !bucket.After(lastBucket); bucket = nextBucket(bucket, granularity) { entry := ReportResultSummaryEntry{ - Date: bucket.Format(summaryDateFormat), - Results: map[string]int{}, + Date: bucket.Format(summaryDateFormat), + Results: map[string]int{}, + OpenActions: map[string]int{}, } for _, r := range summaryResultKeys { entry.Results[r] = 0 + entry.OpenActions[r] = 0 } for r, count := range countByDate[entry.Date] { @@ -507,6 +538,10 @@ func SearchReportsSummary(params ReportSummaryParams) ([]ReportResultSummaryEntr entry.Total += count } + for r, count := range openActionCountByDate[entry.Date] { + entry.OpenActions[r] += count + } + dataset = append(dataset, entry) } @@ -997,6 +1032,40 @@ func applyResultFilter(query *bob.BaseQuery[*dialect.SelectQuery], results []str query.Apply(sm.Where(psql.Quote("pipeline_result").In(args...))) } +// openActionSQLExpr is true of the reports carrying at least one action left open, which is +// how Updatecli reports a pull request still waiting to be merged. +// +// reports.Action.Link is serialized as "actionUrl" and omitted when empty, and Updatecli +// only ever fills it from an open pull request: CheckActionExist queries the forge for open +// pull requests only, and the pull request handler resets the link when it closes one. So +// the presence of that key is a self clearing marker, and it is already true of every report +// stored so far rather than only of the ones produced from now on. +// +// The jsonpath is inlined rather than bound as a parameter on purpose: an expression index +// only matches a literal expression, so binding it would cost +// idx_pipelinereports_updated_at_result_open_action. It contains no user input. +// +// It must also stay free of the jsonpath filter operator: bob reads "?" as a placeholder, +// so a path such as '$.Actions.*.actionUrl ? (@ != "")' silently consumes an argument and +// builds a query which matches nothing. Guarding against an empty link is unnecessary +// anyway, "actionUrl" is omitempty so it is absent rather than empty. +const openActionSQLExpr = `jsonb_path_exists(data, '$.Actions.*.actionUrl')` + +// applyOpenActionFilter restricts the given query to the reports which do, or which do not, +// carry an open action. A nil openAction does not filter anything out. +// +// This is deliberately a dimension of its own rather than a fifth pipeline result: an open +// action is orthogonal to the result. A pipeline may have succeeded because its change is +// already in an open pull request, but it may also have changed something and just opened +// one, or be failing while a pull request from a previous run is still around. +func applyOpenActionFilter(query *bob.BaseQuery[*dialect.SelectQuery], openAction *bool) { + if openAction == nil { + return + } + + query.Apply(sm.Where(psql.Raw(openActionSQLExpr+" = ?", psql.Arg(*openAction)))) +} + // applyScmFilter restricts the given query to the reports associated to a specific scm. // An empty scmID does not filter anything while "none", "null", or "nil" only keeps // the reports which are not associated to any scm. diff --git a/pkg/database/scm.go b/pkg/database/scm.go index 37f85b97..7384d1b7 100644 --- a/pkg/database/scm.go +++ b/pkg/database/scm.go @@ -136,6 +136,15 @@ type ScmSummaryData struct { TotalResult int `json:"total_result"` // TotalActionURLs is the total number of unique action URLs for this SCM. TotalActionURLs int `json:"total_action_urls"` + // TotalOpenActionByResult is a map of result types to the number of pipelines in that + // result which also carry an open action, such as a pull request still waiting to be + // merged. It is a breakdown of TotalResultByType, so its counts are always lower than + // or equal to the matching ones there. + // + // Unlike TotalActionURLs, which counts distinct action URLs, this counts pipelines: a + // single pull request grouping the changes of several pipelines is counted once there + // and once per pipeline here. + TotalOpenActionByResult map[string]int `json:"total_open_action_by_result"` } // SCMBranchDataset represents a map of branches and their summary data for a single SCM URL. @@ -153,7 +162,11 @@ type GetSCMSummaryParams struct { Labels map[string]string // Results restricts the summary to the reports whose pipeline result is one of // them. An empty list does not filter anything out. - Results []string + Results []string + // OpenAction restricts the summary to the pipelines which carry an open action, such as + // a pull request still waiting to be merged, or to the ones which do not. A nil value + // does not filter anything out. + OpenAction *bool TotalCount int TotalActions int Ctx context.Context @@ -214,6 +227,9 @@ func GetSCMSummary(params GetSCMSummaryParams) (*SCMDataset, error) { psql.Raw("data ->> 'ID'"), ), sm.With("filtered_reports").As(filteredSCMsQuery), + // The action URLs are read with the same jsonpath as openActionSQLExpr, so that + // a pipeline counted as carrying an open action here is the one the reports + // search and the reports summary would return too. sm.Columns("id", "data ->> 'Result'", "jsonb_path_query_array(data, '$.Actions.*.actionUrl')"), sm.From("filtered_reports"), sm.OrderBy(psql.Raw("data ->> 'ID'")), @@ -239,8 +255,9 @@ func GetSCMSummary(params GetSCMSummaryParams) (*SCMDataset, error) { } d := ScmSummaryData{ - ID: scmID.String(), - TotalResultByType: make(map[string]int), + ID: scmID.String(), + TotalResultByType: make(map[string]int), + TotalOpenActionByResult: make(map[string]int), } dataset.Data[scmURL][scmBranch] = d @@ -258,16 +275,22 @@ func GetSCMSummary(params GetSCMSummaryParams) (*SCMDataset, error) { return nil, fmt.Errorf("scanning scm summary row: %w", err) } - // The results are dropped here rather than in the query above on purpose. - // That query keeps the latest report of every pipeline, so this summary - // reports where each pipeline stands now; filtering the reports before - // that would instead keep the latest report which happened to carry one + hasOpenAction := len(actionUrls) > 0 + + // The results and the open actions are dropped here rather than in the query + // above on purpose. That query keeps the latest report of every pipeline, so + // this summary reports where each pipeline stands now; filtering the reports + // before that would instead keep the latest report which happened to carry one // of those results, reporting a pipeline as failing long after it // recovered. if len(params.Results) > 0 && !slices.Contains(params.Results, result) { continue } + if params.OpenAction != nil && *params.OpenAction != hasOpenAction { + continue + } + resultFound := false for r := range dataset.Data[scmURL][scmBranch].TotalResultByType { if r == result { @@ -280,6 +303,10 @@ func GetSCMSummary(params GetSCMSummaryParams) (*SCMDataset, error) { dataset.Data[scmURL][scmBranch].TotalResultByType[result] = 1 } + if hasOpenAction { + dataset.Data[scmURL][scmBranch].TotalOpenActionByResult[result]++ + } + for i := range actionUrls { if _, ok := isActionURLsFound[actionUrls[i]]; !ok { isActionURLsFound[actionUrls[i]] = true diff --git a/pkg/server/endpoints_test.go b/pkg/server/endpoints_test.go index b55a42ab..949060f6 100644 --- a/pkg/server/endpoints_test.go +++ b/pkg/server/endpoints_test.go @@ -9,6 +9,7 @@ import ( "maps" "net/http" "net/http/httptest" + "sort" "testing" "time" @@ -526,15 +527,20 @@ func TestEndpoints(t *testing.T) { } // bucketEntry builds the expected response entry for a bucket, starting from - // a zeroed set of results. + // a zeroed set of results. None of the reports seeded here carries an action, so + // the open action breakdown is always zeroed; it is covered on its own below. bucketEntry := func(date string, results map[string]any) map[string]any { - allResults := map[string]any{ - "✔": float64(0), - "✗": float64(0), - "⚠": float64(0), - "-": float64(0), - "unknown": float64(0), + zeroedResults := func() map[string]any { + return map[string]any{ + "✔": float64(0), + "✗": float64(0), + "⚠": float64(0), + "-": float64(0), + "unknown": float64(0), + } } + + allResults := zeroedResults() total := float64(0) for k, v := range results { allResults[k] = v @@ -542,9 +548,10 @@ func TestEndpoints(t *testing.T) { } return map[string]any{ - "date": date, - "results": allResults, - "total": total, + "date": date, + "results": allResults, + "open_actions": zeroedResults(), + "total": total, } } @@ -863,6 +870,225 @@ func TestEndpoints(t *testing.T) { }) }) }) + + t.Run("filtering on an action left open", func(t *testing.T) { + // Updatecli reports a pipeline which had nothing to change as a success even when + // the change it would have made is already waiting in an open pull request. That + // is the state which needs a human, yet the result alone cannot express it: the + // only thing telling it apart from a genuinely up to date pipeline is the action + // link the report carries. + truncateReports(t) + t.Cleanup(func() { + truncateReports(t) + }) + + seed := func(name, pipelineResult, actionURL string) string { + t.Helper() + + id, err := database.InsertReport(ctx, reports.Report{ + Name: name, + Result: pipelineResult, + ID: name, + PipelineID: "venom", + Actions: map[string]*reports.Action{ + "default": {ID: "default", Link: actionURL}, + }, + }) + require.NoError(t, err) + + return id + } + + successWithOpenPR := seed("succeeded, pull request still open", "✔", + "https://example.com/testing/pull/42") + seed("succeeded, nothing to follow up", "✔", "") + attentionWithOpenPR := seed("changed something and opened a pull request", "⚠", + "https://example.com/testing/pull/43") + + // reportNames returns the name of every report of a search response, which + // identifies the seeded reports more readably than their database id. + reportNames := func(resp *http.Response) []string { + t.Helper() + + blob := struct { + Data []struct { + Name string + } + TotalCount int `json:"total_count"` + }{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&blob)) + defer resp.Body.Close() + + names := make([]string, 0, len(blob.Data)) + for _, report := range blob.Data { + names = append(names, report.Name) + } + + // A filter dropping reports from the page while still counting them in the + // total breaks pagination, so the two are checked against each other. + assert.Equal(t, len(names), blob.TotalCount) + sort.Strings(names) + + return names + } + + t.Run("POST /api/pipeline/reports/search", func(t *testing.T) { + t.Run("without the filter", func(t *testing.T) { + resp := doPostRequest(t, srv, "/api/pipeline/reports/search", map[string]any{}) + + assert.Equal(t, []string{ + "changed something and opened a pull request", + "succeeded, nothing to follow up", + "succeeded, pull request still open", + }, reportNames(resp)) + }) + + t.Run("with an open action", func(t *testing.T) { + resp := doPostRequest(t, srv, "/api/pipeline/reports/search", map[string]any{ + "open_action": true, + }) + + assert.Equal(t, []string{ + "changed something and opened a pull request", + "succeeded, pull request still open", + }, reportNames(resp)) + }) + + t.Run("without any open action", func(t *testing.T) { + resp := doPostRequest(t, srv, "/api/pipeline/reports/search", map[string]any{ + "open_action": false, + }) + + assert.Equal(t, []string{"succeeded, nothing to follow up"}, reportNames(resp)) + }) + + t.Run("combined with a result", func(t *testing.T) { + // This is the combination the whole dimension exists for: the pipelines + // which succeeded only because their change is already waiting in a pull + // request nobody merged. + resp := doPostRequest(t, srv, "/api/pipeline/reports/search", map[string]any{ + "results": []string{"✔"}, + "open_action": true, + }) + + assert.Equal(t, []string{"succeeded, pull request still open"}, reportNames(resp)) + }) + }) + + t.Run("POST /api/pipeline/reports/summary", func(t *testing.T) { + summaryOf := func(body map[string]any) (results, openActions map[string]any, totalCount float64) { + t.Helper() + + blob := map[string]any{} + resp := doPostRequest(t, srv, "/api/pipeline/reports/summary", body) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&blob)) + defer resp.Body.Close() + + data := blob["data"].([]any) + today := data[len(data)-1].(map[string]any) + + return today["results"].(map[string]any), + today["open_actions"].(map[string]any), + blob["total_count"].(float64) + } + + t.Run("reports the open actions as a breakdown of the results", func(t *testing.T) { + // Nothing is filtered out here: the breakdown is what lets a dashboard + // split the success bucket without having to run a second query. + results, openActions, totalCount := summaryOf(map[string]any{"days": 1}) + + assert.Equal(t, float64(3), totalCount) + assert.Equal(t, float64(2), results["✔"]) + assert.Equal(t, float64(1), results["⚠"]) + assert.Equal(t, float64(1), openActions["✔"]) + assert.Equal(t, float64(1), openActions["⚠"]) + assert.Equal(t, float64(0), openActions["✗"]) + }) + + t.Run("filtered on an open action", func(t *testing.T) { + results, openActions, totalCount := summaryOf(map[string]any{ + "days": 1, + "open_action": true, + }) + + assert.Equal(t, float64(2), totalCount) + assert.Equal(t, float64(1), results["✔"]) + assert.Equal(t, float64(1), openActions["✔"]) + }) + + t.Run("filtered on the absence of an open action", func(t *testing.T) { + results, openActions, totalCount := summaryOf(map[string]any{ + "days": 1, + "open_action": false, + }) + + assert.Equal(t, float64(1), totalCount) + assert.Equal(t, float64(1), results["✔"]) + assert.Equal(t, float64(0), openActions["✔"]) + }) + }) + + t.Run("POST /api/pipeline/scms/search", func(t *testing.T) { + scmID, err := database.InsertSCM(ctx, "https://example.com/openaction.git", "main") + require.NoError(t, err) + t.Cleanup(func() { + deleteSCM(t, scmID) + }) + + attachReportToSCM(t, successWithOpenPR, scmID) + attachReportToSCM(t, attentionWithOpenPR, scmID) + + branchOf := func(body map[string]any) map[string]any { + t.Helper() + + blob := map[string]any{} + resp := doPostRequest(t, srv, "/api/pipeline/scms/search", body) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&blob)) + defer resp.Body.Close() + + data := blob["data"].(map[string]any) + repository := data["https://example.com/openaction.git"].(map[string]any) + + return repository["main"].(map[string]any) + } + + t.Run("breaks the open actions down per result", func(t *testing.T) { + branch := branchOf(map[string]any{"summary": true, "scmid": scmID}) + + assert.Equal(t, map[string]any{"✔": float64(1), "⚠": float64(1)}, + branch["total_result_by_type"]) + assert.Equal(t, map[string]any{"✔": float64(1), "⚠": float64(1)}, + branch["total_open_action_by_result"]) + // Two pipelines, but each on a pull request of its own. + assert.Equal(t, float64(2), branch["total_action_urls"]) + }) + + t.Run("filtered on a result and an open action", func(t *testing.T) { + branch := branchOf(map[string]any{ + "summary": true, + "scmid": scmID, + "results": []string{"✔"}, + "open_action": true, + }) + + assert.Equal(t, map[string]any{"✔": float64(1)}, branch["total_result_by_type"]) + assert.Equal(t, map[string]any{"✔": float64(1)}, branch["total_open_action_by_result"]) + }) + + t.Run("filtered on the absence of an open action", func(t *testing.T) { + // Both reports attached to this scm carry one, so the summary keeps the + // branch but empties its counts. + branch := branchOf(map[string]any{ + "summary": true, + "scmid": scmID, + "open_action": false, + }) + + assert.Equal(t, map[string]any{}, branch["total_result_by_type"]) + assert.Equal(t, map[string]any{}, branch["total_open_action_by_result"]) + }) + }) + }) } // hourStart returns the beginning of the UTC hour of the provided time. diff --git a/pkg/server/report_handlers.go b/pkg/server/report_handlers.go index 18355b81..f0ab216e 100644 --- a/pkg/server/report_handlers.go +++ b/pkg/server/report_handlers.go @@ -135,6 +135,15 @@ func SearchPipelineReports(c *gin.Context) { // "✔", "✗", "⚠" or "-". A report matches when its result is any of them. // This is optional and an empty list does not filter anything out. Results []string `json:"results,omitempty"` + // OpenAction filters reports by whether they carry an action left open, such as a + // pull request still waiting to be merged. This is optional: unset does not filter + // anything out, true only keeps the reports with an open action and false only the + // ones without. + // + // Combined with results it isolates the pipelines which succeeded because their + // change is already waiting in a pull request, which a result alone cannot express: + // {"results": ["✔"], "open_action": true}. + OpenAction *bool `json:"open_action,omitempty"` } queryParams := queryData{} @@ -162,6 +171,7 @@ func SearchPipelineReports(c *gin.Context) { Latest: queryParams.Latest, Labels: queryParams.Labels, Results: queryParams.Results, + OpenAction: queryParams.OpenAction, }, ) if err != nil { @@ -202,6 +212,14 @@ type SearchPipelineReportsSummaryRequest struct { // "✔", "✗", "⚠" or "-". A report is counted when its result is any of them. // An empty list does not filter anything out. Results []string `json:"results,omitempty"` + // OpenAction filters reports by whether they carry an action left open, such as a + // pull request still waiting to be merged. This is optional: unset does not filter + // anything out, true only counts the reports with an open action and false only the + // ones without. + // + // The same breakdown is reported without filtering anything out under the open_actions + // key of every bucket. + OpenAction *bool `json:"open_action,omitempty"` // StartTime is the start time for the time range filter. // Time format is: 2006-01-02 15:04:05Z07:00 StartTime string `json:"start_time,omitempty"` @@ -323,6 +341,7 @@ func SearchPipelineReportsSummary(c *gin.Context) { ScmID: queryParams.ScmID, Labels: queryParams.Labels, Results: queryParams.Results, + OpenAction: queryParams.OpenAction, StartTime: queryParams.StartTime, EndTime: queryParams.EndTime, }, diff --git a/pkg/server/scmdb_handlers.go b/pkg/server/scmdb_handlers.go index 9dedf57a..1854482b 100644 --- a/pkg/server/scmdb_handlers.go +++ b/pkg/server/scmdb_handlers.go @@ -33,6 +33,11 @@ type SearchSCMsRequest struct { // Results filters SCM summaries by pipeline result, such as "✔", "✗", "⚠" or // "-". An empty list does not filter anything out. Results []string `json:"results,omitempty"` + // OpenAction filters SCM summaries by whether a pipeline carries an action left open, + // such as a pull request still waiting to be merged. This is optional: unset does not + // filter anything out, true only keeps the pipelines with an open action and false only + // the ones without. + OpenAction *bool `json:"open_action,omitempty"` // URL is the SCM URL to filter by. URL string `json:"url,omitempty"` // Branch is the SCM branch to filter by. @@ -78,15 +83,15 @@ func SearchSCMs(c *gin.Context) { } if queryParams.Summary { - findSCMSummary( - c, - rows, - totalCount, - queryParams.StartTime, - queryParams.EndTime, - queryParams.Labels, - queryParams.Results, - ) + findSCMSummary(c, findSCMSummaryParams{ + ScmRows: rows, + TotalCount: totalCount, + StartTime: queryParams.StartTime, + EndTime: queryParams.EndTime, + Labels: queryParams.Labels, + Results: queryParams.Results, + OpenAction: queryParams.OpenAction, + }) return } @@ -158,7 +163,13 @@ func ListSCMs(c *gin.Context) { } if summary { - findSCMSummary(c, rows, totalCount, queryValues.Get("start_time"), queryValues.Get("end_time"), map[string]string{}, nil) + findSCMSummary(c, findSCMSummaryParams{ + ScmRows: rows, + TotalCount: totalCount, + StartTime: queryValues.Get("start_time"), + EndTime: queryValues.Get("end_time"), + Labels: map[string]string{}, + }) return } @@ -185,19 +196,39 @@ type FindSCMSummaryResponse struct { Data map[string]database.SCMBranchDataset `json:"data"` } +// findSCMSummaryParams contains the filters applied to a git repositories summary. +type findSCMSummaryParams struct { + // ScmRows are the SCMs to summarize. + ScmRows []model.SCM + // TotalCount is the number of SCMs matching the search, before pagination. + TotalCount int + // StartTime and EndTime define the time range the reports are summarized over. + StartTime string + EndTime string + // Labels restricts the summary to the reports matching those labels. + Labels map[string]string + // Results restricts the summary to the pipelines whose result is one of them. An empty + // list does not filter anything out. + Results []string + // OpenAction restricts the summary to the pipelines which carry an open action, or to + // the ones which do not. A nil value does not filter anything out. + OpenAction *bool +} + // findSCMSummary returns a summary of all git repositories detected. -func findSCMSummary(c *gin.Context, scmRows []model.SCM, totalCount int, startTime, endTime string, labels map[string]string, results []string) { +func findSCMSummary(c *gin.Context, params findSCMSummaryParams) { var data map[string]database.SCMBranchDataset dataset, err := database.GetSCMSummary(database.GetSCMSummaryParams{ Ctx: c, - ScmRows: scmRows, - TotalCount: totalCount, + ScmRows: params.ScmRows, + TotalCount: params.TotalCount, MonitoringDurationDays: monitoringDurationDays, - StartTime: startTime, - EndTime: endTime, - Labels: labels, - Results: results, + StartTime: params.StartTime, + EndTime: params.EndTime, + Labels: params.Labels, + Results: params.Results, + OpenAction: params.OpenAction, }) if err != nil { logrus.Errorf("getting scm summary failed: %s", err) @@ -213,6 +244,6 @@ func findSCMSummary(c *gin.Context, scmRows []model.SCM, totalCount int, startTi c.JSON(http.StatusOK, FindSCMSummaryResponse{ Data: data, - TotalCount: totalCount, + TotalCount: params.TotalCount, }) } From c087148464acb6d9d32ae785dcbe17e6d1d36308 Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Tue, 11 Aug 2026 17:38:13 +0200 Subject: [PATCH 4/9] feat: another round of improvements * Set pagination limit * Use middleware for publicReadOnly endpoint * Zitadel requires a valid token and a configured role * Accept bare url for auth zero issuer URL * Validate timerange param * Allow to filter getscm query based date filter * Correctly close pg connection * fix report query qq Signed-off-by: Olivier Vernin --- docs/docs.go | 12 ++ docs/swagger.json | 12 ++ docs/swagger.yaml | 8 + pkg/database/config.go | 57 +++--- pkg/database/database_test.go | 47 +++++ pkg/database/label.go | 14 +- pkg/database/pagination_utils.go | 32 ++++ pkg/database/report.go | 135 +++++++------- pkg/database/scm.go | 292 +++++++++++++++++-------------- pkg/server/configdb_handlers.go | 12 +- pkg/server/endpoints.go | 91 +++++----- pkg/server/endpoints_test.go | 186 ++++++++++++++++++++ pkg/server/jwt.go | 52 +++++- pkg/server/jwt_test.go | 81 +++++++++ pkg/server/labeldb_handlers.go | 2 +- pkg/server/report_handlers.go | 42 +++-- pkg/server/scmdb_handlers.go | 36 +++- pkg/server/utilsHandlers.go | 42 +++-- pkg/server/var.go | 4 + 19 files changed, 836 insertions(+), 321 deletions(-) create mode 100644 pkg/database/pagination_utils.go create mode 100644 pkg/server/jwt_test.go diff --git a/docs/docs.go b/docs/docs.go index 5493305f..f8806b61 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -609,6 +609,12 @@ const docTemplate = `{ "description": "End time for filtering reports (RFC3339 format)", "name": "end_time", "in": "query" + }, + { + "type": "string", + "description": "Only return the latest report per pipeline ID, default is true", + "name": "latest", + "in": "query" } ], "responses": { @@ -618,6 +624,12 @@ const docTemplate = `{ "$ref": "#/definitions/server.GetPipelineReportsResponse" } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, "500": { "description": "Internal Server Error", "schema": { diff --git a/docs/swagger.json b/docs/swagger.json index 81d6d0b3..d4165c76 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -598,6 +598,12 @@ "description": "End time for filtering reports (RFC3339 format)", "name": "end_time", "in": "query" + }, + { + "type": "string", + "description": "Only return the latest report per pipeline ID, default is true", + "name": "latest", + "in": "query" } ], "responses": { @@ -607,6 +613,12 @@ "$ref": "#/definitions/server.GetPipelineReportsResponse" } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, "500": { "description": "Internal Server Error", "schema": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 289e03a8..c63fd7b0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1484,6 +1484,10 @@ paths: in: query name: end_time type: string + - description: Only return the latest report per pipeline ID, default is true + in: query + name: latest + type: string produces: - application/json responses: @@ -1491,6 +1495,10 @@ paths: description: OK schema: $ref: '#/definitions/server.GetPipelineReportsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' "500": description: Internal Server Error schema: diff --git a/pkg/database/config.go b/pkg/database/config.go index c8ee3473..3ebcf82d 100644 --- a/pkg/database/config.go +++ b/pkg/database/config.go @@ -60,7 +60,7 @@ func InsertConfigResource(ctx context.Context, resourceType, resourceKind string } var configID uuid.UUID - err = DB.QueryRow(context.Background(), queryString, args...).Scan( + err = DB.QueryRow(ctx, queryString, args...).Scan( &configID, ) @@ -135,11 +135,12 @@ func GetConfigKind(ctx context.Context, resourceType string) ([]string, error) { return nil, err } - rows, err := DB.Query(context.Background(), queryString, args...) + rows, err := DB.Query(ctx, queryString, args...) if err != nil { logrus.Errorf("query failed: %q\n\t%s", queryString, err) return nil, err } + defer rows.Close() results := []string{} for rows.Next() { @@ -152,6 +153,11 @@ func GetConfigKind(ctx context.Context, resourceType string) ([]string, error) { results = append(results, kind) } + if err := rows.Err(); err != nil { + logrus.Errorf("reading config kinds: %s", err) + return nil, err + } + return results, nil } @@ -202,12 +208,7 @@ func GetSourceConfigs(ctx context.Context, kind, id, config string, limit, page logrus.Errorf("parsing total count result: %s", err) } - if limit < totalCount && limit > 0 { - query.Apply( - sm.Limit(limit), - sm.Offset((page-1)*limit), - ) - } + applyPagination(&query, limit, page) queryString, args, err := query.Build(ctx) if err != nil { @@ -215,12 +216,13 @@ func GetSourceConfigs(ctx context.Context, kind, id, config string, limit, page return nil, 0, err } - rows, err := DB.Query(context.Background(), queryString, args...) + rows, err := DB.Query(ctx, queryString, args...) if err != nil { logrus.Errorf("query failed: %q\n\t%s", queryString, err) return nil, 0, err } + defer rows.Close() results := []model.ConfigSource{} @@ -244,6 +246,11 @@ func GetSourceConfigs(ctx context.Context, kind, id, config string, limit, page results = append(results, r) } + if err := rows.Err(); err != nil { + logrus.Errorf("reading config sources: %s", err) + return nil, 0, err + } + return results, totalCount, nil } @@ -293,13 +300,7 @@ func GetConditionConfigs(ctx context.Context, kind, id, config string, limit, pa logrus.Errorf("parsing total count result: %s", err) } - // Apply pagination if limit and page are set - if limit < totalCount && limit > 0 { - query.Apply( - sm.Limit(limit), - sm.Offset((page-1)*limit), - ) - } + applyPagination(&query, limit, page) queryString, args, err := query.Build(ctx) if err != nil { @@ -307,12 +308,13 @@ func GetConditionConfigs(ctx context.Context, kind, id, config string, limit, pa return nil, 0, err } - rows, err := DB.Query(context.Background(), queryString, args...) + rows, err := DB.Query(ctx, queryString, args...) if err != nil { logrus.Errorf("query failed: %q\n\t%s", queryString, err) return nil, 0, err } + defer rows.Close() results := []model.ConfigCondition{} @@ -339,6 +341,11 @@ func GetConditionConfigs(ctx context.Context, kind, id, config string, limit, pa results = append(results, r) } + if err := rows.Err(); err != nil { + logrus.Errorf("reading config conditions: %s", err) + return nil, 0, err + } + return results, totalCount, nil } @@ -388,13 +395,7 @@ func GetTargetConfigs(ctx context.Context, kind, id, config string, limit, page logrus.Errorf("parsing total count result: %s", err) } - // Apply pagination if limit and page are set - if limit < totalCount && limit > 0 { - query.Apply( - sm.Limit(limit), - sm.Offset((page-1)*limit), - ) - } + applyPagination(&query, limit, page) queryString, args, err := query.Build(ctx) if err != nil { @@ -402,12 +403,13 @@ func GetTargetConfigs(ctx context.Context, kind, id, config string, limit, page return nil, 0, err } - rows, err := DB.Query(context.Background(), queryString, args...) + rows, err := DB.Query(ctx, queryString, args...) if err != nil { logrus.Errorf("query failed: %q\n\t%s", queryString, err) return nil, 0, err } + defer rows.Close() results := []model.ConfigTarget{} @@ -432,5 +434,10 @@ func GetTargetConfigs(ctx context.Context, kind, id, config string, limit, page results = append(results, r) } + if err := rows.Err(); err != nil { + logrus.Errorf("reading config targets: %s", err) + return nil, 0, err + } + return results, totalCount, nil } diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index e6d84885..a326c3b0 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -188,6 +188,53 @@ func TestDatabase(t *testing.T) { } }) + t.Run("republishing a report reuses its scm", func(t *testing.T) { + // The scm of a target used to be looked up by Branch.Target and inserted with + // Branch.Source, so as soon as the two differed the lookup of the next report + // missed the row just written and appended a duplicate. Updatecli pushes its + // changes to a dedicated branch, which is exactly when they differ, so the scms + // table grew by one row per published report. + report := reports.Report{ + Name: "ci: bump Venom version", + Result: result.SUCCESS, + ID: "scm-reuse", + PipelineID: "venom", + Targets: map[string]*result.Target{ + "venom": { + Scm: result.SCM{ + URL: "https://example.com/scm-reuse.git", + Branch: struct { + Source string + Working string + Target string + }{Source: "main", Working: "updatecli_main", Target: "updatecli_bump"}, + }, + }, + }, + } + + for range 3 { + id, err := InsertReport(ctx, report) + require.NoError(t, err) + t.Cleanup(func() { + _, err := DB.Exec(ctx, "DELETE FROM pipelineReports WHERE id = $1", id) + assert.NoError(t, err) + }) + } + + scms, _, err := GetSCM(ctx, GetSCMParams{URL: "https://example.com/scm-reuse.git"}) + require.NoError(t, err) + t.Cleanup(func() { + _, err := DB.Exec(ctx, "DELETE FROM scms WHERE url = $1", "https://example.com/scm-reuse.git") + assert.NoError(t, err) + }) + + require.Len(t, scms, 1) + // The branch stored has to be the one the lookup uses, otherwise the next report + // misses it again. + assert.Equal(t, "updatecli_bump", scms[0].Branch) + }) + t.Run("migration 000011 indexes the open action expression", func(t *testing.T) { // The jsonpath is inlined in openActionSQLExpr so that it matches the index // expression. Binding it as a parameter would still return the right reports while diff --git a/pkg/database/label.go b/pkg/database/label.go index 858923f0..860a17f6 100644 --- a/pkg/database/label.go +++ b/pkg/database/label.go @@ -78,12 +78,7 @@ func GetLabelKeyOnlyRecords(ctx context.Context, startTime, endTime string, limi logrus.Errorf("parsing total count result: %s", err) } - if limit < totalCount && limit > 0 { - query.Apply( - sm.Limit(limit), - sm.Offset((page-1)*limit), - ) - } + applyPagination(&query, limit, page) queryString, args, err := query.Build(ctx) @@ -173,12 +168,7 @@ func GetLabelRecords(ctx context.Context, id, key, value, startTime, endTime str logrus.Errorf("parsing total count result: %s", err) } - if limit < totalCount && limit > 0 { - query.Apply( - sm.Limit(limit), - sm.Offset((page-1)*limit), - ) - } + applyPagination(&query, limit, page) queryString, args, err := query.Build(ctx) diff --git a/pkg/database/pagination_utils.go b/pkg/database/pagination_utils.go new file mode 100644 index 00000000..05f28460 --- /dev/null +++ b/pkg/database/pagination_utils.go @@ -0,0 +1,32 @@ +package database + +import ( + "github.com/stephenafamo/bob" + "github.com/stephenafamo/bob/dialect/psql/dialect" + "github.com/stephenafamo/bob/dialect/psql/sm" +) + +// applyPagination restricts the given query to a single page of results. +// +// Pagination is opt in: a limit lower than one returns every matching row, which is what +// the callers asking for a complete dataset rely on. +// +// Pages are one based. A page lower than one is treated as the first one rather than +// building a negative offset: Postgres rejects those with "OFFSET must not be negative", +// and because that error only surfaces when the rows are read it used to turn into an +// empty dataset reported as a success. A request carrying a limit but no page, which is +// what every JSON search endpoint receives by default, went down exactly that path. +func applyPagination(query *bob.BaseQuery[*dialect.SelectQuery], limit, page int) { + if limit < 1 { + return + } + + if page < 1 { + page = 1 + } + + query.Apply( + sm.Limit(limit), + sm.Offset((page-1)*limit), + ) +} diff --git a/pkg/database/report.go b/pkg/database/report.go index e12e577b..23364bb5 100644 --- a/pkg/database/report.go +++ b/pkg/database/report.go @@ -157,20 +157,25 @@ func SearchLatestReports(params SearchLatestReportsParams) ([]SearchLatestReport return nil, 0, fmt.Errorf("applying updated_at range filter: %w", err) } + // Every applied filter adds a column to the select, so the filters are collected + // here and the rows are scanned against that same list further down. Reading the + // three of them independently would build a query returning more columns than the + // scan expects as soon as two are combined. + resourceFilters := []resourceConfigFilter{} if params.SourceID != "" { - if err := applyResourceConfigFilter(&query, params.SourceID, configSourceType); err != nil { - return nil, 0, err - } + resourceFilters = append(resourceFilters, resourceConfigFilter{ID: params.SourceID, Kind: configSourceType}) } if params.ConditionID != "" { - if err := applyResourceConfigFilter(&query, params.ConditionID, configConditionType); err != nil { - return nil, 0, err - } + resourceFilters = append(resourceFilters, resourceConfigFilter{ID: params.ConditionID, Kind: configConditionType}) } if params.TargetID != "" { - if err := applyResourceConfigFilter(&query, params.TargetID, configTargetType); err != nil { + resourceFilters = append(resourceFilters, resourceConfigFilter{ID: params.TargetID, Kind: configTargetType}) + } + + for _, filter := range resourceFilters { + if err := applyResourceConfigFilter(&query, filter.ID, filter.Kind); err != nil { return nil, 0, err } } @@ -199,13 +204,7 @@ func SearchLatestReports(params SearchLatestReportsParams) ([]SearchLatestReport logrus.Errorf("get reports: %s", err) } - // If limit and page are not set, we do not apply pagination. - if params.Limit < totalCount && params.Limit > 0 { - query.Apply( - sm.Limit(params.Limit), - sm.Offset((params.Page-1)*params.Limit), - ) - } + applyPagination(&query, params.Limit, params.Page) queryString, args, err = query.Build(params.Ctx) if err != nil { @@ -216,47 +215,35 @@ func SearchLatestReports(params SearchLatestReportsParams) ([]SearchLatestReport if err != nil { return nil, 0, fmt.Errorf("query failed: %q\n\t%s", queryString, err) } + defer rows.Close() dataset := []SearchLatestReportData{} for rows.Next() { p := model.PipelineReport{} - filteredResources := pgtype.Hstore{} - - if params.SourceID != "" || params.ConditionID != "" || params.TargetID != "" { - err = rows.Scan( - &p.ReportID, - &p.ID, - &p.PipelineID, - &p.Result, - &p.Pipeline, - &p.Created_at, - &p.Updated_at, - &p.TargetConfigIDs, - &p.ConditionConfigIDs, - &p.SourceConfigIDs, - &filteredResources, - ) - if err != nil { - return nil, 0, fmt.Errorf("parsing result: %s", err) - } + // One extra column per applied resource config filter, in the order they were + // applied to the query. + filteredResources := make([]pgtype.Hstore, len(resourceFilters)) + + scanTargets := []any{ + &p.ReportID, + &p.ID, + &p.PipelineID, + &p.Result, + &p.Pipeline, + &p.Created_at, + &p.Updated_at, + &p.TargetConfigIDs, + &p.ConditionConfigIDs, + &p.SourceConfigIDs, + } - } else { - err = rows.Scan( - &p.ReportID, - &p.ID, - &p.PipelineID, - &p.Result, - &p.Pipeline, - &p.Created_at, - &p.Updated_at, - &p.TargetConfigIDs, - &p.ConditionConfigIDs, - &p.SourceConfigIDs, - ) - if err != nil { - return nil, 0, fmt.Errorf("parsing result: %s", err) - } + for i := range filteredResources { + scanTargets = append(scanTargets, &filteredResources[i]) + } + + if err := rows.Scan(scanTargets...); err != nil { + return nil, 0, fmt.Errorf("parsing result: %s", err) } data := SearchLatestReportData{ @@ -271,30 +258,24 @@ func SearchLatestReports(params SearchLatestReportsParams) ([]SearchLatestReport SourceConfigIDs: p.SourceConfigIDs, } - if params.SourceID != "" { - if _, ok := filteredResources[params.SourceID]; !ok { - return nil, 0, fmt.Errorf("sourceID %s not found in pipeline report", params.SourceID) - } - data.FilteredResourceID = *filteredResources[params.SourceID] - } - - if params.ConditionID != "" { - if _, ok := filteredResources[params.ConditionID]; !ok { - return nil, 0, fmt.Errorf("conditionID %s not found in pipeline report", params.ConditionID) + // When several filters are combined the last one wins, as it did when they were + // read one after the other. + for i, filter := range resourceFilters { + resourceID, ok := filteredResources[i][filter.ID] + if !ok || resourceID == nil { + return nil, 0, fmt.Errorf("%sID %s not found in pipeline report", filter.Kind, filter.ID) } - data.FilteredResourceID = *filteredResources[params.ConditionID] - } - if params.TargetID != "" { - if _, ok := filteredResources[params.TargetID]; !ok { - return nil, 0, fmt.Errorf("targetID %s not found in pipeline report", params.TargetID) - } - data.FilteredResourceID = *filteredResources[params.TargetID] + data.FilteredResourceID = *resourceID } dataset = append(dataset, data) } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("reading results: %s", err) + } + return dataset, totalCount, nil } @@ -717,7 +698,7 @@ func InsertReport(ctx context.Context, report reports.Report) (string, error) { url := target.Scm.URL branch := target.Scm.Branch.Target - ids, _, err := GetSCM(ctx, "", url, branch, 0, 1) + ids, _, err := GetSCM(ctx, GetSCMParams{URL: url, Branch: branch}) if err != nil { logrus.Errorf("query failed: %s", err) return "", err @@ -726,7 +707,12 @@ func InsertReport(ctx context.Context, report reports.Report) (string, error) { switch len(ids) { // If no scm is found, we insert it case 0: - id, err := InsertSCM(ctx, target.Scm.URL, target.Scm.Branch.Source) + // The branch inserted must be the one looked up above. Storing + // Branch.Source instead made the lookup of the next report miss the row + // every time the two differ, which is the normal case when Updatecli + // pushes its changes to a dedicated branch, and appended a duplicate scm + // on every published report. + id, err := InsertSCM(ctx, url, branch) if err != nil { logrus.Errorf("insert scm data: %s", err) continue @@ -995,7 +981,18 @@ func SearchLatestReportByPipelineID(ctx context.Context, id string) (*model.Pipe return &report, nil } +// resourceConfigFilter identifies a resource config a reports search is restricted to. +type resourceConfigFilter struct { + // ID is the config resource id the reports must reference. + ID string + // Kind is one of configSourceType, configConditionType or configTargetType. + Kind string +} + // applyResourceConfigFilters applies resource config filters to the given query. +// +// It also selects the matching hstore column so that the caller can report which resource +// of the pipeline matched, which means every call adds one column to the query. func applyResourceConfigFilter(query *bob.BaseQuery[*dialect.SelectQuery], id, kind string) error { // Ensure resource id is a valid UUID @@ -1086,7 +1083,7 @@ func applyScmFilter(ctx context.Context, query *bob.BaseQuery[*dialect.SelectQue ) default: - scm, _, err := GetSCM(ctx, scmID, "", "", 0, 1) + scm, _, err := GetSCM(ctx, GetSCMParams{ID: scmID}) if err != nil { logrus.Errorf("get scm data: %s", err) return err diff --git a/pkg/database/scm.go b/pkg/database/scm.go index 7384d1b7..1e234fc0 100644 --- a/pkg/database/scm.go +++ b/pkg/database/scm.go @@ -44,31 +44,64 @@ func InsertSCM(ctx context.Context, url, branch string) (string, error) { return id.String(), nil } +// GetSCMParams contains the filters used to look up scms. +type GetSCMParams struct { + // ID restricts the lookup to a specific scm. + ID string + // URL restricts the lookup to the scms of a repository. + URL string + // Branch restricts the lookup to the scms of a branch. + Branch string + // StartTime and EndTime restrict the lookup to the scms a report was published for + // within that range. Both must be provided, an empty range does not filter anything + // out. + StartTime string + EndTime string + // Limit is the maximum number of scms to return, a value lower than one returns + // them all. Page is one based. + Limit int + Page int +} + // GetSCM returns a list of scms from the scm database table. -func GetSCM(ctx context.Context, id, url, branch string, limit, page int) ([]model.SCM, int, error) { +func GetSCM(ctx context.Context, params GetSCMParams) ([]model.SCM, int, error) { query := psql.Select( sm.Columns("id", "branch", "url", "created_at", "updated_at"), sm.From("scms"), ) - if id != "" { + if params.ID != "" { query.Apply( - sm.Where(psql.Quote("id").EQ(psql.Arg(id))), + sm.Where(psql.Quote("id").EQ(psql.Arg(params.ID))), ) } - if url != "" { + if params.URL != "" { query.Apply( - sm.Where(psql.Quote("url").EQ(psql.Arg(url))), + sm.Where(psql.Quote("url").EQ(psql.Arg(params.URL))), ) } - if branch != "" { + if params.Branch != "" { query.Apply( - sm.Where(psql.Quote("branch").EQ(psql.Arg(branch))), + sm.Where(psql.Quote("branch").EQ(psql.Arg(params.Branch))), ) } + // An scm is only interesting for a time range if a report was published for it during + // that range, which is what the trigger of migration 000009 records. Filtering on + // created_at would instead answer when the repository was first seen. + if err := applyRangeFilter( + "last_pipeline_report_at", + dateRangeFilterParams{ + Query: &query, + DateRangeDays: 0, + StartTime: params.StartTime, + EndTime: params.EndTime, + }); err != nil { + return nil, 0, fmt.Errorf("applying last_pipeline_report_at range filter: %w", err) + } + // Get total scm count // Get total count of results totalCount := 0 @@ -85,12 +118,7 @@ func GetSCM(ctx context.Context, id, url, branch string, limit, page int) ([]mod logrus.Errorf("parsing total count result: %s", err) } - if limit < totalCount && limit > 0 { - query.Apply( - sm.Limit(limit), - sm.Offset((page-1)*limit), - ) - } + applyPagination(&query, params.Limit, params.Page) queryString, args, err := query.Build(ctx) @@ -104,6 +132,7 @@ func GetSCM(ctx context.Context, id, url, branch string, limit, page int) ([]mod logrus.Errorf("query failed: %s\n\t%s", queryString, err) return nil, 0, err } + defer rows.Close() results := []model.SCM{} @@ -123,6 +152,10 @@ func GetSCM(ctx context.Context, id, url, branch string, limit, page int) ([]mod results = append(results, r) } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("reading scms: %w", err) + } + return results, totalCount, nil } @@ -180,7 +213,6 @@ func GetSCMSummary(params GetSCMSummaryParams) (*SCMDataset, error) { for _, row := range params.ScmRows { - scmID := row.ID scmURL := row.URL scmBranch := row.Branch @@ -189,138 +221,144 @@ func GetSCMSummary(params GetSCMSummaryParams) (*SCMDataset, error) { continue } - filteredSCMsQuery := psql.Select( - sm.From("pipelineReports"), - sm.Where( - psql.Raw("target_db_scm_ids && ?", - psql.Arg(fmt.Sprintf("{%s}", scmID)), - ), - ), - sm.Columns("id", "data", "updated_at"), - ) + data, err := getSingleSCMSummary(params, row) + if err != nil { + return nil, err + } - if err := applyRangeFilter( - "updated_at", - dateRangeFilterParams{ - Query: &filteredSCMsQuery, - DateRangeDays: params.MonitoringDurationDays, - StartTime: params.StartTime, - EndTime: params.EndTime, - }); err != nil { - return nil, fmt.Errorf("applying updated_at range filter: %w", err) + if dataset.Data == nil { + dataset.Data = make(map[string]SCMBranchDataset) } - if len(params.Labels) > 0 { - if err := applyLabelFilter(labelFilterParams{ - Ctx: params.Ctx, - Query: &filteredSCMsQuery, - Labels: params.Labels, - StartTime: params.StartTime, - EndTime: params.EndTime, - }); err != nil { - return nil, fmt.Errorf("applying label filter: %w", err) - } + if dataset.Data[scmURL] == nil { + dataset.Data[scmURL] = make(map[string]ScmSummaryData) } - query := psql.Select( - sm.Distinct( - psql.Raw("data ->> 'ID'"), + dataset.Data[scmURL][scmBranch] = data + } + return &dataset, nil +} + +// getSingleSCMSummary summarizes the reports of a single scm. +// +// It is a function of its own so that the rows of an scm are released as soon as it is +// summarized: closing them from the loop of GetSCMSummary would instead hold one pooled +// connection per scm until the whole summary is built. +func getSingleSCMSummary(params GetSCMSummaryParams, row model.SCM) (ScmSummaryData, error) { + + scmID := row.ID + + data := ScmSummaryData{ + ID: scmID.String(), + TotalResultByType: make(map[string]int), + TotalOpenActionByResult: make(map[string]int), + } + + filteredSCMsQuery := psql.Select( + sm.From("pipelineReports"), + sm.Where( + psql.Raw("target_db_scm_ids && ?", + psql.Arg(fmt.Sprintf("{%s}", scmID)), ), - sm.With("filtered_reports").As(filteredSCMsQuery), - // The action URLs are read with the same jsonpath as openActionSQLExpr, so that - // a pipeline counted as carrying an open action here is the one the reports - // search and the reports summary would return too. - sm.Columns("id", "data ->> 'Result'", "jsonb_path_query_array(data, '$.Actions.*.actionUrl')"), - sm.From("filtered_reports"), - sm.OrderBy(psql.Raw("data ->> 'ID'")), - sm.OrderBy(psql.Quote("updated_at")).Desc(), - ) + ), + sm.Columns("id", "data", "updated_at"), + ) - queryString, queryArgs, err := query.Build(params.Ctx) - if err != nil { - return nil, fmt.Errorf("building scm summary query: %w", err) - } + if err := applyRangeFilter( + "updated_at", + dateRangeFilterParams{ + Query: &filteredSCMsQuery, + DateRangeDays: params.MonitoringDurationDays, + StartTime: params.StartTime, + EndTime: params.EndTime, + }); err != nil { + return data, fmt.Errorf("applying updated_at range filter: %w", err) + } - rows, err := DB.Query(params.Ctx, queryString, queryArgs...) - if err != nil { - return nil, fmt.Errorf("querying scm summary: %w", err) + if len(params.Labels) > 0 { + if err := applyLabelFilter(labelFilterParams{ + Ctx: params.Ctx, + Query: &filteredSCMsQuery, + Labels: params.Labels, + StartTime: params.StartTime, + EndTime: params.EndTime, + }); err != nil { + return data, fmt.Errorf("applying label filter: %w", err) } + } - if dataset.Data == nil { - dataset.Data = make(map[string]SCMBranchDataset) + query := psql.Select( + sm.Distinct( + psql.Raw("data ->> 'ID'"), + ), + sm.With("filtered_reports").As(filteredSCMsQuery), + // The action URLs are read with the same jsonpath as openActionSQLExpr, so that + // a pipeline counted as carrying an open action here is the one the reports + // search and the reports summary would return too. + sm.Columns("id", "data ->> 'Result'", "jsonb_path_query_array(data, '$.Actions.*.actionUrl')"), + sm.From("filtered_reports"), + sm.OrderBy(psql.Raw("data ->> 'ID'")), + sm.OrderBy(psql.Quote("updated_at")).Desc(), + ) + + queryString, queryArgs, err := query.Build(params.Ctx) + if err != nil { + return data, fmt.Errorf("building scm summary query: %w", err) + } + + rows, err := DB.Query(params.Ctx, queryString, queryArgs...) + if err != nil { + return data, fmt.Errorf("querying scm summary: %w", err) + } + defer rows.Close() + + isActionURLsFound := make(map[string]bool) + + for rows.Next() { + + id := "" + result := "" + actionUrls := []string{} + + if err := rows.Scan(&id, &result, &actionUrls); err != nil { + return data, fmt.Errorf("scanning scm summary row: %w", err) } - if dataset.Data[scmURL] == nil { - dataset.Data[scmURL] = make(map[string]ScmSummaryData) + hasOpenAction := len(actionUrls) > 0 + + // The results and the open actions are dropped here rather than in the query + // above on purpose. That query keeps the latest report of every pipeline, so + // this summary reports where each pipeline stands now; filtering the reports + // before that would instead keep the latest report which happened to carry one + // of those results, reporting a pipeline as failing long after it + // recovered. + if len(params.Results) > 0 && !slices.Contains(params.Results, result) { + continue } - d := ScmSummaryData{ - ID: scmID.String(), - TotalResultByType: make(map[string]int), - TotalOpenActionByResult: make(map[string]int), + if params.OpenAction != nil && *params.OpenAction != hasOpenAction { + continue } - dataset.Data[scmURL][scmBranch] = d - - isActionURLsFound := make(map[string]bool) - - for rows.Next() { - - id := "" - result := "" - actionUrls := []string{} - - err = rows.Scan(&id, &result, &actionUrls) - if err != nil { - return nil, fmt.Errorf("scanning scm summary row: %w", err) - } - - hasOpenAction := len(actionUrls) > 0 - - // The results and the open actions are dropped here rather than in the query - // above on purpose. That query keeps the latest report of every pipeline, so - // this summary reports where each pipeline stands now; filtering the reports - // before that would instead keep the latest report which happened to carry one - // of those results, reporting a pipeline as failing long after it - // recovered. - if len(params.Results) > 0 && !slices.Contains(params.Results, result) { - continue - } - - if params.OpenAction != nil && *params.OpenAction != hasOpenAction { - continue - } - - resultFound := false - for r := range dataset.Data[scmURL][scmBranch].TotalResultByType { - if r == result { - dataset.Data[scmURL][scmBranch].TotalResultByType[r]++ - resultFound = true - } - } - - if !resultFound { - dataset.Data[scmURL][scmBranch].TotalResultByType[result] = 1 - } - - if hasOpenAction { - dataset.Data[scmURL][scmBranch].TotalOpenActionByResult[result]++ - } - - for i := range actionUrls { - if _, ok := isActionURLsFound[actionUrls[i]]; !ok { - isActionURLsFound[actionUrls[i]] = true - } - } + data.TotalResultByType[result]++ + + if hasOpenAction { + data.TotalOpenActionByResult[result]++ } - scmData := dataset.Data[scmURL][scmBranch] - for r := range scmData.TotalResultByType { - scmData.TotalResult += scmData.TotalResultByType[r] + for i := range actionUrls { + isActionURLsFound[actionUrls[i]] = true } - scmData.TotalActionURLs = len(isActionURLsFound) + } - dataset.Data[scmURL][scmBranch] = scmData + if err := rows.Err(); err != nil { + return data, fmt.Errorf("reading scm summary: %w", err) } - return &dataset, nil + + for r := range data.TotalResultByType { + data.TotalResult += data.TotalResultByType[r] + } + data.TotalActionURLs = len(isActionURLsFound) + + return data, nil } diff --git a/pkg/server/configdb_handlers.go b/pkg/server/configdb_handlers.go index f3a726e0..d22455c2 100644 --- a/pkg/server/configdb_handlers.go +++ b/pkg/server/configdb_handlers.go @@ -59,9 +59,9 @@ func ListConfigSources(c *gin.Context) { limit, page, err := getPaginationParamFromURLQuery(c) if err != nil { - logrus.Errorf("invalid pagination parameters: %s", err) + logrus.Errorf("getting pagination params: %s", err) c.JSON(http.StatusBadRequest, DefaultResponseModel{ - Err: "invalid pagination parameters: " + err.Error(), + Err: ErrInvalidPaginationParams + ": " + err.Error(), }) return } @@ -204,9 +204,9 @@ func ListConfigConditions(c *gin.Context) { limit, page, err := getPaginationParamFromURLQuery(c) if err != nil { - logrus.Errorf("invalid pagination parameters: %s", err) + logrus.Errorf("getting pagination params: %s", err) c.JSON(http.StatusBadRequest, DefaultResponseModel{ - Err: "invalid pagination parameters: " + err.Error(), + Err: ErrInvalidPaginationParams + ": " + err.Error(), }) return } @@ -316,9 +316,9 @@ func ListConfigTargets(c *gin.Context) { limit, page, err := getPaginationParamFromURLQuery(c) if err != nil { - logrus.Errorf("invalid pagination parameters: %s", err) + logrus.Errorf("getting pagination params: %s", err) c.JSON(http.StatusBadRequest, DefaultResponseModel{ - Err: "invalid pagination parameters: " + err.Error(), + Err: ErrInvalidPaginationParams + ": " + err.Error(), }) return } diff --git a/pkg/server/endpoints.go b/pkg/server/endpoints.go index 32dbbbdf..0a0e0755 100644 --- a/pkg/server/endpoints.go +++ b/pkg/server/endpoints.go @@ -89,6 +89,38 @@ func (s *Server) Run() error { return r.Run() } +// publicReadOnly returns a middleware leaving the read endpoints open while requiring the +// provided authentication for anything which may change the stored data. +// +// The read methods are the ones listed, and every other one requires authentication. It is +// deliberately written that way around: enumerating the write methods instead left PUT +// unauthenticated, and would leave out any method added later. +func publicReadOnly(auth gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + c.Next() + default: + auth(c) + } + } +} + +// zitadelAuthorization requires a valid token, and the configured role when there is one. +// +// An empty role must not be passed to authorization.WithRole: it checks the token against +// a role which is granted to nobody, so it rejects every request instead of accepting any +// authenticated one. +func zitadelAuthorization[T authorization.Ctx](interceptor *Interceptor[T], role string) gin.HandlerFunc { + if role == "" { + logrus.Debugf("No role required to access the API") + return interceptor.RequireAuthorization() + } + + logrus.Debugf("Requiring role %q to access the API", role) + return interceptor.RequireAuthorization(authorization.WithRole(role)) +} + func newGinEngine(opts Options) *gin.Engine { r := gin.Default() @@ -104,27 +136,21 @@ func newGinEngine(opts Options) *gin.Engine { case "oauth": logrus.Debugf("Using OAuth authentication mode: %s", opts.Auth.Mode) + // Built once: the middleware caches the signing keys of the issuer, so building + // it per request would refetch them on every call. + auth, err := checkJWT() + if err != nil { + slog.Error("jwt middleware could not initialize", "error", err) + os.Exit(1) + } + switch opts.Auth.Visibility { case VisibilityPublic: logrus.Debugf("API visibility set to public, no authentication required for read endpoints") - - apiPipeline.Use(func(c *gin.Context) { - switch c.Request.Method { - case http.MethodPost, http.MethodPatch, http.MethodDelete: - auth := checkJWT() - auth(c) - // If the auth middleware aborted the request, stop processing. - if c.IsAborted() { - return - } - return - default: - c.Next() - } - }) + apiPipeline.Use(publicReadOnly(auth)) case VisibilityPrivate: logrus.Debugf("API visibility set to private, authentication required for all endpoints") - apiPipeline.Use(checkJWT()) + apiPipeline.Use(auth) } case "zitadel": @@ -138,42 +164,15 @@ func newGinEngine(opts Options) *gin.Engine { } zitadelInterceptor := NewZitadelGin(authZ) + auth := zitadelAuthorization(zitadelInterceptor, opts.Auth.Zitadel.Role) switch opts.Auth.Visibility { case VisibilityPublic: logrus.Debugf("API visibility set to public, no authentication required for read endpoints") - apiPipeline.Use(func(c *gin.Context) { - switch c.Request.Method { - case http.MethodPost, http.MethodPatch, http.MethodDelete: - var auth gin.HandlerFunc - - switch opts.Auth.Zitadel.Role { - case "": - logrus.Debugf("Requiring role %q to access the API", opts.Auth.Zitadel.Role) - auth = zitadelInterceptor.RequireAuthorization( - authorization.WithRole(opts.Auth.Zitadel.Role)) - default: - auth = zitadelInterceptor.RequireAuthorization() - } - auth(c) - // If the auth middleware aborted the request, stop processing. - if c.IsAborted() { - return - } - return - default: - c.Next() - } - }) + apiPipeline.Use(publicReadOnly(auth)) case VisibilityPrivate: logrus.Debugf("API visibility set to private, authentication required for all endpoints") - switch opts.Auth.Zitadel.Role { - case "": - logrus.Debugf("Requiring role %q to access the API", opts.Auth.Zitadel.Role) - apiPipeline.Use(zitadelInterceptor.RequireAuthorization(authorization.WithRole(opts.Auth.Zitadel.Role))) - default: - apiPipeline.Use(zitadelInterceptor.RequireAuthorization(authorization.WithRole(opts.Auth.Zitadel.Role))) - } + apiPipeline.Use(auth) } } diff --git a/pkg/server/endpoints_test.go b/pkg/server/endpoints_test.go index 949060f6..e618411c 100644 --- a/pkg/server/endpoints_test.go +++ b/pkg/server/endpoints_test.go @@ -9,12 +9,14 @@ import ( "maps" "net/http" "net/http/httptest" + "net/url" "sort" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/stephenafamo/bob/dialect/psql" "github.com/stephenafamo/bob/dialect/psql/dm" "github.com/stretchr/testify/assert" @@ -22,6 +24,7 @@ import ( "github.com/updatecli/udash/pkg/database" "github.com/updatecli/udash/test" "github.com/updatecli/updatecli/pkg/core/reports" + "github.com/updatecli/updatecli/pkg/core/result" ) func TestEndpoints(t *testing.T) { @@ -1089,6 +1092,189 @@ func TestEndpoints(t *testing.T) { }) }) }) + + t.Run("pagination", func(t *testing.T) { + truncateReports(t) + + for range 3 { + id, err := database.InsertReport(ctx, reports.Report{ + Name: "paginated", Result: "✔", ID: "paginated", PipelineID: "paginated", + }) + require.NoError(t, err) + t.Cleanup(func() { + deleteReport(t, id) + }) + } + + reportsOf := func(t *testing.T, body map[string]any) []any { + t.Helper() + + resp := doPostRequest(t, srv, "/api/pipeline/reports/search", body) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + blob := map[string]any{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&blob)) + require.Equal(t, float64(3), blob["total_count"]) + + return blob["data"].([]any) + } + + t.Run("a limit without a page returns the first page", func(t *testing.T) { + // Pages are one based, so an unset page used to build "OFFSET -1". Postgres + // rejects it, and because that error only surfaces when the rows are read it + // was reported as an empty, successful result. + assert.Len(t, reportsOf(t, map[string]any{"limit": 1}), 1) + }) + + t.Run("an explicit page is honoured", func(t *testing.T) { + assert.Len(t, reportsOf(t, map[string]any{"limit": 2, "page": 1}), 2) + assert.Len(t, reportsOf(t, map[string]any{"limit": 2, "page": 2}), 1) + }) + + t.Run("a page past the end returns nothing", func(t *testing.T) { + // The limit used to be ignored whenever it reached the total count, which + // answered any page with the whole dataset. + assert.Empty(t, reportsOf(t, map[string]any{"limit": 3, "page": 2})) + }) + + t.Run("an invalid limit is reported once", func(t *testing.T) { + // The helper reporting the mistake used to answer the request itself and + // still hand the caller a nil error, appending a second body to the 400. + for _, query := range []string{"limit=abc", "limit=2000", "page=xyz"} { + resp := doGetRequest(t, srv, "/api/pipeline/reports?"+query) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, query) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + trailing := json.NewDecoder(bytes.NewReader(body)) + blob := map[string]any{} + require.NoError(t, trailing.Decode(&blob), query) + assert.Contains(t, blob[errMessageType], ErrInvalidPaginationParams) + assert.False(t, trailing.More(), "%s: more than one body was written: %s", query, body) + } + }) + }) + + t.Run("POST /api/pipeline/reports/search combining resource filters", func(t *testing.T) { + truncateReports(t) + + reportID, err := database.InsertReport(ctx, reports.Report{ + Name: "combined", Result: "✔", ID: "combined", PipelineID: "combined", + Sources: map[string]*result.Source{ + "src": {Config: map[string]any{"Kind": "shell", "Spec": map[string]any{"command": "echo"}}}, + }, + Targets: map[string]*result.Target{ + "tgt": {Config: map[string]any{"Kind": "file", "Spec": map[string]any{"file": "combined.txt"}}}, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { + deleteReport(t, reportID) + }) + + sourceIDs, targetIDs := pgtype.Hstore{}, pgtype.Hstore{} + require.NoError(t, database.DB.QueryRow(ctx, + "SELECT config_source_ids, config_target_ids FROM pipelineReports WHERE id = $1", reportID, + ).Scan(&sourceIDs, &targetIDs)) + + firstKeyOf := func(h pgtype.Hstore) string { + for key := range h { + return key + } + return "" + } + + sourceID, targetID := firstKeyOf(sourceIDs), firstKeyOf(targetIDs) + require.NotEmpty(t, sourceID) + require.NotEmpty(t, targetID) + + // Each filter adds a column to the select, so combining two of them used to + // build a query returning more columns than the scan expected. + resp := doPostRequest(t, srv, "/api/pipeline/reports/search", map[string]any{ + "sourceid": sourceID, + "targetid": targetID, + }) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + blob := map[string]any{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&blob)) + + data := blob["data"].([]any) + require.Len(t, data, 1) + // The last applied filter names the matched resource, as it did when a single + // one could be applied. + assert.Equal(t, "tgt", data[0].(map[string]any)["FilteredResourceID"]) + }) + + t.Run("GET /api/pipeline/reports with an invalid latest", func(t *testing.T) { + // An unparsable value used to be warned about and then read as false, which is + // the opposite of the documented default. + resp := doGetRequest(t, srv, "/api/pipeline/reports?latest=notabool") + assertErrorResponse(t, resp, http.StatusBadRequest, ErrInvalidLatestParam) + }) + + t.Run("GET /api/pipeline/scms with a time range", func(t *testing.T) { + truncateReports(t) + + scmID, err := database.InsertSCM(ctx, "https://example.com/timerange.git", "main") + require.NoError(t, err) + t.Cleanup(func() { + deleteSCM(t, scmID) + }) + + reportID, err := database.InsertReport(ctx, reports.Report{ + Name: "timerange", Result: "✔", ID: "timerange", PipelineID: "timerange", + }) + require.NoError(t, err) + t.Cleanup(func() { + deleteReport(t, reportID) + }) + + // The scms are dated by the reports published for them, which the trigger of + // migration 000009 records on insert. + _, err = database.DB.Exec(ctx, + "UPDATE scms SET last_pipeline_report_at = $1 WHERE id = $2", + time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC), scmID) + require.NoError(t, err) + + scmsIn := func(t *testing.T, start, end time.Time) []any { + t.Helper() + + resp := doGetRequest(t, srv, fmt.Sprintf("/api/pipeline/scms?start_time=%s&end_time=%s", + url.QueryEscape(start.Format(timeRangeLayout)), + url.QueryEscape(end.Format(timeRangeLayout)))) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + blob := map[string]any{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&blob)) + + return blob["scms"].([]any) + } + + t.Run("keeps the scms reported within the range", func(t *testing.T) { + assert.Len(t, scmsIn(t, + time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), + time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)), 1) + }) + + t.Run("drops the scms reported outside of it", func(t *testing.T) { + // The range used to be accepted and then ignored, returning every scm. + assert.Empty(t, scmsIn(t, + time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC))) + }) + + t.Run("rejects a half open range", func(t *testing.T) { + resp := doGetRequest(t, srv, "/api/pipeline/scms?start_time=2026-03-01+00:00:00Z") + assertErrorResponse(t, resp, http.StatusBadRequest, ErrInvalidTimeRangeParams) + }) + }) } // hourStart returns the beginning of the UTC hour of the provided time. diff --git a/pkg/server/jwt.go b/pkg/server/jwt.go index 77cb7d73..fb90550c 100644 --- a/pkg/server/jwt.go +++ b/pkg/server/jwt.go @@ -1,8 +1,11 @@ package server import ( + "errors" + "fmt" "net/http" "net/url" + "strings" "time" jwtmiddleware "github.com/auth0/go-jwt-middleware/v2" @@ -23,13 +26,48 @@ var ( authOption = AuthOptions{} ) -// checkJWT is a gin.HandlerFunc middleware -// that will check the validity of our JWT. -func checkJWT() gin.HandlerFunc { +// parseIssuerURL turns a configured issuer into a URL, accepting it either as a bare host +// ("example.eu.auth0.com") or as a full URL ("https://example.eu.auth0.com"). https is +// assumed when no scheme is given. +// +// Beyond that the value is used verbatim, in particular its trailing slash. The validator +// compares the token's `iss` claim against this string exactly, and providers disagree on +// the trailing slash — Auth0 issues one, Zitadel and Keycloak do not — so rewriting it +// would reject otherwise valid tokens. +func parseIssuerURL(issuer string) (*url.URL, error) { + if issuer == "" { + return nil, errors.New("no issuer configured") + } + + if !strings.Contains(issuer, "://") { + issuer = "https://" + issuer + } + + issuerURL, err := url.Parse(issuer) + if err != nil { + return nil, err + } - issuerURL, err := url.Parse("https://" + authOption.Oauth.Issuer + "/") + if issuerURL.Host == "" { + return nil, fmt.Errorf("issuer %q has no host", issuer) + } + + return issuerURL, nil +} + +// checkJWT builds a gin.HandlerFunc middleware that will check the validity of our JWT. +// +// It must be called once, when the routes are set up, and the returned middleware reused +// for every request: the JWKS provider it builds caches the signing keys of the issuer, +// and rebuilding it per request means fetching them again on every single call. +// +// A setup failure is reported rather than logged: carrying on would leave a nil validator +// behind, which panics on the first request it is asked to authenticate. +func checkJWT() (gin.HandlerFunc, error) { + + issuerURL, err := parseIssuerURL(authOption.Oauth.Issuer) if err != nil { - logrus.Errorf("Failed to parse the issuer url: %v", err) + return nil, fmt.Errorf("parsing the issuer url: %w", err) } provider := jwks.NewCachingProvider(issuerURL, 5*time.Minute) @@ -44,7 +82,7 @@ func checkJWT() gin.HandlerFunc { ) if err != nil { - logrus.Errorf("failed to set up the validator: %v", err) + return nil, fmt.Errorf("setting up the validator: %w", err) } errorHandler := func(w http.ResponseWriter, r *http.Request, err error) { @@ -72,5 +110,5 @@ func checkJWT() gin.HandlerFunc { map[string]string{errMessageType: ErrInvalidJWT}, ) } - } + }, nil } diff --git a/pkg/server/jwt_test.go b/pkg/server/jwt_test.go new file mode 100644 index 00000000..d843bfe8 --- /dev/null +++ b/pkg/server/jwt_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseIssuerURL(t *testing.T) { + tests := []struct { + name string + issuer string + want string + wantErr bool + }{ + { + name: "bare host gets https", + issuer: "example.eu.auth0.com", + want: "https://example.eu.auth0.com", + }, + { + name: "explicit https is kept", + issuer: "https://example.eu.auth0.com", + want: "https://example.eu.auth0.com", + }, + { + // Auth0 publishes an `iss` claim with a trailing slash, and the validator + // compares it byte for byte, so the slash must survive untouched. + name: "trailing slash is preserved", + issuer: "https://example.eu.auth0.com/", + want: "https://example.eu.auth0.com/", + }, + { + // Zitadel and Keycloak publish `iss` without a trailing slash; adding one + // here would reject every token they sign. + name: "no trailing slash is added", + issuer: "https://instance.zitadel.cloud", + want: "https://instance.zitadel.cloud", + }, + { + name: "path component is preserved", + issuer: "https://keycloak.example/realms/udash", + want: "https://keycloak.example/realms/udash", + }, + { + name: "bare host with path gets https", + issuer: "keycloak.example/realms/udash", + want: "https://keycloak.example/realms/udash", + }, + { + name: "http scheme is not rewritten", + issuer: "http://localhost:8080/realms/udash", + want: "http://localhost:8080/realms/udash", + }, + { + name: "empty issuer is rejected", + issuer: "", + wantErr: true, + }, + { + name: "scheme without host is rejected", + issuer: "https://", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseIssuerURL(tt.issuer) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got.String()) + }) + } +} diff --git a/pkg/server/labeldb_handlers.go b/pkg/server/labeldb_handlers.go index 79328239..56e30e25 100644 --- a/pkg/server/labeldb_handlers.go +++ b/pkg/server/labeldb_handlers.go @@ -55,7 +55,7 @@ func ListLabels(c *gin.Context) { if err != nil { logrus.Errorf("getting pagination params: %s", err) c.JSON(http.StatusBadRequest, DefaultResponseModel{ - Err: ErrInvalidPaginationParams, + Err: ErrInvalidPaginationParams + ": " + err.Error(), }) return } diff --git a/pkg/server/report_handlers.go b/pkg/server/report_handlers.go index f0ab216e..34117346 100644 --- a/pkg/server/report_handlers.go +++ b/pkg/server/report_handlers.go @@ -156,6 +156,13 @@ func SearchPipelineReports(c *gin.Context) { return } + if err := validateTimeRangeParams(queryParams.StartTime, queryParams.EndTime); err != nil { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: err.Error(), + }) + return + } + dataset, totalCount, err := database.SearchLatestReports( database.SearchLatestReportsParams{ Ctx: c, @@ -321,11 +328,9 @@ func SearchPipelineReportsSummary(c *gin.Context) { return } - // Catching this here returns a 400 instead of the 500 that the database layer - // would return for the same mistake. - if (queryParams.StartTime == "") != (queryParams.EndTime == "") { + if err := validateTimeRangeParams(queryParams.StartTime, queryParams.EndTime); err != nil { c.JSON(http.StatusBadRequest, DefaultResponseModel{ - Err: ErrInvalidTimeRangeParams, + Err: err.Error(), }) return } @@ -389,9 +394,11 @@ func SearchPipelineReportsSummary(c *gin.Context) { // @Param page query string false "Page number for pagination, default is 1" // @Param start_time query string false "Start time for filtering reports (RFC3339 format)" // @Param end_time query string false "End time for filtering reports (RFC3339 format)" +// @Param latest query string false "Only return the latest report per pipeline ID, default is true" // @Accept json // @Produce json // @Success 200 {object} GetPipelineReportsResponse +// @Failure 400 {object} DefaultResponseModel // @Failure 500 {object} DefaultResponseModel // @Router /api/pipeline/reports [get] func ListPipelineReports(c *gin.Context) { @@ -399,21 +406,34 @@ func ListPipelineReports(c *gin.Context) { scmID := queryParams.Get("scmid") startTime := queryParams.Get("start_time") endTime := queryParams.Get("end_time") - lateststr := queryParams.Get("latest") - if lateststr == "" { - lateststr = "true" + // A value which cannot be parsed is rejected rather than ignored: falling through + // used to leave latest at false, which is the opposite of the documented default. + latest := true + if lateststr := queryParams.Get("latest"); lateststr != "" { + parsedLatest, err := strconv.ParseBool(lateststr) + if err != nil { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: ErrInvalidLatestParam, + }) + return + } + + latest = parsedLatest } - latest, err := strconv.ParseBool(lateststr) - if err != nil { - logrus.Warningf("ignoring latest param due to: %s", err) + + if err := validateTimeRangeParams(startTime, endTime); err != nil { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: err.Error(), + }) + return } limit, page, err := getPaginationParamFromURLQuery(c) if err != nil { logrus.Errorf("getting pagination params: %s", err) c.JSON(http.StatusBadRequest, DefaultResponseModel{ - Err: ErrInvalidPaginationParams, + Err: ErrInvalidPaginationParams + ": " + err.Error(), }) return } diff --git a/pkg/server/scmdb_handlers.go b/pkg/server/scmdb_handlers.go index 1854482b..e117358b 100644 --- a/pkg/server/scmdb_handlers.go +++ b/pkg/server/scmdb_handlers.go @@ -72,6 +72,13 @@ func SearchSCMs(c *gin.Context) { return } + if err := validateTimeRangeParams(queryParams.StartTime, queryParams.EndTime); err != nil { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: err.Error(), + }) + return + } + rows, totalCount, err := getSCMRows(c, queryParams) if err != nil { logrus.Errorf("searching for scms: %s", err) @@ -138,7 +145,17 @@ func ListSCMs(c *gin.Context) { if err != nil { logrus.Errorf("getting pagination params: %s", err) c.JSON(http.StatusBadRequest, DefaultResponseModel{ - Err: ErrInvalidPaginationParams, + Err: ErrInvalidPaginationParams + ": " + err.Error(), + }) + return + } + + startTime := queryValues.Get("start_time") + endTime := queryValues.Get("end_time") + + if err := validateTimeRangeParams(startTime, endTime); err != nil { + c.JSON(http.StatusBadRequest, DefaultResponseModel{ + Err: err.Error(), }) return } @@ -147,8 +164,8 @@ func ListSCMs(c *gin.Context) { ScmID: queryValues.Get("scmid"), URL: queryValues.Get("url"), Branch: queryValues.Get("branch"), - StartTime: queryValues.Get("start_time"), - EndTime: queryValues.Get("end_time"), + StartTime: startTime, + EndTime: endTime, Summary: summary, Limit: limit, Page: page, @@ -187,7 +204,18 @@ func getSCMRows(c *gin.Context, params SearchSCMsRequest) ([]model.SCM, int, err page = 0 } - return database.GetSCM(c, params.ScmID, params.URL, params.Branch, limit, page) + return database.GetSCM(c, database.GetSCMParams{ + ID: params.ScmID, + URL: params.URL, + Branch: params.Branch, + // An scm which saw no report during the requested range has nothing to show for + // it, so the range narrows the listing itself and not only the summary built + // from it. + StartTime: params.StartTime, + EndTime: params.EndTime, + Limit: limit, + Page: page, + }) } // FindSCMSummaryResponse represents the response for the FindSCMSummary endpoint. diff --git a/pkg/server/utilsHandlers.go b/pkg/server/utilsHandlers.go index 2411a303..597a060e 100644 --- a/pkg/server/utilsHandlers.go +++ b/pkg/server/utilsHandlers.go @@ -1,7 +1,8 @@ package server import ( - "net/http" + "errors" + "fmt" "strconv" "github.com/gin-gonic/gin" @@ -9,6 +10,10 @@ import ( // getPaginationParamFromURLQuery sanitizes and retrieves pagination parameters from the request context. // It returns the limit and page values, or an error if the parameters are invalid. +// +// It reports an invalid parameter to its caller rather than answering the request itself: +// writing the response here left the caller believing the request was still its to answer, +// which appended a second body to the one already sent. func getPaginationParamFromURLQuery(c *gin.Context) (int, int, error) { limitStr := c.Request.URL.Query().Get("limit") pageStr := c.Request.URL.Query().Get("page") @@ -20,32 +25,43 @@ func getPaginationParamFromURLQuery(c *gin.Context) (int, int, error) { return strconv.Atoi(s) } - errs := []string{} limit, err := atoi(limitStr) if err != nil { - errs = append(errs, "invalid limit value") + return 0, 0, errors.New("invalid limit value") } page, err := atoi(pageStr) if err != nil { - errs = append(errs, "invalid page value") + return 0, 0, errors.New("invalid page value") } - if limit > 1000 { - errs = append(errs, "limit exceeds maximum of 1000") + if limit < 0 { + return 0, 0, errors.New("limit cannot be negative") } - // Set default page value if not specified + if limit > maxPaginationLimit { + return 0, 0, fmt.Errorf("limit exceeds maximum of %d", maxPaginationLimit) + } + + if page < 0 { + return 0, 0, errors.New("page cannot be negative") + } + + // Pages are one based, so an unspecified page is the first one. if page == 0 { page = 1 } - if len(errs) > 0 { - c.JSON(http.StatusBadRequest, DefaultResponseModel{ - Err: "invalid query parameters: " + errs[0], - }) - return 0, 0, err + return limit, page, nil +} + +// validateTimeRangeParams checks a start_time and end_time pair before it reaches the +// database, which rejects a half open range. Catching it here turns what is a client +// mistake into a 400 rather than a 500. +func validateTimeRangeParams(startTime, endTime string) error { + if (startTime == "") != (endTime == "") { + return errors.New(ErrInvalidTimeRangeParams) } - return limit, page, nil + return nil } diff --git a/pkg/server/var.go b/pkg/server/var.go index 8e0cded9..fc3d6255 100644 --- a/pkg/server/var.go +++ b/pkg/server/var.go @@ -10,6 +10,8 @@ var ( // The time range itself is indexed but the aggregation runs over every matching row, // so a wide window means scanning most of the table. maxMonitoringDurationDays int = 366 + // maxPaginationLimit is the largest number of records a single page may return. + maxPaginationLimit int = 1000 // maxSummaryBuckets is the largest number of buckets a summary may return. // maxMonitoringDurationDays bounds how much of the table a summary scans, this bounds // how large its response gets: an hourly summary of a year is a cheap scan but would @@ -28,6 +30,8 @@ const ( ErrInvalidSummaryParam = "invalid summary parameter" // ErrInvalidKeyOnlyParam is the error message returned when the keyonly parameter is invalid. ErrInvalidKeyOnlyParam = "invalid keyonly parameter" + // ErrInvalidLatestParam is the error message returned when the latest parameter is invalid. + ErrInvalidLatestParam = "invalid latest parameter" // ErrInvalidDaysParam is the error message returned when the days parameter is out of range. ErrInvalidDaysParam = "invalid days parameter" // ErrInvalidTimeRangeParams is the error message returned when only one of the time range boundaries is provided. From d475b3e2e95c3c8827c1d062071153110d78844a Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Tue, 11 Aug 2026 17:50:00 +0200 Subject: [PATCH 5/9] chore: disable goconst linter for test Signed-off-by: Olivier Vernin --- .golangci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.yml b/.golangci.yml index a5aab1e4..0137a12c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -38,6 +38,7 @@ linters: goconst: min-len: 3 min-occurrences: 3 + ignore-tests: true gocritic: disabled-checks: - dupImport From 8c7c7000e6a7198d04fdaf478a43fce01088c3ce Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Tue, 11 Aug 2026 17:50:22 +0200 Subject: [PATCH 6/9] fix: misspell Signed-off-by: Olivier Vernin --- pkg/server/endpoints_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/server/endpoints_test.go b/pkg/server/endpoints_test.go index e618411c..a2375f41 100644 --- a/pkg/server/endpoints_test.go +++ b/pkg/server/endpoints_test.go @@ -1127,7 +1127,7 @@ func TestEndpoints(t *testing.T) { assert.Len(t, reportsOf(t, map[string]any{"limit": 1}), 1) }) - t.Run("an explicit page is honoured", func(t *testing.T) { + t.Run("an explicit page is honored", func(t *testing.T) { assert.Len(t, reportsOf(t, map[string]any{"limit": 2, "page": 1}), 2) assert.Len(t, reportsOf(t, map[string]any{"limit": 2, "page": 2}), 1) }) From c617fed9aaae14518ea060546d4b210a1dd331d8 Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Tue, 11 Aug 2026 17:58:46 +0200 Subject: [PATCH 7/9] doc: update readme Signed-off-by: Olivier Vernin --- README.adoc | 173 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 101 insertions(+), 72 deletions(-) diff --git a/README.adoc b/README.adoc index 27d3dc6f..c0e96012 100644 --- a/README.adoc +++ b/README.adoc @@ -1,5 +1,5 @@ = Udash -Another Updatecli Dashboard +An Updatecli Dashboard IMPORTANT: This project is still experimental, feel free to share feedback @@ -25,10 +25,17 @@ Deploy Udash with the following steps: 1. Make sure you have Docker and Docker Compose installed. 2. Run `docker compose up -d` in the directory `demo`. -3. Configure your browser to access Udash at `http://localhost:8080`. +3. Configure your browser to access Udash at `http://localhost`. Traefik serves the frontend on + port 80 and the API on `http://localhost/api`, its own dashboard is the one on port 8080. 4. Run `updatecli udash login "http://localhost" --experimental` to configure Updatecli to upload reports to Udash. 5. Then you can run any updatecli command (apply/diff) to start publishing reports to Udash +The demo runs with authentication disabled. Because no OAuth flag is passed, `udash login` skips the +authorization flow and simply records the endpoint in the Updatecli configuration file. + +Please be aware that the UI is designed to visualize pipelines per git repository, so without an +`scmid` pipelines will be hard to discover. + INFO: You may have to run `docker compose restart server` if the postgresql database wasn't ready in time to receive connections when the Udash server started. @@ -48,7 +55,8 @@ You can run the following commands to configure Updatecli to use the policies de === Requirements -Udash application requires a postgresql database to store the various pipeline reports, and an oauth provider to handle authorization. +Udash requires a postgresql database to store the various pipeline reports. An oauth provider is +only required when authentication is enabled, which it is not by default. **Postgresql Database** @@ -56,6 +64,7 @@ Udash application requires a postgresql database to store the various pipeline r The oauth provider **must** allow the PKCE flow. +* link:https://zitadel.com[Zitadel] (the reference deployment, and the provider behind the dedicated `zitadel` mode) * Auth0 (tested) * GitHub link:https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#1-request-a-users-github-identity[Not supported yet] * link:https://docs.gitlab.com/ee/api/oauth2.html[GitLab] @@ -75,101 +84,121 @@ Udash must be configured via a configuration file, and some settings can be over **Config File** -The configuration file must be one of ["/etc/udash/config.yaml", "~/.udash/config.yaml","~/config.yaml"] - -``` - server: - auth: - # If mode is unset then authentication is disabled - mode: "oauth" - issuer: "auth0 auth URL" - audience: "udash URL" - database: - # uri defines the postgresql URI used to connect with its database - uri: "postgresql URI" +The configuration file is named `config.yaml` and is looked up, in order, in the working directory, +then `$HOME/.udash/`, then `/etc/udash/`. The first one found wins. A different name can be passed +with `--config`. + +```yaml +server: + auth: + # mode selects the authentication backend. + # Accepted values are "oauth", "zitadel", and "none". + # Unset or "none" disables authentication entirely. + mode: "oauth" + # visibility controls which endpoints require a token. + # "public" (the default) leaves the read endpoints open and requires + # authentication for anything that writes. + # "private" requires authentication everywhere. + visibility: "public" + # oauth settings, used when mode is "oauth" + oauth: + # issuer is compared to the "iss" claim of the token, verbatim. + # A scheme is optional, https is assumed when it is omitted, but the + # trailing slash is significant: Auth0 issues one, Zitadel and Keycloak + # do not. A mismatch rejects every token. + issuer: "https://example.eu.auth0.com/" + # audience is a list, and every entry is accepted. + audience: + - "https://udash.example/api" + # zitadel settings, used when mode is "zitadel" + zitadel: + domain: "xxx.region.zitadel.cloud" + # keyfile is the path to a service account key file + keyfile: "/etc/udash/zitadel-key.json" + # role required to access the API. Empty means any authenticated user. + role: "" +database: + # uri defines the postgresql URI used to connect with its database + uri: "postgres://udash:password@db:5432/udash?sslmode=disable" + # migrationdisabled skips the schema migrations run at startup + migrationdisabled: false ``` **Environment** -* **UDASH_AUTH_MODE**: Enable authentication Accept value ["","none","oauth"] -* **UDASH_AUTH_ISSUER**: Define oauth domain url require `UDASH_AUTH_MODE` set to "oauth" -* **UDASH_AUTH_AUDIENCE**: Define oauth audience require `UDASH_AUTH_MODE` set to "oauth" +Each variable below is only a fallback: it is read when the matching key is absent from the +configuration file, so the file always wins. + +* **UDASH_AUTH_MODE**: Authentication mode. Accepted values are ["", "none", "oauth", "zitadel"] +* **UDASH_AUTH_OAUTH_ISSUER**: Oauth issuer URL, requires `UDASH_AUTH_MODE` set to "oauth" +* **UDASH_AUTH_OAUTH_AUDIENCE**: Oauth audience, requires `UDASH_AUTH_MODE` set to "oauth" +* **UDASH_AUTH_ZITADEL_DOMAIN**: Zitadel domain, requires `UDASH_AUTH_MODE` set to "zitadel" +* **UDASH_AUTH_ZITADEL_FILEKEY**: Path to the Zitadel service account key file, requires `UDASH_AUTH_MODE` set to "zitadel" * **UDASH_DB_URI**: Define the postgresql URI === Udash Frontend ==== Option -Even though the Udash frontend is a client-side javascript application, it expects two configuration files that must exist in the `public` directory. - -** config.js +Even though the Udash frontend is a client-side javascript application, it is configured entirely +at runtime through a single `config.json`, served next to the application at +`/usr/share/nginx/html/config.json`. The page fetches it before loading the bundle, so the same +image serves an open deployment and an authenticated one without a rebuild. -``` -const config = (() => { - return { - "OAUTH_DOMAIN": "updatecli.example.oauth.com", - "OAUTH_CLIENTID": "86FVLxxxxxxxxxxxxxxxxxx", - "OAUTH_AUDIENCE": "http://app.updatecli.io/api" - }; -})(); -``` - -`config.js` is used by the frontend application for the login - -** config.json - -``` +```json { - "OAUTH_DOMAIN": "updatecli.example.oauth.com", + "AUTH_ENABLED": false, + "OAUTH_DOMAIN": "https://your-instance.zitadel.cloud", "OAUTH_CLIENTID": "86FVLxxxxxxxxxxxxxxxxxx", - "OAUTH_AUDIENCE": "http://app.updatecli.io/api" + "OAUTH_SCOPE": "openid profile email offline_access urn:zitadel:iam:org:project:id:PROJECT_ID:aud", + "OAUTH_AUDIENCE": "https://app.updatecli.io/api", + "API_BASE_URL": "/api", + "APP_BASE_PATH": "/", + "MAX_HISTORY_DAYS": 30 } ``` -`config.json` is used by the Updatecli application to retrieve oauth setting when running: - -`updatecli login http://app.updatecli.io` +* **AUTH_ENABLED**: Require authentication. Defaults to `false`. +* **OAUTH_DOMAIN**: The provider issuer URL. +* **OAUTH_CLIENTID**: The client ID of the SPA application. +* **OAUTH_SCOPE**: Requested scopes. Defaults to `openid profile email offline_access`, where + `offline_access` is what enables silent token renewal. Zitadel additionally requires the project + audience scope `urn:zitadel:iam:org:project:id::aud`. +* **OAUTH_AUDIENCE**: Not used by the frontend itself. It is read by Updatecli, see below. +* **API_BASE_URL**: Where the browser reaches the API. Relative (`/api`) for same-host routing, or + an absolute URL when the API lives on its own domain. Defaults to `/api`. +* **APP_BASE_PATH**: Base path of the SPA, for mounting it below a subpath such as `/udash/`. + Defaults to `/`. +* **MAX_HISTORY_DAYS**: How far back the date filter and the activity chart may reach. Defaults to + `30` and is capped at the API's own maximum of `366`. + +`config.json` is also what Updatecli reads to discover the oauth settings when running +`updatecli udash login`. It fetches `/config.json` and takes `OAUTH_DOMAIN`, `OAUTH_CLIENTID`, +and `OAUTH_AUDIENCE` from it, so an authenticated deployment has to publish `OAUTH_AUDIENCE` there +even though the frontend never reads it. The value doubles as the API URL Updatecli stores, so it +should be the API base URL the CLI is expected to publish to. === Updatecli -Updatecli is expected to run as usual from CI environment. - -But it must be authenticated before uploading any reports, by running: - -`updatecli login "https://app.updatecli.io" --experimental` - -Then any apply/diff command will upload pipeline reports - - - -=== Demo - -You can try Udash yourself by running the following steps: - -==== 1. Start Udash - -Udash is composed of a Postgresql database, an Updatecli API, and an Updatecli Frontend. - -Using Docker, you can start all services with the following command: - -`docker compose --file docker-compose.example.yaml up` - -Please keep in mind that no authentication is enabled in this example. +Updatecli is expected to run as usual from CI environment. -Udash is now available on `udash.localhost` and the API on `udash.localhost/api` +But it must know where to publish before uploading any reports, by running: -==== 2. Configure Updatecli +`updatecli udash login "https://app.updatecli.io" --experimental` -Once Udash is running, you can configure Updatecli to upload reports to Udash by running: +Then any apply/diff command will upload pipeline reports, as long as it is also run with +`--experimental`. Without that flag the upload is skipped silently. -`updatecli udash login --api-url http://udash.localhost/api http://udash.localhost --experimental` +Against a deployment with authentication enabled, pass at least one oauth flag so the command runs +the PKCE flow rather than just recording the endpoint: -==== 3. Run Updatecli +`updatecli udash login --oauth-clientId "" "https://app.updatecli.io" --experimental` -You can now run Updatecli as usual, and it will upload the pipeline report to Udash. +The remaining oauth settings are then discovered from `/config.json`. -Please be aware that currently the UI is designed to visualize pipelines per git repository so without a scmid -pipelines will be hard to discover. +`--api-url` sets the API endpoint, defaulting to `/api`. Note that the PKCE flow stores the +oauth audience as the API URL instead, so on an authenticated deployment the audience and the API +base URL have to be the same value. === Links From 62f039fc502492116c7e8406491584ef1bf217e8 Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Tue, 11 Aug 2026 18:00:24 +0200 Subject: [PATCH 8/9] deps: update Updatecli version Signed-off-by: Olivier Vernin --- go.mod | 183 ++++++++++++------------ go.sum | 441 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 303 insertions(+), 321 deletions(-) diff --git a/go.mod b/go.mod index a1154bdc..d62d7a08 100644 --- a/go.mod +++ b/go.mod @@ -19,9 +19,9 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 - github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go v0.43.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 - github.com/updatecli/updatecli v0.118.0 + github.com/updatecli/updatecli v0.120.0 github.com/zitadel/zitadel-go/v3 v3.29.2 ) @@ -38,7 +38,7 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/PuerkitoBio/goquery v1.12.0 // indirect github.com/PuerkitoBio/purell v1.2.1 // indirect @@ -47,26 +47,27 @@ require ( github.com/alecthomas/chroma v0.10.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/apparentlymart/go-textseg/v17 v17.0.1 // indirect github.com/aquasecurity/go-pep440-version v0.0.1 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect - github.com/aws/aws-sdk-go-v2/service/ec2 v1.302.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect - github.com/aws/smithy-go v1.25.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.43.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.31 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.30 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect + github.com/aws/aws-sdk-go-v2/service/ec2 v1.316.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect + github.com/aws/smithy-go v1.27.4 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/beevik/etree v1.6.0 // indirect + github.com/beevik/etree v1.7.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect @@ -78,25 +79,25 @@ require ( github.com/chai2010/gettext-go v1.0.3 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cloudflare/circl v1.6.3 // indirect + github.com/cloudflare/circl v1.6.4 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/containerd/containerd v1.7.32 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect - github.com/containerd/platforms v1.0.0-rc.2 // indirect - github.com/containerd/typeurl/v2 v2.2.3 // indirect + github.com/containerd/platforms v1.0.0-rc.4 // indirect + github.com/containerd/typeurl/v2 v2.3.0 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/docker/cli v29.4.3+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.5 // indirect + github.com/docker/cli v29.6.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/drone/go-scm v1.42.3 // indirect + github.com/drone/go-scm v1.42.13 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect @@ -104,8 +105,8 @@ require ( github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.19.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-chi/chi/v5 v5.3.1 // indirect @@ -113,27 +114,27 @@ require ( github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/go-git/go-git/v5 v5.19.1 // indirect + github.com/go-git/go-git/v5 v5.19.2 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect - github.com/go-openapi/jsonreference v0.21.5 // indirect - github.com/go-openapi/spec v0.22.4 // indirect - github.com/go-openapi/swag v0.25.5 // indirect - github.com/go-openapi/swag/cmdutils v0.25.5 // indirect - github.com/go-openapi/swag/conv v0.25.5 // indirect - github.com/go-openapi/swag/fileutils v0.25.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/go-openapi/swag/jsonutils v0.25.5 // indirect - github.com/go-openapi/swag/loading v0.25.5 // indirect - github.com/go-openapi/swag/mangling v0.25.5 // indirect - github.com/go-openapi/swag/netutils v0.25.5 // indirect - github.com/go-openapi/swag/stringutils v0.25.5 // indirect - github.com/go-openapi/swag/typeutils v0.25.5 // indirect - github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/spec v0.22.6 // indirect + github.com/go-openapi/swag v0.26.1 // indirect + github.com/go-openapi/swag/cmdutils v0.26.1 // indirect + github.com/go-openapi/swag/conv v0.27.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.26.1 // indirect + github.com/go-openapi/swag/loading v0.26.1 // indirect + github.com/go-openapi/swag/mangling v0.26.1 // indirect + github.com/go-openapi/swag/netutils v0.26.1 // indirect + github.com/go-openapi/swag/stringutils v0.26.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect @@ -142,19 +143,17 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/go-containerregistry v0.21.6 // indirect + github.com/google/go-containerregistry v0.21.8 // indirect github.com/google/go-github/v69 v69.2.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/goware/urlx v0.3.2 // indirect - github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -165,7 +164,7 @@ require ( github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/hashicorp/hcl/v2 v2.24.0 // indirect github.com/hashicorp/terraform-config-inspect v0.0.0-20230614215431-f32df32a01cd // indirect - github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-registry-address v0.5.0 // indirect github.com/hashicorp/terraform-svchost v0.2.1 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect @@ -175,11 +174,11 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/jferrl/go-githubauth v1.6.0 // indirect + github.com/jferrl/go-githubauth v1.7.0 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.19.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect @@ -188,8 +187,8 @@ require ( github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0 // indirect github.com/minamijoyo/hcledit v0.2.18 // indirect @@ -197,14 +196,14 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/moby/buildkit v0.30.0 // indirect + github.com/moby/buildkit v0.32.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/go-archive v0.2.0 // indirect - github.com/moby/moby/api v1.54.2 // indirect - github.com/moby/moby/client v0.4.1 // indirect + github.com/moby/go-archive v0.2.1 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.1 // indirect github.com/moby/patternmatcher v0.6.1 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -213,13 +212,11 @@ require ( github.com/muhlemmer/gu v0.3.1 // indirect github.com/muhlemmer/httpforwarded v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/ginkgo/v2 v2.28.0 // indirect - github.com/onsi/gomega v1.39.1 // indirect github.com/opencontainers/go-digest v1.0.1-0.20231025023718-d50d2fec9c98 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -237,7 +234,7 @@ require ( github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sergi/go-diff v1.4.0 // indirect - github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect @@ -251,70 +248,70 @@ require ( github.com/tklauser/numcpus v0.11.0 // indirect github.com/tomwright/dasel v1.27.3 // indirect github.com/tomwright/dasel/v2 v2.8.1 // indirect + github.com/tomwright/dasel/v3 v3.11.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xlab/treeprint v1.2.0 // indirect - github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/goldmark v1.8.5 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - github.com/zclconf/go-cty v1.18.1 // indirect + github.com/zclconf/go-cty v1.19.0 // indirect github.com/zitadel/logging v0.7.0 // indirect github.com/zitadel/oidc/v3 v3.47.8 // indirect github.com/zitadel/schema v1.3.2 // indirect gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/arch v0.25.0 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/term v0.44.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect - google.golang.org/grpc v1.82.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260720171339-e059f2f05d78 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260720171339-e059f2f05d78 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/ini.v1 v1.67.2 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - helm.sh/helm/v3 v3.21.0 // indirect - k8s.io/api v0.35.2 // indirect - k8s.io/apiextensions-apiserver v0.35.2 // indirect - k8s.io/apimachinery v0.35.2 // indirect - k8s.io/apiserver v0.35.2 // indirect - k8s.io/cli-runtime v0.35.2 // indirect - k8s.io/client-go v0.35.2 // indirect - k8s.io/component-base v0.35.2 // indirect + helm.sh/helm/v3 v3.21.3 // indirect + k8s.io/api v0.36.2 // indirect + k8s.io/apiextensions-apiserver v0.36.2 // indirect + k8s.io/apimachinery v0.36.2 // indirect + k8s.io/apiserver v0.36.2 // indirect + k8s.io/cli-runtime v0.36.2 // indirect + k8s.io/client-go v0.36.2 // indirect + k8s.io/component-base v0.36.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf // indirect - k8s.io/kubectl v0.35.2 // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect - oras.land/oras-go/v2 v2.6.0 // indirect + k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25 // indirect + k8s.io/kubectl v0.36.2 // indirect + k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect + oras.land/oras-go/v2 v2.6.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/api v0.21.1 // indirect sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index fcc76093..35f950b0 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSC github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= @@ -54,6 +54,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/apparentlymart/go-textseg/v17 v17.0.1 h1:bpMXRgQ5cEoRNuQke1a80/Nl6w3G5eoIbWo9f3gXkAs= +github.com/apparentlymart/go-textseg/v17 v17.0.1/go.mod h1:fa8X4jgGeevslICIY6LcdjkSecWnXmYd9Lk34z/VxZs= github.com/aquasecurity/go-pep440-version v0.0.1 h1:8VKKQtH2aV61+0hovZS3T//rUF+6GDn18paFTVS0h0M= github.com/aquasecurity/go-pep440-version v0.0.1/go.mod h1:3naPe+Bp6wi3n4l5iBFCZgS0JG8vY6FT0H4NGhFJ+i4= github.com/aquasecurity/go-version v0.0.1 h1:4cNl516agK0TCn5F7mmYN+xVs1E3S45LkgZk3cbaW2E= @@ -64,48 +66,48 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/auth0/go-jwt-middleware/v2 v2.3.1 h1:lbDyWE9aLydb3zrank+Gufb9qGJN9u//7EbJK07pRrw= github.com/auth0/go-jwt-middleware/v2 v2.3.1/go.mod h1:mqVr0gdB5zuaFyQFWMJH/c/2hehNjbYUD4i8Dpyf+Hc= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= -github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.302.0 h1:7c0jQaj+QKYUo3pgtEm9fQIePJH6QJA3bVKIgCCLdvM= -github.com/aws/aws-sdk-go-v2/service/ec2 v1.302.0/go.mod h1:Y95W0Hm6FYLPa6o0hbnJ+sWgmdc4ifcLFjGkdobWVhY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI= +github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk= +github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.316.1 h1:x3XE3BMK8aUpGx/m4CwmCmxc1LnN6saZujJ5K6pIFXU= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.316.1/go.mod h1:eoF0SIRbTgKWnTcTPYckiURPba/7ilfEkvwL4V1iHK4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= +github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M= +github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= -github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= +github.com/beevik/etree v1.7.0 h1:xjBk9O4p4x7D1YajePjfLzdaFC4/uYUENA7P0pv6gXA= +github.com/beevik/etree v1.7.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= -github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bshuster-repo/logrus-logstash-hook v1.1.0 h1:o2FzZifLg+z/DN1OFmzTWzZZx/roaqt8IPZCIVco8r4= +github.com/bshuster-repo/logrus-logstash-hook v1.1.0/go.mod h1:Q2aXOe7rNuPgbBtPCOzYyWDvKX7+FpxE5sRdvcPoui0= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -126,22 +128,20 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= +github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/containerd/containerd v1.7.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qHBs+sbY8= -github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= -github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= -github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= -github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= +github.com/containerd/platforms v1.0.0-rc.4 h1:M42JrUT4zfZTqtkUwkr0GzmUWbfyO5VO0Q5b3op97T4= +github.com/containerd/platforms v1.0.0-rc.4/go.mod h1:lKlMXyLybmBedS/JJm11uDofzI8L2v0J2ZbYvNsbq1A= +github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= +github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= @@ -149,8 +149,8 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= +github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -161,29 +161,29 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= -github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= -github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/distribution/v3 v3.1.1 h1:KUbk7C8CfaLXy8kbf/hGq9cad/wCoLB6dbWH6DMbmX0= +github.com/distribution/distribution/v3 v3.1.1/go.mod h1:d7lXwZpph0bVcOj4Aqn0nMrWHIwRQGdiV5TLeI+/w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= -github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= +github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= -github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= +github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-events v0.0.0-20250808211157-605354379745 h1:yOn6Ze6IbYI/KAw2lw/83ELYvZh6hvsygTVkD0dzMC4= +github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/drone/go-scm v1.42.3 h1:i16M9yKKjXSWz2pVCxxmJ9yvew1WbT/uM6bSOAWm47M= -github.com/drone/go-scm v1.42.3/go.mod h1:DFIJJjhMj0TSXPz+0ni4nyZ9gtTtC40Vh/TGRugtyWw= +github.com/drone/go-scm v1.42.13 h1:7fmTY368Inf6MCt4g5tfR7Tz+CsYw7h92D5kfMOCt3o= +github.com/drone/go-scm v1.42.13/go.mod h1:DFIJJjhMj0TSXPz+0ni4nyZ9gtTtC40Vh/TGRugtyWw= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -200,16 +200,16 @@ github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2 github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= @@ -232,8 +232,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= -github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -246,42 +246,42 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= -github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= -github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= -github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= -github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= -github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= -github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= -github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= -github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= -github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= -github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= -github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= -github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= -github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= -github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= -github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= -github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= -github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= -github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= -github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= -github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= -github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= -github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= -github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= -github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= -github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= +github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= +github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= +github.com/go-openapi/swag/cmdutils v0.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= +github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= +github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= +github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= +github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= +github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= +github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= +github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= +github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/netutils v0.26.1 h1:BNctoc39WTAUMxyAs355fExOPzMZtPbZ0ZZ1Am2FR5M= +github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= +github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= +github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= +github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= +github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -324,8 +324,8 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= -github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= +github.com/google/go-containerregistry v0.21.8 h1:Ig/zIsnztdCUNaiNNczE+MoP5xcyUMfvpvfOr1xyMLE= +github.com/google/go-containerregistry v0.21.8/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo= github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= @@ -333,8 +333,8 @@ github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -350,8 +350,6 @@ github.com/goware/urlx v0.3.2 h1:gdoo4kBHlkqZNaf6XlQ12LGtQOmpKJrR04Rc3RnpJEo= github.com/goware/urlx v0.3.2/go.mod h1:h8uwbJy68o+tQXCGZNa9D73WN8n0r9OBae5bUnLcgjw= github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/h2non/gock v1.0.9 h1:17gCehSo8ZOgEsFKpQgqHiR7VLyjxdAG3lkhVvO9QZU= @@ -379,8 +377,8 @@ github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQx github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/terraform-config-inspect v0.0.0-20230614215431-f32df32a01cd h1:1uPcotqoL4TjcGKlgIe7OFSRplf7BMVtUjekwmCrvuM= github.com/hashicorp/terraform-config-inspect v0.0.0-20230614215431-f32df32a01cd/go.mod h1:l8HcFPm9cQh6Q0KSWoYPiePqMvRFenybP1CH2MjKdlg= -github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= -github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-registry-address v0.5.0 h1:FAlhWOLFgMvo/4f5DPhCTwRYfHYdF1DjiOtgxfGr4p0= +github.com/hashicorp/terraform-registry-address v0.5.0/go.mod h1:wOJYCN60i/gSQGPCcGdamatjxn65EZBMFVt7c/Suzis= github.com/hashicorp/terraform-svchost v0.2.1 h1:ubvrTFw3Q7CsoEaX7V06PtCTKG3wu7GyyobAoN4eF3Q= github.com/hashicorp/terraform-svchost v0.2.1/go.mod h1:zDMheBLvNzu7Q6o9TBvPqiZToJcSuCLXjAXxBslSky4= github.com/helm-unittest/yaml-jsonpath v0.4.0 h1:jKytxp8F5mmadA6UE/M/EOjutcbMeql8ewnSC0JzhQ4= @@ -405,18 +403,16 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= -github.com/jferrl/go-githubauth v1.6.0 h1:By+4kqdNPhvizKztD1uVbwk3cp2o9bNIVATZ9oIDYaw= -github.com/jferrl/go-githubauth v1.6.0/go.mod h1:JfSoHpcaY93/UduD45AY15pLgkcE1LnsZfH+Gqf/TBI= +github.com/jferrl/go-githubauth v1.7.0 h1:ksABJxA4ye8H8VSpW5hVsEOeYxrTG+NwJEWZ0X0BmyI= +github.com/jferrl/go-githubauth v1.7.0/go.mod h1:JfSoHpcaY93/UduD45AY15pLgkcE1LnsZfH+Gqf/TBI= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -441,10 +437,10 @@ github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6 github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= @@ -465,22 +461,22 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/buildkit v0.30.0 h1:OsK8T3BaYH52UNStpKd7gytDtHWWt2Fawak/lAPWatU= -github.com/moby/buildkit v0.30.0/go.mod h1:k2wuw5ddaOqzh58RLt+mBn2XhK34gi6+gd0faONQ1xU= +github.com/moby/buildkit v0.32.0 h1:slXarYQoMo4cp2d9x30M9t0L4R+c0CVMov+5P1hhiHY= +github.com/moby/buildkit v0.32.0/go.mod h1:Y10FBWvqxl/Wmhdzjee1Y2wQfjifTiwxENIUdaVNdME= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= -github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= -github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= -github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/moby/go-archive v0.2.1 h1:fAa0wUS/ikZKyx7o/1fhUYmhZ7RgpthdeoDhJvunTLc= +github.com/moby/go-archive v0.2.1/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= +github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -502,8 +498,9 @@ github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= -github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.1-0.20231025023718-d50d2fec9c98 h1:H55sU3giNgBkIvmAo0vI/AAFwVTwfWsf6MN3+9H6U8o= @@ -516,8 +513,8 @@ github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= @@ -578,8 +575,8 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= -github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed h1:KT7hI8vYXgU0s2qaMkrfq9tCA1w/iEPgfredVP+4Tzw= @@ -633,8 +630,8 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= -github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= +github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= @@ -647,12 +644,14 @@ github.com/tomwright/dasel v1.27.3 h1:vnoaZsG8hbubcdh2IioRVjOt2DTg+txLtDGKQn1cdA github.com/tomwright/dasel v1.27.3/go.mod h1:/rESPoTvQxRkrtEH8lhSU8CB2UWPh/bM0kDrKVGf1kQ= github.com/tomwright/dasel/v2 v2.8.1 h1:mo5SlL0V2d3a0uPsD9Rrndn0cHWpbNDheB4+Fm++z8k= github.com/tomwright/dasel/v2 v2.8.1/go.mod h1:6bNDNAnmGEtGpuIvksuQwiNcAgQ87pmzndynsqTNglc= +github.com/tomwright/dasel/v3 v3.11.2 h1:rrvsZv0w4M7sEJ4inuORr7AD7KPG6CbgL3fHHpAcvNE= +github.com/tomwright/dasel/v3 v3.11.2/go.mod h1:NMZl2F0lmpBnSsBg1rYUqGwwiLsB8SIWuqTVnT6wpfg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/updatecli/updatecli v0.118.0 h1:tpN0Aa/m74BWXdaHCjpiW1GdsyJZPoTPJbM5k5lTHdg= -github.com/updatecli/updatecli v0.118.0/go.mod h1:0U6xB8xJInxf1Ek8capJ7T7F6+9hnexqIKgwXsUPpJ8= +github.com/updatecli/updatecli v0.120.0 h1:ZCQ8jaFUiphc4BDp4aDsMd/ILothfswMRU3LnCvC+mU= +github.com/updatecli/updatecli v0.120.0/go.mod h1:fcvFF0kABLMMqQQH2yqbkLwxJ8ASsBWJqhTp+M0CUow= github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= @@ -663,16 +662,14 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= -github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -github.com/zclconf/go-cty v1.18.1 h1:yEGE8M4iIZlyKQURZNb2SnEyZlZHUcBCnx6KF81KuwM= -github.com/zclconf/go-cty v1.18.1/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= +github.com/zclconf/go-cty v1.19.0 h1:IV8WdqYZc2c5rLX9bEoLNXKojBAp0MZPBHMIrCoa/s4= +github.com/zclconf/go-cty v1.19.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ= @@ -693,42 +690,42 @@ go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXv go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= -go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ= +go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= @@ -741,14 +738,14 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE= golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -758,24 +755,20 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= +golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -788,13 +781,11 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -807,7 +798,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -826,8 +816,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -839,8 +829,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -857,30 +847,25 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= -google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/api v0.0.0-20260720171339-e059f2f05d78 h1:A6tVI++lXZuQiRnz7E+iFluPQ+silVmlkbryjSO1z8c= +google.golang.org/genproto/googleapis/api v0.0.0-20260720171339-e059f2f05d78/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720171339-e059f2f05d78 h1:pRUrsnNVD/NpCD42WJ2AO3dQ2s1e2sqMxg8jOwdX2Ak= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720171339-e059f2f05d78/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -891,8 +876,8 @@ gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKK gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= -gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -903,32 +888,32 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -helm.sh/helm/v3 v3.21.0 h1:9TRbaXQH+BIKLLDYlu++JsyWodS5kBBOLF7C7HY5+cs= -helm.sh/helm/v3 v3.21.0/go.mod h1:5IvU6Ae6ruB/vasVHhnC1IU5RvqFM349vLYS1BiHqeY= -k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= -k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= -k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0= -k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU= -k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= -k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.2 h1:rb52v0CZGEL0FkhjS+I6jHflAp7fZ4MIaKcEHX7wmDk= -k8s.io/apiserver v0.35.2/go.mod h1:CROJUAu0tfjZLyYgSeBsBan2T7LUJGh0ucWwTCSSk7g= -k8s.io/cli-runtime v0.35.2 h1:3DNctzpPNXavqyrm/FFiT60TLk4UjUxuUMYbKOE970E= -k8s.io/cli-runtime v0.35.2/go.mod h1:G2Ieu0JidLm5m1z9b0OkFhnykvJ1w+vjbz1tR5OFKL0= -k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= -k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= -k8s.io/component-base v0.35.2 h1:btgR+qNrpWuRSuvWSnQYsZy88yf5gVwemvz0yw79pGc= -k8s.io/component-base v0.35.2/go.mod h1:B1iBJjooe6xIJYUucAxb26RwhAjzx0gHnqO9htWIX+0= +helm.sh/helm/v3 v3.21.3 h1:wkamdwI3liEkW6wI1l9aGqQZGxcTKyt8kx0qJLPcmCg= +helm.sh/helm/v3 v3.21.3/go.mod h1:iaJ0iNsPoTZl++7h6vzQFyT0VEVtLYJiyRBDkPOOBTs= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4= +k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/apiserver v0.36.2 h1:6vMnkmHZPeBloNkHUhmZYq7Ylv8WIB8xjyEl+eSt26E= +k8s.io/apiserver v0.36.2/go.mod h1:9PoQ2ikCytrZyZg11mGhLEF5m8Rgsb5FJmYJ4Wvnl1k= +k8s.io/cli-runtime v0.36.2 h1:CconTvEeV4DJs4ZX3HQKCFbFRGsm6OtuBM9yjmMP2VM= +k8s.io/cli-runtime v0.36.2/go.mod h1:LddcjiMf4YlnHO7c1Y7rEtDqL84FyiYVLco7V679GUU= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= +k8s.io/component-base v0.36.2 h1:Z0VH80O7Ng0HDZnZj3WRR3urEGa0kTwmO8CwEwjVK1w= +k8s.io/component-base v0.36.2/go.mod h1:mGfFOA7Gwpdm1VW2cwSQYbiDIlz8GD2WGwH88QSeCyA= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf h1:btPscg4cMql0XdYK2jLsJcNEKmACJz8l+U7geC06FiM= -k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/kubectl v0.35.2 h1:aSmqhSOfsoG9NR5oR8OD5eMKpLN9x8oncxfqLHbJJII= -k8s.io/kubectl v0.35.2/go.mod h1:+OJC779UsDJGxNPbHxCwvb4e4w9Eh62v/DNYU2TlsyM= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= -oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25 h1:mPMaPMpBij2V1Wv/fR+HW124vVGXXvOSS9ver/9yjWs= +k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= +k8s.io/kubectl v0.36.2 h1:rpUGGpeL09XVOLep2yle5jrtk//JA1L6ZHfkQQtVEwk= +k8s.io/kubectl v0.36.2/go.mod h1:gVbQ3B/yb4bSR2ggQ7rd0W6icUSWs7sduH4e16Vii+0= +k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= +k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= +oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= @@ -939,7 +924,7 @@ sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7 sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= +sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= From 1c1a78c53769c6967aa638ccaade3847468d822d Mon Sep 17 00:00:00 2001 From: Olivier Vernin Date: Fri, 14 Aug 2026 08:25:27 +0200 Subject: [PATCH 9/9] feat: revamp authentication Signed-off-by: Olivier Vernin --- Makefile | 2 +- README.adoc | 116 +++++- docs/docs.go | 360 +++++++++++++++++- docs/swagger.json | 359 ++++++++++++++++- docs/swagger.yaml | 257 +++++++++++++ pkg/database/apitoken.go | 224 +++++++++++ pkg/database/database_test.go | 6 +- pkg/database/migration_test.go | 62 +++ .../000012_create_api_tokens.down.sql | 6 + .../000012_create_api_tokens.up.sql | 32 ++ ...alter_pipelineReports_attribution.down.sql | 7 + ...3_alter_pipelineReports_attribution.up.sql | 11 + pkg/database/report.go | 17 +- pkg/model/apitoken.go | 29 ++ pkg/server/endpoints.go | 122 +++--- pkg/server/endpoints_test.go | 23 +- pkg/server/identity.go | 217 +++++++++++ pkg/server/jwt.go | 49 ++- pkg/server/jwtClaim.go | 22 ++ pkg/server/option.go | 5 +- pkg/server/optionAuth.go | 225 +++++++++++ pkg/server/optionAuth_test.go | 138 +++++++ pkg/server/optionOauth.go | 108 ------ pkg/server/report_handlers.go | 2 +- pkg/server/roles.go | 216 +++++++++++ pkg/server/roles_test.go | 211 ++++++++++ pkg/server/token_handlers.go | 273 +++++++++++++ pkg/server/token_middleware.go | 127 ++++++ pkg/server/token_test.go | 329 ++++++++++++++++ pkg/server/var.go | 17 + pkg/server/zitadel-gin.go | 30 +- pkg/server/zitadel-roles.go | 54 +++ 32 files changed, 3451 insertions(+), 205 deletions(-) create mode 100644 pkg/database/apitoken.go create mode 100644 pkg/database/migration_test.go create mode 100644 pkg/database/migrations/000012_create_api_tokens.down.sql create mode 100644 pkg/database/migrations/000012_create_api_tokens.up.sql create mode 100644 pkg/database/migrations/000013_alter_pipelineReports_attribution.down.sql create mode 100644 pkg/database/migrations/000013_alter_pipelineReports_attribution.up.sql create mode 100644 pkg/model/apitoken.go create mode 100644 pkg/server/identity.go create mode 100644 pkg/server/optionAuth.go create mode 100644 pkg/server/optionAuth_test.go delete mode 100644 pkg/server/optionOauth.go create mode 100644 pkg/server/roles.go create mode 100644 pkg/server/roles_test.go create mode 100644 pkg/server/token_handlers.go create mode 100644 pkg/server/token_middleware.go create mode 100644 pkg/server/token_test.go create mode 100644 pkg/server/zitadel-roles.go diff --git a/Makefile b/Makefile index ab85fd10..29dd8964 100644 --- a/Makefile +++ b/Makefile @@ -64,4 +64,4 @@ test: ## Execute the Golang's tests for updatecli .PHONY: docs docs: ## Generate api documentation - swag init --parseDependencyLevel 1 + swag init --generalInfo pkg/server/endpoints.go --parseDependencyLevel 1 diff --git a/README.adoc b/README.adoc index c0e96012..40522ae6 100644 --- a/README.adoc +++ b/README.adoc @@ -30,8 +30,8 @@ Deploy Udash with the following steps: 4. Run `updatecli udash login "http://localhost" --experimental` to configure Updatecli to upload reports to Udash. 5. Then you can run any updatecli command (apply/diff) to start publishing reports to Udash -The demo runs with authentication disabled. Because no OAuth flag is passed, `udash login` skips the -authorization flow and simply records the endpoint in the Updatecli configuration file. +The demo runs with authentication disabled. `udash login` notices this, skips the token prompt, +and simply records the endpoint in the Updatecli configuration file. Please be aware that the UI is designed to visualize pipelines per git repository, so without an `scmid` pipelines will be hard to discover. @@ -91,17 +91,20 @@ with `--config`. ```yaml server: auth: - # mode selects the authentication backend. - # Accepted values are "oauth", "zitadel", and "none". + # mode selects how incoming tokens are validated. + # Accepted values are "oidc", "zitadel", and "none". # Unset or "none" disables authentication entirely. - mode: "oauth" + # An unrecognised value stops the server rather than serving an open API. + mode: "oidc" # visibility controls which endpoints require a token. # "public" (the default) leaves the read endpoints open and requires # authentication for anything that writes. # "private" requires authentication everywhere. visibility: "public" - # oauth settings, used when mode is "oauth" - oauth: + # oidc settings, used when mode is "oidc". + # Tokens are verified locally against the issuer signing keys, so this mode + # only accepts JWT access tokens. + oidc: # issuer is compared to the "iss" claim of the token, verbatim. # A scheme is optional, https is assumed when it is omitted, but the # trailing slash is significant: Auth0 issues one, Zitadel and Keycloak @@ -110,13 +113,43 @@ server: # audience is a list, and every entry is accepted. audience: - "https://udash.example/api" - # zitadel settings, used when mode is "zitadel" + # zitadel settings, used when mode is "zitadel". + # Tokens are validated by introspection, which also accepts opaque ones such + # as Zitadel personal access tokens. zitadel: domain: "xxx.region.zitadel.cloud" # keyfile is the path to a service account key file keyfile: "/etc/udash/zitadel-key.json" - # role required to access the API. Empty means any authenticated user. - role: "" + # roles maps the roles carried by a token onto Udash permissions. + roles: + # claim is the token claim holding the identity provider roles. It defaults + # to Zitadel's claim in "zitadel" mode and must be set otherwise. + # Both shapes are accepted: an object keyed by role name, as Zitadel emits, + # and an array of strings, as Keycloak and Auth0 emit. + # Zitadel: "urn:zitadel:iam:org:project:roles" + # Keycloak: "realm_access.roles" + # Auth0: "https://udash/roles" + claim: "realm_access.roles" + # mapping lists, per permission, the provider roles granting it. + mapping: + admin: ["udash.admin"] + publisher: ["udash.publisher"] + viewer: ["udash.viewer"] + # default is granted to an authenticated identity matching no role at all. + # It is deliberately the least privileged one: without it, everybody who can + # sign in could publish reports and mint API tokens. + default: "viewer" + # resolver decides how the permission behind an Udash API token is resolved, + # since such a request carries no provider token to read roles from. + # "zitadel" asks Zitadel for the current grants, so revoking a role takes + # effect on tokens created before it. It requires mode "zitadel", and the + # service user behind keyfile must be allowed to read user grants. + # "snapshot" trusts the permission recorded when the token was created, and + # is the only option for other providers. Offboarding somebody then means + # deleting their tokens. + resolver: "snapshot" + # cacheTTL is how long a resolved permission is reused. + cacheTTL: "60s" database: # uri defines the postgresql URI used to connect with its database uri: "postgres://udash:password@db:5432/udash?sslmode=disable" @@ -129,13 +162,68 @@ database: Each variable below is only a fallback: it is read when the matching key is absent from the configuration file, so the file always wins. -* **UDASH_AUTH_MODE**: Authentication mode. Accepted values are ["", "none", "oauth", "zitadel"] -* **UDASH_AUTH_OAUTH_ISSUER**: Oauth issuer URL, requires `UDASH_AUTH_MODE` set to "oauth" -* **UDASH_AUTH_OAUTH_AUDIENCE**: Oauth audience, requires `UDASH_AUTH_MODE` set to "oauth" +* **UDASH_AUTH_MODE**: Authentication mode. Accepted values are ["", "none", "oidc", "zitadel"] +* **UDASH_AUTH_OIDC_ISSUER**: OIDC issuer URL, requires `UDASH_AUTH_MODE` set to "oidc" +* **UDASH_AUTH_OIDC_AUDIENCE**: OIDC audience, requires `UDASH_AUTH_MODE` set to "oidc" * **UDASH_AUTH_ZITADEL_DOMAIN**: Zitadel domain, requires `UDASH_AUTH_MODE` set to "zitadel" -* **UDASH_AUTH_ZITADEL_FILEKEY**: Path to the Zitadel service account key file, requires `UDASH_AUTH_MODE` set to "zitadel" +* **UDASH_AUTH_ZITADEL_KEYFILE**: Path to the Zitadel service account key file, requires `UDASH_AUTH_MODE` set to "zitadel" +* **UDASH_AUTH_ROLES_CLAIM**: Token claim holding the identity provider roles +* **UDASH_AUTH_ROLES_DEFAULT**: Permission granted to an identity matching no role +* **UDASH_AUTH_ROLES_RESOLVER**: How an API token's permission is resolved ["zitadel", "snapshot"] * **UDASH_DB_URI**: Define the postgresql URI +==== Permissions + +Authorization has two axes: what a *person* may do, and what a given *token* may do. + +Permissions come from the identity provider roles, mapped by `server.auth.roles.mapping`: + +[cols="1,3"] +|=== +| Permission | Grants + +| `viewer` | read pipeline reports +| `publisher` | publish pipeline reports, and create API tokens +| `admin` | everything, plus managing any identity's tokens +|=== + +Token scopes are chosen when a token is created and can never exceed what its creator is +allowed to do: `reports:read` and `reports:write`. There is deliberately no scope for +managing tokens, so a token can never mint another one. + +==== API tokens + +An access token from an identity provider always expires, while an unattended pipeline needs +a credential it can keep. Udash therefore issues its own tokens, validates them itself, and +lets them live forever unless an expiry is set. + +They are created from **Profile ▸ Tokens** in the frontend, by anybody with the `publisher` +permission, and shown exactly once — only a sha256 of the token is stored. They are prefixed +`udash_pat_` so they can be told apart from a provider token, and recognised by secret +scanners if one ever leaks. + +Point Updatecli at one with either: + +```bash +updatecli udash login --experimental https://udash.example # prompts for the token +export UPDATECLI_UDASH_ACCESS_TOKEN="udash_pat_..." # for CI +``` + +==== Non-expiring tokens without the frontend + +In `zitadel` mode, tokens are validated by introspection, which accepts opaque tokens. A +Zitadel **personal access token** on a machine user therefore works as a permanent +credential with no Udash-side setup at all: + +1. In Zitadel, create a *service user*, grant it the project role mapped to `publisher` + (`udash.publisher` by default), and create a personal access token leaving the expiration + field empty. +2. Set `UPDATECLI_UDASH_ACCESS_TOKEN` to it in CI. + +The trade-offs against an Udash API token: every request costs an introspection round-trip to +Zitadel, and minting one needs Zitadel administrator rights, so it does not scale to letting +each team issue their own. + === Udash Frontend ==== Option diff --git a/docs/docs.go b/docs/docs.go index f8806b61..6bb6c559 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -989,6 +989,224 @@ const docTemplate = `{ } } } + }, + "/api/tokens": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List the caller's API tokens. Administrators may list everybody's with all=true. The tokens themselves are never returned.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "List API tokens", + "parameters": [ + { + "type": "boolean", + "description": "list every identity's tokens, administrators only", + "name": "all", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.APIToken" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Issue a long lived token to authenticate against the Udash API. The token is returned once and cannot be recovered afterwards.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Create an API token", + "parameters": [ + { + "description": "token to create", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.CreateTokenRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/server.CreateTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke all API tokens created by a given identity, which is what offboarding somebody needs. Administrators only.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Revoke every token of an identity", + "parameters": [ + { + "type": "string", + "description": "identity provider subject", + "name": "subject", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/tokens/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke one of the caller's API tokens. Administrators may revoke anybody's.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Revoke an API token", + "parameters": [ + { + "type": "string", + "description": "token id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/whoami": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return the identity, permission and token scopes behind the credential used. Updatecli calls it to validate a token at login time.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Describe the current identity", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.WhoamiResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } } }, "definitions": { @@ -1141,6 +1359,45 @@ const docTemplate = `{ } } }, + "model.APIToken": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is when the token was issued.", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt is when the token stops working. Nil means it never expires.", + "type": "string" + }, + "id": { + "type": "string" + }, + "last_used_at": { + "description": "LastUsedAt is when the token last authenticated a request, if ever.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, chosen by whoever created it.", + "type": "string" + }, + "permission": { + "description": "Permission is what that identity could do when the token was issued.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do, always a subset of what Permission allows.", + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "description": "Subject is the identity provider subject which created the token.", + "type": "string" + } + } + }, "model.ConfigCondition": { "type": "object", "properties": { @@ -1785,6 +2042,72 @@ const docTemplate = `{ } } }, + "server.CreateTokenRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expires_at": { + "description": "ExpiresAt is when the token stops working. Leave it out for a token which\nnever expires, which is what an unattended pipeline needs.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, shown back in the token list.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do. It defaults to publishing reports, and may\nnever exceed what the identity creating it is allowed to do.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "server.CreateTokenResponse": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is when the token was issued.", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt is when the token stops working. Nil means it never expires.", + "type": "string" + }, + "id": { + "type": "string" + }, + "last_used_at": { + "description": "LastUsedAt is when the token last authenticated a request, if ever.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, chosen by whoever created it.", + "type": "string" + }, + "permission": { + "description": "Permission is what that identity could do when the token was issued.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do, always a subset of what Permission allows.", + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "description": "Subject is the identity provider subject which created the token.", + "type": "string" + }, + "token": { + "description": "Token is the credential itself. It is returned here once and never again:\nonly its hash is stored.", + "type": "string" + } + } + }, "server.DefaultResponseModel": { "type": "object", "properties": { @@ -2058,6 +2381,29 @@ const docTemplate = `{ } } }, + "server.WhoamiResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "tokenName": { + "type": "string" + } + } + }, "source.Config": { "type": "object", "properties": { @@ -2307,17 +2653,25 @@ const docTemplate = `{ } } } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Either an Udash API token, created from the tokens page and prefixed with \"udash_pat_\", or an access token from the configured identity provider. Send it as \"Bearer \u003ctoken\u003e\".", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } } }` // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "", + Version: "1.0", Host: "", BasePath: "", Schemes: []string{}, - Title: "", - Description: "", + Title: "Udash API", + Description: "API for managing Updatecli pipeline reports.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index d4165c76..df0666a7 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,7 +1,10 @@ { "swagger": "2.0", "info": { - "contact": {} + "description": "API for managing Updatecli pipeline reports.", + "title": "Udash API", + "contact": {}, + "version": "1.0" }, "paths": { "/api/": { @@ -978,6 +981,224 @@ } } } + }, + "/api/tokens": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List the caller's API tokens. Administrators may list everybody's with all=true. The tokens themselves are never returned.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "List API tokens", + "parameters": [ + { + "type": "boolean", + "description": "list every identity's tokens, administrators only", + "name": "all", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.APIToken" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Issue a long lived token to authenticate against the Udash API. The token is returned once and cannot be recovered afterwards.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Create an API token", + "parameters": [ + { + "description": "token to create", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.CreateTokenRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/server.CreateTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke all API tokens created by a given identity, which is what offboarding somebody needs. Administrators only.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Revoke every token of an identity", + "parameters": [ + { + "type": "string", + "description": "identity provider subject", + "name": "subject", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/tokens/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke one of the caller's API tokens. Administrators may revoke anybody's.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Revoke an API token", + "parameters": [ + { + "type": "string", + "description": "token id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/whoami": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return the identity, permission and token scopes behind the credential used. Updatecli calls it to validate a token at login time.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Describe the current identity", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.WhoamiResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } } }, "definitions": { @@ -1130,6 +1351,45 @@ } } }, + "model.APIToken": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is when the token was issued.", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt is when the token stops working. Nil means it never expires.", + "type": "string" + }, + "id": { + "type": "string" + }, + "last_used_at": { + "description": "LastUsedAt is when the token last authenticated a request, if ever.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, chosen by whoever created it.", + "type": "string" + }, + "permission": { + "description": "Permission is what that identity could do when the token was issued.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do, always a subset of what Permission allows.", + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "description": "Subject is the identity provider subject which created the token.", + "type": "string" + } + } + }, "model.ConfigCondition": { "type": "object", "properties": { @@ -1774,6 +2034,72 @@ } } }, + "server.CreateTokenRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expires_at": { + "description": "ExpiresAt is when the token stops working. Leave it out for a token which\nnever expires, which is what an unattended pipeline needs.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, shown back in the token list.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do. It defaults to publishing reports, and may\nnever exceed what the identity creating it is allowed to do.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "server.CreateTokenResponse": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is when the token was issued.", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt is when the token stops working. Nil means it never expires.", + "type": "string" + }, + "id": { + "type": "string" + }, + "last_used_at": { + "description": "LastUsedAt is when the token last authenticated a request, if ever.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, chosen by whoever created it.", + "type": "string" + }, + "permission": { + "description": "Permission is what that identity could do when the token was issued.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do, always a subset of what Permission allows.", + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "description": "Subject is the identity provider subject which created the token.", + "type": "string" + }, + "token": { + "description": "Token is the credential itself. It is returned here once and never again:\nonly its hash is stored.", + "type": "string" + } + } + }, "server.DefaultResponseModel": { "type": "object", "properties": { @@ -2047,6 +2373,29 @@ } } }, + "server.WhoamiResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "tokenName": { + "type": "string" + } + } + }, "source.Config": { "type": "object", "properties": { @@ -2296,5 +2645,13 @@ } } } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Either an Udash API token, created from the tokens page and prefixed with \"udash_pat_\", or an access token from the configured identity provider. Send it as \"Bearer \u003ctoken\u003e\".", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c63fd7b0..79c62a3c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -140,6 +140,38 @@ definitions: description: UpdatedAt represents the last update date of the report. type: string type: object + model.APIToken: + properties: + created_at: + description: CreatedAt is when the token was issued. + type: string + expires_at: + description: ExpiresAt is when the token stops working. Nil means it never + expires. + type: string + id: + type: string + last_used_at: + description: LastUsedAt is when the token last authenticated a request, if + ever. + type: string + name: + description: Name is what the token is for, chosen by whoever created it. + type: string + permission: + description: Permission is what that identity could do when the token was + issued. + type: string + scopes: + description: Scopes is what the token may do, always a subset of what Permission + allows. + items: + type: string + type: array + subject: + description: Subject is the identity provider subject which created the token. + type: string + type: object model.ConfigCondition: properties: config: @@ -596,6 +628,63 @@ definitions: reportid: type: string type: object + server.CreateTokenRequest: + properties: + expires_at: + description: |- + ExpiresAt is when the token stops working. Leave it out for a token which + never expires, which is what an unattended pipeline needs. + type: string + name: + description: Name is what the token is for, shown back in the token list. + type: string + scopes: + description: |- + Scopes is what the token may do. It defaults to publishing reports, and may + never exceed what the identity creating it is allowed to do. + items: + type: string + type: array + required: + - name + type: object + server.CreateTokenResponse: + properties: + created_at: + description: CreatedAt is when the token was issued. + type: string + expires_at: + description: ExpiresAt is when the token stops working. Nil means it never + expires. + type: string + id: + type: string + last_used_at: + description: LastUsedAt is when the token last authenticated a request, if + ever. + type: string + name: + description: Name is what the token is for, chosen by whoever created it. + type: string + permission: + description: Permission is what that identity could do when the token was + issued. + type: string + scopes: + description: Scopes is what the token may do, always a subset of what Permission + allows. + items: + type: string + type: array + subject: + description: Subject is the identity provider subject which created the token. + type: string + token: + description: |- + Token is the credential itself. It is returned here once and never again: + only its hash is stored. + type: string + type: object server.DefaultResponseModel: properties: error: @@ -835,6 +924,21 @@ definitions: description: TotalCount is the total number of targets for pagination. type: integer type: object + server.WhoamiResponse: + properties: + name: + type: string + permission: + type: string + scopes: + items: + type: string + type: array + subject: + type: string + tokenName: + type: string + type: object source.Config: properties: dependsOn: @@ -1095,6 +1199,9 @@ definitions: type: object info: contact: {} + description: API for managing Updatecli pipeline reports. + title: Udash API + version: "1.0" paths: /api/: get: @@ -1744,4 +1851,154 @@ paths: summary: Search SCMs tags: - SCMs + /api/tokens: + delete: + description: Revoke all API tokens created by a given identity, which is what + offboarding somebody needs. Administrators only. + parameters: + - description: identity provider subject + in: query + name: subject + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "403": + description: Forbidden + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: Revoke every token of an identity + tags: + - Tokens + get: + description: List the caller's API tokens. Administrators may list everybody's + with all=true. The tokens themselves are never returned. + parameters: + - description: list every identity's tokens, administrators only + in: query + name: all + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.APIToken' + type: array + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: List API tokens + tags: + - Tokens + post: + consumes: + - application/json + description: Issue a long lived token to authenticate against the Udash API. + The token is returned once and cannot be recovered afterwards. + parameters: + - description: token to create + in: body + name: request + required: true + schema: + $ref: '#/definitions/server.CreateTokenRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/server.CreateTokenResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "403": + description: Forbidden + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: Create an API token + tags: + - Tokens + /api/tokens/{id}: + delete: + description: Revoke one of the caller's API tokens. Administrators may revoke + anybody's. + parameters: + - description: token id + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "404": + description: Not Found + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: Revoke an API token + tags: + - Tokens + /api/whoami: + get: + description: Return the identity, permission and token scopes behind the credential + used. Updatecli calls it to validate a token at login time. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.WhoamiResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: Describe the current identity + tags: + - Tokens +securityDefinitions: + BearerAuth: + description: Either an Udash API token, created from the tokens page and prefixed + with "udash_pat_", or an access token from the configured identity provider. + Send it as "Bearer ". + in: header + name: Authorization + type: apiKey swagger: "2.0" diff --git a/pkg/database/apitoken.go b/pkg/database/apitoken.go new file mode 100644 index 00000000..6affd261 --- /dev/null +++ b/pkg/database/apitoken.go @@ -0,0 +1,224 @@ +package database + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/sirupsen/logrus" + "github.com/updatecli/udash/pkg/model" + + "github.com/stephenafamo/bob" + "github.com/stephenafamo/bob/dialect/psql" + "github.com/stephenafamo/bob/dialect/psql/dialect" + "github.com/stephenafamo/bob/dialect/psql/dm" + "github.com/stephenafamo/bob/dialect/psql/im" + "github.com/stephenafamo/bob/dialect/psql/sm" + "github.com/stephenafamo/bob/dialect/psql/um" +) + +// ErrAPITokenNotFound is returned when no token matches the request. +var ErrAPITokenNotFound = errors.New("api token not found") + +// apiTokenColumns is the column list every read shares, in scan order. +var apiTokenColumns = []any{ + "id", "name", "subject", "permission", "scopes", + "created_at", "last_used_at", "expires_at", +} + +// InsertAPIToken stores a new token and returns it. +// +// Only the hash is passed in: the token itself is shown once, to whoever created +// it, and is never written down. +func InsertAPIToken(ctx context.Context, name, subject, permission string, scopes []string, tokenHash []byte, expiresAt *time.Time) (*model.APIToken, error) { + query := psql.Insert( + im.Into("api_tokens", "name", "subject", "permission", "scopes", "token_hash", "expires_at"), + im.Values( + psql.Arg(name), + psql.Arg(subject), + psql.Arg(permission), + psql.Arg(scopes), + psql.Arg(tokenHash), + psql.Arg(expiresAt), + ), + im.Returning(apiTokenColumns...), + ) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return nil, err + } + + token := model.APIToken{} + if err := scanAPIToken(DB.QueryRow(ctx, queryString, args...), &token); err != nil { + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return nil, err + } + + return &token, nil +} + +// GetAPITokenByHash returns the token matching the given hash. +// +// Looking a token up by its hash is what makes the stored value useless to anybody +// who reads the database: it cannot be turned back into a usable credential. +func GetAPITokenByHash(ctx context.Context, tokenHash []byte) (*model.APIToken, error) { + query := psql.Select( + sm.Columns(apiTokenColumns...), + sm.From("api_tokens"), + sm.Where(psql.Quote("token_hash").EQ(psql.Arg(tokenHash))), + ) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return nil, err + } + + token := model.APIToken{} + if err := scanAPIToken(DB.QueryRow(ctx, queryString, args...), &token); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAPITokenNotFound + } + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return nil, err + } + + return &token, nil +} + +// ListAPITokens returns the tokens of a subject, or every token when subject is +// empty, which only an administrator may ask for. +func ListAPITokens(ctx context.Context, subject string) ([]model.APIToken, error) { + mods := []bob.Mod[*dialect.SelectQuery]{ + sm.Columns(apiTokenColumns...), + sm.From("api_tokens"), + sm.OrderBy("created_at").Desc(), + } + if subject != "" { + mods = append(mods, sm.Where(psql.Quote("subject").EQ(psql.Arg(subject)))) + } + + query := psql.Select(mods...) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return nil, err + } + + rows, err := DB.Query(ctx, queryString, args...) + if err != nil { + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return nil, err + } + defer rows.Close() + + tokens := []model.APIToken{} + for rows.Next() { + token := model.APIToken{} + if err := scanAPIToken(rows, &token); err != nil { + logrus.Errorf("parsing result: %s", err) + return nil, err + } + tokens = append(tokens, token) + } + + return tokens, rows.Err() +} + +// DeleteAPIToken removes a token. A non empty subject restricts the deletion to +// that subject's own tokens, so one identity cannot revoke another's. +func DeleteAPIToken(ctx context.Context, id uuid.UUID, subject string) error { + mods := []bob.Mod[*dialect.DeleteQuery]{ + dm.From("api_tokens"), + dm.Where(psql.Quote("id").EQ(psql.Arg(id))), + } + if subject != "" { + mods = append(mods, dm.Where(psql.Quote("subject").EQ(psql.Arg(subject)))) + } + + query := psql.Delete(mods...) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return err + } + + result, err := DB.Exec(ctx, queryString, args...) + if err != nil { + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return err + } + + if result.RowsAffected() == 0 { + return ErrAPITokenNotFound + } + + return nil +} + +// DeleteAPITokensBySubject removes every token of a subject, which is what +// offboarding an identity needs. +func DeleteAPITokensBySubject(ctx context.Context, subject string) (int64, error) { + query := psql.Delete( + dm.From("api_tokens"), + dm.Where(psql.Quote("subject").EQ(psql.Arg(subject))), + ) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return 0, err + } + + result, err := DB.Exec(ctx, queryString, args...) + if err != nil { + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return 0, err + } + + return result.RowsAffected(), nil +} + +// TouchAPIToken records that a token was just used. +// +// A failure here is not worth failing the request it belongs to: the timestamp is +// there to help somebody spot an unused or a leaked token, not to authorize. +func TouchAPIToken(ctx context.Context, id uuid.UUID) error { + query := psql.Update( + um.Table("api_tokens"), + um.SetCol("last_used_at").ToArg(time.Now()), + um.Where(psql.Quote("id").EQ(psql.Arg(id))), + ) + + queryString, args, err := query.Build(ctx) + if err != nil { + return err + } + + _, err = DB.Exec(ctx, queryString, args...) + return err +} + +// scanner is what pgx rows and single rows have in common. +type scanner interface { + Scan(dest ...any) error +} + +func scanAPIToken(row scanner, token *model.APIToken) error { + return row.Scan( + &token.ID, + &token.Name, + &token.Subject, + &token.Permission, + &token.Scopes, + &token.CreatedAt, + &token.LastUsedAt, + &token.ExpiresAt, + ) +} diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index a326c3b0..03994174 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -74,7 +74,7 @@ func TestDatabase(t *testing.T) { Result: result.SUCCESS, ID: "1de1797bbc925e08e473178425b11eb16fc547291f4b45274da24c2b00e2afc3", PipelineID: "venom", - }) + }, Publisher{}) require.NoError(t, err) t.Cleanup(func() { _, err := DB.Exec(ctx, "DELETE FROM pipelineReports WHERE id = $1", id) @@ -171,7 +171,7 @@ func TestDatabase(t *testing.T) { for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { - id, err := InsertReport(ctx, tt.report) + id, err := InsertReport(ctx, tt.report, Publisher{}) require.NoError(t, err) t.Cleanup(func() { _, err := DB.Exec(ctx, "DELETE FROM pipelineReports WHERE id = $1", id) @@ -214,7 +214,7 @@ func TestDatabase(t *testing.T) { } for range 3 { - id, err := InsertReport(ctx, report) + id, err := InsertReport(ctx, report, Publisher{}) require.NoError(t, err) t.Cleanup(func() { _, err := DB.Exec(ctx, "DELETE FROM pipelineReports WHERE id = $1", id) diff --git a/pkg/database/migration_test.go b/pkg/database/migration_test.go new file mode 100644 index 00000000..8f6602b5 --- /dev/null +++ b/pkg/database/migration_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "context" + "testing" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/stretchr/testify/require" + "github.com/updatecli/udash/test" +) + +// firstReversibleVersion is where this test starts rolling back from. +// +// Everything from here up must be reversible. Going further down does not work +// today: migration 000003 recreates its index with jsonb_path_ops while the +// column it migrates back to is json, so postgres rejects it. That predates the +// migrations this test covers and is left alone. +const firstReversibleVersion = 11 + +// TestMigrationsAreReversible walks the recent migrations down and back up. +// +// A migration which cannot be undone is only discovered when a rollback is +// needed, which is the worst moment to find out. +func TestMigrationsAreReversible(t *testing.T) { + ctx := context.Background() + + postgresContainer, err := test.SetupDatabase(t, ctx) + require.NoError(t, err) + + dbURL, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + require.NoError(t, Connect(Options{URI: dbURL})) + + source, err := iofs.New(fs, "migrations") + require.NoError(t, err) + + m, err := migrate.NewWithSourceInstance("iofs", source, URI) + require.NoError(t, err) + + require.NoError(t, m.Up()) + require.NoError(t, m.Migrate(firstReversibleVersion)) + require.NoError(t, m.Up()) + + // The tables the latest migrations add must be back. + for _, table := range []string{"api_tokens", "pipelinereports"} { + var exists bool + require.NoError(t, DB.QueryRow(ctx, + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = $1)", table, + ).Scan(&exists)) + require.True(t, exists, "table %q must exist after migrating back up", table) + } + + var count int + require.NoError(t, DB.QueryRow(ctx, + `SELECT count(*) FROM information_schema.columns + WHERE table_name = 'pipelinereports' + AND column_name IN ('created_by_subject', 'created_by_token_id')`, + ).Scan(&count)) + require.Equal(t, 2, count, "the attribution columns must exist after migrating back up") +} diff --git a/pkg/database/migrations/000012_create_api_tokens.down.sql b/pkg/database/migrations/000012_create_api_tokens.down.sql new file mode 100644 index 00000000..4972c423 --- /dev/null +++ b/pkg/database/migrations/000012_create_api_tokens.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_api_tokens_subject; +DROP TABLE IF EXISTS api_tokens; + +COMMIT; diff --git a/pkg/database/migrations/000012_create_api_tokens.up.sql b/pkg/database/migrations/000012_create_api_tokens.up.sql new file mode 100644 index 00000000..9610b734 --- /dev/null +++ b/pkg/database/migrations/000012_create_api_tokens.up.sql @@ -0,0 +1,32 @@ +BEGIN; + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS api_tokens( + id uuid DEFAULT uuid_generate_v4 (), + name VARCHAR NOT NULL, + -- Only the sha256 of the token is kept: the token itself is shown once, when + -- it is created, and can never be recovered from here. + token_hash BYTEA NOT NULL, + -- subject is the identity provider subject which created the token. + subject VARCHAR NOT NULL, + -- permission is what the creator could do when the token was issued. It bounds + -- the token when the current permission cannot be looked up. + permission VARCHAR NOT NULL, + scopes TEXT[] NOT NULL DEFAULT '{}', + -- These are TIMESTAMPTZ, unlike the older tables: expiry is compared against + -- the current instant, and a TIMESTAMP drops the offset on the way back out, + -- which moves a token's expiry by the server's UTC offset. + created_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + -- A NULL expiry means the token never expires, which is the point of it. + expires_at TIMESTAMPTZ, + CONSTRAINT api_tokens_pkey PRIMARY KEY (id), + CONSTRAINT api_tokens_token_hash_unique UNIQUE (token_hash) +); + +ALTER TABLE api_tokens ALTER COLUMN created_at SET DEFAULT now(); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_subject ON api_tokens (subject); + +COMMIT; diff --git a/pkg/database/migrations/000013_alter_pipelineReports_attribution.down.sql b/pkg/database/migrations/000013_alter_pipelineReports_attribution.down.sql new file mode 100644 index 00000000..69659d8a --- /dev/null +++ b/pkg/database/migrations/000013_alter_pipelineReports_attribution.down.sql @@ -0,0 +1,7 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_pipelinereports_created_by_subject; +ALTER TABLE pipelineReports DROP COLUMN IF EXISTS created_by_token_id; +ALTER TABLE pipelineReports DROP COLUMN IF EXISTS created_by_subject; + +COMMIT; diff --git a/pkg/database/migrations/000013_alter_pipelineReports_attribution.up.sql b/pkg/database/migrations/000013_alter_pipelineReports_attribution.up.sql new file mode 100644 index 00000000..ca7a5495 --- /dev/null +++ b/pkg/database/migrations/000013_alter_pipelineReports_attribution.up.sql @@ -0,0 +1,11 @@ +BEGIN; + +-- Who published a report. Both are nullable: reports published before this +-- migration have no attribution, and neither do reports published against an +-- instance running without authentication. +ALTER TABLE pipelineReports ADD COLUMN IF NOT EXISTS created_by_subject VARCHAR; +ALTER TABLE pipelineReports ADD COLUMN IF NOT EXISTS created_by_token_id uuid; + +CREATE INDEX IF NOT EXISTS idx_pipelinereports_created_by_subject ON pipelineReports (created_by_subject); + +COMMIT; diff --git a/pkg/database/report.go b/pkg/database/report.go index 23364bb5..9e416dd3 100644 --- a/pkg/database/report.go +++ b/pkg/database/report.go @@ -633,7 +633,18 @@ func nextBucket(t time.Time, granularity SummaryGranularity) time.Time { } // InsertReport inserts a new report into the database. -func InsertReport(ctx context.Context, report reports.Report) (string, error) { +// Publisher identifies who published a report. +// +// Both fields are optional: an instance running without authentication has nobody +// to attribute a report to, and a report published from the browser has no token. +type Publisher struct { + // Subject is the identity provider subject which published the report. + Subject *string + // TokenID is the API token used, when one was. + TokenID *uuid.UUID +} + +func InsertReport(ctx context.Context, report reports.Report, publisher Publisher) (string, error) { var err error configTargetIDs := pgtype.Hstore{} configConditionIDs := pgtype.Hstore{} @@ -803,6 +814,8 @@ func InsertReport(ctx context.Context, report reports.Report) (string, error) { "config_condition_ids", "config_target_ids", "label_ids", + "created_by_subject", + "created_by_token_id", ), im.Values( psql.Arg(report), @@ -814,6 +827,8 @@ func InsertReport(ctx context.Context, report reports.Report) (string, error) { psql.Arg(configConditionIDs), psql.Arg(configTargetIDs), psql.Arg(labelIDs), + psql.Arg(publisher.Subject), + psql.Arg(publisher.TokenID), ), im.Returning("id"), ) diff --git a/pkg/model/apitoken.go b/pkg/model/apitoken.go new file mode 100644 index 00000000..7ec9dcb5 --- /dev/null +++ b/pkg/model/apitoken.go @@ -0,0 +1,29 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// APIToken is a long lived credential Udash issues and validates itself. +// +// It exists because an identity provider access token always expires, while a CI +// pipeline needs a credential it can keep for as long as it runs unattended. +type APIToken struct { + ID uuid.UUID `json:"id"` + // Name is what the token is for, chosen by whoever created it. + Name string `json:"name"` + // Subject is the identity provider subject which created the token. + Subject string `json:"subject"` + // Permission is what that identity could do when the token was issued. + Permission string `json:"permission"` + // Scopes is what the token may do, always a subset of what Permission allows. + Scopes []string `json:"scopes"` + // CreatedAt is when the token was issued. + CreatedAt time.Time `json:"created_at"` + // LastUsedAt is when the token last authenticated a request, if ever. + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + // ExpiresAt is when the token stops working. Nil means it never expires. + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} diff --git a/pkg/server/endpoints.go b/pkg/server/endpoints.go index 0a0e0755..bc0b8955 100644 --- a/pkg/server/endpoints.go +++ b/pkg/server/endpoints.go @@ -2,10 +2,8 @@ package server import ( "context" - "log/slog" + "fmt" "net/http" - "os" - "strings" _ "github.com/updatecli/udash/docs" "github.com/zitadel/zitadel-go/v3/pkg/authorization" @@ -78,12 +76,20 @@ func About(c *gin.Context) { // @title Udash API // @version 1.0 // @description API for managing Updatecli pipeline reports. -// @BasePath /api/ +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Either an Udash API token, created from the tokens page and prefixed with "udash_pat_", or an access token from the configured identity provider. Send it as "Bearer ". func (s *Server) Run() error { // Init Server Option - s.Options.Init() + if err := s.Options.Init(); err != nil { + return fmt.Errorf("invalid server options: %w", err) + } - r := newGinEngine(s.Options) + r, err := newGinEngine(s.Options) + if err != nil { + return err + } // listen and server on 0.0.0.0:8080 return r.Run() @@ -106,22 +112,7 @@ func publicReadOnly(auth gin.HandlerFunc) gin.HandlerFunc { } } -// zitadelAuthorization requires a valid token, and the configured role when there is one. -// -// An empty role must not be passed to authorization.WithRole: it checks the token against -// a role which is granted to nobody, so it rejects every request instead of accepting any -// authenticated one. -func zitadelAuthorization[T authorization.Ctx](interceptor *Interceptor[T], role string) gin.HandlerFunc { - if role == "" { - logrus.Debugf("No role required to access the API") - return interceptor.RequireAuthorization() - } - - logrus.Debugf("Requiring role %q to access the API", role) - return interceptor.RequireAuthorization(authorization.WithRole(role)) -} - -func newGinEngine(opts Options) *gin.Engine { +func newGinEngine(opts Options) (*gin.Engine, error) { r := gin.Default() r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) @@ -132,39 +123,69 @@ func newGinEngine(opts Options) *gin.Engine { apiPipeline := r.Group("/api/pipeline") - switch strings.ToLower(opts.Auth.Mode) { - case "oauth": - logrus.Debugf("Using OAuth authentication mode: %s", opts.Auth.Mode) + // auth authenticates a request against the identity provider. It stays nil when + // no authentication is configured. + var auth gin.HandlerFunc + // resolver reports the current permission behind an Udash API token. + var resolver RoleResolver = snapshotResolver{} + + ctx := context.Background() + + switch opts.Auth.Mode { + case ModeOIDC: + logrus.Debugf("Using OpenID Connect authentication mode") // Built once: the middleware caches the signing keys of the issuer, so building // it per request would refetch them on every call. - auth, err := checkJWT() + checked, err := checkJWT(opts.Auth) if err != nil { - slog.Error("jwt middleware could not initialize", "error", err) - os.Exit(1) + return nil, fmt.Errorf("jwt middleware could not initialize: %w", err) } + auth = checked - switch opts.Auth.Visibility { - case VisibilityPublic: - logrus.Debugf("API visibility set to public, no authentication required for read endpoints") - apiPipeline.Use(publicReadOnly(auth)) - case VisibilityPrivate: - logrus.Debugf("API visibility set to private, authentication required for all endpoints") - apiPipeline.Use(auth) + resolver, err = newRoleResolver(opts.Auth, nil) + if err != nil { + return nil, fmt.Errorf("role resolver could not initialize: %w", err) } - case "zitadel": - logrus.Debugf("Using ZITADEL authentication mode: %s", opts.Auth.Mode) - ctx := context.Background() + case ModeZitadel: + logrus.Debugf("Using ZITADEL authentication mode") authZ, err := authorization.New(ctx, zitadel.New(opts.Auth.Zitadel.Domain), oauth.DefaultAuthorization(opts.Auth.Zitadel.KeyFile)) if err != nil { - slog.Error("zitadel sdk could not initialize", "error", err) - os.Exit(1) + return nil, fmt.Errorf("zitadel sdk could not initialize: %w", err) + } + + zitadelInterceptor := NewZitadelGin(authZ, opts.Auth.Roles) + auth = zitadelInterceptor.RequireAuthorization() + + var roles zitadelUserRoles + if opts.Auth.Roles.Resolver == ResolverZitadel { + roles, err = newZitadelUserRoles(ctx, opts.Auth.Zitadel) + if err != nil { + return nil, fmt.Errorf("zitadel management client could not initialize: %w", err) + } + } + + resolver, err = newRoleResolver(opts.Auth, roles) + if err != nil { + return nil, fmt.Errorf("role resolver could not initialize: %w", err) } - zitadelInterceptor := NewZitadelGin(authZ) - auth := zitadelAuthorization(zitadelInterceptor, opts.Auth.Zitadel.Role) + case ModeNone, "": + logrus.Warningf("No authentication configured, every API endpoint is open") + + default: + // Never fail open: an unrecognised mode used to register no middleware at + // all, silently leaving every write endpoint unauthenticated. + return nil, fmt.Errorf("unknown authentication mode %q", opts.Auth.Mode) + } + + if auth != nil { + // An Udash API token is checked first and independently of the mode: Udash + // issues and validates those itself, so they work the same whichever + // identity provider is configured. + auth = udashTokenAuth(resolver, auth) switch opts.Auth.Visibility { case VisibilityPublic: @@ -174,6 +195,8 @@ func newGinEngine(opts Options) *gin.Engine { logrus.Debugf("API visibility set to private, authentication required for all endpoints") apiPipeline.Use(auth) } + + registerTokenRoutes(r, auth) } apiPipeline.GET("/labels", ListLabels) @@ -204,9 +227,16 @@ func newGinEngine(opts Options) *gin.Engine { apiPipeline.POST("/scms/search", SearchSCMs) } - apiPipeline.POST("/reports", CreatePipelineReport) - apiPipeline.PUT("/reports/:id", UpdatePipelineReport) - apiPipeline.DELETE("/reports/:id", DeletePipelineReport) + // Writing a report needs more than a valid token: the caller must be allowed to + // publish, and a token must have been granted the scope to do it. + write := []gin.HandlerFunc{} + if auth != nil { + write = append(write, requireScope(ScopeReportsWrite)) + } + + apiPipeline.POST("/reports", append(write, CreatePipelineReport)...) + apiPipeline.PUT("/reports/:id", append(write, UpdatePipelineReport)...) + apiPipeline.DELETE("/reports/:id", append(write, DeletePipelineReport)...) - return r + return r, nil } diff --git a/pkg/server/endpoints_test.go b/pkg/server/endpoints_test.go index a2375f41..49f13513 100644 --- a/pkg/server/endpoints_test.go +++ b/pkg/server/endpoints_test.go @@ -28,7 +28,8 @@ import ( ) func TestEndpoints(t *testing.T) { - eng := newGinEngine(Options{}) + eng, err := newGinEngine(Options{}) + require.NoError(t, err) srv := httptest.NewServer(eng) defer srv.Close() @@ -142,7 +143,7 @@ func TestEndpoints(t *testing.T) { ID: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", }, }, - }) + }, database.Publisher{}) require.NoError(t, err) resp := doGetRequest(t, srv, "/api/pipeline/reports") @@ -191,7 +192,7 @@ func TestEndpoints(t *testing.T) { ID: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", }, }, - }) + }, database.Publisher{}) require.NoError(t, err) report2ID, err = database.InsertReport(context.TODO(), reports.Report{ @@ -204,7 +205,7 @@ func TestEndpoints(t *testing.T) { ID: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", }, }, - }) + }, database.Publisher{}) require.NoError(t, err) resp := doGetRequest(t, srv, "/api/pipeline/reports?limit=1") @@ -257,7 +258,7 @@ func TestEndpoints(t *testing.T) { ID: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", }, }, - }) + }, database.Publisher{}) require.NoError(t, err) resp := doGetRequest(t, srv, "/api/pipeline/reports/"+reportID) @@ -518,7 +519,7 @@ func TestEndpoints(t *testing.T) { Result: pipelineResult, ID: "1de1797bbc925e08e473178425b11eb16fc547291f4b45274da24c2b00e2afc3", PipelineID: "venom", - }) + }, database.Publisher{}) require.NoError(t, err) setReportTimestamp(t, id, now.AddDate(0, 0, dayOffset)) @@ -819,7 +820,7 @@ func TestEndpoints(t *testing.T) { Result: pipelineResult, ID: "1de1797bbc925e08e473178425b11eb16fc547291f4b45274da24c2b00e2afc3", PipelineID: "venom", - }) + }, database.Publisher{}) require.NoError(t, err) setReportTimestamp(t, id, at) @@ -896,7 +897,7 @@ func TestEndpoints(t *testing.T) { Actions: map[string]*reports.Action{ "default": {ID: "default", Link: actionURL}, }, - }) + }, database.Publisher{}) require.NoError(t, err) return id @@ -1099,7 +1100,7 @@ func TestEndpoints(t *testing.T) { for range 3 { id, err := database.InsertReport(ctx, reports.Report{ Name: "paginated", Result: "✔", ID: "paginated", PipelineID: "paginated", - }) + }, database.Publisher{}) require.NoError(t, err) t.Cleanup(func() { deleteReport(t, id) @@ -1170,7 +1171,7 @@ func TestEndpoints(t *testing.T) { Targets: map[string]*result.Target{ "tgt": {Config: map[string]any{"Kind": "file", "Spec": map[string]any{"file": "combined.txt"}}}, }, - }) + }, database.Publisher{}) require.NoError(t, err) t.Cleanup(func() { deleteReport(t, reportID) @@ -1229,7 +1230,7 @@ func TestEndpoints(t *testing.T) { reportID, err := database.InsertReport(ctx, reports.Report{ Name: "timerange", Result: "✔", ID: "timerange", PipelineID: "timerange", - }) + }, database.Publisher{}) require.NoError(t, err) t.Cleanup(func() { deleteReport(t, reportID) diff --git a/pkg/server/identity.go b/pkg/server/identity.go new file mode 100644 index 00000000..9d36a368 --- /dev/null +++ b/pkg/server/identity.go @@ -0,0 +1,217 @@ +package server + +import ( + "net/http" + "slices" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/updatecli/udash/pkg/database" +) + +// Permission is what an identity is allowed to do in Udash. +type Permission string + +const ( + // PermissionNone is granted to an unauthenticated request. + PermissionNone Permission = "" + // PermissionViewer may read pipeline reports. + PermissionViewer Permission = "viewer" + // PermissionPublisher may publish pipeline reports and create API tokens. + PermissionPublisher Permission = "publisher" + // PermissionAdmin may do anything, including managing other identities' tokens. + PermissionAdmin Permission = "admin" +) + +const ( + // ScopeReportsRead allows a token to read pipeline reports. + ScopeReportsRead = "reports:read" + // ScopeReportsWrite allows a token to publish pipeline reports. + ScopeReportsWrite = "reports:write" +) + +// principalContextKey is where the authenticated identity is stored on the request. +const principalContextKey = "udash.principal" + +// ParsePermission turns a configured string into a Permission. An unknown value +// yields PermissionNone, which IsValid rejects. +func ParsePermission(s string) Permission { + switch Permission(s) { + case PermissionViewer: + return PermissionViewer + case PermissionPublisher: + return PermissionPublisher + case PermissionAdmin: + return PermissionAdmin + } + return PermissionNone +} + +// IsValid reports whether the permission is one Udash knows about. +func (p Permission) IsValid() bool { + return p == PermissionViewer || p == PermissionPublisher || p == PermissionAdmin +} + +// rank orders permissions so they can be compared. Higher is more privileged. +func (p Permission) rank() int { + switch p { + case PermissionAdmin: + return 3 + case PermissionPublisher: + return 2 + case PermissionViewer: + return 1 + } + return 0 +} + +// AtLeast reports whether p grants everything other does. +func (p Permission) AtLeast(other Permission) bool { + return p.rank() >= other.rank() +} + +// Scopes returns the token scopes this permission is allowed to hand out. A token +// can never be granted more than the identity which created it, and never gets to +// manage tokens: a token must not be able to mint another one. +func (p Permission) Scopes() []string { + switch { + case p.AtLeast(PermissionPublisher): + return []string{ScopeReportsRead, ScopeReportsWrite} + case p.AtLeast(PermissionViewer): + return []string{ScopeReportsRead} + } + return nil +} + +// Principal is the identity behind a request. +type Principal struct { + // Subject is the identity provider subject. + Subject string + // Name is a human readable name for that identity, when the provider gives one. + Name string + // Permission is what that identity may do, after intersecting the identity + // provider roles with the scopes of the token in use. + Permission Permission + // TokenID is set only when the request authenticated with an Udash API token. + TokenID *uuid.UUID + // TokenName is the name of that token. + TokenName string + // Scopes is what that token may do. It is nil for an identity provider token, + // which is bounded by its Permission alone. + Scopes []string +} + +// IsToken reports whether the request authenticated with an Udash API token rather +// than with an identity provider token. +func (p Principal) IsToken() bool { + return p.TokenID != nil +} + +// HasScope reports whether the principal may perform the given action. +// +// An identity provider token carries no scopes, so it is bounded by its permission +// only: anything a publisher may do, it may do. +func (p Principal) HasScope(scope string) bool { + if !p.IsToken() { + switch scope { + case ScopeReportsWrite: + return p.Permission.AtLeast(PermissionPublisher) + case ScopeReportsRead: + return p.Permission.AtLeast(PermissionViewer) + } + return false + } + + return slices.Contains(p.Scopes, scope) +} + +// setPrincipal records the authenticated identity on the request. +func setPrincipal(c *gin.Context, p Principal) { + c.Set(principalContextKey, p) +} + +// principalFromContext returns the authenticated identity behind the request, if any. +func principalFromContext(c *gin.Context) (Principal, bool) { + value, ok := c.Get(principalContextKey) + if !ok { + return Principal{}, false + } + + principal, ok := value.(Principal) + return principal, ok +} + +// publisherFromContext describes who is publishing, for attribution. +// +// It yields an empty Publisher when the request is unauthenticated, which is the +// normal case on an instance running without authentication. +func publisherFromContext(c *gin.Context) database.Publisher { + principal, ok := principalFromContext(c) + if !ok || principal.Subject == "" { + return database.Publisher{} + } + + subject := principal.Subject + + return database.Publisher{ + Subject: &subject, + TokenID: principal.TokenID, + } +} + +// requirePermission aborts the request unless the caller is at least as privileged +// as the given permission. +func requirePermission(permission Permission) gin.HandlerFunc { + return func(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + if !principal.Permission.AtLeast(permission) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{errMessageType: ErrInsufficientPermission}) + return + } + + c.Next() + } +} + +// requireScope aborts the request unless the caller may perform the given action. +func requireScope(scope string) gin.HandlerFunc { + return func(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + if !principal.HasScope(scope) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{errMessageType: ErrInsufficientScope}) + return + } + + c.Next() + } +} + +// rejectTokenAuth aborts the request when it authenticated with an Udash API token. +// Minting a token must require an identity provider login, otherwise a leaked token +// could be used to issue fresh ones and outlive its own revocation. +func rejectTokenAuth() gin.HandlerFunc { + return func(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + if principal.IsToken() { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{errMessageType: ErrTokenCannotMintToken}) + return + } + + c.Next() + } +} diff --git a/pkg/server/jwt.go b/pkg/server/jwt.go index fb90550c..a521ee70 100644 --- a/pkg/server/jwt.go +++ b/pkg/server/jwt.go @@ -15,16 +15,11 @@ import ( "github.com/sirupsen/logrus" ) -var ( - // We want this struct to be filled in with - // our custom claims from the token. - customClaims = func() validator.CustomClaims { - return &CustomClaims{} - } - - // jwtOptions holds the JWT options - authOption = AuthOptions{} -) +// We want this struct to be filled in with +// our custom claims from the token. +var customClaims = func() validator.CustomClaims { + return &CustomClaims{} +} // parseIssuerURL turns a configured issuer into a URL, accepting it either as a bare host // ("example.eu.auth0.com") or as a full URL ("https://example.eu.auth0.com"). https is @@ -63,9 +58,9 @@ func parseIssuerURL(issuer string) (*url.URL, error) { // // A setup failure is reported rather than logged: carrying on would leave a nil validator // behind, which panics on the first request it is asked to authenticate. -func checkJWT() (gin.HandlerFunc, error) { +func checkJWT(opts AuthOptions) (gin.HandlerFunc, error) { - issuerURL, err := parseIssuerURL(authOption.Oauth.Issuer) + issuerURL, err := parseIssuerURL(opts.OIDC.Issuer) if err != nil { return nil, fmt.Errorf("parsing the issuer url: %w", err) } @@ -76,7 +71,7 @@ func checkJWT() (gin.HandlerFunc, error) { provider.KeyFunc, validator.RS256, issuerURL.String(), - authOption.Oauth.Audience, + opts.OIDC.Audience, validator.WithCustomClaims(customClaims), validator.WithAllowedClockSkew(30*time.Second), ) @@ -99,6 +94,7 @@ func checkJWT() (gin.HandlerFunc, error) { var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { encounteredError = false ctx.Request = r + setPrincipal(ctx, principalFromValidatedClaims(r, opts.Roles)) ctx.Next() } @@ -112,3 +108,30 @@ func checkJWT() (gin.HandlerFunc, error) { } }, nil } + +// principalFromValidatedClaims turns the claims the middleware validated into the +// identity the handlers work with. +func principalFromValidatedClaims(r *http.Request, roles RolesOptions) Principal { + validated, ok := r.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims) + if !ok || validated == nil { + return Principal{Permission: ParsePermission(roles.Default)} + } + + principal := Principal{ + Subject: validated.RegisteredClaims.Subject, + Permission: ParsePermission(roles.Default), + } + + claims, ok := validated.CustomClaims.(*CustomClaims) + if !ok || claims == nil { + return principal + } + + principal.Name = claims.Name + if principal.Name == "" { + principal.Name = claims.Username + } + principal.Permission = permissionFromRoles(rolesFromClaims(claims.All, roles.Claim), roles) + + return principal +} diff --git a/pkg/server/jwtClaim.go b/pkg/server/jwtClaim.go index 15c0e244..929e9f77 100644 --- a/pkg/server/jwtClaim.go +++ b/pkg/server/jwtClaim.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "errors" ) @@ -10,6 +11,27 @@ type CustomClaims struct { Name string `json:"name"` Username string `json:"username"` ShouldReject bool `json:"shouldReject,omitempty"` + + // All keeps every claim of the token, including the ones above. + // + // Which claim carries the identity provider roles is configuration, not + // something that can be named in a struct tag: Zitadel, Keycloak and Auth0 + // each use a different one. See RolesOptions.Claim. + All map[string]interface{} `json:"-"` +} + +// UnmarshalJSON decodes the named claims and keeps the raw ones alongside. +func (c *CustomClaims) UnmarshalJSON(data []byte) error { + // A local type avoids recursing back into this method. + type claims CustomClaims + + decoded := claims{} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *c = CustomClaims(decoded) + + return json.Unmarshal(data, &c.All) } // Validate errors out if `ShouldReject` is true. diff --git a/pkg/server/option.go b/pkg/server/option.go index 8006c788..090a054e 100644 --- a/pkg/server/option.go +++ b/pkg/server/option.go @@ -5,6 +5,7 @@ type Options struct { Auth AuthOptions } -func (o *Options) Init() { - o.Auth.Init() +// Init fills in the defaults and reports what it cannot make sense of. +func (o *Options) Init() error { + return o.Auth.Init() } diff --git a/pkg/server/optionAuth.go b/pkg/server/optionAuth.go new file mode 100644 index 00000000..b2793382 --- /dev/null +++ b/pkg/server/optionAuth.go @@ -0,0 +1,225 @@ +package server + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +const ( + // VisibilityPublic indicates a public API + VisibilityPublic string = "public" + // VisibilityPrivate indicates a private API + VisibilityPrivate string = "private" + // visibilityDefault indicate Default visibility + VisibilityDefault = VisibilityPublic + // ModeZitadel indicates Zitadel authentication, validating tokens by introspection + ModeZitadel = "zitadel" + // ModeOIDC indicates generic OpenID Connect authentication, validating JWT + // access tokens locally against the issuer signing keys + ModeOIDC = "oidc" + // ModeNone indicates no authentication + ModeNone = "none" + + // DefaultRoleCacheTTL is how long a permission resolved from the identity + // provider is reused before being looked up again. + DefaultRoleCacheTTL = 60 * time.Second + + // ZitadelRolesClaim is the token claim Zitadel puts the project roles in. + ZitadelRolesClaim = "urn:zitadel:iam:org:project:roles" + + // ResolverZitadel resolves the permission behind an Udash API token by asking + // Zitadel for the current grants of the identity which created it. + ResolverZitadel = "zitadel" + // ResolverSnapshot trusts the permission recorded when the token was created. + ResolverSnapshot = "snapshot" +) + +// AuthOptions holds every authentication and authorization setting. +type AuthOptions struct { + // Mode selects how incoming tokens are validated. + // Accepted values are: "oidc", "zitadel", "none" + // Default to "none" + Mode string + // Zitadel holds Zitadel specific options + Zitadel ZitadelOptions + // OIDC holds generic OpenID Connect options + OIDC OIDCOptions + // Roles maps identity provider roles onto Udash permissions + Roles RolesOptions + // Visibility defines the visibility of the API + // Accepted values are: "public", "private" + // Default to "public" + Visibility string +} + +// ZitadelOptions defines Zitadel specific options +// for authentication +type ZitadelOptions struct { + // Domain is the Zitadel domain + // example: xxx.region.zitadel.cloud + Domain string + // KeyFile is the path to the service account key file + // example: /path/to/key.json + KeyFile string +} + +// OIDCOptions defines the settings of the generic OpenID Connect mode. It works +// with any provider issuing JWT access tokens, Zitadel included. +type OIDCOptions struct { + // The issuer of our token. + Issuer string + // The audience of our token. + Audience []string +} + +// RolesOptions describes how the roles carried by a token become Udash permissions. +type RolesOptions struct { + // Claim is the token claim holding the identity provider roles. Providers + // disagree both on the name and on the shape: Zitadel uses an object keyed by + // role name, Keycloak and Auth0 use an array of strings. Both are accepted. + Claim string + // Mapping lists, per Udash permission, the identity provider roles granting it. + Mapping map[string][]string + // Default is the permission granted to an authenticated identity matching no + // role at all. It deliberately defaults to the least privileged one. + Default string + // Resolver decides how the permission behind an Udash API token is resolved, + // since such a request carries no identity provider token to read roles from. + Resolver string + // CacheTTL is how long a resolved permission is reused before being looked up + // again. Without it a publish heavy pipeline would query the identity provider + // on every single report. + CacheTTL time.Duration +} + +// Init fills in the defaults and the environment variable fallbacks, and reports +// what it cannot make sense of. +// +// An error here must stop the server: carrying on with an unusable configuration +// leaves the API unauthenticated, which is the opposite of what was asked for. +func (a *AuthOptions) Init() error { + + if a.Mode == "" { + a.Mode = os.Getenv("UDASH_AUTH_MODE") + } + a.Mode = strings.ToLower(a.Mode) + + switch a.Visibility { + case VisibilityPublic: + logrus.Debugf("API visibility set to public") + case VisibilityPrivate: + logrus.Debugf("API visibility set to private") + case "": + logrus.Debugf("No API visibility set, defaulting to %q", VisibilityDefault) + a.Visibility = VisibilityDefault + default: + return fmt.Errorf("unknown API visibility %q, accepted values are: %q, %q", + a.Visibility, VisibilityPublic, VisibilityPrivate) + } + + switch a.Mode { + case ModeZitadel: + if a.Zitadel.Domain == "" { + a.Zitadel.Domain = os.Getenv("UDASH_AUTH_ZITADEL_DOMAIN") + } + if a.Zitadel.KeyFile == "" { + a.Zitadel.KeyFile = os.Getenv("UDASH_AUTH_ZITADEL_KEYFILE") + } + if a.Zitadel.Domain == "" { + return fmt.Errorf("authentication mode %q requires a Zitadel domain", ModeZitadel) + } + if a.Zitadel.KeyFile == "" { + return fmt.Errorf("authentication mode %q requires a Zitadel key file", ModeZitadel) + } + case ModeOIDC: + if a.OIDC.Issuer == "" { + a.OIDC.Issuer = os.Getenv("UDASH_AUTH_OIDC_ISSUER") + } + if len(a.OIDC.Audience) == 0 { + if audience := os.Getenv("UDASH_AUTH_OIDC_AUDIENCE"); audience != "" { + a.OIDC.Audience = []string{audience} + } + } + if a.OIDC.Issuer == "" { + return fmt.Errorf("authentication mode %q requires an issuer", ModeOIDC) + } + case ModeNone, "": + a.Mode = ModeNone + logrus.Warningf("No authentication configured, every API endpoint is open") + default: + return fmt.Errorf("unknown authentication mode %q, accepted values are: %q, %q, %q", + a.Mode, ModeOIDC, ModeZitadel, ModeNone) + } + + return a.Roles.init(a.Mode) +} + +func (r *RolesOptions) init(mode string) error { + if r.Claim == "" { + r.Claim = os.Getenv("UDASH_AUTH_ROLES_CLAIM") + } + if r.Default == "" { + r.Default = os.Getenv("UDASH_AUTH_ROLES_DEFAULT") + } + if r.Resolver == "" { + r.Resolver = os.Getenv("UDASH_AUTH_ROLES_RESOLVER") + } + + if r.Claim == "" && mode == ModeZitadel { + r.Claim = ZitadelRolesClaim + } + + if len(r.Mapping) == 0 { + r.Mapping = map[string][]string{ + string(PermissionAdmin): {"udash.admin"}, + string(PermissionPublisher): {"udash.publisher"}, + string(PermissionViewer): {"udash.viewer"}, + } + } + + for permission := range r.Mapping { + if !ParsePermission(permission).IsValid() { + return fmt.Errorf("unknown permission %q in the role mapping, accepted values are: %q, %q, %q", + permission, PermissionViewer, PermissionPublisher, PermissionAdmin) + } + } + + if r.Default == "" { + r.Default = string(PermissionViewer) + } + if !ParsePermission(r.Default).IsValid() { + return fmt.Errorf("unknown default permission %q, accepted values are: %q, %q, %q", + r.Default, PermissionViewer, PermissionPublisher, PermissionAdmin) + } + + if r.Resolver == "" { + r.Resolver = ResolverSnapshot + if mode == ModeZitadel { + r.Resolver = ResolverZitadel + } + } + switch r.Resolver { + case ResolverZitadel: + if mode != ModeZitadel { + return fmt.Errorf("role resolver %q requires the %q authentication mode", ResolverZitadel, ModeZitadel) + } + case ResolverSnapshot: + default: + return fmt.Errorf("unknown role resolver %q, accepted values are: %q, %q", + r.Resolver, ResolverZitadel, ResolverSnapshot) + } + + if r.CacheTTL == 0 { + r.CacheTTL = DefaultRoleCacheTTL + } + + if r.Claim == "" && mode != ModeNone { + logrus.Warningf("No role claim configured, every authenticated identity gets the %q permission", r.Default) + } + + return nil +} diff --git a/pkg/server/optionAuth_test.go b/pkg/server/optionAuth_test.go new file mode 100644 index 00000000..6c1b1715 --- /dev/null +++ b/pkg/server/optionAuth_test.go @@ -0,0 +1,138 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthOptionsInit(t *testing.T) { + t.Run("no mode defaults to none and public", func(t *testing.T) { + opts := AuthOptions{} + require.NoError(t, opts.Init()) + + assert.Equal(t, ModeNone, opts.Mode) + assert.Equal(t, VisibilityPublic, opts.Visibility) + assert.Equal(t, string(PermissionViewer), opts.Roles.Default) + assert.Equal(t, ResolverSnapshot, opts.Roles.Resolver) + assert.Equal(t, DefaultRoleCacheTTL, opts.Roles.CacheTTL) + }) + + t.Run("an unknown mode is rejected", func(t *testing.T) { + // Regression: an unrecognised mode used to be logged and then ignored, + // registering no middleware at all and leaving every write endpoint open. + opts := AuthOptions{Mode: "zitadelx"} + require.ErrorContains(t, opts.Init(), "unknown authentication mode") + }) + + t.Run("an unknown visibility is rejected", func(t *testing.T) { + opts := AuthOptions{Mode: ModeNone, Visibility: "sometimes"} + require.ErrorContains(t, opts.Init(), "unknown API visibility") + }) + + t.Run("the mode is case insensitive", func(t *testing.T) { + opts := AuthOptions{Mode: "OIDC", OIDC: OIDCOptions{Issuer: "https://example.com"}} + require.NoError(t, opts.Init()) + assert.Equal(t, ModeOIDC, opts.Mode) + }) + + t.Run("oidc requires an issuer", func(t *testing.T) { + opts := AuthOptions{Mode: ModeOIDC} + require.ErrorContains(t, opts.Init(), "requires an issuer") + }) + + t.Run("zitadel requires a domain and a key file", func(t *testing.T) { + require.ErrorContains(t, (&AuthOptions{Mode: ModeZitadel}).Init(), "requires a Zitadel domain") + + opts := AuthOptions{Mode: ModeZitadel, Zitadel: ZitadelOptions{Domain: "example.zitadel.cloud"}} + require.ErrorContains(t, opts.Init(), "requires a Zitadel key file") + }) + + t.Run("zitadel defaults the claim and the resolver", func(t *testing.T) { + opts := AuthOptions{ + Mode: ModeZitadel, + Zitadel: ZitadelOptions{Domain: "example.zitadel.cloud", KeyFile: "/tmp/key.json"}, + } + require.NoError(t, opts.Init()) + + assert.Equal(t, ZitadelRolesClaim, opts.Roles.Claim) + assert.Equal(t, ResolverZitadel, opts.Roles.Resolver) + }) + + t.Run("the zitadel resolver needs the zitadel mode", func(t *testing.T) { + opts := AuthOptions{ + Mode: ModeOIDC, + OIDC: OIDCOptions{Issuer: "https://example.com"}, + Roles: RolesOptions{Resolver: ResolverZitadel}, + } + require.ErrorContains(t, opts.Init(), "requires the \"zitadel\" authentication mode") + }) + + t.Run("an unknown permission in the mapping is rejected", func(t *testing.T) { + opts := AuthOptions{ + Mode: ModeNone, + Roles: RolesOptions{Mapping: map[string][]string{"superuser": {"udash.superuser"}}}, + } + require.ErrorContains(t, opts.Init(), "unknown permission") + }) + + t.Run("an unknown default permission is rejected", func(t *testing.T) { + opts := AuthOptions{Mode: ModeNone, Roles: RolesOptions{Default: "superuser"}} + require.ErrorContains(t, opts.Init(), "unknown default permission") + }) + + t.Run("environment variables are used as fallbacks", func(t *testing.T) { + t.Setenv("UDASH_AUTH_MODE", ModeOIDC) + t.Setenv("UDASH_AUTH_OIDC_ISSUER", "https://example.com") + t.Setenv("UDASH_AUTH_OIDC_AUDIENCE", "udash") + t.Setenv("UDASH_AUTH_ROLES_CLAIM", "realm_access.roles") + t.Setenv("UDASH_AUTH_ROLES_DEFAULT", string(PermissionPublisher)) + + opts := AuthOptions{} + require.NoError(t, opts.Init()) + + assert.Equal(t, ModeOIDC, opts.Mode) + assert.Equal(t, "https://example.com", opts.OIDC.Issuer) + assert.Equal(t, []string{"udash"}, opts.OIDC.Audience) + assert.Equal(t, "realm_access.roles", opts.Roles.Claim) + assert.Equal(t, string(PermissionPublisher), opts.Roles.Default) + }) + + t.Run("explicit values win over the environment", func(t *testing.T) { + t.Setenv("UDASH_AUTH_MODE", ModeZitadel) + t.Setenv("UDASH_AUTH_OIDC_ISSUER", "https://from-env.example.com") + + opts := AuthOptions{ + Mode: ModeOIDC, + OIDC: OIDCOptions{Issuer: "https://explicit.example.com"}, + Roles: RolesOptions{CacheTTL: 5 * time.Second}, + } + require.NoError(t, opts.Init()) + + assert.Equal(t, ModeOIDC, opts.Mode) + assert.Equal(t, "https://explicit.example.com", opts.OIDC.Issuer) + assert.Equal(t, 5*time.Second, opts.Roles.CacheTTL) + }) +} + +func TestNewGinEngineFailsClosed(t *testing.T) { + // An unusable configuration must stop the server rather than quietly serve an + // unauthenticated API. + _, err := newGinEngine(Options{Auth: AuthOptions{Mode: "zitadelx"}}) + require.ErrorContains(t, err, "unknown authentication mode") +} + +func TestNewGinEngineWithoutAuth(t *testing.T) { + // The default deployment has no authentication at all and must keep working. + engine, err := newGinEngine(Options{}) + require.NoError(t, err) + require.NotNil(t, engine) + + for _, route := range engine.Routes() { + assert.NotEqual(t, "/api/tokens", route.Path, + "the token endpoints must not exist when nobody can be authenticated") + assert.NotEqual(t, "/api/whoami", route.Path) + } +} diff --git a/pkg/server/optionOauth.go b/pkg/server/optionOauth.go deleted file mode 100644 index 7da752f9..00000000 --- a/pkg/server/optionOauth.go +++ /dev/null @@ -1,108 +0,0 @@ -package server - -import ( - "os" - - "github.com/sirupsen/logrus" -) - -const ( - // VisibilityPublic indicates a public API - VisibilityPublic string = "public" - // VisibilityPrivate indicates a private API - VisibilityPrivate string = "private" - // visibilityDefault indicate Default visibility - VisibilityDefault = VisibilityPublic - // ModeZitadel indicates Zitadel authentication - ModeZitadel = "zitadel" - // ModeOauth indicates Oauth authentication - ModeOauth = "oauth" - // ModeNone indicates no authentication - ModeNone = "none" -) - -/* - Code heavily inspired by https://github.com/auth0/go-jwt-middleware/tree/v2.1.0/examples/gin-example -*/ - -type AuthOptions struct { - // Mode enable auth0 authentication - // Accepted values are: "auth0", "zitadel", "none" - // Default to "none" - Mode string - // Zitadel holds Zitadel specific options - Zitadel ZitadelOptions - // Oauth holds Oauth specific options - Oauth OauthOptions - // Visibility defines the visibility of the API - // Accepted values are: "public", "private" - // Default to "public" - Visibility string -} - -// ZitadelOptions defines Zitadel specific options -// for authentication -type ZitadelOptions struct { - // Domain is the Zitadel domain - // example: xxx.region.zitadel.cloud - Domain string - // KeyFile is the path to the service account key file - // example: /path/to/key.json - KeyFile string - // Role is the required role to access the API - Role string -} - -type OauthOptions struct { - // The issuer of our token. - Issuer string - // The audience of our token. - Audience []string -} - -func (a *AuthOptions) Init() { - - if a.Mode == "" { - a.Mode = os.Getenv("UDASH_AUTH_MODE") - } - - switch a.Visibility { - case VisibilityPublic: - logrus.Debugf("API visibility set to public") - case VisibilityPrivate: - logrus.Debugf("API visibility set to private") - case "": - logrus.Debugf("No API visibility set, defaulting to %q", VisibilityDefault) - a.Visibility = VisibilityDefault - default: - logrus.Errorf("Unknown API visibility %q, accepted values are: %q, %q", - a.Visibility, - VisibilityPublic, - VisibilityPrivate, - ) - } - - switch a.Mode { - case ModeZitadel: - if a.Zitadel.Domain == "" { - a.Zitadel.Domain = os.Getenv("UDASH_AUTH_ZITADEL_DOMAIN") - } - if a.Zitadel.KeyFile == "" { - a.Zitadel.KeyFile = os.Getenv("UDASH_AUTH_ZITADEL_FILEKEY") - } - case ModeOauth: - if a.Oauth.Issuer == "" { - a.Oauth.Issuer = os.Getenv("UDASH_AUTH_OAUTH_ISSUER") - } - - if len(a.Oauth.Audience) == 0 { - a.Oauth.Audience = []string{os.Getenv("UDASH_AUTH_OAUTH_AUDIENCE")} - } - case ModeNone, "": - // - default: - logrus.Errorf("Unknown authentication mode %q, accepted values are: %q, %q, %q", a.Mode, ModeOauth, ModeZitadel, ModeNone) - } - - authOption = *a -} diff --git a/pkg/server/report_handlers.go b/pkg/server/report_handlers.go index 34117346..926b03ea 100644 --- a/pkg/server/report_handlers.go +++ b/pkg/server/report_handlers.go @@ -39,7 +39,7 @@ func CreatePipelineReport(c *gin.Context) { return } - newReportID, err := database.InsertReport(c, p) + newReportID, err := database.InsertReport(c, p, publisherFromContext(c)) if err != nil { logrus.Errorf("insert reports: %s", err) c.JSON( diff --git a/pkg/server/roles.go b/pkg/server/roles.go new file mode 100644 index 00000000..c54a9672 --- /dev/null +++ b/pkg/server/roles.go @@ -0,0 +1,216 @@ +package server + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// rolesFromClaims reads the identity provider roles out of a token's claims. +// +// Providers disagree on the shape of that claim. Zitadel uses an object keyed by +// role name, mapping to the organisations granting it: +// +// {"urn:zitadel:iam:org:project:roles": {"udash.admin": {"orgID": "org.domain"}}} +// +// Keycloak and Auth0 use an array of strings: +// +// {"realm_access": {"roles": ["udash.admin"]}} +// +// Both are accepted, and the claim name may be dotted to reach into nested objects. +func rolesFromClaims(claims map[string]interface{}, claim string) []string { + if claim == "" || len(claims) == 0 { + return nil + } + + value, ok := lookupClaim(claims, claim) + if !ok { + return nil + } + + switch typed := value.(type) { + case map[string]interface{}: + // Zitadel: the role names are the keys. + roles := make([]string, 0, len(typed)) + for role := range typed { + roles = append(roles, role) + } + return roles + case []interface{}: + roles := make([]string, 0, len(typed)) + for _, entry := range typed { + if role, ok := entry.(string); ok { + roles = append(roles, role) + } + } + return roles + case []string: + return typed + case string: + return []string{typed} + } + + return nil +} + +// lookupClaim finds a claim by name, first verbatim and then by walking a dotted +// path. Zitadel's claim names contain colons but no dots, while Keycloak nests its +// roles under "realm_access.roles", so the verbatim lookup has to come first. +func lookupClaim(claims map[string]interface{}, claim string) (interface{}, bool) { + if value, ok := claims[claim]; ok { + return value, true + } + + parts := strings.Split(claim, ".") + if len(parts) == 1 { + return nil, false + } + + var current interface{} = claims + for _, part := range parts { + object, ok := current.(map[string]interface{}) + if !ok { + return nil, false + } + current, ok = object[part] + if !ok { + return nil, false + } + } + + return current, true +} + +// permissionFromRoles maps identity provider roles onto the most privileged Udash +// permission they grant, falling back on the configured default. +func permissionFromRoles(roles []string, opts RolesOptions) Permission { + granted := ParsePermission(opts.Default) + + for permission, names := range opts.Mapping { + candidate := ParsePermission(permission) + if !candidate.IsValid() || granted.AtLeast(candidate) { + continue + } + + for _, name := range names { + for _, role := range roles { + if role == name { + granted = candidate + break + } + } + } + } + + return granted +} + +// RoleResolver reports what an identity may currently do, given only its subject. +// +// It exists for requests authenticating with an Udash API token: those carry no +// identity provider token, so there are no claims to read the roles from. +type RoleResolver interface { + // Resolve returns the current permission of the given subject. The recorded + // permission is what was granted when the token was created, and is what a + // resolver returns when it cannot do better. + Resolve(ctx context.Context, subject string, recorded Permission) (Permission, error) +} + +// snapshotResolver trusts the permission recorded when the token was created. +// +// It is the only option for providers without a way to look up a subject's roles. +// Revoking a role at the provider does not downgrade tokens created before, so +// offboarding has to delete the identity's tokens. +type snapshotResolver struct{} + +func (snapshotResolver) Resolve(_ context.Context, _ string, recorded Permission) (Permission, error) { + return recorded, nil +} + +// zitadelUserRoles lists the roles currently granted to a subject. +type zitadelUserRoles func(ctx context.Context, subject string) ([]string, error) + +// cachingResolver asks the identity provider for the current roles of a subject, +// caching the answer so a publish heavy pipeline does not query it per report. +type cachingResolver struct { + roles zitadelUserRoles + opts RolesOptions + + mu sync.Mutex + entries map[string]cacheEntry + // now is overridable so the cache can be tested without sleeping. + now func() time.Time +} + +type cacheEntry struct { + permission Permission + expiresAt time.Time +} + +func newCachingResolver(roles zitadelUserRoles, opts RolesOptions) *cachingResolver { + return &cachingResolver{ + roles: roles, + opts: opts, + entries: map[string]cacheEntry{}, + now: time.Now, + } +} + +func (r *cachingResolver) Resolve(ctx context.Context, subject string, recorded Permission) (Permission, error) { + if subject == "" { + return recorded, nil + } + + r.mu.Lock() + entry, ok := r.entries[subject] + r.mu.Unlock() + + if ok && r.now().Before(entry.expiresAt) { + return entry.permission, nil + } + + roles, err := r.roles(ctx, subject) + if err != nil { + // Falling back on the recorded permission keeps publishing working through a + // provider outage. It cannot escalate: the recorded permission was already + // granted once, and is itself bounded by the token's scopes. + logrus.Warningf("Could not resolve the roles of %q, using the permission recorded on the token: %s", subject, err) + return recorded, nil + } + + permission := permissionFromRoles(roles, r.opts) + + // A token never grants more than it was created with, even if its owner has + // been promoted since. + if !recorded.AtLeast(permission) { + permission = recorded + } + + r.mu.Lock() + r.entries[subject] = cacheEntry{ + permission: permission, + expiresAt: r.now().Add(r.opts.CacheTTL), + } + r.mu.Unlock() + + return permission, nil +} + +// newRoleResolver builds the resolver named by the configuration. +func newRoleResolver(opts AuthOptions, roles zitadelUserRoles) (RoleResolver, error) { + switch opts.Roles.Resolver { + case ResolverSnapshot: + return snapshotResolver{}, nil + case ResolverZitadel: + if roles == nil { + return nil, fmt.Errorf("role resolver %q needs a Zitadel client", ResolverZitadel) + } + return newCachingResolver(roles, opts.Roles), nil + } + + return nil, fmt.Errorf("unknown role resolver %q", opts.Roles.Resolver) +} diff --git a/pkg/server/roles_test.go b/pkg/server/roles_test.go new file mode 100644 index 00000000..0b630524 --- /dev/null +++ b/pkg/server/roles_test.go @@ -0,0 +1,211 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// defaultRolesOptions is the mapping Init falls back on. +func defaultRolesOptions(claim string) RolesOptions { + return RolesOptions{ + Claim: claim, + Mapping: map[string][]string{ + string(PermissionAdmin): {"udash.admin"}, + string(PermissionPublisher): {"udash.publisher"}, + string(PermissionViewer): {"udash.viewer"}, + }, + Default: string(PermissionViewer), + CacheTTL: time.Minute, + } +} + +func TestRolesFromClaims(t *testing.T) { + testCases := []struct { + name string + claims string + claim string + expected []string + }{ + { + name: "zitadel puts the role names in the keys of an object", + claim: ZitadelRolesClaim, + claims: `{"urn:zitadel:iam:org:project:roles":{"udash.admin":{"orgID":"org.example.com"}}}`, + expected: []string{"udash.admin"}, + }, + { + name: "keycloak nests an array of strings", + claim: "realm_access.roles", + claims: `{"realm_access":{"roles":["udash.publisher","offline_access"]}}`, + expected: []string{"udash.publisher", "offline_access"}, + }, + { + name: "auth0 uses a namespaced array", + claim: "https://udash/roles", + claims: `{"https://udash/roles":["udash.viewer"]}`, + expected: []string{"udash.viewer"}, + }, + { + name: "a single string is accepted", + claim: "role", + claims: `{"role":"udash.admin"}`, + expected: []string{"udash.admin"}, + }, + { + name: "a missing claim yields nothing", + claim: "nope", + claims: `{"realm_access":{"roles":["udash.admin"]}}`, + expected: []string{}, + }, + { + name: "an unconfigured claim yields nothing", + claim: "", + claims: `{"urn:zitadel:iam:org:project:roles":{"udash.admin":{}}}`, + expected: []string{}, + }, + { + name: "a dotted path stopping on a non object yields nothing", + claim: "realm_access.roles.deeper", + claims: `{"realm_access":{"roles":["udash.admin"]}}`, + expected: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + claims := map[string]interface{}{} + require.NoError(t, json.Unmarshal([]byte(tc.claims), &claims)) + + assert.ElementsMatch(t, tc.expected, rolesFromClaims(claims, tc.claim)) + }) + } +} + +func TestPermissionFromRoles(t *testing.T) { + opts := defaultRolesOptions(ZitadelRolesClaim) + + testCases := []struct { + name string + roles []string + expected Permission + }{ + {"no role falls back on the default", nil, PermissionViewer}, + {"an unrelated role falls back on the default", []string{"other"}, PermissionViewer}, + {"a mapped role is granted", []string{"udash.publisher"}, PermissionPublisher}, + {"the most privileged role wins", []string{"udash.viewer", "udash.admin", "udash.publisher"}, PermissionAdmin}, + {"order does not matter", []string{"udash.admin", "udash.viewer"}, PermissionAdmin}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, permissionFromRoles(tc.roles, opts)) + }) + } +} + +func TestPermissionRanking(t *testing.T) { + assert.True(t, PermissionAdmin.AtLeast(PermissionPublisher)) + assert.True(t, PermissionPublisher.AtLeast(PermissionViewer)) + assert.True(t, PermissionViewer.AtLeast(PermissionViewer)) + assert.False(t, PermissionViewer.AtLeast(PermissionPublisher)) + assert.False(t, PermissionNone.AtLeast(PermissionViewer)) + + // A viewer must not be able to hand out a token which publishes. + assert.NotContains(t, PermissionViewer.Scopes(), ScopeReportsWrite) + assert.Contains(t, PermissionPublisher.Scopes(), ScopeReportsWrite) + assert.Empty(t, PermissionNone.Scopes()) +} + +func TestSnapshotResolver(t *testing.T) { + got, err := snapshotResolver{}.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + assert.Equal(t, PermissionPublisher, got) +} + +func TestCachingResolver(t *testing.T) { + opts := defaultRolesOptions(ZitadelRolesClaim) + + t.Run("resolves from the identity provider and caches", func(t *testing.T) { + calls := 0 + resolver := newCachingResolver(func(context.Context, string) ([]string, error) { + calls++ + return []string{"udash.viewer"}, nil + }, opts) + + // The creator has been demoted since the token was made. + for range 3 { + got, err := resolver.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + assert.Equal(t, PermissionViewer, got) + } + assert.Equal(t, 1, calls, "the answer must be cached") + }) + + t.Run("looks up again once the entry expired", func(t *testing.T) { + calls := 0 + resolver := newCachingResolver(func(context.Context, string) ([]string, error) { + calls++ + return []string{"udash.viewer"}, nil + }, opts) + + now := time.Now() + resolver.now = func() time.Time { return now } + + _, err := resolver.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + + now = now.Add(2 * opts.CacheTTL) + + _, err = resolver.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + + assert.Equal(t, 2, calls) + }) + + t.Run("never grants more than the token was created with", func(t *testing.T) { + resolver := newCachingResolver(func(context.Context, string) ([]string, error) { + return []string{"udash.admin"}, nil + }, opts) + + // The creator was promoted after making the token; the token must not + // silently gain the new privileges. + got, err := resolver.Resolve(context.Background(), "user-1", PermissionViewer) + require.NoError(t, err) + assert.Equal(t, PermissionViewer, got) + }) + + t.Run("falls back on the recorded permission when the provider is down", func(t *testing.T) { + resolver := newCachingResolver(func(context.Context, string) ([]string, error) { + return nil, errors.New("zitadel unreachable") + }, opts) + + // Publishing has to keep working through an outage, and this cannot + // escalate: the permission was granted once already. + got, err := resolver.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + assert.Equal(t, PermissionPublisher, got) + }) +} + +func TestNewRoleResolver(t *testing.T) { + t.Run("snapshot needs no client", func(t *testing.T) { + resolver, err := newRoleResolver(AuthOptions{Roles: RolesOptions{Resolver: ResolverSnapshot}}, nil) + require.NoError(t, err) + assert.IsType(t, snapshotResolver{}, resolver) + }) + + t.Run("zitadel without a client is an error", func(t *testing.T) { + _, err := newRoleResolver(AuthOptions{Roles: RolesOptions{Resolver: ResolverZitadel}}, nil) + require.Error(t, err) + }) + + t.Run("an unknown resolver is an error", func(t *testing.T) { + _, err := newRoleResolver(AuthOptions{Roles: RolesOptions{Resolver: "nope"}}, nil) + require.Error(t, err) + }) +} diff --git a/pkg/server/token_handlers.go b/pkg/server/token_handlers.go new file mode 100644 index 00000000..3278b548 --- /dev/null +++ b/pkg/server/token_handlers.go @@ -0,0 +1,273 @@ +package server + +import ( + "errors" + "net/http" + "slices" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/updatecli/udash/pkg/database" + "github.com/updatecli/udash/pkg/model" +) + +// CreateTokenRequest is the body of a token creation request. +type CreateTokenRequest struct { + // Name is what the token is for, shown back in the token list. + Name string `json:"name" binding:"required"` + // Scopes is what the token may do. It defaults to publishing reports, and may + // never exceed what the identity creating it is allowed to do. + Scopes []string `json:"scopes,omitempty"` + // ExpiresAt is when the token stops working. Leave it out for a token which + // never expires, which is what an unattended pipeline needs. + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// CreateTokenResponse carries the newly created token. +type CreateTokenResponse struct { + model.APIToken + // Token is the credential itself. It is returned here once and never again: + // only its hash is stored. + Token string `json:"token"` +} + +// WhoamiResponse describes the identity behind the credential used. +type WhoamiResponse struct { + Subject string `json:"subject,omitempty"` + Name string `json:"name,omitempty"` + Permission string `json:"permission,omitempty"` + TokenName string `json:"tokenName,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + +// registerTokenRoutes wires the API token endpoints. +// +// They live on their own group rather than on /api/pipeline: that group is left +// open for reads when the API is public, which must never apply here. +func registerTokenRoutes(r *gin.Engine, auth gin.HandlerFunc) { + tokens := r.Group("/api/tokens", auth) + + // Creating a token requires signing in with the identity provider. Letting a + // token mint another one would let a leaked token outlive its own revocation. + tokens.POST("", rejectTokenAuth(), requirePermission(PermissionPublisher), CreateAPIToken) + tokens.GET("", ListAPITokens) + tokens.DELETE("/:id", DeleteAPIToken) + tokens.DELETE("", requirePermission(PermissionAdmin), DeleteAPITokensBySubject) + + r.GET("/api/whoami", auth, Whoami) +} + +// CreateAPIToken issues a new API token. +// +// @Summary Create an API token +// @Description Issue a long lived token to authenticate against the Udash API. The token is returned once and cannot be recovered afterwards. +// @Tags Tokens +// @Accept json +// @Produce json +// @Param request body CreateTokenRequest true "token to create" +// @Success 201 {object} CreateTokenResponse +// @Failure 400 {object} DefaultResponseModel +// @Failure 401 {object} DefaultResponseModel +// @Failure 403 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/tokens [post] +func CreateAPIToken(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, DefaultResponseModel{Err: ErrUnauthenticated}) + return + } + + request := CreateTokenRequest{} + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, DefaultResponseModel{Err: ErrInvalidTokenRequest}) + return + } + + allowed := principal.Permission.Scopes() + + scopes := request.Scopes + if len(scopes) == 0 { + // Publishing reports is what a token is almost always created for. + scopes = []string{ScopeReportsWrite} + } + + // A token must never grant more than the identity creating it. + for _, scope := range scopes { + if !slices.Contains(allowed, scope) { + c.JSON(http.StatusForbidden, DefaultResponseModel{Err: ErrInsufficientScope}) + return + } + } + + if request.ExpiresAt != nil && request.ExpiresAt.Before(time.Now()) { + c.JSON(http.StatusBadRequest, DefaultResponseModel{Err: ErrInvalidTokenRequest}) + return + } + + token, hash, err := generateAPIToken() + if err != nil { + logrus.Errorf("generating an API token: %s", err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + created, err := database.InsertAPIToken( + c.Request.Context(), + request.Name, + principal.Subject, + string(principal.Permission), + scopes, + hash, + request.ExpiresAt, + ) + if err != nil { + logrus.Errorf("storing an API token: %s", err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + c.JSON(http.StatusCreated, CreateTokenResponse{APIToken: *created, Token: token}) +} + +// ListAPITokens returns the caller's API tokens. +// +// @Summary List API tokens +// @Description List the caller's API tokens. Administrators may list everybody's with all=true. The tokens themselves are never returned. +// @Tags Tokens +// @Produce json +// @Param all query bool false "list every identity's tokens, administrators only" +// @Success 200 {array} model.APIToken +// @Failure 401 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/tokens [get] +func ListAPITokens(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, DefaultResponseModel{Err: ErrUnauthenticated}) + return + } + + // Default to the caller's own tokens, so listing everybody's has to be asked + // for explicitly and is refused to anyone but an administrator. + subject := principal.Subject + if c.Query("all") == "true" { + if !principal.Permission.AtLeast(PermissionAdmin) { + c.JSON(http.StatusForbidden, DefaultResponseModel{Err: ErrInsufficientPermission}) + return + } + subject = "" + } + + tokens, err := database.ListAPITokens(c.Request.Context(), subject) + if err != nil { + logrus.Errorf("listing API tokens: %s", err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + c.JSON(http.StatusOK, tokens) +} + +// DeleteAPIToken revokes an API token. +// +// @Summary Revoke an API token +// @Description Revoke one of the caller's API tokens. Administrators may revoke anybody's. +// @Tags Tokens +// @Produce json +// @Param id path string true "token id" +// @Success 200 {object} DefaultResponseModel +// @Failure 401 {object} DefaultResponseModel +// @Failure 404 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/tokens/{id} [delete] +func DeleteAPIToken(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, DefaultResponseModel{Err: ErrUnauthenticated}) + return + } + + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, DefaultResponseModel{Err: ErrTokenNotFound}) + return + } + + // Restricting the delete to the caller's own subject is what stops one identity + // revoking another's tokens. An administrator is not restricted. + subject := principal.Subject + if principal.Permission.AtLeast(PermissionAdmin) { + subject = "" + } + + if err := database.DeleteAPIToken(c.Request.Context(), id, subject); err != nil { + if errors.Is(err, database.ErrAPITokenNotFound) { + c.JSON(http.StatusNotFound, DefaultResponseModel{Err: ErrTokenNotFound}) + return + } + logrus.Errorf("deleting API token %s: %s", id, err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + c.JSON(http.StatusOK, DefaultResponseModel{Message: "token successfully revoked"}) +} + +// DeleteAPITokensBySubject revokes every token of an identity. +// +// @Summary Revoke every token of an identity +// @Description Revoke all API tokens created by a given identity, which is what offboarding somebody needs. Administrators only. +// @Tags Tokens +// @Produce json +// @Param subject query string true "identity provider subject" +// @Success 200 {object} DefaultResponseModel +// @Failure 400 {object} DefaultResponseModel +// @Failure 403 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/tokens [delete] +func DeleteAPITokensBySubject(c *gin.Context) { + subject := c.Query("subject") + if subject == "" { + c.JSON(http.StatusBadRequest, DefaultResponseModel{Err: ErrInvalidTokenRequest}) + return + } + + deleted, err := database.DeleteAPITokensBySubject(c.Request.Context(), subject) + if err != nil { + logrus.Errorf("deleting the API tokens of %q: %s", subject, err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + logrus.Infof("Revoked %d API tokens of %q", deleted, subject) + c.JSON(http.StatusOK, DefaultResponseModel{Message: "tokens successfully revoked"}) +} + +// Whoami describes the identity behind the credential used. +// +// @Summary Describe the current identity +// @Description Return the identity, permission and token scopes behind the credential used. Updatecli calls it to validate a token at login time. +// @Tags Tokens +// @Produce json +// @Success 200 {object} WhoamiResponse +// @Failure 401 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/whoami [get] +func Whoami(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, DefaultResponseModel{Err: ErrUnauthenticated}) + return + } + + c.JSON(http.StatusOK, WhoamiResponse{ + Subject: principal.Subject, + Name: principal.Name, + Permission: string(principal.Permission), + TokenName: principal.TokenName, + Scopes: principal.Scopes, + }) +} diff --git a/pkg/server/token_middleware.go b/pkg/server/token_middleware.go new file mode 100644 index 00000000..7a60a549 --- /dev/null +++ b/pkg/server/token_middleware.go @@ -0,0 +1,127 @@ +package server + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/updatecli/udash/pkg/database" + "github.com/updatecli/udash/pkg/model" +) + +// APITokenPrefix marks a bearer token as one Udash issued itself. +// +// The prefix is what lets the middleware tell an Udash token from an identity +// provider one without having to try both, and lets secret scanners recognise one +// if it ever leaks into a public repository. +const APITokenPrefix = "udash_pat_" + +// apiTokenBytes is how much entropy a token carries. +const apiTokenBytes = 32 + +// generateAPIToken returns a new token and the hash to store for it. +func generateAPIToken() (string, []byte, error) { + buffer := make([]byte, apiTokenBytes) + if _, err := rand.Read(buffer); err != nil { + return "", nil, err + } + + token := APITokenPrefix + base64.RawURLEncoding.EncodeToString(buffer) + + return token, hashAPIToken(token), nil +} + +// hashAPIToken returns what gets stored for a token. +// +// A plain sha256 is enough here, unlike for a password: the token is 32 random +// bytes, so there is no dictionary to run against it. +func hashAPIToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// bearerToken returns the credential presented by a request, if any. +func bearerToken(c *gin.Context) string { + header := c.GetHeader("Authorization") + if header == "" { + return "" + } + + if len(header) < 7 || !strings.EqualFold(header[:7], "bearer ") { + return "" + } + + return strings.TrimSpace(header[7:]) +} + +// udashTokenAuth authenticates requests presenting an Udash API token, and hands +// everything else to the identity provider middleware. +// +// It runs first and independently of the configured mode: Udash issues and +// validates these tokens itself, so they behave the same whichever provider is in +// use, and they keep working when an identity provider token would have expired. +func udashTokenAuth(resolver RoleResolver, next gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + token := bearerToken(c) + if !strings.HasPrefix(token, APITokenPrefix) { + next(c) + return + } + + stored, err := database.GetAPITokenByHash(c.Request.Context(), hashAPIToken(token)) + if err != nil { + if !errors.Is(err, database.ErrAPITokenNotFound) { + logrus.Errorf("looking up an API token: %s", err) + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + if stored.ExpiresAt != nil && stored.ExpiresAt.Before(time.Now()) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + setPrincipal(c, principalFromToken(c.Request.Context(), resolver, stored)) + + // Best effort: the timestamp helps spot an unused or leaked token, it does + // not authorize anything, so a failure must not fail the request. + if err := database.TouchAPIToken(c.Request.Context(), stored.ID); err != nil { + logrus.Debugf("recording the use of token %s: %s", stored.ID, err) + } + + c.Next() + } +} + +// principalFromToken works out what a token may currently do. +// +// The permission recorded on the token is what its creator could do when it was +// issued. Asking the resolver lets a role revoked at the identity provider take +// effect without having to hunt down the tokens created before it. +func principalFromToken(ctx context.Context, resolver RoleResolver, token *model.APIToken) Principal { + recorded := ParsePermission(token.Permission) + + permission, err := resolver.Resolve(ctx, token.Subject, recorded) + if err != nil { + logrus.Warningf("resolving the permission of %q: %s", token.Subject, err) + permission = recorded + } + + id := token.ID + + return Principal{ + Subject: token.Subject, + Permission: permission, + TokenID: &id, + TokenName: token.Name, + Scopes: token.Scopes, + } +} diff --git a/pkg/server/token_test.go b/pkg/server/token_test.go new file mode 100644 index 00000000..d252ecbe --- /dev/null +++ b/pkg/server/token_test.go @@ -0,0 +1,329 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/updatecli/udash/pkg/database" + "github.com/updatecli/udash/test" + "github.com/updatecli/updatecli/pkg/core/reports" + "github.com/updatecli/updatecli/pkg/core/result" +) + +// fakeIdentityAuth stands in for the identity provider middleware, so the token +// endpoints can be tested without a live Zitadel. +func fakeIdentityAuth(principal Principal) gin.HandlerFunc { + return func(c *gin.Context) { + if c.GetHeader("Authorization") == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + setPrincipal(c, principal) + c.Next() + } +} + +// tokenTestServer wires the token endpoints behind a stubbed identity, plus the +// report write route so scope enforcement can be checked end to end. +func tokenTestServer(t *testing.T, identity Principal) *httptest.Server { + t.Helper() + + gin.SetMode(gin.TestMode) + r := gin.New() + + auth := udashTokenAuth(snapshotResolver{}, fakeIdentityAuth(identity)) + registerTokenRoutes(r, auth) + + r.POST("/api/pipeline/reports", auth, requireScope(ScopeReportsWrite), CreatePipelineReport) + + server := httptest.NewServer(r) + t.Cleanup(server.Close) + + return server +} + +func doJSON(t *testing.T, method, url, bearer string, body any) (int, map[string]any) { + t.Helper() + + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + require.NoError(t, err) + reader = bytes.NewReader(encoded) + } + + req, err := http.NewRequest(method, url, reader) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + decoded := map[string]any{} + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + if len(raw) > 0 && raw[0] == '{' { + require.NoError(t, json.Unmarshal(raw, &decoded)) + } + + return resp.StatusCode, decoded +} + +func TestAPITokens(t *testing.T) { + ctx := context.Background() + + postgresContainer, err := test.SetupDatabase(t, ctx) + require.NoError(t, err) + + dbURL, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + require.NoError(t, database.Connect(database.Options{URI: dbURL})) + require.NoError(t, database.RunMigrationUp()) + + publisher := Principal{Subject: "user-publisher", Name: "Pat", Permission: PermissionPublisher} + + t.Run("lifecycle", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{ + "name": "ci", + }) + require.Equal(t, http.StatusCreated, status) + + token, _ := created["token"].(string) + require.NotEmpty(t, token) + assert.True(t, len(token) > len(APITokenPrefix), "the token must carry the prefix and some entropy") + assert.Contains(t, token, APITokenPrefix) + assert.Nil(t, created["expires_at"], "a token created without an expiry never expires") + + // The token authenticates on its own, with no identity provider involved. + status, who := doJSON(t, http.MethodGet, srv.URL+"/api/whoami", token, nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, "user-publisher", who["subject"]) + assert.Equal(t, "ci", who["tokenName"]) + + // And it may publish. + status, _ = doJSON(t, http.MethodPost, srv.URL+"/api/pipeline/reports", token, map[string]any{ + "Name": "ci: bump something", "ID": "abc", "PipelineID": "p", + }) + assert.Equal(t, http.StatusCreated, status) + + id, _ := created["id"].(string) + require.NotEmpty(t, id) + + status, _ = doJSON(t, http.MethodDelete, srv.URL+"/api/tokens/"+id, "session", nil) + require.Equal(t, http.StatusOK, status) + + // Once revoked it stops working. + status, _ = doJSON(t, http.MethodGet, srv.URL+"/api/whoami", token, nil) + assert.Equal(t, http.StatusUnauthorized, status) + }) + + t.Run("a token cannot mint another token", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{"name": "ci"}) + require.Equal(t, http.StatusCreated, status) + token := created["token"].(string) + + // Otherwise a leaked token could issue fresh ones and outlive its revocation. + status, _ = doJSON(t, http.MethodPost, srv.URL+"/api/tokens", token, map[string]any{"name": "sneaky"}) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("a viewer cannot create a token", func(t *testing.T) { + srv := tokenTestServer(t, Principal{Subject: "user-viewer", Permission: PermissionViewer}) + + // This is what stops everybody who can sign in from minting a publishing token. + status, _ := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{"name": "nope"}) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("a token cannot be granted more than its creator", func(t *testing.T) { + srv := tokenTestServer(t, Principal{Subject: "user-viewer-2", Permission: PermissionViewer}) + + status, _ := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{ + "name": "escalate", + "scopes": []string{ScopeReportsWrite}, + }) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("a read only token cannot publish", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{ + "name": "read-only", + "scopes": []string{ScopeReportsRead}, + }) + require.Equal(t, http.StatusCreated, status) + token := created["token"].(string) + + status, _ = doJSON(t, http.MethodPost, srv.URL+"/api/pipeline/reports", token, map[string]any{ + "Name": "nope", "ID": "def", "PipelineID": "p", + }) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("an expired token is rejected", func(t *testing.T) { + expired := time.Now().Add(-time.Hour) + token, hash, err := generateAPIToken() + require.NoError(t, err) + + _, err = database.InsertAPIToken(ctx, "expired", "user-publisher", + string(PermissionPublisher), []string{ScopeReportsWrite}, hash, &expired) + require.NoError(t, err) + + srv := tokenTestServer(t, publisher) + status, _ := doJSON(t, http.MethodGet, srv.URL+"/api/whoami", token, nil) + assert.Equal(t, http.StatusUnauthorized, status) + }) + + t.Run("an unknown token is rejected", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, _ := doJSON(t, http.MethodGet, srv.URL+"/api/whoami", APITokenPrefix+"nonexistent", nil) + assert.Equal(t, http.StatusUnauthorized, status) + }) + + t.Run("one identity cannot revoke another's token", func(t *testing.T) { + owner := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, owner.URL+"/api/tokens", "session", map[string]any{"name": "mine"}) + require.Equal(t, http.StatusCreated, status) + id := created["id"].(string) + + other := tokenTestServer(t, Principal{Subject: "somebody-else", Permission: PermissionPublisher}) + status, _ = doJSON(t, http.MethodDelete, other.URL+"/api/tokens/"+id, "session", nil) + assert.Equal(t, http.StatusNotFound, status) + + // An administrator may. + admin := tokenTestServer(t, Principal{Subject: "an-admin", Permission: PermissionAdmin}) + status, _ = doJSON(t, http.MethodDelete, admin.URL+"/api/tokens/"+id, "session", nil) + assert.Equal(t, http.StatusOK, status) + }) + + t.Run("listing is scoped to the caller unless they are an administrator", func(t *testing.T) { + srv := tokenTestServer(t, Principal{Subject: "user-lister", Permission: PermissionPublisher}) + + status, _ := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{"name": "mine"}) + require.Equal(t, http.StatusCreated, status) + + req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/tokens", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer session") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + listed := []map[string]any{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&listed)) + require.NotEmpty(t, listed) + for _, entry := range listed { + assert.Equal(t, "user-lister", entry["subject"]) + assert.NotContains(t, entry, "token", "the secret must never be listed") + } + + // Asking for everybody's is refused to a non administrator. + status, _ = doJSON(t, http.MethodGet, srv.URL+"/api/tokens?all=true", "session", nil) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("unauthenticated requests are refused", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, _ := doJSON(t, http.MethodGet, srv.URL+"/api/tokens", "", nil) + assert.Equal(t, http.StatusUnauthorized, status) + }) +} + +func TestReportAttribution(t *testing.T) { + ctx := context.Background() + + postgresContainer, err := test.SetupDatabase(t, ctx) + require.NoError(t, err) + + dbURL, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + require.NoError(t, database.Connect(database.Options{URI: dbURL})) + require.NoError(t, database.RunMigrationUp()) + + publisher := Principal{Subject: "user-publisher", Permission: PermissionPublisher} + srv := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{"name": "ci"}) + require.Equal(t, http.StatusCreated, status) + token := created["token"].(string) + tokenID := created["id"].(string) + + status, published := doJSON(t, http.MethodPost, srv.URL+"/api/pipeline/reports", token, map[string]any{ + "Name": "ci: attributed", "ID": "attributed", "PipelineID": "p", + }) + require.Equal(t, http.StatusCreated, status) + + reportID, _ := published["reportid"].(string) + require.NotEmpty(t, reportID) + + var subject, storedTokenID *string + require.NoError(t, database.DB.QueryRow(ctx, + "SELECT created_by_subject, created_by_token_id::text FROM pipelineReports WHERE id = $1", + reportID, + ).Scan(&subject, &storedTokenID)) + + require.NotNil(t, subject) + assert.Equal(t, "user-publisher", *subject) + require.NotNil(t, storedTokenID) + assert.Equal(t, tokenID, *storedTokenID) +} + +func TestReportAttributionWithoutAuth(t *testing.T) { + ctx := context.Background() + + postgresContainer, err := test.SetupDatabase(t, ctx) + require.NoError(t, err) + + dbURL, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + require.NoError(t, database.Connect(database.Options{URI: dbURL})) + require.NoError(t, database.RunMigrationUp()) + + // An instance running without authentication has nobody to attribute to, and + // must keep publishing regardless. + reportID, err := database.InsertReport(ctx, anonymousReport(), database.Publisher{}) + require.NoError(t, err) + + var subject, tokenID *string + require.NoError(t, database.DB.QueryRow(ctx, + "SELECT created_by_subject, created_by_token_id::text FROM pipelineReports WHERE id = $1", + reportID, + ).Scan(&subject, &tokenID)) + + assert.Nil(t, subject) + assert.Nil(t, tokenID) +} + +// anonymousReport is a minimal report, for the attribution tests. +func anonymousReport() reports.Report { + return reports.Report{ + Name: "ci: anonymous", + Result: result.SUCCESS, + ID: "anonymous", + PipelineID: "p", + } +} diff --git a/pkg/server/var.go b/pkg/server/var.go index fc3d6255..d2e029ba 100644 --- a/pkg/server/var.go +++ b/pkg/server/var.go @@ -52,6 +52,23 @@ const ( ErrTooManyBuckets = "requested time range and granularity produce too many buckets" ErrInvalidJWT = "JWT is invalid" + // ErrUnauthenticated is returned when a request carries no usable credential. + ErrUnauthenticated = "authentication required" + // ErrInsufficientPermission is returned when the caller is authenticated but not + // privileged enough for the endpoint. + ErrInsufficientPermission = "insufficient permission" + // ErrInsufficientScope is returned when the token used is not allowed to perform + // the requested action, even though the identity behind it would be. + ErrInsufficientScope = "token is not allowed to perform this action" + // ErrTokenCannotMintToken is returned when an API token is used to create another + // one, which must require an identity provider login. + ErrTokenCannotMintToken = "creating a token requires signing in, it cannot be done with a token" + // ErrTokenNotFound is returned when the requested API token does not exist, or + // belongs to somebody else. + ErrTokenNotFound = "token not found" + // ErrInvalidTokenRequest is returned when a token creation request is malformed. + ErrInvalidTokenRequest = "invalid token request" + // summaryMetricResult counts the pipeline reports per Updatecli result. It is the // only metric supported by the reports summary so far. summaryMetricResult = "result" diff --git a/pkg/server/zitadel-gin.go b/pkg/server/zitadel-gin.go index d11602ec..e2d554eb 100644 --- a/pkg/server/zitadel-gin.go +++ b/pkg/server/zitadel-gin.go @@ -7,15 +7,19 @@ import ( "github.com/gin-gonic/gin" "github.com/zitadel/zitadel-go/v3/pkg/authorization" + "github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth" ) type Interceptor[T authorization.Ctx] struct { authorizer *authorization.Authorizer[T] + // roles describes how the claims of a token become an Udash permission. + roles RolesOptions } -func NewZitadelGin[T authorization.Ctx](authorizer *authorization.Authorizer[T]) *Interceptor[T] { +func NewZitadelGin[T authorization.Ctx](authorizer *authorization.Authorizer[T], roles RolesOptions) *Interceptor[T] { return &Interceptor[T]{ authorizer: authorizer, + roles: roles, } } @@ -33,10 +37,34 @@ func (i *Interceptor[T]) RequireAuthorization(options ...authorization.CheckOpti return } c.Request = c.Request.WithContext(authorization.WithAuthContext(c.Request.Context(), authCtx)) + setPrincipal(c, i.principal(authCtx)) c.Next() } } +// principal turns the introspected token into the identity the handlers work with. +func (i *Interceptor[T]) principal(authCtx T) Principal { + principal := Principal{ + Subject: authCtx.UserID(), + Permission: ParsePermission(i.roles.Default), + } + + // The introspection response carries the claims, but only the concrete type + // exposes them; authorization.Ctx deliberately does not. + introspection, ok := any(authCtx).(*oauth.IntrospectionContext) + if !ok || introspection == nil { + return principal + } + + principal.Name = introspection.Username + principal.Permission = permissionFromRoles( + rolesFromClaims(introspection.Claims, i.roles.Claim), + i.roles, + ) + + return principal +} + func (i *Interceptor[T]) Context(ctx context.Context) T { return authorization.Context[T](ctx) } diff --git a/pkg/server/zitadel-roles.go b/pkg/server/zitadel-roles.go new file mode 100644 index 00000000..26a98ccd --- /dev/null +++ b/pkg/server/zitadel-roles.go @@ -0,0 +1,54 @@ +package server + +import ( + "context" + "fmt" + + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/zitadel-go/v3/pkg/client" + "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/management" + "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user" + "github.com/zitadel/zitadel-go/v3/pkg/zitadel" +) + +// newZitadelUserRoles builds a lookup of the roles currently granted to a subject. +// +// A request authenticating with an Udash API token carries no Zitadel token, so +// there are no claims to read the roles from and they have to be asked for. The +// service user behind the key file needs permission to read user grants in the +// organisation, otherwise every lookup fails and the resolver falls back on the +// permission recorded when the token was created. +func newZitadelUserRoles(ctx context.Context, opts ZitadelOptions) (zitadelUserRoles, error) { + api, err := client.New(ctx, zitadel.New(opts.Domain), + client.WithAuth(client.DefaultServiceUserAuthentication( + opts.KeyFile, + oidc.ScopeOpenID, + client.ScopeZitadelAPI(), + )), + ) + if err != nil { + return nil, fmt.Errorf("connecting to Zitadel: %w", err) + } + + return func(ctx context.Context, subject string) ([]string, error) { + resp, err := api.ManagementService().ListUserGrants(ctx, &management.ListUserGrantRequest{ + Queries: []*user.UserGrantQuery{ + { + Query: &user.UserGrantQuery_UserIdQuery{ + UserIdQuery: &user.UserGrantUserIDQuery{UserId: subject}, + }, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("listing the grants of %q: %w", subject, err) + } + + roles := []string{} + for _, grant := range resp.GetResult() { + roles = append(roles, grant.GetRoleKeys()...) + } + + return roles, nil + }, nil +}