Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Changed

- Config-file errors now list all default paths attempted, not just the last one
- Upgraded Echo web framework to v5.1.0

## [v0.2.0](https://github.com/ganto/pkgproxy/releases/tag/v0.2.0) - 2026-04-06
Expand Down
116 changes: 102 additions & 14 deletions cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
package cmd

import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
Expand All @@ -22,47 +24,59 @@ func writeConfig(t *testing.T, dir, name string) string {

func TestResolveConfigPath(t *testing.T) {
tests := []struct {
name string
localExists bool
localIsDir bool
koDataSet bool
koFileExists bool
want func(koDir string) string
name string
localExists bool
localIsDir bool
koDataSet bool
koFileExists bool
wantPath func(koDir string) string
wantCandidates func(koDir string) []string
}{
{
name: "local file wins over ko fallback",
localExists: true,
koDataSet: true,
koFileExists: true,
want: func(_ string) string { return defaultConfigPath },
wantPath: func(_ string) string { return defaultConfigPath },
wantCandidates: func(_ string) []string { return []string{defaultConfigPath} },
},
{
name: "ko fallback used when local missing",
localExists: false,
koDataSet: true,
koFileExists: true,
want: func(koDir string) string { return filepath.Join(koDir, "pkgproxy.yaml") },
wantPath: func(koDir string) string { return filepath.Join(koDir, "pkgproxy.yaml") },
wantCandidates: func(koDir string) []string {
return []string{defaultConfigPath, filepath.Join(koDir, "pkgproxy.yaml")}
},
},
{
name: "both missing returns default path",
localExists: false,
koDataSet: false,
want: func(_ string) string { return defaultConfigPath },
wantPath: func(_ string) string { return defaultConfigPath },
wantCandidates: func(_ string) []string { return []string{defaultConfigPath} },
},
{
name: "KO_DATA_PATH set but no file returns ko path",
name: "KO_DATA_PATH set but file missing falls back to default",
localExists: false,
koDataSet: true,
koFileExists: false,
want: func(koDir string) string { return filepath.Join(koDir, "pkgproxy.yaml") },
wantPath: func(_ string) string { return defaultConfigPath },
wantCandidates: func(koDir string) []string {
return []string{defaultConfigPath, filepath.Join(koDir, "pkgproxy.yaml")}
},
},
{
name: "directory named pkgproxy.yaml falls through to ko",
localExists: true,
localIsDir: true,
koDataSet: true,
koFileExists: true,
want: func(koDir string) string { return filepath.Join(koDir, "pkgproxy.yaml") },
wantPath: func(koDir string) string { return filepath.Join(koDir, "pkgproxy.yaml") },
wantCandidates: func(koDir string) []string {
return []string{defaultConfigPath, filepath.Join(koDir, "pkgproxy.yaml")}
},
},
}

Expand Down Expand Up @@ -105,13 +119,35 @@ func TestResolveConfigPath(t *testing.T) {
t.Setenv(koDataPathEnvVar, "")
}

got, err := resolveConfigPath()
gotPath, gotCandidates, err := resolveConfigPath()
assert.NoError(t, err)
assert.Equal(t, tt.want(koDir), got)
assert.Equal(t, tt.wantPath(koDir), gotPath)
assert.Equal(t, tt.wantCandidates(koDir), gotCandidates)
})
}
}

func TestResolveConfigPathKoStatError(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("permission test cannot run as root")
}
localDir := t.TempDir()
koDir := t.TempDir()
require.NoError(t, os.Chmod(koDir, 0))
t.Cleanup(func() { _ = os.Chmod(koDir, 0700) })

origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(localDir))
t.Cleanup(func() { _ = os.Chdir(origDir) })

t.Setenv(koDataPathEnvVar, koDir)

_, _, err = resolveConfigPath()
require.Error(t, err)
assert.False(t, errors.Is(err, os.ErrNotExist))
}

func TestInitConfig(t *testing.T) {
t.Run("explicit --config bypasses lookup and env var", func(t *testing.T) {
dir := t.TempDir()
Expand Down Expand Up @@ -142,6 +178,58 @@ func TestInitConfig(t *testing.T) {
assert.Equal(t, envFile, configPath)
})

t.Run("ordered lookup error names all candidates", func(t *testing.T) {
localDir := t.TempDir()
koDir := t.TempDir()

origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(localDir))
t.Cleanup(func() { _ = os.Chdir(origDir) })

// unset PKGPROXY_CONFIG so the ordered lookup runs
if prev, ok := os.LookupEnv(configPathEnvVar); ok {
require.NoError(t, os.Unsetenv(configPathEnvVar))
t.Cleanup(func() { _ = os.Setenv(configPathEnvVar, prev) })
}
t.Setenv(koDataPathEnvVar, koDir)
// neither ./pkgproxy.yaml nor $koDir/pkgproxy.yaml exist

configPath = defaultConfigPath
t.Cleanup(func() { configPath = defaultConfigPath })

err = initConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), fmt.Sprintf("tried: %s, %s", defaultConfigPath, filepath.Join(koDir, "pkgproxy.yaml")))
})

