diff --git a/Makefile b/Makefile index f6109ba98..25e149b02 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ IMAGE_TAG ?= latest PLATFORMS ?= linux/amd64 DOCKER_COMMAND ?= $(notdir $(shell command -v docker || command -v podman)) DOCKER_BUILD_COMMAND ?= buildx build +BUILDX_BUILDER ?= hobbyfarm SERVICE_BASE_PATH ?= $(shell realpath ./v3/services) SERVICE_DOCKERFILE ?= $(shell realpath ./v3/Dockerfile) GARGANTUA_DOCKERFILE ?= $(shell realpath ./Dockerfile) @@ -26,7 +27,7 @@ endif # If no services are specified, get all service directories ifeq ($(SERVICES),) - SERVICES := gargantua $(shell find $(SERVICE_BASE_PATH) -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort) + SERVICES := gargantua $(shell find "$(SERVICE_BASE_PATH)" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort) endif .PHONY: help @@ -67,8 +68,12 @@ generate-protos: .PHONY: docker-setup docker-setup: @if [ "$(DOCKER_COMMAND)" = "docker" ]; then \ - docker buildx create --use --name hobbyfarm || true; \ - docker buildx inspect --bootstrap; \ + if "$(DOCKER_COMMAND)" buildx inspect "$(BUILDX_BUILDER)" >/dev/null 2>&1; then \ + "$(DOCKER_COMMAND)" buildx use "$(BUILDX_BUILDER)"; \ + else \ + "$(DOCKER_COMMAND)" buildx create --use --name "$(BUILDX_BUILDER)"; \ + fi; \ + "$(DOCKER_COMMAND)" buildx inspect "$(BUILDX_BUILDER)" --bootstrap; \ fi .PHONY: docker-build @@ -76,11 +81,11 @@ docker-build: @for service in $(SERVICES); do \ if [ "$$service" = "gargantua" ]; then \ echo "Building gargantua..."; \ - $(DOCKER_COMMAND) $(DOCKER_BUILD_COMMAND) $(if $(PUSH),--push,--load) $(PLATFORM_ARG) --file $(GARGANTUA_DOCKERFILE) --tag $(IMAGE_REGISTRY)/$$service:$(IMAGE_TAG) .; \ + $(DOCKER_COMMAND) $(DOCKER_BUILD_COMMAND) $(if $(PUSH),--push,--load) $(PLATFORM_ARG) --file "$(GARGANTUA_DOCKERFILE)" --tag "$(IMAGE_REGISTRY)/$$service:$(IMAGE_TAG)" "."; \ else \ echo "Building $$service..."; \ image_tag=$(IMAGE_REGISTRY)/$${service/%svc/-service}:$(IMAGE_TAG); \ - $(DOCKER_COMMAND) $(DOCKER_BUILD_COMMAND) $(if $(PUSH),--push,--load) $(PLATFORM_ARG) --build-arg SERVICE_NAME=$$service --file $(SERVICE_DOCKERFILE) --tag $$image_tag .; \ + $(DOCKER_COMMAND) $(DOCKER_BUILD_COMMAND) $(if $(PUSH),--push,--load) $(PLATFORM_ARG) --build-arg SERVICE_NAME="$$service" --file "$(SERVICE_DOCKERFILE)" --tag "$$image_tag" "."; \ fi; \ done diff --git a/v3/services/quizsvc/internal/quizevaluation/convert.go b/v3/services/quizsvc/internal/quizevaluation/convert.go index e6f9c9e08..91b44cb42 100644 --- a/v3/services/quizsvc/internal/quizevaluation/convert.go +++ b/v3/services/quizsvc/internal/quizevaluation/convert.go @@ -41,6 +41,25 @@ func NewPreparedQuizEvaluation(validationType quiz.ValidationType, quizEvaluatio } } +func NewPreparedQuizEvaluationReport(quizEvaluation *quizpb.QuizEvaluation) PreparedQuizEvaluationReport { + attempts := make([]PreparedQuizEvaluationReportAttempt, len(quizEvaluation.GetAttempts())) + for i, attempt := range quizEvaluation.GetAttempts() { + attempts[i] = PreparedQuizEvaluationReportAttempt{ + Timestamp: attempt.GetTimestamp(), + Score: attempt.GetScore(), + Pass: attempt.GetPass(), + } + } + + return PreparedQuizEvaluationReport{ + Id: quizEvaluation.GetId(), + Quiz: quizEvaluation.GetQuiz(), + User: quizEvaluation.GetUser(), + Scenario: quizEvaluation.GetScenario(), + Attempts: attempts, + } +} + func NewPBQuizEvaluationAttemptForStart(attempt uint32, quiz *quizpb.Quiz) *quizpb.QuizEvaluationAttempt { var selects map[string]*generalpb.StringArray if quiz.GetShuffle() { diff --git a/v3/services/quizsvc/internal/quizevaluation/convert_test.go b/v3/services/quizsvc/internal/quizevaluation/convert_test.go index d9b723909..491977076 100644 --- a/v3/services/quizsvc/internal/quizevaluation/convert_test.go +++ b/v3/services/quizsvc/internal/quizevaluation/convert_test.go @@ -333,3 +333,35 @@ func TestNewPreparedAttempt(t *testing.T) { }) } } + +func TestNewPreparedQuizEvaluationReport(t *testing.T) { + evaluation := &quizpb.QuizEvaluation{ + Id: "evaluation-id", + Quiz: "quiz-id", + User: "user-id", + Scenario: "scenario-id", + Attempts: []*quizpb.QuizEvaluationAttempt{ + { + Timestamp: "completed-at", + Score: 80, + Pass: true, + Corrects: map[string]*generalpb.StringArray{ + "question-id": {Values: []string{"answer-id"}}, + }, + Selects: map[string]*generalpb.StringArray{ + "question-id": {Values: []string{"answer-id"}}, + }, + }, + }, + } + + assert.Equal(t, PreparedQuizEvaluationReport{ + Id: "evaluation-id", + Quiz: "quiz-id", + User: "user-id", + Scenario: "scenario-id", + Attempts: []PreparedQuizEvaluationReportAttempt{ + {Timestamp: "completed-at", Score: 80, Pass: true}, + }, + }, NewPreparedQuizEvaluationReport(evaluation)) +} diff --git a/v3/services/quizsvc/internal/quizevaluation/service.go b/v3/services/quizsvc/internal/quizevaluation/service.go index 83ce78d9a..d3750077e 100644 --- a/v3/services/quizsvc/internal/quizevaluation/service.go +++ b/v3/services/quizsvc/internal/quizevaluation/service.go @@ -40,6 +40,43 @@ func NewQuizEvaluationService( } } +func (qes QuizEvaluationService) ListFunc(w http.ResponseWriter, r *http.Request) { + user, err := rbac.AuthenticateRequest(r, qes.authnClient) + if err != nil { + util.ReturnHTTPMessage(w, r, http.StatusUnauthorized, "unauthorized", "authentication failed") + return + } + + impersonatedUserId := user.GetId() + authrResponse, err := rbac.AuthorizeSimple(r, qes.authrClient, impersonatedUserId, rbac.HobbyfarmPermission(rbac.ResourcePluralQuizEvaluation, rbac.VerbList)) + if err != nil || !authrResponse.Success { + util.ReturnHTTPMessage(w, r, http.StatusForbidden, "forbidden", "no access to list quiz evaluations") + return + } + + quizEvaluationList, err := qes.internalServer.ListQuizEvaluation(r.Context(), &generalpb.ListOptions{}) + if err != nil { + glog.Errorf("error while listing all quiz evaluations: %s", hferrors.GetErrorMessage(err)) + util.ReturnHTTPMessage(w, r, http.StatusInternalServerError, "error", "error listing all quiz evaluations") + return + } + + preparedQuizEvaluations := make([]PreparedQuizEvaluationReport, 0, len(quizEvaluationList.GetQuizEvaluations())) + for _, quizEvaluation := range quizEvaluationList.GetQuizEvaluations() { + preparedQuizEvaluations = append(preparedQuizEvaluations, NewPreparedQuizEvaluationReport(quizEvaluation)) + } + + encodedQuizEvaluations, err := json.Marshal(preparedQuizEvaluations) + if err != nil { + glog.Errorf("error marshalling quiz evaluations: %v", err) + util.ReturnHTTPMessage(w, r, http.StatusInternalServerError, "error", "error listing all quiz evaluations") + return + } + + util.ReturnHTTPContent(w, r, http.StatusOK, "success", encodedQuizEvaluations) + glog.V(2).Info("retrieved list of all quiz evaluations") +} + func (qes QuizEvaluationService) GetFunc(w http.ResponseWriter, r *http.Request) { user, err := rbac.AuthenticateRequest(r, qes.authnClient) if err != nil { diff --git a/v3/services/quizsvc/internal/quizevaluation/types.go b/v3/services/quizsvc/internal/quizevaluation/types.go index b3ed4c59a..63dc3c285 100644 --- a/v3/services/quizsvc/internal/quizevaluation/types.go +++ b/v3/services/quizsvc/internal/quizevaluation/types.go @@ -8,6 +8,20 @@ type PreparedQuizEvaluation struct { Attempts []PreparedAttempt `json:"attempts"` } +type PreparedQuizEvaluationReport struct { + Id string `json:"id"` + Quiz string `json:"quiz"` + User string `json:"user"` + Scenario string `json:"scenario"` + Attempts []PreparedQuizEvaluationReportAttempt `json:"attempts"` +} + +type PreparedQuizEvaluationReportAttempt struct { + Timestamp string `json:"timestamp,omitempty"` + Score uint32 `json:"score"` + Pass bool `json:"pass"` +} + type PreparedAttempt struct { CreationTimestamp string `json:"creation_timestamp"` Timestamp string `json:"timestamp,omitempty"` diff --git a/v3/services/quizsvc/internal/server.go b/v3/services/quizsvc/internal/server.go index ddae7ec7a..6348d0cb2 100644 --- a/v3/services/quizsvc/internal/server.go +++ b/v3/services/quizsvc/internal/server.go @@ -36,6 +36,7 @@ func (qs QuizServer) SetupRoutes(r *mux.Router) { r.HandleFunc("/a/quiz/{id}/delete", qs.internalQuizService.DeleteFunc).Methods("DELETE") r.HandleFunc("/quiz/{id}", qs.internalQuizService.GetForUserFunc).Methods("GET") // quiz score + r.HandleFunc("/a/quiz/evaluation/list", qs.internalQuizEvaluationService.ListFunc).Methods("GET") r.HandleFunc("/a/quiz/evaluation/{id}", qs.internalQuizEvaluationService.GetFunc).Methods("GET") r.HandleFunc("/a/quiz/evaluation/{id}/delete", qs.internalQuizEvaluationService.DeleteFunc).Methods("DELETE") r.HandleFunc("/quiz/evaluation/start", qs.internalQuizEvaluationService.StartFunc).Methods("POST") diff --git a/v3/services/rbacsvc/internal/rbac/rbac_roles.go b/v3/services/rbacsvc/internal/rbac/rbac_roles.go index c494c46ba..4f6202764 100644 --- a/v3/services/rbacsvc/internal/rbac/rbac_roles.go +++ b/v3/services/rbacsvc/internal/rbac/rbac_roles.go @@ -7,6 +7,7 @@ import ( "github.com/hobbyfarm/gargantua/v3/pkg/util" wranglerRbac "github.com/rancher/wrangler/pkg/generated/controllers/rbac" rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/rest" ) @@ -29,10 +30,10 @@ func list() []Role { addRule([]string{"hobbyfarm.io"}, []string{"list", "get"}, []string{"users"}). addRule([]string{"rbac.authorization.k8s.io"}, []string{"*"}, []string{"roles", "rolebindings"}) }), - // Content Creator can create and edit scenarios and courses + // Content Creator can create and edit scenarios, courses and quiz newRole("content-creator", func(r Role) Role { return r. - addRule([]string{"hobbyfarm.io"}, []string{"*"}, []string{"scenarios", "courses"}). + addRule([]string{"hobbyfarm.io"}, []string{"*"}, []string{"scenarios", "courses", "quizes"}). addRule([]string{"hobbyfarm.io"}, []string{"list", "get"}, []string{"virtualmachinetemplates"}) }), // Infatructure Admin can edit environments and vmtemplates @@ -52,7 +53,7 @@ func list() []Role { // ScheduledEvent Proctor is allowed to view scheduled events + dashboards newRole("scheduledevent-proctor", func(r Role) Role { return r. - addRule([]string{"hobbyfarm.io"}, []string{"list", "get"}, []string{"scheduledevents", "accesscodes", "scenarios", "courses", "environments", "virtualmachinetemplates", "virtualmachinesets", "users"}). + addRule([]string{"hobbyfarm.io"}, []string{"list", "get"}, []string{"scheduledevents", "accesscodes", "scenarios", "courses", "environments", "virtualmachinetemplates", "virtualmachinesets", "users", "quizes", "quizevaluations"}). addRule([]string{"hobbyfarm.io"}, []string{"list"}, []string{"environments"}). addRule([]string{"hobbyfarm.io"}, []string{"list", "get", "watch"}, []string{"progresses", "virtualmachines", "virtualmachineclaims"}). addRule([]string{"hobbyfarm.io"}, []string{"update", "delete", "list", "get"}, []string{"sessions"}) @@ -78,7 +79,21 @@ func Create(ctx context.Context, cfg *rest.Config) error { roles := list() rf := factory.Rbac().V1().Role() for _, role := range roles { - rf.Create(&role.r) + existing, err := rf.Get(role.r.Namespace, role.r.Name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + if _, err = rf.Create(&role.r); err != nil { + return err + } + continue + } + if err != nil { + return err + } + + role.r.ResourceVersion = existing.ResourceVersion + if _, err = rf.Update(&role.r); err != nil { + return err + } } return nil