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
6 changes: 0 additions & 6 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ on:
merge_group:

permissions:
id-token: write
contents: read

jobs:
Expand All @@ -26,12 +25,7 @@ jobs:
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
# setup-go caches by default; disable it here because this workflow
# also runs on tags and has id-token: write.
cache: false

- name: OIDC Setup for goproxy
uses: github/setup-goproxy@5e60e1074d42316dfe2949ebf9a92bf77b24645b # v1.1.0

- name: Run Go linter
run: make lint
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ gh elm --help # list available commands
gh elm --version # print the extension version

gh elm config # interactively set up credentials
gh elm config check # check source and target connectivity and authentication
gh elm config show # print current config (tokens redacted)
gh elm config reset # remove stored config and credentials
gh elm config set-source-pat ORG # set an organization's SOURCE_PAT secret
Expand Down Expand Up @@ -232,6 +233,13 @@ Where values are stored:
Force a specific backend with `GH_ELM_CREDENTIAL_STORE=keyring` or `GH_ELM_CREDENTIAL_STORE=file`.
`gh elm config show` prints which backend is active.

`gh elm config check` checks source and target independently. For each endpoint it reports
network reachability, API service health (including `5xx` responses), and whether the configured
credentials can access the authenticated-user endpoint. Results, including successes, are written
to standard error so they remain available when standard output is redirected or formatted as JSON.
Every source-backed `gh elm migration` command runs the same source check before its migration API
request.

### Environment variables and precedence

