From 12c9410dc40f2d6909e11ca03e00eb6864b146c1 Mon Sep 17 00:00:00 2001 From: benjoe Date: Sat, 15 Feb 2025 13:32:50 +0100 Subject: [PATCH 01/18] closes issue #8 changed implementation, started redocumenting and testing --- 99-labs/04-immutability/README.md | 18 ++++++++++-------- 99-labs/code/kvstore/pkg/server/server.go | 13 +++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/99-labs/04-immutability/README.md b/99-labs/04-immutability/README.md index c788bd1..c8b2b7e 100644 --- a/99-labs/04-immutability/README.md +++ b/99-labs/04-immutability/README.md @@ -62,7 +62,7 @@ Cloud native apps are, in contrast, stateless, storing all *resource state* in a Version int `json:"version"` } ``` -- `POST /api/get, body: "key"`: get versioned value for the key given in the HTTP request body; +- `GET /api/get?id= `: get versioned value for the key given in the query parameter with the name id; - `POST /api/put, body: {"key":, "value":, "version":}`: insert the key-value pair into the database (only if the current version equals the specified version); - `GET /api/reset`: remove all key-value pairs from the store; and - `GET /api/list`: list the stored versioned key-value pairs. @@ -86,7 +86,7 @@ Make sure to familiarize yourself with the workings of `kvstore`: ``` - query the currently stored value and version in the database for `key1`: ```shell - curl -X POST -H "Content-Type: application/json" --data '"key1"' http://localhost:8081/api/get + curl -X GET http://localhost:8081/api/get?id=key1 {"value":"1","version":1} ``` - insert the new value with the new version and then list the entire content of the database: @@ -177,18 +177,20 @@ Your task is to implement this interface. Some help: 4. At this point, we are ready to actually implement the `Client` interface. Let us add the implementation for the `get` method first; recall, this receives a string key as an argument and returns the corresponding key and version as an `api.VersionedValue`: ``` go // Get returns the value and version stored for the given key, or an error if something goes wrong. - func (c *client) Get(key string) (api.VersionedValue, error) { - // this will package the requested key into the body of the HTTP request - body := []byte(fmt.Sprintf("\"%s\"", key)) - + func (c *client) Get(key string) (api.VersionedValue, error) { + //this will append the query parameter formatted to the url + url, _ := url2.Parse(c.url + "/api/get") + q := url.Query() + q.Set("id", key) + url.RawQuery = q.Encode() ... } ``` The function itself will have to perform the following steps: - - make a HTTP POST call to the HTTP server at the URL `c.url` (`c` is the receiver of the `Get` function of our implementation type `client`) at the API endpoint `/api/get`: + - make a HTTP GET call to the HTTP server at the URL `c.url` (`c` is the receiver of the `Get` function of our implementation type `client`) at the API endpoint `/api/get` using the `key` argument as the query id parameter value: ```go - r, err := http.Post(c.url+"/api/get", "application/json", bytes.NewReader(body)) + r, err := http.Get(url.String()) ``` - return an empty `api.VersionedValue{}` and an error if something goes wrong, - check if the return status is 200 (`http.StatusOK`) and return an empty `api.VersionedValue{}` and an error if not, diff --git a/99-labs/code/kvstore/pkg/server/server.go b/99-labs/code/kvstore/pkg/server/server.go index 2c6d176..e303c3e 100644 --- a/99-labs/code/kvstore/pkg/server/server.go +++ b/99-labs/code/kvstore/pkg/server/server.go @@ -44,16 +44,17 @@ func NewServer(logFile string) (*Server, error) { }) http.HandleFunc("/api/get", func(w http.ResponseWriter, r *http.Request) { - key := "" - defer r.Body.Close() - if err := json.NewDecoder(r.Body).Decode(&key); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + //Enforce HTTP GET on the GET endpoint + if r.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) return } - if key == "" { - w.WriteHeader(http.StatusBadRequest) + params := r.URL.Query() + if !params.Has("id") { + http.Error(w, "No transaction id supplied as query parameter", http.StatusBadRequest) return } + key := params.Get("id") log.Printf("get: key=%s\n", key) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(s.get(key)) From a9c30a3b9de4dbccc48edd824eb35d29b89174f4 Mon Sep 17 00:00:00 2001 From: benjoe Date: Mon, 17 Feb 2025 23:52:52 +0100 Subject: [PATCH 02/18] changed url2 to url --- 99-labs/04-immutability/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/99-labs/04-immutability/README.md b/99-labs/04-immutability/README.md index c8b2b7e..9975f6b 100644 --- a/99-labs/04-immutability/README.md +++ b/99-labs/04-immutability/README.md @@ -179,10 +179,10 @@ Your task is to implement this interface. Some help: // Get returns the value and version stored for the given key, or an error if something goes wrong. func (c *client) Get(key string) (api.VersionedValue, error) { //this will append the query parameter formatted to the url - url, _ := url2.Parse(c.url + "/api/get") - q := url.Query() + uri, _ := url.Parse(c.url + "/api/get") + q := uri.Query() q.Set("id", key) - url.RawQuery = q.Encode() + uri.RawQuery = q.Encode() ... } ``` @@ -190,7 +190,7 @@ Your task is to implement this interface. Some help: The function itself will have to perform the following steps: - make a HTTP GET call to the HTTP server at the URL `c.url` (`c` is the receiver of the `Get` function of our implementation type `client`) at the API endpoint `/api/get` using the `key` argument as the query id parameter value: ```go - r, err := http.Get(url.String()) + r, err := http.Get(uri.String()) ``` - return an empty `api.VersionedValue{}` and an error if something goes wrong, - check if the return status is 200 (`http.StatusOK`) and return an empty `api.VersionedValue{}` and an error if not, From 00ddd508614b0743a383894b3dbcb3e93a8a67bd Mon Sep 17 00:00:00 2001 From: benjoe Date: Sun, 9 Mar 2025 12:55:34 +0100 Subject: [PATCH 03/18] modofied so now it reads Get instead of Post --- 99-labs/05-resilience/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/99-labs/05-resilience/README.md b/99-labs/05-resilience/README.md index 82bc646..a8ca0f1 100644 --- a/99-labs/05-resilience/README.md +++ b/99-labs/05-resilience/README.md @@ -105,7 +105,7 @@ It is easy to test this: - You should see `curl` hanging for a while just to timeout after a longish wait. Meanwhile the `splitdim` pod is going completely berserk, filling the log with the traces of desperately trying to reach the key-value store: ```shell kubectl logs $(kubectl get pods -l app=splitdim -o jsonpath='{.items[0].metadata.name}') -f - kvstore_db.go:52: transfer: could not set balance for user "a": get: HTTP error: Post "http://kvstore.default:8081/api/get": dial tcp: lookup kvstore.default on 10.96.0.10:53: no such host + kvstore_db.go:52: transfer: could not set balance for user "a": get: HTTP error: Get "http://kvstore.default:8081/api/get": dial tcp: lookup kvstore.default on 10.96.0.10:53: no such host ... ``` From 3bece58d29551eccc1a5d743eb2722900e4a2897 Mon Sep 17 00:00:00 2001 From: benjoe Date: Sat, 15 Mar 2025 22:32:24 +0100 Subject: [PATCH 04/18] started working on pipelines and modified testutils to correct splitdim k8s tests --- .github/templates/docker/action.yml | 34 ++++++++ .github/templates/docker/build.sh | 9 +++ .github/templates/go/action.yml | 16 ++++ .github/templates/go/main.sh | 25 ++++++ .../templates/kind-cluster-setup/action.yml | 55 +++++++++++++ .github/templates/load-images/action.yml | 18 +++++ .../minikube-cluster-setup/action.yml | 36 +++++++++ .github/templates/test-istio/action.yml | 11 +++ .github/templates/test-istio/main.sh | 7 ++ .github/templates/test-istio/wait.sh | 14 ++++ .github/workflows/test-homeworks.yaml | 26 +++++++ .github/workflows/test-labs.yaml | 78 +++++++++++++++++++ 99-labs/code/helloworld/kubernetes_test.go | 10 --- 99-labs/code/splitdim/kubernetes_test.go | 12 +-- 99-labs/code/splitdim/testutils.go | 1 + README.md | 17 ++++ 16 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 .github/templates/docker/action.yml create mode 100644 .github/templates/docker/build.sh create mode 100644 .github/templates/go/action.yml create mode 100644 .github/templates/go/main.sh create mode 100644 .github/templates/kind-cluster-setup/action.yml create mode 100644 .github/templates/load-images/action.yml create mode 100644 .github/templates/minikube-cluster-setup/action.yml create mode 100644 .github/templates/test-istio/action.yml create mode 100644 .github/templates/test-istio/main.sh create mode 100644 .github/templates/test-istio/wait.sh create mode 100644 .github/workflows/test-homeworks.yaml create mode 100644 .github/workflows/test-labs.yaml diff --git a/.github/templates/docker/action.yml b/.github/templates/docker/action.yml new file mode 100644 index 0000000..4dcd450 --- /dev/null +++ b/.github/templates/docker/action.yml @@ -0,0 +1,34 @@ +name: Docker build +description: "Build the specified docker image and publishes it for the workflow" +inputs: + path: + description: "The path of the dockerfile to be built" + required: true + filepath: + description: "If the dockerfile is in a different directory then the context it needs to be specified here" + required: false + tags: + description: "The tag(s) for building the image" + required: true +runs: + using: composite + steps: + - name: Build the container + id: img + shell: bash + env: + CONTEXT_PATH: ${{ inputs.path }} + FILE_PATH: ${{ inputs.filepath }} + TAGS: ${{ inputs.tags }} + ACTION_PATH: ${{ github.action_path }} + run: | + bash $GITHUB_ACTION_PATH/build.sh + IMAGE_NAME=$(echo "${TAGS}" | cut -d : -f1) + echo ${IMAGE_NAME} + echo "name=${IMAGE_NAME}" >> $GITHUB_ENV + + - name: Publish image + uses: actions/upload-artifact@v4 + with: + name: ${{ env.name }} + path: ${{ inputs.path }}/images \ No newline at end of file diff --git a/.github/templates/docker/build.sh b/.github/templates/docker/build.sh new file mode 100644 index 0000000..b680aad --- /dev/null +++ b/.github/templates/docker/build.sh @@ -0,0 +1,9 @@ +cd "${CONTEXT_PATH}" || exit 1 +if [[ -z "${FILE_PATH}" ]]; then + docker build -t "${TAGS}" . +else + docker build -f "${FILE_PATH}" -t "${TAGS}" . +fi +IMAGE_NAME=$(echo "${TAGS}" | cut -d : -f1) +mkdir images +docker image save -o "images/${IMAGE_NAME}" "${TAGS}" diff --git a/.github/templates/go/action.yml b/.github/templates/go/action.yml new file mode 100644 index 0000000..005bf39 --- /dev/null +++ b/.github/templates/go/action.yml @@ -0,0 +1,16 @@ +name: Test go +description: "Tests the go files with the given arguments" +inputs: + tests: + description: "The project(s) that should be tested (kubernetes tests only), if you want to run multiple, provide their name separated with spaces" + required: true + default: 'all' +runs: + using: composite + steps: + - name: Test go program + shell: bash + env: + PROJECT_PATH: ${{ github.workspace }} + TESTS: ${{ inputs.tests }} + run: bash ${{ github.action_path }}/main.sh diff --git a/.github/templates/go/main.sh b/.github/templates/go/main.sh new file mode 100644 index 0000000..d8873f8 --- /dev/null +++ b/.github/templates/go/main.sh @@ -0,0 +1,25 @@ +cd "${PROJECT_PATH}"/99-labs/code || exit 1 +if [ "${TESTS}" = "all" ]; then + items=("helloworld" "splitdim", "kvstore") + for item in "${items[@]}"; do + pushd . > /dev/null + cd "${item}" && apply_and_wait && go mod download && go test ./... --tags=kubernetes --count 1 + ret=$? + if [[ $ret -ne 0 ]]; then + exit $ret + fi + popd > /dev/null + done +else + for TEST in ${TESTS}; do + pushd . > /dev/null + cd ${TEST} || exit 1 + go mod download + go test ./... --tags=kubernetes --count 1 + ret=$? + if [[ $ret -ne 0 ]]; then + exit $ret + fi + popd > /dev/null + done +fi diff --git a/.github/templates/kind-cluster-setup/action.yml b/.github/templates/kind-cluster-setup/action.yml new file mode 100644 index 0000000..1c5a81a --- /dev/null +++ b/.github/templates/kind-cluster-setup/action.yml @@ -0,0 +1,55 @@ +name: "Kind setup" +description: "Installs and sets up a running kind cluster, including the cloud-provider-kind and istio for service mash" +inputs: + istio: + description: "A flag weather to start the istio service mash" + default: 'false' + required: false +runs: + using: composite + steps: + - name: Install kind and start cluster + uses: helm/kind-action@v1 + with: + cluster_name: test + kubectl_version: 'v1.29.3' + + - name: Load images to node(s) + shell: bash + run: | + for i in $(docker images --format "{{.Repository}}:{{.Tag}}" | grep ':test'); do + kind load docker-image -n test ${i} + done + + - name: Check if cloud provider is cached + id: cpvd + uses: actions/cache@v4 + with: + path: ~/go/bin/cloud-provider-kind + key: cpvd-cache + + - name: Install cloud-provider-kind + if: steps.cpvd.outputs.cache-hit != 'true' + shell: bash + run: go install sigs.k8s.io/cloud-provider-kind@latest + + - name: Save cloud-provider-cache + uses: actions/cache/save@v4 + with: + path: ~/go/bin/cloud-provider-kind + key: cpvd-cache + + - name: Start cloud-provider-kind in background + shell: bash + run: cloud-provider-kind & + + - name: Start istio service mash + if: inputs.istio == 'true' + shell: bash + run: | + curl -L https://istio.io/downloadIstio | sh - + cd istio* + kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd?ref=v1.0.0" | kubectl apply -f - + bin/istioctl install --set profile=minimal -y + kubectl label namespace default istio-injection=enabled --overwrite + diff --git a/.github/templates/load-images/action.yml b/.github/templates/load-images/action.yml new file mode 100644 index 0000000..1ed7521 --- /dev/null +++ b/.github/templates/load-images/action.yml @@ -0,0 +1,18 @@ +name: Load built images +description: "Loads the previously built images and deletes the artifacts after" +runs: + using: composite + steps: + - name: Load images + shell: bash + run: | + for i in $(ls images); do + docker image load < "images/$i" + done + - name: Delete artifacts + uses: geekyeggo/delete-artifact@v5 + with: + useGlob: 'true' + name: '*' + failOnError: false + diff --git a/.github/templates/minikube-cluster-setup/action.yml b/.github/templates/minikube-cluster-setup/action.yml new file mode 100644 index 0000000..4d78199 --- /dev/null +++ b/.github/templates/minikube-cluster-setup/action.yml @@ -0,0 +1,36 @@ +name: Minikube cluster setup +description: "Sets up a minikube cluster, starts the minikube tunnel and loads the necessery images" +inputs: + istio: + description: "Flag if istio should be installed" + default: 'false' +runs: + using: composite + steps: + - name: Start minikube + uses: medyagh/setup-minikube@latest + with: + driver: docker + container-runtime: containerd + wait: all + cache: true + + - name: Start minikube tunnel + shell: bash + run: minikube tunnel &> /dev/null & + + - name: Load images + shell: bash + run: | + for i in $(docker images --format "{{.Repository}}:{{.Tag}}" | grep ':test'); do + minikube image load ${i} + done + - name: Start istio service mash + if: inputs.istio == 'true' + shell: bash + run: | + curl -L https://istio.io/downloadIstio | sh - + cd istio* + kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd?ref=v1.0.0" | kubectl apply -f - + bin/istioctl install --set profile=minimal -y + kubectl label namespace default istio-injection=enabled --overwrite \ No newline at end of file diff --git a/.github/templates/test-istio/action.yml b/.github/templates/test-istio/action.yml new file mode 100644 index 0000000..0fb58ad --- /dev/null +++ b/.github/templates/test-istio/action.yml @@ -0,0 +1,11 @@ +name: Test istio +description: "Tests the istio service mash" +runs: + using: composite + steps: + - name: Test istio service mash program + shell: bash + run: bash ${{ github.action_path }}/main.sh + env: + PROJECT_PATH: ${{ github.workspace }} + ACTION_PATH: ${{ github.action_path }} diff --git a/.github/templates/test-istio/main.sh b/.github/templates/test-istio/main.sh new file mode 100644 index 0000000..6290a01 --- /dev/null +++ b/.github/templates/test-istio/main.sh @@ -0,0 +1,7 @@ +cd ${PROJECT_PATH}/99-labs/code/splitdim/deploy || exit 1 +kubectl apply -f kubernetes-istio.yaml +bash ${ACTION_PATH}/wait.sh +export EXTERNAL_IP=$(kubectl get service splitdim-istio -o jsonpath='{.status.loadBalancer.ingress[0].ip}') +export EXTERNAL_PORT=80 +cd .. +go test ./... --tags=httphandler,api,localconstructor,reset,transfer,accounts,clear -v -count 1 \ No newline at end of file diff --git a/.github/templates/test-istio/wait.sh b/.github/templates/test-istio/wait.sh new file mode 100644 index 0000000..9e8ed70 --- /dev/null +++ b/.github/templates/test-istio/wait.sh @@ -0,0 +1,14 @@ +SECONDS=0 +while true; do + kubectl get svc #for debug + IP=$(kubectl get service splitdim-istio -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + if [[ ! -n "$IP" ]]; then + if (( SECONDS == 15));then + exit 1 + fi + SECONDS=$(( SECONDS + 1 )) + sleep 1 + else + exit 0 + fi +done \ No newline at end of file diff --git a/.github/workflows/test-homeworks.yaml b/.github/workflows/test-homeworks.yaml new file mode 100644 index 0000000..c6e98b2 --- /dev/null +++ b/.github/workflows/test-homeworks.yaml @@ -0,0 +1,26 @@ +name: Test Homeworks +on: + push: + branches: + - master + paths-ignore: + - 99-labs + - env + - internals + - .gitignore +jobs: + test-homeworks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '1' + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version: '^1.23.0' + - name: Test homeworks + run: | + export STUDENT_ID="$(grep -v -e '^$' STUDENT_ID)" # set the student_id used for test generation + make generate # generate the tests + make test \ No newline at end of file diff --git a/.github/workflows/test-labs.yaml b/.github/workflows/test-labs.yaml new file mode 100644 index 0000000..20de287 --- /dev/null +++ b/.github/workflows/test-labs.yaml @@ -0,0 +1,78 @@ +name: Test +on: + workflow_dispatch: + inputs: + kube-distro: + type: choice + description: "The type of kubernetes distribution to use, i.e minikube, kind" + options: + - minikube + - kind + default: kind + required: false + + istio-ready: + type: boolean + description: "Set to true if istio can be tested, leave false otherwise" + required: false + + kubernetes-tests: + type: string + description: "The name(s) of the kubernetes test(s) to be run, if you want to run multiple, provide them separated with spaces, e.g 'kvstore splitdim', if you provide 'all', it will execute all kubernetes tests" + default: 'all' + required: false + + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + image-root: ['helloworld','kvstore', 'splitdim'] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/templates/docker + with: + path: ${{ github.workspace }}/99-labs/code/${{ matrix.image-root }} + filepath: ${{ github.workspace }}/99-labs/code/${{ matrix.image-root }}/deploy/Dockerfile + tags: ${{ matrix.image-root }}:test + test: + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '1' + - uses: actions/download-artifact@v4 + with: + path: images + merge-multiple: 'true' + - name: Load all docker images + uses: ./.github/templates/load-images + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version: '^1.23.0' + + - name: Start kind cluster + if: github.event.inputs.kube-distro == 'kind' + uses: ./.github/templates/kind-cluster-setup + with: + istio: 'true' + + - name: Start minikube cluster + if: github.event.inputs.kube-distro == 'minikube' + uses: ./.github/templates/minikube-cluster-setup + with: + istio: 'true' + + + - name: Test istio + if: github.event.inputs.istio-ready == true + uses: ./.github/templates/test-istio + + - name: Test go project + uses: ./.github/templates/go + with: + tests: ${{ github.event.inputs.kubernetes-tests }} diff --git a/99-labs/code/helloworld/kubernetes_test.go b/99-labs/code/helloworld/kubernetes_test.go index 1ba77cd..ab4ed4d 100644 --- a/99-labs/code/helloworld/kubernetes_test.go +++ b/99-labs/code/helloworld/kubernetes_test.go @@ -3,7 +3,6 @@ package main import ( - "context" "fmt" "io" "net" @@ -17,17 +16,10 @@ import ( ) func TestHelloWorldKubernetes(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // clean up cluster execCmd(t, "kubectl", "delete", "-f", "deploy/kubernetes-deployment.yaml") execCmd(t, "kubectl", "delete", "-f", "deploy/kubernetes-service.yaml") - // build the container image - execCmd(t, "minikube", "image", "build", "-t", "helloworld", "-f", "deploy/Dockerfile", ".") - go execCmd(t, "minikube", "tunnel") - // redeploy execCmd(t, "kubectl", "apply", "-f", "deploy/kubernetes-deployment.yaml") execCmd(t, "kubectl", "apply", "-f", "deploy/kubernetes-service.yaml") @@ -37,8 +29,6 @@ func TestHelloWorldKubernetes(t *testing.T) { ip, _ := execCmd(t, "kubectl", "get", "service", "helloworld", "-o", `jsonpath="{.status.loadBalancer.ingress[0].ip}"`) if ip == "" { - // make sure minikube tunnel is running if no public IP exists - execCmdContext(ctx, t, "minikube", "tunnel") // may take a while time.Sleep(10 * time.Second) ip, _ = execCmd(t, "kubectl", "get", "service", "helloworld", "-o", `jsonpath="{.status.loadBalancer.ingress[0].ip}"`) diff --git a/99-labs/code/splitdim/kubernetes_test.go b/99-labs/code/splitdim/kubernetes_test.go index 28bd9b6..2b9cf7c 100644 --- a/99-labs/code/splitdim/kubernetes_test.go +++ b/99-labs/code/splitdim/kubernetes_test.go @@ -4,7 +4,6 @@ package main import ( "bytes" - "context" "fmt" "io" "net" @@ -17,18 +16,11 @@ import ( ) func TestSplitDimKubernetes(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // clean up cluster execCmd(t, "kubectl", "delete", "-f", "deploy/kubernetes-local-db.yaml") - // build the container image - _, _, err := execCmd(t, "minikube", "image", "build", "-t", "splitdim", "-f", "deploy/Dockerfile", ".") - assert.NoError(t, err, "kubectl delete") - // redeploy - _, _, err = execCmd(t, "kubectl", "apply", "-f", "deploy/kubernetes-local-db.yaml") + _, _, err := execCmd(t, "kubectl", "apply", "-f", "deploy/kubernetes-local-db.yaml") assert.NoError(t, err, "kubectl apply") // may take a while @@ -40,8 +32,6 @@ func TestSplitDimKubernetes(t *testing.T) { ip, _, err := execCmd(t, "kubectl", "get", "service", "splitdim", "-o", `jsonpath="{.status.loadBalancer.ingress[0].ip}"`) if ip == "" { - // make sure minikube tunnel is running if no public IP exists - execCmdContext(ctx, t, "minikube", "tunnel") // may take a while time.Sleep(10 * time.Second) ip, _, err = execCmd(t, "kubectl", "get", "service", "splitdim", "-o", `jsonpath="{.status.loadBalancer.ingress[0].ip}"`) diff --git a/99-labs/code/splitdim/testutils.go b/99-labs/code/splitdim/testutils.go index 2d4093a..994d130 100644 --- a/99-labs/code/splitdim/testutils.go +++ b/99-labs/code/splitdim/testutils.go @@ -63,6 +63,7 @@ func execCmdContext(ctx context.Context, t *testing.T, cmd string, args ...strin var outb, errb bytes.Buffer e.Stdout = &outb e.Stderr = &errb + assert.NoError(t, e.Run(), fmt.Sprintf("run command %q", cmd)) log.Print("StdOut:\t", outb.String()) log.Print("StdErr:\t ", errb.String()) diff --git a/README.md b/README.md index 673c992..18e5498 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,29 @@ You can divide your code into as many files as you like but don't forget to add ### Test +#### Manual testing At any point in time you can test your solutions as follows. ``` console make test ``` +#### Automated testing remotely +You have the option to test your homeworks using GitHub Actions. +To do so, you just have to commit your local changes and push them to your master branch on your forked repository. + +```console +git add . +git commit -m '' +git push +``` +This will start an automatic testing pipeline that you can check in the GitHub GUI. + +#### Automated testing locally +Alternatively, if you want to you can execute the testing pipeline locally using nektos/act. +Installation and usage guide can be found here: https://github.com/nektos/act + + ### Keep track of repo updates Sometimes we update the main git repo to fix bugs or add new exercises. The below workflow shows how to update your local working copy from the master without overwriting your solutions already written. From 7d82f56aa1bf7396a8597efdfbab30149f2342c0 Mon Sep 17 00:00:00 2001 From: benjoe Date: Sun, 16 Mar 2025 14:00:26 +0100 Subject: [PATCH 05/18] changed README and added .idea to .gitignore --- .gitignore | 2 ++ README.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 817930f..4954d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ *.out .vscode/* + +.idea diff --git a/README.md b/README.md index 18e5498..a10717a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ git push ``` This will start an automatic testing pipeline that you can check in the GitHub GUI. +You can also check your labs exercies, to do so, navigate to the GitHub repository (your repo), click `Actions`, click `Run workflow`, pass in the parameters and click `Run workflow`. + #### Automated testing locally Alternatively, if you want to you can execute the testing pipeline locally using nektos/act. Installation and usage guide can be found here: https://github.com/nektos/act From 994e8eed092a2590a66b4a0b51c07f43a808959a Mon Sep 17 00:00:00 2001 From: benjoe Date: Fri, 18 Apr 2025 15:59:16 +0200 Subject: [PATCH 06/18] Created report generation made report generating function separated report logic into its own file changed template html and also fixed issue with templating removed needless function call fixed report and modified the makefile fixed report target added report action testing out workflow testing workflo2 now properly runs test on push testing out report fixed branch --- .github/workflows/test-homeworks.yaml | 24 +++- 24-generics/01-sorting/.README.md | 2 +- Makefile | 14 ++ exercises-cli.go | 20 ++- internals/lib/lib.go | 4 + internals/lib/report.go | 196 ++++++++++++++++++++++++++ internals/static/report_template.html | 146 +++++++++++++++++++ 7 files changed, 398 insertions(+), 8 deletions(-) create mode 100644 internals/lib/report.go create mode 100644 internals/static/report_template.html diff --git a/.github/workflows/test-homeworks.yaml b/.github/workflows/test-homeworks.yaml index c6e98b2..c34b4b6 100644 --- a/.github/workflows/test-homeworks.yaml +++ b/.github/workflows/test-homeworks.yaml @@ -8,6 +8,12 @@ on: - env - internals - .gitignore + workflow_dispatch: + inputs: + report: + type: boolean + description: If true then the pipeline creates a report json used for final evaluation and doesn't execute regular tests + default: false jobs: test-homeworks: runs-on: ubuntu-latest @@ -18,9 +24,23 @@ jobs: - name: Setup go uses: actions/setup-go@v5 with: - go-version: '^1.23.0' + go-version: '^1.24.0' - name: Test homeworks + if: ! inputs.report || github.event_name == 'push' run: | export STUDENT_ID="$(grep -v -e '^$' STUDENT_ID)" # set the student_id used for test generation make generate # generate the tests - make test \ No newline at end of file + make test + - name: Generate report + if: inputs.report + run: | + export STUDENT_ID="$(grep -v -e '^$' STUDENT_ID)" # set the student_id used for test generation + make generate + make report + - name: Artifact report + if: inputs.report + uses: actions/upload-artifact@v4 + with: + name: 'report' + path: '*-report.json' + diff --git a/24-generics/01-sorting/.README.md b/24-generics/01-sorting/.README.md index 1649edd..51fe5ea 100644 --- a/24-generics/01-sorting/.README.md +++ b/24-generics/01-sorting/.README.md @@ -8,4 +8,4 @@ The sorting should satisfy the following requirements: An example for the usage: {{ index . "example" }} -Place your code into the file `exercise.go` near the placeholder `// INSERT YOUR CODE HERE`. \ No newline at end of file +Place your code into the file `exercise.go` near the placeholder `// INSERT YOUR CODE HERE`. diff --git a/Makefile b/Makefile index a11c9d1..6de24f9 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,19 @@ test: export STUDENT_ID=$(STUDENT_ID) go test ./... -v -count 1 +# generate reports +report: + export STUDENT_ID=$(STUDENT_ID) + if [ ! -f report_tmp ]; then \ + go test ./... -v -count 1 -parallel 1 > report_tmp; \ + fi + go run exercises-cli.go -v report + +# generate the hmtl report +report-html: + export STUDENT_ID=$(STUDENT_ID) + go run exercises-cli.go --report-dir $(REPORT_DIR) html > report.html + # clean up generated files clean: for dir in $(EXERCISE_DIRS); do \ @@ -59,3 +72,4 @@ clean: # also wipe student id realclean: clean echo "PLEASE SET STUDENT ID" > $(STUDENT_ID_FILE) + diff --git a/exercises-cli.go b/exercises-cli.go index 72394c6..15ce833 100644 --- a/exercises-cli.go +++ b/exercises-cli.go @@ -12,7 +12,7 @@ import ( ) func Usage() { - fmt.Fprintf(os.Stderr, "exercises-cli \n") + fmt.Fprintf(os.Stderr, "exercises-cli \n") fmt.Fprintf(os.Stderr, "Flags:\n") flag.PrintDefaults() } @@ -20,6 +20,7 @@ func Usage() { var ( studentId = flag.String("student-id", "", "student id; optional, default is to read from file STUDENT_ID") verbose = flag.Bool("verbose", false, "verbose mode; default is off") + reportDir = flag.String("report-dir", "", "path to directory where the report files are stored") ) func main() { @@ -29,10 +30,10 @@ func main() { flag.Usage = Usage flag.Parse() - if len(flag.Args()) != 1 { - flag.Usage() - os.Exit(1) - } + //if len(flag.Args()) != 1 { + // flag.Usage() + // os.Exit(1) + //} id, err := lib.GetStudentId(studentId) if err != nil { @@ -50,6 +51,15 @@ func main() { } case "check": fmt.Println("Would check stuff") + case "report": + if err := lib.CreateStudentReport(id, *verbose); err != nil { + log.Fatalf("Cannot create report for student id %q: %s", id, err) + } + case "html": + fmt.Println(reportDir) + if err := lib.CreateTeacherReport(*reportDir, *verbose); err != nil { + log.Fatalf("Cannot create teacher report %s", err) + } default: flag.Usage() os.Exit(1) diff --git a/internals/lib/lib.go b/internals/lib/lib.go index 4ef2b14..2cece27 100644 --- a/internals/lib/lib.go +++ b/internals/lib/lib.go @@ -28,6 +28,10 @@ const ( SolutionTemplateFile = ".exercise.go" // SolutionFile is the name of the generated solution SolutionFile = "exercise.go" + // ReportFile is the name of the test outputs generated by make report + ReportFile = "report_tmp" + // ReportOutDir is the directory in which the report will be put + ReportOutDir = "reports" ) // Input is a type alias to the input field of an exercise. diff --git a/internals/lib/report.go b/internals/lib/report.go new file mode 100644 index 0000000..9543a53 --- /dev/null +++ b/internals/lib/report.go @@ -0,0 +1,196 @@ +package lib + +import ( + "bufio" + "encoding/json" + "fmt" + "html/template" + "math" + "os" + "path/filepath" + "regexp" + "strings" +) + +type reportCard struct { + StudentID string `json:"student_id"` + ExerciseScores map[string]exerciseStats `json:"exercise_scores"` + Summary string `json:"report"` +} +type exerciseStats struct { + Score float64 `json:"score"` + TestCaseSuccess map[string]bool `json:"testCaseSuccess"` +} + +// getTestCaseName takes a line and returns the name of the test case if the line is a go test output +func getTestCaseName(line string) string { + re := regexp.MustCompile(`^=== RUN\s+(\w+)`) + matches := re.FindStringSubmatch(line) + if len(matches) > 1 { + testName := matches[1] + return testName + } + return "" +} + +// newPrefilledReport takes a slice of string and returns a reportCard that has keys matching the names of the tested exercises (e.g 01-hello-world) +func newPrefilledReport(id string, lines []string) reportCard { + ret := reportCard{StudentID: id, ExerciseScores: make(map[string]exerciseStats)} + re := regexp.MustCompile(`^ok\s+\S+/([^/\s]+)/([^/\s]+)\s+\d+\.\d+s`) + for _, line := range lines { + if match := re.FindStringSubmatch(line); match != nil { + matchString := match[1] + "/" + match[2] + ret.ExerciseScores[matchString] = exerciseStats{} + } + } + return ret +} + +func generateReportSummary(id string, failed int, passed int) string { + if failed != 0 { + return fmt.Sprintf("%s didn't pass all the homework tests, %d test cases have passed out of %d test cases", id, passed, passed+failed) + } else { + return fmt.Sprintf("%s did pass all the homework tests, %d/%d test cases passed", id, passed, passed) + } +} + +// extractExerciseScores takes in a string slice (test output line by line) and returns the exerciseStats representation alongside the total of failed and successful test cases +func extractExerciseScores(lines []string, verbose bool) (exStats []exerciseStats, failTotal int, succTotal int) { + exStats = make([]exerciseStats, 0) + exStat := exerciseStats{Score: 0.0, TestCaseSuccess: make(map[string]bool)} + var testCaseName string + failTotal = 0 + succTotal = 0 + var succ, fail float64 + if verbose { + fmt.Println("Parsing test outputs") + } + for _, line := range lines { + if strings.Contains(line, "RUN") { + testCaseName = getTestCaseName(line) + } else if strings.Contains(line, "PASS:") { + succTotal++ + succ += 1.0 + exStat.TestCaseSuccess[testCaseName] = true + } else if strings.Contains(line, "FAIL:") { + failTotal++ + fail += 1.0 + exStat.TestCaseSuccess[testCaseName] = false + } else if strings.Contains(line, "github.com/") { + exStat.Score = succ / (fail + succ) * 100 + exStats = append(exStats, exStat) + exStat = exerciseStats{Score: 0, TestCaseSuccess: make(map[string]bool)} + fail, succ = 0.0, 0.0 + } + } + if verbose { + fmt.Println("Finished parsing test outputs") + } + return +} + +// CreateStudentReport takes the output of the tests and generates a report card for the student +func CreateStudentReport(id string, verbose bool) error { + f, err := os.Open(ReportFile) + if err != nil { + return fmt.Errorf("error opening %q: %w", ReportFile, err) + } + if verbose { + defer func() { + if err != nil { + fmt.Printf("the program exited with error %s", err.Error()) + } else { + fmt.Printf("the program finished, successfully") + } + }() + } + var lines []string + scanner := bufio.NewScanner(f) + if verbose { + fmt.Println("Reading test outputs...") + } + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if verbose { + fmt.Println("Read test outputs") + } + report := newPrefilledReport(id, lines) + exStats, failTotal, succTotal := extractExerciseScores(lines, verbose) + index := 0 + for k, _ := range report.ExerciseScores { + if math.IsNaN(exStats[index].Score) { + delete(report.ExerciseScores, k) + continue + } + report.ExerciseScores[k] = exStats[index] + index++ + } + report.Summary = generateReportSummary(id, failTotal, succTotal) + outName := fmt.Sprintf("/%s-report.json", report.StudentID) + output, err := os.Create(ReportOutDir + outName) + if err != nil { + return err + } + defer output.Close() + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + return err + } + return nil +} + +// createVisualReport takes a report card as argument and templates the html accordingly +func createVisualReport(rp []reportCard, file *os.File) error { + temp, err := template.New("report_template.html").ParseFiles("internals/static/report_template.html") + if err != nil { + return err + } + err = temp.Execute(file, rp) + return nil +} + +// CreateTeacherReport templates the html file generating templated reports (uses reports defined in reportDir) +func CreateTeacherReport(reportDir string, verbose bool) error { + dir, err := os.ReadDir(reportDir) + if err != nil { + return err + } + if verbose { + fmt.Println("Reading student reports...") + } + rpCards := make([]reportCard, len(dir)) + for i, file := range dir { + if !strings.Contains(file.Name(), "json") { + continue + } + filePath := filepath.Join(reportDir, file.Name()) + report, err := os.Open(filePath) + if err != nil { + if verbose { + fmt.Printf("error opening %q for reading: %s", file.Name(), err) + } + continue + } + var rep reportCard + if err = json.NewDecoder(report).Decode(&rep); err != nil { + fmt.Println(err.Error()) + if verbose { + fmt.Printf("error encoding %q: %s", file.Name(), err) + } + } + rpCards[i] = rep + report.Close() + } + if verbose { + fmt.Println("Finished reading student reports") + } + if err = createVisualReport(rpCards, os.Stdout); err != nil { + if verbose { + fmt.Printf("error creating visual report: %s", err) + } + return err + } + return nil +} diff --git a/internals/static/report_template.html b/internals/static/report_template.html new file mode 100644 index 0000000..e8048f7 --- /dev/null +++ b/internals/static/report_template.html @@ -0,0 +1,146 @@ + + + + + + Student Exercise Statistics + + + +
+

