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
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,52 @@

> Environment variables substitution for Go. see docs [below](#docs)

## About this fork

This is a fork of [a8m/envsubst](https://github.com/a8m/envsubst) tuned for use as
an [ArgoCD ConfigManagementPlugin](https://argo-cd.readthedocs.io/en/stable/operator-manual/config-management-plugins/)
rendering step. Two deliberate deviations from upstream:

1. **`-prefix` flag.** Restrict substitution to variables matching a given prefix
(e.g. `-prefix ARGOCD_ENV_`). Variable references that don't match the prefix
are left as literal text. This keeps the plugin from accidentally substituting
anything that happens to look like a shell variable in rendered Kubernetes
manifests.

2. **`$$` is preserved as literal text, not collapsed to `$`.** Upstream a8m
treats `$$` as a shell-style escape (so `$$VAR` produces literal `$VAR`).
That semantics silently mangles Kubernetes manifests whose contents quote
shell or kubelet idioms verbatim — most notably the KEDA Helm chart, which
embeds Kubernetes' own env-var-expansion docs in CRD schema descriptions
("Double `$$` are reduced to a single `$`"). With upstream behavior, ArgoCD
shows perpetual `$$ → $` drift on synced resources. This fork emits `$$` as
literal text.

The fork is intentionally narrow: same parser, same library API, same
restrictions (`-no-unset`, `-no-empty`, `-fail-fast`). **Shell-style parameter
expansion operators are preserved** — `${VAR:-default}`, `${VAR:=default}`,
`${VAR:+alt}`, `${VAR:?err}`, `${VAR-default}`, `${VAR=default}`,
`${VAR+alt}`, `${VAR?err}` all work as in upstream. Only the `$$` escape
behavior is dropped. If you want shell-style `$$` escapes, use upstream
[a8m/envsubst](https://github.com/a8m/envsubst) instead.

#### Combining literal `$$` with a substitution

After this change, `$$VAR` does **not** substitute `VAR` — both `$` characters
are consumed as literal text and `VAR` becomes plain text without a leading `$`
to mark it as a variable reference. The same applies to `$${VAR}`: the `$$`
consumes both dollars, leaving `{VAR}` as plain text rather than a substitution.

To emit literal `$$` followed by a substituted variable, use one of:

```
input: $$$ARGOCD_ENV_FOO → output: $$<value-of-FOO> (triple-dollar; third $ starts the variable)
input: $$ ${ARGOCD_ENV_FOO} → output: $$ <value-of-FOO> (space-separated)

input: $$ARGOCD_ENV_FOO → output: $$ARGOCD_ENV_FOO (no substitution)
input: $${ARGOCD_ENV_FOO} → output: $${ARGOCD_ENV_FOO} (no substitution)
```

#### Installation:

##### From binaries
Expand Down Expand Up @@ -48,6 +94,7 @@ The flags and their restrictions are:
| ------------| -------------- | ------------ | ------------ |
|`-i` | input file | `string \| stdin` | `stdin`
|`-o` | output file | `string \| stdout` | `stdout`
|`-prefix` | only substitute variables with this prefix; others are left as literal text (e.g. `-prefix ARGOCD_ENV_`) | `string` | `""` (substitute all)
|`-no-digit` | do not replace variables starting with a digit, e.g. $1 and ${1} | `flag` | `false`
|`-no-unset` | fail if a variable is not set | `flag` | `false`
|`-no-empty` | fail if a variable is set but empty | `flag` | `false`
Expand Down Expand Up @@ -86,7 +133,7 @@ func main() {
|`${var:=$DEFAULT}` | If var not set or is empty, evaluate expression as $DEFAULT
|`${var+$OTHER}` | If var set, evaluate expression as $OTHER, otherwise as empty string
|`${var:+$OTHER}` | If var set, evaluate expression as $OTHER, otherwise as empty string
|`$$var` | Escape expressions. Result will be `$var`.
|`$$` | Preserved as literal `$$`. **Differs from upstream a8m**, which treats `$$` as a shell-style escape collapsing to `$`. See [About this fork](#about-this-fork).

<sub>Most of the rows in this table were taken from [here](http://www.tldp.org/LDP/abs/html/refcards.html#AEN22728)</sub>

Expand Down
24 changes: 21 additions & 3 deletions cmd/envsubst/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
var (
input = flag.String("i", "", "")
output = flag.String("o", "", "")
prefix = flag.String("prefix", "", "")
noDigit = flag.Bool("no-digit", false, "")
noUnset = flag.Bool("no-unset", false, "")
noEmpty = flag.Bool("no-empty", false, "")
Expand All @@ -25,6 +26,8 @@ Options:
-i Specify file input, otherwise use last argument as input file.
If no input file is specified, read from stdin.
-o Specify file output. If none is specified, write to stdout.
-prefix Only substitute variables with this prefix (e.g., -prefix ARGOCD_ENV_).
Other variable references are left as literal text in the output.
-no-digit Do not replace variables starting with a digit. e.g. $1 and ${1}
-no-unset Fail if a variable is not set.
-no-empty Fail if a variable is set but empty.
Expand All @@ -33,7 +36,7 @@ Options:

func main() {
flag.Usage = func() {
fmt.Fprint(os.Stderr, fmt.Sprintf(usage))
fmt.Fprint(os.Stderr, usage)
}
flag.Parse()
var reader *bufio.Reader
Expand Down Expand Up @@ -82,7 +85,22 @@ func main() {
parserMode = parse.Quick
}
restrictions := &parse.Restrictions{*noUnset, *noEmpty, *noDigit}
result, err := (&parse.Parser{Name: "string", Env: os.Environ(), Restrict: restrictions, Mode: parserMode}).Parse(data)

// Build the variable filter if prefix is specified
var varFilter *parse.VarFilter
if *prefix != "" {
varFilter = &parse.VarFilter{
Prefixes: []string{*prefix},
}
}

result, err := (&parse.Parser{
Name: "string",
Env: os.Environ(),
Restrict: restrictions,
Mode: parserMode,
VarFilter: varFilter,
}).Parse(data)
if err != nil {
errorAndExit(err)
}
Expand All @@ -97,7 +115,7 @@ func main() {

func usageAndExit(msg string) {
if msg != "" {
fmt.Fprintf(os.Stderr, msg)
fmt.Fprintf(os.Stderr, "%s", msg)
fmt.Fprintf(os.Stderr, "\n\n")
}
flag.Usage()
Expand Down
9 changes: 7 additions & 2 deletions parse/lex.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,13 @@ Loop:
l.next()
l.emit(itemText)
case r == '$':
// ignore the previous '$'.
l.ignore()
// preserve "$$" as literal text. Upstream a8m/envsubst treats
// "$$" as a shell-style escape that collapses to "$", but that
// silently mangles inputs that contain literal "$$" (e.g. KEDA
// CRD descriptions quoting Kubernetes' env-var-expansion docs:
// "Double $$ are reduced to a single $"). Since this fork is
// scoped to ArgoCD CMP rendering with --prefix filtering, we
// preserve "$$" verbatim instead of escaping it.
l.next()
l.emit(itemText)
case r == '{':
Expand Down
9 changes: 5 additions & 4 deletions parse/lex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,16 @@ var lexTests = []lexTest{
{itemVariable, 0, "world"},
{itemError, 0, "closing brace expected"},
}},
{"escaping $$var", "hello $$HOME", []item{
// "$$" is preserved as literal text in this fork (see lex.go).
{"literal $$var", "hello $$HOME", []item{
{itemText, 0, "hello "},
{itemText, 7, "$"},
{itemText, 6, "$$"},
{itemText, 8, "HOME"},
tEOF,
}},
{"escaping $${subst}", "hello $${HOME}", []item{
{"literal $${subst}", "hello $${HOME}", []item{
{itemText, 0, "hello "},
{itemText, 7, "$"},
{itemText, 6, "$$"},
{itemText, 8, "{HOME}"},
tEOF,
}},
Expand Down
65 changes: 57 additions & 8 deletions parse/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,31 @@ package parse

import (
"fmt"
"strings"
)

// VarFilter provides filtering for variable substitution by prefix.
// A nil filter allows all variables. A non-nil filter only allows
// variables whose names start with one of the specified prefixes.
type VarFilter struct {
Prefixes []string // Prefix patterns to match (e.g., "ARGOCD_ENV_")
}

// IsAllowed checks if a variable name is allowed by this filter.
// Returns true if the variable matches any prefix pattern.
// A nil filter allows all variables.
func (f *VarFilter) IsAllowed(varName string) bool {
if f == nil {
return true
}
for _, prefix := range f.Prefixes {
if strings.HasPrefix(varName, prefix) {
return true
}
}
return false
}

type Node interface {
Type() NodeType
String() (string, error)
Expand Down Expand Up @@ -39,16 +62,32 @@ func (t *TextNode) String() (string, error) {

type VariableNode struct {
NodeType
Ident string
Env Env
Restrict *Restrictions
Ident string
Env Env
Restrict *Restrictions
VarFilter *VarFilter // nil means all vars allowed
OriginalSrc string // Original source like "$VAR" for literal output when not allowed
}

func NewVariable(ident string, env Env, restrict *Restrictions) *VariableNode {
return &VariableNode{NodeVariable, ident, env, restrict}
func NewVariable(ident string, env Env, restrict *Restrictions, varFilter *VarFilter) *VariableNode {
return &VariableNode{
NodeType: NodeVariable,
Ident: ident,
Env: env,
Restrict: restrict,
VarFilter: varFilter,
}
}

func (t *VariableNode) String() (string, error) {
// If filtering is enabled and this var is not allowed,
// return original source as literal text
if t.VarFilter != nil && !t.VarFilter.IsAllowed(t.Ident) {
if t.OriginalSrc != "" {
return t.OriginalSrc, nil
}
return "$" + t.Ident, nil
}
if err := t.validateNoUnset(); err != nil {
return "", err
}
Expand Down Expand Up @@ -79,12 +118,22 @@ func (t *VariableNode) validateNoEmpty(value string) error {

type SubstitutionNode struct {
NodeType
ExpType itemType
Variable *VariableNode
Default Node // Default could be variable or text
ExpType itemType
Variable *VariableNode
Default Node // Default could be variable or text
OriginalSrc string // Original source like "${VAR:-default}" for literal output when not allowed
}

func (t *SubstitutionNode) String() (string, error) {
// If filtering is enabled and this var is not allowed,
// return original source as literal text
if t.Variable.VarFilter != nil && !t.Variable.VarFilter.IsAllowed(t.Variable.Ident) {
if t.OriginalSrc != "" {
return t.OriginalSrc, nil
}
// Fallback: reconstruct basic form
return "${" + t.Variable.Ident + "}", nil
}
if t.ExpType >= itemPlus && t.Default != nil {
switch t.ExpType {
case itemColonDash, itemColonEquals:
Expand Down
41 changes: 29 additions & 12 deletions parse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ var (

// Parser type initializer
type Parser struct {
Name string // name of the processing template
Env Env
Restrict *Restrictions
Mode Mode
Name string // name of the processing template
Env Env
Restrict *Restrictions
Mode Mode
VarFilter *VarFilter // nil means all vars allowed; non-nil limits substitution
// parsing state;
lex *lexer
token [3]item // three-token lookahead
Expand Down Expand Up @@ -105,11 +106,12 @@ Loop:
case itemError:
return p.errorf(t.val)
case itemVariable:
varNode := NewVariable(strings.TrimPrefix(t.val, "$"), p.Env, p.Restrict)
varNode := NewVariable(strings.TrimPrefix(t.val, "$"), p.Env, p.Restrict, p.VarFilter)
varNode.OriginalSrc = t.val // Store original like "$VAR"
p.nodes = append(p.nodes, varNode)
case itemLeftDelim:
if p.peek().typ == itemVariable {
n, err := p.action()
n, err := p.action(t.pos) // Pass the position of ${
if err != nil {
return err
}
Expand All @@ -126,19 +128,35 @@ Loop:
}

// Parse substitution. first item is a variable.
func (p *Parser) action() (Node, error) {
// delimPos is the position of the opening ${
func (p *Parser) action(delimPos Pos) (Node, error) {
var expType itemType
var defaultNode Node
varNode := NewVariable(p.next().val, p.Env, p.Restrict)
Loop:
varNode := NewVariable(p.next().val, p.Env, p.Restrict, p.VarFilter)
for {
switch t := p.next(); t.typ {
case itemRightDelim:
break Loop
// Capture original source from ${ through }
// t.pos is the position of }, and we need to include it
endPos := int(t.pos) + len(t.val)
if endPos > len(p.lex.input) {
endPos = len(p.lex.input)
}
originalSrc := p.lex.input[delimPos:endPos]
node := &SubstitutionNode{
NodeType: NodeSubstitution,
ExpType: expType,
Variable: varNode,
Default: defaultNode,
OriginalSrc: originalSrc,
}
return node, nil
case itemError:
return nil, p.errorf(t.val)
case itemVariable:
defaultNode = NewVariable(strings.TrimPrefix(t.val, "$"), p.Env, p.Restrict)
defVar := NewVariable(strings.TrimPrefix(t.val, "$"), p.Env, p.Restrict, p.VarFilter)
defVar.OriginalSrc = t.val
defaultNode = defVar
case itemText:
n := NewText(t.val)
Text:
Expand All @@ -156,7 +174,6 @@ Loop:
expType = t.typ
}
}
return &SubstitutionNode{NodeSubstitution, expType, varNode, defaultNode}, nil
}

func (p *Parser) errorf(s string) error {
Expand Down
Loading