Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ localai-models/
CLAUDE.md
GEMINI.md
/gopher-updater
docker-compose.yml
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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 . .
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
```
Expand Down Expand Up @@ -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"
Expand All @@ -109,7 +110,6 @@ spec:
path: /readyz
port: http
```

## Development

```bash
Expand Down
4 changes: 3 additions & 1 deletion cmd/updater/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/pprof"
"os"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 16 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@ package config

import (
"context"
"fmt"
"net/url"
"time"

"github.com/sethvargo/go-envconfig"
)

// 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"`
Expand All @@ -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
}
61 changes: 61 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
})
})
41 changes: 32 additions & 9 deletions cosmos/client.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -110,15 +120,28 @@ 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)
}

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)
}
}
}
}

Expand Down
Loading