From 82e5b3964abd4f27335873cb46d0d5fbf3eee207 Mon Sep 17 00:00:00 2001 From: Ondrej Belusky Date: Thu, 12 Feb 2026 11:48:01 +0100 Subject: [PATCH] create nats-upload action --- action.yml | 42 ++++++++++ go.mod | 16 ++++ go.sum | 14 ++++ justfile | 119 +++++++++++++++++++++++++++ main.go | 238 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 429 insertions(+) create mode 100644 action.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 justfile create mode 100644 main.go diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..c575a40 --- /dev/null +++ b/action.yml @@ -0,0 +1,42 @@ +name: 'NATS Upload' +description: 'Upload binaries to NATS Object Store for self-update' +inputs: + nats_url: + description: 'NATS server URL' + required: true + default: 'nats://localhost:4222' + bucket: + description: 'Object store bucket name' + required: true + default: 'binaries' + source: + description: 'Directory containing binaries to upload' + required: true + default: 'upload' + strip_prefix: + description: 'Prefix to strip from paths' + required: false + default: '' + binary: + description: 'Binary name (defaults to first binary found)' + required: false + default: '' + notify_topic: + description: 'NATS topic to publish update notification' + required: false + default: 'binaries.update' + skip_notify: + description: 'Skip publishing update notification' + required: false + default: 'false' + cleanup: + description: 'Keep only N most recent versions (0 disables cleanup)' + required: false + default: '0' + cleanup_all: + description: 'Cleanup all binaries, not just current one' + required: false + default: 'false' +runs: + using: 'go' + main: 'main.go' diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..11fd91f --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module lab.zlymeda.dynu.net/actions/nats-upload + +go 1.26 + +require ( + github.com/nats-io/nats.go v1.48.0 + golang.org/x/mod v0.33.0 +) + +require ( + github.com/klauspost/compress v1.18.4 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..041fce2 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/justfile b/justfile new file mode 100644 index 0000000..3c4d302 --- /dev/null +++ b/justfile @@ -0,0 +1,119 @@ +set fallback + +default: + just --choose + +list: + just --list + + +import? 'justfile.custom' + +# Variables with defaults +CGO_ENABLED := env_var_or_default('CGO_ENABLED', '0') +GO111MODULE := env_var_or_default('GO111MODULE', 'on') +GOFLAGS := env_var_or_default('GOFLAGS', '"-mod=mod"') +SUFFIX := env_var_or_default('SUFFIX', '') + +# Determine project name +NAME := if env_var_or_default('GITEA_REPO_NAME', '') != '' { + env_var('GITEA_REPO_NAME') +} else { + file_name(justfile_directory()) +} + +OUTBIN := env_var_or_default('OUTBIN', NAME) + +# Build metadata +VERSION := env_var_or_default('VERSION', `git describe --tags --always --dirty 2> /dev/null || echo dev`) +BUILD_TIME := env_var_or_default('BUILD_TIME', `/bin/date +%FT%T%z`) +PKG := `GOWORK=off go list -m` +LD_FLAGS_OPTIMIZE := env_var_or_default('LD_FLAGS_OPTIMIZE', '-s -w') +LD_FLAGS := '"-X ' + PKG + '/pkg/version.Version=' + VERSION + ' -X ' + PKG + '/pkg/version.BuildTime=' + BUILD_TIME + ' ' + LD_FLAGS_OPTIMIZE + '"' +GO_OPTS := env_var_or_default('GO_OPTS', '-trimpath') +OPTIMIZE := env_var_or_default('OPTIMIZE', 'false') + +# Display version information +version: + @echo {{NAME}} {{VERSION}} {{BUILD_TIME}} {{PKG}} {{LD_FLAGS}} + +# Build for all platforms +all-build: build-linux-amd64 build-linux-arm64 build-linux-386 + +# Download dependencies +download: + @echo Download go.mod dependencies + go mod download + +# Generate code +generate: + GOWORK=off go generate -mod=mod ./... + +# Run wire dependency injection +wire: + go generate -x ./di + +# Build for specific platform +build GOOS GOARCH: + GOOS={{GOOS}} GOARCH={{GOARCH}} CGO_ENABLED={{CGO_ENABLED}} go build \ + -ldflags {{LD_FLAGS}} {{GO_OPTS}} \ + -mod mod \ + -o bin/{{OUTBIN}}{{SUFFIX}}_{{GOOS}}_{{GOARCH}} \ + ./cmd/{{OUTBIN}}/main.go +build-race: + GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -race \ + -ldflags {{LD_FLAGS}} {{GO_OPTS}} \ + -mod mod \ + -o bin/{{OUTBIN}}{{SUFFIX}}_linux_amd64 \ + ./cmd/{{OUTBIN}}/main.go + +# Install binaries +install: + CGO_ENABLED={{CGO_ENABLED}} go install \ + -ldflags {{LD_FLAGS}} \ + ./... + +# Build for Linux ARM64 +build-linux-arm64: (build "linux" "arm64") + +# Build for Linux AMD64 +build-linux-amd64: (build "linux" "amd64") + +# Build for Linux 386 +build-linux-386: (build "linux" "386") + +# Pre-push checks +prepush: generate vet fmt tidy test + +# Run tests +test: + CGO_ENABLED=0 go build -mod=mod ./... + CGO_ENABLED=1 go test -race -cover -v -mod=mod ./... && echo -e "\033[32mSUCCESS\033[0m" || (echo -e "\033[31mFAILED\033[0m" && exit 1) + +# Run benchmarks +bench: + go test -test.timeout=30m -benchmem -run ^$$ -benchtime=20s -bench . ./... && echo -e "\033[32mSUCCESS\033[0m" || (echo -e "\033[31mFAILED\033[0m" && exit 1) + +# Run go vet +vet: + go vet ./... + +# Format code +fmt: + go fmt ./... + +# Tidy dependencies +tidy: + go mod tidy + +verify: + go mod verify + +# List all modules +go_list: + go list -u -m all + +# Update all dependencies +go_update_all: + go get -t -u ./... + diff --git a/main.go b/main.go new file mode 100644 index 0000000..0e0808c --- /dev/null +++ b/main.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + "golang.org/x/mod/semver" +) + +func main() { + var ( + natsURL = flag.String("nats", getEnv("INPUT_NATS_URL", "nats://localhost:4222"), "NATS server URL") + bucketName = flag.String("bucket", getEnv("INPUT_BUCKET", "binaries"), "Object store bucket name") + directory = flag.String("dir", getEnv("INPUT_SOURCE", "upload"), "Directory containing binaries to upload") + prefix = flag.String("prefix", getEnv("INPUT_STRIP_PREFIX", ""), "Prefix to strip from paths (like 'upload/')") + binaryName = flag.String("binary", getEnv("INPUT_BINARY", ""), "Binary name (defaults to first binary found)") + notifyTopic = flag.String("notify", getEnv("INPUT_NOTIFY_TOPIC", "binaries.update"), "NATS topic to publish update notification") + skipNotify = flag.Bool("skip-notify", getEnvBool("INPUT_SKIP_NOTIFY", false), "Skip publishing update notification") + cleanup = flag.Int("cleanup", getEnvInt("INPUT_CLEANUP", 0), "Keep only N most recent versions (0 disables cleanup)") + cleanupAll = flag.Bool("cleanup-all", getEnvBool("INPUT_CLEANUP_ALL", false), "Cleanup all binaries, not just current one") + ) + flag.Parse() + + if *directory == "" { + log.Fatal("Directory path is required") + } + + ctx := context.Background() + + nc, err := nats.Connect(*natsURL) + if err != nil { + log.Fatalf("Failed to connect to NATS: %v", err) + } + defer nc.Close() + + js, err := jetstream.New(nc) + if err != nil { + log.Fatalf("Failed to create JetStream context: %v", err) + } + + store, err := js.ObjectStore(ctx, *bucketName) + if err != nil { + store, err = js.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{ + Bucket: *bucketName, + Description: "Binary storage for self-update", + }) + if err != nil { + log.Fatalf("Failed to get/create object store: %v", err) + } + log.Printf("Created object store: %s", *bucketName) + } + + err = filepath.Walk(*directory, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + + relPath, err := filepath.Rel(*directory, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + objectKey := relPath + if *prefix != "" { + objectKey = strings.TrimPrefix(relPath, *prefix) + } + + objectKey = filepath.ToSlash(objectKey) + + if *binaryName == "" { + parts := strings.Split(objectKey, "/") + if len(parts) >= 2 { + *binaryName = parts[0] + } + } + + log.Printf("Uploading %s as %s (%d bytes)", path, objectKey, len(data)) + + _, err = store.PutBytes(ctx, objectKey, data) + if err != nil { + return fmt.Errorf("failed to upload %s: %w", path, err) + } + + log.Printf("✓ Uploaded %s", objectKey) + return nil + }) + + if err != nil { + log.Fatalf("Failed to upload files: %v", err) + } + + log.Printf("Successfully uploaded all files from %s to NATS object store '%s'", *directory, *bucketName) + + if *cleanup > 0 { + log.Printf("Cleaning up old versions, keeping %d most recent", *cleanup) + err = cleanupOldVersions(ctx, store, *binaryName, *cleanup, *cleanupAll) + if err != nil { + log.Fatalf("Failed to cleanup old versions: %v", err) + } + } + + if !*skipNotify && *notifyTopic != "" { + log.Printf("Publishing update notification to topic: %s", *notifyTopic) + + message := fmt.Sprintf("binaries updated in %s", *bucketName) + err = nc.Publish(*notifyTopic, []byte(message)) + if err != nil { + log.Fatalf("Failed to publish notification: %v", err) + } + + // Flush to ensure message is sent + err = nc.Flush() + if err != nil { + log.Fatalf("Failed to flush notification: %v", err) + } + + log.Printf("✓ Published update notification") + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + b, err := strconv.ParseBool(value) + if err == nil { + return b + } + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + i, err := strconv.Atoi(value) + if err == nil { + return i + } + } + return defaultValue +} + +func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, currentBinary string, keepCount int, cleanAll bool) error { + objects, err := store.List(ctx) + if err != nil { + return fmt.Errorf("failed to list objects: %w", err) + } + + // Group objects by binary/architecture path + // Expected structure: binary/arch/version + versionsByPath := make(map[string][]*jetstream.ObjectInfo) + + for _, obj := range objects { + parts := strings.Split(obj.Name, "/") + if len(parts) < 3 { + // Not a version path, skip + continue + } + + binaryName := parts[0] + arch := parts[1] + pathKey := binaryName + "/" + arch + + // If not cleaning all and this isn't the current binary, skip + if !cleanAll && currentBinary != "" && binaryName != currentBinary { + continue + } + + versionsByPath[pathKey] = append(versionsByPath[pathKey], obj) + } + + // For each binary/arch combination, keep only the most recent N versions + for pathKey, versions := range versionsByPath { + if len(versions) <= keepCount { + log.Printf("Path %s has %d versions, keeping all", pathKey, len(versions)) + continue + } + + // Sort by semantic version (newest first) + sort.Slice(versions, func(i, j int) bool { + // Extract version from path: binary/arch/version + versionI := filepath.Base(versions[i].Name) + versionJ := filepath.Base(versions[j].Name) + + // Ensure versions start with 'v' for semver.Compare + if !strings.HasPrefix(versionI, "v") { + versionI = "v" + versionI + } + if !strings.HasPrefix(versionJ, "v") { + versionJ = "v" + versionJ + } + + // semver.Compare returns -1, 0, or 1 + // We want newest first, so reverse the comparison + return semver.Compare(versionI, versionJ) > 0 + }) + + // Delete old versions (everything after keepCount) + toDelete := versions[keepCount:] + log.Printf("Path %s has %d versions, deleting %d old versions", pathKey, len(versions), len(toDelete)) + + for _, obj := range toDelete { + version := filepath.Base(obj.Name) + log.Printf("Deleting old version: %s (version: %s)", obj.Name, version) + err := store.Delete(ctx, obj.Name) + if err != nil && !errors.Is(err, jetstream.ErrObjectNotFound) { + return fmt.Errorf("failed to delete %s: %w", obj.Name, err) + } + log.Printf("✓ Deleted %s", obj.Name) + } + } + + return nil +}