From 4576063961f2bac8f69584877a51fc7b8092db3a Mon Sep 17 00:00:00 2001 From: Luke Roy Date: Wed, 22 Apr 2026 14:18:04 +0200 Subject: [PATCH 1/5] add example using pre and post hook to monitor fleet workers Signed-off-by: Luke Roy --- .../.dockerignore | 36 +++ .../Dockerfile | 37 +++ .../README.md | 138 ++++++++ serverless-fleet-worker-registration/go.mod | 16 + serverless-fleet-worker-registration/go.sum | 29 ++ serverless-fleet-worker-registration/main.go | 304 ++++++++++++++++++ serverless-fleet-worker-registration/run | 67 ++++ 7 files changed, 627 insertions(+) create mode 100644 serverless-fleet-worker-registration/.dockerignore create mode 100644 serverless-fleet-worker-registration/Dockerfile create mode 100644 serverless-fleet-worker-registration/README.md create mode 100644 serverless-fleet-worker-registration/go.mod create mode 100644 serverless-fleet-worker-registration/go.sum create mode 100644 serverless-fleet-worker-registration/main.go create mode 100755 serverless-fleet-worker-registration/run diff --git a/serverless-fleet-worker-registration/.dockerignore b/serverless-fleet-worker-registration/.dockerignore new file mode 100644 index 00000000..eb18aed1 --- /dev/null +++ b/serverless-fleet-worker-registration/.dockerignore @@ -0,0 +1,36 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +fleet-register + +# Test files +*_test.go + +# Output files +*.xlsx +*.xls + +# Git +.git +.gitignore + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Documentation +README.md + +# Docker +Dockerfile +.dockerignore + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/serverless-fleet-worker-registration/Dockerfile b/serverless-fleet-worker-registration/Dockerfile new file mode 100644 index 00000000..0c1640e1 --- /dev/null +++ b/serverless-fleet-worker-registration/Dockerfile @@ -0,0 +1,37 @@ +# Stage 1: Build stage +FROM quay.io/projectquay/golang:1.25 AS builder + +# Set working directory +WORKDIR /build + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY main.go ./ + +# Build the application +# CGO_ENABLED=0 for static binary +# -ldflags="-w -s" to reduce binary size +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o fleet-register . + +# Stage 2: Runtime stage +FROM gcr.io/distroless/static-debian13 + +# Copy binary from builder +COPY --from=builder /build/fleet-register /app/fleet-register + +# Set working directory +WORKDIR /app + +# Expose port +EXPOSE 8080 + +# Set environment variable for the workbook path +ENV WORKBOOK_PATH=/fleet-workers/fleet-register.xlsx + +# Run the application +CMD ["/app/fleet-register"] \ No newline at end of file diff --git a/serverless-fleet-worker-registration/README.md b/serverless-fleet-worker-registration/README.md new file mode 100644 index 00000000..974caacb --- /dev/null +++ b/serverless-fleet-worker-registration/README.md @@ -0,0 +1,138 @@ +# Fleet Register + +A lightweight Go HTTP server that monitors IBM Cloud Code Engine fleet workers by tracking their lifecycle events in an Excel file. + +## Use Case + +This application is deployed as a Code Engine application to monitor fleet workers. Fleet workers call the `/register` endpoint when they start and the `/deregister` endpoint before they shut down. The application maintains a persistent Excel file tracking all worker activity, including registration and completion timestamps. + +**Deployment Flow:** +1. Deploy this app as a Code Engine application with persistent storage +2. Configure the fleet with register/deregister hooks pointing to this app +3. Fleet workers automatically report their status throughout their lifecycle + +## Endpoints + +- `POST /register` + Creates a row in `fleet-register.xlsx` with status `running` and records the registration timestamp. + +- `POST /deregister` + Updates the matching row by `worker_name` and `worker_ip` to `completed` and records the completion timestamp. + If no row exists, it creates a new row with status `completed`. + +- `GET /download` + Downloads the Excel file. + +## Request body + +```json +{ + "worker_name": "worker-01", + "worker_ip": "192.168.1.10" +} +``` + +## Deployment + +### Code Engine (Production) + +1. **Deploy the application with persistent storage:** + ```bash + ibmcloud ce app create --name fleet-workers \ + --build-dockerfile Dockerfile \ + --build-source . \ + --mount-data-store /fleet-workers=fleet-data + ``` + +2. **Run fleet with hooks:** + + The `run` script automatically retrieves the app URL and creates a fleet with register/deregister hooks: + ```bash + ./run + ``` + + Or manually create a fleet: + ```bash + APP_URL=$(ibmcloud ce app get --name fleet-workers -o url) + + PREHOOK="curl -X POST \${APP_URL}/register -H 'Content-Type: application/json' -d '{\"worker_name\":\"\${CE_WORKER_NAME}\",\"worker_ip\":\"\${CE_WORKER_IP}\"}'" + + POSTHOOK="curl -X POST \${APP_URL}/deregister -H 'Content-Type: application/json' -d '{\"worker_name\":\"\${CE_WORKER_NAME}\",\"worker_ip\":\"\${CE_WORKER_IP}\"}'" + + ibmcloud code-engine fleet create --name my-fleet \ + --tasks-state-store fleet-task-store \ + --subnetpool-name fleet-subnetpool \ + --image registry.access.redhat.com/ubi10/ubi-minimal \ + --max-scale 100 \ + --command="sleep" \ + --arg "30" \ + --tasks 100 \ + --env __CE_INTERNAL_HOOK_AFTER_STARTUP="${PREHOOK}" \ + --env __CE_INTERNAL_HOOK_AFTER_STARTUP_RETRY_LIMIT=3 \ + --env __CE_INTERNAL_HOOK_AFTER_STARTUP_MAX_EXECUTION_TIME=30m \ + --env __CE_INTERNAL_HOOK_BEFORE_SHUTDOWN="${POSTHOOK}" \ + --env __CE_INTERNAL_HOOK_BEFORE_SHUTDOWN_RETRY_LIMIT=3 \ + --env __CE_INTERNAL_HOOK_BEFORE_SHUTDOWN_MAX_EXECUTION_TIME=30m \ + --env APP_URL="${APP_URL}" \ + --cpu 2 \ + --memory 4G + ``` + +### Local Development + +```bash +go run . +``` + +Server listens on `:8080`. + +### Docker + +```bash +docker build -t fleet-register . +docker run -p 8080:8080 -v fleet-data:/fleet-workers fleet-register +``` + +The Excel file is saved to `/fleet-workers/fleet-register.xlsx` and persisted in the volume. + +## Examples + +Register a worker: + +```bash +curl -X POST http://localhost:8080/register \ + -H "Content-Type: application/json" \ + -d '{ + "worker_name": "worker-01", + "worker_ip": "192.168.1.10" + }' +``` + +Deregister a worker: + +```bash +curl -X POST http://localhost:8080/deregister \ + -H "Content-Type: application/json" \ + -d '{ + "worker_name": "worker-01", + "worker_ip": "192.168.1.10" + }' +``` + +Download the workbook: + +```bash +curl -O -J http://localhost:8080/download +``` + +## Excel file + +The server automatically creates `fleet-register.xlsx` in the project root (or `/fleet-workers/` in Docker) if it does not exist. + +The workbook contains one sheet named `Workers` with these columns: + +- `worker_name` - Name of the worker +- `worker_ip` - IP address of the worker +- `status` - Current status (running/completed) +- `registered_at` - Timestamp when the worker was registered (ISO 8601 format) +- `completed_at` - Timestamp when the worker completed (ISO 8601 format) \ No newline at end of file diff --git a/serverless-fleet-worker-registration/go.mod b/serverless-fleet-worker-registration/go.mod new file mode 100644 index 00000000..38048eed --- /dev/null +++ b/serverless-fleet-worker-registration/go.mod @@ -0,0 +1,16 @@ +module fleet-register + +go 1.22 + +require github.com/xuri/excelize/v2 v2.8.1 + +require ( + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.3 // indirect + github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect + github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/serverless-fleet-worker-registration/go.sum b/serverless-fleet-worker-registration/go.sum new file mode 100644 index 00000000..144577c6 --- /dev/null +++ b/serverless-fleet-worker-registration/go.sum @@ -0,0 +1,29 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM= +github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0= +github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ= +github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE= +github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4= +github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= +golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/serverless-fleet-worker-registration/main.go b/serverless-fleet-worker-registration/main.go new file mode 100644 index 00000000..2787340c --- /dev/null +++ b/serverless-fleet-worker-registration/main.go @@ -0,0 +1,304 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/xuri/excelize/v2" +) + +const ( + serverAddr = ":8080" + sheetName = "Workers" +) + +var workbookPath = getWorkbookPath() + +func getWorkbookPath() string { + if path := os.Getenv("WORKBOOK_PATH"); path != "" { + return path + } + return "fleet-register.xlsx" +} + +var workbookMu sync.Mutex + +type workerRequest struct { + WorkerName string `json:"worker_name"` + WorkerIP string `json:"worker_ip"` +} + +type apiResponse struct { + Message string `json:"message"` + WorkerName string `json:"worker_name,omitempty"` + WorkerIP string `json:"worker_ip,omitempty"` + Status string `json:"status,omitempty"` + RegisteredAt string `json:"registered_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` + File string `json:"file,omitempty"` +} + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("POST /register", registerHandler) + mux.HandleFunc("POST /deregister", deregisterHandler) + mux.HandleFunc("GET /download", downloadHandler) + + log.Printf("server listening on %s", serverAddr) + if err := http.ListenAndServe(serverAddr, mux); err != nil { + log.Fatal(err) + } +} + +func registerHandler(w http.ResponseWriter, r *http.Request) { + req, err := decodeWorkerRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + workbookMu.Lock() + defer workbookMu.Unlock() + + if err := appendWorkerRow(req, "running"); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to register worker: %v", err)) + return + } + + writeJSON(w, http.StatusCreated, apiResponse{ + Message: "worker registered", + WorkerName: req.WorkerName, + WorkerIP: req.WorkerIP, + Status: "running", + File: workbookPath, + }) +} + +func deregisterHandler(w http.ResponseWriter, r *http.Request) { + req, err := decodeWorkerRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + workbookMu.Lock() + defer workbookMu.Unlock() + + updated, err := completeWorker(req) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to deregister worker: %v", err)) + return + } + + message := "worker deregistered" + if !updated { + message = "worker deregistration added" + } + + writeJSON(w, http.StatusOK, apiResponse{ + Message: message, + WorkerName: req.WorkerName, + WorkerIP: req.WorkerIP, + Status: "completed", + File: workbookPath, + }) +} + +func downloadHandler(w http.ResponseWriter, r *http.Request) { + workbookMu.Lock() + defer workbookMu.Unlock() + + if err := ensureWorkbook(); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to prepare workbook: %v", err)) + return + } + + content, err := os.ReadFile(workbookPath) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read workbook: %v", err)) + return + } + + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, workbookPath)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) + w.WriteHeader(http.StatusOK) + + if _, err := w.Write(content); err != nil { + log.Printf("failed to write download response: %v", err) + } +} + +func decodeWorkerRequest(r *http.Request) (workerRequest, error) { + defer r.Body.Close() + + var req workerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return workerRequest{}, errors.New("invalid JSON body") + } + + req.WorkerName = strings.TrimSpace(req.WorkerName) + req.WorkerIP = strings.TrimSpace(req.WorkerIP) + + if req.WorkerName == "" { + return workerRequest{}, errors.New("field \"worker_name\" is required") + } + + if req.WorkerIP == "" { + return workerRequest{}, errors.New("field \"worker_ip\" is required") + } + + return req, nil +} + +func appendWorkerRow(req workerRequest, status string) error { + file, err := openWorkbook() + if err != nil { + return err + } + defer closeWorkbook(file) + + rows, err := file.GetRows(sheetName) + if err != nil { + return err + } + + nextRow := len(rows) + 1 + registeredAt := time.Now().Format(time.RFC3339) + values := []interface{}{req.WorkerName, req.WorkerIP, status, registeredAt, ""} + + cell, err := excelize.CoordinatesToCellName(1, nextRow) + if err != nil { + return err + } + + if err := file.SetSheetRow(sheetName, cell, &values); err != nil { + return err + } + + return file.SaveAs(workbookPath) +} + +func completeWorker(req workerRequest) (bool, error) { + file, err := openWorkbook() + if err != nil { + return false, err + } + defer closeWorkbook(file) + + rows, err := file.GetRows(sheetName) + if err != nil { + return false, err + } + + completedAt := time.Now().Format(time.RFC3339) + + for index := 1; index < len(rows); index++ { + row := rows[index] + if len(row) < 2 { + continue + } + + if row[0] == req.WorkerName && row[1] == req.WorkerIP { + // Update status to completed (column 3) + statusCell, err := excelize.CoordinatesToCellName(3, index+1) + if err != nil { + return false, err + } + + if err := file.SetCellValue(sheetName, statusCell, "completed"); err != nil { + return false, err + } + + // Update completed_at timestamp (column 5) + completedCell, err := excelize.CoordinatesToCellName(5, index+1) + if err != nil { + return false, err + } + + if err := file.SetCellValue(sheetName, completedCell, completedAt); err != nil { + return false, err + } + + return true, file.SaveAs(workbookPath) + } + } + + // If worker not found, add new row with completed status + values := []interface{}{req.WorkerName, req.WorkerIP, "completed", "", completedAt} + nextRow := len(rows) + 1 + + cell, err := excelize.CoordinatesToCellName(1, nextRow) + if err != nil { + return false, err + } + + if err := file.SetSheetRow(sheetName, cell, &values); err != nil { + return false, err + } + + return false, file.SaveAs(workbookPath) +} + +func ensureWorkbook() error { + if _, err := os.Stat(workbookPath); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + + file := excelize.NewFile() + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + + if defaultSheet != sheetName { + file.SetSheetName(defaultSheet, sheetName) + } + + headers := []interface{}{"worker_name", "worker_ip", "status", "registered_at", "completed_at"} + if err := file.SetSheetRow(sheetName, "A1", &headers); err != nil { + return err + } + + return file.SaveAs(workbookPath) +} + +func openWorkbook() (*excelize.File, error) { + if err := ensureWorkbook(); err != nil { + return nil, err + } + + file, err := excelize.OpenFile(workbookPath) + if err != nil { + return nil, err + } + + return file, nil +} + +func closeWorkbook(file *excelize.File) { + if err := file.Close(); err != nil { + log.Printf("failed to close workbook: %v", err) + } +} + +func writeJSON(w http.ResponseWriter, statusCode int, payload apiResponse) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Printf("failed to encode JSON response: %v", err) + } +} + +func writeError(w http.ResponseWriter, statusCode int, message string) { + writeJSON(w, statusCode, apiResponse{Message: message}) +} + +// Made with Bob diff --git a/serverless-fleet-worker-registration/run b/serverless-fleet-worker-registration/run new file mode 100755 index 00000000..e3766c63 --- /dev/null +++ b/serverless-fleet-worker-registration/run @@ -0,0 +1,67 @@ +#!/bin/bash + +set -e + +uuid=$(uuidgen | tr '[:upper:]' '[:lower:]' | awk -F- '{print $1}') + +APP_URL="$(ibmcloud ce app get --name fleet-workers -o url)" + +PREHOOK=$(cat < Date: Wed, 22 Apr 2026 14:21:09 +0200 Subject: [PATCH 2/5] cleanup bob Signed-off-by: Luke Roy --- serverless-fleet-worker-registration/main.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/serverless-fleet-worker-registration/main.go b/serverless-fleet-worker-registration/main.go index 2787340c..1f3f1595 100644 --- a/serverless-fleet-worker-registration/main.go +++ b/serverless-fleet-worker-registration/main.go @@ -300,5 +300,3 @@ func writeJSON(w http.ResponseWriter, statusCode int, payload apiResponse) { func writeError(w http.ResponseWriter, statusCode int, message string) { writeJSON(w, statusCode, apiResponse{Message: message}) } - -// Made with Bob From c742ddd65aed66e370a91712f32681741aea22f9 Mon Sep 17 00:00:00 2001 From: Luke Roy Date: Wed, 22 Apr 2026 14:31:09 +0200 Subject: [PATCH 3/5] docker file fix Signed-off-by: Luke Roy --- serverless-fleet-worker-registration/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/serverless-fleet-worker-registration/Dockerfile b/serverless-fleet-worker-registration/Dockerfile index 0c1640e1..1d3dd24f 100644 --- a/serverless-fleet-worker-registration/Dockerfile +++ b/serverless-fleet-worker-registration/Dockerfile @@ -19,7 +19,7 @@ COPY main.go ./ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o fleet-register . # Stage 2: Runtime stage -FROM gcr.io/distroless/static-debian13 +FROM registry.access.redhat.com/ubi10/ubi-minimal # Copy binary from builder COPY --from=builder /build/fleet-register /app/fleet-register @@ -30,6 +30,8 @@ WORKDIR /app # Expose port EXPOSE 8080 +RUN mkdir /fleet-workers + # Set environment variable for the workbook path ENV WORKBOOK_PATH=/fleet-workers/fleet-register.xlsx From 3987b2ce626b557f920afa3fbc800a0468d59a24 Mon Sep 17 00:00:00 2001 From: Luke Roy Date: Wed, 22 Apr 2026 15:28:44 +0200 Subject: [PATCH 4/5] remove excel for csv Signed-off-by: Luke Roy --- .../.dockerignore | 3 +- .../Dockerfile | 4 +- .../README.md | 18 +- serverless-fleet-worker-registration/go.mod | 13 -- serverless-fleet-worker-registration/main.go | 177 +++++++----------- 5 files changed, 82 insertions(+), 133 deletions(-) diff --git a/serverless-fleet-worker-registration/.dockerignore b/serverless-fleet-worker-registration/.dockerignore index eb18aed1..b06bada8 100644 --- a/serverless-fleet-worker-registration/.dockerignore +++ b/serverless-fleet-worker-registration/.dockerignore @@ -10,8 +10,7 @@ fleet-register *_test.go # Output files -*.xlsx -*.xls +*.csv # Git .git diff --git a/serverless-fleet-worker-registration/Dockerfile b/serverless-fleet-worker-registration/Dockerfile index 1d3dd24f..c4d131fd 100644 --- a/serverless-fleet-worker-registration/Dockerfile +++ b/serverless-fleet-worker-registration/Dockerfile @@ -32,8 +32,8 @@ EXPOSE 8080 RUN mkdir /fleet-workers -# Set environment variable for the workbook path -ENV WORKBOOK_PATH=/fleet-workers/fleet-register.xlsx +# Set environment variable for the CSV path +ENV CSV_PATH=/fleet-workers/fleet-register.csv # Run the application CMD ["/app/fleet-register"] \ No newline at end of file diff --git a/serverless-fleet-worker-registration/README.md b/serverless-fleet-worker-registration/README.md index 974caacb..030edfb0 100644 --- a/serverless-fleet-worker-registration/README.md +++ b/serverless-fleet-worker-registration/README.md @@ -1,10 +1,10 @@ # Fleet Register -A lightweight Go HTTP server that monitors IBM Cloud Code Engine fleet workers by tracking their lifecycle events in an Excel file. +A lightweight Go HTTP server that monitors IBM Cloud Code Engine fleet workers by tracking their lifecycle events in a CSV file. ## Use Case -This application is deployed as a Code Engine application to monitor fleet workers. Fleet workers call the `/register` endpoint when they start and the `/deregister` endpoint before they shut down. The application maintains a persistent Excel file tracking all worker activity, including registration and completion timestamps. +This application is deployed as a Code Engine application to monitor fleet workers. Fleet workers call the `/register` endpoint when they start and the `/deregister` endpoint before they shut down. The application maintains a persistent CSV file tracking all worker activity, including registration and completion timestamps. **Deployment Flow:** 1. Deploy this app as a Code Engine application with persistent storage @@ -14,14 +14,14 @@ This application is deployed as a Code Engine application to monitor fleet worke ## Endpoints - `POST /register` - Creates a row in `fleet-register.xlsx` with status `running` and records the registration timestamp. + Creates a row in `fleet-register.csv` with status `running` and records the registration timestamp. - `POST /deregister` Updates the matching row by `worker_name` and `worker_ip` to `completed` and records the completion timestamp. If no row exists, it creates a new row with status `completed`. - `GET /download` - Downloads the Excel file. + Downloads the CSV file. ## Request body @@ -93,7 +93,7 @@ docker build -t fleet-register . docker run -p 8080:8080 -v fleet-data:/fleet-workers fleet-register ``` -The Excel file is saved to `/fleet-workers/fleet-register.xlsx` and persisted in the volume. +The CSV file is saved to `/fleet-workers/fleet-register.csv` and persisted in the volume. ## Examples @@ -119,17 +119,17 @@ curl -X POST http://localhost:8080/deregister \ }' ``` -Download the workbook: +Download the CSV file: ```bash curl -O -J http://localhost:8080/download ``` -## Excel file +## CSV file -The server automatically creates `fleet-register.xlsx` in the project root (or `/fleet-workers/` in Docker) if it does not exist. +The server automatically creates `fleet-register.csv` in the project root (or `/fleet-workers/` in Docker) if it does not exist. -The workbook contains one sheet named `Workers` with these columns: +The CSV file contains the following columns: - `worker_name` - Name of the worker - `worker_ip` - IP address of the worker diff --git a/serverless-fleet-worker-registration/go.mod b/serverless-fleet-worker-registration/go.mod index 38048eed..24b45f57 100644 --- a/serverless-fleet-worker-registration/go.mod +++ b/serverless-fleet-worker-registration/go.mod @@ -1,16 +1,3 @@ module fleet-register go 1.22 - -require github.com/xuri/excelize/v2 v2.8.1 - -require ( - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/richardlehane/mscfb v1.0.4 // indirect - github.com/richardlehane/msoleps v1.0.3 // indirect - github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect - github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect - golang.org/x/crypto v0.19.0 // indirect - golang.org/x/net v0.21.0 // indirect - golang.org/x/text v0.14.0 // indirect -) diff --git a/serverless-fleet-worker-registration/main.go b/serverless-fleet-worker-registration/main.go index 1f3f1595..45553974 100644 --- a/serverless-fleet-worker-registration/main.go +++ b/serverless-fleet-worker-registration/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/csv" "encoding/json" "errors" "fmt" @@ -10,25 +11,22 @@ import ( "strings" "sync" "time" - - "github.com/xuri/excelize/v2" ) const ( serverAddr = ":8080" - sheetName = "Workers" ) -var workbookPath = getWorkbookPath() +var csvPath = getCSVPath() -func getWorkbookPath() string { - if path := os.Getenv("WORKBOOK_PATH"); path != "" { +func getCSVPath() string { + if path := os.Getenv("CSV_PATH"); path != "" { return path } - return "fleet-register.xlsx" + return "fleet-register.csv" } -var workbookMu sync.Mutex +var csvMu sync.Mutex type workerRequest struct { WorkerName string `json:"worker_name"` @@ -64,8 +62,8 @@ func registerHandler(w http.ResponseWriter, r *http.Request) { return } - workbookMu.Lock() - defer workbookMu.Unlock() + csvMu.Lock() + defer csvMu.Unlock() if err := appendWorkerRow(req, "running"); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to register worker: %v", err)) @@ -77,7 +75,7 @@ func registerHandler(w http.ResponseWriter, r *http.Request) { WorkerName: req.WorkerName, WorkerIP: req.WorkerIP, Status: "running", - File: workbookPath, + File: csvPath, }) } @@ -88,8 +86,8 @@ func deregisterHandler(w http.ResponseWriter, r *http.Request) { return } - workbookMu.Lock() - defer workbookMu.Unlock() + csvMu.Lock() + defer csvMu.Unlock() updated, err := completeWorker(req) if err != nil { @@ -107,27 +105,27 @@ func deregisterHandler(w http.ResponseWriter, r *http.Request) { WorkerName: req.WorkerName, WorkerIP: req.WorkerIP, Status: "completed", - File: workbookPath, + File: csvPath, }) } func downloadHandler(w http.ResponseWriter, r *http.Request) { - workbookMu.Lock() - defer workbookMu.Unlock() + csvMu.Lock() + defer csvMu.Unlock() - if err := ensureWorkbook(); err != nil { - writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to prepare workbook: %v", err)) + if err := ensureCSV(); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to prepare CSV: %v", err)) return } - content, err := os.ReadFile(workbookPath) + content, err := os.ReadFile(csvPath) if err != nil { - writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read workbook: %v", err)) + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read CSV: %v", err)) return } - w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, workbookPath)) + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, csvPath)) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) w.WriteHeader(http.StatusOK) @@ -159,133 +157,98 @@ func decodeWorkerRequest(r *http.Request) (workerRequest, error) { } func appendWorkerRow(req workerRequest, status string) error { - file, err := openWorkbook() - if err != nil { + if err := ensureCSV(); err != nil { return err } - defer closeWorkbook(file) - rows, err := file.GetRows(sheetName) + file, err := os.OpenFile(csvPath, os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return err } + defer file.Close() - nextRow := len(rows) + 1 - registeredAt := time.Now().Format(time.RFC3339) - values := []interface{}{req.WorkerName, req.WorkerIP, status, registeredAt, ""} - - cell, err := excelize.CoordinatesToCellName(1, nextRow) - if err != nil { - return err - } + writer := csv.NewWriter(file) + defer writer.Flush() - if err := file.SetSheetRow(sheetName, cell, &values); err != nil { - return err - } + registeredAt := time.Now().Format(time.RFC3339) + record := []string{req.WorkerName, req.WorkerIP, status, registeredAt, ""} - return file.SaveAs(workbookPath) + return writer.Write(record) } func completeWorker(req workerRequest) (bool, error) { - file, err := openWorkbook() + if err := ensureCSV(); err != nil { + return false, err + } + + // Read all records + file, err := os.Open(csvPath) if err != nil { return false, err } - defer closeWorkbook(file) - rows, err := file.GetRows(sheetName) + reader := csv.NewReader(file) + records, err := reader.ReadAll() + file.Close() if err != nil { return false, err } completedAt := time.Now().Format(time.RFC3339) - - for index := 1; index < len(rows); index++ { - row := rows[index] - if len(row) < 2 { - continue - } - - if row[0] == req.WorkerName && row[1] == req.WorkerIP { - // Update status to completed (column 3) - statusCell, err := excelize.CoordinatesToCellName(3, index+1) - if err != nil { - return false, err - } - - if err := file.SetCellValue(sheetName, statusCell, "completed"); err != nil { - return false, err - } - - // Update completed_at timestamp (column 5) - completedCell, err := excelize.CoordinatesToCellName(5, index+1) - if err != nil { - return false, err - } - - if err := file.SetCellValue(sheetName, completedCell, completedAt); err != nil { - return false, err - } - - return true, file.SaveAs(workbookPath) + updated := false + + // Update matching record + for i := 1; i < len(records); i++ { + if len(records[i]) >= 2 && records[i][0] == req.WorkerName && records[i][1] == req.WorkerIP { + records[i][2] = "completed" + records[i][4] = completedAt + updated = true + break } } - // If worker not found, add new row with completed status - values := []interface{}{req.WorkerName, req.WorkerIP, "completed", "", completedAt} - nextRow := len(rows) + 1 + // If not found, add new record + if !updated { + records = append(records, []string{req.WorkerName, req.WorkerIP, "completed", "", completedAt}) + } - cell, err := excelize.CoordinatesToCellName(1, nextRow) + // Write all records back + file, err = os.Create(csvPath) if err != nil { return false, err } + defer file.Close() - if err := file.SetSheetRow(sheetName, cell, &values); err != nil { - return false, err + writer := csv.NewWriter(file) + defer writer.Flush() + + for _, record := range records { + if err := writer.Write(record); err != nil { + return false, err + } } - return false, file.SaveAs(workbookPath) + return updated, nil } -func ensureWorkbook() error { - if _, err := os.Stat(workbookPath); err == nil { +func ensureCSV() error { + if _, err := os.Stat(csvPath); err == nil { return nil } else if !os.IsNotExist(err) { return err } - file := excelize.NewFile() - defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) - - if defaultSheet != sheetName { - file.SetSheetName(defaultSheet, sheetName) - } - - headers := []interface{}{"worker_name", "worker_ip", "status", "registered_at", "completed_at"} - if err := file.SetSheetRow(sheetName, "A1", &headers); err != nil { - return err - } - - return file.SaveAs(workbookPath) -} - -func openWorkbook() (*excelize.File, error) { - if err := ensureWorkbook(); err != nil { - return nil, err - } - - file, err := excelize.OpenFile(workbookPath) + file, err := os.Create(csvPath) if err != nil { - return nil, err + return err } + defer file.Close() - return file, nil -} + writer := csv.NewWriter(file) + defer writer.Flush() -func closeWorkbook(file *excelize.File) { - if err := file.Close(); err != nil { - log.Printf("failed to close workbook: %v", err) - } + headers := []string{"worker_name", "worker_ip", "status", "registered_at", "completed_at"} + return writer.Write(headers) } func writeJSON(w http.ResponseWriter, statusCode int, payload apiResponse) { From 433d14bd72a1b48f63c7de327e1c1693c89792f6 Mon Sep 17 00:00:00 2001 From: Luke Roy Date: Wed, 22 Apr 2026 15:35:19 +0200 Subject: [PATCH 5/5] go mod tidy Signed-off-by: Luke Roy --- serverless-fleet-worker-registration/go.sum | 29 --------------------- 1 file changed, 29 deletions(-) diff --git a/serverless-fleet-worker-registration/go.sum b/serverless-fleet-worker-registration/go.sum index 144577c6..e69de29b 100644 --- a/serverless-fleet-worker-registration/go.sum +++ b/serverless-fleet-worker-registration/go.sum @@ -1,29 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= -github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= -github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM= -github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0= -github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ= -github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE= -github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4= -github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= -golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= -golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=