Skip to content

Commit 0431342

Browse files
JAORMXclaude
andcommitted
Address review feedback on upgrade detection
- Lowercase an uppercase "V" tag prefix so semver comparison works; "V1.2.0" vs "V1.3.0" no longer falls through to undecidable and hides a real upgrade. - Drop the raw provider error from CheckResult.Reason (it is serialized into the API response and can leak internal addressing); log it at DEBUG and return a fixed string. Same for the CheckAll path. - Add a defensive default to the comparison switch so an unexpected value yields StatusUnknown rather than the least-safe StatusUpToDate. - Stop reporting network-isolation drift: the registry has no network-isolation field, so it fired for every isolated workload regardless of the candidate version. Remove the ConfigDrift field and the now-unused BoolChange type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3350917 commit 0431342

5 files changed

Lines changed: 39 additions & 35 deletions

File tree

pkg/workloads/upgrade/checker.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
"log/slog"
1011

1112
regtypes "github.com/stacklok/toolhive-core/registry/types"
1213
"github.com/stacklok/toolhive/pkg/registry"
@@ -61,8 +62,13 @@ func (c *Checker) Check(_ context.Context, cfg *runner.RunConfig) (*CheckResult,
6162
result.Status = StatusServerNotFound
6263
return result, nil
6364
}
65+
// Keep the detailed provider error out of the result: Reason is
66+
// serialized into the HTTP response, and for an unreachable or
67+
// misconfigured registry the raw error can carry internal addressing
68+
// (e.g. "dial tcp 10.x.x.x:443: ..."). Log it for operators instead.
69+
slog.Debug("registry lookup failed", "server", cfg.RegistryServerName, "error", err)
6470
result.Status = StatusUnknown
65-
result.Reason = fmt.Sprintf("registry lookup failed: %v", err)
71+
result.Reason = "registry lookup failed"
6672
return result, nil
6773
}
6874

@@ -86,6 +92,11 @@ func (c *Checker) Check(_ context.Context, cfg *runner.RunConfig) (*CheckResult,
8692
case comparisonUndecidable:
8793
result.Status = StatusUnknown
8894
result.Reason = reason
95+
default:
96+
// Defensive: a future tagComparison value (or an unset zero value) must
97+
// not fall through to the least-safe StatusUpToDate. Treat anything
98+
// unexpected as unknown.
99+
result.Status = StatusUnknown
89100
}
90101

91102
return result, nil
@@ -105,10 +116,11 @@ func (c *Checker) CheckAll(ctx context.Context, configs []*runner.RunConfig) []*
105116
// so the error here is unreachable; encode defensively rather than drop.
106117
res, err := c.Check(ctx, cfg)
107118
if err != nil {
119+
slog.Debug("upgrade check failed", "workload", cfg.Name, "error", err)
108120
res = &CheckResult{
109121
WorkloadName: cfg.Name,
110122
Status: StatusUnknown,
111-
Reason: fmt.Sprintf("check failed: %v", err),
123+
Reason: "check failed",
112124
}
113125
}
114126
results = append(results, res)
@@ -176,13 +188,6 @@ func computeConfigDrift(cfg *runner.RunConfig, imgMeta *regtypes.ImageMetadata)
176188
drift.Transport = &StringChange{From: currentTransport, To: candidateTransport}
177189
}
178190

179-
// Network isolation is a plain boolean on the config; the registry entry has
180-
// no explicit network-isolation field, so the candidate posture is the
181-
// default (false). Report drift only when the workload currently isolates.
182-
if cfg.IsolateNetwork {
183-
drift.NetworkIsolation = &BoolChange{From: true, To: false}
184-
}
185-
186191
// Permission profile: compare names. The candidate name is only known when
187192
// the registry entry carries a profile with a non-empty Name.
188193
candidateProfile := ""
@@ -194,7 +199,7 @@ func computeConfigDrift(cfg *runner.RunConfig, imgMeta *regtypes.ImageMetadata)
194199
drift.PermissionProfile = &StringChange{From: currentProfile, To: candidateProfile}
195200
}
196201

