Skip to content
Open
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 .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ on:
env:
BIN_NAME: dcld
COSMOVISOR_VERSION: 1.5.0
URL_LIVENESS_CHECK_ENABLED: true

jobs:

Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ jobs:
run: |
set -euo pipefail
# shellcheck disable=SC2046
go test -json -v $(go list ./... | grep -v '/integration_tests') -coverprofile=./cover.out -covermode=set -coverpkg=./... 2>&1 | tee /tmp/gotest.log | gotestfmt
go test -tags=dev -json -v $(go list ./... | grep -v '/integration_tests') -coverprofile=./cover.out -covermode=set -coverpkg=./... 2>&1 | tee /tmp/gotest.log | gotestfmt
go test -v utils/cli/url_liveness.go utils/cli/url_liveness_test.go
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GOCOVER_MODE: set
Expand Down Expand Up @@ -141,6 +142,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
GOCOVER_MODE: set # Ensure integration tests use the same mode as unit tests
URL_LIVENESS_CHECK_ENABLED: false # Disable network calls to check URLs for integration tests
- name: Generate test coverage
shell: bash
run: go tool covdata textfmt -i=integration_tests/gocover -o cover.out
Expand Down
1 change: 1 addition & 0 deletions Dockerfile-build
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ COPY app ./app/
COPY cmd ./cmd/
COPY testutil ./testutil/
COPY utils ./utils/
COPY internal ./internal/
COPY x ./x/
COPY types ./types/
COPY go.mod go.sum Makefile ./
Expand Down
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ endif
NAME ?= dcl
APPNAME ?= $(NAME)d
LEDGER_ENABLED ?= true

URL_LIVENESS_CHECK_ENABLED ?= true
OUTPUT_DIR ?= build

### Process ld flags
Expand Down Expand Up @@ -56,6 +56,10 @@ ifeq (cleveldb,$(findstring cleveldb,$(COSMOS_BUILD_OPTIONS)))
build_tags += gcc
endif

ifeq ($(URL_LIVENESS_CHECK_ENABLED),false)
build_tags += dev
endif

whitespace :=
whitespace += $(whitespace)
comma := ,
Expand Down Expand Up @@ -110,7 +114,8 @@ go.sum: go.mod
GO111MODULE=on go mod verify

test:
go test -v $(PACKAGES)
go test -tags=dev -v $(PACKAGES)
go test -v utils/cli/url_liveness.go utils/cli/url_liveness_test.go
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move these files into variable just like PACKAGES?

Copy link
Copy Markdown
Collaborator Author

@Abdulbois Abdulbois May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, because url_liveness_test.go should be executed without dev tag


lint:
golangci-lint run ./... --timeout 5m0s
Expand Down
1 change: 1 addition & 0 deletions integration_tests/deploy/Dockerfile-build
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ COPY app ./app/
COPY cmd ./cmd/
COPY testutil ./testutil/
COPY utils ./utils/
COPY internal ./internal/
COPY x ./x/
COPY types ./types/
COPY go.mod go.sum Makefile ./
Expand Down
4 changes: 2 additions & 2 deletions integration_tests/run-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ set -euo pipefail
log "Verifying the environment"
check_env

log "Compiling local binaries"
make install &>${DETAILED_OUTPUT_TARGET}
log "Compiling local binaries. URL liveness check is DISABLED"
URL_LIVENESS_CHECK_ENABLED=false make install &>${DETAILED_OUTPUT_TARGET}

log "Building docker image"
make image &>${DETAILED_OUTPUT_TARGET}
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build !dev

package config

const URLLivenessCheckEnabled = true
5 changes: 5 additions & 0 deletions internal/config/config_dev.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build dev

package config

const URLLivenessCheckEnabled = false
88 changes: 88 additions & 0 deletions utils/cli/url_liveness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cli

import (
"context"
"net/http"
"sync"
"time"

"github.com/zigbee-alliance/distributed-compliance-ledger/internal/config"
)

const (
livenessCheckTimeout = 10 * time.Second
)

