Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add izanami as feature flipping provider #2507

Merged
merged 10 commits into from
Apr 4, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions Gopkg.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 0 additions & 4 deletions Gopkg.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,6 @@
name = "github.com/fatih/structs"
version = "1.0.0"

[[constraint]]
name = "github.com/fsamin/go-dump"
version = "1.0.3"

[[constraint]]
name = "github.com/fsamin/go-shredder"
version = "1.0.0"
Expand Down
17 changes: 17 additions & 0 deletions engine/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/ovh/cds/engine/api/cache"
"github.com/ovh/cds/engine/api/database"
"github.com/ovh/cds/engine/api/event"
"github.com/ovh/cds/engine/api/feature"
"github.com/ovh/cds/engine/api/hatchery"
"github.com/ovh/cds/engine/api/hook"
"github.com/ovh/cds/engine/api/mail"
Expand Down Expand Up @@ -127,6 +128,14 @@ type Configuration struct {
Password string `toml:"password"`
} `toml:"kafka"`
} `toml:"events" comment:"#######################\n CDS Events Settings \n######################"`
FeaturesFlipping struct {
Izanami struct {
ApiURL string `toml:"api_url"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
Token string `toml:"token" comment:"Token shared between Izanami and CDS to be able to send webhooks from izanami"`
} `toml:"izanami" comment:"Feature flipping provider: https://maif.github.io/izanami"`
} `toml:"features" comment:"###########################\n CDS Features flipping Settings \n##########################"`
Schedulers struct {
Disabled bool `toml:"disabled" default:"false" commented:"true" comment:"This is mainly for dev purpose, you should not have to change it"`
} `toml:"schedulers" comment:"###########################\n CDS Schedulers Settings \n##########################"`
Expand Down Expand Up @@ -352,6 +361,14 @@ func (a *API) Serve(ctx context.Context) error {
a.Config.SMTP.TLS,
a.Config.SMTP.Disable)

// Initialize feature package
log.Info("Initializing feature flipping with izanami %s", a.Config.FeaturesFlipping.Izanami.ApiURL)
if a.Config.FeaturesFlipping.Izanami.ApiURL != "" {
if err := feature.Init(a.Config.FeaturesFlipping.Izanami.ApiURL, a.Config.FeaturesFlipping.Izanami.ClientID, a.Config.FeaturesFlipping.Izanami.ClientSecret); err != nil {
return fmt.Errorf("Feature flipping not enabled with izanami: %s", err)
}
}

//Initialize artifacts storage
log.Info("Initializing %s objectstore...", a.Config.Artifact.Mode)
var objectstoreKind objectstore.Kind
Expand Down
3 changes: 3 additions & 0 deletions engine/api/api_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ func (api *API) InitRouter() {
// SSE
r.Handle("/mon/lastupdates/events", r.GET(api.lastUpdateBroker.ServeHTTP))

// Feature
r.Handle("/feature/clean", r.POST(api.cleanFeatureHandler, NeedToken("X-Izanami-Token", api.Config.FeaturesFlipping.Izanami.Token), Auth(false)))

// Engine µServices
r.Handle("/services/register", r.POST(api.postServiceRegisterHandler, Auth(false)))

Expand Down
15 changes: 15 additions & 0 deletions engine/api/feature.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package api

import (
"context"
"net/http"

"github.com/ovh/cds/engine/api/feature"
)

func (api *API) cleanFeatureHandler() Handler {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test (to cover the router)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it will be overkill no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
feature.Clean(api.Cache)
return nil
}
}
82 changes: 82 additions & 0 deletions engine/api/feature/flipping.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package feature

import (
"github.com/ovhlabs/izanami-go-client"

"github.com/ovh/cds/engine/api/cache"
"github.com/ovh/cds/sdk"
"github.com/ovh/cds/sdk/log"
)

const (
// FeatWorkflowAsCode is workflow as code feature id
FeatWorkflowAsCode = "cds:wasc"

cacheFeatureKey = "feature:"
)

var c *client.Client

// CheckContext represents the context send to izanami to check if the feature is enabled
type CheckContext struct {
Key string `json:"key"`
}

// List all features
func List() []string {
return []string{FeatWorkflowAsCode}
}

// Init initialize izanami client
func Init(apiURL, clientID, clientSecret string) error {
var errC error
c, errC = client.New(apiURL, clientID, clientSecret)
return errC
}

// GetFromCache get feature tree for the given project from cache
func GetFromCache(store cache.Store, projectKey string) sdk.ProjectFeatures {
projFeats := sdk.ProjectFeatures{}
store.Get(cacheFeatureKey+projectKey, &projFeats)
return projFeats
}

// IsEnabled check if feature is enabled for the given project
func IsEnabled(cache cache.Store, featureID string, projectKey string) bool {
// No feature flipping
if c == nil {
return true
}

var projFeats sdk.ProjectFeatures

// Get from cache
if !cache.Get(cacheFeatureKey+projectKey, &projFeats) {
if v, ok := projFeats.Features[featureID]; ok {
return v
}
}

// Get from izanami
resp, errCheck := c.Feature().CheckWithContext(featureID, CheckContext{projectKey})
if errCheck != nil {
log.Warning("Feature.IsEnabled > Cannot check feature %s: %s", featureID, errCheck)
return false
}
projFeats.Key = projectKey
if projFeats.Features == nil {
projFeats.Features = make(map[string]bool)
}
projFeats.Features[featureID] = resp.Active

// Push in cache
cache.Set(projectKey, projFeats)

return resp.Active
}

// Clean the feature cache
func Clean(store cache.Store) {
keys := cache.Key(cacheFeatureKey, "*")
store.DeleteAll(keys)
}
54 changes: 54 additions & 0 deletions engine/api/feature_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package api

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"

"github.com/go-gorp/gorp"
"github.com/gorilla/mux"
"github.com/ovh/cds/engine/api/auth"
"github.com/ovh/cds/engine/api/bootstrap"
"github.com/ovh/cds/engine/api/event"
"github.com/ovh/cds/engine/api/sessionstore"
"github.com/ovh/cds/engine/api/test"
"time"
)

func newTestAPIWithIzanamiToken(t *testing.T, token string, bootstrapFunc ...test.Bootstrapf) (*API, *gorp.DbMap, *Router) {
bootstrapFunc = append(bootstrapFunc, bootstrap.InitiliazeDB)
db, cache := test.SetupPG(t, bootstrapFunc...)
router := newRouter(auth.TestLocalAuth(t, db, sessionstore.Options{Cache: cache, TTL: 30}), mux.NewRouter(), "/"+test.GetTestName(t))
api := &API{
StartupTime: time.Now(),
Router: router,
DBConnectionFactory: test.DBConnectionFactory,
Config: Configuration{},
Cache: cache,
}
api.Config.FeaturesFlipping.Izanami.Token = token
event.Cache = api.Cache
api.InitRouter()
return api, db, router
}

func TestFeatureClean(t *testing.T) {
api, _, router := newTestAPIWithIzanamiToken(t, "mytoken", bootstrap.InitiliazeDB)

vars := map[string]string{}
uri := router.GetRoute("POST", api.cleanFeatureHandler, vars)
req, err := http.NewRequest("POST", uri, nil)
test.NoError(t, err)
req.Header.Set("X-Izanami-Token", "666")

w := httptest.NewRecorder()
router.Mux.ServeHTTP(w, req)
assert.Equal(t, 401, w.Code)

w = httptest.NewRecorder()
req.Header.Set("X-Izanami-Token", "mytoken")
router.Mux.ServeHTTP(w, req)
assert.Equal(t, 204, w.Code)
}
10 changes: 10 additions & 0 deletions engine/api/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"net/http"
"strconv"
"strings"

"github.com/go-gorp/gorp"
"github.com/gorilla/mux"
Expand Down Expand Up @@ -60,6 +61,15 @@ func (api *API) deletePermissionMiddleware(ctx context.Context, w http.ResponseW
func (api *API) authMiddleware(ctx context.Context, w http.ResponseWriter, req *http.Request, rc *HandlerConfig) (context.Context, error) {
headers := req.Header

// Check Token
if h, ok := rc.Options["token"]; ok {
headerSplitted := strings.Split(h, ":")
receivedValue := req.Header.Get(headerSplitted[0])
if receivedValue != headerSplitted[1] {
return ctx, sdk.WrapError(sdk.ErrUnauthorized, "Router> Authorization denied on %s %s for %s", req.Method, req.URL, req.RemoteAddr)
}
}

//Check Authentication
if rc.Options["auth"] == "true" {
switch headers.Get("User-Agent") {
Expand Down
4 changes: 4 additions & 0 deletions engine/api/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ func (api *API) getProjectHandler() Handler {
withWorkflows := FormBool(r, "withWorkflows")
withWorkflowNames := FormBool(r, "withWorkflowNames")
withPlatforms := FormBool(r, "withPlatforms")
withFeatures := FormBool(r, "withFeatures")

opts := []project.LoadOptionFunc{}
if withVariables {
Expand Down Expand Up @@ -181,6 +182,9 @@ func (api *API) getProjectHandler() Handler {
if withPlatforms {
opts = append(opts, project.LoadOptions.WithPlatforms)
}
if withFeatures {
opts = append(opts, project.LoadOptions.WithFeatures)
}

p, errProj := project.Load(api.mustDB(), api.Cache, key, getUser(ctx), opts...)
if errProj != nil {
Expand Down
2 changes: 2 additions & 0 deletions engine/api/project/dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ var LoadOptions = struct {
WithClearKeys LoadOptionFunc
WithPlatforms LoadOptionFunc
WithClearPlatforms LoadOptionFunc
WithFeatures LoadOptionFunc
}{
Default: &loadDefault,
WithPipelines: &loadPipelines,
Expand All @@ -315,6 +316,7 @@ var LoadOptions = struct {
WithClearKeys: &loadClearKeys,
WithPlatforms: &loadPlatforms,
WithClearPlatforms: &loadClearPlatforms,
WithFeatures: &loadFeatures,
}

// LoadProjectByNodeJobRunID return a project from node job run id
Expand Down
4 changes: 4 additions & 0 deletions engine/api/project/dao_dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ var (
return nil
}

loadFeatures = func(db gorp.SqlExecutor, store cache.Store, proj *sdk.Project, u *sdk.User) error {
return LoadFeatures(store, proj)
}

loadClearPlatforms = func(db gorp.SqlExecutor, store cache.Store, proj *sdk.Project, u *sdk.User) error {
pf, err := LoadPlatformsByID(db, proj.ID, true)
if err != nil {
Expand Down
16 changes: 16 additions & 0 deletions engine/api/project/features.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package project

import (
"github.com/ovh/cds/engine/api/cache"
"github.com/ovh/cds/engine/api/feature"
"github.com/ovh/cds/sdk"
)

func LoadFeatures(store cache.Store, p *sdk.Project) error {
for _, f := range feature.List() {
// force load cache for the given project
feature.IsEnabled(store, f, p.Key)
}
p.Features = feature.GetFromCache(store, p.Key)
return nil
}
8 changes: 8 additions & 0 deletions engine/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,14 @@ func NeedAdmin(admin bool) HandlerConfigParam {
return f
}

// NeedToken set the route for requests that have the given header
func NeedToken(k, v string) HandlerConfigParam {
f := func(rc *HandlerConfig) {
rc.Options["token"] = fmt.Sprintf("%s:%s", k, v)
}
return f
}

// NeedUsernameOrAdmin set the route for cds admin or current user = username called on route
func NeedUsernameOrAdmin(need bool) HandlerConfigParam {
f := func(rc *HandlerConfig) {
Expand Down
Loading