discovery: Make status updates non blocking (#6345)

Fixes #6343

A status endpoint that times out, will no longer delay boots.

Signed-off-by: Charlie Egan <charlie@styra.com>
This commit is contained in:
Charlie Egan
2023-11-06 16:19:09 -06:00
committed by GitHub
parent 2acef3bb79
commit f102042ea8
6 changed files with 232 additions and 73 deletions
+1
View File
@@ -257,6 +257,7 @@ func (c *Discovery) loadAndActivateBundleFromDisk(ctx context.Context) {
})
c.logger.Debug("Discovery bundle loaded from disk and activated successfully.")
return
}
}
}
+171 -30
View File
@@ -11,12 +11,12 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"sync"
"testing"
"time"
@@ -1467,12 +1467,12 @@ func TestProcessBundleWithNoSigningConfig(t *testing.T) {
type testServer struct {
t *testing.T
mtx sync.Mutex
server *httptest.Server
updates []status.UpdateRequestV1
updates chan status.UpdateRequestV1
}
func (ts *testServer) Start() {
ts.updates = make(chan status.UpdateRequestV1, 100)
ts.server = httptest.NewServer(http.HandlerFunc(ts.handle))
}
@@ -1480,12 +1480,6 @@ func (ts *testServer) Stop() {
ts.server.Close()
}
func (ts *testServer) Updates() []status.UpdateRequestV1 {
ts.mtx.Lock()
defer ts.mtx.Unlock()
return ts.updates
}
func (ts *testServer) handle(w http.ResponseWriter, r *http.Request) {
var update status.UpdateRequestV1
@@ -1494,11 +1488,7 @@ func (ts *testServer) handle(w http.ResponseWriter, r *http.Request) {
ts.t.Fatal(err)
}
func() {
ts.mtx.Lock()
defer ts.mtx.Unlock()
ts.updates = append(ts.updates, update)
}()
ts.updates <- update
w.WriteHeader(200)
}
@@ -1557,24 +1547,175 @@ func TestStatusUpdates(t *testing.T) {
disco.oneShot(ctx, download.Update{ETag: "etag-2"})
// Check that all updates were received and active revisions are expected.
var ok bool
var updates []status.UpdateRequestV1
t0 := time.Now()
for !ok && time.Since(t0) < time.Second {
updates = ts.Updates()
ok = len(updates) == 7 &&
updates[0].Plugins["discovery"].State == plugins.StateNotReady && updates[0].Plugins["status"].State == plugins.StateOK &&
updates[1].Plugins["discovery"].State == plugins.StateOK && updates[1].Plugins["status"].State == plugins.StateOK &&
updates[2].Plugins["discovery"].State == plugins.StateOK && updates[2].Discovery.ActiveRevision == "test-revision-1" && updates[2].Discovery.Code == "" &&
updates[3].Plugins["discovery"].State == plugins.StateOK && updates[3].Discovery.ActiveRevision == "test-revision-1" && updates[3].Discovery.Code == "bundle_error" &&
updates[4].Plugins["discovery"].State == plugins.StateOK && updates[4].Discovery.ActiveRevision == "test-revision-2" && updates[4].Discovery.Code == "" &&
updates[5].Plugins["discovery"].State == plugins.StateOK && updates[5].Discovery.ActiveRevision == "test-revision-2" && updates[5].Discovery.Code == "bundle_error" &&
updates[6].Plugins["discovery"].State == plugins.StateOK && updates[6].Discovery.ActiveRevision == "test-revision-2" && updates[6].Discovery.Code == ""
expectedDiscoveryUpdates := []struct {
Code string
Revision string
}{
{
Code: "",
Revision: "test-revision-1",
},
{
Code: "bundle_error",
Revision: "test-revision-1",
},
{
Code: "",
Revision: "test-revision-2",
},
{
Code: "bundle_error",
Revision: "test-revision-2",
},
{
Code: "",
Revision: "test-revision-2",
},
}
if !ok {
t.Fatalf("Did not receive expected updates before timeout expired. Received: %+v", updates)
// nextExpectedDiscoveryUpdate, we look for each
nextExpectedDiscoveryUpdate := expectedDiscoveryUpdates[0]
expectedDiscoveryUpdates = expectedDiscoveryUpdates[1:]
timeout, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
for {
select {
case update := <-ts.updates:
if update.Discovery != nil {
matches := false
if update.Discovery.Code == nextExpectedDiscoveryUpdate.Code &&
update.Discovery.ActiveRevision == nextExpectedDiscoveryUpdate.Revision {
matches = true
}
if matches {
if len(expectedDiscoveryUpdates) == 0 {
return
}
nextExpectedDiscoveryUpdate = expectedDiscoveryUpdates[0]
expectedDiscoveryUpdates = expectedDiscoveryUpdates[1:]
}
}
case <-timeout.Done():
cancel()
t.Fatalf("Waiting for following statuses timed out: %v", expectedDiscoveryUpdates)
}
}
}
func TestStatusUpdatesFromPersistedBundlesDontDelayBoot(t *testing.T) {
dir := t.TempDir()
// write the disco bundle to disk
discoBundle := bundleApi.Bundle{
Data: map[string]interface{}{
"discovery": map[string]interface{}{
"bundles": map[string]interface{}{
"main": map[string]interface{}{
"persist": true,
"resource": "/bundle",
"service": "localhost",
},
},
"status": map[string]interface{}{
"service": "localhost",
},
},
},
}
discoBundleDir := filepath.Join(dir, "bundles", "config")
if err := os.MkdirAll(discoBundleDir, 0755); err != nil {
t.Fatal(err)
}
discoBundleFile, err := os.Create(filepath.Join(discoBundleDir, "bundle.tar.gz"))
if err != nil {
t.Fatal(err)
}
defer discoBundleFile.Close()
err = bundleApi.NewWriter(discoBundleFile).Write(discoBundle)
if err != nil {
t.Fatal(err)
}
// write an example data bundle ('main') to disk
mainBundle := bundleApi.Bundle{
Data: map[string]interface{}{
"foo": "bar",
},
}
mainBundleDir := filepath.Join(dir, "bundles", "main")
if err := os.MkdirAll(mainBundleDir, 0755); err != nil {
t.Fatal(err)
}
mainBundleFile, err := os.Create(filepath.Join(mainBundleDir, "bundle.tar.gz"))
if err != nil {
t.Fatal(err)
}
defer mainBundleFile.Close()
err = bundleApi.NewWriter(mainBundleFile).Write(mainBundle)
if err != nil {
t.Fatal(err)
}
// Create a timing out listener for the referenced localhost service
// :0 will cause net to find an available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
manager, err := plugins.New([]byte(fmt.Sprintf(`{
"persistence_directory": %q,
"services": {
"localhost": {
"url": "http://%s"
}
},
"discovery": {"name": "config", "persist": true, "decision": "discovery"},
}`, dir, listener.Addr().String())), "test-id", inmem.New())
if err != nil {
t.Fatal(err)
}
// allow 2s of time to start before failing
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// start Discovery instance, wait for it to complete Start()
booted := make(chan bool)
go func() {
disco, err := New(manager)
if err != nil {
t.Log(err)
return
}
err = disco.Start(ctx)
if err != nil {
t.Log(err)
return
}
booted <- true
}()
select {
case <-booted:
for k, pi := range manager.PluginStatus() {
if pi.State != plugins.StateOK {
t.Errorf("Expected %s plugin to be in OK state but got %v", k, pi.State)
}
}
case <-ctx.Done():
t.Errorf("Timed out waiting for disco to start")
}
}
+1
View File
@@ -15,6 +15,7 @@ import (
"go.opentelemetry.io/otel/sdk/trace"
"github.com/gorilla/mux"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/bundle"
"github.com/open-policy-agent/opa/config"
+8 -5
View File
@@ -12,9 +12,10 @@ import (
"net/http"
"reflect"
lstat "github.com/open-policy-agent/opa/plugins/logs/status"
prom "github.com/prometheus/client_golang/prometheus"
lstat "github.com/open-policy-agent/opa/plugins/logs/status"
"github.com/open-policy-agent/opa/logging"
"github.com/open-policy-agent/opa/metrics"
"github.com/open-policy-agent/opa/plugins"
@@ -204,7 +205,9 @@ func New(parsedConfig *Config, manager *plugins.Manager) *Plugin {
decisionLogsCh: make(chan lstat.Status),
stop: make(chan chan struct{}),
reconfig: make(chan reconfigure),
pluginStatusCh: make(chan map[string]*plugins.Status),
// we use a buffered channel here to avoid blocking other plugins
// when updating statuses
pluginStatusCh: make(chan map[string]*plugins.Status, 1),
queryCh: make(chan chan *UpdateRequestV1),
logger: manager.Logger().WithFields(map[string]interface{}{"plugin": Name}),
trigger: make(chan trigger),
@@ -236,7 +239,7 @@ func Lookup(manager *plugins.Manager) *Plugin {
func (p *Plugin) Start(ctx context.Context) error {
p.logger.Info("Starting status reporter.")
go p.loop()
go p.loop(ctx)
// Setup a listener for plugin statuses, but only after starting the loop
// to prevent blocking threads pushing the plugin updates.
@@ -344,9 +347,9 @@ func (p *Plugin) Trigger(ctx context.Context) error {
}
}
func (p *Plugin) loop() {
func (p *Plugin) loop(ctx context.Context) {
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(ctx)
for {
+2 -5
View File
@@ -349,12 +349,9 @@ func TestPluginStartTriggerManual(t *testing.T) {
"app": "example-app",
"version": version.Version,
},
Plugins: map[string]*plugins.Status{
"status": {State: plugins.StateOK},
},
}
if !reflect.DeepEqual(result, exp) {
if !reflect.DeepEqual(result.Labels, exp.Labels) {
t.Fatalf("Expected: %v but got: %v", exp, result)
}
@@ -371,7 +368,7 @@ func TestPluginStartTriggerManual(t *testing.T) {
exp.Bundles = map[string]*bundle.Status{"test": status}
if !reflect.DeepEqual(result, exp) {
if !reflect.DeepEqual(result.Bundles, exp.Bundles) {
t.Fatalf("Expected: %v but got: %v", exp, result)
}
}
+49 -33
View File
@@ -25,6 +25,7 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/open-policy-agent/opa/internal/prometheus"
"github.com/open-policy-agent/opa/ast"
@@ -3169,26 +3170,34 @@ func TestStatusV1(t *testing.T) {
f.server.manager.Register(pluginStatus.Name, bs)
req = newReqV1(http.MethodGet, "/status", "")
f.reset()
f.server.Handler.ServeHTTP(f.recorder, req)
if f.recorder.Result().StatusCode != http.StatusOK {
t.Fatal("expected ok")
}
// Fetch the status info, wait for status plugin to be ok
t0 := time.Now()
ok := false
for !ok && time.Since(t0) < time.Second {
req = newReqV1(http.MethodGet, "/status", "")
f.reset()
f.server.Handler.ServeHTTP(f.recorder, req)
if f.recorder.Result().StatusCode != http.StatusOK {
t.Fatal("expected ok")
}
var resp1 struct {
Result struct {
Plugins struct {
Status struct {
State string
var resp1 struct {
Result struct {
Plugins struct {
Status struct {
State string
}
}
}
}
}
if err := util.NewJSONDecoder(f.recorder.Body).Decode(&resp1); err != nil {
t.Fatal(err)
} else if resp1.Result.Plugins.Status.State != "OK" {
t.Fatal("expected plugin state for status to be 'OK' but got:", resp1)
if err := util.NewJSONDecoder(f.recorder.Body).Decode(&resp1); err != nil {
t.Fatal(err)
}
if resp1.Result.Plugins.Status.State == "OK" {
ok = true
} else {
t.Log("expected plugin state for status to be 'OK' but got:", resp1)
}
}
// Expect HTTP 200 and updated status after bundle update occurs
@@ -3281,27 +3290,34 @@ func TestStatusV1MetricsWithSystemAuthzPolicy(t *testing.T) {
}
f.server.manager.Register(pluginStatus.Name, bs)
// Fetch the status info
req = newReqV1(http.MethodGet, "/status", "")
f.reset()
f.server.Handler.ServeHTTP(f.recorder, req)
if f.recorder.Result().StatusCode != http.StatusOK {
t.Fatal("expected ok")
}
// Fetch the status info, wait for status plugin to be ok
t0 := time.Now()
ok := false
for !ok && time.Since(t0) < time.Second {
req = newReqV1(http.MethodGet, "/status", "")
f.reset()
f.server.Handler.ServeHTTP(f.recorder, req)
if f.recorder.Result().StatusCode != http.StatusOK {
t.Fatal("expected ok")
}
var resp1 struct {
Result struct {
Plugins struct {
Status struct {
State string
var resp1 struct {
Result struct {
Plugins struct {
Status struct {
State string
}
}
}
}
}
if err := util.NewJSONDecoder(f.recorder.Body).Decode(&resp1); err != nil {
t.Fatal(err)
} else if resp1.Result.Plugins.Status.State != "OK" {
t.Fatal("expected plugin state for status to be 'OK' but got:", resp1)
if err := util.NewJSONDecoder(f.recorder.Body).Decode(&resp1); err != nil {
t.Fatal(err)
}
if resp1.Result.Plugins.Status.State == "OK" {
ok = true
} else {
t.Log("expected plugin state for status to be 'OK' but got:", resp1)
}
}
// Make requests that should get denied