Compare commits
10
Commits
v0.1.0
...
ce2ca18753
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce2ca18753 | ||
|
|
3aacdb3ace | ||
|
|
62013663f8 | ||
|
|
d5bc797446 | ||
|
|
07ab53860e | ||
|
|
1be5902ad9 | ||
|
|
5bcfab5269 | ||
|
|
b2952f467d | ||
|
|
a72bc68a52 | ||
|
|
c776d47714 |
@@ -27,3 +27,4 @@ go.work.sum
|
|||||||
|
|
||||||
./bin
|
./bin
|
||||||
bin
|
bin
|
||||||
|
nats-upload
|
||||||
|
|||||||
+15
-1
@@ -37,7 +37,21 @@ inputs:
|
|||||||
description: 'Cleanup all binaries, not just current one'
|
description: 'Cleanup all binaries, not just current one'
|
||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
gitea_url:
|
||||||
|
description: 'Gitea server URL (for cleaning feature branches)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
gitea_token:
|
||||||
|
description: 'Gitea API token (for cleaning feature branches)'
|
||||||
|
repository:
|
||||||
|
description: 'Repository path (owner/repo) for Gitea branch API (defaults to GITHUB_REPOSITORY env)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
cleanup_features:
|
||||||
|
description: 'Cleanup stale feature-branch binaries whose branches no longer exist in Gitea'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
# noinspection YAMLSchemaValidation
|
# noinspection YAMLSchemaValidation
|
||||||
using: 'go'
|
using: 'go'
|
||||||
main: 'main.go'
|
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
module lab.zlymeda.dynu.net/actions/nats-upload
|
module lab.zlymeda.dynu.net/actions/nats-upload
|
||||||
|
|
||||||
go 1.26
|
go 1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/nats-io/nats.go v1.49.0
|
github.com/nats-io/nats.go v1.52.0
|
||||||
golang.org/x/mod v0.33.0
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
golang.org/x/mod v0.39.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.19.1 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
|
golang.org/x/crypto v0.54.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,65 @@
|
|||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
|
||||||
|
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||||
|
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||||
|
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
|
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||||
|
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -3,210 +3,414 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/nats-io/nats.go/jetstream"
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
type Config struct {
|
||||||
var (
|
NatsURL string `mapstructure:"nats"`
|
||||||
natsURL = flag.String("nats", getEnv("INPUT_NATS_URL", "nats://localhost:4222"), "NATS server URL")
|
BucketName string `mapstructure:"bucket"`
|
||||||
bucketName = flag.String("bucket", getEnv("INPUT_BUCKET", "binaries"), "Object store bucket name")
|
Directory string `mapstructure:"dir"`
|
||||||
directory = flag.String("dir", getEnv("INPUT_SOURCE", "upload"), "Directory containing binaries to upload")
|
Prefix string `mapstructure:"prefix"`
|
||||||
prefix = flag.String("prefix", getEnv("INPUT_STRIP_PREFIX", ""), "Prefix to strip from paths (like 'upload/')")
|
BinaryName string `mapstructure:"binary"`
|
||||||
binaryName = flag.String("binary", getEnv("INPUT_BINARY", ""), "Binary name (defaults to first binary found)")
|
NotifyTopic string `mapstructure:"notify"`
|
||||||
notifyTopic = flag.String("notify", getEnv("INPUT_NOTIFY_TOPIC", "binaries.update"), "NATS topic to publish update notification")
|
SkipNotify bool `mapstructure:"skip-notify"`
|
||||||
skipNotify = flag.Bool("skip-notify", getEnvBool("INPUT_SKIP_NOTIFY", false), "Skip publishing update notification")
|
Cleanup int `mapstructure:"cleanup"`
|
||||||
cleanup = flag.Int("cleanup", getEnvInt("INPUT_CLEANUP", 0), "Keep only N most recent versions (0 disables cleanup)")
|
CleanupAll bool `mapstructure:"cleanup-all"`
|
||||||
cleanupAll = flag.Bool("cleanup-all", getEnvBool("INPUT_CLEANUP_ALL", false), "Cleanup all binaries, not just current one")
|
GiteaURL string `mapstructure:"gitea-url"`
|
||||||
)
|
GiteaToken string `mapstructure:"gitea-token"`
|
||||||
flag.Parse()
|
CleanupFeatures bool `mapstructure:"cleanup-features"`
|
||||||
|
Repository string `mapstructure:"repository"`
|
||||||
|
}
|
||||||
|
|
||||||
if *directory == "" {
|
var rootCmd = &cobra.Command{
|
||||||
log.Fatal("Directory path is required")
|
Use: "nats-upload",
|
||||||
|
Short: "Upload binaries to NATS object store and cleanup old versions",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := viper.Unmarshal(&cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.Directory == "" && cfg.Cleanup == 0 && !cfg.CleanupFeatures {
|
||||||
|
return errors.New("directory path is required, or cleanup/cleanup-features must be enabled")
|
||||||
|
}
|
||||||
|
return runUploadAndCleanup(cmd.Context(), &cfg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanCmd = &cobra.Command{
|
||||||
|
Use: "clean",
|
||||||
|
Short: "Cleanup old versions in NATS object store",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := viper.Unmarshal(&cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.Cleanup == 0 {
|
||||||
|
return errors.New("cleanup count must be greater than 0")
|
||||||
|
}
|
||||||
|
return runCleanupOnly(cmd.Context(), &cfg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanFeaturesCmd = &cobra.Command{
|
||||||
|
Use: "clean-features",
|
||||||
|
Short: "Delete feature-branch binaries whose branches no longer exist in Gitea",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := viper.Unmarshal(&cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
|
}
|
||||||
|
return runCleanupFeatures(cmd.Context(), &cfg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
|
rootCmd.PersistentFlags().String("nats", "nats://localhost:4222", "NATS server URL")
|
||||||
|
rootCmd.PersistentFlags().String("bucket", "binaries", "Object store bucket name")
|
||||||
|
rootCmd.PersistentFlags().String("binary", "", "Binary name (defaults to first binary found)")
|
||||||
|
rootCmd.PersistentFlags().Int("cleanup", 2, "Keep only N most recent versions (0 disables cleanup)")
|
||||||
|
rootCmd.PersistentFlags().Bool("cleanup-all", false, "Cleanup all binaries, not just current one")
|
||||||
|
rootCmd.PersistentFlags().Bool("clean-all", false, "Alias for --cleanup-all")
|
||||||
|
rootCmd.PersistentFlags().String("gitea-url", "", "Gitea server URL (for cleaning feature branches)")
|
||||||
|
rootCmd.PersistentFlags().String("gitea-token", "", "Gitea API token (for cleaning feature branches)")
|
||||||
|
rootCmd.PersistentFlags().Bool("cleanup-features", false, "Cleanup stale feature-branch binaries")
|
||||||
|
rootCmd.PersistentFlags().String("repository", "", "Repository path (owner/repo) for Gitea branch API")
|
||||||
|
|
||||||
|
rootCmd.Flags().String("dir", "upload", "Directory containing binaries to upload")
|
||||||
|
rootCmd.Flags().String("prefix", "", "Prefix to strip from paths (like 'upload/')")
|
||||||
|
rootCmd.Flags().String("notify", "binaries.update", "NATS topic to publish update notification")
|
||||||
|
rootCmd.Flags().Bool("skip-notify", false, "Skip publishing update notification")
|
||||||
|
|
||||||
|
rootPersistentFlags := rootCmd.PersistentFlags()
|
||||||
|
for _, name := range []string{"nats", "bucket", "binary", "cleanup", "cleanup-all", "gitea-url", "gitea-token", "cleanup-features", "repository"} {
|
||||||
|
if err := viper.BindPFlag(name, rootPersistentFlags.Lookup(name)); err != nil {
|
||||||
|
log.Fatalf("error binding %s flag: %v", name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
rootFlags := rootCmd.Flags()
|
||||||
|
for _, name := range []string{"dir", "prefix", "notify", "skip-notify"} {
|
||||||
|
if err := viper.BindPFlag(name, rootFlags.Lookup(name)); err != nil {
|
||||||
|
log.Fatalf("error binding %s flag: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nc, err := nats.Connect(*natsURL)
|
rootCmd.AddCommand(cleanCmd, cleanFeaturesCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initConfig() {
|
||||||
|
viper.SetEnvPrefix("INPUT")
|
||||||
|
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
|
viper.RegisterAlias("nats_url", "nats")
|
||||||
|
viper.RegisterAlias("source", "dir")
|
||||||
|
viper.RegisterAlias("strip_prefix", "prefix")
|
||||||
|
viper.RegisterAlias("notify_topic", "notify")
|
||||||
|
viper.RegisterAlias("clean_all", "cleanup-all")
|
||||||
|
viper.RegisterAlias("gitea_url", "gitea-url")
|
||||||
|
viper.RegisterAlias("gitea_token", "gitea-token")
|
||||||
|
viper.RegisterAlias("cleanup_features", "cleanup-features")
|
||||||
|
viper.RegisterAlias("repository", "repository")
|
||||||
|
|
||||||
|
_ = viper.BindEnv("nats", "INPUT_NATS", "INPUT_NATS_URL")
|
||||||
|
_ = viper.BindEnv("dir", "INPUT_DIR", "INPUT_SOURCE")
|
||||||
|
_ = viper.BindEnv("prefix", "INPUT_PREFIX", "INPUT_STRIP_PREFIX")
|
||||||
|
_ = viper.BindEnv("notify", "INPUT_NOTIFY", "INPUT_NOTIFY_TOPIC")
|
||||||
|
_ = viper.BindEnv("cleanup-all", "INPUT_CLEANUP_ALL", "INPUT_CLEAN_ALL")
|
||||||
|
_ = viper.BindEnv("gitea-url", "INPUT_GITEA_URL", "INPUT_GITEA_URL")
|
||||||
|
_ = viper.BindEnv("gitea-token", "INPUT_GITEA_TOKEN", "INPUT_GITEA_TOKEN")
|
||||||
|
_ = viper.BindEnv("cleanup-features", "INPUT_CLEANUP_FEATURES", "INPUT_CLEANUP_FEATURES")
|
||||||
|
_ = viper.BindEnv("repository", "INPUT_REPOSITORY", "INPUT_REPOSITORY", "GITHUB_REPOSITORY")
|
||||||
|
}
|
||||||
|
|
||||||
|
type NATSClient struct {
|
||||||
|
Conn *nats.Conn
|
||||||
|
JS jetstream.JetStream
|
||||||
|
Store jetstream.ObjectStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNATSConnection(ctx context.Context, cfg *Config) (*NATSClient, error) {
|
||||||
|
nc, err := nats.Connect(cfg.NatsURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to connect to NATS: %v", err)
|
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
|
||||||
}
|
}
|
||||||
defer nc.Close()
|
|
||||||
|
|
||||||
js, err := jetstream.New(nc)
|
js, err := jetstream.New(nc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create JetStream context: %v", err)
|
nc.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create JetStream context: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := js.ObjectStore(ctx, *bucketName)
|
store, err := js.ObjectStore(ctx, cfg.BucketName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
store, err = js.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{
|
store, err = js.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{
|
||||||
Bucket: *bucketName,
|
Bucket: cfg.BucketName,
|
||||||
Description: "Binary storage for self-update",
|
Description: "Binary storage for self-update",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to get/create object store: %v", err)
|
nc.Close()
|
||||||
|
return nil, fmt.Errorf("failed to get/create object store: %w", err)
|
||||||
}
|
}
|
||||||
log.Printf("Created object store: %s", *bucketName)
|
log.Printf("Created object store: %s", cfg.BucketName)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = filepath.Walk(*directory, func(path string, info os.FileInfo, err error) error {
|
return &NATSClient{
|
||||||
if err != nil {
|
Conn: nc,
|
||||||
return err
|
JS: js,
|
||||||
}
|
Store: store,
|
||||||
|
}, nil
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
|
func runUploadAndCleanup(ctx context.Context, cfg *Config) error {
|
||||||
|
client, err := getNATSConnection(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to upload files: %v", err)
|
return err
|
||||||
|
}
|
||||||
|
defer client.Conn.Close()
|
||||||
|
|
||||||
|
if cfg.Directory != "" {
|
||||||
|
err := filepath.Walk(cfg.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(cfg.Directory, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get relative path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
objectKey := relPath
|
||||||
|
if cfg.Prefix != "" {
|
||||||
|
objectKey = strings.TrimPrefix(relPath, cfg.Prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
objectKey = filepath.ToSlash(objectKey)
|
||||||
|
|
||||||
|
if cfg.BinaryName == "" {
|
||||||
|
parts := strings.Split(objectKey, "/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
cfg.BinaryName = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Uploading %s as %s (%d bytes)", path, objectKey, len(data))
|
||||||
|
|
||||||
|
_, err = client.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 {
|
||||||
|
return fmt.Errorf("failed to upload files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully uploaded all files from %s to NATS object store '%s'", cfg.Directory, cfg.BucketName)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Successfully uploaded all files from %s to NATS object store '%s'", *directory, *bucketName)
|
if cfg.Cleanup > 0 {
|
||||||
|
log.Printf("Cleaning up old versions, keeping %d most recent", cfg.Cleanup)
|
||||||
if *cleanup > 0 {
|
err := cleanupOldVersions(ctx, client.Store, cfg.BinaryName, cfg.Cleanup, cfg.CleanupAll)
|
||||||
log.Printf("Cleaning up old versions, keeping %d most recent", *cleanup)
|
|
||||||
err = cleanupOldVersions(ctx, store, *binaryName, *cleanup, *cleanupAll)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to cleanup old versions: %v", err)
|
return fmt.Errorf("failed to cleanup old versions: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !*skipNotify && *notifyTopic != "" {
|
if cfg.CleanupFeatures {
|
||||||
log.Printf("Publishing update notification to topic: %s", *notifyTopic)
|
log.Printf("Cleaning up stale feature branch binaries...")
|
||||||
|
if err := runCleanupFeatures(ctx, cfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to cleanup feature branches: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message := fmt.Sprintf("binaries updated in %s", *bucketName)
|
if !cfg.SkipNotify && cfg.NotifyTopic != "" {
|
||||||
err = nc.Publish(*notifyTopic, []byte(message))
|
log.Printf("Publishing update notification to topic: %s", cfg.NotifyTopic)
|
||||||
|
|
||||||
|
message := fmt.Sprintf("binaries updated in %s", cfg.BucketName)
|
||||||
|
err := client.Conn.Publish(cfg.NotifyTopic, []byte(message))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to publish notification: %v", err)
|
return fmt.Errorf("failed to publish notification: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush to ensure message is sent
|
err = client.Conn.Flush()
|
||||||
err = nc.Flush()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to flush notification: %v", err)
|
return fmt.Errorf("failed to flush notification: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("✓ Published update notification")
|
log.Printf("✓ Published update notification")
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func runCleanupOnly(ctx context.Context, cfg *Config) error {
|
||||||
if value := os.Getenv(key); value != "" {
|
client, err := getNATSConnection(ctx, cfg)
|
||||||
return value
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return defaultValue
|
defer client.Conn.Close()
|
||||||
|
|
||||||
|
log.Printf("Cleaning up old versions, keeping %d most recent", cfg.Cleanup)
|
||||||
|
err = cleanupOldVersions(ctx, client.Store, cfg.BinaryName, cfg.Cleanup, cfg.CleanupAll)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to cleanup old versions: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnvBool(key string, defaultValue bool) bool {
|
func runCleanupFeatures(ctx context.Context, cfg *Config) error {
|
||||||
if value := os.Getenv(key); value != "" {
|
client, err := getNATSConnection(ctx, cfg)
|
||||||
b, err := strconv.ParseBool(value)
|
if err != nil {
|
||||||
if err == nil {
|
return err
|
||||||
return b
|
}
|
||||||
|
defer client.Conn.Close()
|
||||||
|
|
||||||
|
log.Printf("Cleaning up stale feature branch binaries from bucket %s", cfg.BucketName)
|
||||||
|
|
||||||
|
objects, err := client.Store.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list objects: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type featureTag struct {
|
||||||
|
binary string
|
||||||
|
objectKey string
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
var featureObjects []featureTag
|
||||||
|
|
||||||
|
for _, obj := range objects {
|
||||||
|
parts := strings.Split(obj.Name, "/")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
version := parts[len(parts)-1]
|
||||||
|
if !strings.HasPrefix(version, "feature-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
featureObjects = append(featureObjects, featureTag{
|
||||||
|
binary: parts[0],
|
||||||
|
objectKey: obj.Name,
|
||||||
|
version: version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(featureObjects) == 0 {
|
||||||
|
log.Printf("No feature branch objects found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Found %d feature branch objects, checking active feature branches via git...", len(featureObjects))
|
||||||
|
|
||||||
|
// Use git ls-remote to list remote branches (no token needed)
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "ls-remote", "--heads", "origin")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list remote branches via git: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeBranches := make(map[string]bool)
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
// Format: "<sha>\trefs/heads/<branch>"
|
||||||
|
parts := strings.Split(line, "\t")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ref := parts[len(parts)-1]
|
||||||
|
branchName := strings.TrimPrefix(ref, "refs/heads/")
|
||||||
|
if strings.HasPrefix(branchName, "feature/") {
|
||||||
|
activeBranches[branchName] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvInt(key string, defaultValue int) int {
|
log.Printf("Active feature branches: %d", len(activeBranches))
|
||||||
if value := os.Getenv(key); value != "" {
|
for branch := range activeBranches {
|
||||||
i, err := strconv.Atoi(value)
|
log.Printf(" - %s", branch)
|
||||||
if err == nil {
|
}
|
||||||
return i
|
|
||||||
|
var deleted int
|
||||||
|
for _, fo := range featureObjects {
|
||||||
|
branchPart := strings.TrimPrefix(fo.version, "feature-")
|
||||||
|
branchName := "feature/" + strings.ReplaceAll(branchPart, "--", "/")
|
||||||
|
|
||||||
|
if !activeBranches[branchName] {
|
||||||
|
log.Printf("Deleting stale: %s (branch %s no longer exists)", fo.objectKey, branchName)
|
||||||
|
err := client.Store.Delete(ctx, fo.objectKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to delete %s: %v", fo.objectKey, err)
|
||||||
|
} else {
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
|
log.Printf("Cleanup complete: deleted %d stale feature branch objects", deleted)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, currentBinary string, keepCount int, cleanAll bool) error {
|
func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, currentBinary string, keepCount int, cleanAll bool) error {
|
||||||
objects, err := store.List(ctx)
|
objects, err := store.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list objects: %w", err)
|
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)
|
versionsByPath := make(map[string][]*jetstream.ObjectInfo)
|
||||||
|
|
||||||
for _, obj := range objects {
|
for _, obj := range objects {
|
||||||
parts := strings.Split(obj.Name, "/")
|
parts := strings.Split(obj.Name, "/")
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
// Not a version path, skip
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
binaryName := parts[0]
|
binName := parts[0]
|
||||||
arch := parts[1]
|
arch := parts[1]
|
||||||
pathKey := binaryName + "/" + arch
|
pathKey := binName + "/" + arch
|
||||||
|
|
||||||
// If not cleaning all and this isn't the current binary, skip
|
if !cleanAll && currentBinary != "" && binName != currentBinary {
|
||||||
if !cleanAll && currentBinary != "" && binaryName != currentBinary {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
versionsByPath[pathKey] = append(versionsByPath[pathKey], obj)
|
versionsByPath[pathKey] = append(versionsByPath[pathKey], obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each binary/arch combination, keep only the most recent N versions
|
|
||||||
for pathKey, versions := range versionsByPath {
|
for pathKey, versions := range versionsByPath {
|
||||||
if len(versions) <= keepCount {
|
if len(versions) <= keepCount {
|
||||||
log.Printf("Path %s has %d versions, keeping all", pathKey, len(versions))
|
log.Printf("Path %s has %d versions, keeping all", pathKey, len(versions))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by semantic version (newest first)
|
|
||||||
sort.Slice(versions, func(i, j int) bool {
|
sort.Slice(versions, func(i, j int) bool {
|
||||||
// Extract version from path: binary/arch/version
|
|
||||||
versionI := filepath.Base(versions[i].Name)
|
versionI := filepath.Base(versions[i].Name)
|
||||||
versionJ := filepath.Base(versions[j].Name)
|
versionJ := filepath.Base(versions[j].Name)
|
||||||
|
|
||||||
// Ensure versions start with 'v' for semver.Compare
|
|
||||||
if !strings.HasPrefix(versionI, "v") {
|
if !strings.HasPrefix(versionI, "v") {
|
||||||
versionI = "v" + versionI
|
versionI = "v" + versionI
|
||||||
}
|
}
|
||||||
@@ -214,18 +418,14 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
|
|||||||
versionJ = "v" + versionJ
|
versionJ = "v" + versionJ
|
||||||
}
|
}
|
||||||
|
|
||||||
// semver.Compare returns -1, 0, or 1
|
|
||||||
// We want newest first, so reverse the comparison
|
|
||||||
return semver.Compare(versionI, versionJ) > 0
|
return semver.Compare(versionI, versionJ) > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Delete old versions (everything after keepCount)
|
|
||||||
toDelete := versions[keepCount:]
|
toDelete := versions[keepCount:]
|
||||||
log.Printf("Path %s has %d versions, deleting %d old versions", pathKey, len(versions), len(toDelete))
|
log.Printf("Path %s has %d versions, deleting %d old versions", pathKey, len(versions), len(toDelete))
|
||||||
|
|
||||||
for _, obj := range toDelete {
|
for _, obj := range toDelete {
|
||||||
version := filepath.Base(obj.Name)
|
log.Printf("Deleting old version: %s", obj.Name)
|
||||||
log.Printf("Deleting old version: %s (version: %s)", obj.Name, version)
|
|
||||||
err := store.Delete(ctx, obj.Name)
|
err := store.Delete(ctx, obj.Name)
|
||||||
if err != nil && !errors.Is(err, jetstream.ErrObjectNotFound) {
|
if err != nil && !errors.Is(err, jetstream.ErrObjectNotFound) {
|
||||||
return fmt.Errorf("failed to delete %s: %w", obj.Name, err)
|
return fmt.Errorf("failed to delete %s: %w", obj.Name, err)
|
||||||
@@ -236,3 +436,12 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func main() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := rootCmd.ExecuteContext(ctx); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user