From b5dfb6c306b31cf7d9765c7cde94b587df374186 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Fri, 29 Aug 2025 14:14:43 -0400 Subject: [PATCH 1/5] finalize makefiles --- resources/Makefile | 2 +- resources/Makefile-common | 42 ++++++ resources/Makefile-pattern | 184 -------------------------- resources/pattern.sh | 33 ++--- resources/values-secret.yaml.template | 18 ++- 5 files changed, 75 insertions(+), 204 deletions(-) create mode 100644 resources/Makefile-common delete mode 100644 resources/Makefile-pattern diff --git a/resources/Makefile b/resources/Makefile index abc130d..d47b9ff 100644 --- a/resources/Makefile +++ b/resources/Makefile @@ -2,4 +2,4 @@ # This Makefile includes the common pattern targets from Makefile-pattern # You can add custom targets above or below the include line -include Makefile-pattern +include Makefile-common diff --git a/resources/Makefile-common b/resources/Makefile-common new file mode 100644 index 0000000..c3aeb84 --- /dev/null +++ b/resources/Makefile-common @@ -0,0 +1,42 @@ +MAKEFLAGS += --no-print-directory +ANSIBLE_STDOUT_CALLBACK ?= null # null silences all ansible output. Override this with default, minimal, oneline, etc. when debugging. +ANSIBLE_RUN := ANSIBLE_STDOUT_CALLBACK=$(ANSIBLE_STDOUT_CALLBACK) ansible-playbook $(EXTRA_PLAYBOOK_OPTS) +DOCS_URL := https://validatedpatterns.io/blog/2025-08-25-new-common-makefile-structure/ + +.PHONY: help +help: ## Print this help message + @echo "For a complete guide to these targets and the available overrides, please visit $(DOCS_URL)" + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^(\s|[a-zA-Z_0-9-])+:.*?##/ { printf " \033[36m%-35s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Pattern Install Tasks +.PHONY: show +show: ## Shows the template that would be applied by the `make install` target + @$(ANSIBLE_RUN) rhvp.cluster_utils.show + +.PHONY: operator-deploy +operator-deploy operator-upgrade: ## Installs/updates the pattern on a cluster (DOES NOT load secrets) + @$(ANSIBLE_RUN) rhvp.cluster_utils.operator_deploy + +.PHONY: install +install: pattern-install ## Installs the pattern onto a cluster (Loads secrets as well if configured) + +.PHONY: pattern-install +pattern-install: + @$(ANSIBLE_RUN) rhvp.cluster_utils.install + +.PHONY: load-secrets +load-secrets: ## Loads secrets onto the cluster (unless explicitly disabled in values-global.yaml) + @$(ANSIBLE_RUN) rhvp.cluster_utils.load_secrets + +##@ Validation Tasks +.PHONY: validate-prereq +validate-prereq: ## verify pre-requisites + @$(ANSIBLE_RUN) rhvp.cluster_utils.validate_prereq + +.PHONY: validate-origin +validate-origin: ## verify the git origin is available + @$(ANSIBLE_RUN) rhvp.cluster_utils.validate_origin + +.PHONY: validate-cluster +validate-cluster: ## Do some cluster validations before installing + @$(ANSIBLE_RUN) rhvp.cluster_utils.validate_cluster diff --git a/resources/Makefile-pattern b/resources/Makefile-pattern deleted file mode 100644 index 9c41d4a..0000000 --- a/resources/Makefile-pattern +++ /dev/null @@ -1,184 +0,0 @@ -NAME ?= $(shell basename "`pwd`") - -ifneq ($(origin TARGET_SITE), undefined) - TARGET_SITE_OPT=--set main.clusterGroupName=$(TARGET_SITE) -endif - -# Set this to true if you want to skip any origin validation -DISABLE_VALIDATE_ORIGIN ?= false -ifeq ($(DISABLE_VALIDATE_ORIGIN),true) - VALIDATE_ORIGIN := -else - VALIDATE_ORIGIN := validate-origin -endif - -# This variable can be set in order to pass additional helm arguments from the -# the command line. I.e. we can set things without having to tweak values files -EXTRA_HELM_OPTS ?= - -# This variable can be set in order to pass additional ansible-playbook arguments from the -# the command line. I.e. we can set -vvv for more verbose logging -EXTRA_PLAYBOOK_OPTS ?= - -# INDEX_IMAGES=registry-proxy.engineering.redhat.com/rh-osbs/iib:394248 -# or -# INDEX_IMAGES=registry-proxy.engineering.redhat.com/rh-osbs/iib:394248,registry-proxy.engineering.redhat.com/rh-osbs/iib:394249 -INDEX_IMAGES ?= - -# git branch --show-current is also available as of git 2.22, but we will use this for compatibility -TARGET_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) - -# Default to the branch remote -TARGET_ORIGIN ?= $(shell git config branch.$(TARGET_BRANCH).remote) - -# This is to ensure that whether we start with a git@ or https:// URL, we end up with an https:// URL -# This is because we expect to use tokens for repo authentication as opposed to SSH keys -TARGET_REPO=$(shell git ls-remote --get-url --symref $(TARGET_ORIGIN) | sed -e 's/.*URL:[[:space:]]*//' -e 's%^git@%%' -e 's%^https://%%' -e 's%:%/%' -e 's%^%https://%') - -UUID_FILE ?= ~/.config/validated-patterns/pattern-uuid -UUID_HELM_OPTS ?= - -# --set values always take precedence over the contents of -f -ifneq ("$(wildcard $(UUID_FILE))","") - UUID := $(shell cat $(UUID_FILE)) - UUID_HELM_OPTS := --set main.analyticsUUID=$(UUID) -endif - -# Set the secret name *and* its namespace when deploying from private repositories -# The format of said secret is documented here: https://argo-cd.readthedocs.io/en/stable/operator-manual/declarative-setup/#repositories -TOKEN_SECRET ?= -TOKEN_NAMESPACE ?= - -ifeq ($(TOKEN_SECRET),) - HELM_OPTS=-f values-global.yaml --set main.git.repoURL="$(TARGET_REPO)" --set main.git.revision=$(TARGET_BRANCH) $(TARGET_SITE_OPT) $(UUID_HELM_OPTS) $(EXTRA_HELM_OPTS) -else - # When we are working with a private repository we do not escape the git URL as it might be using an ssh secret which does not use https:// - TARGET_CLEAN_REPO=$(shell git ls-remote --get-url --symref $(TARGET_ORIGIN)) - HELM_OPTS=-f values-global.yaml --set main.tokenSecret=$(TOKEN_SECRET) --set main.tokenSecretNamespace=$(TOKEN_NAMESPACE) --set main.git.repoURL="$(TARGET_CLEAN_REPO)" --set main.git.revision=$(TARGET_BRANCH) $(TARGET_SITE_OPT) $(UUID_HELM_OPTS) $(EXTRA_HELM_OPTS) -endif - -# Helm does the right thing and fetches all the tags and detects the newest one -PATTERN_INSTALL_CHART ?= oci://quay.io/hybridcloudpatterns/pattern-install - -.PHONY: default -default: help - -.PHONY: help -help: ## This help message - @echo "Pattern: $(NAME)" - @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^(\s|[a-zA-Z_0-9-])+:.*?##/ { printf " \033[36m%-35s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) - -.PHONY: install -# Dynamically read secretLoader.disabled from values-global.yaml to determine LOAD_SECRETS -# If secretLoader.disabled is true, LOAD_SECRETS should be false (secrets disabled) -# If secretLoader.disabled is false, LOAD_SECRETS should be true (secrets enabled) -LOAD_SECRETS := $(shell if [ -f values-global.yaml ]; then \ - YQ_OUTPUT=$$(yq -r '.global.secretLoader.disabled' values-global.yaml 2>/dev/null); \ - YQ_EXIT=$$?; \ - if [ $$YQ_EXIT -eq 0 ] && [ "$$YQ_OUTPUT" != "null" ]; then \ - DISABLED="$$YQ_OUTPUT"; \ - else \ - DISABLED="true"; \ - fi; \ - if [ "$$DISABLED" = "false" ]; then echo "true"; else echo "false"; fi; \ -else \ - echo "false"; \ -fi) -install: operator-deploy ## Install the pattern (LOAD_SECRETS determined by global.secretLoader.disabled in values-global.yaml) - @echo "Secrets configuration: global.secretLoader.disabled=$$(yq -r '.global.secretLoader.disabled // true' values-global.yaml 2>/dev/null || echo 'true'), LOAD_SECRETS=$(LOAD_SECRETS)" - @if [ "$(LOAD_SECRETS)" != "false" ]; then \ - echo "Loading secrets..."; \ - $(MAKE) load-secrets; \ - else \ - echo "Skipping secrets loading (disabled)"; \ - fi - @echo "Installed" - -.PHONY: operator-deploy -operator-deploy operator-upgrade: validate-prereq $(VALIDATE_ORIGIN) validate-cluster - @echo -n "Installing pattern: " - @RUNS=10; \ - WAIT=15; \ - for i in $$(seq 1 $$RUNS); do \ - exec 3>&1 4>&2; \ - OUT=$$( { helm template --include-crds --name-template $(NAME) $(PATTERN_INSTALL_CHART) $(HELM_OPTS) 2>&4 | oc apply -f- 2>&4 1>&3; } 4>&1 3>&1); \ - ret=$$?; \ - exec 3>&- 4>&-; \ - if [ $$ret -eq 0 ]; then \ - break; \ - else \ - echo -n "."; \ - sleep "$$WAIT"; \ - fi; \ - done; \ - if [ $$i -eq $$RUNS ]; then \ - echo "Installation failed [$$i/$$RUNS]. Error:"; \ - echo "$$OUT"; \ - exit 1; \ - fi - @echo "Done" - -.PHONY: validate-prereq -validate-prereq: - $(eval GLOBAL_PATTERN := $(shell yq -r .global.pattern values-global.yaml)) - @if [ $(NAME) != $(GLOBAL_PATTERN) ]; then\ - echo "";\ - echo "WARNING: folder directory is \"$(NAME)\" and global.pattern is set to \"$(GLOBAL_PATTERN)\"";\ - echo "this can create problems. Please make sure they are the same!";\ - echo "";\ - fi - @if [ ! -f /run/.containerenv ]; then\ - echo "Checking prerequisites:";\ - echo -n " Check for python-kubernetes: ";\ - if ! ansible -m ansible.builtin.command -a "{{ ansible_python_interpreter }} -c 'import kubernetes'" localhost > /dev/null 2>&1; then echo "Not found"; exit 1; fi;\ - echo "OK";\ - echo -n " Check for kubernetes.core collection: ";\ - if ! ansible-galaxy collection list | grep kubernetes.core > /dev/null 2>&1; then echo "Not found"; exit 1; fi;\ - echo "OK";\ - else\ - if [ -f values-global.yaml ]; then\ - OUT=`yq -r '.main.multiSourceConfig.enabled // (.main.multiSourceConfig.enabled = "false")' values-global.yaml`;\ - if [ "$${OUT,,}" = "false" ]; then\ - echo "You must set \".main.multiSourceConfig.enabled: true\" in your 'values-global.yaml' file";\ - echo "because your common subfolder is the slimmed down version with no helm charts in it";\ - exit 1;\ - fi;\ - fi;\ - fi - -.PHONY: validate-origin -validate-origin: - @echo "Checking repository:" - $(eval UPSTREAMURL := $(shell yq -r '.main.git.repoUpstreamURL // (.main.git.repoUpstreamURL = "")' values-global.yaml)) - @if [ -z "$(UPSTREAMURL)" ]; then\ - echo -n " $(TARGET_REPO) - branch '$(TARGET_BRANCH)': ";\ - git ls-remote --exit-code --heads $(TARGET_REPO) $(TARGET_BRANCH) >/dev/null &&\ - echo "OK" || (echo "NOT FOUND"; exit 1);\ - else\ - echo "Upstream URL set to: $(UPSTREAMURL)";\ - echo -n " $(UPSTREAMURL) - branch '$(TARGET_BRANCH)': ";\ - git ls-remote --exit-code --heads $(UPSTREAMURL) $(TARGET_BRANCH) >/dev/null &&\ - echo "OK" || (echo "NOT FOUND"; exit 1);\ - fi - -.PHONY: validate-cluster -validate-cluster: - @echo "Checking cluster:" - @echo -n " cluster-info: " - @oc cluster-info >/dev/null && echo "OK" || (echo "Error"; exit 1) - @echo -n " storageclass: " - @if [ `oc get storageclass -o go-template='{{printf "%d\n" (len .items)}}'` -eq 0 ]; then\ - echo "WARNING: No storageclass found";\ - else\ - echo "OK";\ - fi - -.PHONY: load-secrets -load-secrets: ## Load secrets into the configured backend - @PATTERN_NAME=$(NAME); \ - PATTERN_DIR=$$(pwd); \ - BACKEND=$$(yq '.global.secretStore.backend' "$$PATTERN_DIR/values-global.yaml" 2>/dev/null); \ - if [ -z "$$BACKEND" -o "$$BACKEND" = "null" ]; then \ - BACKEND="vault"; \ - fi; \ - ansible-playbook -e pattern_name="$$PATTERN_NAME" -e pattern_dir="$$PATTERN_DIR" -e secrets_backing_store="$$BACKEND" $(EXTRA_PLAYBOOK_OPTS) "rhvp.cluster_utils.process_secrets" diff --git a/resources/pattern.sh b/resources/pattern.sh index ff6b2bb..54ed86f 100755 --- a/resources/pattern.sh +++ b/resources/pattern.sh @@ -9,7 +9,7 @@ function version { } if [ -z "$PATTERN_UTILITY_CONTAINER" ]; then - PATTERN_UTILITY_CONTAINER="quay.io/hybridcloudpatterns/utility-container" + PATTERN_UTILITY_CONTAINER="quay.io/validatedpatterns/utility-container" fi # If PATTERN_DISCONNECTED_HOME is set it will be used to populate both PATTERN_UTILITY_CONTAINER # and PATTERN_INSTALL_CHART automatically @@ -84,25 +84,28 @@ fi podman run -it --rm --pull=newer \ --security-opt label=disable \ + -e ANSIBLE_STDOUT_CALLBACK \ + -e DISABLE_VALIDATE_ORIGIN \ -e EXTRA_HELM_OPTS \ -e EXTRA_PLAYBOOK_OPTS \ - -e TARGET_ORIGIN \ - -e TARGET_SITE \ - -e TARGET_BRANCH \ - -e NAME \ - -e TOKEN_SECRET \ - -e TOKEN_NAMESPACE \ - -e VALUES_SECRET \ - -e KUBECONFIG \ - -e PATTERN_INSTALL_CHART \ - -e PATTERN_DISCONNECTED_HOME \ - -e DISABLE_VALIDATE_ORIGIN \ -e K8S_AUTH_HOST \ - -e K8S_AUTH_VERIFY_SSL \ - -e K8S_AUTH_SSL_CA_CERT \ - -e K8S_AUTH_USERNAME \ -e K8S_AUTH_PASSWORD \ + -e K8S_AUTH_SSL_CA_CERT \ -e K8S_AUTH_TOKEN \ + -e K8S_AUTH_USERNAME \ + -e K8S_AUTH_VERIFY_SSL \ + -e KUBECONFIG \ + -e PATTERN_DIR \ + -e PATTERN_DISCONNECTED_HOME \ + -e PATTERN_INSTALL_CHART \ + -e PATTERN_NAME \ + -e TARGET_BRANCH \ + -e TARGET_CLUSTERGROUP \ + -e TARGET_ORIGIN \ + -e TOKEN_NAMESPACE \ + -e TOKEN_SECRET \ + -e UUID_FILE \ + -e VALUES_SECRET \ ${PKI_HOST_MOUNT_ARGS} \ -v "${HOME}":"${HOME}" \ -v "${HOME}":/pattern-home \ diff --git a/resources/values-secret.yaml.template b/resources/values-secret.yaml.template index a981c5e..2104801 100644 --- a/resources/values-secret.yaml.template +++ b/resources/values-secret.yaml.template @@ -1,8 +1,18 @@ -# A more formal description of this format can be found here: -# https://github.com/validatedpatterns/rhvp.cluster_utils/tree/main/roles/vault_utils#values-secret-file-format - -version: "2.0" # Ideally you NEVER COMMIT THESE VALUES TO GIT (although if all passwords are # automatically generated inside the vault this should not really matter) +# If this is your first time using secrets in Validated Patterns, please check out the following links for a guide: +# https://validatedpatterns.io/learn/secrets-management-in-the-validated-patterns-framework/ +# https://validatedpatterns.io/learn/getting-started-secret-management/#_adding_a_secret_to_the_multicloud_gitops_pattern + +version: "2.0" + secrets: [] + # - name: mysecret + # vaultPrefixes: + # - global + # fields: + # - name: foo + # onMissingValue: generate + # - name: bar + # onMissingValue: generate From e11c1f921fe6df27d1792fced6a39e52399f7966 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Fri, 29 Aug 2025 14:21:12 -0400 Subject: [PATCH 2/5] fix tests and references to Makefile-common --- README.md | 6 +++--- resources/Makefile | 2 +- src/cmd/init.go | 10 ++++----- test/initial_makefile_pattern_overwrite | 2 +- test/integration_test.sh | 28 ++++++++++++------------- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a1520f9..3f91d47 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,8 @@ Running `patternizer init` creates the following: * `values-global.yaml`: Global pattern configuration. * `values-.yaml`: Cluster group-specific values. * `pattern.sh`: A utility script for common pattern operations (`install`, `upgrade`, etc.). - * `Makefile`: A simple Makefile that includes `Makefile-pattern`. - * `Makefile-pattern`: The core Makefile with all pattern-related targets. + * `Makefile`: A simple Makefile that includes `Makefile-common`. + * `Makefile-common`: The core Makefile with all pattern-related targets. Using the `--with-secrets` flag additionally creates: @@ -176,7 +176,7 @@ Patternizer has a comprehensive test suite to ensure stability and correctness. 2. **Init with Secrets:** Ensures secrets-related applications and files are correctly added. 3. **Configuration Preservation:** Verifies that existing custom values are preserved when the tool is re-run. 4. **Sequential Execution:** Tests running `init` and then `init --with-secrets` to ensure a clean upgrade. - 5. **Selective File Overwriting:** Confirms that running `init` on a repository with pre-existing custom files correctly **merges YAML configurations**, preserves user-modified files (like `Makefile` and `values-secret.yaml.template`), and only overwrites essential, generated scripts (`pattern.sh`, `Makefile-pattern`). + 5. **Selective File Overwriting:** Confirms that running `init` on a repository with pre-existing custom files correctly **merges YAML configurations**, preserves user-modified files (like `Makefile` and `values-secret.yaml.template`), and only overwrites essential, generated scripts (`pattern.sh`, `Makefile-common`). 6. **Mixed State Handling:** Validates that the tool correctly initializes a partially-configured repository, **creating files that are missing** while leaving existing ones untouched. ### Architecture diff --git a/resources/Makefile b/resources/Makefile index d47b9ff..b6b36c3 100644 --- a/resources/Makefile +++ b/resources/Makefile @@ -1,5 +1,5 @@ # Generated by patternizer -# This Makefile includes the common pattern targets from Makefile-pattern +# This Makefile includes the common pattern targets from Makefile-common # You can add custom targets above or below the include line include Makefile-common diff --git a/src/cmd/init.go b/src/cmd/init.go index f010baf..70b9cda 100644 --- a/src/cmd/init.go +++ b/src/cmd/init.go @@ -47,14 +47,14 @@ func runInit(withSecrets bool) error { return fmt.Errorf("error copying pattern.sh: %w", err) } - // Always copy Makefile-pattern to the pattern repo - makefilePatternSrc := filepath.Join(resourcesDir, "Makefile-pattern") - makefilePatternDst := filepath.Join(repoRoot, "Makefile-pattern") + // Always copy Makefile-common to the pattern repo + makefilePatternSrc := filepath.Join(resourcesDir, "Makefile-common") + makefilePatternDst := filepath.Join(repoRoot, "Makefile-common") if err := fileutils.CopyFile(makefilePatternSrc, makefilePatternDst); err != nil { - return fmt.Errorf("error copying Makefile-pattern: %w", err) + return fmt.Errorf("error copying Makefile-common: %w", err) } - // Create a simple Makefile that includes Makefile-pattern (only if it doesn't exist) + // Create a simple Makefile that includes Makefile-common (only if it doesn't exist) makefileSrc := filepath.Join(resourcesDir, "Makefile") makefileDst := filepath.Join(repoRoot, "Makefile") if _, err := os.Stat(makefileDst); os.IsNotExist(err) { diff --git a/test/initial_makefile_pattern_overwrite b/test/initial_makefile_pattern_overwrite index 610d40b..1f6cb3c 100644 --- a/test/initial_makefile_pattern_overwrite +++ b/test/initial_makefile_pattern_overwrite @@ -1,4 +1,4 @@ -# Old Makefile-pattern content +# Old Makefile-common content # This SHOULD be overwritten .PHONY: old-target diff --git a/test/integration_test.sh b/test/integration_test.sh index d5f5602..e27cddd 100755 --- a/test/integration_test.sh +++ b/test/integration_test.sh @@ -68,7 +68,7 @@ INITIAL_VALUES_SECRET_TEMPLATE_OVERWRITE="$REPO_ROOT/test/initial_values_secret_ # Set paths for expected resource files EXPECTED_MAKEFILE="$PATTERNIZER_RESOURCES_DIR/Makefile" -EXPECTED_MAKEFILE_PATTERN="$PATTERNIZER_RESOURCES_DIR/Makefile-pattern" +EXPECTED_MAKEFILE_COMMON="$PATTERNIZER_RESOURCES_DIR/Makefile-common" EXPECTED_PATTERN_SH="$PATTERNIZER_RESOURCES_DIR/pattern.sh" EXPECTED_VALUES_SECRET_TEMPLATE="$PATTERNIZER_RESOURCES_DIR/values-secret.yaml.template" @@ -208,8 +208,8 @@ fi # Test 1.4: Check Makefile has exact expected content compare_files "$EXPECTED_MAKEFILE" "Makefile" "Makefile has expected content (init without secrets)" -# Test 1.5: Check Makefile-pattern has exact expected content -compare_files "$EXPECTED_MAKEFILE_PATTERN" "Makefile-pattern" "Makefile-pattern has expected content (init without secrets)" +# Test 1.5: Check Makefile-common has exact expected content +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common has expected content (init without secrets)" test_pass "=== Test 1: Basic initialization PASSED ===" @@ -248,8 +248,8 @@ compare_files "$EXPECTED_VALUES_SECRET_TEMPLATE" "values-secret.yaml.template" " # Test 2.5: Check Makefile has exact expected content compare_files "$EXPECTED_MAKEFILE" "Makefile" "Makefile has expected content (init with secrets)" -# Test 2.6: Check Makefile-pattern has exact expected content -compare_files "$EXPECTED_MAKEFILE_PATTERN" "Makefile-pattern" "Makefile-pattern has expected content (init with secrets)" +# Test 2.6: Check Makefile-common has exact expected content +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common has expected content (init with secrets)" test_pass "=== Test 2: Initialization with secrets PASSED ===" @@ -291,8 +291,8 @@ compare_files "$EXPECTED_VALUES_SECRET_TEMPLATE" "values-secret.yaml.template" " # Test 3.5: Check Makefile has exact expected content compare_files "$EXPECTED_MAKEFILE" "Makefile" "Makefile has expected content (custom names with secrets)" -# Test 3.6: Check Makefile-pattern has exact expected content -compare_files "$EXPECTED_MAKEFILE_PATTERN" "Makefile-pattern" "Makefile-pattern has expected content (custom names with secrets)" +# Test 3.6: Check Makefile-common has exact expected content +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common has expected content (custom names with secrets)" test_pass "=== Test 3: Custom pattern and cluster group names (with secrets) PASSED ===" @@ -335,8 +335,8 @@ compare_files "$EXPECTED_VALUES_SECRET_TEMPLATE" "values-secret.yaml.template" " # Test 4.5: Check Makefile has exact expected content compare_files "$EXPECTED_MAKEFILE" "Makefile" "Makefile has expected content (sequential execution)" -# Test 4.6: Check Makefile-pattern has exact expected content -compare_files "$EXPECTED_MAKEFILE_PATTERN" "Makefile-pattern" "Makefile-pattern has expected content (sequential execution)" +# Test 4.6: Check Makefile-common has exact expected content +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common has expected content (sequential execution)" test_pass "=== Test 4: Sequential execution PASSED ===" @@ -357,7 +357,7 @@ test_header "Setting up existing custom files..." cp "$INITIAL_VALUES_GLOBAL_OVERWRITE" "values-global.yaml" cp "$INITIAL_VALUES_CUSTOM_CLUSTER_OVERWRITE" "values-custom-cluster.yaml" cp "$INITIAL_MAKEFILE_OVERWRITE" "Makefile" -cp "$INITIAL_MAKEFILE_PATTERN_OVERWRITE" "Makefile-pattern" +cp "$INITIAL_MAKEFILE_PATTERN_OVERWRITE" "Makefile-common" cp "$INITIAL_PATTERN_SH_OVERWRITE" "pattern.sh" cp "$INITIAL_VALUES_SECRET_TEMPLATE_OVERWRITE" "values-secret.yaml.template" @@ -378,8 +378,8 @@ compare_yaml "$EXPECTED_VALUES_CUSTOM_CLUSTER_OVERWRITE" "values-custom-cluster. # Test 5.3: Makefile should NOT be overwritten compare_files "$INITIAL_MAKEFILE_OVERWRITE" "Makefile" "Makefile was not overwritten (content preserved)" -# Test 5.4: Makefile-pattern SHOULD be overwritten with exact expected content -compare_files "$EXPECTED_MAKEFILE_PATTERN" "Makefile-pattern" "Makefile-pattern was overwritten with correct content" +# Test 5.4: Makefile-common SHOULD be overwritten with exact expected content +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common was overwritten with correct content" # Test 5.5: pattern.sh SHOULD be overwritten with exact expected content and be executable compare_files "$EXPECTED_PATTERN_SH" "pattern.sh" "pattern.sh was overwritten with correct content" @@ -416,7 +416,7 @@ cp "$INITIAL_MAKEFILE_OVERWRITE" "Makefile" cp "$INITIAL_VALUES_SECRET_TEMPLATE_OVERWRITE" "values-secret.yaml.template" # Don't create values-global.yaml, values-prod.yaml (should be created) -# Don't create Makefile-pattern, pattern.sh (should be created/overwritten) +# Don't create Makefile-common, pattern.sh (should be created/overwritten) test_header "Running patternizer init --with-secrets on mixed repository..." "$PATTERNIZER_BINARY" init --with-secrets @@ -427,7 +427,7 @@ test_header "Verifying mixed overwrite behavior..." check_file_exists "values-global.yaml" "values-global.yaml created when missing" check_file_exists "values-prod.yaml" "values-prod.yaml created when missing" -compare_files "$EXPECTED_MAKEFILE_PATTERN" "Makefile-pattern" "Makefile-pattern created with correct content" +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common created with correct content" compare_files "$EXPECTED_PATTERN_SH" "pattern.sh" "pattern.sh created with correct content" From aa6e6961e7d77758253cdd5ff42b2ec8f573cfa3 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Fri, 29 Aug 2025 15:24:50 -0400 Subject: [PATCH 3/5] add upgrade command --- .github/workflows/ci.yaml | 2 +- README.md | 36 ++++- src/cmd/root.go | 19 +++ src/cmd/upgrade.go | 82 ++++++++++++ src/internal/fileutils/fileutils.go | 58 ++++++++ src/internal/pattern/pattern.go | 20 +-- test/integration_test.sh | 200 ++++++++++++++++++++++------ 7 files changed, 354 insertions(+), 63 deletions(-) create mode 100644 src/cmd/upgrade.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9efb501..cbf5756 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ on: - main env: - IMAGE_NAME: quay.io/hybridcloudpatterns/patternizer + IMAGE_NAME: quay.io/validatedpatterns/patternizer GO_VERSION: '1.24' jobs: diff --git a/README.md b/README.md index 3f91d47..a2dc3b3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Patternizer -[![Quay Repository](https://img.shields.io/badge/Quay.io-patternizer-blue?logo=quay)](https://quay.io/repository/hybridcloudpatterns/patternizer) +[![Quay Repository](https://img.shields.io/badge/Quay.io-patternizer-blue?logo=quay)](https://quay.io/repository/validatedpatterns/patternizer) [![CI Pipeline](https://github.com/validatedpatterns/patternizer/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/validatedpatterns/patternizer/actions/workflows/ci.yaml) **Patternizer** is a command-line tool that bootstraps a Git repository containing Helm charts into a ready-to-use Validated Pattern. It automatically generates the necessary scaffolding, configuration files, and utility scripts, so you can get your pattern up and running in minutes. @@ -15,6 +15,7 @@ - [Container Usage (Recommended)](#container-usage-recommended) - [**Initialize without secrets:**](#initialize-without-secrets) - [**Initialize with secrets support:**](#initialize-with-secrets-support) + - [**Upgrade an existing pattern repository:**](#upgrade-an-existing-pattern-repository) - [Understanding Secrets Management](#understanding-secrets-management) - [Generated Files](#generated-files) - [Development \& Contributing](#development--contributing) @@ -33,6 +34,7 @@ - 🔍 **Auto-discovery** of Helm charts and Git repository metadata - 🔐 **Optional secrets integration** with Vault and External Secrets - 🏗️ **Makefile-driven** utility scripts for easy pattern management + - ♻️ **Upgrade command** to refresh existing pattern repositories to the latest common structure ## Quick Start @@ -47,7 +49,7 @@ Navigate to your repository's root directory and run the initialization command: ```bash # In the root of your pattern-repo -podman run -v "$PWD:/repo:z" quay.io/hybridcloudpatterns/patternizer init +podman run -v "$PWD:/repo:z" quay.io/validatedpatterns/patternizer init ``` This single command will generate all the necessary files to turn your repository into a Validated Pattern. @@ -65,7 +67,7 @@ This single command will generate all the necessary files to turn your repositor 2. **Initialize the pattern using Patternizer:** ```bash - podman run -v "$PWD:/repo:z" quay.io/hybridcloudpatterns/patternizer init + podman run -v "$PWD:/repo:z" quay.io/validatedpatterns/patternizer init ``` 3. **Review, commit, and push the generated files:** @@ -92,15 +94,39 @@ Using the prebuilt container is the easiest way to run Patternizer, as it requir #### **Initialize without secrets:** ```bash -podman run -v "$PWD:/repo:z" quay.io/hybridcloudpatterns/patternizer init +podman run -v "$PWD:/repo:z" quay.io/validatedpatterns/patternizer init ``` #### **Initialize with secrets support:** ```bash -podman run -v "$PWD:/repo:z" quay.io/hybridcloudpatterns/patternizer init --with-secrets +podman run -v "$PWD:/repo:z" quay.io/validatedpatterns/patternizer init --with-secrets ``` +#### **Upgrade an existing pattern repository:** + +Use this to migrate or refresh an existing pattern repo to the latest common structure and scripts. + +```bash +# Refresh common assets, keep your Makefile unless it lacks the include +podman run -v "$PWD:/repo:z" quay.io/validatedpatterns/patternizer upgrade + +# Replace your Makefile with the default from Patternizer +podman run -v "$PWD:/repo:z" quay.io/validatedpatterns/patternizer upgrade --replace-makefile +``` + +What upgrade does: + +- Removes the `common/` directory if it exists +- Removes `./pattern.sh` if it exists (symlink or file) +- Copies `resources/Makefile-common` and `resources/pattern.sh` into the repo root +- Makefile handling: + - If `--replace-makefile` is set: copies the default `Makefile` into the repo root (overwriting any existing one) + - If not set: + - If no `Makefile` exists: copies the default `Makefile` + - If a `Makefile` exists and already contains `include Makefile-common` anywhere: leaves it unchanged + - Otherwise: prepends `include Makefile-common` to the first line so your existing targets are preserved + ### Understanding Secrets Management You can start simple and add secrets management later. diff --git a/src/cmd/root.go b/src/cmd/root.go index 74dd504..350cbd3 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -10,6 +10,7 @@ import ( // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { var withSecrets bool + var replaceMakefile bool var rootCmd = &cobra.Command{ Use: "patternizer", @@ -40,6 +41,24 @@ configures the pattern.sh script for secrets usage.`, rootCmd.AddCommand(initCmd) + var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Short: "Upgrade an existing pattern repository", + Long: `Upgrade an existing pattern repository by refreshing common assets. + +This will remove the legacy common/ directory and pattern.sh symlink if present, +copy updated Makefile-common and pattern.sh, and optionally replace or update the Makefile.`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && args[0] == "help" { + return cmd.Help() + } + return runUpgrade(replaceMakefile) + }, + } + + upgradeCmd.Flags().BoolVar(&replaceMakefile, "replace-makefile", false, "Replace the existing Makefile with the default") + rootCmd.AddCommand(upgradeCmd) + // Hide the completion command from help since this is primarily used in containers rootCmd.CompletionOptions.HiddenDefaultCmd = true diff --git a/src/cmd/upgrade.go b/src/cmd/upgrade.go new file mode 100644 index 0000000..be7277e --- /dev/null +++ b/src/cmd/upgrade.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/dminnear-rh/patternizer/internal/fileutils" + "github.com/dminnear-rh/patternizer/internal/pattern" +) + +// runUpgrade handles the upgrade logic for the upgrade command. +func runUpgrade(replaceMakefile bool) error { + // Determine repository root + _, repoRoot, err := pattern.GetPatternNameAndRepoRoot() + if err != nil { + return fmt.Errorf("error getting pattern information: %w", err) + } + + // Paths + commonDirPath := filepath.Join(repoRoot, "common") + patternShPath := filepath.Join(repoRoot, "pattern.sh") + + // Remove common/ directory if it exists + if err := fileutils.RemovePathIfExists(commonDirPath); err != nil { + return fmt.Errorf("error removing common directory: %w", err) + } + + // Remove ./pattern.sh if it exists (symlink or file) + if err := fileutils.RemovePathIfExists(patternShPath); err != nil { + return fmt.Errorf("error removing pattern.sh: %w", err) + } + + // Copy resources into repo root + resourcesDir, err := fileutils.GetResourcesPath() + if err != nil { + return fmt.Errorf("error getting resource path: %w", err) + } + + // Copy pattern.sh + if err := fileutils.CopyFile(filepath.Join(resourcesDir, "pattern.sh"), patternShPath); err != nil { + return fmt.Errorf("error copying pattern.sh: %w", err) + } + + // Copy Makefile-common + if err := fileutils.CopyFile(filepath.Join(resourcesDir, "Makefile-common"), filepath.Join(repoRoot, "Makefile-common")); err != nil { + return fmt.Errorf("error copying Makefile-common: %w", err) + } + + // Makefile handling + makefileSrc := filepath.Join(resourcesDir, "Makefile") + makefileDst := filepath.Join(repoRoot, "Makefile") + + if replaceMakefile { + if err := fileutils.CopyFile(makefileSrc, makefileDst); err != nil { + return fmt.Errorf("error replacing Makefile: %w", err) + } + } else { + // If Makefile doesn't exist, copy it + if _, err := os.Stat(makefileDst); os.IsNotExist(err) { + if err := fileutils.CopyFile(makefileSrc, makefileDst); err != nil { + return fmt.Errorf("error copying Makefile: %w", err) + } + } else if err == nil { + // If Makefile exists, check for include and prepend if missing + hasInclude, err := fileutils.FileContainsIncludeMakefileCommon(makefileDst) + if err != nil { + return fmt.Errorf("error checking Makefile for include: %w", err) + } + if !hasInclude { + if err := fileutils.PrependLineToFile(makefileDst, "include Makefile-common"); err != nil { + return fmt.Errorf("error updating Makefile: %w", err) + } + } + } else { + return fmt.Errorf("error accessing Makefile: %w", err) + } + } + + fmt.Printf("Successfully upgraded pattern repository in %s\n", repoRoot) + return nil +} diff --git a/src/internal/fileutils/fileutils.go b/src/internal/fileutils/fileutils.go index adc8246..d35815f 100644 --- a/src/internal/fileutils/fileutils.go +++ b/src/internal/fileutils/fileutils.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "strings" ) // CopyFile copies a file from src to dst. If dst already exists, it will be overwritten. @@ -72,3 +73,60 @@ func GetResourcesPath() (path string, err error) { // Error out if the resources directory is not found return "", fmt.Errorf("PATTERNIZER_RESOURCES_DIR environment variable is not set") } + +// RemovePathIfExists removes a file, directory, or symlink at the given path if it exists. +// It does nothing if the path does not exist. +func RemovePathIfExists(targetPath string) error { + if targetPath == "" { + return nil + } + info, err := os.Lstat(targetPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + if info.IsDir() { + return os.RemoveAll(targetPath) + } + + return os.Remove(targetPath) +} + +// FileContainsIncludeMakefileCommon checks if a Makefile already contains an include Makefile-common line. +func FileContainsIncludeMakefileCommon(makefilePath string) (bool, error) { + data, err := os.ReadFile(makefilePath) + if err != nil { + return false, err + } + contents := string(data) + // We keep this simple to avoid regex: look for lines with 'include' and 'Makefile-common' + for _, line := range strings.Split(contents, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, "include") && strings.Contains(trimmed, "Makefile-common") { + return true, nil + } + } + return false, nil +} + +// PrependLineToFile prepends a line to a file, preserving existing permissions. +func PrependLineToFile(filePath, line string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + + mode := os.FileMode(0o644) + if info, statErr := os.Stat(filePath); statErr == nil { + mode = info.Mode() + } + + newContents := []byte(line + "\n" + string(data)) + return os.WriteFile(filePath, newContents, mode) +} diff --git a/src/internal/pattern/pattern.go b/src/internal/pattern/pattern.go index b1711b6..6a3e1f2 100644 --- a/src/internal/pattern/pattern.go +++ b/src/internal/pattern/pattern.go @@ -3,7 +3,6 @@ package pattern import ( "fmt" "os" - "os/exec" "path/filepath" "strings" @@ -22,23 +21,8 @@ func GetPatternNameAndRepoRoot() (patternName, repoRoot string, err error) { return "", "", fmt.Errorf("failed to get current directory: %w", err) } - // Try to get the remote URL using git command - cmd := exec.Command("git", "remote", "get-url", "origin") - cmd.Dir = repoRoot - output, err := cmd.Output() - if err != nil { - // If we can't get the remote URL, use the directory name as pattern name - patternName = filepath.Base(repoRoot) - return patternName, repoRoot, nil - } - - // Extract pattern name from the remote URL - remoteURL := strings.TrimSpace(string(output)) - patternName, err = extractPatternNameFromURL(remoteURL) - if err != nil { - return "", "", fmt.Errorf("failed to extract pattern name from git remote URL '%s': %w", remoteURL, err) - } - + // Use the basename as the pattern name + patternName = filepath.Base(repoRoot) return patternName, repoRoot, nil } diff --git a/test/integration_test.sh b/test/integration_test.sh index e27cddd..d4d17f5 100755 --- a/test/integration_test.sh +++ b/test/integration_test.sh @@ -17,6 +17,13 @@ TEST_DIR_CUSTOM="/tmp/patternizer-integration-test-custom" TEST_DIR_SEQUENTIAL="/tmp/patternizer-integration-test-sequential" TEST_DIR_OVERWRITE="/tmp/patternizer-integration-test-overwrite" TEST_DIR_MIXED="/tmp/patternizer-integration-test-mixed" +TEST_DIR_UPGRADE="/tmp/patternizer-integration-test-upgrade" +TEST_DIR_UPGRADE_INCLUDE="/tmp/patternizer-integration-test-upgrade-include" +TEST_DIR_UPGRADE_REPLACE="/tmp/patternizer-integration-test-upgrade-replace" +TEST_DIR_UPGRADE_NOMAKEFILE="/tmp/patternizer-integration-test-upgrade-nomakefile" +REPO_NAME=$(basename -s .git "$TEST_REPO_URL") +SHARED_CLONE_PARENT="/tmp/patternizer-shared-clone" +SHARED_REPO_DIR="$SHARED_CLONE_PARENT/$REPO_NAME" echo -e "${YELLOW}Starting patternizer integration tests...${NC}" @@ -39,6 +46,23 @@ fi if [ -d "$TEST_DIR_MIXED" ]; then rm -rf "$TEST_DIR_MIXED" fi +if [ -d "$TEST_DIR_UPGRADE" ]; then + rm -rf "$TEST_DIR_UPGRADE" +fi +if [ -d "$TEST_DIR_UPGRADE_INCLUDE" ]; then + rm -rf "$TEST_DIR_UPGRADE_INCLUDE" +fi +if [ -d "$TEST_DIR_UPGRADE_REPLACE" ]; then + rm -rf "$TEST_DIR_UPGRADE_REPLACE" +fi +if [ -d "$TEST_DIR_UPGRADE_NOMAKEFILE" ]; then + rm -rf "$TEST_DIR_UPGRADE_NOMAKEFILE" +fi + +# Clean up any previous shared clone +if [ -d "$SHARED_CLONE_PARENT" ]; then + rm -rf "$SHARED_CLONE_PARENT" +fi # Convert PATTERNIZER_BINARY to absolute path before changing directories PATTERNIZER_BINARY=$(realpath "$PATTERNIZER_BINARY") @@ -78,6 +102,24 @@ if [ ! -x "$PATTERNIZER_BINARY" ]; then exit 1 fi +# Perform a single shallow clone of the source repository into a shared location +mkdir -p "$SHARED_CLONE_PARENT" +cd "$SHARED_CLONE_PARENT" +git clone --depth 1 "$TEST_REPO_URL" +cd "$REPO_ROOT" + +# Helper to prepare a test repository copy and cd into it +prepare_and_enter_repo() { + local dest_dir="$1" + local header_msg="$2" + test_header "$header_msg" + cd "$REPO_ROOT" + mkdir -p "$dest_dir" + rm -rf "${dest_dir:?}/${REPO_NAME:?}" + cp -a "$SHARED_REPO_DIR" "$dest_dir/" + cd "$dest_dir/$REPO_NAME" +} + # Function to compare YAML files (ignoring whitespace differences) compare_yaml() { local expected_file="$1" @@ -180,11 +222,7 @@ check_file_exists() { # # Test 1: Basic initialization (without secrets) # -test_header "=== Test 1: Basic initialization (without secrets) ===" - -test_header "Cloning test repository..." -git clone "$TEST_REPO_URL" "$TEST_DIR" -cd "$TEST_DIR" +prepare_and_enter_repo "$TEST_DIR" "=== Test 1: Basic initialization (without secrets) ===" test_header "Running patternizer init..." "$PATTERNIZER_BINARY" init @@ -216,12 +254,7 @@ test_pass "=== Test 1: Basic initialization PASSED ===" # # Test 2: Initialization with secrets # -test_header "=== Test 2: Initialization with secrets ===" - -cd "$REPO_ROOT" # Go back to repo root -test_header "Cloning test repository for secrets test..." -git clone "$TEST_REPO_URL" "$TEST_DIR_SECRETS" -cd "$TEST_DIR_SECRETS" +prepare_and_enter_repo "$TEST_DIR_SECRETS" "=== Test 2: Initialization with secrets ===" test_header "Running patternizer init --with-secrets..." "$PATTERNIZER_BINARY" init --with-secrets @@ -256,12 +289,7 @@ test_pass "=== Test 2: Initialization with secrets PASSED ===" # # Test 3: Custom pattern and cluster group names (merging test with secrets) # -test_header "=== Test 3: Custom pattern and cluster group names (with secrets) ===" - -cd "$REPO_ROOT" # Go back to repo root -test_header "Cloning test repository for custom names test..." -git clone "$TEST_REPO_URL" "$TEST_DIR_CUSTOM" -cd "$TEST_DIR_CUSTOM" +prepare_and_enter_repo "$TEST_DIR_CUSTOM" "=== Test 3: Custom pattern and cluster group names (with secrets) ===" test_header "Setting up initial values-global.yaml with custom names..." cp "$INITIAL_VALUES_GLOBAL_CUSTOM" "values-global.yaml" @@ -299,13 +327,7 @@ test_pass "=== Test 3: Custom pattern and cluster group names (with secrets) PAS # # Test 4: Sequential execution (init followed by init --with-secrets) # -test_header "=== Test 4: Sequential execution (init + init --with-secrets) ===" - -cd "$REPO_ROOT" # Go back to repo root - -test_header "Cloning test repository for sequential test..." -git clone "$TEST_REPO_URL" "$TEST_DIR_SEQUENTIAL" -cd "$TEST_DIR_SEQUENTIAL" +prepare_and_enter_repo "$TEST_DIR_SEQUENTIAL" "=== Test 4: Sequential execution (init + init --with-secrets) ===" test_header "Running patternizer init (first)..." "$PATTERNIZER_BINARY" init @@ -343,13 +365,7 @@ test_pass "=== Test 4: Sequential execution PASSED ===" # # Test 5: File overwrite behavior with existing custom files # -test_header "=== Test 5: File overwrite behavior with existing custom files ===" - -cd "$REPO_ROOT" # Go back to repo root - -test_header "Cloning test repository for overwrite behavior test..." -git clone "$TEST_REPO_URL" "$TEST_DIR_OVERWRITE" -cd "$TEST_DIR_OVERWRITE" +prepare_and_enter_repo "$TEST_DIR_OVERWRITE" "=== Test 5: File overwrite behavior with existing custom files ===" test_header "Setting up existing custom files..." @@ -399,13 +415,7 @@ test_pass "=== Test 5: File overwrite behavior PASSED ===" # # Test 6: Mixed file overwrite behavior (some files exist, some don't) # -test_header "=== Test 6: Mixed file overwrite behavior ===" - -cd "$REPO_ROOT" # Go back to repo root - -test_header "Cloning test repository for mixed scenario..." -git clone "$TEST_REPO_URL" "$TEST_DIR_MIXED" -cd "$TEST_DIR_MIXED" +prepare_and_enter_repo "$TEST_DIR_MIXED" "=== Test 6: Mixed file overwrite behavior ===" test_header "Setting up partial existing files..." @@ -445,8 +455,120 @@ fi test_pass "=== Test 6: Mixed file overwrite behavior PASSED ===" +# +# Test 7: Upgrade without --replace-makefile, inject include on first line +# +prepare_and_enter_repo "$TEST_DIR_UPGRADE" "=== Test 7: Upgrade (no replace, inject include) ===" + +# Simulate legacy structure +mkdir -p common +ln -s common/pattern.sh pattern.sh + +# Create a simple Makefile without include +cat > Makefile <<'EOF' +all: + @echo hello +EOF + +# Run upgrade +"$PATTERNIZER_BINARY" upgrade + +# Verify common/ removed and pattern.sh replaced (not symlink) +if [ -d common ]; then + test_fail "common directory was not removed by upgrade" +fi +if [ -L pattern.sh ]; then + test_fail "pattern.sh symlink was not removed by upgrade" +fi + +# Verify pattern.sh and Makefile-common contents +compare_files "$EXPECTED_PATTERN_SH" "pattern.sh" "pattern.sh copied during upgrade" +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common copied during upgrade" + +# Verify Makefile first line and content +EXPECTED_UPGRADE_MF=$(mktemp) +printf "include Makefile-common\nall:\n\t@echo hello\n" > "$EXPECTED_UPGRADE_MF" +compare_files "$EXPECTED_UPGRADE_MF" "Makefile" "Makefile injected include at first line" +rm -f "$EXPECTED_UPGRADE_MF" + +test_pass "=== Test 7: Upgrade (no replace, inject include) PASSED ===" + +# +# Test 8: Upgrade without --replace-makefile, include already exists elsewhere +# +prepare_and_enter_repo "$TEST_DIR_UPGRADE_INCLUDE" "=== Test 8: Upgrade (no replace, include present) ===" + +# Legacy bits +mkdir -p common +ln -s common/pattern.sh pattern.sh + +# Makefile already contains include, not on first line +cat > Makefile <<'EOF' +foo: + @echo foo +include Makefile-common +bar: + @echo bar +EOF +cp Makefile /tmp/expected_makefile_include_present + +# Run upgrade +"$PATTERNIZER_BINARY" upgrade + +# Verify removals and copies +if [ -d common ]; then + test_fail "common directory was not removed by upgrade (include-present case)" +fi +if [ -L pattern.sh ]; then + test_fail "pattern.sh symlink was not removed by upgrade (include-present case)" +fi +compare_files "$EXPECTED_PATTERN_SH" "pattern.sh" "pattern.sh copied during upgrade (include-present)" +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common copied during upgrade (include-present)" + +# Verify Makefile unchanged +compare_files "/tmp/expected_makefile_include_present" "Makefile" "Makefile unchanged when include already present" +rm -f /tmp/expected_makefile_include_present + +test_pass "=== Test 8: Upgrade (no replace, include present) PASSED ===" + +# +# Test 9: Upgrade with --replace-makefile replaces Makefile exactly +# +prepare_and_enter_repo "$TEST_DIR_UPGRADE_REPLACE" "=== Test 9: Upgrade (--replace-makefile) ===" + +# Create a custom Makefile to be overwritten +echo "custom: ; @echo custom" > Makefile + +# Run upgrade with flag +"$PATTERNIZER_BINARY" upgrade --replace-makefile + +# Verify Makefile replaced and other files copied +compare_files "$EXPECTED_MAKEFILE" "Makefile" "Makefile replaced during upgrade --replace-makefile" +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common copied during upgrade --replace-makefile" +compare_files "$EXPECTED_PATTERN_SH" "pattern.sh" "pattern.sh copied during upgrade --replace-makefile" + +test_pass "=== Test 9: Upgrade (--replace-makefile) PASSED ===" + +# +# Test 10: Upgrade without existing Makefile creates default Makefile +# +prepare_and_enter_repo "$TEST_DIR_UPGRADE_NOMAKEFILE" "=== Test 10: Upgrade (no Makefile present) ===" + +# Ensure no Makefile exists +rm -f Makefile + +# Run upgrade +"$PATTERNIZER_BINARY" upgrade + +# Verify Makefile created and matches default +compare_files "$EXPECTED_MAKEFILE" "Makefile" "Makefile created during upgrade when missing" +compare_files "$EXPECTED_MAKEFILE_COMMON" "Makefile-common" "Makefile-common copied during upgrade when missing" +compare_files "$EXPECTED_PATTERN_SH" "pattern.sh" "pattern.sh copied during upgrade when missing" + +test_pass "=== Test 10: Upgrade (no Makefile present) PASSED ===" + test_pass "All integration tests passed!" # Clean up cd "$REPO_ROOT" -rm -rf "$TEST_DIR" "$TEST_DIR_SECRETS" "$TEST_DIR_CUSTOM" "$TEST_DIR_SEQUENTIAL" "$TEST_DIR_OVERWRITE" "$TEST_DIR_MIXED" +rm -rf "$TEST_DIR" "$TEST_DIR_SECRETS" "$TEST_DIR_CUSTOM" "$TEST_DIR_SEQUENTIAL" "$TEST_DIR_OVERWRITE" "$TEST_DIR_MIXED" "$TEST_DIR_UPGRADE" "$TEST_DIR_UPGRADE_INCLUDE" "$TEST_DIR_UPGRADE_REPLACE" "$TEST_DIR_UPGRADE_NOMAKEFILE" "$SHARED_CLONE_PARENT" From 710c5d237778c191137edac6f937f3409fe7ec3a Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Fri, 29 Aug 2025 15:30:56 -0400 Subject: [PATCH 4/5] add unit tests for fileutils package --- src/internal/fileutils/fileutils_test.go | 223 +++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 src/internal/fileutils/fileutils_test.go diff --git a/src/internal/fileutils/fileutils_test.go b/src/internal/fileutils/fileutils_test.go new file mode 100644 index 0000000..fe8937a --- /dev/null +++ b/src/internal/fileutils/fileutils_test.go @@ -0,0 +1,223 @@ +package fileutils + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func writeFileWithMode(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { // initial perms don't matter + t.Fatalf("write file failed: %v", err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatalf("chmod failed: %v", err) + } +} + +func TestCopyFile_CopiesContentsAndMode(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.txt") + dst := filepath.Join(dir, "dst.txt") + + content := "hello world" + srcMode := os.FileMode(0o640) + writeFileWithMode(t, src, content, srcMode) + + if err := CopyFile(src, dst); err != nil { + t.Fatalf("CopyFile failed: %v", err) + } + + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read dst failed: %v", err) + } + if string(got) != content { + t.Fatalf("unexpected content: %q", string(got)) + } + + info, err := os.Stat(dst) + if err != nil { + t.Fatalf("stat dst failed: %v", err) + } + // Compare permissions only (mask out non-permission bits) + if info.Mode().Perm() != srcMode.Perm() { + t.Fatalf("mode mismatch: got %v want %v", info.Mode().Perm(), srcMode.Perm()) + } +} + +func TestHandleSecretsSetup_CopiesWhenMissing_DoesNotOverwriteWhenPresent(t *testing.T) { + resources := t.TempDir() + repoRoot := t.TempDir() + + templatePath := filepath.Join(resources, "values-secret.yaml.template") + originalContent := "foo: bar\n" + if err := os.WriteFile(templatePath, []byte(originalContent), 0o644); err != nil { + t.Fatalf("write template failed: %v", err) + } + + // First call should copy + if err := HandleSecretsSetup(resources, repoRoot); err != nil { + t.Fatalf("HandleSecretsSetup failed: %v", err) + } + copied := filepath.Join(repoRoot, "values-secret.yaml.template") + data, err := os.ReadFile(copied) + if err != nil { + t.Fatalf("read copied failed: %v", err) + } + if string(data) != originalContent { + t.Fatalf("unexpected copied content: %q", string(data)) + } + + // Change the source and call again; destination should remain unchanged + if err := os.WriteFile(templatePath, []byte("baz: qux\n"), 0o644); err != nil { + t.Fatalf("rewrite template failed: %v", err) + } + if err := HandleSecretsSetup(resources, repoRoot); err != nil { + t.Fatalf("HandleSecretsSetup second call failed: %v", err) + } + data2, err := os.ReadFile(copied) + if err != nil { + t.Fatalf("read copied again failed: %v", err) + } + if string(data2) != originalContent { + t.Fatalf("destination was overwritten unexpectedly: %q", string(data2)) + } +} + +func TestGetResourcesPath_EnvSetAndUnset(t *testing.T) { + old := os.Getenv("PATTERNIZER_RESOURCES_DIR") + t.Cleanup(func() { _ = os.Setenv("PATTERNIZER_RESOURCES_DIR", old) }) + + tmp := t.TempDir() + if err := os.Setenv("PATTERNIZER_RESOURCES_DIR", tmp); err != nil { + t.Fatalf("setenv failed: %v", err) + } + got, err := GetResourcesPath() + if err != nil || got != tmp { + t.Fatalf("GetResourcesPath with env set failed: got %q err %v", got, err) + } + + if err := os.Unsetenv("PATTERNIZER_RESOURCES_DIR"); err != nil { + t.Fatalf("unsetenv failed: %v", err) + } + if _, err := GetResourcesPath(); err == nil { + t.Fatalf("expected error when env is unset") + } +} + +func TestRemovePathIfExists_FileDirSymlink(t *testing.T) { + base := t.TempDir() + + // File removal + f := filepath.Join(base, "file.txt") + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatalf("write file failed: %v", err) + } + if err := RemovePathIfExists(f); err != nil { + t.Fatalf("RemovePathIfExists(file) failed: %v", err) + } + if _, err := os.Stat(f); !os.IsNotExist(err) { + t.Fatalf("file not removed") + } + + // Directory removal + d := filepath.Join(base, "dir") + if err := os.MkdirAll(filepath.Join(d, "nested"), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + if err := RemovePathIfExists(d); err != nil { + t.Fatalf("RemovePathIfExists(dir) failed: %v", err) + } + if _, err := os.Stat(d); !os.IsNotExist(err) { + t.Fatalf("dir not removed") + } + + // Symlink removal (link to a directory) + targetDir := t.TempDir() + link := filepath.Join(base, "link") + // Windows symlinks require admin/dev mode; skip on windows + if runtime.GOOS != "windows" { + if err := os.Symlink(targetDir, link); err != nil { + t.Fatalf("symlink failed: %v", err) + } + if err := RemovePathIfExists(link); err != nil { + t.Fatalf("RemovePathIfExists(symlink) failed: %v", err) + } + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Fatalf("symlink not removed") + } + // Ensure target still exists + if _, err := os.Stat(targetDir); err != nil { + t.Fatalf("target dir should still exist: %v", err) + } + } + + // Non-existent path should be no-op + if err := RemovePathIfExists(filepath.Join(base, "does-not-exist")); err != nil { + t.Fatalf("RemovePathIfExists(nonexistent) failed: %v", err) + } +} + +func TestFileContainsIncludeMakefileCommon_Detection(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "Makefile") + + cases := []struct { + content string + want bool + }{ + {content: "all:\n\t@echo hi\n", want: false}, + {content: "include Makefile-common\nall:\n\t@echo hi\n", want: true}, + {content: " include Makefile-common\nall:\n\t@echo hi\n", want: true}, + {content: "# include Makefile-common\nall:\n\t@echo hi\n", want: false}, + {content: "foo:\n\t@echo foo\n# comment\nbar:\n\t@echo bar\n", want: false}, + {content: strings.Join([]string{"foo:", "\t@echo foo", "include Makefile-common", "bar:", "\t@echo bar", ""}, "\n"), want: true}, + } + + for i, tc := range cases { + if err := os.WriteFile(p, []byte(tc.content), 0o644); err != nil { + t.Fatalf("write case %d failed: %v", i, err) + } + got, err := FileContainsIncludeMakefileCommon(p) + if err != nil { + t.Fatalf("case %d: err: %v", i, err) + } + if got != tc.want { + t.Fatalf("case %d: got %v want %v", i, got, tc.want) + } + } +} + +func TestPrependLineToFile_PrependsAndPreservesMode(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "Makefile") + original := "all:\n\t@echo hi\n" + mode := os.FileMode(0o600) + writeFileWithMode(t, p, original, mode) + + line := "include Makefile-common" + if err := PrependLineToFile(p, line); err != nil { + t.Fatalf("PrependLineToFile failed: %v", err) + } + + data, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read failed: %v", err) + } + expected := line + "\n" + original + if string(data) != expected { + t.Fatalf("unexpected content after prepend: %q", string(data)) + } + + info, err := os.Stat(p) + if err != nil { + t.Fatalf("stat failed: %v", err) + } + if info.Mode().Perm() != mode.Perm() { + t.Fatalf("mode not preserved: got %v want %v", info.Mode().Perm(), mode.Perm()) + } +} From f53bbc36324a12af6a6c018d257f6f595ec2f903 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Fri, 29 Aug 2025 15:36:58 -0400 Subject: [PATCH 5/5] add info about new integration tests to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index a2dc3b3..b584af2 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,10 @@ Patternizer has a comprehensive test suite to ensure stability and correctness. 4. **Sequential Execution:** Tests running `init` and then `init --with-secrets` to ensure a clean upgrade. 5. **Selective File Overwriting:** Confirms that running `init` on a repository with pre-existing custom files correctly **merges YAML configurations**, preserves user-modified files (like `Makefile` and `values-secret.yaml.template`), and only overwrites essential, generated scripts (`pattern.sh`, `Makefile-common`). 6. **Mixed State Handling:** Validates that the tool correctly initializes a partially-configured repository, **creating files that are missing** while leaving existing ones untouched. + 7. **Upgrade (no replace):** Removes legacy `common/` and `pattern.sh` symlink, copies `Makefile-common`/`pattern.sh`, and injects `include Makefile-common` at the top of `Makefile` when missing. + 8. **Upgrade (include present):** Leaves the existing `Makefile` unchanged when it already contains `include Makefile-common` anywhere. + 9. **Upgrade with `--replace-makefile`:** Replaces `Makefile` with the default and refreshes common assets. + 10. **Upgrade (no Makefile present):** Creates the default `Makefile` and refreshes common assets when a `Makefile` does not exist. ### Architecture