diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2129f17..05cb081 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: - name: Idempotency end-to-end test (local fixture repo) run: ./scripts/ci-e2e-idempotency.sh + - name: managed_file bootstrap/enforce contract test + run: ./scripts/ci-managed-file-contract.sh + - name: List pipeline stages (plan dry run) env: # The plan requires these to be set, but nothing calls the APIs in diff --git a/AGENTS.md b/AGENTS.md index 7431363..5f8e6c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Three layers, all data-driven via Hiera (`hiera.yaml` defines two separate hiera 2. **The baseline — `modules/role/` and `modules/profile/`** - One stage (`apply_puppet_role`) applies a `role::*` class to each cloned repo's working tree. The role is chosen per repo via Hiera's `classes` key, keyed off the `project_type` fact (see `data/project_types/*.yaml`). - - `role::*` classes are thin lists of `profile::*` includes. Each profile manages a specific slice of the baseline (Gemfile, git files, GitHub Actions workflows, lint configs, ...), writing into `$::repo_path`. + - `role::*` classes are thin lists of `profile::*` includes. Each profile manages a specific slice of the baseline (Gemfile, git files, GitHub Actions workflows, lint configs, ...), writing into `$::repo_path` via the `profile::managed_file` define. Files have two management strategies (Hiera-overridable `strategy` param per profile): `enforce` (content fully managed, the default) and `bootstrap` (complete file laid down only when missing — used for files whose values Renovate maintains in place, like the Gemfile). - Static file sources live in `modules/profile/files/`, templates in `modules/profile/templates/`. Dotfiles are stored with a leading underscore (`_gitignore` → `.gitignore`). Profiles use fallback lookup chains so a repo-specific override (`_gitignore.`, or `_github/workflows/..yml`) beats the generic file. 3. **Data — `data/`** diff --git a/modules/profile/manifests/github_actions.pp b/modules/profile/manifests/github_actions.pp index ac4acd0..df6d6e3 100644 --- a/modules/profile/manifests/github_actions.pp +++ b/modules/profile/manifests/github_actions.pp @@ -7,6 +7,13 @@ # # files/pupmod/_github/workflows/{workflow_name}.{repo_name}.yml # +# @param strategy +# `enforce` (default): workflow files are fully managed — puppetsync is +# still the delivery mechanism for workflow changes. NOTE: the `uses:` +# action refs in these files are Renovate-managed, so an `enforce` sync +# can revert Renovate's bumps until the in-place merge stage exists +# (simp/puppetsync#50); switch to `bootstrap` per project_type in Hiera +# once that lands. class profile::github_actions( Stdlib::Absolutepath $target_github_actions_dir = "${::repo_path}/.github/workflows", Optional[String[1]] $target_repo_name = $facts.dig('module_metadata','name'), @@ -14,6 +21,7 @@ Array[String] $absent_action_files = [ 'pr_glci.yml', 'pr_glci_cleanup.yml', 'pr_glci_manual.yml', ], + Enum['enforce','bootstrap'] $strategy = 'enforce', ){ $project_type = $facts.dig('project_type').lest || {'unknown'} $project_type2 = $project_type == 'pupmod_skeleton' ? { @@ -29,7 +37,8 @@ $present_action_files.each |$action_file| { $action = basename( $action_file, '.yml' ) - file{ "${target_github_actions_dir}/${action_file}": + profile::managed_file{ "${target_github_actions_dir}/${action_file}": + strategy => $strategy, content => file( "${module_name}/${project_type}/_github/workflows/${action}.${target_repo_name}.yml", "${module_name}/${project_type}/_github/workflows/${action}.yml", diff --git a/modules/profile/manifests/managed_file.pp b/modules/profile/manifests/managed_file.pp new file mode 100644 index 0000000..28f0936 --- /dev/null +++ b/modules/profile/manifests/managed_file.pp @@ -0,0 +1,28 @@ +# @summary Manage a baseline file in a target repo +# +# @param content +# Full file content +# +# @param strategy +# How the file is managed: +# +# * `enforce` (default) ― content is fully managed; local changes are +# always overwritten by the next sync +# * `bootstrap` ― lay down the complete file only if it does not exist +# yet. Use this for files whose values are maintained in place after +# creation (e.g. version pins managed by Renovate), so a sync never +# clobbers them. (In-place structural updates are handled separately — +# see the merge stages planned in simp/puppetsync#50.) +# +# @param path +# Absolute path of the file to manage (defaults to the resource title) +define profile::managed_file ( + String $content, + Enum['enforce','bootstrap'] $strategy = 'enforce', + Stdlib::Absolutepath $path = $title, +) { + file { $path: + content => $content, + replace => $strategy ? { 'bootstrap' => false, default => true }, + } +} diff --git a/modules/profile/manifests/pupmod/gemfile.pp b/modules/profile/manifests/pupmod/gemfile.pp index 10a4b32..3600070 100644 --- a/modules/profile/manifests/pupmod/gemfile.pp +++ b/modules/profile/manifests/pupmod/gemfile.pp @@ -1,13 +1,21 @@ -# Static Gemfile for Puppet modules +# Baseline Gemfile for Puppet modules +# +# @param strategy +# `bootstrap` (default): the Gemfile is only laid down when it doesn't +# exist, so Renovate-managed gem pins in existing repos are never +# clobbered. Set to `enforce` (e.g. per project_type in Hiera) to +# overwrite existing files. class profile::pupmod::gemfile( - Stdlib::Absolutepath $gemfile_path = "${::repo_path}/Gemfile", - Optional[String[1]] $target_module_name = $facts.dig('module_metadata','name'), + Stdlib::Absolutepath $gemfile_path = "${::repo_path}/Gemfile", + Optional[String[1]] $target_module_name = $facts.dig('module_metadata','name'), + Enum['enforce','bootstrap'] $strategy = 'bootstrap', ){ - file{ $gemfile_path: + profile::managed_file{ $gemfile_path: + strategy => $strategy, content => file( "${module_name}/pupmod/Gemfile.${target_module_name}", "${module_name}/pupmod/Gemfile", - ) + ), } file{ "${gemfile_path}.lock": diff --git a/modules/profile/manifests/pupmod/git_files.pp b/modules/profile/manifests/pupmod/git_files.pp index 03f85ea..7812bb1 100644 --- a/modules/profile/manifests/pupmod/git_files.pp +++ b/modules/profile/manifests/pupmod/git_files.pp @@ -1,10 +1,16 @@ # Manages .gitignore and .gitattributes +# +# @param strategy +# `enforce` (default): these files carry no externally-managed values, so +# their content is fully managed class profile::pupmod::git_files( - Stdlib::Absolutepath $gitignore_path = "${::repo_path}/.gitignore", - Stdlib::Absolutepath $gitattributes_path = "${::repo_path}/.gitattributes", - Optional[String[1]] $target_module_name = $facts.dig('module_metadata','name'), + Stdlib::Absolutepath $gitignore_path = "${::repo_path}/.gitignore", + Stdlib::Absolutepath $gitattributes_path = "${::repo_path}/.gitattributes", + Optional[String[1]] $target_module_name = $facts.dig('module_metadata','name'), + Enum['enforce','bootstrap'] $strategy = 'enforce', ){ - file{ $gitignore_path: + profile::managed_file{ $gitignore_path: + strategy => $strategy, content => file( "${module_name}/pupmod/_gitignore.${target_module_name}", "${module_name}/pupmod/_gitignore", @@ -12,7 +18,8 @@ ), } - file{ $gitattributes_path: + profile::managed_file{ $gitattributes_path: + strategy => $strategy, content => file( "${module_name}/pupmod/_gitattributes.${target_module_name}", "${module_name}/pupmod/_gitattributes", diff --git a/modules/profile/manifests/pupmod/pdkignore.pp b/modules/profile/manifests/pupmod/pdkignore.pp index 95cddc0..3dedac3 100644 --- a/modules/profile/manifests/pupmod/pdkignore.pp +++ b/modules/profile/manifests/pupmod/pdkignore.pp @@ -7,11 +7,16 @@ # # files/pupmod/_pdkignore.pupmod-simp-name # +# @param strategy +# `enforce` (default): this file carries no externally-managed values, so +# its content is fully managed class profile::pupmod::pdkignore( - Stdlib::Absolutepath $target_pdkignore_path = "${::repo_path}/.pdkignore", - Optional[String[1]] $target_module_name = $facts.dig('module_metadata','name'), + Stdlib::Absolutepath $target_pdkignore_path = "${::repo_path}/.pdkignore", + Optional[String[1]] $target_module_name = $facts.dig('module_metadata','name'), + Enum['enforce','bootstrap'] $strategy = 'enforce', ){ - file{ $target_pdkignore_path: + profile::managed_file{ $target_pdkignore_path: + strategy => $strategy, content => file( "${module_name}/pupmod/_pdkignore.${target_module_name}", "${module_name}/pupmod/_pdkignore" diff --git a/modules/profile/manifests/pupmod/puppet_lint.pp b/modules/profile/manifests/pupmod/puppet_lint.pp index 045849e..7bb7390 100644 --- a/modules/profile/manifests/pupmod/puppet_lint.pp +++ b/modules/profile/manifests/pupmod/puppet_lint.pp @@ -1,9 +1,15 @@ # Manages .puppet-lint.rc +# +# @param strategy +# `enforce` (default): this file carries no externally-managed values, so +# its content is fully managed class profile::pupmod::puppet_lint( - Stdlib::Absolutepath $puppet_lint_rc_path = "${::repo_path}/.puppet-lint.rc", - Optional[String[1]] $target_module_name = $facts.dig('module_metadata','name'), + Stdlib::Absolutepath $puppet_lint_rc_path = "${::repo_path}/.puppet-lint.rc", + Optional[String[1]] $target_module_name = $facts.dig('module_metadata','name'), + Enum['enforce','bootstrap'] $strategy = 'enforce', ){ - file{ $puppet_lint_rc_path: + profile::managed_file{ $puppet_lint_rc_path: + strategy => $strategy, content => file( "${module_name}/pupmod/_puppet-lint.rc.${target_module_name}", "${module_name}/pupmod/_puppet-lint.rc", diff --git a/modules/profile/manifests/pupmod/rspec.pp b/modules/profile/manifests/pupmod/rspec.pp index 4567ac1..c6abe02 100644 --- a/modules/profile/manifests/pupmod/rspec.pp +++ b/modules/profile/manifests/pupmod/rspec.pp @@ -2,21 +2,27 @@ # @param rspec_path Path to .rspec # @param spec_helper_path Path to spec_helper.rb # @param target_module_name Target module name +# @param strategy +# `enforce` (default): these files carry no externally-managed values, so +# their content is fully managed class profile::pupmod::rspec ( # lint:ignore:top_scope_facts Stdlib::Absolutepath $rspec_path = "${::repo_path}/.rspec", Stdlib::Absolutepath $spec_helper_path = "${::repo_path}/spec/spec_helper.rb", # lint:endignore Optional[String[1]] $target_module_name = $facts.dig('module_metadata','name'), + Enum['enforce','bootstrap'] $strategy = 'enforce', ) { - file { $rspec_path: + profile::managed_file { $rspec_path: + strategy => $strategy, content => file( "${module_name}/pupmod/_rspec.${target_module_name}", "${module_name}/pupmod/_rspec", ), } - file { $spec_helper_path: + profile::managed_file { $spec_helper_path: + strategy => $strategy, content => epp( "${module_name}/pupmod/spec/spec_helper.rb.epp", ), diff --git a/scripts/ci-managed-file-contract.sh b/scripts/ci-managed-file-contract.sh new file mode 100755 index 0000000..78ddfa0 --- /dev/null +++ b/scripts/ci-managed-file-contract.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Contract test for profile::managed_file (simp/puppetsync#50): +# +# - strategy => 'bootstrap' must NEVER overwrite an existing file +# (this is the guarantee that keeps puppetsync from clobbering +# Renovate-managed values) +# - strategy => 'enforce' (and the default) must always overwrite +# - both strategies must create a missing file with the given content +# +# Requires bolt (openbolt) and the project's modules (./Rakefile install). +set -euo pipefail +cd "$(dirname "$0")/.." + +PUPPET="${PUPPET:-/opt/puppetlabs/bolt/bin/puppet}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +fail() { echo "FAIL: $1" >&2; exit 1; } +apply() { "$PUPPET" apply --modulepath modules:.modules -e "$1" >/dev/null; } + +printf 'custom\n' > "$WORK/bootstrap-existing.txt" +apply "profile::managed_file { '$WORK/bootstrap-existing.txt': content => \"baseline\n\", strategy => 'bootstrap' }" +[ "$(cat "$WORK/bootstrap-existing.txt")" = 'custom' ] \ + || fail "strategy => bootstrap overwrote an existing file" + +printf 'custom\n' > "$WORK/enforce-existing.txt" +apply "profile::managed_file { '$WORK/enforce-existing.txt': content => \"baseline\n\", strategy => 'enforce' }" +[ "$(cat "$WORK/enforce-existing.txt")" = 'baseline' ] \ + || fail "strategy => enforce did not overwrite an existing file" + +printf 'custom\n' > "$WORK/default-existing.txt" +apply "profile::managed_file { '$WORK/default-existing.txt': content => \"baseline\n\" }" +[ "$(cat "$WORK/default-existing.txt")" = 'baseline' ] \ + || fail "the default strategy is not enforce" + +apply "profile::managed_file { '$WORK/bootstrap-missing.txt': content => \"baseline\n\", strategy => 'bootstrap' }" +[ "$(cat "$WORK/bootstrap-missing.txt")" = 'baseline' ] \ + || fail "strategy => bootstrap did not create a missing file" + +echo 'PASS: managed_file contract'