From a99f4cba2a893c27e618050e41c33fbe42bf2c7c Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Sun, 30 Jul 2023 17:33:35 +1000 Subject: [PATCH] Add a custom assets server instead of using nginx. --- .../workflows/build-and-publish-images.yaml | 1 + Makefile | 16 +- assets-server/Dockerfile | 28 ++ assets-server/go.mod | 9 + assets-server/go.sum | 10 + assets-server/main.go | 264 ++++++++++++++++++ .../bundle/config/00-values.yaml | 2 - .../training-platform/config/images.yaml | 4 +- session-manager/handlers/operator_config.py | 3 +- .../handlers/workshopenvironment.py | 67 +++-- 10 files changed, 360 insertions(+), 44 deletions(-) create mode 100644 assets-server/Dockerfile create mode 100644 assets-server/go.mod create mode 100644 assets-server/go.sum create mode 100644 assets-server/main.go diff --git a/.github/workflows/build-and-publish-images.yaml b/.github/workflows/build-and-publish-images.yaml index bd62cab2..352f6080 100644 --- a/.github/workflows/build-and-publish-images.yaml +++ b/.github/workflows/build-and-publish-images.yaml @@ -25,6 +25,7 @@ jobs: - image: secrets-manager - image: tunnel-manager - image: image-cache + - image: assets-server steps: - name: Check out the repository diff --git a/Makefile b/Makefile index 959fa4e8..8a973a5f 100644 --- a/Makefile +++ b/Makefile @@ -19,21 +19,23 @@ build-all-images: build-session-manager build-training-portal \ build-base-environment build-jdk8-environment build-jdk11-environment \ build-jdk17-environment build-conda-environment build-docker-registry \ build-pause-container build-secrets-manager build-tunnel-manager \ - build-image-cache + build-image-cache build-assets-server push-all-images: push-session-manager push-training-portal \ push-base-environment push-jdk8-environment push-jdk11-environment \ push-jdk17-environment push-conda-environment push-docker-registry \ push-pause-container push-secrets-manager push-tunnel-manager \ - push-image-cache + push-image-cache push-assets-server build-core-images: build-session-manager build-training-portal \ build-base-environment build-docker-registry build-pause-container \ - build-secrets-manager build-tunnel-manager build-image-cache + build-secrets-manager build-tunnel-manager build-image-cache \ + build-assets-server push-core-images: push-session-manager push-training-portal \ push-base-environment push-docker-registry push-pause-container \ - push-secrets-manager push-tunnel-manager push-image-cache + push-secrets-manager push-tunnel-manager push-image-cache \ + push-assets-server build-session-manager: docker build --platform $(DOCKER_PLATFORM) -t $(IMAGE_REPOSITORY)/educates-session-manager:$(PACKAGE_VERSION) session-manager @@ -113,6 +115,12 @@ build-image-cache: push-image-cache: build-image-cache docker push $(IMAGE_REPOSITORY)/educates-image-cache:$(PACKAGE_VERSION) +build-assets-server: + docker build --platform $(DOCKER_PLATFORM) -t $(IMAGE_REPOSITORY)/educates-assets-server:$(PACKAGE_VERSION) assets-server + +push-assets-server: build-assets-server + docker push $(IMAGE_REPOSITORY)/educates-assets-server:$(PACKAGE_VERSION) + verify-cluster-essentials-config: ifneq ("$(wildcard testing/educates-cluster-essentials-values.yaml)","") @ytt --file carvel-packages/cluster-essentials/bundle/config --data-values-file testing/educates-cluster-essentials-values.yaml diff --git a/assets-server/Dockerfile b/assets-server/Dockerfile new file mode 100644 index 00000000..9ee35518 --- /dev/null +++ b/assets-server/Dockerfile @@ -0,0 +1,28 @@ +FROM golang:1.19-buster as builder-image + +WORKDIR /app + +COPY . /app/ + +RUN go mod download && \ + go build -o assets-server main.go + +FROM fedora:36 + +RUN useradd -u 1001 -g 0 -M -d /opt/app-root/src default && \ + mkdir -p /opt/app-root/src && \ + chown -R 1001:0 /opt/app-root + +WORKDIR /opt/app-root + +COPY --from=builder-image /app/assets-server /opt/app-root/bin/ + +USER 1001 + +EXPOSE 8080 + +VOLUME ["/opt/app-root/data"] + +ENTRYPOINT ["/opt/app-root/bin/assets-server"] + +CMD ["--dir", "/opt/app-root/data", "--host", "0.0.0.0"] diff --git a/assets-server/go.mod b/assets-server/go.mod new file mode 100644 index 00000000..ccaa044f --- /dev/null +++ b/assets-server/go.mod @@ -0,0 +1,9 @@ +module github.com/vmware-tanzu-labs/educates-training-platform/assets-server + +go 1.20 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.7.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/assets-server/go.sum b/assets-server/go.sum new file mode 100644 index 00000000..f3366a91 --- /dev/null +++ b/assets-server/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/assets-server/main.go b/assets-server/main.go new file mode 100644 index 00000000..bab8f322 --- /dev/null +++ b/assets-server/main.go @@ -0,0 +1,264 @@ +/* + * This is a Golang application that serves static files from a specified + * directory and can also create and serve tar and zip archives of directories + * from the same directory. + * + * The application uses the cobra package for command-line argument handling. It + * allows the user to specify the directory path from which static files are + * served, the port the server listens on, and the host interface the listener + * socket is bound to. + * + * The server can handle the following types of requests: + * - Requests for regular static files (e.g., http://localhost:8080/file.txt) + * - Requests for tar archives of directories (e.g., http://localhost:8080/subdir/.tar) + * - Requests for tar.gz or .tgz archives of directories (e.g., http://localhost:8080/subdir/.tar.gz) + * - Requests for zip archives of directories (e.g., http://localhost:8080/subdir/.zip) + */ + +package main + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +func createTarArchive(dirPath string, writer io.Writer, compress bool) error { + var tarWriter *tar.Writer + if compress { + gzipWriter := gzip.NewWriter(writer) + defer gzipWriter.Close() + tarWriter = tar.NewWriter(gzipWriter) + } else { + tarWriter = tar.NewWriter(writer) + } + defer tarWriter.Close() + + return filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(dirPath, path) + if err != nil { + return err + } + + // Create a new tar header + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = filepath.ToSlash(relPath) + + // Write the header to the tar archive + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + // If the file is not a directory, write its content to the tar archive + if !info.IsDir() { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(tarWriter, file) + if err != nil { + return err + } + } + + return nil + }) +} + +func createZipArchive(dirPath string, writer io.Writer) error { + zipWriter := zip.NewWriter(writer) + defer zipWriter.Close() + + return filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(dirPath, path) + if err != nil { + return err + } + + // Create a new zip header + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = filepath.ToSlash(relPath) + + // Write the header to the zip archive + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + // If the file is not a directory, write its content to the zip archive + if !info.IsDir() { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + if err != nil { + return err + } + } + + return nil + }) +} + +func main() { + var rootCmd = &cobra.Command{ + Use: "static-server", + Short: "Serve static files from a directory", + Run: startServer, + } + + var dataDir string + var port string + var host string + + rootCmd.Flags().StringVarP(&dataDir, "dir", "d", "data", "Directory path containing static files") + rootCmd.Flags().StringVarP(&port, "port", "p", "8080", "Port number to listen on") + rootCmd.Flags().StringVarP(&host, "host", "H", "localhost", "Host interface to bind the listener socket") + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func startServer(cmd *cobra.Command, args []string) { + dataDir, _ := cmd.Flags().GetString("dir") + port, _ := cmd.Flags().GetString("port") + host, _ := cmd.Flags().GetString("host") + + // Check if the data directory exists + _, err := os.Stat(dataDir) + if err != nil { + if os.IsNotExist(err) { + fmt.Println("Directory", dataDir, "does not exist. Please create the directory and put your static files in it.") + return + } + fmt.Println("Error:", err) + return + } + + // Middleware for logging HTTP requests + loggingMiddleware := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("Incoming request: %s %s", r.Method, r.URL.Path) + next.ServeHTTP(w, r) + }) + } + + // Create a file server handler to serve static files from the data directory + fileServer := http.FileServer(http.Dir(dataDir)) + + // Handle requests for tar, tar.gz, or zip archives of directories + http.Handle("/", loggingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath := r.URL.Path + + // Check if the requested path ends with ".tar", ".tar.gz" or ".tgz" + if strings.HasSuffix(requestedPath, "/.tar") { + // Remove the ".tar" suffix from the path + requestedPath = strings.TrimSuffix(requestedPath, ".tar") + + // Check if the path maps to a directory + fileInfo, err := os.Stat(filepath.Join(dataDir, requestedPath)) + if err != nil || !fileInfo.IsDir() { + // Serve static files as the path does not map to a directory + fileServer.ServeHTTP(w, r) + return + } + + // Serve the tar archive for the requested directory + w.Header().Set("Content-Type", "application/x-tar") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.tar\"", requestedPath)) + + err = createTarArchive(filepath.Join(dataDir, requestedPath), w, false) + if err != nil { + http.Error(w, "Error creating tar archive", http.StatusInternalServerError) + return + } + return + } else if strings.HasSuffix(requestedPath, "/.tar.gz") || strings.HasSuffix(requestedPath, "/.tgz") { + // Remove the ".tar.gz" or ".tgz" suffix from the path + requestedPath = strings.TrimSuffix(requestedPath, ".tar.gz") + requestedPath = strings.TrimSuffix(requestedPath, ".tgz") + + // Check if the path maps to a directory + fileInfo, err := os.Stat(filepath.Join(dataDir, requestedPath)) + if err != nil || !fileInfo.IsDir() { + // Serve static files as the path does not map to a directory + fileServer.ServeHTTP(w, r) + return + } + + // Serve the tar.gz archive for the requested directory + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.tar.gz\"", requestedPath)) + + err = createTarArchive(filepath.Join(dataDir, requestedPath), w, true) + if err != nil { + http.Error(w, "Error creating tar.gz archive", http.StatusInternalServerError) + return + } + return + } else if strings.HasSuffix(requestedPath, "/.zip") { + // Remove the ".zip" suffix from the path + requestedPath = strings.TrimSuffix(requestedPath, ".zip") + + // Check if the path maps to a directory + fileInfo, err := os.Stat(filepath.Join(dataDir, requestedPath)) + if err != nil || !fileInfo.IsDir() { + // Serve static files as the path does not map to a directory + fileServer.ServeHTTP(w, r) + return + } + + // Serve the zip archive for the requested directory + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", requestedPath)) + + err = createZipArchive(filepath.Join(dataDir, requestedPath), w) + if err != nil { + http.Error(w, "Error creating zip archive", http.StatusInternalServerError) + return + } + return + } + + // Serve static files + fileServer.ServeHTTP(w, r) + }))) + + // Start the server on the specified host and port + addr := host + ":" + port + fmt.Println("Server is running on http://" + addr) + err = http.ListenAndServe(addr, nil) + if err != nil { + fmt.Println("Error:", err) + } +} diff --git a/carvel-packages/training-platform/bundle/config/00-values.yaml b/carvel-packages/training-platform/bundle/config/00-values.yaml index 50d8e0ee..3977109e 100644 --- a/carvel-packages/training-platform/bundle/config/00-values.yaml +++ b/carvel-packages/training-platform/bundle/config/00-values.yaml @@ -16,8 +16,6 @@ imageVersions: image: "rancher/k3s:v1.25.3-k3s1" - name: loftsh-vcluster image: "loftsh/vcluster:0.13.0" -- name: nginx-server - image: "bitnami/nginx:1.22.1" - name: contour-bundle #! contour.community.tanzu.vmware.com.1.22.0 image: "projects.registry.vmware.com/tce/contour@sha256:b68ad8ec3012db7d2a2e84f8544685012e2dca09d28d54dce8735fb60f0d05bf" diff --git a/carvel-packages/training-platform/config/images.yaml b/carvel-packages/training-platform/config/images.yaml index c6420668..836643bc 100644 --- a/carvel-packages/training-platform/config/images.yaml +++ b/carvel-packages/training-platform/config/images.yaml @@ -37,6 +37,8 @@ imageVersions: image: #@ image_reference("tunnel-manager") - name: image-cache image: #@ image_reference("image-cache") +- name: assets-server + image: #@ image_reference("assets-server") - name: debian-base-image image: "debian:sid-20230502-slim" - name: docker-in-docker @@ -51,8 +53,6 @@ imageVersions: image: "rancher/k3s:v1.25.3-k3s1" - name: loftsh-vcluster image: "loftsh/vcluster:0.13.0" -- name: nginx-server - image: "bitnami/nginx:1.22.1" - name: contour-bundle #! contour.community.tanzu.vmware.com.1.22.0 image: "projects.registry.vmware.com/tce/contour@sha256:b68ad8ec3012db7d2a2e84f8544685012e2dca09d28d54dce8735fb60f0d05bf" diff --git a/session-manager/handlers/operator_config.py b/session-manager/handlers/operator_config.py index 59a47b8e..a8c336a3 100644 --- a/session-manager/handlers/operator_config.py +++ b/session-manager/handlers/operator_config.py @@ -141,6 +141,7 @@ def image_reference(name): DOCKER_REGISTRY_IMAGE = image_reference("docker-registry") TUNNEL_MANAGER_IMAGE = image_reference("tunnel-manager") IMAGE_CACHE_IMAGE = image_reference("image-cache") +ASSETS_SERVER_IMAGE = image_reference("assets-server") BASE_ENVIRONMENT_IMAGE = image_reference("base-environment") JDK8_ENVIRONMENT_IMAGE = image_reference("jdk8-environment") @@ -163,8 +164,6 @@ def image_reference(name): LOFTSH_VCLUSTER_IMAGE = image_reference("loftsh-vcluster") -NGINX_SERVER_IMAGE = image_reference("nginx-server") - CONTOUR_BUNDLE_IMAGE = image_reference("contour-bundle") diff --git a/session-manager/handlers/workshopenvironment.py b/session-manager/handlers/workshopenvironment.py index 4cfbecfe..4a70e90a 100644 --- a/session-manager/handlers/workshopenvironment.py +++ b/session-manager/handlers/workshopenvironment.py @@ -44,9 +44,9 @@ NETWORK_BLOCKCIDRS, DOCKER_REGISTRY_IMAGE, BASE_ENVIRONMENT_IMAGE, - NGINX_SERVER_IMAGE, TUNNEL_MANAGER_IMAGE, IMAGE_CACHE_IMAGE, + ASSETS_SERVER_IMAGE, ) __all__ = ["workshop_environment_create", "workshop_environment_delete"] @@ -1552,10 +1552,10 @@ def workshop_environment_create( kopf.adopt(object_body, namespace_instance.obj) create_from_dict(object_body) - # If any assets are required for the workshop environment, deploy an nginx - # server and pre-load it with the assets. + # If any assets are required for the workshop environment, deploy the + # assets-server and pre-load it with the assets. - nginx_objects = [] + assets_server_objects = [] assets_files = xget(workshop_spec, "environment.assets.files", []) assets_storage = xget(workshop_spec, "environment.assets.storage", "") @@ -1565,10 +1565,10 @@ def workshop_environment_create( ) if assets_files: - nginx_image = NGINX_SERVER_IMAGE - nginx_image_pull_policy = image_pull_policy(nginx_image) + assets_server_image = ASSETS_SERVER_IMAGE + assets_server_image_pull_policy = image_pull_policy(assets_server_image) - nginx_deployment_body = { + assets_server_deployment_body = { "apiVersion": "apps/v1", "kind": "Deployment", "metadata": { @@ -1626,9 +1626,9 @@ def workshop_environment_create( ], "containers": [ { - "name": "nginx-server", - "image": nginx_image, - "imagePullPolicy": nginx_image_pull_policy, + "name": "assets-server", + "image": assets_server_image, + "imagePullPolicy": assets_server_image_pull_policy, "securityContext": { "allowPrivilegeEscalation": False, "capabilities": {"drop": ["ALL"]}, @@ -1640,11 +1640,10 @@ def workshop_environment_create( "requests": {"memory": assets_memory}, }, "ports": [{"containerPort": 8080, "protocol": "TCP"}], - "env": [{"name": "NGINX_PORT", "value": "8080"}], "volumeMounts": [ { "name": "data", - "mountPath": "/app", + "mountPath": "/opt/app-root/data", "subPath": "files", }, ], @@ -1667,7 +1666,7 @@ def workshop_environment_create( } if assets_storage: - nginx_persistent_volume_claim_body = { + assets_server_persistent_volume_claim_body = { "apiVersion": "v1", "kind": "PersistentVolumeClaim", "metadata": { @@ -1687,7 +1686,7 @@ def workshop_environment_create( } if CLUSTER_STORAGE_CLASS: - nginx_persistent_volume_claim_body["spec"][ + assets_server_persistent_volume_claim_body["spec"][ "storageClassName" ] = CLUSTER_STORAGE_CLASS @@ -1718,28 +1717,28 @@ def workshop_environment_create( "volumeMounts": [{"name": "data", "mountPath": "/mnt"}], } - nginx_deployment_body["spec"]["template"]["spec"][ + assets_server_deployment_body["spec"]["template"]["spec"][ "initContainers" ].insert(0, storage_init_container) - nginx_deployment_body["spec"]["template"]["spec"]["volumes"].append( + assets_server_deployment_body["spec"]["template"]["spec"]["volumes"].append( { "name": "data", "persistentVolumeClaim": {"claimName": "assets-server"}, } ) - nginx_objects.extend([nginx_persistent_volume_claim_body]) + assets_server_objects.extend([assets_server_persistent_volume_claim_body]) else: - nginx_deployment_body["spec"]["template"]["spec"]["volumes"].append( + assets_server_deployment_body["spec"]["template"]["spec"]["volumes"].append( { "name": "data", "emptyDir": {}, } ) - nginx_service_body = { + assets_server_service_body = { "apiVersion": "v1", "kind": "Service", "metadata": { @@ -1759,7 +1758,7 @@ def workshop_environment_create( }, } - nginx_config_map_body = { + assets_server_config_map_body = { "apiVersion": "v1", "kind": "ConfigMap", "metadata": { @@ -1800,24 +1799,24 @@ def workshop_environment_create( vendir_config["directories"] = directories_config - nginx_config_map_body["data"][ + assets_server_config_map_body["data"][ "vendir-assets-%02d.yaml" % vendir_count ] = yaml.dump(vendir_config, Dumper=yaml.Dumper) vendir_count += 1 - nginx_objects.extend( + assets_server_objects.extend( [ - nginx_deployment_body, - nginx_service_body, - nginx_config_map_body, + assets_server_deployment_body, + assets_server_service_body, + assets_server_config_map_body, ] ) if assets_ingress_enabled: - nginx_host = f"assets-{workshop_namespace}.{INGRESS_DOMAIN}" + assets_server_host = f"assets-{workshop_namespace}.{INGRESS_DOMAIN}" - nginx_ingress_body = { + assets_server_ingress_body = { "apiVersion": "networking.k8s.io/v1", "kind": "Ingress", "metadata": { @@ -1834,7 +1833,7 @@ def workshop_environment_create( "spec": { "rules": [ { - "host": nginx_host, + "host": assets_server_host, "http": { "paths": [ { @@ -1855,7 +1854,7 @@ def workshop_environment_create( } if INGRESS_PROTOCOL == "https": - nginx_ingress_body["metadata"]["annotations"].update( + assets_server_ingress_body["metadata"]["annotations"].update( { "ingress.kubernetes.io/force-ssl-redirect": "true", "nginx.ingress.kubernetes.io/ssl-redirect": "true", @@ -1864,20 +1863,20 @@ def workshop_environment_create( ) if INGRESS_SECRET: - nginx_ingress_body["spec"]["tls"] = [ + assets_server_ingress_body["spec"]["tls"] = [ { - "hosts": [nginx_host], + "hosts": [assets_server_host], "secretName": INGRESS_SECRET, } ] - nginx_objects.extend( + assets_server_objects.extend( [ - nginx_ingress_body, + assets_server_ingress_body, ] ) - for object_body in nginx_objects: + for object_body in assets_server_objects: object_body = substitute_variables(object_body, environment_variables) kopf.adopt(object_body, namespace_instance.obj) create_from_dict(object_body)