Student Exercise Statistics

+ +
+ {{ range . }} + + {{ end }} +
+ + +
+ + + + From 05ea39f3158dcda92f3ee45502dad10ec3e759ed Mon Sep 17 00:00:00 2001 From: benjoe Date: Mon, 5 May 2025 15:16:09 +0200 Subject: [PATCH 07/18] fixed bug with report, now the test cases are correctly mapped --- internals/lib/report.go | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/internals/lib/report.go b/internals/lib/report.go index 9543a53..c4f874f 100644 --- a/internals/lib/report.go +++ b/internals/lib/report.go @@ -18,6 +18,7 @@ type reportCard struct { Summary string `json:"report"` } type exerciseStats struct { + _owner string Score float64 `json:"score"` TestCaseSuccess map[string]bool `json:"testCaseSuccess"` } @@ -33,14 +34,22 @@ func getTestCaseName(line string) string { return "" } +// extractExerciseName takes a string and returns the exercise name and the concrete exercise name (e.g 01-getting-started/01-hello-world) +func extractExerciseName(line string) (string, bool) { + re := regexp.MustCompile(`github\.com(?:/[^/\s]+)*/([^/\s]+)/([^/\s]+)`) + match := re.FindStringSubmatch(line) + if match == nil { + return "", false + } + return fmt.Sprintf("%s/%s", match[1], match[2]), true +} + // newPrefilledReport takes a slice of string and returns a reportCard that has keys matching the names of the tested exercises (e.g 01-hello-world) func newPrefilledReport(id string, lines []string) reportCard { ret := reportCard{StudentID: id, ExerciseScores: make(map[string]exerciseStats)} - re := regexp.MustCompile(`^ok\s+\S+/([^/\s]+)/([^/\s]+)\s+\d+\.\d+s`) for _, line := range lines { - if match := re.FindStringSubmatch(line); match != nil { - matchString := match[1] + "/" + match[2] - ret.ExerciseScores[matchString] = exerciseStats{} + if match, ok := extractExerciseName(line); ok { + ret.ExerciseScores[match] = exerciseStats{} } } return ret @@ -77,9 +86,11 @@ func extractExerciseScores(lines []string, verbose bool) (exStats []exerciseStat fail += 1.0 exStat.TestCaseSuccess[testCaseName] = false } else if strings.Contains(line, "github.com/") { + owner, _ := extractExerciseName(line) exStat.Score = succ / (fail + succ) * 100 + exStat._owner = owner exStats = append(exStats, exStat) - exStat = exerciseStats{Score: 0, TestCaseSuccess: make(map[string]bool)} + exStat = exerciseStats{_owner: "", Score: 0, TestCaseSuccess: make(map[string]bool)} fail, succ = 0.0, 0.0 } } @@ -117,14 +128,19 @@ func CreateStudentReport(id string, verbose bool) error { } report := newPrefilledReport(id, lines) exStats, failTotal, succTotal := extractExerciseScores(lines, verbose) - index := 0 - for k, _ := range report.ExerciseScores { - if math.IsNaN(exStats[index].Score) { - delete(report.ExerciseScores, k) - continue + + for k := range report.ExerciseScores { + Inner: + for i, exStat := range exStats { + if exStat._owner == k { + if math.IsNaN(exStats[i].Score) { + delete(report.ExerciseScores, k) + break Inner + } + report.ExerciseScores[k] = exStat + break Inner + } } - report.ExerciseScores[k] = exStats[index] - index++ } report.Summary = generateReportSummary(id, failTotal, succTotal) outName := fmt.Sprintf("/%s-report.json", report.StudentID) From 4948ab3f7a36f059bb6c0b9729c72078d301efef Mon Sep 17 00:00:00 2001 From: benjoe Date: Mon, 5 May 2025 15:30:10 +0200 Subject: [PATCH 08/18] fixed makefile command issues and added back error checking for exercises-cli.go --- Makefile | 4 ++-- exercises-cli.go | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 6de24f9..4016aaa 100644 --- a/Makefile +++ b/Makefile @@ -55,12 +55,12 @@ report: if [ ! -f report_tmp ]; then \ go test ./... -v -count 1 -parallel 1 > report_tmp; \ fi - go run exercises-cli.go -v report + go run exercises-cli.go -verbose report # generate the hmtl report report-html: export STUDENT_ID=$(STUDENT_ID) - go run exercises-cli.go --report-dir $(REPORT_DIR) html > report.html + go run exercises-cli.go -report-dir=$(REPORT_DIR) -verbose html > report.html # clean up generated files clean: diff --git a/exercises-cli.go b/exercises-cli.go index 15ce833..41bcf05 100644 --- a/exercises-cli.go +++ b/exercises-cli.go @@ -30,10 +30,10 @@ func main() { flag.Usage = Usage flag.Parse() - //if len(flag.Args()) != 1 { - // flag.Usage() - // os.Exit(1) - //} + if len(flag.Args()) != 1 { + flag.Usage() + os.Exit(1) + } id, err := lib.GetStudentId(studentId) if err != nil { @@ -56,7 +56,7 @@ func main() { log.Fatalf("Cannot create report for student id %q: %s", id, err) } case "html": - fmt.Println(reportDir) + log.Println(*reportDir) if err := lib.CreateTeacherReport(*reportDir, *verbose); err != nil { log.Fatalf("Cannot create teacher report %s", err) } From 24fd2d5f58bf488f35dc4e9554863f16207932bd Mon Sep 17 00:00:00 2001 From: benjoe Date: Mon, 5 May 2025 15:43:21 +0200 Subject: [PATCH 09/18] minor improvement, now indexes won't overflow, may remove later --- internals/static/report_template.html | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internals/static/report_template.html b/internals/static/report_template.html index e8048f7..e5998bb 100644 --- a/internals/static/report_template.html +++ b/internals/static/report_template.html @@ -122,16 +122,27 @@

Student Exercise Statistics