Provisioning: Use inline secrets for gitsync (#109908)

Co-authored-by: Clarity-89 <homes89@ukr.net>
Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
This commit is contained in:
Ryan McKinley
2025-08-22 18:38:28 +02:00
committed by GitHub
co-authored by Clarity-89 Roberto Jimenez Sanchez
parent 04f392d37b
commit ce65391067
69 changed files with 694 additions and 4255 deletions
@@ -2605,20 +2605,10 @@
"type": "string",
"default": ""
},
"encryptedToken": {
"description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.",
"type": "string",
"format": "byte",
"x-kubernetes-list-type": "atomic"
},
"path": {
"description": "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.",
"type": "string"
},
"token": {
"description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.",
"type": "string"
},
"tokenUser": {
"description": "TokenUser is the user that will be used to access the repository if it's a personal access token.",
"type": "string"
@@ -2761,12 +2751,6 @@
"type": "string",
"default": ""
},
"encryptedToken": {
"description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.",
"type": "string",
"format": "byte",
"x-kubernetes-list-type": "atomic"
},
"generateDashboardPreviews": {
"description": "Whether we should show dashboard previews for pull requests. By default, this is false (i.e. we will not create previews).",
"type": "boolean"
@@ -2775,10 +2759,6 @@
"description": "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.",
"type": "string"
},
"token": {
"description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.",
"type": "string"
},
"url": {
"description": "The repository URL (e.g. `https://github.com/example/test`).",
"type": "string"
@@ -2796,20 +2776,10 @@
"type": "string",
"default": ""
},
"encryptedToken": {
"description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.",
"type": "string",
"format": "byte",
"x-kubernetes-list-type": "atomic"
},
"path": {
"description": "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.",
"type": "string"
},
"token": {
"description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.",
"type": "string"
},
"url": {
"description": "The repository URL (e.g. `https://gitlab.com/example/test`).",
"type": "string"
@@ -2827,20 +2797,10 @@
"type": "string",
"default": ""
},
"encryptedToken": {
"description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.",
"type": "string",
"format": "byte",
"x-kubernetes-list-type": "atomic"
},
"path": {
"description": "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.",
"type": "string"
},
"token": {
"description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.",
"type": "string"
},
"tokenUser": {
"description": "TokenUser is the user that will be used to access the repository if it's a personal access token.",
"type": "string"
@@ -4213,7 +4173,6 @@
]
},
"com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.SecureValues": {
"description": "NOT YET USED FOR REAL -- testing secure value workflow",
"type": "object",
"properties": {
"token": {
@@ -4226,7 +4185,7 @@
]
},
"webhookSecret": {
"description": "Some webhooks (github) require a secret key value",
"description": "Some webhooks (including github) require a secret key value",
"default": {},
"allOf": [
{
@@ -4422,10 +4381,6 @@
"com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.WebhookStatus": {
"type": "object",
"properties": {
"encryptedSecret": {
"type": "string",
"format": "byte"
},
"id": {
"type": "integer",
"format": "int64"
@@ -4434,9 +4389,6 @@
"type": "integer",
"format": "int64"
},
"secret": {
"type": "string"
},
"subscribedEvents": {
"type": "array",
"items": {
@@ -570,17 +570,9 @@ func withLogs(opts *testinfra.GrafanaOpts) {
opts.EnableLog = true
}
func useAppPlatformSecrets(opts *testinfra.GrafanaOpts) {
opts.EnableFeatureToggles = append(opts.EnableFeatureToggles,
featuremgmt.FlagProvisioningSecretsService,
featuremgmt.FlagSecretsManagementAppPlatform,
)
}
func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper {
provisioningPath := t.TempDir()
opts := testinfra.GrafanaOpts{
AppModeProduction: false, // required for experimental APIs
EnableFeatureToggles: []string{
featuremgmt.FlagProvisioning,
},
@@ -49,12 +49,12 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
require.NoError(t, err, "failed to read back resource")
// Move encrypted token mutation
token, found, err := unstructured.NestedString(output.Object, "spec", "github", "encryptedToken")
require.NoError(t, err, "encryptedToken is not a string")
token, found, err := unstructured.NestedString(output.Object, "secure", "token", "name")
require.NoError(t, err, "secure token name is not a string")
if found {
unstructured.RemoveNestedField(input.Object, "spec", "github", "token")
err = unstructured.SetNestedField(input.Object, token, "spec", "github", "encryptedToken")
require.NoError(t, err, "unable to copy encrypted token")
require.True(t, strings.HasPrefix("inline-", token)) // name created automatically
err = unstructured.SetNestedField(input.Object, token, "secure", "token", "name")
require.NoError(t, err, "unable to copy secure token")
}
// Marshal as real objects to ",omitempty" values are tested properly
+2 -525
View File
@@ -2,9 +2,6 @@ package provisioning
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"testing"
"time"
@@ -13,10 +10,6 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
)
func TestIntegrationProvisioning_InlineSecrets(t *testing.T) {
@@ -24,12 +17,12 @@ func TestIntegrationProvisioning_InlineSecrets(t *testing.T) {
t.Skip("skipping integration test")
}
helper := runGrafana(t, useAppPlatformSecrets)
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
decryptService := helper.GetEnv().DecryptService
require.NotNil(t, decryptService, "decrypt service wired properly")
require.NotNil(t, decryptService, "decrypt service not wired properly")
type expectedField struct {
Path []string
@@ -107,519 +100,3 @@ func TestIntegrationProvisioning_InlineSecrets(t *testing.T) {
})
}
}
func TestIntegrationProvisioning_LegacySecrets(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
type expectedField struct {
Path []string
ExpectedDecryptedValue string
}
secretsService := helper.GetEnv().RepositorySecrets
tests := []struct {
name string
values map[string]any
inputFile string
expectedFields []expectedField
}{
{
name: "github token encrypted",
values: map[string]any{
"Token": "some-token",
},
inputFile: "testdata/github-readonly.json.tmpl",
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedDecryptedValue: "some-token",
},
},
},
{
name: "git token encrypted",
values: map[string]any{
"Token": "some-token",
},
inputFile: "testdata/git-readonly.json.tmpl",
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedDecryptedValue: "some-token",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := helper.RenderObject(t, test.inputFile, test.values)
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
name := mustNestedString(input.Object, "metadata", "name")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
repo := unstructuredToRepository(t, output)
// Move encrypted token mutation
for _, expectedField := range test.expectedFields {
value, decrypted := encryptedField(t, secretsService, repo, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
require.False(t, strings.HasPrefix(value, name), "value should not be prefixed with the repository name")
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
}
})
}
}
func TestIntegrationProvisioning_Secrets_LegacyUpdate(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
updateOptions := metav1.UpdateOptions{}
ctx := context.Background()
secretsService := helper.GetEnv().RepositorySecrets
type expectedField struct {
Path []string
ExpectedValue string
ExpectedDecryptedValue string
}
tests := []struct {
name string
values map[string]any
inputFile string
updateValues map[string]any
expectedFields []expectedField
}{
{
name: "update github token (legacy secrets)",
values: map[string]any{
"Token": "initial-token",
},
inputFile: "testdata/github-readonly.json.tmpl",
updateValues: map[string]any{
"Token": "updated-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedDecryptedValue: "updated-token",
},
},
},
{
name: "update git token (legacy secrets)",
values: map[string]any{
"Token": "initial-token",
},
inputFile: "testdata/git-readonly.json.tmpl",
updateValues: map[string]any{
"Token": "updated-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedDecryptedValue: "updated-token",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Create initial resource
input := helper.RenderObject(t, test.inputFile, test.values)
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
name := mustNestedString(input.Object, "metadata", "name")
// Prepare updated resource
updatedInput := helper.RenderObject(t, test.inputFile, test.updateValues)
// Set the same name and resourceVersion for update
updatedInput.Object["metadata"].(map[string]any)["name"] = name
// Fetch current resourceVersion
current, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to get current resource for update")
updatedInput.Object["metadata"].(map[string]any)["resourceVersion"] = current.Object["metadata"].(map[string]any)["resourceVersion"]
_, err = helper.Repositories.Resource.Update(ctx, updatedInput, updateOptions)
require.NoError(t, err, "failed to update resource")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource after update")
repo := unstructuredToRepository(t, output)
for _, expectedField := range test.expectedFields {
value, decrypted := encryptedField(t, secretsService, repo, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
require.False(t, strings.HasPrefix(value, name), "value should not be prefixed with the repository name")
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
}
})
}
}
func TestIntegrationProvisioning_Secrets(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t, useAppPlatformSecrets)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
secretsService := helper.GetEnv().RepositorySecrets
type expectedField struct {
Path []string
ExpectedValue string
ExpectedDecryptedValue string
}
// TODO: Add test of fallbacks
tests := []struct {
name string
values map[string]any
inputFile string
expectedFields []expectedField
}{
{
name: "github token encrypted",
values: map[string]any{
"Token": "some-token",
},
inputFile: "testdata/github-readonly.json.tmpl",
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedValue: "github-token",
ExpectedDecryptedValue: "some-token",
},
},
},
{
name: "git token encrypted",
values: map[string]any{
"Token": "some-token",
},
inputFile: "testdata/git-readonly.json.tmpl",
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedValue: "git-token",
ExpectedDecryptedValue: "some-token",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := helper.RenderObject(t, test.inputFile, test.values)
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
name := mustNestedString(input.Object, "metadata", "name")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
repo := unstructuredToRepository(t, output)
// Move encrypted token mutation
for _, expectedField := range test.expectedFields {
value, decrypted := encryptedField(t, secretsService, repo, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
if expectedField.ExpectedValue != "" {
require.Equal(t, name+"-"+expectedField.ExpectedValue, value)
}
if expectedField.ExpectedDecryptedValue != "" {
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
}
}
})
}
}
func TestIntegrationProvisioning_Secrets_Update(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
helper := runGrafana(t, useAppPlatformSecrets)
secretsService := helper.GetEnv().RepositorySecrets
createOptions := metav1.CreateOptions{}
updateOptions := metav1.UpdateOptions{}
type expectedField struct {
Path []string
ExpectedValue string
ExpectedDecryptedValue string
}
tests := []struct {
name string
inputFile string
values map[string]interface{}
updateValues map[string]interface{}
expectedFields []expectedField
updatedFields []expectedField
}{
{
name: "update encrypted git token",
inputFile: "testdata/git-readonly.json.tmpl",
values: map[string]interface{}{
"Token": "initial-token",
},
updateValues: map[string]interface{}{
"Token": "updated-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedValue: "git-token",
ExpectedDecryptedValue: "initial-token",
},
},
updatedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedValue: "git-token",
ExpectedDecryptedValue: "updated-token",
},
},
},
{
name: "update encrypted github token",
inputFile: "testdata/github-readonly.json.tmpl",
values: map[string]interface{}{
"Token": "initial-token",
},
updateValues: map[string]interface{}{
"Token": "updated-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedValue: "github-token",
ExpectedDecryptedValue: "initial-token",
},
},
updatedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedValue: "github-token",
ExpectedDecryptedValue: "updated-token",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Create initial resource
input := helper.RenderObject(t, test.inputFile, test.values)
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
name := mustNestedString(input.Object, "metadata", "name")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
// Update the resource
updatedInput := helper.RenderObject(t, test.inputFile, test.updateValues)
// Set the same name and resourceVersion for update
_ = unstructured.SetNestedField(updatedInput.Object, name, "metadata", "name")
_ = unstructured.SetNestedField(updatedInput.Object, output.GetResourceVersion(), "metadata", "resourceVersion")
_, err = helper.Repositories.Resource.Update(ctx, updatedInput, updateOptions)
require.NoError(t, err, "failed to update resource")
updatedOutput, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back updated resource")
updatedRepo := unstructuredToRepository(t, updatedOutput)
// Check updated fields
for _, expectedField := range test.updatedFields {
value, decrypted := encryptedField(t, secretsService, updatedRepo, updatedOutput.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
if expectedField.ExpectedValue != "" {
require.Equal(t, name+"-"+expectedField.ExpectedValue, value)
}
if expectedField.ExpectedDecryptedValue != "" {
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
}
}
})
}
}
func TestIntegrationProvisioning_Secrets_Removal(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
helper := runGrafana(t, useAppPlatformSecrets)
secretsService := helper.GetEnv().RepositorySecrets
createOptions := metav1.CreateOptions{}
type expectedField struct {
Path []string
}
tests := []struct {
name string
inputFile string
values map[string]interface{}
expectedFields []expectedField
updatedFields []expectedField
}{
{
name: "remove encrypted git token",
inputFile: "testdata/git-readonly.json.tmpl",
values: map[string]interface{}{
"Token": "initial-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "encryptedToken"},
},
},
},
{
name: "remove encrypted github token",
inputFile: "testdata/github-readonly.json.tmpl",
values: map[string]interface{}{
"Token": "initial-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "encryptedToken"},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Create initial resource
input := helper.RenderObject(t, test.inputFile, test.values)
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
name := mustNestedString(input.Object, "metadata", "name")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
repo := unstructuredToRepository(t, output)
// Set the same name and resourceVersion for update
err = helper.Repositories.Resource.Delete(ctx, name, metav1.DeleteOptions{})
require.NoError(t, err, "failed to delete resource")
for _, expectedField := range test.expectedFields {
secretName, found, err := base64DecodedField(output.Object, expectedField.Path)
require.NoError(t, err, "failed to decode base64 value")
require.True(t, found, "secretName should be found")
require.NotEmpty(t, secretName)
var lastDecrypted []byte
require.Eventually(t, func() bool {
lastDecrypted, err = secretsService.Decrypt(ctx, repo, secretName)
return err != nil && errors.Is(err, contracts.ErrDecryptNotFound)
}, 1000*time.Second, 500*time.Millisecond, "expected ErrDecryptNotFound error, got %v", lastDecrypted)
}
})
}
}
func encryptedField(t *testing.T, secretsService secrets.RepositorySecrets, repo *provisioning.Repository, obj map[string]any, path []string, expectedValue bool) (string, string) {
value, found, err := base64DecodedField(obj, path)
if err != nil {
require.NoError(t, err, "failed to decode base64 value")
}
if expectedValue {
decrypted, err := secretsService.Decrypt(context.Background(), repo, value)
require.NoError(t, err, "failed to eecrypt value")
return value, string(decrypted)
} else {
require.False(t, found, "value should not be found")
return "", ""
}
}
func base64DecodedField(obj map[string]any, path []string) (string, bool, error) {
value, found, err := unstructured.NestedFieldNoCopy(obj, path...)
if err != nil {
return "", false, err
}
if !found {
return "", false, nil
}
valueStr, ok := value.(string)
if !ok {
return "", false, fmt.Errorf("value is not a string")
}
decodedValue, err := base64.StdEncoding.DecodeString(valueStr)
if err != nil {
return "", false, fmt.Errorf("failed to decode base64 valueStr: %w", err)
}
return string(decodedValue), true, nil
}
@@ -11,7 +11,6 @@
"git": {
"url": "{{ or .URL "https://github.com/grafana/grafana-git-sync-demo" }}",
"branch": "{{ or .Branch "integration-test" }}",
"token": "{{ or .Token "" }}",
"path": "{{ or .Path "grafana/" }}"
},
"sync": {
@@ -20,5 +19,8 @@
"intervalSeconds": {{ or .SyncIntervalSeconds 60 }}
},
"workflows": []
},
"secure": {
"token": { "create": "{{ or .Token "" }}" }
}
}
@@ -12,7 +12,6 @@
"url": "{{ or .URL "https://github.com/grafana/grafana-git-sync-demo" }}",
"branch": "{{ or .Branch "integration-test" }}",
"generateDashboardPreviews": {{ if .GenerateDashboardPreviews }} true {{ else }} false {{ end }},
"token": "{{ or .Token "" }}",
"path": "{{ or .Path "grafana/" }}"
},
"sync": {
@@ -21,5 +20,8 @@
"intervalSeconds": {{ or .SyncIntervalSeconds 60 }}
},
"workflows": []
},
"secure": {
"token": { "create": "{{ or .Token "" }}" }
}
}
@@ -12,7 +12,6 @@
"url": "{{ or .URL "https://github.com/grafana/grafana-git-sync-demo" }}",
"branch": "{{ or .Branch "integration-test" }}",
"generateDashboardPreviews": {{ if .GenerateDashboardPreviews }} true {{ else }} false {{ end }},
"token": "{{ or .Token "" }}",
"path": "{{ or .Path "grafana/" }}"
}
},