diff --git a/config/flipt.schema.cue b/config/flipt.schema.cue index 11777769ea..4a25a7832b 100644 --- a/config/flipt.schema.cue +++ b/config/flipt.schema.cue @@ -435,6 +435,16 @@ import "strings" cloud?: { enabled?: bool | *false } + jira?: { + enabled?: bool | *false + instance_name: string + authentication: { + oauth: { + client_id: string + client_secret: string + } + } + } } #duration: "^([0-9]+(ns|us|µs|ms|s|m|h))+$" diff --git a/config/flipt.schema.json b/config/flipt.schema.json index dae02fac89..9a0f85a766 100644 --- a/config/flipt.schema.json +++ b/config/flipt.schema.json @@ -1475,6 +1475,36 @@ "default": false } } + }, + "jira": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "instance_name": { + "type": "string" + }, + "authentication": { + "type": "object", + "additionalProperties": false, + "properties": { + "oauth": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + } + } + } + } + } + } } }, "title": "Experimental" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 62d0b51748..d69996ea3f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -605,6 +605,21 @@ func TestLoad(t *testing.T) { return cfg }, }, + { + name: "empty instance name when jira integration is enabled", + path: "./testdata/experimental/jira_empty_instance_name.yml", + wantErr: errors.New("instance name cannot be empty when jira integration is enabled"), + }, + { + name: "empty oauth client id when jira integration is enabled", + path: "./testdata/experimental/jira_empty_oauth_client_id.yml", + wantErr: errors.New("invalid oauth parameters for jira integration"), + }, + { + name: "empty oauth client secret when jira integration is enabled", + path: "./testdata/experimental/jira_empty_oauth_client_secret.yml", + wantErr: errors.New("invalid oauth parameters for jira integration"), + }, { name: "authentication github requires read:org scope when allowing orgs", path: "./testdata/authentication/github_missing_org_scope.yml", @@ -939,6 +954,17 @@ func TestLoad(t *testing.T) { }, } + cfg.Experimental.Jira = Jira{ + Enabled: true, + InstanceName: "INSTANCE_NAME", + Authentication: JiraAuthentication{ + OAuth: JiraOauth{ + ClientID: "CLIENT_ID", + ClientSecret: "CLIENT_SECRET", + }, + }, + } + return cfg }, }, diff --git a/internal/config/experimental.go b/internal/config/experimental.go index fa2d851ab8..c62fc3a4ab 100644 --- a/internal/config/experimental.go +++ b/internal/config/experimental.go @@ -1,20 +1,57 @@ package config import ( + "errors" + "strings" + "github.com/spf13/viper" ) // ExperimentalConfig allows for experimental features to be enabled // and disabled. type ExperimentalConfig struct { + Jira Jira `json:"jira,omitempty" mapstructure:"jira" yaml:"jira,omitempty"` } func (c *ExperimentalConfig) deprecations(v *viper.Viper) []deprecated { return nil } +func (c *ExperimentalConfig) validate() error { + return c.Jira.validate() +} + // ExperimentalFlag is a structure which has properties to configure // experimental feature enablement. type ExperimentalFlag struct { Enabled bool `json:"enabled,omitempty" mapstructure:"enabled" yaml:"enabled,omitempty"` } + +type Jira struct { + Enabled bool `json:"enabled,omitempty" mapstructure:"enabled" yaml:"enabled,omitempty"` + InstanceName string `json:"instanceName,omitempty" mapstructure:"instance_name" yaml:"instance_name,omitempty"` + Authentication JiraAuthentication `json:"authentication,omitempty" mapstructure:"authentication" yaml:"authentication,omitempty"` +} + +func (j *Jira) validate() error { + if j.Enabled { + if strings.TrimSpace(j.InstanceName) == "" { + return errors.New("instance name cannot be empty when jira integration is enabled") + } + + if strings.TrimSpace(j.Authentication.OAuth.ClientID) == "" || strings.TrimSpace(j.Authentication.OAuth.ClientSecret) == "" { + return errors.New("invalid oauth parameters for jira integration") + } + } + + return nil +} + +type JiraAuthentication struct { + OAuth JiraOauth `json:"oauth,omitempty" mapstructure:"oauth" yaml:"oauth,omitempty"` +} + +type JiraOauth struct { + ClientID string `json:"-" mapstructure:"client_id" yaml:"-"` + ClientSecret string `json:"-" mapstructure:"client_secret" yaml:"-"` +} diff --git a/internal/config/testdata/advanced.yml b/internal/config/testdata/advanced.yml index 80bf6963d5..6231c472c4 100644 --- a/internal/config/testdata/advanced.yml +++ b/internal/config/testdata/advanced.yml @@ -121,3 +121,10 @@ authorization: experimental: authorization: enabled: true + jira: + enabled: true + instance_name: "INSTANCE_NAME" + authentication: + oauth: + client_id: "CLIENT_ID" + client_secret: "CLIENT_SECRET" diff --git a/internal/config/testdata/experimental/jira_empty_instance_name.yml b/internal/config/testdata/experimental/jira_empty_instance_name.yml new file mode 100644 index 0000000000..6cfd9c1a07 --- /dev/null +++ b/internal/config/testdata/experimental/jira_empty_instance_name.yml @@ -0,0 +1,61 @@ +log: + level: INFO + encoding: console + grpc_level: ERROR + +ui: + default_theme: system + +analytics: + buffer: + flush_period: "10s" + +cors: + enabled: false + allowed_origins: + - "*" + allowed_headers: + - "Accept" + - "Authorization" + - "Content-Type" + - "X-CSRF-Token" + - "X-Fern-Language" + - "X-Fern-SDK-Name" + - "X-Fern-SDK-Version" + - "X-Flipt-Namespace" + - "X-Flipt-Accept-Server-Version" + +server: + host: 0.0.0.0 + http_port: 8080 + https_port: 443 + grpc_port: 9000 + +metrics: + enabled: true + exporter: prometheus + +storage: + type: database + +diagnostics: + profiling: + enabled: true + +db: + url: file:/var/opt/flipt/flipt.db + max_idle_conn: 2 + prepared_statements_enabled: true + +experimental: + jira: + enabled: true + instance_name: "" + authentication: + oauth: + client_id: "CLIENT_ID" + client_secret: "CLIENT_SECRET" + +meta: + check_for_updates: true + telemetry_enabled: true diff --git a/internal/config/testdata/experimental/jira_empty_oauth_client_id.yml b/internal/config/testdata/experimental/jira_empty_oauth_client_id.yml new file mode 100644 index 0000000000..3a1f993ab0 --- /dev/null +++ b/internal/config/testdata/experimental/jira_empty_oauth_client_id.yml @@ -0,0 +1,61 @@ +log: + level: INFO + encoding: console + grpc_level: ERROR + +ui: + default_theme: system + +analytics: + buffer: + flush_period: "10s" + +cors: + enabled: false + allowed_origins: + - "*" + allowed_headers: + - "Accept" + - "Authorization" + - "Content-Type" + - "X-CSRF-Token" + - "X-Fern-Language" + - "X-Fern-SDK-Name" + - "X-Fern-SDK-Version" + - "X-Flipt-Namespace" + - "X-Flipt-Accept-Server-Version" + +server: + host: 0.0.0.0 + http_port: 8080 + https_port: 443 + grpc_port: 9000 + +metrics: + enabled: true + exporter: prometheus + +storage: + type: database + +diagnostics: + profiling: + enabled: true + +db: + url: file:/var/opt/flipt/flipt.db + max_idle_conn: 2 + prepared_statements_enabled: true + +experimental: + jira: + enabled: true + instance_name: "INSTANCE_NAME" + authentication: + oauth: + client_id: "" + client_secret: "CLIENT_SECRET" + +meta: + check_for_updates: true + telemetry_enabled: true diff --git a/internal/config/testdata/experimental/jira_empty_oauth_client_secret.yml b/internal/config/testdata/experimental/jira_empty_oauth_client_secret.yml new file mode 100644 index 0000000000..462da2043f --- /dev/null +++ b/internal/config/testdata/experimental/jira_empty_oauth_client_secret.yml @@ -0,0 +1,61 @@ +log: + level: INFO + encoding: console + grpc_level: ERROR + +ui: + default_theme: system + +analytics: + buffer: + flush_period: "10s" + +cors: + enabled: false + allowed_origins: + - "*" + allowed_headers: + - "Accept" + - "Authorization" + - "Content-Type" + - "X-CSRF-Token" + - "X-Fern-Language" + - "X-Fern-SDK-Name" + - "X-Fern-SDK-Version" + - "X-Flipt-Namespace" + - "X-Flipt-Accept-Server-Version" + +server: + host: 0.0.0.0 + http_port: 8080 + https_port: 443 + grpc_port: 9000 + +metrics: + enabled: true + exporter: prometheus + +storage: + type: database + +diagnostics: + profiling: + enabled: true + +db: + url: file:/var/opt/flipt/flipt.db + max_idle_conn: 2 + prepared_statements_enabled: true + +experimental: + jira: + enabled: true + instance_name: "INSTANCE_NAME" + authentication: + oauth: + client_id: "CLIENT_ID" + client_secret: "" + +meta: + check_for_updates: true + telemetry_enabled: true diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 23993a03d1..f2f91b3897 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -53,16 +53,20 @@ type analytics struct { Storage string `json:"storage,omitempty"` } +type experimental struct { + Jira bool `json:"jira,omitempty"` +} + type flipt struct { - Version string `json:"version"` - OS string `json:"os"` - Arch string `json:"arch"` - Storage *storage `json:"storage,omitempty"` - Authentication *authentication `json:"authentication,omitempty"` - Audit *audit `json:"audit,omitempty"` - Tracing *tracing `json:"tracing,omitempty"` - Analytics *analytics `json:"analytics,omitempty"` - Experimental config.ExperimentalConfig `json:"experimental,omitempty"` + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` + Storage *storage `json:"storage,omitempty"` + Authentication *authentication `json:"authentication,omitempty"` + Audit *audit `json:"audit,omitempty"` + Tracing *tracing `json:"tracing,omitempty"` + Analytics *analytics `json:"analytics,omitempty"` + Experimental experimental `json:"experimental,omitempty"` } type state struct { @@ -193,10 +197,9 @@ func (r *Reporter) ping(_ context.Context, f file) error { var ( props = segment.NewProperties() flipt = flipt{ - OS: info.OS, - Arch: info.Arch, - Version: info.Version, - Experimental: r.cfg.Experimental, + OS: info.OS, + Arch: info.Arch, + Version: info.Version, } ) @@ -268,6 +271,8 @@ func (r *Reporter) ping(_ context.Context, f file) error { } } + flipt.Experimental.Jira = r.cfg.Experimental.Jira.Enabled + p := ping{ Version: version, UUID: s.UUID, diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 354b448af7..6ae9ddabeb 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -86,7 +86,7 @@ func TestShutdown(t *testing.T) { assert.True(t, mockAnalytics.closed) } -var experimental = map[string]any{} +var defaultExperimental = map[string]any{} func TestPing(t *testing.T) { test := []struct { @@ -108,7 +108,7 @@ func TestPing(t *testing.T) { "storage": map[string]any{ "database": "sqlite", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -125,7 +125,7 @@ func TestPing(t *testing.T) { "storage": map[string]any{ "database": "sqlite", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -142,7 +142,7 @@ func TestPing(t *testing.T) { "storage": map[string]any{ "database": "unknown", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -163,7 +163,7 @@ func TestPing(t *testing.T) { "storage": map[string]any{ "database": "sqlite", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -185,7 +185,7 @@ func TestPing(t *testing.T) { "database": "sqlite", "cache": "redis", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -210,7 +210,7 @@ func TestPing(t *testing.T) { "storage": map[string]any{ "database": "sqlite", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -240,7 +240,7 @@ func TestPing(t *testing.T) { "token", }, }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -264,7 +264,7 @@ func TestPing(t *testing.T) { "storage": map[string]any{ "database": "sqlite", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -293,7 +293,7 @@ func TestPing(t *testing.T) { "log", }, }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -317,7 +317,7 @@ func TestPing(t *testing.T) { "storage": map[string]any{ "database": "sqlite", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -346,7 +346,7 @@ func TestPing(t *testing.T) { "webhook", }, }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -378,7 +378,7 @@ func TestPing(t *testing.T) { "log", "webhook", }, }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -399,7 +399,7 @@ func TestPing(t *testing.T) { "storage": map[string]any{ "database": "sqlite", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -423,7 +423,7 @@ func TestPing(t *testing.T) { "tracing": map[string]any{ "exporter": "otlp", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -448,7 +448,7 @@ func TestPing(t *testing.T) { "storage": map[string]any{ "database": "sqlite", }, - "experimental": experimental, + "experimental": defaultExperimental, }, }, { @@ -476,7 +476,38 @@ func TestPing(t *testing.T) { "analytics": map[string]any{ "storage": "clickhouse", }, - "experimental": experimental, + "experimental": defaultExperimental, + }, + }, + { + name: "with experimental jira enabled", + cfg: config.Config{ + Database: config.DatabaseConfig{ + Protocol: config.DatabaseSQLite, + }, + Experimental: config.ExperimentalConfig{ + Jira: config.Jira{ + Enabled: true, + InstanceName: "INSTANCE_NAME", + Authentication: config.JiraAuthentication{ + OAuth: config.JiraOauth{ + ClientID: "CLIENT_ID", + ClientSecret: "CLIENT_SECRET", + }, + }, + }, + }, + }, + want: map[string]any{ + "version": "1.0.0", + "os": "linux", + "arch": "amd64", + "storage": map[string]any{ + "database": "sqlite", + }, + "experimental": map[string]any{ + "jira": true, + }, }, }, }