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
28 changes: 27 additions & 1 deletion docs/pages_en/usage/build/stapel/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ packages:

Runs `pnpm install --frozen-lockfile`. Default files: `package.json` (spec) and `pnpm-lock.yaml` (lock).

All file-based types support `workdir` (required), `spec` (optional, overrides default manifest filename), and `lock` (optional, overrides default lock filename). All types, including `os-pm`, support an optional `env: {KEY: value}` field — the environment variables are added to the install command. Values are passed to the package manager as is: shell constructs such as `$(...)`, backticks and `$VARIABLE` are not evaluated. Multiple entries of the same or different types can be combined in one image:
All file-based types support `workdir` (required), `spec` (optional, overrides default manifest filename), and `lock` (optional, overrides default lock filename). All types, including `os-pm`, support an optional `env: {KEY: value}` field — the environment variables are added to the install command. Values are passed to the package manager as is: shell constructs such as `$(...)`, backticks and `$VARIABLE` are not evaluated. A value may reference a declared build secret, see [Secrets in packages](#secrets-in-packages). Multiple entries of the same or different types can be combined in one image:

```yaml
packages:
Expand All @@ -284,6 +284,32 @@ packages:
- libssl-dev
```

### Secrets in packages

A `packages[].env` value can reference a secret declared in the `secrets` section:

- `%secret:<id>%` — the contents of the secret, without trailing newlines;
- `%secret_path:<id>%` — the path the secret is mounted at, `/run/secrets/<id>`.

A reference is resolved while the package manager runs, so the secret is never part of the build instructions, the stage digest or the resulting image. Referencing a secret that is not declared fails the build during configuration parsing. Any other `%...%` sequence stays literal.

```yaml
secrets:
- id: GOPROXY
env: GOPROXY
- id: CI_JOB_TOKEN
env: CI_JOB_TOKEN
packages:
- type: go-mod
workdir: /app
env:
GOPROXY: "%secret:GOPROXY%"
GOPRIVATE: git.example.com/*
GIT_CONFIG_KEY_0: 'url.https://gitlab-ci-token:%secret:CI_JOB_TOKEN%@git.example.com/.insteadOf'
GIT_CONFIG_VALUE_0: 'https://git.example.com/'
GIT_CONFIG_COUNT: "1"
```

For file-based types, declare the spec and lock files in `git.stageDependencies.packages` — otherwise changes to their contents will not rebuild the packages stage, leaving installed dependencies stale while the SBOM reports the updated files:

```yaml
Expand Down
28 changes: 27 additions & 1 deletion docs/pages_ru/usage/build/stapel/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ packages:

Выполняет `luarocks install --only-deps <spec>`. В отличие от остальных экосистем, у `lua-rock` нет значения `spec` по умолчанию: поле `spec` обязательно и должно указывать на файл `.rockspec` (имена rockspec-файлов следуют соглашению `<имя>-<версия>-<ревизия>.rockspec`). У LuaRocks нет lock-файла, поэтому поле `lock` отклоняется с ошибкой. Для генерации SBOM syft использует каталогизатор `lua-rock-cataloger`.

Все файловые типы поддерживают поля `workdir` (обязательно), `spec` (опционально, переопределяет имя файла манифеста) и `lock` (опционально, переопределяет имя lock-файла). Все типы, включая `os-pm`, поддерживают опциональное поле `env: {KEY: value}` — переменные окружения добавляются к команде установки. Значения передаются пакетному менеджеру как есть: конструкции шелла вида `$(...)`, обратные кавычки и `$VARIABLE` не вычисляются. В одном образе можно комбинировать несколько записей одного или разных типов:
Все файловые типы поддерживают поля `workdir` (обязательно), `spec` (опционально, переопределяет имя файла манифеста) и `lock` (опционально, переопределяет имя lock-файла). Все типы, включая `os-pm`, поддерживают опциональное поле `env: {KEY: value}` — переменные окружения добавляются к команде установки. Значения передаются пакетному менеджеру как есть: конструкции шелла вида `$(...)`, обратные кавычки и `$VARIABLE` не вычисляются. Значение может ссылаться на объявленный секрет сборки, см. [Секреты в packages](#секреты-в-packages). В одном образе можно комбинировать несколько записей одного или разных типов:

```yaml
packages:
Expand All @@ -297,6 +297,32 @@ packages:
- libssl-dev
```

### Секреты в packages

Значение `packages[].env` может ссылаться на секрет, объявленный в секции `secrets`:

- `%secret:<id>%` — содержимое секрета без завершающих переводов строки;
- `%secret_path:<id>%` — путь, по которому секрет смонтирован, `/run/secrets/<id>`.

Ссылка разрешается во время работы пакетного менеджера, поэтому секрет не попадает ни в инструкции сборки, ни в дайджест стадии, ни в итоговый образ. Ссылка на необъявленный секрет прерывает сборку на этапе разбора конфигурации. Любая другая последовательность вида `%...%` остаётся литералом.

```yaml
secrets:
- id: GOPROXY
env: GOPROXY
- id: CI_JOB_TOKEN
env: CI_JOB_TOKEN
packages:
- type: go-mod
workdir: /app
env:
GOPROXY: "%secret:GOPROXY%"
GOPRIVATE: git.example.com/*
GIT_CONFIG_KEY_0: 'url.https://gitlab-ci-token:%secret:CI_JOB_TOKEN%@git.example.com/.insteadOf'
GIT_CONFIG_VALUE_0: 'https://git.example.com/'
GIT_CONFIG_COUNT: "1"
```

Для файловых типов укажите spec- и lock-файлы в `git.stageDependencies.packages` — иначе изменение их содержимого не приведет к пересборке стадии packages: установленные зависимости устареют, а SBOM при этом будет отражать обновленные файлы:

```yaml
Expand Down
3 changes: 1 addition & 2 deletions pkg/config/packages_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"sort"
"strings"

"github.com/alessio/shellescape"
"github.com/samber/lo"

"github.com/werf/werf/v2/pkg/sbom/os_pm/metadata"
Expand All @@ -22,7 +21,7 @@ func formatEnvVars(env map[string]string) string {
sort.Strings(keys)

parts := lo.Map(keys, func(k string, _ int) string {
return fmt.Sprintf("%s=%s", k, shellescape.Quote(env[k]))
return fmt.Sprintf("%s=%s", k, formatPackageEnvValue(env[k]))
})
return strings.Join(parts, " ")
}
Expand Down
105 changes: 105 additions & 0 deletions pkg/config/packages_env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package config

import (
"fmt"
"regexp"
"strings"

"github.com/alessio/shellescape"
)

const (
packageSecretsDir = "/run/secrets/"

packageEnvNamespaceSecret = "secret"
packageEnvNamespaceSecretPath = "secret_path"
)

var (
packageEnvReferenceRe = regexp.MustCompile(`%(secret_path|secret):([^%]*)%`)
packageSecretIDRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`)
)

type packageEnvPart struct {
literal string
namespace string
secretID string
}

func splitPackageEnvValue(value string) ([]packageEnvPart, error) {
var parts []packageEnvPart

end := 0
for _, match := range packageEnvReferenceRe.FindAllStringSubmatchIndex(value, -1) {
namespace, secretID := value[match[2]:match[3]], value[match[4]:match[5]]
if !packageSecretIDRe.MatchString(secretID) {
return nil, fmt.Errorf("invalid secret id %q in %q: must match %s", secretID, value, packageSecretIDRe)
}

if match[0] > end {
parts = append(parts, packageEnvPart{literal: value[end:match[0]]})
}
parts = append(parts, packageEnvPart{namespace: namespace, secretID: secretID})
end = match[1]
}

if end < len(value) {
parts = append(parts, packageEnvPart{literal: value[end:]})
}

return parts, nil
}

func formatPackageEnvValue(value string) string {
parts, err := splitPackageEnvValue(value)
if err != nil {
// Malformed references are rejected while parsing the config, so reaching this point
// means the value never went through validatePackageEnvValues: keep it literal.
return shellescape.Quote(value)
}

if len(parts) == 0 {
return shellescape.Quote("")
}

formatted := make([]string, 0, len(parts))
for _, part := range parts {
switch part.namespace {
case packageEnvNamespaceSecret:
formatted = append(formatted, fmt.Sprintf(`"$(<%s%s)"`, packageSecretsDir, part.secretID))
case packageEnvNamespaceSecretPath:
formatted = append(formatted, shellescape.Quote(packageSecretsDir+part.secretID))
default:
formatted = append(formatted, shellescape.Quote(part.literal))
}
}

return strings.Join(formatted, "")
}

func validatePackageEnvValues(rawPackages []*rawPackagesDirective, secrets []Secret) error {
declaredSecretIDs := make(map[string]struct{}, len(secrets))
for _, secret := range secrets {
declaredSecretIDs[secret.Id] = struct{}{}
}

for index, rawPackage := range rawPackages {
for name, value := range rawPackage.Env {
parts, err := splitPackageEnvValue(value)
if err != nil {
return newDetailedConfigError(fmt.Sprintf("packages[%d].env[%q]: %s", index, name, err), rawPackage, rawPackage.docForErrors())
}

for _, part := range parts {
if part.namespace == "" {
continue
}
if _, declared := declaredSecretIDs[part.secretID]; !declared {
return newDetailedConfigError(fmt.Sprintf("packages[%d].env[%q] references secret %q, which is not declared in the `secrets` section", index, name, part.secretID), rawPackage, rawPackage.docForErrors())
}
}
}
}

return nil
}
77 changes: 77 additions & 0 deletions pkg/config/packages_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package config

import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("formatPackageEnvValue", func() {
DescribeTable("keeps the secret out of the generated command",
func(value, expected string) {
Expect(formatPackageEnvValue(value)).To(Equal(expected))
},
Entry("secret content", "%secret:TOKEN%", `"$(</run/secrets/TOKEN)"`),
Entry("secret path", "%secret_path:credentials%", "/run/secrets/credentials"),
Entry("secret inside a larger value", "https://gitlab-ci-token:%secret:TOKEN%@example.com/", `https://gitlab-ci-token:"$(</run/secrets/TOKEN)"@example.com/`),
Entry("two references", "%secret:A%:%secret:B%", `"$(</run/secrets/A)":"$(</run/secrets/B)"`),
Entry("unknown namespace stays literal", "%env:TOKEN%", "%env:TOKEN%"),
Entry("unterminated reference stays literal", "%secret:TOKEN", "%secret:TOKEN"),
Entry("ordinary value", "http://proxy.example.com:8080", "http://proxy.example.com:8080"),
Entry("empty value", "", "''"),
)

DescribeTable("resolves the reference at runtime",
func(ctx SpecContext, value, expected string) {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(dir, "TOKEN"), []byte("s3cret\n"), 0o600)).To(Succeed())

assignment := strings.ReplaceAll(formatPackageEnvValue(value), packageSecretsDir, dir+"/")
cmd := exec.CommandContext(ctx, "bash", "-ec", `SOME_VAR=`+assignment+`; printf '%s' "$SOME_VAR"`)
stderr := &bytes.Buffer{}
cmd.Stderr = stderr
stdout, err := cmd.Output()
Expect(err).NotTo(HaveOccurred(), stderr.String())
Expect(string(stdout)).To(Equal(strings.ReplaceAll(expected, packageSecretsDir, dir+"/")))
},
Entry("secret content without its trailing newline", "%secret:TOKEN%", "s3cret"),
Entry("secret path", "%secret_path:TOKEN%", "/run/secrets/TOKEN"),
Entry("secret composed into a url", "https://gitlab-ci-token:%secret:TOKEN%@example.com/", "https://gitlab-ci-token:s3cret@example.com/"),
Entry("unknown namespace stays literal", "%env:TOKEN%", "%env:TOKEN%"),
Entry("shell construct stays literal", "$(echo pwned)", "$(echo pwned)"),
)
})

var _ = Describe("validatePackageEnvValues", func() {
rawPackages := func(value string) []*rawPackagesDirective {
return []*rawPackagesDirective{{Type: "go-mod", Env: map[string]string{"GOPROXY": value}}}
}
declared := []Secret{{Id: "TOKEN"}}

DescribeTable("rejects a reference the build cannot satisfy",
func(value, message string) {
err := validatePackageEnvValues(rawPackages(value), declared)
Expect(err).To(MatchError(ContainSubstring(message)))
},
Entry("undeclared secret", "%secret:MISSING%", `packages[0].env["GOPROXY"] references secret "MISSING", which is not declared`),
Entry("undeclared secret path", "%secret_path:MISSING%", `references secret "MISSING", which is not declared`),
Entry("empty secret id", "%secret:%", `invalid secret id ""`),
Entry("secret id with a space", "%secret:my token%", `invalid secret id "my token"`),
)

DescribeTable("accepts a valid value",
func(value string) {
Expect(validatePackageEnvValues(rawPackages(value), declared)).To(Succeed())
},
Entry("secret content", "%secret:TOKEN%"),
Entry("secret path", "%secret_path:TOKEN%"),
Entry("unknown namespace", "%env:MISSING%"),
Entry("unterminated reference", "%secret:MISSING"),
Entry("ordinary value", "direct"),
)
})
25 changes: 25 additions & 0 deletions pkg/config/raw_packages_directive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,4 +462,29 @@ var _ = Describe("rawPackagesDirective", func() {
Entry("path", "/run/secrets/docker-config", false),
Entry("empty value", "", false),
)

DescribeTable("validates packages env secret references against the secrets section",
func(ctx SpecContext, value, expectedErr string) {
yamlMap := map[string]interface{}{
"image": "image1",
"from": "alpine:latest",
"secrets": []map[string]interface{}{{"env": "TOKEN"}},
"packages": []map[string]interface{}{{
"type": "go-mod",
"workdir": "/app",
"env": map[string]interface{}{"GOPROXY": value},
}},
}

_, err := directivesFromYaml(ctx, yamlMap)
if expectedErr == "" {
Expect(err).To(Succeed())
return
}
Expect(err).To(MatchError(ContainSubstring(expectedErr)))
},
Entry("declared secret", "%secret:TOKEN%", ""),
Entry("undeclared secret", "%secret:MISSING%", `packages[0].env["GOPROXY"] references secret "MISSING", which is not declared`),
Entry("malformed secret id", "%secret:my token%", `invalid secret id "my token"`),
)
})
4 changes: 4 additions & 0 deletions pkg/config/raw_stapel_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,10 @@ func (c *rawStapelImage) toStapelImageBaseDirective(ctx context.Context, gitermi

imageBase.Secrets = secrets

if err := validatePackageEnvValues(c.RawPackages, secrets); err != nil {
return nil, err
}

if c.RawImageSpec != nil {
imageBase.ImageSpec = c.RawImageSpec.toDirective()
}
Expand Down
12 changes: 12 additions & 0 deletions pkg/config/stubs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,24 @@ func (manager *GiterminismManagerStub) Dev() bool {
return false
}

func (manager *GiterminismManagerStub) Inspector() giterminism_manager.Inspector {
return &GiterminismInspectorStub{}
}

func (manager *GiterminismManagerStub) HeadCommit(ctx context.Context) string {
commit, err := manager.localGitRepo.HeadCommitHash(ctx)
Expect(err).To(Succeed())
return commit
}

type GiterminismInspectorStub struct {
giterminism_manager.Inspector
}

func (inspector *GiterminismInspectorStub) InspectConfigSecretEnvAccepted(secret string) error {
return nil
}

type LocalGitRepoStub struct {
git_repo.GitRepo

Expand Down
Loading