var allowed4XXStatusCodes = []int{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusUnavailableForLegalReasons,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to include also 405 Method Not Allowed — server rejects HEAD specifically?
Another option can be to treat any status beside 404 (and 5xx?) as reachable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 405. For the second option, as I know, when the server is down, it should return a 5xx code even requested page does not exist

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if the server is down - you only get server codes, not resource.

http.StatusMethodNotAllowed,
}
var httpClient = &http.Client{Timeout: livenessCheckTimeout}

func IsLiveURL(u string) bool {
if !config.URLLivenessCheckEnabled {
return true
}

ctx, cancel := context.WithTimeout(context.Background(), livenessCheckTimeout)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodHead, u, nil)
if err != nil {
return false
}

resp, err := httpClient.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()

if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusBadRequest {
return true
}

for _, code := range allowed4XXStatusCodes {
if code == resp.StatusCode {
return true
}
}

return false
}

// CheckURLsForLiveness checks the liveness of the given URLs concurrently and
// returns unreachable URLs as a list.
// Empty strings are skipped.
//
// Returns an empty list if all non-empty URLs are reachable.
func CheckURLsForLiveness(urls ...string) []string {
results := make([]string, len(urls))

var wg sync.WaitGroup
for i, u := range urls {
if u == "" {
continue
}
// Call each URL concurrently
wg.Add(1)
go func(i int, u string) {
defer wg.Done()
if !IsLiveURL(u) {
results[i] = u
}
}(i, u)
}
wg.Wait()

var unreachable []string
for _, u := range results {
if u != "" {
unreachable = append(unreachable, u)
}
}

return unreachable
}
134 changes: 134 additions & 0 deletions utils/cli/url_liveness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright 2020 DSR Corporation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build !dev

package cli

import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

"github.com/stretchr/testify/require"
)

const unreachableURL = "http://192.0.2.1:1"

func TestIsLiveURL(t *testing.T) {
tests := []struct {
name string
statusCode int
want bool
}{
{"200 OK", http.StatusOK, true},
{"301 redirect", http.StatusMovedPermanently, true},
{"401 unauthorized", http.StatusUnauthorized, true},
{"403 forbidden", http.StatusForbidden, true},
{"405 method not allowed", http.StatusMethodNotAllowed, true},
{"451 unavailable for legal reasons", http.StatusUnavailableForLegalReasons, true},
{"404 not found", http.StatusNotFound, false},
{"500 internal server error", http.StatusInternalServerError, false},
{"502 bad gateway", http.StatusBadGateway, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodHead, r.Method)
w.WriteHeader(tt.statusCode)
}))
defer srv.Close()

u, err := url.ParseRequestURI(srv.URL)
require.NoError(t, err)

require.Equal(t, tt.want, IsLiveURL(u.String()))
})
}
}

func TestIsLiveURLUnreachable(t *testing.T) {
u, err := url.ParseRequestURI(unreachableURL)
require.NoError(t, err)

require.False(t, IsLiveURL(u.String()))
}

func TestCheckURLsForLiveness(t *testing.T) {
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer okSrv.Close()

notFoundSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer notFoundSrv.Close()

tests := []struct {
name string
urls []string
want []string
}{
{"no URLs", nil, nil},
{"all empty strings", []string{"", "", ""}, nil},
{"all reachable", []string{okSrv.URL, okSrv.URL}, nil},
{"single unreachable", []string{okSrv.URL, notFoundSrv.URL}, []string{notFoundSrv.URL}},
{"empties skipped", []string{"", okSrv.URL, ""}, nil},
{
"multiple unreachable preserve input order",
[]string{okSrv.URL, notFoundSrv.URL, unreachableURL},
[]string{notFoundSrv.URL, unreachableURL},
},
{
"unreachable later in list",
[]string{okSrv.URL, "", notFoundSrv.URL},
[]string{notFoundSrv.URL},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, CheckURLsForLiveness(tt.urls...))
})
}
}

