mirror of
https://github.com/fnproject/fn.git
synced 2022-10-28 21:29:17 +03:00
* move calls to logstore, implement s3 closes #482 the basic motivation is that logs and calls will be stored with a very high write rate, while apps and routes will be relatively infrequently updated; it follows that we should likely split up their storage location, to back them with appropriate storage facilities. s3 is a good candidate for ingesting higher write rate data than a sql database, and will make it easier to manage that data set. can read #482 for more detailed justification. summary: * calls api moved from datastore to logstore * logstore used in front-end to serve calls endpoints * agent now throws calls into logstore instead of datastore * s3 implementation of calls api for logstore * s3 logs key changed (nobody using / nbd?) * removed UpdateCall api (not in use) * moved call tests from datastore to logstore tests * mock logstore now tested (prev. sqlite3 only) * logstore tests run against every datastore (mysql, pg; prev. only sqlite3) * simplify NewMock in tests commentary: brunt of the work is implementing the listing of calls in GetCalls for the s3 logstore implementation. the GetCalls API requires returning items in the newest to oldest order, and the s3 api lists items in lexicographic order based on created_at. An easy thing to do here seemed to be to reverse the encoding of our id format to return a lexicographically descending order, since ids are time based, reasonably encoded to be lexicographically sortable, and de-duped (unlike created_at). This seems to work pretty well, it's not perfect around the boundaries of to_time and from_time and a tiny amount of results may be omitted, but to me this doesn't seem like a deal breaker to get 6999 results instead of 7000 when trying to get calls between 3:00pm and 4:00pm Monday 3 weeks ago. Of course, without to_time and from_time, there are no issues in listing results. We could use created at and encode it, but it would be an additional marker for point lookup (GetCall) since we would have to search for a created_at stamp, search for ids around that until we find the matching one, just to do a point lookup. So, the tradeoff here seems worth it. There is additional optimization around to_time to seek over newer results (since we have descending order). The other complication in GetCalls is returning a list of calls for a given path. Since the keys to do point lookups are only app_id + call_id, and we need listing across an app as well, this leads us to the 'marker' collection which is sorted by app_id + path + call_id, to allow quick listing by path. All in all, it should be pretty straightforward to follow the implementation and I tried to be lavish with the comments, please let me know if anything needs further clarification in the code. The implementation itself has some glaring inefficiencies, but they're relatively minute: json encoding is kinda lazy, but workable; s3 doesn't offer batch retrieval, so we point look up each call one by one in get call; not re-using buffers -- but the seeking around the keys should all be relatively fast, not too worried about performance really and this isn't a hot path for reads (need to make a cut point and turn this in!). Interestingly, in testing, minio performs significantly worse than pg for storing both logs and calls (or just logs, I tested that too). minio seems to have really high cpu consumption, but in any event, we won't be using minio, we'll be using a cloud object store that implements the s3 api. Anyway, mostly a knock on using minio for high performance, not really anything to do with this, just thought it was interesting. I think it's safe to remove UpdateCall, admittedly this made implementing the s3 api a lot easier. This operation may also be something we never need, it was unused at present and was only in the cards for a previous hybrid implementation, which we've now abandoned. If we need, we can always resurrect from git. Also not worried about changing the log key, we need to put a prefix on this thing anyway, but I don't think anybody is using this anyway. in any event, it simply means old logs won't show up through the API, but aside from nobody using this yet, that doesn't seem a big deal breaker really -- new logs will appear fine. future: TODO make logstore implementation optional for datastore, check in front-end at runtime and offer a nil logstore that errors appropriately TODO low hanging fruit optimizations of json encoding, re-using buffers for download, get multiple calls at a time, id reverse encoding could be optimized like normal encoding to not be n^2 TODO api for range removal of logs and calls * address review comments * push id to_time magic into id package * add note about s3 key sizes * fix validation check
315 lines
12 KiB
Go
315 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/fnproject/fn/api/agent"
|
|
"github.com/fnproject/fn/api/datastore"
|
|
"github.com/fnproject/fn/api/id"
|
|
"github.com/fnproject/fn/api/logs"
|
|
"github.com/fnproject/fn/api/models"
|
|
"github.com/fnproject/fn/api/mqs"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
var tmpDatastoreTests = "/tmp/func_test_datastore.db"
|
|
|
|
func testServer(ds models.Datastore, mq models.MessageQueue, logDB models.LogStore, rnr agent.Agent, nodeType ServerNodeType) *Server {
|
|
return New(context.Background(),
|
|
WithLogLevel(getEnv(EnvLogLevel, DefaultLogLevel)),
|
|
WithDatastore(ds),
|
|
WithMQ(mq),
|
|
WithLogstore(logDB),
|
|
WithAgent(rnr),
|
|
WithType(nodeType),
|
|
)
|
|
}
|
|
|
|
func createRequest(t *testing.T, method, path string, body io.Reader) *http.Request {
|
|
|
|
bodyLen := int64(0)
|
|
|
|
// HACK: derive content-length since protocol/http does not add content-length
|
|
// if it's not present.
|
|
if body != nil {
|
|
buf := &bytes.Buffer{}
|
|
nRead, err := io.Copy(buf, body)
|
|
if err != nil {
|
|
t.Fatalf("Test: Could not copy %s request body to %s: %v", method, path, err)
|
|
}
|
|
|
|
bodyLen = nRead
|
|
body = buf
|
|
}
|
|
|
|
req, err := http.NewRequest(method, "http://127.0.0.1:8080"+path, body)
|
|
if err != nil {
|
|
t.Fatalf("Test: Could not create %s request to %s: %v", method, path, err)
|
|
}
|
|
|
|
if body != nil {
|
|
req.ContentLength = bodyLen
|
|
req.Header.Set("Content-Length", strconv.FormatInt(bodyLen, 10))
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
func routerRequest(t *testing.T, router *gin.Engine, method, path string, body io.Reader) (*http.Request, *httptest.ResponseRecorder) {
|
|
req := createRequest(t, method, path, body)
|
|
return routerRequest2(t, router, req)
|
|
}
|
|
|
|
func routerRequest2(_ *testing.T, router *gin.Engine, req *http.Request) (*http.Request, *httptest.ResponseRecorder) {
|
|
rec := httptest.NewRecorder()
|
|
rec.Body = new(bytes.Buffer)
|
|
router.ServeHTTP(rec, req)
|
|
return req, rec
|
|
}
|
|
|
|
func newRouterRequest(t *testing.T, method, path string, body io.Reader) (*http.Request, *httptest.ResponseRecorder) {
|
|
req := createRequest(t, method, path, body)
|
|
rec := httptest.NewRecorder()
|
|
rec.Body = new(bytes.Buffer)
|
|
return req, rec
|
|
}
|
|
|
|
func getErrorResponse(t *testing.T, rec *httptest.ResponseRecorder) *models.Error {
|
|
var err models.Error
|
|
decodeErr := json.NewDecoder(rec.Body).Decode(&err)
|
|
if decodeErr != nil {
|
|
t.Error("Test: Expected not empty response body")
|
|
}
|
|
return &err
|
|
}
|
|
|
|
func prepareDB(ctx context.Context, t *testing.T) (models.Datastore, models.LogStore, func()) {
|
|
os.Remove(tmpDatastoreTests)
|
|
ds, err := datastore.New(ctx, "sqlite3://"+tmpDatastoreTests)
|
|
if err != nil {
|
|
t.Fatalf("Error when creating datastore: %s", err)
|
|
}
|
|
logDB := ds
|
|
return ds, logDB, func() {
|
|
os.Remove(tmpDatastoreTests)
|
|
}
|
|
}
|
|
|
|
func TestFullStack(t *testing.T) {
|
|
ctx := context.Background()
|
|
buf := setLogBuffer()
|
|
ds, logDB, close := prepareDB(ctx, t)
|
|
defer close()
|
|
|
|
rnr, rnrcancel := testRunner(t, ds)
|
|
defer rnrcancel()
|
|
|
|
srv := testServer(ds, &mqs.Mock{}, logDB, rnr, ServerTypeFull)
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
expectedCode int
|
|
expectedCacheSize int // TODO kill me
|
|
}{
|
|
{"create my app", "POST", "/v1/apps", `{ "app": { "name": "myapp" } }`, http.StatusOK, 0},
|
|
{"list apps", "GET", "/v1/apps", ``, http.StatusOK, 0},
|
|
{"get app", "GET", "/v1/apps/myapp", ``, http.StatusOK, 0},
|
|
// NOTE: cache is lazy, loads when a request comes in for the route, not when added
|
|
{"add myroute", "POST", "/v1/apps/myapp/routes", `{ "route": { "name": "myroute", "path": "/myroute", "image": "fnproject/fn-test-utils", "type": "sync" } }`, http.StatusOK, 0},
|
|
{"add myroute2", "POST", "/v1/apps/myapp/routes", `{ "route": { "name": "myroute2", "path": "/myroute2", "image": "fnproject/fn-test-utils", "type": "sync" } }`, http.StatusOK, 0},
|
|
{"get myroute", "GET", "/v1/apps/myapp/routes/myroute", ``, http.StatusOK, 0},
|
|
{"get myroute2", "GET", "/v1/apps/myapp/routes/myroute2", ``, http.StatusOK, 0},
|
|
{"get all routes", "GET", "/v1/apps/myapp/routes", ``, http.StatusOK, 0},
|
|
{"execute myroute", "POST", "/r/myapp/myroute", `{ "echoContent": "Teste" }`, http.StatusOK, 1},
|
|
{"execute myroute2", "POST", "/r/myapp/myroute2", `{"sleepTime": 0, "isDebug": true, "isCrash": true}`, http.StatusBadGateway, 2},
|
|
{"get myroute2", "GET", "/v1/apps/myapp/routes/myroute2", ``, http.StatusOK, 2},
|
|
{"delete myroute", "DELETE", "/v1/apps/myapp/routes/myroute", ``, http.StatusOK, 1},
|
|
{"delete myroute2", "DELETE", "/v1/apps/myapp/routes/myroute2", ``, http.StatusOK, 0},
|
|
{"delete app (success)", "DELETE", "/v1/apps/myapp", ``, http.StatusOK, 0},
|
|
{"get deleted app", "GET", "/v1/apps/myapp", ``, http.StatusNotFound, 0},
|
|
{"get deleteds route on deleted app", "GET", "/v1/apps/myapp/routes/myroute", ``, http.StatusNotFound, 0},
|
|
} {
|
|
_, rec := routerRequest(t, srv.Router, test.method, test.path, bytes.NewBuffer([]byte(test.body)))
|
|
|
|
if rec.Code != test.expectedCode {
|
|
t.Log(buf.String())
|
|
t.Log(rec.Body.String())
|
|
t.Errorf("Test \"%s\": Expected status code to be %d but was %d",
|
|
test.name, test.expectedCode, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunnerNode(t *testing.T) {
|
|
ctx := context.Background()
|
|
buf := setLogBuffer()
|
|
ds, logDB, close := prepareDB(ctx, t)
|
|
defer close()
|
|
|
|
rnr, rnrcancel := testRunner(t, ds)
|
|
defer rnrcancel()
|
|
|
|
// Add route with an API server using the same DB
|
|
{
|
|
apiServer := testServer(ds, &mqs.Mock{}, logDB, nil, ServerTypeAPI)
|
|
_, rec := routerRequest(t, apiServer.Router, "POST", "/v1/apps/myapp/routes", bytes.NewBuffer([]byte(`{ "route": { "name": "myroute", "path": "/myroute", "image": "fnproject/fn-test-utils", "type": "sync" } }`)))
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("Expected status code 200 when creating sync route, but got %d", rec.Code)
|
|
}
|
|
_, rec = routerRequest(t, apiServer.Router, "POST", "/v1/apps/myapp/routes", bytes.NewBuffer([]byte(`{ "route": { "name": "myasyncroute", "path": "/myasyncroute", "image": "fnproject/fn-test-utils", "type": "async" } }`)))
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("Expected status code 200 when creating async route, but got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
srv := testServer(ds, &mqs.Mock{}, logDB, rnr, ServerTypeRunner)
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
expectedCode int
|
|
expectedCacheSize int // TODO kill me
|
|
}{
|
|
// Support sync and async API calls
|
|
{"execute sync route succeeds", "POST", "/r/myapp/myroute", `{ "echoContent": "Teste" }`, http.StatusOK, 1},
|
|
{"execute async route succeeds", "POST", "/r/myapp/myasyncroute", `{ "echoContent": "Teste" }`, http.StatusAccepted, 1},
|
|
|
|
// All other API functions should not be available on runner nodes
|
|
{"create app not found", "POST", "/v1/apps", `{ "app": { "name": "myapp" } }`, http.StatusBadRequest, 0},
|
|
{"list apps not found", "GET", "/v1/apps", ``, http.StatusBadRequest, 0},
|
|
{"get app not found", "GET", "/v1/apps/myapp", ``, http.StatusBadRequest, 0},
|
|
|
|
{"add route not found", "POST", "/v1/apps/myapp/routes", `{ "route": { "name": "myroute", "path": "/myroute", "image": "fnproject/fn-test-utils", "type": "sync" } }`, http.StatusBadRequest, 0},
|
|
{"get route not found", "GET", "/v1/apps/myapp/routes/myroute", ``, http.StatusBadRequest, 0},
|
|
{"get all routes not found", "GET", "/v1/apps/myapp/routes", ``, http.StatusBadRequest, 0},
|
|
{"delete app not found", "DELETE", "/v1/apps/myapp", ``, http.StatusBadRequest, 0},
|
|
} {
|
|
_, rec := routerRequest(t, srv.Router, test.method, test.path, bytes.NewBuffer([]byte(test.body)))
|
|
|
|
if rec.Code != test.expectedCode {
|
|
t.Log(buf.String())
|
|
t.Errorf("Test \"%s\": Expected status code to be %d but was %d",
|
|
test.name, test.expectedCode, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApiNode(t *testing.T) {
|
|
ctx := context.Background()
|
|
buf := setLogBuffer()
|
|
ds, logDB, close := prepareDB(ctx, t)
|
|
defer close()
|
|
|
|
srv := testServer(ds, &mqs.Mock{}, logDB, nil, ServerTypeAPI)
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
expectedCode int
|
|
expectedCacheSize int // TODO kill me
|
|
}{
|
|
// All routes should be supported
|
|
{"create my app", "POST", "/v1/apps", `{ "app": { "name": "myapp" } }`, http.StatusOK, 0},
|
|
{"list apps", "GET", "/v1/apps", ``, http.StatusOK, 0},
|
|
{"get app", "GET", "/v1/apps/myapp", ``, http.StatusOK, 0},
|
|
|
|
{"add myroute", "POST", "/v1/apps/myapp/routes", `{ "route": { "name": "myroute", "path": "/myroute", "image": "fnproject/fn-test-utils", "type": "sync" } }`, http.StatusOK, 0},
|
|
{"add myroute2", "POST", "/v1/apps/myapp/routes", `{ "route": { "name": "myroute2", "path": "/myroute2", "image": "fnproject/fn-test-utils", "type": "sync" } }`, http.StatusOK, 0},
|
|
{"add myasyncroute", "POST", "/v1/apps/myapp/routes", `{ "route": { "name": "myasyncroute", "path": "/myasyncroute", "image": "fnproject/fn-test-utils", "type": "async" } }`, http.StatusOK, 0},
|
|
{"get myroute", "GET", "/v1/apps/myapp/routes/myroute", ``, http.StatusOK, 0},
|
|
{"get myroute2", "GET", "/v1/apps/myapp/routes/myroute2", ``, http.StatusOK, 0},
|
|
{"get all routes", "GET", "/v1/apps/myapp/routes", ``, http.StatusOK, 0},
|
|
|
|
// Don't support calling sync or async
|
|
{"execute myroute", "POST", "/r/myapp/myroute", `{ "echoContent": "Teste" }`, http.StatusBadRequest, 1},
|
|
{"execute myroute2", "POST", "/r/myapp/myroute2", `{ "echoContent": "Teste" }`, http.StatusBadRequest, 2},
|
|
{"execute myasyncroute", "POST", "/r/myapp/myasyncroute", `{ "echoContent": "Teste" }`, http.StatusBadRequest, 1},
|
|
|
|
{"get myroute2", "GET", "/v1/apps/myapp/routes/myroute2", ``, http.StatusOK, 2},
|
|
{"delete myroute", "DELETE", "/v1/apps/myapp/routes/myroute", ``, http.StatusOK, 1},
|
|
{"delete myroute2", "DELETE", "/v1/apps/myapp/routes/myroute2", ``, http.StatusOK, 0},
|
|
{"delete app (success)", "DELETE", "/v1/apps/myapp", ``, http.StatusOK, 0},
|
|
{"get deleted app", "GET", "/v1/apps/myapp", ``, http.StatusNotFound, 0},
|
|
{"get deleted route on deleted app", "GET", "/v1/apps/myapp/routes/myroute", ``, http.StatusNotFound, 0},
|
|
} {
|
|
_, rec := routerRequest(t, srv.Router, test.method, test.path, bytes.NewBuffer([]byte(test.body)))
|
|
if rec.Code != test.expectedCode {
|
|
t.Log(buf.String())
|
|
t.Errorf("Test \"%s\": Expected status code to be %d but was %d",
|
|
test.name, test.expectedCode, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHybridEndpoints(t *testing.T) {
|
|
buf := setLogBuffer()
|
|
app := &models.App{Name: "myapp"}
|
|
app.SetDefaults()
|
|
ds := datastore.NewMockInit(
|
|
[]*models.App{app},
|
|
[]*models.Route{{
|
|
AppID: app.ID,
|
|
Path: "yodawg",
|
|
}},
|
|
)
|
|
|
|
logDB := logs.NewMock()
|
|
|
|
srv := testServer(ds, &mqs.Mock{}, logDB, nil /* TODO */, ServerTypeAPI)
|
|
|
|
newCallBody := func() string {
|
|
call := &models.Call{
|
|
AppID: app.ID,
|
|
ID: id.New().String(),
|
|
Path: "yodawg",
|
|
// TODO ?
|
|
}
|
|
var b bytes.Buffer
|
|
json.NewEncoder(&b).Encode(&call)
|
|
return b.String()
|
|
}
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
expectedCode int
|
|
}{
|
|
// TODO change all these tests to just do an async task in normal order once plumbing is done
|
|
|
|
{"post async call", "PUT", "/v1/runner/async", newCallBody(), http.StatusOK},
|
|
|
|
// TODO this one only works if it's not the same as the first since update isn't hooked up
|
|
{"finish call", "POST", "/v1/runner/finish", newCallBody(), http.StatusOK},
|
|
|
|
// TODO these won't work until update works and the agent gets shut off
|
|
//{"get async call", "GET", "/v1/runner/async", "", http.StatusOK},
|
|
//{"start call", "POST", "/v1/runner/start", "TODO", http.StatusOK},
|
|
} {
|
|
_, rec := routerRequest(t, srv.Router, test.method, test.path, strings.NewReader(test.body))
|
|
|
|
if rec.Code != test.expectedCode {
|
|
t.Log(buf.String())
|
|
t.Errorf("Test \"%s\": Expected status code to be %d but was %d",
|
|
test.name, test.expectedCode, rec.Code)
|
|
}
|
|
}
|
|
}
|