Every command resolves each URL and token in this order, so scripts and CI can skip
Expand Down
40 changes: 25 additions & 15 deletions integration/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ func TestMigrationStatus(t *testing.T) {
requestCount.Add(1)

assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v3/enterprise/live-migrations/mig-1", r.URL.Path)
assert.Equal(t, "Bearer source-token", r.Header.Get("Authorization"))
if r.URL.Path == "/api/v3/user" {
w.WriteHeader(http.StatusOK)
return
}
assert.Equal(t, "/api/v3/enterprise/live-migrations/mig-1", r.URL.Path)

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(response))
Expand All @@ -93,8 +97,10 @@ func TestMigrationStatus(t *testing.T) {
for _, want := range []string{"Migration", "mig-1", "In progress"} {
assert.Contains(t, result.Stdout, want)
}
assert.Empty(t, result.Stderr)
assert.Equal(t, int32(1), requestCount.Load())
assert.Contains(t, result.Stderr, "[OK] Source network")
assert.Contains(t, result.Stderr, "[OK] Source service")
assert.Contains(t, result.Stderr, "[OK] Source authentication")
assert.Equal(t, int32(2), requestCount.Load())

// Tokens must never be included in user-facing output.
assert.NotContains(t, result.Stdout, "source-token")
Expand All @@ -107,7 +113,7 @@ func TestMigrationStatus(t *testing.T) {
requestCount.Add(1)

assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v3/enterprise/live-migrations/mig-1", r.URL.Path)
assert.Equal(t, "/api/v3/user", r.URL.Path)
assert.Equal(t, "Bearer invalid-source-token", r.Header.Get("Authorization"))

w.Header().Set("Content-Type", "application/json")
Expand All @@ -133,10 +139,10 @@ func TestMigrationStatus(t *testing.T) {
assert.Equal(t, int32(1), requestCount.Load())

assert.Contains(t, result.Stderr, "Error\n")
assert.Contains(t, result.Stderr, "authentication failed")
assert.Contains(t, result.Stderr, "HTTP 401")
assert.Contains(t, result.Stderr, "GH_SOURCE_HOST")
assert.Contains(t, result.Stderr, "GH_SOURCE_TOKEN")
assert.Contains(t, result.Stderr, "[OK] Source network: reachable (HTTP 401 Unauthorized)")
assert.Contains(t, result.Stderr, "[OK] Source service: responding (HTTP 401 Unauthorized)")
assert.Contains(t, result.Stderr, "[FAIL] Source authentication: HTTP 401 Unauthorized")
assert.Contains(t, result.Stderr, "configured token was rejected or has expired")

// The supplied credential must not be echoed in diagnostics.
assert.NotContains(t, result.Stdout, "invalid-source-token")
Expand Down Expand Up @@ -256,7 +262,7 @@ func TestSourceConfigurationPrecedence(t *testing.T) {

require.Equal(t, 0, result.ExitCode, result.Stderr)
assert.Contains(t, result.Stdout, `"source": "stored"`)
assert.Empty(t, result.Stderr)
assert.Contains(t, result.Stderr, "[OK] Source authentication")
})

t.Run("environment overrides stored configuration", func(t *testing.T) {
Expand All @@ -275,7 +281,7 @@ func TestSourceConfigurationPrecedence(t *testing.T) {

require.Equal(t, 0, result.ExitCode, result.Stderr)
assert.Contains(t, result.Stdout, `"source": "environment"`)
assert.Empty(t, result.Stderr)
assert.Contains(t, result.Stderr, "[OK] Source authentication")
})

t.Run("flags override environment and stored configuration", func(t *testing.T) {
Expand All @@ -298,12 +304,12 @@ func TestSourceConfigurationPrecedence(t *testing.T) {

require.Equal(t, 0, result.ExitCode, result.Stderr)
assert.Contains(t, result.Stdout, `"source": "flag"`)
assert.Empty(t, result.Stderr)
assert.Contains(t, result.Stderr, "[OK] Source authentication")
})

assert.Equal(t, int32(1), storedRequests.Load())
assert.Equal(t, int32(1), envRequests.Load())
assert.Equal(t, int32(1), flagRequests.Load())
assert.Equal(t, int32(2), storedRequests.Load())
assert.Equal(t, int32(2), envRequests.Load())
assert.Equal(t, int32(2), flagRequests.Load())
}

// newStatusServer returns a source API server that identifies itself in its
Expand All @@ -318,8 +324,12 @@ func newStatusServer(
requestCount.Add(1)

assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v3/enterprise/live-migrations/mig-1", r.URL.Path)
assert.Equal(t, "Bearer "+expectedToken, r.Header.Get("Authorization"))
if r.URL.Path == "/api/v3/user" {
w.WriteHeader(http.StatusOK)
return
}
assert.Equal(t, "/api/v3/enterprise/live-migrations/mig-1", r.URL.Path)

w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(
Expand Down
48 changes: 48 additions & 0 deletions internal/cmd/config_check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cmd

import (
"errors"
"fmt"
"io"

"github.com/spf13/cobra"

"github.com/github/gh-elm/internal/endpoints"
"github.com/github/gh-elm/internal/preflight"
)

func newConfigCheckCmd() *cobra.Command {
return &cobra.Command{
Use: "check",
Short: "Check source and target connectivity and authentication",
Long: "Check network reachability, API service health, and authenticated-user access\n" +
"for both the configured source and target. All results are written to stderr.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
stderr := cmd.ErrOrStderr()
resolver, err := endpoints.NewResolver()
if err != nil {
sourceErr := checkResolvedEndpoint(cmd, stderr, "Source", "", "", err)
targetErr := checkResolvedEndpoint(cmd, stderr, "Target", "", "", err)
return errors.Join(sourceErr, targetErr)
}

source, sourceResolveErr := resolver.Source("", "")
target, targetResolveErr := resolver.Target("", "")
sourceErr := checkResolvedEndpoint(cmd, stderr, "Source", source.URL, source.Token, sourceResolveErr)
targetErr := checkResolvedEndpoint(cmd, stderr, "Target", target.URL, target.Token, targetResolveErr)
return errors.Join(sourceErr, targetErr)
},
}
}

func checkResolvedEndpoint(cmd *cobra.Command, stderr io.Writer, name, endpointURL, token string, resolveErr error) error {
if resolveErr == nil {
return preflight.CheckEndpoint(cmd.Context(), stderr, name, endpointURL, token)
}

fmt.Fprintf(stderr, "[FAIL] %s network: not checked because configuration could not be read\n", name)
fmt.Fprintf(stderr, "[FAIL] %s service: not checked because configuration could not be read\n", name)
fmt.Fprintf(stderr, "[FAIL] %s authentication: not checked because configuration could not be read\n", name)
return fmt.Errorf("%s preflight failed: reading configuration: %w", name, resolveErr)
}
1 change: 1 addition & 0 deletions internal/cmd/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func newConfigCmd() *cobra.Command {
_ = cmd.Flags().MarkHidden("show")
_ = cmd.Flags().MarkHidden("reset")
cmd.AddCommand(
newConfigCheckCmd(),
newConfigShowCmd(),
newConfigResetCmd(),
newSetMigratorPATInteractiveCmd(),
Expand Down
76 changes: 76 additions & 0 deletions internal/cmd/configure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ package cmd
import (
"bytes"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -71,6 +76,77 @@ func TestConfigureShow(t *testing.T) {
assert.Contains(t, out, "not set", "unset target token should read as not set")
}

func TestConfigCheck(t *testing.T) {
assertBothEndpointsReported := func(t *testing.T) {
t.Helper()

output, err := execConfigure("check")

require.Error(t, err)
for _, endpoint := range []string{"Source", "Target"} {
for _, check := range []string{"network", "service", "authentication"} {
assert.Contains(t, output, fmt.Sprintf("[FAIL] %s %s: not checked because configuration could not be read", endpoint, check))
}
}
}

t.Run("checks configured endpoints", func(t *testing.T) {
seedFileStore(t)
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Contains(t, []string{"/api/v3/meta", "/api/v3/user"}, r.URL.Path)
w.WriteHeader(http.StatusOK)
}))
defer source.Close()
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Contains(t, []string{"/meta", "/user"}, r.URL.Path)
if r.URL.Path == "/user" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()

require.NoError(t, (&config.Config{SourceURL: source.URL, TargetURL: target.URL}).Save())
store, err := creds.NewStore()
require.NoError(t, err)
require.NoError(t, store.Set(creds.SourceToken, "source-token"))
require.NoError(t, store.Set(creds.TargetToken, "target-token"))

root := NewRootCmd("test")
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetArgs([]string{"config", "check"})
err = root.Execute()

require.Error(t, err)
assert.Empty(t, stdout.String())
assert.Contains(t, stderr.String(), "[OK] Source network")
assert.Contains(t, stderr.String(), "[OK] Source service")
assert.Contains(t, stderr.String(), "[OK] Source authentication")
assert.Contains(t, stderr.String(), "[OK] Target network")
assert.Contains(t, stderr.String(), "[OK] Target service")
assert.Contains(t, stderr.String(), "[FAIL] Target authentication: HTTP 403 Forbidden")
})

t.Run("malformed config reports both endpoints", func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("GH_ELM_CONFIG_DIR", configDir)
t.Setenv("GH_ELM_CREDENTIAL_STORE", "file")
require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.json"), []byte("{"), 0o600))

assertBothEndpointsReported(t)
})

t.Run("invalid credential store reports both endpoints", func(t *testing.T) {
t.Setenv("GH_ELM_CONFIG_DIR", t.TempDir())
t.Setenv("GH_ELM_CREDENTIAL_STORE", "invalid")

assertBothEndpointsReported(t)
})
}

func TestMigratorPATInput(t *testing.T) {
t.Run("body flag", func(t *testing.T) {
t.Setenv("SOURCE_PAT", "env-pat")
Expand Down
Loading