diff --git a/docs/pages_en/usage/build/stapel/instructions.md b/docs/pages_en/usage/build/stapel/instructions.md index 6cdc751d17..2c6664d63b 100644 --- a/docs/pages_en/usage/build/stapel/instructions.md +++ b/docs/pages_en/usage/build/stapel/instructions.md @@ -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: @@ -284,6 +284,32 @@ packages: - libssl-dev ``` +### Secrets in packages + +A `packages[].env` value can reference a secret declared in the `secrets` section: + +- `%secret:%` — the contents of the secret, without trailing newlines; +- `%secret_path:%` — the path the secret is mounted at, `/run/secrets/`. + +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 diff --git a/docs/pages_ru/usage/build/stapel/instructions.md b/docs/pages_ru/usage/build/stapel/instructions.md index e88c4978f8..00da2060e7 100644 --- a/docs/pages_ru/usage/build/stapel/instructions.md +++ b/docs/pages_ru/usage/build/stapel/instructions.md @@ -281,7 +281,7 @@ packages: Выполняет `luarocks install --only-deps `. В отличие от остальных экосистем, у `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: @@ -297,6 +297,32 @@ packages: - libssl-dev ``` +### Секреты в packages + +Значение `packages[].env` может ссылаться на секрет, объявленный в секции `secrets`: + +- `%secret:%` — содержимое секрета без завершающих переводов строки; +- `%secret_path:%` — путь, по которому секрет смонтирован, `/run/secrets/`. + +Ссылка разрешается во время работы пакетного менеджера, поэтому секрет не попадает ни в инструкции сборки, ни в дайджест стадии, ни в итоговый образ. Ссылка на необъявленный секрет прерывает сборку на этапе разбора конфигурации. Любая другая последовательность вида `%...%` остаётся литералом. + +```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 diff --git a/pkg/config/packages_commands.go b/pkg/config/packages_commands.go index 789e321f6b..142daee072 100644 --- a/pkg/config/packages_commands.go +++ b/pkg/config/packages_commands.go @@ -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" @@ -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, " ") } diff --git a/pkg/config/packages_env.go b/pkg/config/packages_env.go new file mode 100644 index 0000000000..ae7fb2f151 --- /dev/null +++ b/pkg/config/packages_env.go @@ -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 +} diff --git a/pkg/config/packages_env_test.go b/pkg/config/packages_env_test.go new file mode 100644 index 0000000000..a36c96856e --- /dev/null +++ b/pkg/config/packages_env_test.go @@ -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%", `"$(