create nats-upload action
This commit is contained in:
42
action.yml
Normal file
42
action.yml
Normal file
@@ -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'
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
14
go.sum
Normal file
14
go.sum
Normal file
@@ -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=
|
||||||
119
justfile
Normal file
119
justfile
Normal file
@@ -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 ./...
|
||||||
|
|
||||||
238
main.go
Normal file
238
main.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user