197-
if drift.Transport == nil && drift.NetworkIsolation == nil && drift.PermissionProfile == nil {
202+
if drift.Transport == nil && drift.PermissionProfile == nil {
198203
return nil
199204
}
200205
return drift

pkg/workloads/upgrade/checker_test.go

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,6 @@ func TestChecker_Check(t *testing.T) {
148148
Image: "ghcr.io/example/server:1.0.0",
149149
RegistryServerName: testServerName,
150150
Transport: transporttypes.TransportTypeStdio,
151-
IsolateNetwork: true,
152151
PermissionProfileNameOrPath: "none",
153152
EnvVars: map[string]string{"LOG_LEVEL": "info"},
154153
Secrets: []string{"mykey,target=API_KEY"},
@@ -182,9 +181,6 @@ func TestChecker_Check(t *testing.T) {
182181
require.NotNil(t, res.ConfigDrift.Transport)
183182
assert.Equal(t, "stdio", res.ConfigDrift.Transport.From)
184183
assert.Equal(t, "streamable-http", res.ConfigDrift.Transport.To)
185-
require.NotNil(t, res.ConfigDrift.NetworkIsolation)
186-
assert.True(t, res.ConfigDrift.NetworkIsolation.From)
187-
assert.False(t, res.ConfigDrift.NetworkIsolation.To)
188184
require.NotNil(t, res.ConfigDrift.PermissionProfile)
189185
assert.Equal(t, "none", res.ConfigDrift.PermissionProfile.From)
190186
assert.Equal(t, "network", res.ConfigDrift.PermissionProfile.To)
@@ -279,16 +275,15 @@ func TestComputeConfigDrift_GracefulDegradation(t *testing.T) {
279275
assert.Nil(t, computeConfigDrift(cfg, meta))
280276
})
281277

282-
t.Run("network isolation alone drifts", func(t *testing.T) {
278+
t.Run("network isolation alone does not drift", func(t *testing.T) {
283279
t.Parallel()
280+
// The registry has no network-isolation field, so a workload's own
281+
// isolation choice is not registry-driven drift and must not be
282+
// reported (it would otherwise fire for every isolated workload).
284283
cfg := &runner.RunConfig{IsolateNetwork: true}
285284
meta := imageMeta("ghcr.io/example/server:1.0.0")
286285
meta.Transport = ""
287-
drift := computeConfigDrift(cfg, meta)
288-
require.NotNil(t, drift)
289-
require.NotNil(t, drift.NetworkIsolation)
290-
assert.Nil(t, drift.Transport)
291-
assert.Nil(t, drift.PermissionProfile)
286+
assert.Nil(t, computeConfigDrift(cfg, meta))
292287
})
293288
}
294289

pkg/workloads/upgrade/types.go

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,6 @@ type ConfigDrift struct {
110110
// workload's current transport.
111111
Transport *StringChange `json:"transport,omitempty"`
112112

113-
// NetworkIsolation is set when the candidate's network isolation posture
114-
// differs from the workload's current setting.
115-
NetworkIsolation *BoolChange `json:"network_isolation,omitempty"`
116-
117113
// PermissionProfile is set when the candidate's permission profile differs
118114
// from the workload's current profile.
119115
PermissionProfile *StringChange `json:"permission_profile,omitempty"`
@@ -126,13 +122,6 @@ type StringChange struct {
126122
To string `json:"to"`
127123
}
128124

129-
// BoolChange records a boolean-valued configuration change from the workload's
130-
// current value to the candidate registry value.
131-
type BoolChange struct {
132-
From bool `json:"from"`
133-
To bool `json:"to"`
134-
}
135-
136125
// ApplyOptions controls how an upgrade is applied to a workload.
137126
//
138127
// It is defined here for use by the Applier (Phase D). No apply logic is

pkg/workloads/upgrade/version.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,24 @@ func splitRepoTag(ref string) (repo, tag string, err error) {
103103

104104
// normalizeSemver prepends a "v" to a tag that looks like a bare semantic
105105
// version (e.g. "1.2.3" -> "v1.2.3") so it can be validated and compared by
106-
// golang.org/x/mod/semver, which requires the "v" prefix. Tags that already
107-
// start with "v", or that are not semver-shaped, are returned unchanged.
106+
// golang.org/x/mod/semver, which requires a lowercase "v" prefix. Tags that
107+
// already start with a lowercase "v" are returned unchanged; an uppercase "V"
108+
// prefix is lowercased (semver.IsValid rejects "V1.2.3"). Tags that are not
109+
// semver-shaped are returned unchanged.
108110
func normalizeSemver(tag string) string {
109111
if tag == "" {
110112
return tag
111113
}
112-
if tag[0] == 'v' || tag[0] == 'V' {
114+
if tag[0] == 'v' {
113115
return tag
114116
}
117+
if tag[0] == 'V' {
118+
// semver.IsValid only accepts a lowercase "v"; without this an
119+
// uppercase-tagged pair (e.g. "V1.2.0" vs "V1.3.0") falls through to
120+
// the non-semver path and reports an undecidable comparison, hiding a
121+
// real upgrade.
122+
return "v" + tag[1:]
123+
}
115124
if tag[0] >= '0' && tag[0] <= '9' {
116125
return "v" + tag
117126
}

pkg/workloads/upgrade/version_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ func TestCompareImageTags(t *testing.T) {
3131
candidate: "ghcr.io/example/server:v2.0.0",
3232
want: comparisonNewer,
3333
},
34+
{
35+
name: "candidate newer semver with uppercase V prefix",
36+
current: "ghcr.io/example/server:V1.2.0",
37+
candidate: "ghcr.io/example/server:V1.3.0",
38+
want: comparisonNewer,
39+
},
3440
{
3541
name: "double-digit minor orders numerically not lexically",
3642
current: "ghcr.io/example/server:1.9.0",
@@ -190,7 +196,7 @@ func TestNormalizeSemver(t *testing.T) {
190196
}{
191197
{name: "bare version gets v prefix", in: "1.2.3", want: "v1.2.3"},
192198
{name: "already prefixed unchanged", in: "v1.2.3", want: "v1.2.3"},
193-
{name: "uppercase V unchanged", in: "V1.2.3", want: "V1.2.3"},
199+
{name: "uppercase V lowercased for semver validation", in: "V1.2.3", want: "v1.2.3"},
194200
{name: "non-semver tag unchanged", in: "stable", want: "stable"},
195201
{name: "empty unchanged", in: "", want: ""},
196202
{name: "numeric prefix gets v", in: "2024.01", want: "v2024.01"},

0 commit comments

Comments
 (0)