t.Run("explicit config path error names only that path", func(t *testing.T) {
dir := t.TempDir()

configPath = filepath.Join(dir, "nonexistent.yaml")
t.Cleanup(func() { configPath = defaultConfigPath })

err := initConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), configPath)
assert.NotContains(t, err.Error(), "tried:")
})

t.Run("PKGPROXY_CONFIG error names only that path", func(t *testing.T) {
dir := t.TempDir()

t.Setenv(configPathEnvVar, filepath.Join(dir, "nonexistent.yaml"))
t.Setenv(koDataPathEnvVar, "")

configPath = defaultConfigPath
t.Cleanup(func() { configPath = defaultConfigPath })

err := initConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), filepath.Join(dir, "nonexistent.yaml"))
assert.NotContains(t, err.Error(), "tried:")
})

t.Run("ordered lookup used when flag and env var unset", func(t *testing.T) {
localDir := t.TempDir()

Expand Down
33 changes: 26 additions & 7 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/ganto/pkgproxy/pkg/pkgproxy"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -59,36 +60,54 @@ func injectServeDefault() {
}

// resolveConfigPath returns the config file path to use when neither --config
// nor $PKGPROXY_CONFIG has been set explicitly.
func resolveConfigPath() (string, error) {
// nor $PKGPROXY_CONFIG has been set explicitly, along with the ordered list of
// candidate paths it considered.
func resolveConfigPath() (string, []string, error) {
candidates := []string{defaultConfigPath}

if info, err := os.Stat(defaultConfigPath); err == nil {
if info.Mode().IsRegular() {
return defaultConfigPath, nil
return defaultConfigPath, candidates, nil
}
// not a regular file — fall through to KO_DATA_PATH
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
return "", candidates, err
}

if koDataPath, ok := os.LookupEnv(koDataPathEnvVar); ok && koDataPath != "" {
return filepath.Join(koDataPath, "pkgproxy.yaml"), nil
koPath := filepath.Join(koDataPath, "pkgproxy.yaml")
candidates = append(candidates, koPath)
if info, err := os.Stat(koPath); err == nil {
if info.Mode().IsRegular() {
return koPath, candidates, nil
}
} else if !errors.Is(err, os.ErrNotExist) {
return "", candidates, err
}
}
return defaultConfigPath, nil

return defaultConfigPath, candidates, nil
}

func initConfig() error {
var candidates []string

if configPath == defaultConfigPath {
if value, found := os.LookupEnv(configPathEnvVar); found {
configPath = value
} else {
var err error
configPath, err = resolveConfigPath()
configPath, candidates, err = resolveConfigPath()
if err != nil {
return fmt.Errorf("unable to resolve config path: %w", err)
}
}
}

if err := pkgproxy.LoadConfig(&repoConfig, configPath); err != nil {
if candidates != nil {
return fmt.Errorf("unable to load configuration; tried: %s: %w", strings.Join(candidates, ", "), err)
}
return fmt.Errorf("unable to load configuration from %s: %w", configPath, err)
}
return nil
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-16
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
## Context

`cmd/root.go`'s `resolveConfigPath` performs an ordered lookup for the repository configuration file when neither `--config` nor `PKGPROXY_CONFIG` is set:

1. `./pkgproxy.yaml`
2. `$KO_DATA_PATH/pkgproxy.yaml` (only attempted if `KO_DATA_PATH` is non-empty)

Today the function returns the joined `$KO_DATA_PATH/pkgproxy.yaml` path unconditionally when the local default is missing — it does not stat the ko candidate. `initConfig` then calls `LoadConfig(configPath)`, and any failure surfaces in a wrapped error that names only the single, last-tried path. An operator who unknowingly inherited `KO_DATA_PATH` from a parent shell sees an error pointing at a directory they may not even know about, with no indication that the local default was tried first.

The existing capability spec (`openspec/specs/config-discovery/spec.md`) explicitly codifies the current behavior in its "KO_DATA_PATH set but no file present" scenario, so this change must update that spec.

## Goals / Non-Goals

**Goals:**

- Operators see every candidate path the resolver attempted when the lookup fails, in the order it tried them.
- The `$KO_DATA_PATH` candidate is verified (stat'd) before being treated as the path to load — silent fall-through to the local default avoids reporting a confusing single-path error.
- No change to success-path behavior, precedence rules, or public surface (CLI flags, env vars, README, config schema).

**Non-Goals:**

- Reworking how `--config` / `PKGPROXY_CONFIG` precedence is evaluated.
- Adding new lookup locations (e.g. `$XDG_CONFIG_HOME`, `/etc/pkgproxy/`).
- Changing what `LoadConfig` itself reports when the file exists but is malformed.

## Decisions

### Decision 1: Surface all attempted candidates in the failure error, not just the last one

The wrapped error from `initConfig` becomes:

```
unable to load configuration; tried: ./pkgproxy.yaml, /var/run/ko/pkgproxy.yaml: <underlying error>
```

`resolveConfigPath` is reshaped to return both the path to load and the list of candidates it considered. `initConfig` includes that list in its wrapped error.

**Alternative considered:** keep `resolveConfigPath`'s `(string, error)` signature and have `initConfig` re-derive the candidate list. Rejected — duplicating the lookup logic in two places invites them drifting apart, and the resolver already knows the answer.

**Alternative considered:** include the candidate list only when `len(candidates) > 1`. Rejected — the cost of always listing is one path in single-candidate cases, which is clearer than a conditional format.

### Decision 2: Stat the `$KO_DATA_PATH` candidate before returning it

If `$KO_DATA_PATH/pkgproxy.yaml` does not exist as a regular file, `resolveConfigPath` returns `defaultConfigPath` as the path to load (matching what would happen if `KO_DATA_PATH` were unset). The candidate list still records both paths that were checked, so the eventual error message remains informative.

**Alternative considered:** return the (unverified) ko path and let `LoadConfig` fail. Rejected — pairs poorly with Decision 1, because the resolver should pick the most-likely-to-succeed path when both candidates miss; reporting `./pkgproxy.yaml` is more meaningful to an operator running locally.

### Decision 3: Update the existing `config-discovery` capability spec, not introduce a new one

This is a refinement of an already-shipped capability. The spec delta uses `MODIFIED Requirements` for the lookup requirement (the failure-mode clause changes) and revises the `KO_DATA_PATH set but no file present` scenario in place. No new requirement is introduced.

## Risks / Trade-offs

- **Risk:** existing test `"KO_DATA_PATH set but no file returns ko path"` in `cmd/config_test.go` pins the old behavior and will fail.
- **Mitigation:** rewrite that case to assert the new return value (`defaultConfigPath`) and add a new test asserting the wrapped error from `initConfig` enumerates both candidates.
- **Risk:** operators parsing the error string in scripts (unlikely, but possible) break.
- **Mitigation:** the change is documented in `CHANGELOG.md` under `[Unreleased]`; the surface is a CLI error message, not a contract.
- **Trade-off:** the resolver now returns a small struct/tuple instead of a single string, marginally complicating the call site. Worth it for the single source of truth on candidate paths.

## Migration Plan

No migration needed — the change is a pure error-message and fallback-path refinement. No data, configuration, or persisted state is affected. Rollback is a single `git revert`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Why

When `KO_DATA_PATH` is set in the operator's environment but no `pkgproxy.yaml` exists under it, the config lookup currently fails with an error pointing at the (unverified) ko path — e.g. `unable to load configuration from /var/run/ko/pkgproxy.yaml: no such file or directory`. An operator running pkgproxy outside a container may have inherited `KO_DATA_PATH` from a parent shell and not realize the lookup ever attempted that path, leaving them confused about where the error originated and which file they were expected to provide.

## What Changes

- When the ordered config-file lookup exhausts all candidates without finding a readable file, the resulting error SHALL enumerate every candidate path that was tried (in order), not just the last one.
- The `LoadConfig` call site SHALL receive a candidate path that is known to exist where possible — `resolveConfigPath` will stat the `$KO_DATA_PATH/pkgproxy.yaml` candidate and only return it when it exists, otherwise fall through to the local default path for the final attempt.
- No CLI flags, env vars, or precedence rules change. Behavior is unchanged in the success cases (both for local checkouts and ko-built containers).

## Capabilities

### New Capabilities

_None — this change refines the existing `config-discovery` capability._

### Modified Capabilities

- `config-discovery`: the failure scenario where no config file is found is tightened — the error must name all attempted candidates rather than just the last one, and the `KO_DATA_PATH` candidate must be verified before it is returned as the path to load.

## Impact

- `cmd/root.go`: `resolveConfigPath` and `initConfig` (error-wrapping site)
- `cmd/config_test.go`: existing test case `"KO_DATA_PATH set but no file returns ko path"` will be revised, and a new case will assert the multi-candidate error message
- No changes to public CLI surface, configuration schema, README, or CHANGELOG beyond a one-line entry under `[Unreleased]`
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## MODIFIED Requirements

### Requirement: Repository configuration is discovered via an ordered lookup
When neither the `--config`/`-c` flag nor the `PKGPROXY_CONFIG` environment variable is set, the CLI SHALL resolve the repository configuration file by trying the following paths in order and using the first one that exists as a regular readable file:

1. `./pkgproxy.yaml` (relative to the process working directory)
2. `$KO_DATA_PATH/pkgproxy.yaml` — only attempted if the `KO_DATA_PATH` environment variable is set to a non-empty value

If neither path yields a readable file, the CLI SHALL fail with an error that enumerates every candidate path that was attempted, in the order it tried them, of the form `unable to load configuration; tried: <path1>, <path2>: <underlying error>`. When the `$KO_DATA_PATH` candidate is attempted but no `pkgproxy.yaml` exists under it, the resolver SHALL fall through to the local default path as the path that is finally passed to the loader, while still recording the `$KO_DATA_PATH` candidate in the enumerated error.

Explicit user input SHALL always take precedence over the ordered lookup: when `--config`/`-c` is passed with a value other than the built-in default, or when `PKGPROXY_CONFIG` is set, that path is used directly and the ordered lookup is not consulted.

#### Scenario: Local source checkout uses `./pkgproxy.yaml`
- **WHEN** the binary is started with no `--config` flag, no `PKGPROXY_CONFIG` env var, and `./pkgproxy.yaml` exists in the working directory
- **THEN** the CLI SHALL load configuration from `./pkgproxy.yaml`
- **AND** the value of `KO_DATA_PATH` SHALL NOT affect the outcome

#### Scenario: Ko-built container uses the bundled config
- **WHEN** the binary is started in a ko-built image (working directory `/ko-app`, `KO_DATA_PATH=/var/run/ko`, `pkgproxy.yaml` present at `/var/run/ko/pkgproxy.yaml` and not at `./pkgproxy.yaml`) with no `--config` flag and no `PKGPROXY_CONFIG` env var
- **THEN** the CLI SHALL load configuration from `/var/run/ko/pkgproxy.yaml`

#### Scenario: Local file wins over ko-bundled fallback
- **WHEN** the binary is started with both `./pkgproxy.yaml` present and `KO_DATA_PATH` set to a directory containing a different `pkgproxy.yaml`, with no `--config` flag and no `PKGPROXY_CONFIG` env var
- **THEN** the CLI SHALL load configuration from `./pkgproxy.yaml`

#### Scenario: Explicit `--config` bypasses the lookup
- **WHEN** the binary is started with `--config /custom/path.yaml`
- **THEN** the CLI SHALL load configuration from `/custom/path.yaml` regardless of whether `./pkgproxy.yaml` or `$KO_DATA_PATH/pkgproxy.yaml` exist

#### Scenario: `PKGPROXY_CONFIG` bypasses the lookup
- **WHEN** the binary is started with `PKGPROXY_CONFIG=/custom/path.yaml` and no `--config` flag
- **THEN** the CLI SHALL load configuration from `/custom/path.yaml` regardless of whether `./pkgproxy.yaml` or `$KO_DATA_PATH/pkgproxy.yaml` exist

#### Scenario: All default paths missing produces a clear error
- **WHEN** the binary is started with no `--config` flag, no `PKGPROXY_CONFIG` env var, `./pkgproxy.yaml` absent, and `KO_DATA_PATH` unset
- **THEN** the CLI SHALL exit with an error of the form `unable to load configuration; tried: ./pkgproxy.yaml: <underlying error>`

#### Scenario: `KO_DATA_PATH` set but no file present
- **WHEN** the binary is started with no `--config` flag, no `PKGPROXY_CONFIG` env var, `./pkgproxy.yaml` absent, and `KO_DATA_PATH` set to a directory that does not contain `pkgproxy.yaml`
- **THEN** the CLI SHALL exit with an error of the form `unable to load configuration; tried: ./pkgproxy.yaml, $KO_DATA_PATH/pkgproxy.yaml: <underlying error>` (with the expanded `$KO_DATA_PATH` value)
- **AND** the path that was finally passed to the configuration loader SHALL be `./pkgproxy.yaml`, not the joined `$KO_DATA_PATH/pkgproxy.yaml`
Loading
Loading