Dashboards: Improve support for writing k8s dashboards to the legacy API (#119538)

This commit is contained in:
Ryan McKinley
2026-03-05 09:36:12 +03:00
committed by GitHub
parent 68acbf8c0b
commit c8ab2fa5cc
6 changed files with 126 additions and 14 deletions
+58 -5
View File
@@ -413,9 +413,10 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S
return response.Error(http.StatusBadRequest, "Failed to read dashboard", err)
}
// Items with v2 schema elements must set v2 properties
// Check for v2 schema elements without a k8s style wrapper
if dashboards.LooksLikeV2Spec(spec) {
return response.Error(http.StatusBadRequest, dashboards.LooksLikeV2SpecMessage, nil)
return response.Error(http.StatusBadRequest, dashboards.LooksLikeV2SpecMessage+
" OR it should include a object wrapper with an explicit 'apiVersion' and move the body into a 'spec' element", nil)
}
// Items with metadata, spec, etc
@@ -543,12 +544,48 @@ func (hs *HTTPServer) saveDashboardViaK8s(c *contextmodel.ReqContext, cmd dashbo
meta.SetManagedFields(nil)
name := obj.GetName()
if name == "" {
name, _, _ = unstructured.NestedString(obj.Object, "spec", "uid")
}
// Check (and remove) any legacy internal IDs
var old *unstructured.Unstructured
internalID, err := nestedInternalID(obj.Object)
if err != nil {
return response.Error(http.StatusBadRequest, err.Error(), err)
}
if internalID > 0 && name == "" {
found, err := client.List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("%s=%d", utils.LabelKeyDeprecatedInternalID, internalID),
Limit: 2,
})
if err != nil {
return response.Error(http.StatusInternalServerError, "unable to lookup previous version", err)
}
if len(found.Items) == 0 {
return response.Error(http.StatusBadRequest,
fmt.Sprintf("The payload includes an internal identifier (%d) that is not found", internalID), nil)
}
old = &found.Items[0]
name = old.GetName()
meta.SetName(name)
if !cmd.Overwrite {
return response.Error(http.StatusConflict,
"Dashboard with the same internal ID already exists. Use overwrite flag to update.", nil)
}
}
// Never send internal ID or UID in the body
unstructured.RemoveNestedField(obj.Object, "spec", "id")
unstructured.RemoveNestedField(obj.Object, "spec", "uid")
isCreate := name == ""
if isCreate {
obj.SetGenerateName("a") // prefix
} else {
} else if old == nil {
// Read the old value first
old, err := client.Get(ctx, name, metav1.GetOptions{})
old, err = client.Get(ctx, name, metav1.GetOptions{})
if err == nil && old != nil {
if !cmd.Overwrite {
return response.Error(http.StatusConflict,
@@ -582,7 +619,7 @@ func (hs *HTTPServer) saveDashboardViaK8s(c *contextmodel.ReqContext, cmd dashbo
meta, err = utils.MetaAccessor(dash)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to save dashboard", err)
return response.Error(http.StatusInternalServerError, "Failed get meta accessor", err)
}
title, _, _ = unstructured.NestedString(dash.Object, "spec", "title")
@@ -599,6 +636,22 @@ func (hs *HTTPServer) saveDashboardViaK8s(c *contextmodel.ReqContext, cmd dashbo
})
}
func nestedInternalID(obj map[string]interface{}) (int64, error) {
val, found, err := unstructured.NestedFieldNoCopy(obj, "spec", "id")
if !found || err != nil {
return 0, nil
}
i, ok := val.(int64)
if ok {
return i, nil
}
n, ok := val.(json.Number)
if ok {
return n.Int64()
}
return 0, fmt.Errorf("unsupported ID type: %T", val)
}
// swagger:route GET /dashboards/home dashboards getHomeDashboard
//
// NOTE: the home dashboard is configured in preferences. This API will be removed in G13
@@ -77,6 +77,8 @@ WHERE dashboard.is_folder = {{ .Arg .Query.GetFolders }}
{{ else }}
{{ if .Query.UID }}
AND dashboard.uid = {{ .Arg .Query.UID }}
{{ else if .Query.DeprecatedInternalID }}
AND dashboard.id = {{ .Arg .Query.DeprecatedInternalID }}
{{ else if .Query.LastID }}
AND dashboard.id < {{ .Arg .Query.LastID }}
{{ end }}
@@ -154,12 +154,30 @@ func (a *dashboardSqlAccess) executeQuery(ctx context.Context, helper *legacysql
return helper.DB.GetSqlxSession().Query(ctx, query, args...)
}
func getLegacyIDSelector(labels []*resourcepb.Requirement) int64 {
if len(labels) != 1 {
return 0
}
q := labels[0]
if q.Key == utils.LabelKeyDeprecatedInternalID &&
q.Operator == "=" &&
len(q.Values) == 1 {
i, _ := strconv.ParseInt(labels[0].Values[0], 10, 64)
return i
}
return 0
}
func (a *dashboardSqlAccess) getRows(ctx context.Context, helper *legacysql.LegacyDatabaseHelper, query *DashboardQuery) (*rowsWrapper, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.getRows")
defer span.End()
if len(query.Labels) > 0 {
return nil, fmt.Errorf("labels not yet supported")
query.DeprecatedInternalID = getLegacyIDSelector(query.Labels)
if query.DeprecatedInternalID == 0 {
return nil, fmt.Errorf("labels not yet supported")
}
// if query.Requirements.Folder != nil {
// args = append(args, *query.Requirements.Folder)
// sqlcmd = fmt.Sprintf("%s AND dashboard.folder_uid=?$%d", sqlcmd, len(args))
@@ -17,6 +17,8 @@ type DashboardQuery struct {
UID string // to select a single dashboard
Limit int
DeprecatedInternalID int64 // to select a single dashboard
// MaxRows is used internally by the iterator to fetch data in batches
// When set, the SQL query will include LIMIT MaxRows
// If Limit is smaller, that will be used instead
+44 -8
View File
@@ -300,12 +300,25 @@ func TestIntegrationLegacySupport(t *testing.T) {
}
})
t.Run("validate k8s payload in legacy API", func(t *testing.T) {
t.Run("use k8s payload in legacy API", func(t *testing.T) {
cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig())
cfg.GroupVersion = &dashboardV0.GroupVersion
adminClient, err := k8srest.RESTClientFor(cfg)
require.NoError(t, err)
// Construct a legacy api payload from a k8s object
getLegacySaveCommand := func(obj *unstructured.Unstructured, title string, overwrite bool) []byte {
err := unstructured.SetNestedField(obj.Object, title, "spec", "title") // update the title
require.NoError(t, err)
cmd := map[string]any{
"dashboard": obj.Object,
"overwrite": overwrite,
}
jj, err := json.Marshal(cmd)
require.NoError(t, err)
return jj
}
names := []string{"test-v0", "test-v1", "test-v2"}
clients := []dynamic.ResourceInterface{
clientV0.Resource,
@@ -339,14 +352,13 @@ func TestIntegrationLegacySupport(t *testing.T) {
foundTitle, _, _ := unstructured.NestedString(found.Object, "spec", "title")
require.Equal(t, title, foundTitle, "in object: %s", obj.GetName())
// ID must not be a saved element
_, ok, _ := unstructured.NestedInt64(obj.Object, "spec", "id")
require.False(t, ok, "internal id should not be part of the saved spec")
// Update the title -- try to save without overwrite=false
title = "update:" + name
err = unstructured.SetNestedField(obj.Object, title, "spec", "title") // update the title
require.NoError(t, err)
jj, err = obj.MarshalJSON()
require.NoError(t, err)
body = []byte(`{"dashboard": ` + string(jj) + `, "overwrite": false}`)
body = getLegacySaveCommand(obj, title, false)
_ = adminClient.Post().AbsPath("api", "dashboards", "db").
Body(body).
SetHeader("Content-type", "application/json").
@@ -355,7 +367,7 @@ func TestIntegrationLegacySupport(t *testing.T) {
require.Equal(t, int(http.StatusConflict), statusCode) // already exists
// Overwrite!
body = []byte(`{"dashboard": ` + string(jj) + `, "overwrite": true}`)
body = getLegacySaveCommand(obj, title, true)
_ = adminClient.Post().AbsPath("api", "dashboards", "db").
Body(body).
SetHeader("Content-type", "application/json").
@@ -378,6 +390,30 @@ func TestIntegrationLegacySupport(t *testing.T) {
err = json.Unmarshal(jj, dto)
require.NoError(t, err)
require.Equal(t, title, dto.Dashboard.Get("title").MustString(""), "in object: %s", obj.GetName())
// Update by internal id (without name)
meta, err := utils.MetaAccessor(found)
require.NoError(t, err)
internalId := meta.GetDeprecatedInternalID() // nolint:staticcheck
require.True(t, internalId > 0)
title = "updated using internal ID"
unstructured.RemoveNestedField(obj.Object, "spec", "uid")
unstructured.RemoveNestedField(obj.Object, "metadata", "name")
err = unstructured.SetNestedField(obj.Object, internalId, "spec", "id")
require.NoError(t, err)
body = getLegacySaveCommand(obj, title, true)
rsp := adminClient.Post().AbsPath("api", "dashboards", "db").
Body(body).
SetHeader("Content-type", "application/json").
Do(ctx).
StatusCode(&statusCode)
require.Equal(t, int(http.StatusOK), statusCode) // already exists
body, _ = rsp.Raw()
err = json.Unmarshal(body, &obj.Object)
require.NoError(t, err)
require.Equal(t, name+"-legacy", obj.Object["uid"])
require.Equal(t, float64(internalId), obj.Object["id"]) // same internal ID
})
}
})
@@ -174,6 +174,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
dashboard.timezone = timeRange.timeZone;
}
delete dashboard.id; // Make sure we never save an internal ID
return sortedDeepCloneWithoutNulls(dashboard, true);
}