From 8cd763e296b748aaa1270075c4e8c90dcce053e3 Mon Sep 17 00:00:00 2001 From: Saurav Das <saurav.das@bigswitch.com> Date: Thu, 16 May 2013 12:42:10 -0700 Subject: [PATCH] DebugEvent items: 1. making the API more RESTful by replacing dashes with slashes and maintaining URI tree 2. adding REST calls for listing events and resetting events 3. completing the REST API for GET calls --- .../debugevent/CircularBuffer.java | 16 +- .../debugevent/DebugEvent.java | 101 +++++++--- .../debugevent/IDebugEventService.java | 63 ++++-- .../debugevent/NullDebugEvent.java | 31 ++- .../debugevent/web/DebugEventResource.java | 190 +++++++++++++++--- .../debugevent/web/DebugEventRoutable.java | 6 +- .../debugevent/DebugEventTest.java | 10 +- 7 files changed, 332 insertions(+), 85 deletions(-) diff --git a/src/main/java/net/floodlightcontroller/debugevent/CircularBuffer.java b/src/main/java/net/floodlightcontroller/debugevent/CircularBuffer.java index 0f6a3a209..879ec71b4 100644 --- a/src/main/java/net/floodlightcontroller/debugevent/CircularBuffer.java +++ b/src/main/java/net/floodlightcontroller/debugevent/CircularBuffer.java @@ -70,9 +70,12 @@ public class CircularBuffer<T> implements Iterable<T>{ } /** - * ArrayDeques are not threadsafe and thus must be externally synchronized. - * For concurrent iteration we call a copy constructor on the ArrayDeque and - * return the iterator of the newly created ArrayDeque. + * Returns an iterator over the elements in the circular buffer in proper sequence. + * The elements will be returned in order from oldest to most-recent. + * The returned Iterator is a "weakly consistent" iterator that will never + * throw ConcurrentModificationException, and guarantees to traverse elements + * as they existed upon construction of the iterator, and may (but is not + * guaranteed to) reflect any modifications subsequent to construction. */ @Override public Iterator<T> iterator() { @@ -82,4 +85,11 @@ public class CircularBuffer<T> implements Iterable<T>{ public int size() { return buffer.size(); } + + /** + * Atomically removes all elements in the circular buffer + */ + public void clear() { + buffer.clear(); + } } diff --git a/src/main/java/net/floodlightcontroller/debugevent/DebugEvent.java b/src/main/java/net/floodlightcontroller/debugevent/DebugEvent.java index 1a43b970c..0201bd3c1 100644 --- a/src/main/java/net/floodlightcontroller/debugevent/DebugEvent.java +++ b/src/main/java/net/floodlightcontroller/debugevent/DebugEvent.java @@ -39,15 +39,8 @@ public class DebugEvent implements IFloodlightModule, IDebugEventService { protected int eventIdCounter = 0; protected Object eventIdLock = new Object(); - /** - * A limit on the maximum number of event types that can be created - */ - protected static final int MAX_EVENTS = 2000; - private static final long MIN_FLUSH_DELAY = 100; //ms - private static final int PCT_LOCAL_CAP = 10; // % of global capacity - private static final int MIN_LOCAL_CAPACITY = 10; //elements /** @@ -62,8 +55,8 @@ public class DebugEvent implements IFloodlightModule, IDebugEventService { String eventDesc; String eventName; String moduleName; - String moduleEventName; boolean flushNow; + String moduleEventName; public EventInfo(int eventId, boolean enabled, int bufferCapacity, EventType etype, String formatStr, String eventDesc, @@ -76,8 +69,8 @@ public class DebugEvent implements IFloodlightModule, IDebugEventService { this.eventDesc = eventDesc; this.eventName = eventName; this.moduleName = moduleName; - this.moduleEventName = moduleName + "-" + eventName; this.flushNow = flushNow; + this.moduleEventName = moduleName + "/" + eventName; } public int getEventId() { return eventId; } @@ -89,7 +82,6 @@ public class DebugEvent implements IFloodlightModule, IDebugEventService { public String getEventName() { return eventName; } public String getModuleName() { return moduleName; } public String getModuleEventName() { return moduleEventName; } - } //****************** @@ -324,50 +316,107 @@ public class DebugEvent implements IFloodlightModule, IDebugEventService { } @Override - public boolean containsMEName(String moduleEventName) { - String[] temp = moduleEventName.split("-"); - if (temp.length != 2) return false; - if (!moduleEvents.containsKey(temp[0])) return false; - if (moduleEvents.get(temp[0]).containsKey(temp[1])) return true; + public boolean containsModuleEventName(String moduleName, String eventName) { + if (!moduleEvents.containsKey(moduleName)) return false; + if (moduleEvents.get(moduleName).containsKey(eventName)) return true; return false; } @Override - public boolean containsModName(String moduleName) { + public boolean containsModuleName(String moduleName) { return moduleEvents.containsKey(moduleName); } @Override public List<DebugEventInfo> getAllEventHistory() { - // TODO Auto-generated method stub - return null; + ArrayList<DebugEventInfo> moduleEventList = new ArrayList<DebugEventInfo>(); + for (Map<String, Integer> modev : moduleEvents.values()) { + for (int eventId : modev.values()) { + DebugEventHistory de = allEvents[eventId]; + if (de != null) { + ArrayList<String> ret = new ArrayList<String>(); + for (Event e : de.eventBuffer) { + ret.add(e.toString(de.einfo.formatStr, de.einfo.moduleEventName)); + } + moduleEventList.add(new DebugEventInfo(de.einfo, ret)); + } + } + } + return moduleEventList; } @Override public List<DebugEventInfo> getModuleEventHistory(String moduleName) { - // TODO Auto-generated method stub - return null; + if (!moduleEvents.containsKey(moduleName)) return null; + ArrayList<DebugEventInfo> moduleEventList = new ArrayList<DebugEventInfo>(); + for (int eventId : moduleEvents.get(moduleName).values()) { + DebugEventHistory de = allEvents[eventId]; + if (de != null) { + ArrayList<String> ret = new ArrayList<String>(); + for (Event e : de.eventBuffer) { + ret.add(e.toString(de.einfo.formatStr, de.einfo.moduleEventName)); + } + moduleEventList.add(new DebugEventInfo(de.einfo, ret)); + } + } + return moduleEventList; } @Override - public DebugEventInfo getSingleEventHistory(String moduleEventName) { - String[] temp = moduleEventName.split("-"); - if (temp.length != 2) return null; - if (!moduleEvents.containsKey(temp[0])) return null; - Integer eventId = moduleEvents.get(temp[0]).get(temp[1]); + public DebugEventInfo getSingleEventHistory(String moduleName, String eventName) { + if (!moduleEvents.containsKey(moduleName)) return null; + Integer eventId = moduleEvents.get(moduleName).get(eventName); if (eventId == null) return null; DebugEventHistory de = allEvents[eventId]; if (de != null) { ArrayList<String> ret = new ArrayList<String>(); for (Event e : de.eventBuffer) { ret.add(e.toString(de.einfo.formatStr, de.einfo.moduleEventName)); - //ret.add(e.toString()); } return new DebugEventInfo(de.einfo, ret); } return null; } + @Override + public void resetAllEvents() { + for (Map<String, Integer> eventMap : moduleEvents.values()) { + for (Integer evId : eventMap.values()) { + allEvents[evId].eventBuffer.clear(); + } + } + } + + @Override + public void resetAllModuleEvents(String moduleName) { + if (!moduleEvents.containsKey(moduleName)) return; + Map<String, Integer> modEvents = moduleEvents.get(moduleName); + for (Integer evId : modEvents.values()) { + allEvents[evId].eventBuffer.clear(); + } + } + + @Override + public void resetSingleEvent(String moduleName, String eventName) { + if (!moduleEvents.containsKey(moduleName)) return; + Integer eventId = moduleEvents.get(moduleName).get(eventName); + if (eventId == null) return; + DebugEventHistory de = allEvents[eventId]; + if (de != null) { + de.eventBuffer.clear(); + } + } + + @Override + public ArrayList<EventInfo> getEventList() { + ArrayList<EventInfo> eil = new ArrayList<EventInfo>(); + for (Map<String, Integer> eventMap : moduleEvents.values()) { + for (Integer evId : eventMap.values()) { + eil.add(allEvents[evId].einfo); + } + } + return eil; + } protected void printEvents() { for (int eventId : currentEvents) { diff --git a/src/main/java/net/floodlightcontroller/debugevent/IDebugEventService.java b/src/main/java/net/floodlightcontroller/debugevent/IDebugEventService.java index 72f1d4c9c..b574a9c61 100644 --- a/src/main/java/net/floodlightcontroller/debugevent/IDebugEventService.java +++ b/src/main/java/net/floodlightcontroller/debugevent/IDebugEventService.java @@ -17,6 +17,11 @@ public interface IDebugEventService extends IFloodlightService { LOG_ON_DEMAND } + /** + * A limit on the maximum number of event types that can be created + */ + public static final int MAX_EVENTS = 2000; + /** * Public class for information returned in response to rest API calls. */ @@ -54,7 +59,7 @@ public interface IDebugEventService extends IFloodlightService { * in the packet processing pipeline (eg. switch * connect/disconnect). * @param eventDescription A descriptive string describing event. - * @param et EventType for this event. + * @param eventType EventType for this event. * @param bufferCapacity Number of events to store for this event in a circular * buffer. Older events will be discarded once the * buffer is full. @@ -74,7 +79,7 @@ public interface IDebugEventService extends IFloodlightService { * @throws MaxEventsRegistered */ public int registerEvent(String moduleName, String eventName, boolean flushNow, - String eventDescription, EventType et, + String eventDescription, EventType eventType, int bufferCapacity, String formatStr, Object[] params) throws MaxEventsRegistered; @@ -106,37 +111,67 @@ public interface IDebugEventService extends IFloodlightService { public void flushEvents(); /** - * Determine if moduleEventName is a registered event. moduleEventName must - * be of the type {moduleName-eventName} eg. linkdiscovery-linkevent + * Determine if eventName is a registered event for a given moduleName */ - public boolean containsMEName(String moduleEventName); + public boolean containsModuleEventName(String moduleName, String eventName); /** - * Determine if any events have been registerd by a module of name moduleName + * Determine if any events have been registered for module of name moduleName */ - public boolean containsModName(String moduleName); + public boolean containsModuleName(String moduleName); /** + * Get event history for all events. This call can be expensive as it + * formats the event histories for all events. * - * @return + * @return a list of all event histories or an empty list if no events have + * been registered */ public List<DebugEventInfo> getAllEventHistory(); /** + * Get event history for all events registered for a given moduleName * - * @param moduleName - * @return + * @return a list of all event histories for all events registered for the + * the module or null if there are no events for this module */ public List<DebugEventInfo> getModuleEventHistory(String moduleName); /** - * Get event history for an event of name moduleEventName. moduleEventName - * must be of the type {moduleName-eventName} eg. linkdiscovery-linkevent + * Get event history for a single event * - * @param moduleEventName + * @param moduleName registered module name + * @param eventName registered event name for moduleName * @return DebugEventInfo for that event, or null if the moduleEventName * does not correspond to a registered event. */ - public DebugEventInfo getSingleEventHistory(String moduleEventName); + public DebugEventInfo getSingleEventHistory(String moduleName, String eventName); + + /** + * Wipe out all event history for all registered active events + */ + public void resetAllEvents(); + + /** + * Wipe out all event history for all events registered for a specific module + * + * @param moduleName registered module name + */ + public void resetAllModuleEvents(String moduleName); + + /** + * Wipe out event history for a single event + * @param moduleName registered module name + * @param eventName registered event name for moduleName + */ + public void resetSingleEvent(String moduleName, String eventName); + + /** + * Retrieve information on all registered events + * + * @return the arraylist of event-info or an empty list if no events are registered + */ + public ArrayList<EventInfo> getEventList(); + } diff --git a/src/main/java/net/floodlightcontroller/debugevent/NullDebugEvent.java b/src/main/java/net/floodlightcontroller/debugevent/NullDebugEvent.java index d404d6a62..6f49892c0 100644 --- a/src/main/java/net/floodlightcontroller/debugevent/NullDebugEvent.java +++ b/src/main/java/net/floodlightcontroller/debugevent/NullDebugEvent.java @@ -10,6 +10,7 @@ import net.floodlightcontroller.core.module.FloodlightModuleContext; import net.floodlightcontroller.core.module.FloodlightModuleException; import net.floodlightcontroller.core.module.IFloodlightModule; import net.floodlightcontroller.core.module.IFloodlightService; +import net.floodlightcontroller.debugevent.DebugEvent.EventInfo; public class NullDebugEvent implements IFloodlightModule, IDebugEventService { @@ -70,13 +71,13 @@ public class NullDebugEvent implements IFloodlightModule, IDebugEventService { } @Override - public boolean containsMEName(String param) { + public boolean containsModuleEventName(String moduleName, String eventName) { // TODO Auto-generated method stub return false; } @Override - public boolean containsModName(String param) { + public boolean containsModuleName(String moduleName) { // TODO Auto-generated method stub return false; } @@ -94,7 +95,31 @@ public class NullDebugEvent implements IFloodlightModule, IDebugEventService { } @Override - public DebugEventInfo getSingleEventHistory(String param) { + public DebugEventInfo getSingleEventHistory(String moduleName, String eventName) { + // TODO Auto-generated method stub + return null; + } + + @Override + public void resetAllEvents() { + // TODO Auto-generated method stub + + } + + @Override + public void resetAllModuleEvents(String moduleName) { + // TODO Auto-generated method stub + + } + + @Override + public void resetSingleEvent(String moduleName, String eventName) { + // TODO Auto-generated method stub + + } + + @Override + public ArrayList<EventInfo> getEventList() { // TODO Auto-generated method stub return null; } diff --git a/src/main/java/net/floodlightcontroller/debugevent/web/DebugEventResource.java b/src/main/java/net/floodlightcontroller/debugevent/web/DebugEventResource.java index 3fab95487..9e8ae7bbf 100644 --- a/src/main/java/net/floodlightcontroller/debugevent/web/DebugEventResource.java +++ b/src/main/java/net/floodlightcontroller/debugevent/web/DebugEventResource.java @@ -5,24 +5,16 @@ import java.util.HashMap; import java.util.List; import java.util.Map; - +import net.floodlightcontroller.debugevent.DebugEvent.EventInfo; import net.floodlightcontroller.debugevent.IDebugEventService.DebugEventInfo; import org.restlet.resource.Get; +import org.restlet.resource.Post; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - /** - * Return the debug event data for the get rest-api call - * - * URI must be in one of the following forms: - * "http://{controller-hostname}:8080/wm/debugevent/{param}/json - * - * where {param} must be one of (no quotes): - * "all" returns value/info on all active events. - * "{moduleName}" returns value/info on all active events for the specified module. - * "{moduleEventName}" returns value/info for specific event if it is active. + * Web interface for Debug Events * * @author Saurav */ @@ -34,17 +26,21 @@ public class DebugEventResource extends DebugEventResourceBase { * The output JSON model that contains the counter information */ public static class DebugEventInfoOutput { - public Map<String, DebugEventInfo> eventMap; - public String error; + public Map<String, DebugEventInfo> eventMap = null; + public List<EventInfo> eventList = null; + public String error = null; - DebugEventInfoOutput() { - eventMap = new HashMap<String, DebugEventInfo>(); - error = null; + DebugEventInfoOutput(boolean getList) { + if (!getList) { + eventMap = new HashMap<String, DebugEventInfo>(); + } } public Map<String, DebugEventInfo> getEventMap() { return eventMap; } - + public List<EventInfo> getEventList() { + return eventList; + } public String getError() { return error; } @@ -56,31 +52,153 @@ public class DebugEventResource extends DebugEventResourceBase { ERROR_BAD_MODULE_EVENT_NAME } + public static class DebugEventPost { + public Boolean reset; + + public Boolean getReset() { + return reset; + } + public void setReset(Boolean reset) { + this.reset = reset; + } + } + + public static class ResetOutput { + String error = null; + + public String getError() { + return error; + } + public void setError(String error) { + this.error = error; + } + } + + /** + * Reset events + * + * If using curl: + * curl -X POST -d {\"reset\":true} -H "Content-Type: application/json" URL + * where URL must be in one of the following forms for resetting registered events: + * "http://{controller-hostname}:8080/wm/debugevent/ + * "http://{controller-hostname}:8080/wm/debugevent/{param1} + * "http://{controller-hostname}:8080/wm/debugevent/{param1}/{param2} + * + * Not giving {param1} will reset all events + * {param1} can be 'all' or the name of a module. The former case will reset + * all events, while the latter will reset all events for the moduleName. + * {param2} must be an eventName for the given moduleName + */ + @Post + public ResetOutput postHandler(DebugEventPost postData) { + ResetOutput output = new ResetOutput(); + String param1 = (String)getRequestAttributes().get("param1"); + String param2 = (String)getRequestAttributes().get("param2"); + + if (postData.getReset() != null && postData.getReset()) { + Option choice = Option.ERROR_BAD_PARAM; + + if (param1 == null) { + param1 = "all"; + choice = Option.ALL; + } else if (param1.equals("all")) { + choice = Option.ALL; + } else if (param2 == null) { + boolean isRegistered = debugEvent.containsModuleName(param1); + if (isRegistered) { + choice = Option.ONE_MODULE; + } else { + choice = Option.ERROR_BAD_MODULE_NAME; + } + } else { + // differentiate between disabled and non-existing events + boolean isRegistered = debugEvent.containsModuleEventName(param1, param2); + if (isRegistered) { + choice = Option.ONE_MODULE_EVENT; + } else { + choice = Option.ERROR_BAD_MODULE_EVENT_NAME; + } + } + + switch (choice) { + case ALL: + debugEvent.resetAllEvents(); + break; + case ONE_MODULE: + debugEvent.resetAllModuleEvents(param1); + break; + case ONE_MODULE_EVENT: + debugEvent.resetSingleEvent(param1, param2); + break; + case ERROR_BAD_MODULE_NAME: + output.error = "Module name has no corresponding registered events"; + break; + case ERROR_BAD_MODULE_EVENT_NAME: + output.error = "Event not registered"; + break; + case ERROR_BAD_PARAM: + output.error = "Bad param"; + } + } + + return output; + + } + + /** + * Return the debug event data for the get rest-api call + * + * URL must be in one of the following forms for retrieving a list of all + * registered events: + * "http://{controller-hostname}:8080/wm/debugevent/ + * + * URL must be in one of the following forms for retrieving event data: + * "http://{controller-hostname}:8080/wm/debugevent/{param1} + * "http://{controller-hostname}:8080/wm/debugevent/{param1}/{param2} + * + * where {param1} must be one of (no quotes): + * "all" returns value/info on all active events. + * "{moduleName}" returns value/info on events for the specified module + * depending on the value of param2 + * and {param2} must be one of (no quotes): + * "{eventName}" returns value/info for specific event if it is active. + * + * {param2} is optional; in which case the event history for all the events registered + * for that moduleName will be returned. + * + */ @Get("json") public DebugEventInfoOutput handleEventInfoQuery() { - DebugEventInfoOutput output = new DebugEventInfoOutput(); Option choice = Option.ERROR_BAD_PARAM; + DebugEventInfoOutput output; - String param = (String)getRequestAttributes().get("param"); - if (param == null) { - param = "all"; - choice = Option.ALL; - } else if (param.equals("all")) { + String param1 = (String)getRequestAttributes().get("param1"); + String param2 = (String)getRequestAttributes().get("param2"); + + if (param1 == null && param2 == null) { + output = new DebugEventInfoOutput(true); + return listEvents(output); + } + output = new DebugEventInfoOutput(false); + + if (param1 == null && param2 != null) { + choice = Option.ERROR_BAD_PARAM; + } else if (param1.equals("all")) { choice = Option.ALL; - } else if (param.contains("-")) { - // differentiate between disabled and non-existing counters - boolean isRegistered = debugEvent.containsMEName(param); + } else if (param2 == null) { + boolean isRegistered = debugEvent.containsModuleName(param1); if (isRegistered) { - choice = Option.ONE_MODULE_EVENT; + choice = Option.ONE_MODULE; } else { - choice = Option.ERROR_BAD_MODULE_EVENT_NAME; + choice = Option.ERROR_BAD_MODULE_NAME; } } else { - boolean isRegistered = debugEvent.containsModName(param); + // differentiate between disabled and non-existing events + boolean isRegistered = debugEvent.containsModuleEventName(param1, param2); if (isRegistered) { - choice = Option.ONE_MODULE; + choice = Option.ONE_MODULE_EVENT; } else { - choice = Option.ERROR_BAD_MODULE_NAME; + choice = Option.ERROR_BAD_MODULE_EVENT_NAME; } } @@ -89,10 +207,11 @@ public class DebugEventResource extends DebugEventResourceBase { populateEvents(debugEvent.getAllEventHistory(), output); break; case ONE_MODULE: - populateEvents(debugEvent.getModuleEventHistory(param), output); + populateEvents(debugEvent.getModuleEventHistory(param1), output); break; case ONE_MODULE_EVENT: - populateSingleEvent(debugEvent.getSingleEventHistory(param), output); + populateSingleEvent(debugEvent.getSingleEventHistory(param1, param2), + output); break; case ERROR_BAD_MODULE_NAME: output.error = "Module name has no corresponding registered events"; @@ -107,6 +226,11 @@ public class DebugEventResource extends DebugEventResourceBase { return output; } + private DebugEventInfoOutput listEvents(DebugEventInfoOutput output) { + output.eventList = debugEvent.getEventList(); + return output; + } + private void populateSingleEvent(DebugEventInfo singleEventHistory, DebugEventInfoOutput output) { if (singleEventHistory != null) { diff --git a/src/main/java/net/floodlightcontroller/debugevent/web/DebugEventRoutable.java b/src/main/java/net/floodlightcontroller/debugevent/web/DebugEventRoutable.java index 88223af4e..d4ee7c6fd 100644 --- a/src/main/java/net/floodlightcontroller/debugevent/web/DebugEventRoutable.java +++ b/src/main/java/net/floodlightcontroller/debugevent/web/DebugEventRoutable.java @@ -11,7 +11,11 @@ public class DebugEventRoutable implements RestletRoutable { @Override public Restlet getRestlet(Context context) { Router router = new Router(context); - router.attach("/{param}", DebugEventResource.class); + router.attach("/{param1}/{param2}/", DebugEventResource.class); + router.attach("/{param1}/{param2}", DebugEventResource.class); + router.attach("/{param1}/", DebugEventResource.class); + router.attach("/{param1}", DebugEventResource.class); + router.attach("/", DebugEventResource.class); return router; } diff --git a/src/test/java/net/floodlightcontroller/debugevent/DebugEventTest.java b/src/test/java/net/floodlightcontroller/debugevent/DebugEventTest.java index d5fbedb95..7d912077b 100644 --- a/src/test/java/net/floodlightcontroller/debugevent/DebugEventTest.java +++ b/src/test/java/net/floodlightcontroller/debugevent/DebugEventTest.java @@ -42,9 +42,9 @@ public class DebugEventTest extends FloodlightTestCase { get("switchevent").intValue()); assertEquals(eventId2, debugEvent.moduleEvents.get("dbgevtest"). get("pktinevent").intValue()); - assertEquals(true, debugEvent.containsModName("dbgevtest")); - assertEquals(true, debugEvent.containsMEName("dbgevtest-switchevent")); - assertEquals(true, debugEvent.containsMEName("dbgevtest-pktinevent")); + assertEquals(true, debugEvent.containsModuleName("dbgevtest")); + assertEquals(true, debugEvent.containsModuleEventName("dbgevtest","switchevent")); + assertEquals(true, debugEvent.containsModuleEventName("dbgevtest","pktinevent")); assertEquals(0, DebugEvent.allEvents[eventId1].eventBuffer.size()); assertEquals(0, DebugEvent.allEvents[eventId2].eventBuffer.size()); @@ -61,12 +61,12 @@ public class DebugEventTest extends FloodlightTestCase { assertEquals(1, DebugEvent.allEvents[eventId1].eventBuffer.size()); assertEquals(1, DebugEvent.allEvents[eventId2].eventBuffer.size()); - DebugEventInfo de = debugEvent.getSingleEventHistory("dbgevtest-switchevent"); + DebugEventInfo de = debugEvent.getSingleEventHistory("dbgevtest","switchevent"); assertEquals(1, de.events.size()); assertEquals(true, de.events.get(0) .contains("Sw=00:00:00:00:00:00:00:01, reason=connected")); - DebugEventInfo de2 = debugEvent.getSingleEventHistory("dbgevtest-pktinevent"); + DebugEventInfo de2 = debugEvent.getSingleEventHistory("dbgevtest","pktinevent"); assertEquals(1, de2.events.size()); assertEquals(true, de2.events.get(0) .contains("Sw=1, reason=switch sent pkt-in")); -- GitLab