Skip to content

Commit c3bbae1

Browse files
committed
build: bump version 0.2.0 for deployment, productionise, fix all tests
1 parent b221020 commit c3bbae1

20 files changed

Lines changed: 623 additions & 552 deletions

File tree

.github/workflows/release_binary.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
runs-on: ubuntu-latest
1313

1414
container:
15-
image: docker.io/goreleaser/goreleaser-cross:v1.25
15+
image: docker.io/goreleaser/goreleaser-cross:v1.26
1616

1717
steps:
1818
- name: Checkout

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM golang:1.25 AS base
1+
FROM golang:1.26 AS base
22

33

44
# Build statically compiled binary

app/api/main.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@ import (
1818

1919
// Make the API, JobQueue, and WorkflowClient available on each endpoint
2020
type API struct {
21-
api huma.API
22-
workflowClient workflows.WorkflowClient
23-
metadataStore *meta.Store
21+
api huma.API
22+
workflowClient workflows.WorkflowClient
23+
metadataStore *meta.Store
24+
downloadHandler http.Handler // raw handler for download redirect
2425
}
2526

2627
// NewAPI creates the Huma API and registers routes.
2728
// It returns the API object and the HTTP handler (stdlib mux) that should be served.
2829
func NewAPI(metadataStore *meta.Store, workflowClient workflows.WorkflowClient) (*API, http.Handler) {
29-
apiConfig := huma.DefaultConfig("ScaleODM API", "0.1.0")
30+
apiConfig := huma.DefaultConfig("ScaleODM API", "0.2.0")
3031
apiConfig.DocsPath = "/"
3132
apiConfig.OpenAPIPath = "/openapi.json"
3233
apiConfig.Servers = []*huma.Server{
@@ -90,6 +91,12 @@ func NewAPI(metadataStore *meta.Store, workflowClient workflows.WorkflowClient)
9091
apiObj.registerNodeODMRoutes()
9192
// apiObj.registerScaleODMRoutes()
9293

94+
// Register the download handler as a raw HTTP route (outside Huma)
95+
// so we can issue proper HTTP redirects to pre-signed S3 URLs.
96+
if apiObj.downloadHandler != nil {
97+
router.Handle("GET /task/{uuid}/download/{asset}", apiObj.downloadHandler)
98+
}
99+
93100
return apiObj, router
94101
}
95102

app/api/nodeodm_api.go

Lines changed: 71 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,39 @@ import (
66
"fmt"
77
"log"
88
"net/http"
9+
"regexp"
910
"strings"
1011
"time"
1112

1213
wfv1 "github.com/argoproj/argo-workflows/v3/pkg/apis/workflow/v1alpha1"
1314
"github.com/danielgtaylor/huma/v2"
1415
_ "github.com/danielgtaylor/huma/v2/formats/cbor"
16+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
1517

1618
"github.com/hotosm/scaleodm/app/config"
1719
"github.com/hotosm/scaleodm/app/s3"
1820
"github.com/hotosm/scaleodm/app/workflows"
1921
)
2022

23+
// isNotFound checks whether an error (possibly wrapped) represents a
24+
// Kubernetes "not found" response.
25+
func isNotFound(err error) bool {
26+
return k8serrors.IsNotFound(err)
27+
}
28+
29+
// shellSafePattern matches strings that are safe to embed in shell scripts.
30+
// Allows alphanumerics, hyphens, underscores, dots, forward slashes, colons,
31+
// and the equals sign. This prevents shell injection via user-supplied values.
32+
var shellSafePattern = regexp.MustCompile(`^[a-zA-Z0-9\-_./=:@]+$`)
33+
34+
// validateShellSafe checks that a string is safe to embed in a shell script.
35+
func validateShellSafe(value, fieldName string) error {
36+
if !shellSafePattern.MatchString(value) {
37+
return fmt.Errorf("%s contains invalid characters: only alphanumerics, hyphens, underscores, dots, slashes, colons, equals, and @ are allowed", fieldName)
38+
}
39+
return nil
40+
}
41+
2142
// NodeODM status codes
2243
const (
2344
StatusCodeQueued = 10
@@ -167,7 +188,7 @@ func (a *API) registerNodeODMRoutes() {
167188
}
168189

169190
resp := &InfoResponse{}
170-
resp.Body.Version = "0.1.0" // The ScaleODM version (normally the NodeODM version)
191+
resp.Body.Version = "0.2.0" // The ScaleODM version (normally the NodeODM version)
171192
resp.Body.TaskQueueCount = queueCount
172193
resp.Body.MaxImages = nil // Unlimited
173194
resp.Body.Engine = "odm"
@@ -338,6 +359,22 @@ func (a *API) registerNodeODMRoutes() {
338359
projectID = "odm-project"
339360
}
340361

362+
// Validate all values that will be embedded in shell scripts
363+
if err := validateShellSafe(projectID, "name"); err != nil {
364+
return nil, huma.NewError(400, err.Error())
365+
}
366+
for _, flag := range odmFlags {
367+
if err := validateShellSafe(flag, "options flag"); err != nil {
368+
return nil, huma.NewError(400, err.Error())
369+
}
370+
}
371+
if err := validateShellSafe(readPath, "readS3Path"); err != nil {
372+
return nil, huma.NewError(400, err.Error())
373+
}
374+
if err := validateShellSafe(writePath, "writeS3Path"); err != nil {
375+
return nil, huma.NewError(400, err.Error())
376+
}
377+
341378
// Determine S3 region & optional endpoint
342379
s3Region := req.S3Region
343380
if s3Region == "" {
@@ -375,7 +412,9 @@ func (a *API) registerNodeODMRoutes() {
375412
s3Region,
376413
)
377414

378-
// Record metadata in database
415+
// Record metadata in database. If this fails, the workflow exists in
416+
// Argo but won't be visible via the API - treat as a hard error so the
417+
// caller knows to retry rather than losing track of the workflow.
379418
_, err = a.metadataStore.CreateJob(
380419
ctx,
381420
wf.Name,
@@ -386,7 +425,8 @@ func (a *API) registerNodeODMRoutes() {
386425
s3Region,
387426
)
388427
if err != nil {
389-
log.Printf("Warning: Failed to record job metadata: %v", err)
428+
log.Printf("ERROR: Failed to record job metadata for workflow %q: %v", wf.Name, err)
429+
return nil, huma.NewError(500, "Workflow created but failed to record metadata - retry the request", err)
390430
}
391431

392432
resp := &TaskNewResponse{}
@@ -601,7 +641,7 @@ func (a *API) registerNodeODMRoutes() {
601641

602642
err := a.workflowClient.DeleteWorkflow(ctx, input.Body.UUID)
603643
if err != nil {
604-
if strings.Contains(err.Error(), "not found") {
644+
if isNotFound(err) {
605645
log.Printf("POST /task/cancel: task %q not found", input.Body.UUID)
606646
return nil, huma.NewError(404, "Task not found")
607647
}
@@ -636,7 +676,7 @@ func (a *API) registerNodeODMRoutes() {
636676

637677
// Delete from Argo
638678
err := a.workflowClient.DeleteWorkflow(ctx, input.Body.UUID)
639-
if err != nil && !strings.Contains(err.Error(), "not found") {
679+
if err != nil && !isNotFound(err) {
640680
log.Printf("POST /task/remove: failed to delete workflow for %q: %v", input.Body.UUID, err)
641681
return nil, huma.NewError(500, "Failed to remove task", err)
642682
}
@@ -696,7 +736,7 @@ func (a *API) registerNodeODMRoutes() {
696736
}
697737

698738
// Delete old workflow
699-
if err := a.workflowClient.DeleteWorkflow(ctx, input.Body.UUID); err != nil && !strings.Contains(err.Error(), "not found") {
739+
if err := a.workflowClient.DeleteWorkflow(ctx, input.Body.UUID); err != nil && !isNotFound(err) {
700740
log.Printf("POST /task/restart: failed to delete old workflow for %q: %v", input.Body.UUID, err)
701741
}
702742

@@ -736,94 +776,41 @@ func (a *API) registerNodeODMRoutes() {
736776
})
737777

738778
// GET /task/{uuid}/download/{asset} - Download task asset (redirects to pre-signed URL)
739-
huma.Register(a.api, huma.Operation{
740-
OperationID: "task-uuid-download-asset-get",
741-
Method: http.MethodGet,
742-
Path: "/task/{uuid}/download/{asset}",
743-
Summary: "Download task output asset",
744-
Description: "Redirects to a pre-signed URL for downloading the asset directly from S3",
745-
Tags: []string{"task"},
746-
}, func(ctx context.Context, input *struct {
747-
UUID string `path:"uuid" doc:"UUID of the task"`
748-
Asset string `path:"asset" doc:"Asset type (all.zip, orthophoto.tif, etc)"`
749-
Token string `query:"token" doc:"Authentication token (optional)"`
750-
}) (*struct{ Body string }, error) {
751-
log.Printf("GET /task/%s/download/%s: token_provided=%t", input.UUID, input.Asset, input.Token != "")
752-
753-
// Get job metadata to retrieve write path
754-
metadata, err := a.metadataStore.GetJob(ctx, input.UUID)
779+
// Registered as a raw HTTP handler on the mux for reliable redirect support.
780+
// Huma handlers return structured responses which can't express HTTP redirects,
781+
// so we handle this endpoint outside Huma.
782+
a.downloadHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
783+
uuid := r.PathValue("uuid")
784+
asset := r.PathValue("asset")
785+
log.Printf("GET /task/%s/download/%s", uuid, asset)
786+
787+
metadata, err := a.metadataStore.GetJob(r.Context(), uuid)
755788
if err != nil {
756-
log.Printf("GET /task/%s/download/%s: failed to retrieve metadata: %v", input.UUID, input.Asset, err)
757-
return nil, huma.NewError(500, "Failed to retrieve task metadata", err)
789+
log.Printf("GET /task/%s/download/%s: failed to retrieve metadata: %v", uuid, asset, err)
790+
http.Error(w, `{"error":"Failed to retrieve task metadata"}`, http.StatusInternalServerError)
791+
return
758792
}
759-
760793
if metadata == nil {
761-
log.Printf("GET /task/%s/download/%s: task not found in metadata store", input.UUID, input.Asset)
762-
return nil, huma.NewError(404, "Task not found")
794+
log.Printf("GET /task/%s/download/%s: task not found", uuid, asset)
795+
http.Error(w, `{"error":"Task not found"}`, http.StatusNotFound)
796+
return
763797
}
764-
765798
if metadata.WriteS3Path == "" {
766-
log.Printf("GET /task/%s/download/%s: write S3 path not available", input.UUID, input.Asset)
767-
return nil, huma.NewError(400, "Write S3 path not available for this task")
799+
log.Printf("GET /task/%s/download/%s: write S3 path not available", uuid, asset)
800+
http.Error(w, `{"error":"Write S3 path not available for this task"}`, http.StatusBadRequest)
801+
return
768802
}
769803

770-
// Get S3 client
771804
s3Client := s3.GetS3Client()
772-
773-
// Generate pre-signed URL (valid for 1 hour)
774-
presignedURL, err := s3.GeneratePresignedURL(ctx, s3Client, metadata.WriteS3Path, input.Asset, 1*time.Hour)
805+
presignedURL, err := s3.GeneratePresignedURL(r.Context(), s3Client, metadata.WriteS3Path, asset, 1*time.Hour)
775806
if err != nil {
776-
log.Printf("GET /task/%s/download/%s: failed to generate pre-signed URL: %v", input.UUID, input.Asset, err)
777-
return nil, huma.NewError(404, fmt.Sprintf("File not found: %s", input.Asset), err)
778-
}
779-
780-
log.Printf("GET /task/%s/download/%s: redirecting to pre-signed URL (expires in 1 hour)", input.UUID, input.Asset)
781-
782-
// Access response writer and request from humago adapter's context
783-
// The humago adapter stores the response writer using a specific context key
784-
// We need to access it through the adapter's internal context storage
785-
type responseWriterKey struct{}
786-
type requestKey struct{}
787-
788-
var rw http.ResponseWriter
789-
var req *http.Request
790-
791-
// Try accessing through typed context keys
792-
if val := ctx.Value(responseWriterKey{}); val != nil {
793-
rw, _ = val.(http.ResponseWriter)
794-
}
795-
if val := ctx.Value(requestKey{}); val != nil {
796-
req, _ = val.(*http.Request)
797-
}
798-
799-
// Try string-based keys (some adapters use these)
800-
if rw == nil {
801-
if val := ctx.Value("http.responseWriter"); val != nil {
802-
rw, _ = val.(http.ResponseWriter)
803-
}
804-
}
805-
if req == nil {
806-
if val := ctx.Value("http.request"); val != nil {
807-
req, _ = val.(*http.Request)
808-
}
807+
log.Printf("GET /task/%s/download/%s: failed to generate pre-signed URL: %v", uuid, asset, err)
808+
http.Error(w, fmt.Sprintf(`{"error":"File not found: %s"}`, asset), http.StatusNotFound)
809+
return
809810
}
810-
811-
// Try getting from http.ServerContextKey
812-
if req == nil {
813-
if httpReq, ok := ctx.Value(http.ServerContextKey).(*http.Request); ok && httpReq != nil {
814-
req = httpReq
815-
}
816-
}
817-
818-
if rw != nil && req != nil {
819-
// Perform redirect
820-
http.Redirect(rw, req, presignedURL, http.StatusFound)
821-
return nil, nil
822-
}
823-
824-
// Fallback: Return the URL in response body if redirect not possible
825-
// This provides graceful degradation - client can still get the URL
826-
return &struct{ Body string }{Body: presignedURL}, nil
811+
812+
log.Printf("GET /task/%s/download/%s: redirecting to pre-signed URL (expires in 1 hour)", uuid, asset)
813+
http.Redirect(w, r, presignedURL, http.StatusFound)
827814
})
828815
}
829816

app/api/nodeodm_api_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func TestInfoEndpoint(t *testing.T) {
3737
}
3838
err := json.Unmarshal(w.Body.Bytes(), &response)
3939
require.NoError(t, err)
40-
assert.Equal(t, "0.1.0", response.Version)
40+
assert.Equal(t, "0.2.0", response.Version)
4141
assert.Equal(t, "odm", response.Engine)
4242
}
4343

app/db/connection.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package db
22

33
import (
44
"context"
5+
"hash/crc32"
56
_ "embed"
67
"fmt"
78
"strings"
@@ -10,6 +11,11 @@ import (
1011
"github.com/jackc/pgx/v5/pgxpool"
1112
)
1213

14+
// schemaLockID is a PostgreSQL advisory lock ID used to serialise
15+
// concurrent schema initialisations. Derived from a CRC32 hash of a
16+
// well-known string so it won't collide with other applications.
17+
var schemaLockID = int64(crc32.ChecksumIEEE([]byte("scaleodm-schema-init")))
18+
1319
//go:embed schema.sql
1420
var schemaSQL string
1521

@@ -53,9 +59,7 @@ func (db *DB) Close() {
5359

5460
// Create the required tables and indexes
5561
func (db *DB) InitSchema(ctx context.Context) error {
56-
// Use advisory lock to prevent concurrent schema initialization
57-
// Lock ID 123456 is arbitrary but consistent
58-
lockID := int64(123456)
62+
lockID := schemaLockID
5963

6064
// Use a transaction to ensure we use the same connection for lock/unlock
6165
tx, err := db.Pool.Begin(ctx)

app/s3/files.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,12 @@ extract_and_clean() {
9999
find "$dir" -type f \( -name "*.tar.gz" -o -name "*.tar" -o -name "*.TAR.GZ" -o -name "*.TAR" \) | while read tarfile; do
100100
found_archive=true
101101
echo "Extracting $tarfile..."
102-
# Use --no-same-owner and strip leading path components to prevent path traversal
103-
tar --no-same-owner --no-same-permissions -xf "$tarfile" -C "$(dirname "$tarfile")" 2>/dev/null || \
104-
tar --no-same-owner --no-same-permissions -xzf "$tarfile" -C "$(dirname "$tarfile")" 2>/dev/null || true
102+
# --no-same-owner: don't try to preserve ownership
103+
# --no-same-permissions: don't try to preserve permissions
104+
# --no-absolute-filenames: strip leading / to prevent writing outside target
105+
# --transform: strip leading directory component (like -j for zip)
106+
tar --no-same-owner --no-same-permissions --no-absolute-filenames --transform='s|.*/||' -xf "$tarfile" -C "$(dirname "$tarfile")" 2>/dev/null || \
107+
tar --no-same-owner --no-same-permissions --no-absolute-filenames --transform='s|.*/||' -xzf "$tarfile" -C "$(dirname "$tarfile")" 2>/dev/null || true
105108
rm -f "$tarfile"
106109
check_extract_limits "$dir"
107110
done

chart/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ apiVersion: v2
22
type: application
33
name: scaleodm
44
description: Kubernetes-native auto-scaling and load balancing for OpenDroneMap.
5-
version: "0.1.0"
6-
appVersion: "0.1.0"
5+
version: "0.2.0"
6+
appVersion: "0.2.0"
77
maintainers:
88
- email: sam.woodcock@hotosm.org
99
name: Sam Woodcock

chart/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ At least one database and one S3 configuration path must be provided:
9898
- `database.postgres.enabled=true` and corresponding `database.postgres.auth.*` for the bundled Postgres subchart.
9999

100100
- **S3** (two secrets required in the release namespace):
101-
- `s3.external.secret.name` Secret containing `SCALEODM_S3_ENDPOINT`, `SCALEODM_S3_ACCESS_KEY`, and `SCALEODM_S3_SECRET_KEY` (used by the API server).
102-
- `s3.workflowSecret.name` Secret containing `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_DEFAULT_REGION` (used by workflow pods via `secretKeyRef`).
101+
- `s3.external.secret.name` - Secret containing `SCALEODM_S3_ENDPOINT`, `SCALEODM_S3_ACCESS_KEY`, and `SCALEODM_S3_SECRET_KEY` (used by the API server).
102+
- `s3.workflowSecret.name` - Secret containing `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_DEFAULT_REGION` (used by workflow pods via `secretKeyRef`).
103103

104104
Both secrets live in the release namespace. If you use the same credentials for both, you can point them at the same secret (provided the key names match).
105105

chart/templates/deployment.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,18 @@ spec:
2222
{{- toYaml . | nindent 8 }}
2323
{{- end }}
2424
serviceAccountName: {{ include "scaleodm.serviceAccountName" . }}
25+
{{- with .Values.api.podSecurityContext }}
26+
securityContext:
27+
{{- toYaml . | nindent 8 }}
28+
{{- end }}
2529
containers:
2630
- name: {{ .Chart.Name }}
2731
image: "{{ .Values.global.imageRegistry }}{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Chart.AppVersion }}"
2832
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
33+
{{- with .Values.api.securityContext }}
34+
securityContext:
35+
{{- toYaml . | nindent 12 }}
36+
{{- end }}
2937
ports:
3038
- name: http
3139
containerPort: {{ .Values.api.port }}

0 commit comments

Comments
 (0)