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
6 changes: 6 additions & 0 deletions authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ func ReadAuthority(ctx context.Context, names ...AuthorityValueName) (*Authority
continue
}
value, err := query.workspaceValueLive(name.Name, name.Key)
if err != nil && !isWorkspaceValueAbsent(err) {
return nil, fmt.Errorf("%w: %s cannot be read: %w", ErrAuthorityValueMissing, name, err)
}
if err != nil || strings.TrimSpace(value) == "" {
return nil, fmt.Errorf("%w: %s", ErrAuthorityValueMissing, name)
}
Expand Down Expand Up @@ -164,6 +167,9 @@ func (a *Authority) Recheck(ctx context.Context) error {
query := For(ctx)
for _, name := range a.Names() {
value, err := query.workspaceValueLive(name.Name, name.Key)
if err != nil && !isWorkspaceValueAbsent(err) {
return fmt.Errorf("%w: %s cannot be read: %w", ErrAuthorityValueChanged, name, err)
}
if err != nil || strings.TrimSpace(value) == "" {
return fmt.Errorf("%w: %s is no longer configured", ErrAuthorityValueChanged, name)
}
Expand Down
33 changes: 33 additions & 0 deletions codefly.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,39 @@ func codeflyEnvironmentVariables() []string {
return append([]string(nil), environmentVariablesSnapshot...)
}

// deliveredNames lists the name of every carrier the SDK already holds with a
// non-empty value: the snapshot (process values, the values file carriers
// delivered at load, and injected values) and the live process environment,
// where an inline value is listed by its own name and a file carrier by the
// key it carries. For a live file carrier the non-empty value is its PATH: no
// file is opened, so a carrier naming an empty file is listed, and the per-key
// lookup then answers absent. Its content is read only by that lookup, so
// listing a secret namespace neither materialises nor caches a secret value.
func deliveredNames() []string {
names := map[string]struct{}{}
for _, entry := range codeflyEnvironmentVariables() {
if name, value, found := strings.Cut(entry, "="); found && value != "" {
names[name] = struct{}{}
}
}
for _, entry := range os.Environ() {
name, value, found := strings.Cut(entry, "=")
if !found || value == "" {
continue
}
if resources.IsFileCarrierKey(name) {
name = resources.CarriedKey(name)
}
names[name] = struct{}{}
}
listed := make([]string, 0, len(names))
for name := range names {
listed = append(listed, name)
}
sort.Strings(listed)
return listed
}

func injectedEnvironmentValue(name string) (string, bool) {
environmentVariablesMu.RLock()
defer environmentVariablesMu.RUnlock()
Expand Down
46 changes: 46 additions & 0 deletions file_carrier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,49 @@ func TestAnUnreadableFileCarrierFailsClosed(t *testing.T) {
require.ErrorIs(t, err, resources.ErrFileCarrier)
require.Error(t, codefly.LoadEnvironmentVariables())
}

// A public delivery that cannot be read is refused with its reason. It is
// never answered from the secret namespace under the same name: that is a
// different delivery, and answering with it would silently replace the value
// the host meant this process to read (review r139 probed exactly this and got
// "secretvalue" back with no error). The same holds where the value is read as
// authority: ReadAuthority refuses, and Recheck refuses a pinned value whose
// public delivery broke even when the secret namespace holds the pinned value.
func TestAnUnreadablePublicValueIsNeverAnsweredFromTheSecretNamespace(t *testing.T) {
ctx := context.Background()
query := codefly.For(ctx)
public := resources.WorkspaceConfigurationPrefix + "__PROBE_FB__BINDING"
t.Setenv(resources.FileCarrierKey(public), filepath.Join(t.TempDir(), "absent"))
t.Setenv(resources.WorkspaceSecretConfigurationPrefix+"__PROBE_FB__BINDING", "secretvalue")

// The premise: the public read fails, and the secret namespace answers.
_, err := query.WorkspaceConfiguration("probe-fb", "BINDING")
require.ErrorIs(t, err, resources.ErrFileCarrier)
secret, err := query.WorkspaceSecret("probe-fb", "BINDING")
require.NoError(t, err)
require.Equal(t, "secretvalue", secret)

value, err := query.WorkspaceValue("probe-fb", "BINDING")
require.ErrorIs(t, err, resources.ErrFileCarrier, "the refusal names the public delivery's failure")
require.NotErrorIs(t, err, codefly.ErrWorkspaceConfigurationAbsent)
require.Empty(t, value)
require.NotContains(t, err.Error(), "secretvalue")

_, err = codefly.ReadAuthority(ctx, codefly.AuthorityValueName{Name: "probe-fb", Key: "BINDING"})
require.ErrorIs(t, err, codefly.ErrAuthorityValueMissing)
require.ErrorIs(t, err, resources.ErrFileCarrier)

pinned := resources.WorkspaceConfigurationPrefix + "__PROBE_FB_PINNED__BINDING"
t.Setenv(pinned, "pinnedvalue")
authority, err := codefly.ReadAuthority(ctx, codefly.AuthorityValueName{Name: "probe-fb-pinned", Key: "BINDING"})
require.NoError(t, err)
require.NoError(t, os.Unsetenv(pinned))
t.Setenv(resources.FileCarrierKey(pinned), filepath.Join(t.TempDir(), "absent"))
t.Setenv(resources.WorkspaceSecretConfigurationPrefix+"__PROBE_FB_PINNED__BINDING", "pinnedvalue")

err = authority.Recheck(ctx)
require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged)
require.ErrorIs(t, err, resources.ErrFileCarrier)
_, err = query.WorkspaceValue("probe-fb-pinned", "BINDING")
require.ErrorIs(t, err, resources.ErrFileCarrier)
}
117 changes: 95 additions & 22 deletions for.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/codefly-dev/core/configurations"
Expand Down Expand Up @@ -382,10 +383,79 @@ func (q *Query) WorkspaceSecret(name string, key string) (string, error) {
return q.workspaceConfigurationValue(resources.WorkspaceSecretConfigurationPrefix, name, key)
}

// ErrWorkspaceConfigurationAbsent reports that no carrier holds the workspace
// configuration asked for: a group with no key, or a key with no value. It is
// absence, an answer; a carrier that is there and cannot be read is a
// different error and never wraps it.
var ErrWorkspaceConfigurationAbsent = errors.New("no workspace configuration value")

// WorkspaceConfigurationKeys lists, sorted, the keys of one non-secret
// workspace configuration group this process received. A group whose keys
// carry an identity the caller must discover (a group keyed by generated
// names, read value by value) is enumerated here rather than by reading
// Codefly's environment variable encoding.
//
// Each key is spelled so that WorkspaceConfiguration(name, key) reads its
// value. A key is listed when a carrier names it: an inline value that is
// non-empty, or a file carrier whose path is non-empty. A group no carrier
// holds returns an empty list and an error wrapping
// ErrWorkspaceConfigurationAbsent.
//
// Listing reads names, never values: no file a carrier names is opened, and
// no authority pin is read. A file's content is judged only by the per-key
// accessors, which apply their own rules, so a listed key can still read as
// absent (its file is empty) or fail with a named reason (its file is missing,
// or it is delivered both inline and by file). It is never dropped from the
// list.
func (q *Query) WorkspaceConfigurationKeys(name string) ([]string, error) {
return workspaceConfigurationKeys(resources.WorkspaceConfigurationPrefix, name)
}

// WorkspaceSecretKeys is WorkspaceConfigurationKeys for the secret namespace:
// each key it lists is read with WorkspaceSecret(name, key). Listing opens no
// secret file and caches no secret value.
func (q *Query) WorkspaceSecretKeys(name string) ([]string, error) {
return workspaceConfigurationKeys(resources.WorkspaceSecretConfigurationPrefix, name)
}

// workspaceConfigurationKeys matches every delivered name against the group's
// spellings (exact, then normalized, as workspaceConfigurationValue reads
// them). A key is kept only in the spelling the per-key lookup builds, so the
// name it lists is exactly the carrier that lookup reads.
func workspaceConfigurationKeys(prefix string, name string) ([]string, error) {
groups := []string{
fmt.Sprintf("%s__%s__", prefix, strings.ToUpper(name)),
fmt.Sprintf("%s__%s__", prefix, normalizeWorkspaceEnvironmentKey(name)),
}
found := map[string]struct{}{}
for _, carrier := range deliveredNames() {
for _, group := range groups {
key, ok := strings.CutPrefix(carrier, group)
if ok && key != "" && key == normalizeWorkspaceEnvironmentKey(key) {
found[key] = struct{}{}
}
}
}
if len(found) == 0 {
return []string{}, fmt.Errorf("%w in group %s", ErrWorkspaceConfigurationAbsent, name)
}
keys := make([]string, 0, len(found))
for key := range found {
keys = append(keys, key)
}
sort.Strings(keys)
return keys, nil
}

// WorkspaceValue resolves a workspace value from the public namespace first,
// then the secret namespace. It is intended for settings whose sensitivity is
// deployment-defined while preserving a single SDK-only lookup boundary.
//
// Only an absent public value moves the lookup on to the secret namespace. A
// public delivery that cannot be read (a missing file, a value delivered both
// inline and by file) is refused with that reason, and the secret namespace
// is not consulted.
//
// A value this process read as authority-bearing (see ReadAuthority) answers
// from what was read at boot, and a live value that has since drifted is
// refused with ErrAuthorityValueChanged rather than returned. Without that, a
Expand All @@ -395,6 +465,9 @@ func (q *Query) WorkspaceValue(name string, key string) (string, error) {
addressed := AuthorityValueName{Name: name, Key: key}.canonical()
pinned, isPinned := authorityPin(addressed)
value, err := q.workspaceValueLive(name, key)
if err != nil && !isWorkspaceValueAbsent(err) {
return "", err
}
if !isPinned {
return value, err
}
Expand All @@ -407,16 +480,16 @@ func (q *Query) WorkspaceValue(name string, key string) (string, error) {
return pinned, nil
}

// WorkspaceValueIfSet resolves a workspace value and distinguishes the three
// answers WorkspaceValue flattens into two: the value is set, the value is
// genuinely not configured, or the lookup FAILED.
// WorkspaceValueIfSet resolves a workspace value and answers absence without
// an error: the value is set, the value is genuinely not configured, or the
// lookup FAILED.
//
// WorkspaceValue reports "not configured" and "the delivery could not be read"
// with the same error, because its callers want a value or nothing. A caller
// deciding a posture question cannot live with that: a setting that must be
// asserted before something is permitted has to treat an unreadable delivery,
// and an authority-bearing value that has drifted, as a refusal rather than as
// "the operator did not assert it". Absence is an answer; a failed read is not.
// WorkspaceValue answers absence with an error, because its callers want a
// value or nothing. A caller deciding a posture question needs absence as an
// answer: a setting that must be asserted before something is permitted has to
// treat an unreadable delivery, and an authority-bearing value that has
// drifted, as a refusal rather than as "the operator did not assert it".
// Absence is an answer; a failed read is not.
//
// So an unreadable file carrier, a value delivered both inline and by file, and
// ErrAuthorityValueChanged all come back as errors here, and only a value no
Expand Down Expand Up @@ -465,23 +538,23 @@ func (q *Query) workspaceValueIfSetLive(name string, key string) (string, bool,
// isWorkspaceValueAbsent reports whether err is the "nothing carries this name"
// answer rather than a failure to read something that is there.
func isWorkspaceValueAbsent(err error) bool {
return err != nil && strings.Contains(err.Error(), workspaceValueAbsentReason)
return errors.Is(err, ErrWorkspaceConfigurationAbsent)
}

// workspaceValueAbsentReason is the text workspaceConfigurationValue returns
// when no namespace carries the name. It is matched here because the lookup
// reports absence with a wool error rather than a sentinel; a sentinel is the
// better shape and this is where it would be read from if one lands.
const workspaceValueAbsentReason = "no workspace configuration value for"

// workspaceValueLive is workspaceValueIfSetLive with absence as an error. A
// public delivery that cannot be read is returned as the failure it is, never
// stepped over to the secret namespace: a value under the same name there is a
// different delivery, and answering with it would let an unreadable public
// value be silently replaced.
func (q *Query) workspaceValueLive(name string, key string) (string, error) {
if value, err := q.WorkspaceConfiguration(name, key); err == nil && value != "" {
return value, nil
value, found, err := q.workspaceValueIfSetLive(name, key)
if err != nil {
return "", err
}
if value, err := q.WorkspaceSecret(name, key); err == nil && value != "" {
return value, nil
if !found {
return "", fmt.Errorf("%w for %s/%s", ErrWorkspaceConfigurationAbsent, name, key)
}
return "", wool.Get(q.ctx).In("WorkspaceValue").NewError("no workspace configuration value for %s/%s", name, key)
return value, nil
}

func (q *Query) workspaceConfigurationValue(prefix string, name string, key string) (string, error) {
Expand All @@ -504,7 +577,7 @@ func (q *Query) workspaceConfigurationValue(prefix string, name string, key stri
break
}
}
return "", wool.Get(q.ctx).In("WorkspaceConfiguration").NewError("no workspace configuration value for %s/%s", name, key)
return "", fmt.Errorf("%w for %s/%s", ErrWorkspaceConfigurationAbsent, name, key)
}

func normalizeWorkspaceEnvironmentKey(value string) string {
Expand Down
12 changes: 11 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/codefly-dev/core v0.19.1
github.com/jackc/pgx/v5 v5.10.0
github.com/stretchr/testify v1.12.1
golang.org/x/mod v0.42.0
google.golang.org/grpc v1.84.0
google.golang.org/protobuf v1.36.12
)
Expand Down Expand Up @@ -103,4 +104,13 @@ require (
// retraction takes effect only once a NEW version is tagged carrying this
// go.mod, and nothing here deletes anything from the proxy, because nothing
// can.
retract [v0.1.51, v0.1.65] // a second Work Context implementation at the root
retract (
v0.1.66-0.20260917202218-97dfea9e20d8 // a second Work Context implementation, pinned from compat/v0.1.65
[v0.1.51, v0.1.65] // a second Work Context implementation at the root
// Two pseudo-versions released modules pinned from the compat/v0.1.65 branch
// (annotations 0.2.15, robin 0.1.23, record-store 0.2.2): they carry the same
// implementation and sort outside the range above (a prerelease of v0.1.51, and
// one of v0.1.66). The branch is gone; the tag compat-v0.1.65-pins keeps them
// reachable so those releases still build, retracted so nothing new picks them.
v0.1.51-0.20260927165945-8157d3abecb2 // a second Work Context implementation, pinned from compat/v0.1.65
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
Expand Down
13 changes: 12 additions & 1 deletion published_tags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package codefly_test
import (
"encoding/json"
"fmt"
"golang.org/x/mod/semver"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -202,7 +203,11 @@ func localTags(t *testing.T) []string {
require.NoError(t, err)
var tags []string
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if line != "" {
// A VERSION tag is a published version; any other tag is not one. Go reads only
// `vX.Y.Z` (and a module subdirectory prefix) as a version, so a reachability
// tag such as `compat-v0.1.65-pins` publishes no version of its own — what it
// keeps reachable is pseudo-versions, which go.mod retracts by name below.
if line != "" && semver.IsValid(strings.TrimPrefix(line, "workcontext/")) {
tags = append(tags, line)
}
}
Expand Down Expand Up @@ -244,6 +249,12 @@ func retractedVersions(t *testing.T, dir string) map[string]bool {
covered := map[string]bool{}
for _, tag := range localTags(t) {
for _, span := range spans {
// A single retracted PSEUDO-version (a commit a release pinned, never a
// tag) cannot be any tag: tags are plain releases here, and the ordering
// below refuses a prerelease by design. It is what go.mod retracts by name.
if span.Low == span.High && semver.Prerelease(span.Low) != "" {
continue
}
// `go mod edit -json` writes a single version as Low == High, so
// one comparison covers both shapes.
if semverAtMost(t, span.Low, tag) && semverAtMost(t, tag, span.High) {
Expand Down
Loading
Loading