func TestCheckURLsForLivenessRunsConcurrently(t *testing.T) {
const handlerDelay = 200 * time.Millisecond
const concurrentURLs = 5

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(handlerDelay)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

urls := make([]string, concurrentURLs)
for i := range urls {
urls[i] = srv.URL
}

start := time.Now()
require.Empty(t, CheckURLsForLiveness(urls...))
elapsed := time.Since(start)

// Sequential calls would take approximately concurrentURLs*handlerDelay time
// Concurrent execution should finish in roughly handlerDelay.
require.Less(t, elapsed, time.Duration(concurrentURLs)*handlerDelay/2,
"URL checks did not run concurrently (elapsed %s)", elapsed)
}
19 changes: 11 additions & 8 deletions utils/validator/validations.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,27 +49,30 @@ func requiredIfBit0Set(fl validator.FieldLevel) bool {
}

func isValidHttpOrHttpsUrl(fl validator.FieldLevel) bool { //nolint:stylecheck
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reachability checks cannot be implemented here, because it's part of ValidateBasic function which is part of the static validation, see https://csa-matter.slack.com/archives/C025PQ6RMRA/p1777291064854469.
Please implement reachability checks in separate dedicated functions which should be called only within CLI directly, as described in the original ticket.

Copy link
Copy Markdown
Collaborator Author

@Abdulbois Abdulbois May 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update, but it is called(should be called) before the broadcasting txn to the DCLD nodes(before tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg) call)

return _validURL(fl, "http", "https")
return validURL(fl, "http", "https")
}

func isValidHttpsUrl(fl validator.FieldLevel) bool { //nolint:stylecheck
return _validURL(fl, "https")
return validURL(fl, "https")
}

func _validURL(fl validator.FieldLevel, allowedSchemas ...string) bool {
func validURL(fl validator.FieldLevel, allowedSchemes ...string) bool {
raw := fl.Field().String()
// Field is empty or omitempty is set, skip checks
if raw == "" {
return true
}

u, _ := url.Parse(raw)
if u.Host == "" {
u, err := url.ParseRequestURI(raw)
if err != nil || u.Host == "" {
return false
}

for _, schema := range allowedSchemas {
if u.Scheme == schema {
return isSchemeAllowed(u.Scheme, allowedSchemes)
}

func isSchemeAllowed(scheme string, allowed []string) bool {
for _, s := range allowed {
if scheme == s {
return true
}
}
Expand Down
15 changes: 15 additions & 0 deletions x/model/client/cli/tx_model.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cli

import (
"fmt"
"strings"

"github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/client/flags"
"github.com/cosmos/cosmos-sdk/client/tx"
Expand Down Expand Up @@ -99,6 +102,12 @@ func CmdCreateModel() *cobra.Command {
commissioningFallbackURL,
)

unreachable := cli.CheckURLsForLiveness(commissioningCustomFlowURL, userManualURL, supportURL, productURL, lsfURL,
enhancedSetupFlowTCURL, enhancedSetupFlowMaintenanceURL, commissioningFallbackURL)
if len(unreachable) > 0 {
return fmt.Errorf("URLs not reachable: %s", strings.Join(unreachable, ", "))
}

// validate basic will be called in GenerateOrBroadcastTxCLI
err = tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg)
if cli.IsWriteInsteadReadRPCError(err) {
Expand Down Expand Up @@ -284,6 +293,12 @@ func CmdUpdateModel() *cobra.Command {
factoryResetStepsInstruction,
)

unreachable := cli.CheckURLsForLiveness(commissioningCustomFlowURL, userManualURL, supportURL, productURL, lsfURL,
enhancedSetupFlowTCURL, enhancedSetupFlowMaintenanceURL, commissioningFallbackURL)
if len(unreachable) > 0 {
return fmt.Errorf("URLs not reachable: %s", strings.Join(unreachable, ", "))
}

// validate basic will be called in GenerateOrBroadcastTxCLI
err = tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg)
if cli.IsWriteInsteadReadRPCError(err) {
Expand Down
Loading
Loading