diff --git a/.gitignore b/.gitignore index cf0fcce..77e2aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ localai-models/ CLAUDE.md GEMINI.md /gopher-updater +docker-compose.yml diff --git a/Dockerfile b/Dockerfile index 589dfbe..b8d8e1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # --- Builder --- ARG GO_VERSION=1.25.1 FROM golang:${GO_VERSION}-alpine AS builder -WORKDIR /app +WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . diff --git a/README.md b/README.md index 121d708..2b87521 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ All configuration is done by means of environment variables: ### Connectivity -`RPC_URL` - URL to connect to the Cosmos chain REST API. Default is `http://localhost:1317`. +`API_URL` - URL to connect to the Cosmos chain REST API. Default is `http://localhost:1317`. ### Docker parameters @@ -32,12 +32,13 @@ All configuration is done by means of environment variables: `POLL_INTERVAL` - How long to wait between Cosmos chain polls, in Golang Duration format. The default is `1m`. +`DRY_RUN` - If set to `true`, the application will not perform any retagging operations on DockerHub. Instead, it will log the actions it would have taken. This is useful for testing and validation. Default is `false`. + `HTTP_PORT` - The port on which to expose health, metrics, and profiling endpoints. Default is `8080`. ## Observability The service exposes several endpoints for monitoring and debugging: - * `GET /healthz`: A liveness probe that returns `200 OK` if the service is running. * `GET /readyz`: A readiness probe that returns `200 OK` if the service can connect to both the Cosmos chain and DockerHub. Otherwise, it returns `503 Service Unavailable`. * `GET /metrics`: Exposes Prometheus metrics for monitoring. @@ -53,7 +54,7 @@ docker run \ -e TARGET_PREFIX="mainnet-" \ -e DOCKERHUB_USER="myuser" \ -e DOCKERHUB_PASSWORD="mypassword" \ - -e RPC_URL="http://my-cosmos-node:1317" \ + -e API_URL="http://my-cosmos-node:1317" \ -p 8080:8080 \ gopher-updater:latest ``` @@ -96,7 +97,7 @@ spec: secretKeyRef: name: dockerhub key: password - - name: RPC_URL + - name: API_URL value: "http://my-cosmos-node:1317" - name: HTTP_PORT value: "8080" @@ -109,7 +110,6 @@ spec: path: /readyz port: http ``` - ## Development ```bash diff --git a/cmd/updater/main.go b/cmd/updater/main.go index 8a62370..b705aa6 100644 --- a/cmd/updater/main.go +++ b/cmd/updater/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "io" "net/http" "net/http/pprof" "os" @@ -48,7 +49,7 @@ func main() { }, } - cosmosClient := cosmos.NewClient(cfg.RPCURL, httpClient) + cosmosClient := cosmos.NewClient(cfg.APIURL, httpClient) dockerhubClient := dockerhub.NewClient(cfg.DockerHubUser, cfg.DockerHubPassword, httpClient) checker := health.NewChecker(cosmosClient, dockerhubClient, cfg.RepoPath) @@ -81,6 +82,7 @@ func main() { func startHTTPServer(cfg *config.Config, checker *health.Checker, cancel context.CancelFunc) *echo.Echo { e := echo.New() e.HideBanner = true + e.Logger.SetOutput(io.Discard) // --- Routes --- e.GET("/healthz", func(c echo.Context) error { diff --git a/config/config.go b/config/config.go index e8b0694..515929f 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,8 @@ package config import ( "context" + "fmt" + "net/url" "time" "github.com/sethvargo/go-envconfig" @@ -9,13 +11,14 @@ import ( // Config holds the application configuration. type Config struct { - RPCURL string `env:"RPC_URL,default=http://localhost:1317"` - DockerHubUser string `env:"DOCKERHUB_USER,required"` - DockerHubPassword string `env:"DOCKERHUB_PASSWORD,required"` + APIURL *url.URL `env:"API_URL,default=http://localhost:1317"` + DockerHubUser string `env:"DOCKERHUB_USER"` + DockerHubPassword string `env:"DOCKERHUB_PASSWORD"` RepoPath string `env:"REPO_PATH,required"` SourcePrefix string `env:"SOURCE_PREFIX,default=release-"` TargetPrefix string `env:"TARGET_PREFIX,required"` PollInterval time.Duration `env:"POLL_INTERVAL,default=1m"` + DryRun bool `env:"DRY_RUN,default=false"` HTTPMaxIdleConns int `env:"HTTP_MAX_IDLE_CONNS,default=100"` HTTPMaxIdleConnsPerHost int `env:"HTTP_MAX_IDLE_CONNS_PER_HOST,default=10"` @@ -29,5 +32,15 @@ func New(ctx context.Context) (*Config, error) { if err := envconfig.Process(ctx, &cfg); err != nil { return nil, err } + + if !cfg.DryRun { + if cfg.DockerHubUser == "" { + return nil, fmt.Errorf("DOCKERHUB_USER is required when not in dry-run mode") + } + if cfg.DockerHubPassword == "" { + return nil, fmt.Errorf("DOCKERHUB_PASSWORD is required when not in dry-run mode") + } + } + return &cfg, nil } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..eaf0013 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,61 @@ +package config_test + +import ( + "context" + "os" + "testing" + + "github.com/gopher-lab/gopher-updater/config" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} + +var _ = Describe("Config", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + os.Clearenv() + }) + + Context("when creating a new config", func() { + It("should return an error if dry run is false and dockerhub user is not set", func() { + Expect(os.Setenv("DOCKERHUB_PASSWORD", "password")).ToNot(HaveOccurred()) + Expect(os.Setenv("REPO_PATH", "repo")).ToNot(HaveOccurred()) + Expect(os.Setenv("TARGET_PREFIX", "prefix")).ToNot(HaveOccurred()) + _, err := config.New(ctx) + Expect(err).To(HaveOccurred()) + }) + + It("should return an error if dry run is false and dockerhub password is not set", func() { + Expect(os.Setenv("DOCKERHUB_USER", "user")).ToNot(HaveOccurred()) + Expect(os.Setenv("REPO_PATH", "repo")).ToNot(HaveOccurred()) + Expect(os.Setenv("TARGET_PREFIX", "prefix")).ToNot(HaveOccurred()) + _, err := config.New(ctx) + Expect(err).To(HaveOccurred()) + }) + + It("should not return an error if dry run is true and dockerhub credentials are not set", func() { + Expect(os.Setenv("DRY_RUN", "true")).ToNot(HaveOccurred()) + Expect(os.Setenv("REPO_PATH", "repo")).ToNot(HaveOccurred()) + Expect(os.Setenv("TARGET_PREFIX", "prefix")).ToNot(HaveOccurred()) + _, err := config.New(ctx) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should not return an error if dry run is false and dockerhub credentials are set", func() { + Expect(os.Setenv("DOCKERHUB_USER", "user")).ToNot(HaveOccurred()) + Expect(os.Setenv("DOCKERHUB_PASSWORD", "password")).ToNot(HaveOccurred()) + Expect(os.Setenv("REPO_PATH", "repo")).ToNot(HaveOccurred()) + Expect(os.Setenv("TARGET_PREFIX", "prefix")).ToNot(HaveOccurred()) + _, err := config.New(ctx) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/cosmos/client.go b/cosmos/client.go index 8eb6858..d632d87 100644 --- a/cosmos/client.go +++ b/cosmos/client.go @@ -1,11 +1,16 @@ package cosmos import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" + "net/url" "strconv" + + "github.com/gopher-lab/gopher-updater/pkg/xlog" ) // ClientInterface defines the methods to interact with a Cosmos chain. @@ -16,12 +21,12 @@ type ClientInterface interface { // Client for interacting with the Cosmos REST API. type Client struct { - rpcURL string + rpcURL *url.URL httpClient *http.Client } // NewClient creates a new Cosmos client. -func NewClient(rpcURL string, httpClient *http.Client) *Client { +func NewClient(rpcURL *url.URL, httpClient *http.Client) *Client { return &Client{ rpcURL: rpcURL, httpClient: httpClient, @@ -47,7 +52,7 @@ type LatestBlockResponse struct { // GetLatestBlockHeight returns the latest block height of the chain. func (c *Client) GetLatestBlockHeight(ctx context.Context) (int64, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.rpcURL+"/blocks/latest", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.rpcURL.JoinPath("/cosmos/base/tendermint/v1beta1/blocks/latest").String(), nil) if err != nil { return 0, fmt.Errorf("failed to create request: %w", err) } @@ -79,14 +84,14 @@ type Plan struct { Height string `json:"height"` } -type ProposalContent struct { +type Message struct { Type string `json:"@type"` Plan Plan `json:"plan"` } type Proposal struct { - Status string `json:"status"` - Content ProposalContent `json:"content"` + Status string `json:"status"` + Messages []Message `json:"messages"` } type ProposalsResponse struct { @@ -95,7 +100,12 @@ type ProposalsResponse struct { // GetUpgradePlans finds all passed software upgrade proposals and returns their plans. func (c *Client) GetUpgradePlans(ctx context.Context) ([]Plan, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.rpcURL+"/cosmos/gov/v1beta1/proposals", nil) + reqURL := c.rpcURL.JoinPath("/cosmos/gov/v1/proposals") + q := reqURL.Query() + q.Set("proposal_status", "3") // PROPOSAL_STATUS_PASSED + reqURL.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -110,6 +120,15 @@ func (c *Client) GetUpgradePlans(ctx context.Context) ([]Plan, error) { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } + // Read the body for debugging + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + xlog.Debug("received proposals response", "body", string(bodyBytes)) + // Replace the body so it can be read again by the JSON decoder + resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + var proposalsResp ProposalsResponse if err := json.NewDecoder(resp.Body).Decode(&proposalsResp); err != nil { return nil, fmt.Errorf("failed to decode proposals response: %w", err) @@ -117,8 +136,12 @@ func (c *Client) GetUpgradePlans(ctx context.Context) ([]Plan, error) { var plans []Plan for _, p := range proposalsResp.Proposals { - if p.Status == "PROPOSAL_STATUS_PASSED" && p.Content.Type == "/cosmos.upgrade.v1beta1.SoftwareUpgradeProposal" { - plans = append(plans, p.Content.Plan) + if p.Status == "PROPOSAL_STATUS_PASSED" { + for _, msg := range p.Messages { + if msg.Type == "/cosmos.upgrade.v1beta1.MsgSoftwareUpgrade" { + plans = append(plans, msg.Plan) + } + } } } diff --git a/cosmos/client_integration_test.go b/cosmos/client_integration_test.go index 99b5d3a..a7128fb 100644 --- a/cosmos/client_integration_test.go +++ b/cosmos/client_integration_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -24,7 +25,9 @@ var _ = Describe("Client Integration", func() { ctx = context.Background() mux = http.NewServeMux() server = httptest.NewServer(mux) - client = cosmos.NewClient(server.URL, server.Client()) + serverURL, err := url.Parse(server.URL) + Expect(err).NotTo(HaveOccurred()) + client = cosmos.NewClient(serverURL, server.Client()) }) AfterEach(func() { @@ -33,7 +36,7 @@ var _ = Describe("Client Integration", func() { Describe("GetLatestBlockHeight", func() { It("should return the correct block height on a valid response", func() { - mux.HandleFunc("/blocks/latest", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/cosmos/base/tendermint/v1beta1/blocks/latest", func(w http.ResponseWriter, r *http.Request) { _, err := fmt.Fprint(w, `{"block":{"header":{"height":"12345"}}}`) Expect(err).NotTo(HaveOccurred()) }) @@ -44,7 +47,7 @@ var _ = Describe("Client Integration", func() { }) It("should return an error on a non-200 status code", func() { - mux.HandleFunc("/blocks/latest", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/cosmos/base/tendermint/v1beta1/blocks/latest", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) @@ -53,7 +56,7 @@ var _ = Describe("Client Integration", func() { }) It("should return an error on malformed JSON", func() { - mux.HandleFunc("/blocks/latest", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/cosmos/base/tendermint/v1beta1/blocks/latest", func(w http.ResponseWriter, r *http.Request) { _, err := fmt.Fprint(w, `{"block":{"header":{"height":malformed}}}`) Expect(err).NotTo(HaveOccurred()) }) @@ -65,28 +68,35 @@ var _ = Describe("Client Integration", func() { Describe("GetUpgradePlans", func() { It("should correctly parse and filter for passed software upgrade proposals", func() { - mux.HandleFunc("/cosmos/gov/v1beta1/proposals", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/cosmos/gov/v1/proposals", func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("proposal_status")).To(Equal("3")) _, err := fmt.Fprint(w, `{ "proposals": [ { "status": "PROPOSAL_STATUS_PASSED", - "content": { - "@type": "/cosmos.upgrade.v1beta1.SoftwareUpgradeProposal", - "plan": { "name": "v1.2.3", "height": "100" } - } + "messages": [ + { + "@type": "/cosmos.upgrade.v1beta1.MsgSoftwareUpgrade", + "plan": { "name": "v1.2.3", "height": "100" } + } + ] }, { "status": "PROPOSAL_STATUS_REJECTED", - "content": { - "@type": "/cosmos.upgrade.v1beta1.SoftwareUpgradeProposal", - "plan": { "name": "v1.2.4", "height": "200" } - } + "messages": [ + { + "@type": "/cosmos.upgrade.v1beta1.MsgSoftwareUpgrade", + "plan": { "name": "v1.2.4", "height": "200" } + } + ] }, { "status": "PROPOSAL_STATUS_PASSED", - "content": { - "@type": "/cosmos.params.v1beta1.ParameterChangeProposal" - } + "messages": [ + { + "@type": "/cosmos.params.v1beta1.ParameterChangeProposal" + } + ] } ] }`) @@ -101,7 +111,7 @@ var _ = Describe("Client Integration", func() { }) It("should return an empty slice when no passed upgrade proposals are found", func() { - mux.HandleFunc("/cosmos/gov/v1beta1/proposals", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/cosmos/gov/v1/proposals", func(w http.ResponseWriter, r *http.Request) { _, err := fmt.Fprint(w, `{"proposals": []}`) Expect(err).NotTo(HaveOccurred()) }) @@ -112,7 +122,7 @@ var _ = Describe("Client Integration", func() { }) It("should return an error on a non-200 status code", func() { - mux.HandleFunc("/cosmos/gov/v1beta1/proposals", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/cosmos/gov/v1/proposals", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0570ba4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + gopher-node: + build: + context: ../gopher + container_name: gopher-testnet + restart: unless-stopped + environment: + - DAEMON_RESTART_AFTER_UPGRADE=false + - ALICE_MNEMONIC=${ALICE_MNEMONIC:-} + - BOB_MNEMONIC=${BOB_MNEMONIC:-} + ports: + - "26656:26656" # P2P + - "26657:26657" # RPC + - "1317:1317" # REST API + - "9090:9090" # gRPC + volumes: + - gopher-data:/home/gopher/.gopher + networks: + - gopher-network + + gopher-updater: + build: . + container_name: gopher-updater + restart: unless-stopped + environment: + LOG_LEVEL: "debug" + DRY_RUN: "true" + API_URL: "http://gopher-node:1317" + REPO_PATH: "test/test" + SOURCE_PREFIX: "release-" + TARGET_PREFIX: "testnet-" + POLL_INTERVAL: "1s" + networks: + - gopher-network + +volumes: + gopher-data: + +networks: + gopher-network: + driver: bridge diff --git a/mise.local.toml b/mise.local.toml new file mode 100644 index 0000000..d4c9bbf --- /dev/null +++ b/mise.local.toml @@ -0,0 +1,3 @@ +[env] +ALICE_MNEMONIC = "what close joke frequent rookie afraid lobster raise punch cluster industry talent render game stage ski math success rail elder critic negative there swap" +BOB_MNEMONIC = "hockey clever provide steel bachelor escape deal mansion dirt drift trouble coach setup tape chuckle report kangaroo concert congress first differ dragon hurry stamp" diff --git a/updater/updater.go b/updater/updater.go index 891a581..8a9b935 100644 --- a/updater/updater.go +++ b/updater/updater.go @@ -18,6 +18,7 @@ type Updater struct { cosmosClient cosmos.ClientInterface dockerhubClient dockerhub.ClientInterface cfg *config.Config + lastHeight int64 } // New creates a new Updater. @@ -40,7 +41,7 @@ func (u *Updater) Run(ctx context.Context) error { xlog.Info("performing initial check for software upgrade proposal") if err := u.CheckAndProcessUpgrade(ctx); err != nil { - xlog.Error("failed to process upgrade on initial check", "err", err) + xlog.Error("error checking for upgrade on initial check", "err", err) } for { @@ -50,7 +51,7 @@ func (u *Updater) Run(ctx context.Context) error { case <-ticker.C: xlog.Info("checking for software upgrade proposal") if err := u.CheckAndProcessUpgrade(ctx); err != nil { - xlog.Error("failed to process upgrade", "err", err) + xlog.Error("error checking for upgrade", "err", err) } } } @@ -68,18 +69,34 @@ func (u *Updater) CheckAndProcessUpgrade(ctx context.Context) error { return nil } + // We only need to get the height if there are plans to process. currentHeight, err := u.cosmosClient.GetLatestBlockHeight(ctx) if err != nil { + // The chain might be halted. Check if we are near an upgrade height. + for _, plan := range plans { + proposalHeight, pErr := strconv.ParseInt(plan.Height, 10, 64) + if pErr != nil { + xlog.Error("failed to parse upgrade height for plan, skipping", "plan", plan.Name, "height", plan.Height, "err", pErr) + continue + } + + if u.lastHeight > 0 && u.lastHeight >= proposalHeight-5 { + xlog.Warn("failed to get latest block height, but last known height is within 5 blocks of a passed proposal. Assuming chain has halted for upgrade.", "lastKnownHeight", u.lastHeight, "upgradeHeight", proposalHeight) + return u.processUpgrade(ctx, &plan) + } + } return fmt.Errorf("failed to get latest block height: %w", err) } + u.lastHeight = currentHeight var pendingPlans []cosmos.Plan for _, plan := range plans { - upgradeHeight, err := strconv.ParseInt(plan.Height, 10, 64) + proposalHeight, err := strconv.ParseInt(plan.Height, 10, 64) if err != nil { xlog.Error("failed to parse upgrade height, skipping plan", "plan", plan.Name, "height", plan.Height, "err", err) continue } + upgradeHeight := proposalHeight - 1 if currentHeight >= upgradeHeight { targetTag := u.cfg.TargetPrefix + plan.Name @@ -117,6 +134,11 @@ func (u *Updater) processUpgrade(ctx context.Context, plan *cosmos.Plan) error { xlog.Info("retagging image", "repo", u.cfg.RepoPath, "source", sourceTag, "target", targetTag) + if u.cfg.DryRun { + xlog.Info("dry run enabled, skipping retag") + return nil + } + err := u.dockerhubClient.RetagImage(ctx, u.cfg.RepoPath, sourceTag, targetTag) if err != nil { return fmt.Errorf("failed to retag image: %w", err) diff --git a/updater/updater_test.go b/updater/updater_test.go index 075bd67..92e1f4b 100644 --- a/updater/updater_test.go +++ b/updater/updater_test.go @@ -42,7 +42,7 @@ var _ = Describe("Updater", func() { return plans, nil } mockCosmosClient.getLatestBlockHeightFunc = func(ctx context.Context) (int64, error) { - return 101, nil + return 100, nil } mockDockerHubClient.tagExistsFunc = func(ctx context.Context, repoPath, tag string) (bool, error) { Expect(tag).To(Equal("mainnet-v1.2.3")) @@ -115,7 +115,7 @@ var _ = Describe("Updater", func() { return plans, nil } mockCosmosClient.getLatestBlockHeightFunc = func(ctx context.Context) (int64, error) { - return 99, nil + return 98, nil } err := up.CheckAndProcessUpgrade(ctx) @@ -159,6 +159,32 @@ var _ = Describe("Updater", func() { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("cosmos boom")) }) + + Context("with dry run enabled", func() { + BeforeEach(func() { + cfg.DryRun = true + }) + + It("should not retag the image if a single upgrade height has been reached and the tag does not exist", func() { + plans := []cosmos.Plan{{Name: "v1.2.3", Height: "100"}} + mockCosmosClient.getUpgradePlansFunc = func(ctx context.Context) ([]cosmos.Plan, error) { + return plans, nil + } + mockCosmosClient.getLatestBlockHeightFunc = func(ctx context.Context) (int64, error) { + return 100, nil + } + mockDockerHubClient.tagExistsFunc = func(ctx context.Context, repoPath, tag string) (bool, error) { + Expect(tag).To(Equal("mainnet-v1.2.3")) + return false, nil + } + + err := up.CheckAndProcessUpgrade(ctx) + Expect(err).ToNot(HaveOccurred()) + + retagCalls := mockDockerHubClient.RetagCalls() + Expect(retagCalls).To(HaveLen(0)) + }) + }) }) })